diff --git a/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml index 958224125..f6c7c6be6 100644 --- a/.github/workflows/codex-review-gate.yml +++ b/.github/workflows/codex-review-gate.yml @@ -62,9 +62,21 @@ jobs: "p3 badge", ) + class TransientGitHubApiError(RuntimeError): + pass + def gh_json(path): - output = subprocess.check_output(["gh", "api", path], text=True) - return json.loads(output) + result = subprocess.run( + ["gh", "api", path], + text=True, + capture_output=True, + ) + if result.returncode: + error = result.stderr.strip() or result.stdout.strip() + if re.search(r"\(HTTP 5\d\d\)", error): + raise TransientGitHubApiError(error) + raise RuntimeError(f"gh api {path} failed: {error}") + return json.loads(result.stdout) def gh_json_pages(path): page = 1 @@ -157,7 +169,25 @@ jobs: deadline = time.time() + timeout_seconds while True: - state, items = verdict() + try: + state, items = verdict() + except TransientGitHubApiError as error: + remaining = int(deadline - time.time()) + if remaining <= 0: + print( + "Timed out waiting for GitHub API recovery while checking " + f"the Codex verdict: {error}" + ) + sys.exit(1) + + retry_seconds = min(poll_seconds, remaining) + print( + "Transient GitHub API failure while checking the Codex verdict; " + f"retrying in {retry_seconds}s ({remaining}s left): {error}" + ) + time.sleep(retry_seconds) + continue + if state == "pass": print("Found clean Codex review for current HEAD:") for kind, url in items: diff --git a/.github/workflows/qa-world-server-artifact.yml b/.github/workflows/qa-world-server-artifact.yml index 09e54dc06..27d41cea1 100644 --- a/.github/workflows/qa-world-server-artifact.yml +++ b/.github/workflows/qa-world-server-artifact.yml @@ -77,6 +77,7 @@ jobs: env: CARGO_BUILD_JOBS: "1" CARGO_INCREMENTAL: "0" + RUST_MIN_STACK: "268435456" CARGO_PROFILE_RELEASE_INCREMENTAL: "false" CARGO_PROFILE_RELEASE_LTO: "false" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a21db948f..6f2c617a3 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -20,6 +20,7 @@ concurrency: env: CARGO_TERM_COLOR: always + RUST_MIN_STACK: "268435456" jobs: fmt: @@ -35,6 +36,9 @@ jobs: with: components: rustfmt + - name: Install preflight dependencies + run: sudo apt-get update && sudo apt-get install --yes ripgrep + - name: Validate local preflight harness run: ./tools/pr-preflight.sh self-test @@ -47,12 +51,16 @@ jobs: name: Check core crates runs-on: ubuntu-24.04 timeout-minutes: 20 + env: + CARGO_INCREMENTAL: "0" steps: - name: Checkout uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -73,10 +81,18 @@ jobs: cargo +1.88.0 check --locked --manifest-path tools/wow-test-bot/Cargo.toml cargo +1.88.0 build --locked -p bnet-server -p world-server + - name: Clippy loot authority boundary + run: cargo +1.88.0 clippy --locked --no-deps --message-format short -p wow-loot -p wow-entities -p wow-map -p wow-network --lib + + - name: Clippy world target with inherited debt capped + run: cargo +1.88.0 clippy --locked --no-deps --message-format short -p wow-world --lib -- --cap-lints warn + tests: name: Focused library tests runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: "0" steps: - name: Checkout uses: actions/checkout@v4 @@ -97,12 +113,18 @@ jobs: chmod +x "$HOME/.local/bin/protoc" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Run packet/data/world tests + - name: Run focused library and loot-race harness tests run: | cargo +1.88.0 test --locked -p wow-data --lib cargo +1.88.0 test --locked -p wow-packet --lib + cargo +1.88.0 test --locked -p wow-loot --lib + cargo +1.88.0 test --locked -p wow-entities --lib cargo +1.88.0 test --locked -p wow-map --lib + cargo +1.88.0 test --locked -p wow-network --lib cargo +1.88.0 test --locked -p wow-world --lib + cargo +1.88.0 test --locked --manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests + cargo +1.88.0 test --locked -p capture-diff + cargo +1.88.0 run --locked -p capture-diff -- verify-required loot-single-item-claim latest-stable: name: Latest stable compatibility diff --git a/Cargo.lock b/Cargo.lock index ffda822f8..d6eb4b723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ name = "capture-diff" version = "0.1.0" dependencies = [ "anyhow", + "libc", "num-traits", "serde", "serde_json", @@ -3231,6 +3232,7 @@ dependencies = [ "bitflags", "wow-constants", "wow-core", + "wow-loot", ] [[package]] @@ -3267,6 +3269,7 @@ name = "wow-loot" version = "0.1.0" dependencies = [ "rand", + "tokio", "wow-constants", "wow-core", ] @@ -3283,6 +3286,7 @@ dependencies = [ "wow-core", "wow-ecs", "wow-entities", + "wow-loot", "wow-math", ] @@ -3337,6 +3341,7 @@ dependencies = [ "tracing", "wow-constants", "wow-core", + "wow-loot", "wow-movement", ] diff --git a/crates/capture-diff/Cargo.toml b/crates/capture-diff/Cargo.toml index 15da8bc6d..6b3194772 100644 --- a/crates/capture-diff/Cargo.toml +++ b/crates/capture-diff/Cargo.toml @@ -22,6 +22,7 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } sha2 = { workspace = true } +libc = { workspace = true } [lints] workspace = true diff --git a/crates/capture-diff/README.md b/crates/capture-diff/README.md index 26bea5fa4..6e696466b 100644 --- a/crates/capture-diff/README.md +++ b/crates/capture-diff/README.md @@ -21,8 +21,44 @@ cargo run -p capture-diff -- diff --cpp some.pkt --rust some/dump/dir # Regression gate (exit non-zero if the diff drifts from the accepted baseline): cargo run -p capture-diff -- diff login --strict + +# Milestone gate (operator-attested ready state, empty baseline and pinned shape): +cargo run -p capture-diff -- verify-required loot-single-item-claim ``` +`verify-required` is intentionally stricter than `diff --strict`: it refuses an +`awaiting-real-captures` contract, missing artifacts, accepted divergences, an +incorrect exact packet count/boundary/socket/order shape, an invalid correlated +payload, a selection that differs from the exact reviewed boundaries/ignores, +a missing or malformed RAW-to-derived lineage, any retained manifest/output +hash mismatch, or any C++↔Rust difference. This +lets a PR record exactly which capture it still owes without manufacturing a +golden. Issue #106's existing `loot-single-item-claim` action windows contain +six packets each and compare CLEAN with an empty accepted-divergence baseline, +but they predate the mandatory RAW manifests and cannot establish which +processes produced them. The contract therefore remains +`awaiting-real-captures` until a new, fully accredited C++/Rust pair is imported. + +The `ready` status is an operator attestation made only after inspecting the +capture run and its provenance. Each wrapper publishes a completed RAW +manifest only after cleanup succeeds. `import` validates that manifest against +the RAW bytes, preserves an exact copy, and hashes the filtered C++ packet, +normalized Rust dump, and baseline into `capture-lineage.json`. The manifest +also pins the harness/source commits and worktree-state digests, +expected/source/live executable paths and hashes, PM2 entrypoint path/hash, +entry/listener identity and restart count, plus a canonical +redacted capture-config hash. This is an integrity and review chain, not a +cryptographic signature: repository write access can still replace evidence +and all of its hashes together, which normal review must catch. + +For a required flow, `required_order` is the complete post-filter packet +sequence, not a subsequence. The issue-#106 contract therefore permits exactly +the six declared packets; an extra or duplicate packet fails even when it uses +the wrong direction or socket. Its versioned semantic contract independently +requires one loot request and correlates the LootObj/list id, Doctor removal, +created item, recipient, quantity, item GUID/entry, deterministic keyring slot +and inventory update before accepting either side of the pair. + To isolate one action from the surrounding login/session traffic, give the import command directional inclusive boundaries. The stand-state bot requires `SMSG_CONNECT_TO` plus distinct realm/instance sockets, waits for the realm ACK, @@ -50,6 +86,19 @@ cargo run -p capture-diff -- import stand-state \ cargo run -p capture-diff -- diff stand-state --strict ``` +The default RAW manifests are the wrapper outputs next to those captures: +`target/captures//cpp.capture-manifest.json` and +`target/captures//rust/rust.capture-manifest.json`. Use +`--cpp-manifest` or `--rust-manifest` only if those same completed manifests +were deliberately moved. Import rejects a missing/unknown field, wrong +flow/side, incomplete run, RAW size/count/hash mismatch, malformed UTC time, +process/executable inconsistency, a dirty RustyCore harness/source, or an +unpinned executable for a required flow. A legacy C++ source checkout may be +dirty: its HEAD is then informational, `source_worktree_dirty` is explicit, +and a deterministic changed/untracked path+mode+content-hash state digest is +retained. The pinned listener-binary path/SHA remains the primary C++ runtime +identity; the manifest does not claim that the recorded source HEAD built it. + The fence uses a fixed ping serial and zero latency; its Pong is verified live by the bot but intentionally falls after the inclusive import boundary. The import fails if either capture lacks either boundary. With `--strict`, it @@ -87,10 +136,55 @@ The one-packet fixture proves the reward wire shape; the bot workflow separately proves offline accrual, DB XP/rest consumption, relog persistence, fixture restoration, and the natural respawn timer. +The issue-#106 loot gate has an equally narrow comparator for +`SMSG_LOOT_REMOVED`: paired real captures assigned its one reviewed Doctor +Maleficus identity (Creature, realm 1, map 530, entry 21779, subtype/server 0) +different nonzero map-runtime counters. The bot preflight proves that this +entry has exactly one SQL spawn on that map. Only the lower 40 bits of that +Owner counter are omitted. Owner type/realm/map/entry/subtype/server id, the +complete map-530 LootObj GUID including its counter, LootListID 0, canonical +packed encoding, packet direction, and instance-socket routing remain exact. +Zero counters, malformed or non-canonical bodies, every other Creature +identity, and non-Creature owners are never normalized. + +The required-flow validator separately decodes the preceding item +`CreateObject`. It requires exactly one map-530 `UpdateType::CreateObject` +block with `TypeID::Item`, all item movement flags clear, the owner-visible +item-30712 value shape, owner and contained-in GUID equal to character 15, +StackCount 1, and the reviewed deterministic zero tail. Its Item GUID must be +the same canonical realm-1/server-0 GUID reported by `ItemPushResult` and the +one `InvSlots` child; the push must report quantity 1 and the observed keyring +slot 106. + +The same gate has one separate, fail-closed `SMSG_UPDATE_OBJECT` comparator for +the one-player `UpdateType::Values` inventory delta observed after the claim. +Two independent real C++ captures produced the same 51-byte body: before the +otherwise identical `ActivePlayerData::InvSlots` update, `UnitData` contained +only its five-byte mask fragment `08 00 10 00 00` (parent bit 116 for the Power +arrays), with no child bit and no payload. Rust produced the corresponding +46-byte body without that empty parent. This follows the throttled +`Player::Regenerate` path in `Player.cpp` (`DoWithSuppressingObjectUpdates`, +then `UnitData::Power::ClearChanged`): the parent can remain accumulated until +the next object-update flush even though its child was cleared. + +Normalization is allowed only for S2C opcode `0x27CB` when both bodies contain +exactly one VALUES block for the same canonical Player GUID and map, no +destroy/out-of-range section, and exactly the same single canonical non-empty +Item GUID at the same `InvSlots` slot after removing that C++-only fragment. +Every child or payload under Unit parent 116, every other Unit/ActivePlayer +mask, GUID, slot, value, malformed or non-canonical encoding, opcode, +direction, and socket route remains strict; CreateObject and every other +UpdateObject shape receive no normalization. This exception records proven +accumulated-change-mask/capture-cadence noise only. It is not evidence of power +regeneration—or any other gameplay—parity. + `--ignore-opcode` is repeatable, direction-required, and applied symmetrically after boundary selection. It is fail-closed to the reviewed periodic allowlist -(`s2c:0x2DD2 SMSG_TIME_SYNC_REQUEST` and `s2c:0x2DD4 -SMSG_ON_MONSTER_MOVE`) and cannot overlap either action boundary. The +(`s2c:0x2DD2 SMSG_TIME_SYNC_REQUEST`, its causally paired +`c2s:0x3A3D CMSG_TIME_SYNC_RESPONSE`, and `s2c:0x2DD4 +SMSG_ON_MONSTER_MOVE`) and cannot overlap either action boundary. Filtering +only the request would leave its client-time-bearing response in the strict +window, so a flow that answers time sync must exclude that reviewed pair. The stand-state flow excludes only `SMSG_ON_MONSTER_MOVE` produced by the independent global creature clock; the stand ACK, VALUES/aura side effects, connection ids, request, and ping fence remain strict. `update-baseline` @@ -146,16 +240,97 @@ artifact into `target/captures//`. See each script's header for the env vars (server paths, pm2 process names) it honors. The Rust script recreates the world process from a mode-0600 snapshot whose only capture-time difference is `RUSTYCORE_PACKET_DUMP_DIR`, verifies the exact PM2 profile and listener ports, -and rejects a capture if the process PID/restart counter changes. Set +and rejects a capture if the process PID/start-time/restart counter or redacted +PM2 launch-profile hash changes. Set `RUST_CAPTURE_EXEC` to an absolute canonical path when a feature-branch binary must be captured and set `RUST_CAPTURE_EXEC_SHA256` to that file's 64-hex SHA-256. The script verifies the source immediately before launch and checks that both `/proc//exe` and its bytes match the pinned path and digest before and after the interactive capture; a provenance mismatch fails closed. The snapshot's original executable is still restored on normal exit or a caught -termination signal. The dump parser also rejects -duplicate global sequence numbers, which would otherwise make a capture -spanning an autorestart ambiguous. +termination signal, but readiness requires both distinct world/instance +listeners to belong to one accredited PID that is the PM2 entry itself or its +verified descendant. The stopped-world accreditation used during C++ swaps +waits at most 30 seconds and stable startup waits at most 180 seconds by +default; operators may override the bounded +`CAPTURE_WORLD_STOP_TIMEOUT_SECONDS` and +`CAPTURE_WORLD_READY_TIMEOUT_SECONDS` values from 1 and 3, respectively, +through 3600 seconds. The automated two-session QA gives the wrapper both +budgets plus a separate 120-second pre-readiness margin for offline-character, +tree-termination, SQL, PM2, and accreditation work. +Before any fixture restoration, cleanup removes the PM2 +entry and verifies the recorded entry, listener, and descendant process-tree +identities plus both listeners are absent; a stubborn tree is terminated and +rechecked. The dump parser accepts only a flat set of regular, non-symlink +`.meta`/`.bin` pairs with exact metadata keys, canonical filenames/opcodes and +contiguous global sequence numbers. Extras, orphans, subdirectories, and +symlinks fail closed. + +Each successful wrapper also publishes one completed RAW manifest (schema v3): C++ writes +`cpp.capture-manifest.json` next to `cpp.pkt`, while Rust writes +`rust.capture-manifest.json` inside the dump directory. Publication happens +without overwriting an existing generation and only after service/fixture +cleanup succeeds and the normal runtime is healthy again. The manifest records +repository HEADs and worktree state, expected/source/live executable identity, +PM2 entrypoint plus listener PID/start-time identity and restart count, the +redacted PM2 profile hash, and a capture-config digest. Direct Rust profiles +may have the same PM2-entry and listener PID; +legacy C++ uses a non-`exec` shell entrypoint, so its unique listener PID must +be a live descendant. The capture-config digest is calculated from an ordered +allowlist of capture-relevant settings; credential-like values are +replaced with a fixed presence marker before hashing, so no password, token, or +complete secret-bearing configuration is hashed or stored. C++ additionally +derives the effective config selected by PM2 argv or its pinned wrapper and +requires it to be the exact canonical `CPP_CONF`; caller declaration alone is +not evidence. + +`loot-single-item-claim` cannot opt out of its versioned fixture guard: both +C++ and Rust wrappers require guard/acknowledgement, a pinned +`WOW_BOT_EXEC`/SHA-256, and a fresh absolute `WOW_BOT_REPORT`. The report must +prove the exact successful TESTBOT2/character-15 Doctor-21779/spawn-1117/item- +30712 single-item flow before schema-v3 evidence can publish. Rust guards use +both `RUST_CAPTURE_LOOT_FIXTURE_GUARD=1` and +`RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1`, plus an unused absolute +`WOW_BOT_FIXTURE_JOURNAL` path in a private directory. `loot-single-item-claim` retains its +temporary Doctor-health fixture. `loot-two-session-atomic-race` instead checks +the pinned entry-2846/loot-2278 prerequisites, temporarily installs shared QA +chest spawn `9106001` with item 38 and 10 copper, and removes/restores only +unchanged fixture-owned values—including its exact generated respawn row—before +restarting the original PM2 profile. Any external drift fails visibly and +leaves the normal world stopped for manual inspection. + +Both guarded Rust flows also require a pinned `WOW_BOT_EXEC`/SHA-256 and a +fresh absolute `WOW_BOT_REPORT` path before any service mutation. For +`loot-two-session-atomic-race`, the outer preflight runs the two clients and +writes that report while the wrapper is waiting at its prompt. Publication is +fail-closed: only the exact TESTBOT2/TESTBOT3 success contract (same live target +counter/list ID, one item winner, `10`/`0` money notifications, exact database +deltas, and relog proof) can produce a completed race dump. Cleanup and normal +PM2 restoration still run when the report is absent or invalid. A direct/manual +invocation must arrange the same pinned bot report; pressing ENTER without that +evidence intentionally restores the runtime without publishing. +A successful race generation retains `race.bot-report.json` inside the atomic +Rust artifact and records its final path/SHA-256 plus non-null fixture/bot +contracts in the manifest; the preflight may then remove its private source +copy without orphaning the provenance record. + +The bot writes a mode-0600 recovery journal before its first character/fixture +mutation, removes it only after verified restoration, and atomically creates +`${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The capture wrapper removes that +marker only after the original PM2 profile is healthy again. If the wrapper +fails before it exposes the flow prompt, both journal and marker must still be +absent and the original world can be restored without inventing a cleanup +receipt. Once the prompt is exposed, a pending journal, missing marker, caught +TERM, state/metadata drift, or cleanup error leaves the normal world stopped +and retains recovery evidence; cleanup failure is the reported exit status even +when the capture already failed. `SIGKILL` cannot be trapped, so an operator +must recover a retained journal before restarting the normal world. + +The shared-chest preflight claims no spawn that has pool, event, +linked-respawn, `gameobject_addon`, `gameobject_overrides`, or `spawn_group` +metadata. It pins every `gameobject_template_addon` field used by the fixture +and validates the complete spawn—including `state`—before any cleanup write. +Neither the script nor the bot may normalize that state merely to hide drift. ## Flows and the golden fixtures @@ -167,6 +342,9 @@ flows/login/cpp.pkt # C++ PKT 3.1 golden flows/login/rust/ # reference Rust dump (.bin/.meta) flows/login/expected-divergences.json # accepted-divergence baseline flows/login/flow.json # description + directions +flows//requirement.json # attestation + exact wire/payload contract +flows//capture-lineage.json # RAW/import/output hashes + exact selection +flows//capture-provenance/ # exact reviewed C++ and Rust RAW manifests ``` `cargo test -p capture-diff` runs the gate: it parses the committed pair, diffs @@ -196,13 +374,28 @@ cargo run -p capture-diff -- import login \ --until-opcode 0x3A46 --direction s2c ``` -`import` trims both captures at the boundary opcode, writes `cpp.pkt` + `rust/`, -and rewrites `expected-divergences.json`. +`import` trims both captures at the boundary opcode and prepares `cpp.pkt`, +`rust/`, the baseline, retained RAW manifests, and lineage as one complete +generation. It publishes that generation with one atomic directory rename (or +an atomic directory exchange when replacing a flow), so an interrupted import +leaves the previously installed generation intact. Existing metadata is copied +only from the explicit `README.md`, `flow.json`, and `requirement.json` +allowlist; unknown entries or symlinks fail closed. ## Adding a flow -1. Record a `cpp.pkt` (C++ `PacketLogFile`) and a `rust/` dump (scripts above). -2. `cargo run -p capture-diff -- import --cpp --rust [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--direction s2c]` - — installs the fixtures under `flows//` and pins the baseline. +1. Record a `cpp.pkt` (C++ `PacketLogFile`) and a `rust/` dump with their + completed RAW manifests (scripts above). +2. `cargo run -p capture-diff -- import --cpp --rust [--cpp-manifest ] [--rust-manifest ] [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--direction s2c]` + — validates the RAW artifacts and process provenance, installs the whole + fixture generation atomically, and pins the baseline plus lineage. 3. Optionally edit `flows//flow.json` (`description` / `directions`). -4. `cargo test -p capture-diff` — the new flow is now gated. +4. Inspect `capture-provenance/*.capture-manifest.json` and + `capture-lineage.json`, then run `cargo test -p capture-diff`. + +For a flow required by an issue before its real pair exists, add only +`requirement.json`, `flow.json`, and a capture runbook. Keep its status +`awaiting-real-captures`; do not add placeholder `cpp.pkt`, `rust/`, or an +accepted divergence. After a strict real import succeeds, inspect provenance, +set the requirement to `ready` as an explicit operator attestation, remove its +stale `blocked_reason`, and run `verify-required `. diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta index e91bb513e..284152bfe 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000001-counter1-0x3765-AuthSession-len70.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=1 counter=1 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta index 59d45fb6d..5fa4b0b5a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000003-counter3-0x3767-EnterEncryptedModeAck-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=3 counter=3 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta index fa52ad6db..6ff448c34 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000012-counter12-0x35E9-EnumCharacters-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=12 counter=12 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta index ad2a8f642..c2c3ad240 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000013-counter13-0x36E7-GetUndeleteCharacterCooldownStatus-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=13 counter=13 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta index 57d48beab..63917f2bd 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000014-counter14-0x36C5-BattlePayGetPurchaseList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=14 counter=14 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta index 668e2d105..3fc4f57e4 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000015-counter15-0x36C4-BattlePayGetProductList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=15 counter=15 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta index e9b2244ac..f61e6856a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000016-counter16-0x36FB-UpdateVasPurchaseStates-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=16 counter=16 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta index f71b3e115..a8f0ead90 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000019-counter19-0x374C-SocialContractRequest-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=19 counter=19 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta index 880de9e1f..63e4113e3 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000020-counter20-0x35E6-HotfixRequest-len18.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=20 counter=20 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta index d5daf3e93..a2345fce0 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000021-counter21-0x36FD-BattlenetRequest-len41.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=21 counter=21 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta index 8967c7902..1aeae1473 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000025-counter25-0x35E5-DbQueryBulk-len56.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=25 counter=25 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta index f0e396123..69f5d88b6 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000038-counter38-0x35E5-DbQueryBulk-len136.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=38 counter=38 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta index cbd79513d..e92a63c40 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000071-counter71-0x35F9-LoadingScreenNotify-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=71 counter=71 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta index 65cf9beba..046615aa7 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000072-counter72-0x36BF-GetAccountCharacterList-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=72 counter=72 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta index 9f057ef64..37e3ed57c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000073-counter73-0x35E4-RequestRatedPvpInfo-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=73 counter=73 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta index b2e21a6ce..29c7a6f21 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000074-counter74-0x36C5-BattlePayGetPurchaseList-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=74 counter=74 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta index 9559035e1..e256d253d 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000075-counter75-0x35EB-PlayerLogin-len11.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=75 counter=75 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta index 61d00c781..57494fa84 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000078-counter78-0x3766-AuthContinuedSession-len58.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=78 counter=78 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta index 004d56595..dba9ca987 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000080-counter80-0x3767-EnterEncryptedModeAck-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=80 counter=80 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta index 443e6ee84..7dee74c0a 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000132-counter132-0x351E-OverrideScreenFlash-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=132 counter=132 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta index 171451e86..43477fe10 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000133-counter133-0x3187-ViolenceLevel-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=133 counter=133 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta index 67bc1d96b..8e4b9871d 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000134-counter134-0x3196-RequestPvpRewards-len2.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=134 counter=134 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta index d9d726c97..f795b8811 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000135-counter135-0x376C-QueuedMessagesEnd-len6.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=135 counter=135 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta index 922e9574d..1896d0e2c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000137-counter137-0x3A3D-TimeSyncResponse-len10.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=137 counter=137 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta index 5f5d921cf..0e97ad9c3 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000138-counter138-0x3A3C-SetActiveMover-len7.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=138 counter=138 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta index e31484e9e..18b2c7140 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000139-counter139-0x327A-RequestPlayedTime-len3.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=139 counter=139 diff --git a/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta b/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta index c73ae8a0c..e885a5e0c 100644 --- a/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-c2s-00000147-counter147-0x3A46-MoveInitActiveMoverComplete-len6.meta @@ -1,4 +1,5 @@ direction=c2s +connection_id=0 addr=127.0.0.1:0 seq=147 counter=147 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta index 5184f335d..118e1ad0b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000000-counter0-0x3048-AuthChallenge-len51.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=0 counter=0 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta index c61fbdc2a..5dfa98b21 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000002-counter2-0x3049-EnterEncryptedMode-len67.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=2 counter=2 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta index 75c9c55f3..59d06e8ce 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000004-counter4-0x256D-AuthResponse-len434.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=4 counter=4 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta index 18db8b647..9afe77a6a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000005-counter5-0x2677-SetTimeZoneInformation-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=5 counter=5 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta index 2c72e89ca..4929ebc49 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000006-counter6-0x25C0-FeatureSystemStatusGlueScreen-len91.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=6 counter=6 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta index cc89e5d53..2d552185b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000007-counter7-0x291C-CacheVersion-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=7 counter=7 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta index 8c905e414..72d22fc7c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000008-counter8-0x290F-AvailableHotfixes-len690.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=8 counter=8 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta index 58086508a..9de56de2d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000009-counter9-0x270A-AccountDataTimes-len132.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=9 counter=9 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta index 3434dae3b..34248e0e5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000010-counter10-0x27BE-TutorialFlags-len34.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=10 counter=10 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta index af8445b97..3c435338a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000011-counter11-0x2809-BattleNetConnectionStatus-len3.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=11 counter=11 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta index d679ee1d9..99dda809f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000017-counter17-0x2583-EnumCharactersResult-len1262.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=17 counter=17 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta index decc96190..7fc34f09a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000018-counter18-0x27CE-UndeleteCooldownStatusResponse-len11.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=18 counter=18 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta index 5d3879c7a..bdfe23f93 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000022-counter22-0x2892-SocialContractRequestResponse-len3.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=22 counter=22 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta index d79ef5267..d193daea8 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000023-counter23-0x2911-HotfixConnect-len262.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=23 counter=23 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta index 54d8c4965..d56a84176 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000024-counter24-0x2807-BattlenetResponse-len30.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=24 counter=24 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta index 0ae4176cb..5c29bcb30 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000026-counter26-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=26 counter=26 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta index 20c103c2a..3d955297d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000027-counter27-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=27 counter=27 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta index 5b43afb69..a60e2d1f5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000028-counter28-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=28 counter=28 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta index 00fdd9f1a..ed03fe9ef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000029-counter29-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=29 counter=29 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta index ce73afab1..0aeafda4b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000030-counter30-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=30 counter=30 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta index 244fb97d1..023a98961 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000031-counter31-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=31 counter=31 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta index 6f2b3148d..e7de1c153 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000032-counter32-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=32 counter=32 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta index aee5c45bf..36b05ffaa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000033-counter33-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=33 counter=33 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta index 6095edc31..3c931df26 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000034-counter34-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=34 counter=34 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta index 39b84189b..749906854 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000035-counter35-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=35 counter=35 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta index 807bc90c1..b1ae295a1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000036-counter36-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=36 counter=36 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta index f3ff7d7f1..3700f62fa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000037-counter37-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=37 counter=37 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta index 747bdac77..2fbcdd761 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000039-counter39-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=39 counter=39 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta index b71d2fa23..339015907 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000040-counter40-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=40 counter=40 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta index d0e82adae..2e617d2b9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000041-counter41-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=41 counter=41 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta index ed7430649..9d6f67a7b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000042-counter42-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=42 counter=42 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta index 143d4c06d..4250c1f6f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000043-counter43-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=43 counter=43 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta index 046fb4a46..68859c8dd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000044-counter44-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=44 counter=44 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta index 410982029..b448b8b62 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000045-counter45-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=45 counter=45 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta index e7d8b180c..dd9ea6e69 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000046-counter46-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=46 counter=46 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta index 72b7a1f35..ce01f0c58 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000047-counter47-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=47 counter=47 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta index 97f0a417f..52e151e02 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000048-counter48-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=48 counter=48 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta index 5f0fca5d2..f48848a62 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000049-counter49-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=49 counter=49 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta index ffcd28d64..d08e36c1a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000050-counter50-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=50 counter=50 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta index 75798a9ac..618b46420 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000051-counter51-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=51 counter=51 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta index 9f3c81b3e..a2b72df11 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000052-counter52-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=52 counter=52 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta index 53c56eeac..773ef9a7c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000053-counter53-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=53 counter=53 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta index d4ca9010e..1c561bbaa 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000054-counter54-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=54 counter=54 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta index 738090dee..abe7c7048 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000055-counter55-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=55 counter=55 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta index d1e616404..d007a0ce2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000056-counter56-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=56 counter=56 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta index 1e5eaa1e4..bd6dd344f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000057-counter57-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=57 counter=57 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta index 14a27cf50..5a467fe87 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000058-counter58-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=58 counter=58 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta index 297ad805b..06c60a1e9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000059-counter59-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=59 counter=59 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta index f966b6286..4ce8e22c8 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000060-counter60-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=60 counter=60 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta index e12eacbb4..048e53147 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000061-counter61-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=61 counter=61 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta index 64f6b2493..800238561 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000062-counter62-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=62 counter=62 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta index afa7a93bc..406ffd8fd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000063-counter63-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=63 counter=63 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta index d2134813f..761bf8964 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000064-counter64-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=64 counter=64 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta index 81ca18671..89e26bba0 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000065-counter65-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=65 counter=65 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta index d2a1d3f13..9e22c4eb0 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000066-counter66-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=66 counter=66 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta index a634e03f0..366d901ac 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000067-counter67-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=67 counter=67 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta index 771a821da..bc8d74db9 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000068-counter68-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=68 counter=68 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta index ec8683812..eb7c505ef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000069-counter69-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=69 counter=69 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta index 205251886..95b166250 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000070-counter70-0x290E-DbReply-len19.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=70 counter=70 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta index 20b5ed57a..a6609ecdb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000076-counter76-0x304D-ConnectTo-len278.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=76 counter=76 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta index fa43e3feb..99bb10042 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000077-counter77-0x3048-AuthChallenge-len51.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=77 counter=77 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta index 91a9ac192..baf17142b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000079-counter79-0x3049-EnterEncryptedMode-len67.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=79 counter=79 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta index f181efc7f..4c592cc46 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000081-counter81-0x304B-ResumeComms-len2.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=81 counter=81 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta index 76432f4cc..84a10065a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000082-counter82-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=82 counter=82 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta index 67a28cbea..43b21bb28 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000083-counter83-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=83 counter=83 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta index 4fd851bed..4ada60a9f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000084-counter84-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=84 counter=84 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta index 90969cda8..19b7acbd1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000085-counter85-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=85 counter=85 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta index 6cb7def53..575be440b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000086-counter86-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=86 counter=86 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta index 6727ae6ba..9b6bd0a05 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000087-counter87-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=87 counter=87 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta index e45e34f0e..d8e9788dc 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000088-counter88-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=88 counter=88 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta index 10e4d7e2e..2bf736b8b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000089-counter89-0x2735-SetProficiency-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=89 counter=89 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta index c223869ad..781536c5f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000090-counter90-0x2C1F-AuraUpdate-len43.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=90 counter=90 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta index 712fbfd2c..a3d88d1a4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000091-counter91-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=91 counter=91 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta index 11105a192..2297fd6a3 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000092-counter92-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=92 counter=92 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta index 16175e70e..86e32b9c1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000093-counter93-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=93 counter=93 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta index fbc8f3eb0..fa9f0d0e2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000094-counter94-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=94 counter=94 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta index fe465ace4..f35204fee 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000095-counter95-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=95 counter=95 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta index 995209376..d8d075a86 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000096-counter96-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=96 counter=96 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta index 329265cc7..2287cf2fb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000097-counter97-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=97 counter=97 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta index 806103e16..ab80adcef 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000098-counter98-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=98 counter=98 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta index ca90afeb4..6316d9fed 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000099-counter99-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=99 counter=99 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta index f9e19ff69..d5afdfbbe 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000100-counter100-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=100 counter=100 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta index 686d5dbdf..288f3fd41 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000101-counter101-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=101 counter=101 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta index 0bb112093..13edbe021 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000102-counter102-0x2C1F-AuraUpdate-len44.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=102 counter=102 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta index f3d4ef4b4..eb47059fd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000103-counter103-0x26A4-SetDungeonDifficulty-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=103 counter=103 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta index 5a4ecb48b..c8813cf68 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000104-counter104-0x2597-LoginVerifyWorld-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=104 counter=104 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta index e4df6ae14..585f49b09 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000105-counter105-0x270A-AccountDataTimes-len135.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=105 counter=105 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta index f848757da..d0314c512 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000106-counter106-0x2DD2-TimeSyncRequest-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=106 counter=106 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta index ac25d9d2b..6e5da126f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000107-counter107-0x25BF-FeatureSystemStatus-len192.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=107 counter=107 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta index 89ac7bac0..a45d9404a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000108-counter108-0x2BC5-ChatServerMessage-len41.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=108 counter=108 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta index 15f8fc39a..9dab9521f 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000109-counter109-0x2677-SetTimeZoneInformation-len26.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=109 counter=109 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta index cdea9ae45..7882bb1eb 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000110-counter110-0x278C-ContactList-len40.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=110 counter=110 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta index 09dceab95..92a6b7c53 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000111-counter111-0x301B-QueryPlayerNamesResponse-len43.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=111 counter=111 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta index 220ddf15e..d5687d142 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000112-counter112-0x2C51-ActiveGlyphs-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=112 counter=112 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta index d7ca3fe26..66951edea 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000113-counter113-0x257D-BindPointUpdate-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=113 counter=113 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta index 301e86a94..11a8c1a4a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000114-counter114-0x25D7-UpdateTalentData-len49.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=114 counter=114 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta index cb7dd161f..4eb01d89a 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000115-counter115-0x2C27-SendKnownSpells-len1191.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=115 counter=115 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta index 2eca31e11..cafa9ab55 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000116-counter116-0x2C2B-SendUnlearnSpells-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=116 counter=116 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta index 13b6edcc0..654316220 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000117-counter117-0x2C28-SendSpellHistory-len31.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=117 counter=117 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta index c82b50182..f14b069b5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000118-counter118-0x2C2A-SendSpellCharges-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=118 counter=118 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta index 200206b32..2dc6bcca4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000119-counter119-0x25E0-UpdateActionButtons-len1443.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=119 counter=119 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta index b902c4f43..ce122f56b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000120-counter120-0x2724-InitializeFactions-len6127.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=120 counter=120 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta index 89717f60b..d69f1067b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000121-counter121-0x2573-SetupCurrency-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=121 counter=121 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta index 34eee1845..1c10ee63c 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000122-counter122-0x270E-LoadEquipmentSet-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=122 counter=122 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta index a9dfd78b1..e92129a12 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000123-counter123-0x2570-AllAchievementData-len10.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=123 counter=123 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta index db80e167f..8ab8862a4 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000124-counter124-0x270D-LoginSetTimeSpeed-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=124 counter=124 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta index e04c8fab6..5ae38540b 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000125-counter125-0x25AD-WorldServerInfo-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=125 counter=125 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta index 7aafa99fd..647ba9248 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000126-counter126-0x2C33-SetFlatSpellModifier-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=126 counter=126 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta index 6761661ea..aa94bdb34 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000127-counter127-0x2C34-SetPctSpellModifier-len6.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=127 counter=127 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta index 06e9e9eb4..99688ebbd 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000128-counter128-0x25AE-AccountMountUpdate-len22.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=128 counter=128 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta index fa7d0fee5..4b518d6f2 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000129-counter129-0x25B0-AccountToyUpdate-len15.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=129 counter=129 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta index d8ead5e94..5d3e1f971 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000130-counter130-0x2580-InitialSetup-len4.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=130 counter=130 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta index 34fdcf69a..01be36af1 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000131-counter131-0x2DD5-MoveSetActiveMover-len7.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=131 counter=131 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta index fa7ae6854..519e70138 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000136-counter136-0x27CB-UpdateObject-len49227.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=136 counter=136 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta index 7736a774e..a2f090338 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000140-counter140-0x27CB-UpdateObject-len680.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=140 counter=140 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta index b3ab94057..c5fa52692 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000141-counter141-0x2578-PhaseShiftChange-len29.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=141 counter=141 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta index bb283d452..46774932d 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000142-counter142-0x2746-InitWorldStates-len386.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=142 counter=142 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta index a108cf910..4239550b5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000143-counter143-0x2578-PhaseShiftChange-len29.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=143 counter=143 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta index 43ee4738e..c10736ea5 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000144-counter144-0x25BC-LoadCufProfiles-len30.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=144 counter=144 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta index 67ec06180..1496d2886 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000145-counter145-0x2C1F-AuraUpdate-len463.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=145 counter=145 diff --git a/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta b/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta index 848f2ba3f..16ee51130 100644 --- a/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta +++ b/crates/capture-diff/flows/login/rust/rust-s2c-00000146-counter146-0x27CB-UpdateObject-len506.meta @@ -1,4 +1,5 @@ direction=s2c +connection_id=0 addr=127.0.0.1:0 seq=146 counter=146 diff --git a/crates/capture-diff/flows/loot-single-item-claim/README.md b/crates/capture-diff/flows/loot-single-item-claim/README.md new file mode 100644 index 000000000..74d89425b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/README.md @@ -0,0 +1,367 @@ +# Required capture: single-session loot item claim + +This directory is the required real-capture gate for issue #106. Its existing +isolated one-client C++/Rust action windows contain six packets each and report +`6 matched, 0 value-diffs, 0 routing-diffs, 0 missing, 0 extra`, with an empty +accepted-divergence baseline. They predate the mandatory completed RAW +manifests, however, so their executable/process/config lineage cannot be +verified. They are useful semantic regression fixtures but are not accredited +completion evidence. `requirement.json` must stay `awaiting-real-captures` and +`verify-required` must fail until this runbook produces and imports a fresh +manifest-backed pair. + +## C++-anchored wire contract + +Use exactly one logged-in character and no party. Kill and open one disposable, +stationary creature whose loot table contains exactly one unconditional +quantity-1 item. Drain the open response before the action window, then claim +that item and finish with a fixed-serial, zero-latency `CMSG_PING` on the +instance socket. The imported window must begin and end at those two CMSGs. + +After the three reviewed symmetric filters below, the required window is +exactly these six packets in this order: + +1. instance `CMSG_LOOT_ITEM` (`c2s`, connection 1, `0x3211`); +2. instance `SMSG_UPDATE_OBJECT` Item `CreateObject` (`s2c`, connection 1, + `0x27CB`); +3. instance `SMSG_LOOT_REMOVED` (`s2c`, connection 1, `0x2615`); +4. realm `SMSG_ITEM_PUSH_RESULT` (`s2c`, connection 0, `0x2623`); +5. instance `SMSG_UPDATE_OBJECT` one-slot `InvSlots` VALUES update (`s2c`, + connection 1, `0x27CB`); +6. instance `CMSG_PING` fence (`c2s`, connection 1, `0x3768`). + +This is grounded in the legacy source, not inferred from Rust: + +- `LootHandler.cpp::HandleAutostoreLootItemOpcode` delegates to + `Player::StoreLootItem`; +- `Player.cpp::StoreLootItem` calls `NotifyItemRemoved` before `SendNewItem`; +- `Opcodes.cpp` routes `SMSG_LOOT_REMOVED` to `CONNECTION_TYPE_INSTANCE` and + `SMSG_ITEM_PUSH_RESULT` to `CONNECTION_TYPE_REALM`. + +No extra, replacement, reordered, wrong-socket, wrong-direction, or duplicate +packet is accepted inside that post-filter window. The semantic contract also +decodes each side independently: the request is one non-soft claim for the +canonical type-15/map-530 LootObj at list `0`; the created Item is the exact +owner-visible quantity-1 entry `30712`; removal is owned by Doctor Maleficus +entry `21779` on map `530` and correlates the request LootObj/list; the push is +for character `15`, quantity `1`, the same canonical Item GUID and deterministic +slot/flags/bonus/modification shape; the one-child `InvSlots` VALUES update +correlates that player, item, and `SlotInBag`; and the fence body is fixed +`TOOL` serial plus zero latency. The bot sends the fence as soon as it has +received the one removal and one item-push response; it does not add a fixed +settle delay. Periodic time-sync request/response pairs and independent creature +movement are the only reviewed symmetric import filters. + +## Reproducibility and safety + +- The bot requires zero globally-online characters before setup, after logout + verification, and after cleanup. It snapshots both disposable characters' + position/money plus level, XP, health, powers, rest, exploration and title + state; it also snapshots all personal achievement/criteria/quest rows and + reputation. Cleanup restores these rows transactionally and reloads them for + exact verification. The characters must not belong to a guild, because C++ + fans kill/item criteria into guild-wide state. +- The versioned defaults are character GUIDs `15` and `16`; setup verifies + their configured account owners and every cleanup delete/update remains + bounded to those two GUIDs (plus the newly awarded item GUID discovered + under those owners). +- Item `30712` is The Doctor's Key. Character `15` must have exact top-level + keyring destination `bag=0, slot=106` empty before either run. Wire evidence, + persistence, and post-cleanup verification all pin that same slot; a grant + displaced into a backpack slot is a failure even if the item entry matches. +- Snapshot the inventory rows, creature respawn row, and current maximum + item-instance GUID before either run as an operator-visible cross-check. +- Use the same clean DB snapshot and restart each world server before its run. + This is necessary because both servers allocate the awarded item at the + process-global `MAX(item_instance.guid) + 1` frontier. +- Restore the snapshot before switching C++ → Rust. Do not run another item + creator between the two captures. +- Keep money outside this window. Doctor Maleficus (entry `21779`, database + spawn id `1117`) has exactly one guaranteed item (`30712`) and + `GoldMin=GoldMax=0`; a money claim is a separate capture contract. +- Do not use the two-client loot-race dump as this golden. Global C++/Rust logs + merge both clients, so concurrent claims cannot be attributed or ordered as a + reproducible single-session flow. +- The paired issue-#106 captures proved one unavoidable identity difference: + the same Doctor owner was Creature runtime counter `268` in C++ and `1` in + Rust. `capture-diff` therefore normalizes only that Owner GUID's lower 40-bit + map-runtime counter in `SMSG_LOOT_REMOVED`. Both counters must be nonzero; + the complete stable Owner identity, complete LootObj GUID (including its own + counter), LootListID, canonical packed shape, body boundaries, and instance + routing remain strict. Do not add any broader GUID normalization. +- Two independent C++ runs also emitted the same second + `SMSG_UPDATE_OBJECT` (`0x27CB`) body byte-for-byte. It was a 51-byte, + single-player VALUES update whose `UnitData` contribution was exactly + `08 00 10 00 00`: parent bit 116 for the Power arrays, with no child and no + payload. The rest was one `ActivePlayerData::InvSlots` update and matched the + 46-byte Rust body exactly after removing those five C++ bytes. The legacy + anchor is `Player.cpp::Regenerate` at the throttled + `DoWithSuppressingObjectUpdates` + `UnitData::Power::ClearChanged` path; + `UpdateFields.h` places the Power arrays under parent 116 and + `UpdateFields.cpp::UnitData::WriteUpdate` emits that parent mask before any + child payload. This is reproducible accumulated-change-mask/capture-cadence + noise, not proof that Rust power regeneration or gameplay timing is correct. +- The corresponding semantic exception is intentionally narrower than the + opcode. It requires S2C `0x27CB`, one VALUES block, the same map and canonical + Player GUID, no destroy/out-of-range data, and the exact same one-slot + canonical non-empty Item GUID update after deleting only the C++ parent-116 + mask. A Unit child or payload, any other Unit or ActivePlayer bit, a different + GUID/slot/value, malformed or non-canonical bytes, reversed C++/Rust + orientation, different direction/opcode/socket route, CreateObject, or any + other UpdateObject shape still fails. +- For the complete C++→Rust pair, use the host's external firewall/control plane + to allow world ports `8085` and `8086` only from loopback (`127.0.0.1` and + `::1`). Apply the block before Terminal A starts C++, verify it from a remote + host, keep it in place through Rust capture and strict import, then restore the + normal policy. The database online-count guards detect persisted sessions but + cannot close the race of an external client connecting after preflight. + +Record each side with the existing service-safe wrappers while performing the +same one-client action when they pause. There are two separate acknowledgements +and neither substitutes for the other: + +1. answer `y` to each capture script's service swap/restart prompt (or pass its + explicit `--yes` only in controlled automation); +2. set `WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1` for the bot's destructive + fixture guard. + +Pin the exact feature binary and bot bytes before touching services. The Rust +capture wrapper checks the world executable both at its source path and through +`/proc//exe`; the bot wrapper likewise refuses a supplied executable whose +SHA-256 does not match. + +The RustyCore worktree must be committed and completely clean (including +non-ignored untracked files) for both recordings. The legacy C++ checkout is +known to contain local port/test changes, so its HEAD is informational rather +than a build attestation: the wrapper records `source_worktree_dirty` and a +stable path/mode/content-hash state digest. The pinned live `worldserver` +path/SHA-256 is the primary C++ executable evidence. RAW manifest schema v3 +records this explicitly. Its PM2 profile runs +`worldserver-wrapper.sh` without `exec`; the manifest therefore records that +PM2 entrypoint separately and proves that the one PID owning both listeners is +its descendant. + +```bash +# Preparation (no service mutation). +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +PROTOC=/home/cdmonio/.local/protoc/bin/protoc \ + cargo +1.88.0 build --locked -p world-server +cargo +1.88.0 build --locked \ + --manifest-path tools/wow-test-bot/Cargo.toml + +RUST_EXEC="$(realpath target/debug/world-server)" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +git rev-parse HEAD +sha256sum /home/server/trinity-legacy-install/bin/worldserver \ + "$RUST_EXEC" "$BOT_EXEC" +``` + +The wrappers refuse to overwrite an existing RAW generation. Before starting +a replacement run, archive the old gitignored directory under another name (or +remove it only after separately preserving anything needed for investigation): + +```bash +test -d target/captures/loot-single-item-claim && \ + mv target/captures/loot-single-item-claim \ + "target/captures/loot-single-item-claim.pre-lineage-$(date -u +%Y%m%dT%H%M%SZ)" +``` + +Then use two terminals. For C++: + +```bash +# Terminal A: answer y, then leave this waiting at "Perform the flow". +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +CPP_EXEC="$(realpath /home/server/trinity-legacy-install/bin/worldserver)" +CPP_SHA="$(sha256sum "$CPP_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-cpp-${USER:-$(id -u)}" +install -d -m 700 "$FIXTURE_DIR" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +test ! -e "$FIXTURE_JOURNAL" \ + && test ! -L "$FIXTURE_JOURNAL" \ + && test ! -e "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -L "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -e "$BOT_REPORT" \ + && test ! -L "$BOT_REPORT" \ + && test ! -e "$BOT_LOG" \ + && test ! -L "$BOT_LOG" +CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 \ +CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ +CPP_CAPTURE_DB_CONF="$DB_CONF" \ +CPP_CAPTURE_EXEC="$CPP_EXEC" \ +CPP_CAPTURE_EXEC_SHA256="$CPP_SHA" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +crates/capture-diff/scripts/capture-cpp.sh loot-single-item-claim + +# Terminal B: only after Terminal A is waiting. +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-cpp-${USER:-$(id -u)}" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +WOW_BOT_LOOT_ITEM_CAPTURE=1 \ +WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_LOG="$BOT_LOG" \ +tools/wow-test-bot/run_rustycore_login_smoke.sh +``` + +The C++ wrapper uses the same shared guard as the Rust wrapper: after Rust is +stopped, it requires every character offline, atomically changes only Doctor +Maleficus entry `21779` difficulty-0 `HealthModifier` from `1` to `0.0001`, and +arms exact CAS restoration before C++ starts. Wait for Terminal B to exit `0`; +the pinned report and bot log remain under the private `FIXTURE_DIR` for the +operator and RAW-manifest validation. +Only then press +ENTER in Terminal A; that copies `cpp.pkt`, stops C++, restores its config, and +starts the normal Rust PM2 process only after the Doctor row is restored and the +bot journal is gone with a valid mode-0600 cleanup marker. The marker is consumed +only after PM2 starts successfully. + +If the bot fails or receives TERM/INT, do not collect the run. Interrupt +Terminal A normally; its trap stops C++, restores the outer Doctor guard, and +leaves the normal world stopped while the journal is pending. Then use the exact +retained path for explicit bot recovery: + +```bash +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +"$BOT_EXEC" --recover-loot-fixture +``` + +Only after recovery removes the journal and writes a valid marker may the +operator restart the normal PM2 world and remove that marker. A `SIGKILL` or +host death of Terminal A crosses the outer process boundary, so no shell trap runs. +In that case keep both worlds stopped, retain the `.capture-diff.bak`, journal, +and marker evidence, recover the bot journal explicitly, and inspect/restore the +Doctor CAS plus C++ config before any restart. Use a clean disposable DB snapshot +only if that outer state can no longer be proven; a normal bot failure is not a +reason to bypass journal recovery. + +For Rust, recalculate the hashes from the unchanged files and pin the feature +world executable in the capture wrapper: + +```bash +# Terminal A: answer y, then leave this waiting at "Perform the flow". +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +RUST_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-rust-${USER:-$(id -u)}" +install -d -m 700 "$FIXTURE_DIR" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +test ! -e "$FIXTURE_JOURNAL" \ + && test ! -L "$FIXTURE_JOURNAL" \ + && test ! -e "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -L "${FIXTURE_JOURNAL}.cleanup-complete" \ + && test ! -e "$BOT_REPORT" \ + && test ! -L "$BOT_REPORT" \ + && test ! -e "$BOT_LOG" \ + && test ! -L "$BOT_LOG" +RUST_EXEC="$(realpath target/debug/world-server)" +RUST_SHA="$(sha256sum "$RUST_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +RUST_CONFIG="$(realpath "$RUST_CONFIG")" +RUST_CAPTURE_EXEC="$RUST_EXEC" \ +RUST_CAPTURE_EXEC_SHA256="$RUST_SHA" \ +RUST_CAPTURE_EFFECTIVE_CONFIG="$RUST_CONFIG" \ +RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ +RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ +RUST_CAPTURE_DB_CONF="$DB_CONF" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +crates/capture-diff/scripts/capture-rust.sh loot-single-item-claim + +# Terminal B: run the exact same pinned bot mode used for C++. +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf +FIXTURE_DIR="${TMPDIR:-/tmp}/rustycore-loot-item-rust-${USER:-$(id -u)}" +FIXTURE_JOURNAL="$FIXTURE_DIR/fixture.journal" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +BOT_REPORT="$FIXTURE_DIR/bot-report.json" +BOT_LOG="$FIXTURE_DIR/bot.log" +WOW_BOT_DB_CONF="$DB_CONF" \ +WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ +WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ +WOW_BOT_LOOT_ITEM_CAPTURE=1 \ +WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +WOW_BOT_REPORT="$BOT_REPORT" \ +WOW_BOT_LOG="$BOT_LOG" \ +tools/wow-test-bot/run_rustycore_login_smoke.sh +``` + +Again, press ENTER in Terminal A only after Terminal B exits `0`. The Rust +wrapper then verifies that the live process still matches the pinned hash, +collects the dump, and restores the exact original PM2 profile. Do not import a +capture from a failed bot run or a process-provenance warning. A normal failed +run uses the same explicit `--recover-loot-fixture` procedure above after the +capture trap has stopped the QA world; do not delete a pending journal or invent +a cleanup marker. + +Finally, without either capture script running: + +```bash +REPO_ROOT=/absolute/path/to/your/rustycore-worktree +cd "$REPO_ROOT" +cargo +1.88.0 run -p capture-diff -- import loot-single-item-claim \ + --cpp target/captures/loot-single-item-claim/cpp.pkt \ + --rust target/captures/loot-single-item-claim/rust \ + --cpp-manifest target/captures/loot-single-item-claim/cpp.capture-manifest.json \ + --rust-manifest target/captures/loot-single-item-claim/rust/rust.capture-manifest.json \ + --from-opcode c2s:0x3211 \ + --until-opcode c2s:0x3768 \ + --ignore-opcode s2c:0x2DD2 \ + --ignore-opcode c2s:0x3A3D \ + --ignore-opcode s2c:0x2DD4 \ + --direction both \ + --strict +``` + +After that import reports `CLEAN`, maintainers must: + +1. inspect both artifacts with `capture-diff show`, both retained files under + `capture-provenance/`, and `capture-lineage.json`; confirm the one-client + provenance and all repository/executable/PM2/config identities; +2. set `requirement.json` to `"status": "ready"` with no stale + `blocked_reason`; +3. run `cargo +1.88.0 run -p capture-diff -- verify-required + loot-single-item-claim` and the local `capture` preflight. + +The pre-lineage issue-#106 import on 2026-07-18 did not produce the RAW +manifests now required by this gate, so it does not satisfy those steps. A new +capture must repeat the complete provenance, fixture restoration, strict +import, inspection, and verification sequence; `ready` is never permission to +replace the golden with uninspected input. + +`verify-required` rejects missing artifacts or manifests, lineage/output hash +drift, any import selection other than the exact declared boundaries and three +ordered ignores, a non-empty accepted-divergence baseline, routing/order drift, +extra packets inside the selected action window, or any byte/opcode diff. diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json b/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json new file mode 100644 index 000000000..223c19633 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-lineage.json @@ -0,0 +1,176 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "completed": true, + "sources": { + "cpp": { + "manifest_path": "capture-provenance/cpp.capture-manifest.json", + "manifest_sha256": "8bd4e2e5afa0969a50852f21d01cdd5f9e48508ddabc01c3ea77031aa377ff8c", + "raw_artifact_sha256": "a25f2c2bbf60de6cda7e32f305d732733017e711eb474dd5dbf6e007690143a8", + "raw_artifact_size": 111420, + "raw_packet_count": null, + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": true, + "source_worktree_state_sha256": "3eeb18895c18215d381c7dc13cab516bb06e0441be2901e92c05d1845107afb1", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "expected_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "source_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "source_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "live_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "live_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "executable_pin_enforced": true, + "pm2_entry_pid": 881529, + "pm2_entry_starttime": 70890932, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 881531, + "listener_runtime_starttime": 70890933, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "8d55b563745586a1d0014a1ee19704ec018e2097fd566116fec99090434c2242", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-cpp.rlOxSn/bot-report.json", + "report_sha256": "e546d8019eaec3ce2850a8f58f837ee0335b0fb8e1bc2f28283c0d9953a34784", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "retained_bot_report_path": "capture-provenance/cpp.bot-report.json" + }, + "rust": { + "manifest_path": "capture-provenance/rust.capture-manifest.json", + "manifest_sha256": "95f5dce92ad6ecf67268a7e8b7db3526dea6f9a8d7c2a3dda2a292a80cc0ca31", + "raw_artifact_sha256": "5fe815b89f622bddd862232d7e00d288c569655872d02b4760220fd8a2eef818", + "raw_artifact_size": null, + "raw_packet_count": 191, + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "expected_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "source_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "source_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "live_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "live_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "executable_pin_enforced": true, + "pm2_entry_pid": 885094, + "pm2_entry_starttime": 70907497, + "pm2_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "pm2_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "pm2_profile_redacted_sha256": "546d60f16e924203996a1a738cd9013d152eda7fb67e7bbb107d64792716e195", + "listener_runtime_pid": 885094, + "listener_runtime_starttime": 70907497, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/etc/worldserver.conf", + "effective_config_redacted_sha256": "5e3f4e1ff0b296932b6d620f5a71ce03d13137f494e4812db8b9623dde0f5606", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-rust.CA4pL6/bot-report.json", + "report_sha256": "7c93aac0aebde59248716a6e593ee9a80c055411e256a9c63b4a249d953c7f40", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "retained_bot_report_path": "capture-provenance/rust.bot-report.json" + } + }, + "selection": { + "directions": [ + "s2c", + "c2s" + ], + "from_opcode": { + "direction": "c2s", + "opcode": 12817 + }, + "until_opcode": { + "direction": "c2s", + "opcode": 14184 + }, + "ignored_opcodes": [ + { + "direction": "s2c", + "opcode": 11730 + }, + { + "direction": "c2s", + "opcode": 14909 + }, + { + "direction": "s2c", + "opcode": 11732 + } + ], + "strict": true + }, + "outputs": { + "cpp_pkt": { + "path": "cpp.pkt", + "size": 795, + "sha256": "a84abf8f1d067fc68a9fdbe1608243479b0740a703b07444097ab5af0aebf487" + }, + "rust": { + "path": "rust", + "file_count": 12, + "packet_count": 6, + "tree_sha256": "afb4de111efb401703c4828a73018f9e878d1c52e6e2544868241435c705f795" + }, + "expected_divergences": { + "path": "expected-divergences.json", + "size": 2, + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + } +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json new file mode 100644 index 000000000..2070f0142 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.bot-report.json @@ -0,0 +1,387 @@ +{ + "dungeon_id": 220, + "timeout_secs": 60, + "require_proposal": true, + "require_group": false, + "auto_teleport": false, + "login_only": false, + "stand_state_smoke": false, + "bank_smoke": false, + "homebind_smoke": false, + "inventory_swap_smoke": false, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": true, + "quest_smoke": false, + "results": [ + { + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "dungeon_id": 220, + "role": 4, + "join_result": null, + "join_detail": null, + "got_proposal": false, + "accepted_proposal": false, + "got_ready_check": false, + "group_formed": false, + "teleport_denied_reason": null, + "entered_world": false, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "login_only": false, + "stand_state_smoke": false, + "stand_state_smoke_passed": null, + "stand_states_requested": [], + "stand_states_confirmed": [], + "stand_state_failure": null, + "bank_smoke": false, + "bank_smoke_passed": null, + "bank_banker_entry": null, + "bank_banker_spawn_guid": null, + "bank_banker_guid_counter": null, + "bank_item_guid": null, + "bank_item_entry": null, + "bank_inventory_slot": null, + "bank_bank_slot": null, + "bank_open_confirmed": false, + "bank_deposit_persisted": false, + "bank_relogin_after_deposit": false, + "bank_withdraw_persisted": false, + "bank_failure": null, + "homebind_smoke": false, + "homebind_smoke_passed": null, + "homebind_innkeeper_entry": null, + "homebind_innkeeper_spawn_guid": null, + "homebind_innkeeper_guid_counter": null, + "homebind_spell_go_seen": false, + "homebind_bind_point_update_seen": false, + "homebind_player_bound_seen": false, + "homebind_gossip_complete_seen": false, + "homebind_db_persisted": false, + "homebind_relogin_verified": false, + "homebind_failure": null, + "inventory_swap_smoke": false, + "inventory_swap_smoke_passed": null, + "inventory_swap_item_guid_a": null, + "inventory_swap_item_guid_b": null, + "inventory_swap_item_entry_a": null, + "inventory_swap_item_entry_b": null, + "inventory_swap_slot_a": null, + "inventory_swap_slot_b": null, + "inventory_swap_forward_persisted": false, + "inventory_swap_relogin_after_forward": false, + "inventory_swap_reverse_persisted": false, + "inventory_swap_failure": null, + "rested_xp_smoke": false, + "rested_xp_smoke_passed": null, + "rested_xp_offline_wilderness_bonus": null, + "rested_xp_offline_resting_bonus": null, + "rested_xp_target_entry": null, + "rested_xp_target_spawn_guid": null, + "rested_xp_target_guid_counter": null, + "rested_xp_packet_amount": null, + "rested_xp_packet_original": null, + "rested_xp_db_xp_before": null, + "rested_xp_db_xp_after": null, + "rested_xp_db_rest_before": null, + "rested_xp_db_rest_after": null, + "rested_xp_relog_verified": false, + "rested_xp_failure": null, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_runtime_counter": 268, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 0, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null, + "quest_smoke": false, + "quest_smoke_passed": null, + "quest_target_entry": null, + "quest_target_spawn_guid": null, + "quest_target_guid_counter": null, + "quest_target_map_id": null, + "quest_gossip_hello_sent": false, + "quest_questgiver_hello_sent": false, + "quest_gossip_id_seen": null, + "quest_gossip_select_sent": false, + "quest_gossip_message_seen": false, + "quest_quest_list_seen": false, + "quest_details_seen": false, + "quest_request_items_seen": false, + "trainer_list_seen": false, + "trainer_id_seen": null, + "trainer_spell_count_seen": null, + "quest_accept_sent": false, + "quest_accept_confirm_seen": false, + "quest_db_verified": false, + "quest_db_status": null, + "quest_objective_persist": false, + "quest_objective_seeded": [], + "quest_objective_db_before": [], + "quest_objective_db_after": [], + "quest_objective_db_verified": false, + "quest_objective_update_seen": false, + "quest_objective_update_has_expected": false, + "quest_ids_seen": [], + "quest_titles_seen": [], + "quest_failure": null, + "seen_opcodes": [ + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2597", + "0x2DD2", + "0x257D", + "0x25D7", + "0x2C27", + "0x2C2B", + "0x2C28", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x2746", + "0x25BC", + "0x2C1F", + "0x2578", + "0x25E0", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x27CB", + "0x27CB", + "0x26D9", + "0x27CB", + "0x26B5", + "0x27BE", + "0x293D", + "0x26A4", + "0x2952", + "0x270A", + "0x26E1", + "0x25BF", + "0x27CB", + "0x2BC5", + "0x293D", + "0x2677", + "0x2952", + "0x25ED", + "0x27CB", + "0x278C", + "0x2C51", + "0x26D1", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2952", + "0x26D1", + "0x26E1", + "0x27CB", + "0x27CB", + "0x26D2", + "0x2952", + "0x2952", + "0x26D1", + "0x26E1", + "0x27CB", + "0x27CB", + "0x26D2", + "0x2952", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x26E1", + "0x293E", + "0x293E", + "0x27CB", + "0x2614", + "0x27CB", + "0x2623", + "0x27CB", + "0x2615", + "0x27CB", + "0x304E", + "0x271C", + "0x261A", + "0x2683", + "0x2DF9", + "0x27CB", + "0x2DD2", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x2DD4", + "0x27CB", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x2DD4", + "0x27CB", + "0x26D2", + "0x27CB", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x2DD2", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x2DD4", + "0x26D2", + "0x27CB", + "0x2DD4", + "0x2DD4", + "0x294B", + "0x294B", + "0x2684", + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2597", + "0x270A", + "0x2DD2", + "0x27BE", + "0x257D", + "0x26A4", + "0x25D7", + "0x270A", + "0x2C27", + "0x25BF", + "0x2C2B", + "0x2BC5", + "0x2C28", + "0x2677", + "0x2C2A", + "0x25ED", + "0x25E0", + "0x278C", + "0x2724", + "0x2C51", + "0x2573", + "0x271C", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x2746", + "0x25BC", + "0x2C1F", + "0x2578", + "0x25E0", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x27CB", + "0x2683", + "0x2DF9", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x2DD2", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x27CB", + "0x26D2", + "0x26D2", + "0x2DD2", + "0x26D2", + "0x26D2", + "0x294B", + "0x294B", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json new file mode 100644 index 000000000..c31693707 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/cpp.capture-manifest.json @@ -0,0 +1,65 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "side": "cpp", + "completed": true, + "created_at": "2026-07-19T14:51:28Z", + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "5100ce3d8fc6150feedf902e62512760ec03ea92", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": true, + "source_worktree_state_sha256": "3eeb18895c18215d381c7dc13cab516bb06e0441be2901e92c05d1845107afb1", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "expected_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "source_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "source_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "live_exec_path": "/home/server/trinity-legacy-install/bin/worldserver", + "live_exec_sha256": "189ac0ef6a5fef0fe3033a9844e2251523a442b746734cbcfd667277bdf99603", + "executable_pin_enforced": true, + "pm2_entry_pid": 881529, + "pm2_entry_starttime": 70890932, + "pm2_exec_path": "/home/server/trinity-legacy-install/bin/worldserver-wrapper.sh", + "pm2_exec_sha256": "41c0e84f282bf5711a2d4b433a054426d28de9875cfce1b3f7fcfe6c576df213", + "pm2_profile_redacted_sha256": "5de156ef078858c9d979e6bcc274d5d06e4d44588c273357fbf3f34dc235aca0", + "listener_runtime_pid": 881531, + "listener_runtime_starttime": 70890933, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/bin/worldserver.conf", + "effective_config_redacted_sha256": "8d55b563745586a1d0014a1ee19704ec018e2097fd566116fec99090434c2242", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-cpp.rlOxSn/bot-report.json", + "report_sha256": "e546d8019eaec3ce2850a8f58f837ee0335b0fb8e1bc2f28283c0d9953a34784", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "cpp.pkt", + "size": 111420, + "sha256": "a25f2c2bbf60de6cda7e32f305d732733017e711eb474dd5dbf6e007690143a8" + } +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json new file mode 100644 index 000000000..dab1d87b8 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.bot-report.json @@ -0,0 +1,290 @@ +{ + "dungeon_id": 220, + "timeout_secs": 60, + "require_proposal": true, + "require_group": false, + "auto_teleport": false, + "login_only": false, + "stand_state_smoke": false, + "bank_smoke": false, + "homebind_smoke": false, + "inventory_swap_smoke": false, + "rested_xp_smoke": false, + "loot_race_smoke": false, + "loot_item_capture": true, + "quest_smoke": false, + "results": [ + { + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "dungeon_id": 220, + "role": 4, + "join_result": null, + "join_detail": null, + "got_proposal": false, + "accepted_proposal": false, + "got_ready_check": false, + "group_formed": false, + "teleport_denied_reason": null, + "entered_world": false, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "login_only": false, + "stand_state_smoke": false, + "stand_state_smoke_passed": null, + "stand_states_requested": [], + "stand_states_confirmed": [], + "stand_state_failure": null, + "bank_smoke": false, + "bank_smoke_passed": null, + "bank_banker_entry": null, + "bank_banker_spawn_guid": null, + "bank_banker_guid_counter": null, + "bank_item_guid": null, + "bank_item_entry": null, + "bank_inventory_slot": null, + "bank_bank_slot": null, + "bank_open_confirmed": false, + "bank_deposit_persisted": false, + "bank_relogin_after_deposit": false, + "bank_withdraw_persisted": false, + "bank_failure": null, + "homebind_smoke": false, + "homebind_smoke_passed": null, + "homebind_innkeeper_entry": null, + "homebind_innkeeper_spawn_guid": null, + "homebind_innkeeper_guid_counter": null, + "homebind_spell_go_seen": false, + "homebind_bind_point_update_seen": false, + "homebind_player_bound_seen": false, + "homebind_gossip_complete_seen": false, + "homebind_db_persisted": false, + "homebind_relogin_verified": false, + "homebind_failure": null, + "inventory_swap_smoke": false, + "inventory_swap_smoke_passed": null, + "inventory_swap_item_guid_a": null, + "inventory_swap_item_guid_b": null, + "inventory_swap_item_entry_a": null, + "inventory_swap_item_entry_b": null, + "inventory_swap_slot_a": null, + "inventory_swap_slot_b": null, + "inventory_swap_forward_persisted": false, + "inventory_swap_relogin_after_forward": false, + "inventory_swap_reverse_persisted": false, + "inventory_swap_failure": null, + "rested_xp_smoke": false, + "rested_xp_smoke_passed": null, + "rested_xp_offline_wilderness_bonus": null, + "rested_xp_offline_resting_bonus": null, + "rested_xp_target_entry": null, + "rested_xp_target_spawn_guid": null, + "rested_xp_target_guid_counter": null, + "rested_xp_packet_amount": null, + "rested_xp_packet_original": null, + "rested_xp_db_xp_before": null, + "rested_xp_db_xp_after": null, + "rested_xp_db_rest_before": null, + "rested_xp_db_rest_after": null, + "rested_xp_relog_verified": false, + "rested_xp_failure": null, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_runtime_counter": 1, + "loot_race_party_confirmed": false, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 0, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": null, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null, + "quest_smoke": false, + "quest_smoke_passed": null, + "quest_target_entry": null, + "quest_target_spawn_guid": null, + "quest_target_guid_counter": null, + "quest_target_map_id": null, + "quest_gossip_hello_sent": false, + "quest_questgiver_hello_sent": false, + "quest_gossip_id_seen": null, + "quest_gossip_select_sent": false, + "quest_gossip_message_seen": false, + "quest_quest_list_seen": false, + "quest_details_seen": false, + "quest_request_items_seen": false, + "trainer_list_seen": false, + "trainer_id_seen": null, + "trainer_spell_count_seen": null, + "quest_accept_sent": false, + "quest_accept_confirm_seen": false, + "quest_db_verified": false, + "quest_db_status": null, + "quest_objective_persist": false, + "quest_objective_seeded": [], + "quest_objective_db_before": [], + "quest_objective_db_after": [], + "quest_objective_db_verified": false, + "quest_objective_update_seen": false, + "quest_objective_update_has_expected": false, + "quest_ids_seen": [], + "quest_titles_seen": [], + "quest_failure": null, + "seen_opcodes": [ + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2597", + "0x2DD2", + "0x257D", + "0x25D7", + "0x2C27", + "0x2C2B", + "0x2C28", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x27CB", + "0x2746", + "0x270A", + "0x2578", + "0x25BF", + "0x25BC", + "0x2BC5", + "0x2C1F", + "0x2677", + "0x27CB", + "0x278C", + "0x27CB", + "0x2C51", + "0x27CB", + "0x293D", + "0x2952", + "0x27CB", + "0x293E", + "0x27CB", + "0x2614", + "0x2623", + "0x27CB", + "0x2615", + "0x27CB", + "0x304E", + "0x261A", + "0x2683", + "0x261B", + "0x2684", + "0x3049", + "0x304D", + "0x304B", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2735", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2C1F", + "0x2597", + "0x26A4", + "0x2DD2", + "0x270A", + "0x257D", + "0x25BF", + "0x25D7", + "0x2BC5", + "0x2C27", + "0x2677", + "0x2C2B", + "0x278C", + "0x2C28", + "0x2C51", + "0x2C2A", + "0x25E0", + "0x2724", + "0x2573", + "0x270E", + "0x2570", + "0x270D", + "0x25AD", + "0x2C33", + "0x2C34", + "0x25AE", + "0x25B0", + "0x2580", + "0x2DD5", + "0x27CB", + "0x27CB", + "0x2578", + "0x27CB", + "0x2746", + "0x2578", + "0x25BC", + "0x2C1F", + "0x27CB", + "0x2683", + "0x2684" + ] + } + ] +} \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json new file mode 100644 index 000000000..26bdeb5e4 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/capture-provenance/rust.capture-manifest.json @@ -0,0 +1,65 @@ +{ + "version": 3, + "flow": "loot-single-item-claim", + "side": "rust", + "completed": true, + "created_at": "2026-07-19T14:53:28Z", + "harness_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "source_repo_head": "72c5967dad248f869b2d740b55e885ce51db1a56", + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "source_worktree_dirty": false, + "source_worktree_state_sha256": "7f1df5ac739db5b1f58a4a49a9950d9e88c7fbcf5d6efdac91368ed5da32d052", + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "expected_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "source_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "source_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "live_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "live_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "executable_pin_enforced": true, + "pm2_entry_pid": 885094, + "pm2_entry_starttime": 70907497, + "pm2_exec_path": "/home/server/rustycore-issue-106/target/issue106-release/release/world-server", + "pm2_exec_sha256": "6234630546446b115a74014b3ced1d99d1c9d9946a136e5171f1a3dd01a2cba5", + "pm2_profile_redacted_sha256": "546d60f16e924203996a1a738cd9013d152eda7fb67e7bbb107d64792716e195", + "listener_runtime_pid": 885094, + "listener_runtime_starttime": 70907497, + "listener_relationship_verified": true, + "restart_count": 0, + "effective_config_path": "/home/server/trinity-legacy-install/etc/worldserver.conf", + "effective_config_redacted_sha256": "5e3f4e1ff0b296932b6d620f5a71ce03d13137f494e4812db8b9623dde0f5606", + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "fixture_guard": { + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }, + "bot_report": { + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/home/server/rustycore-issue-106/tools/wow-test-bot/target/debug/wow-test-bot", + "exec_sha256": "da98cd7f0a884fdbc1cd7f971d567c50143984d0430d7aba1b14a00d84a900ad", + "report_path": "/tmp/rustycore-loot-item-rust.CA4pL6/bot-report.json", + "report_sha256": "7c93aac0aebde59248716a6e593ee9a80c055411e256a9c63b4a249d953c7f40", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }, + "artifact": { + "path": "rust", + "packet_count": 191, + "tree_sha256": "5fe815b89f622bddd862232d7e00d288c569655872d02b4760220fd8a2eef818" + } +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/cpp.pkt b/crates/capture-diff/flows/loot-single-item-claim/cpp.pkt new file mode 100644 index 000000000..d039b740c Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/cpp.pkt differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json b/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/expected-divergences.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/crates/capture-diff/flows/loot-single-item-claim/flow.json b/crates/capture-diff/flows/loot-single-item-claim/flow.json new file mode 100644 index 000000000..14cbe318e --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/flow.json @@ -0,0 +1,7 @@ +{ + "description": "Single-session deterministic item claim: CMSG_LOOT_ITEM on instance, C++ LootRemoved on instance before ItemPushResult on realm, bounded by a fixed instance CMSG_PING fence.", + "directions": [ + "s2c", + "c2s" + ] +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/requirement.json b/crates/capture-diff/flows/loot-single-item-claim/requirement.json new file mode 100644 index 000000000..5f279265d --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/requirement.json @@ -0,0 +1,73 @@ +{ + "issue": "#106", + "status": "ready", + "description": "One solo character claims one deterministic creature-loot item; the isolated wire window starts at CMSG_LOOT_ITEM and ends at a fixed instance CMSG_PING fence.", + "directions": [ + "s2c", + "c2s" + ], + "import_selection": { + "from_opcode": { + "direction": "c2s", + "opcode": 12817 + }, + "until_opcode": { + "direction": "c2s", + "opcode": 14184 + }, + "ignored_opcodes": [ + { + "direction": "s2c", + "opcode": 11730 + }, + { + "direction": "c2s", + "opcode": 14909 + }, + { + "direction": "s2c", + "opcode": 11732 + } + ] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + { + "direction": "c2s", + "connection_id": 1, + "opcode": 12817, + "label": "CMSG_LOOT_ITEM" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 10187, + "label": "SMSG_UPDATE_OBJECT item CreateObject" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 9749, + "label": "SMSG_LOOT_REMOVED" + }, + { + "direction": "s2c", + "connection_id": 0, + "opcode": 9763, + "label": "SMSG_ITEM_PUSH_RESULT" + }, + { + "direction": "s2c", + "connection_id": 1, + "opcode": 10187, + "label": "SMSG_UPDATE_OBJECT InvSlots VALUES" + }, + { + "direction": "c2s", + "connection_id": 1, + "opcode": 14184, + "label": "CMSG_PING" + } + ] +} diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin new file mode 100644 index 000000000..040e1ddb3 Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta new file mode 100644 index 000000000..7c35ea82b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000000-counter0-0x3211-LootItem-len15.meta @@ -0,0 +1,8 @@ +direction=c2s +connection_id=1 +addr=127.0.0.1:0 +seq=0 +counter=0 +opcode=0x3211 +name=LootItem +len=15 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin new file mode 100644 index 000000000..78f8a9baa Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta new file mode 100644 index 000000000..358e97652 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-c2s-00000005-counter5-0x3768-Ping-len10.meta @@ -0,0 +1,8 @@ +direction=c2s +connection_id=1 +addr=127.0.0.1:0 +seq=5 +counter=5 +opcode=0x3768 +name=Ping +len=10 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin new file mode 100644 index 000000000..dd1a37bcc Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta new file mode 100644 index 000000000..ac08d3b2b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000001-counter1-0x27CB-UpdateObject-len312.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=1 +counter=1 +opcode=0x27CB +name=UpdateObject +len=312 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin new file mode 100644 index 000000000..c661ff12d Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta new file mode 100644 index 000000000..711885bc5 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000002-counter2-0x2615-LootRemoved-len20.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=2 +counter=2 +opcode=0x2615 +name=LootRemoved +len=20 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin new file mode 100644 index 000000000..2c16c9de4 Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.meta new file mode 100644 index 000000000..850bad039 --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000003-counter3-0x2623-ItemPushResult-len66.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=0 +addr=127.0.0.1:0 +seq=3 +counter=3 +opcode=0x2623 +name=ItemPushResult +len=66 diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.bin b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.bin new file mode 100644 index 000000000..d54e8e8ac Binary files /dev/null and b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.bin differ diff --git a/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta new file mode 100644 index 000000000..11d4d5d6b --- /dev/null +++ b/crates/capture-diff/flows/loot-single-item-claim/rust/rust-s2c-00000004-counter4-0x27CB-UpdateObject-len48.meta @@ -0,0 +1,8 @@ +direction=s2c +connection_id=1 +addr=127.0.0.1:0 +seq=4 +counter=4 +opcode=0x27CB +name=UpdateObject +len=48 diff --git a/crates/capture-diff/scripts/capture-cpp.sh b/crates/capture-diff/scripts/capture-cpp.sh index 30381c024..286bdf7f3 100755 --- a/crates/capture-diff/scripts/capture-cpp.sh +++ b/crates/capture-diff/scripts/capture-cpp.sh @@ -7,6 +7,7 @@ # # Usage: crates/capture-diff/scripts/capture-cpp.sh [--yes] # Output: target/captures//cpp.pkt (gitignored) +# target/captures//cpp.capture-manifest.json # # Honored env vars (defaults target this machine's layout): # CPP_RUNTIME_DIR directory the PM2 wrapper enters before worldserver starts @@ -18,6 +19,34 @@ # an empty LogsDir means CPP_RUNTIME_DIR, matching C++. # PM2_CPP_WORLD pm2 name of the C++ world (default: cpp-world) # PM2_RUST_WORLD pm2 name of the Rust world (default: rustycore-world) +# CPP_WORLD_PORT shared realm listener port (default: 8085) +# CPP_INSTANCE_PORT shared instance listener port (default: 8086) +# CPP_CAPTURE_EXEC absolute canonical legacy worldserver executable. It and +# CPP_CAPTURE_EXEC_SHA256 are mandatory for the required +# loot-single-item-claim evidence flow +# CPP_CAPTURE_EXEC_SHA256 expected 64-hex SHA-256. The source file and live +# /proc//exe are checked before and after the flow +# CPP_CAPTURE_SOURCE_REPO clean legacy C++ source checkout whose exact HEAD +# is recorded (default: /home/server/woltk-trinity-legacy) +# CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared +# with capture-rust.sh (default: /tmp, keyed by uid+ports) +# CAPTURE_WORLD_STOP_TIMEOUT_SECONDS bounded wait for a stopped world/ports +# (default: 30, range: 1 through 3600) +# CAPTURE_WORLD_READY_TIMEOUT_SECONDS bounded wait for a stable ready world +# (default: 180, range: 3 through 3600) +# CPP_CAPTURE_LOOT_FIXTURE_GUARD set to 1 only for the versioned +# loot-single-item-claim fixture. It CAS-lowers Doctor +# Maleficus 21779 HealthModifier while both worlds are +# stopped and restores it before normal Rust PM2 resumes +# CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION must be 1 with the fixture guard +# CPP_CAPTURE_DB_CONF worldserver.conf containing WorldDatabaseInfo and +# CharacterDatabaseInfo (default: CPP_CONF) +# WOW_BOT_FIXTURE_JOURNAL absolute bot recovery-journal path. Guarded +# capture will not restart normal Rust until the journal is +# gone and its mode-0600 cleanup marker validates +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for required +# loot-single-item-claim evidence +# WOW_BOT_REPORT fresh absolute bot JSON report path for that exact flow # # This stops the live RustyCore world server (disconnecting players). It refuses # to run without confirmation; pass --yes to skip the prompt. @@ -36,6 +65,249 @@ CPP_RUNTIME_DIR="${CPP_RUNTIME_DIR:-/home/server/trinity-legacy-install/bin}" CPP_CONF="${CPP_CONF:-${CPP_RUNTIME_DIR}/worldserver.conf}" PM2_CPP_WORLD="${PM2_CPP_WORLD:-cpp-world}" PM2_RUST_WORLD="${PM2_RUST_WORLD:-rustycore-world}" +CPP_WORLD_PORT="${CPP_WORLD_PORT:-8085}" +CPP_INSTANCE_PORT="${CPP_INSTANCE_PORT:-8086}" +CPP_CAPTURE_EXEC="${CPP_CAPTURE_EXEC:-}" +CPP_CAPTURE_EXEC_SHA256="${CPP_CAPTURE_EXEC_SHA256:-}" +CPP_CAPTURE_SOURCE_REPO="${CPP_CAPTURE_SOURCE_REPO:-/home/server/woltk-trinity-legacy}" +CAPTURE_WORLD_PORT="$CPP_WORLD_PORT" +CAPTURE_INSTANCE_PORT="$CPP_INSTANCE_PORT" +CAPTURE_ORCHESTRATION_LOCK="${CAPTURE_ORCHESTRATION_LOCK:-${XDG_RUNTIME_DIR:-/tmp}/rustycore-capture-$(id -u)-${CPP_WORLD_PORT}-${CPP_INSTANCE_PORT}.lock.d}" +CAPTURE_ORCHESTRATION_LOCK_FD="" +CPP_CAPTURE_LOOT_FIXTURE_GUARD="${CPP_CAPTURE_LOOT_FIXTURE_GUARD:-0}" +CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION="${CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-0}" +CPP_CAPTURE_DB_CONF="${CPP_CAPTURE_DB_CONF:-$CPP_CONF}" +LOOT_FIXTURE_DB_CONF="$CPP_CAPTURE_DB_CONF" +LOOT_FIXTURE_GUARD_ENABLED="$CPP_CAPTURE_LOOT_FIXTURE_GUARD" +WOW_BOT_FIXTURE_JOURNAL="${WOW_BOT_FIXTURE_JOURNAL:-}" +WOW_BOT_EXEC="${WOW_BOT_EXEC:-}" +WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256:-}" +WOW_BOT_REPORT="${WOW_BOT_REPORT:-}" +LOOT_FIXTURE_CLEANUP_MARKER="" +LOOT_FIXTURE_ENTRY=21779 +LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 +LOOT_FIXTURE_TEMP_HEALTH_MODIFIER=0.0001 +LOOT_FIXTURE_SNAPSHOT_READY=0 +LOOT_FIXTURE_WORLD_HOST="" +LOOT_FIXTURE_WORLD_PORT="" +LOOT_FIXTURE_WORLD_USER="" +LOOT_FIXTURE_WORLD_PASSWORD="" +LOOT_FIXTURE_WORLD_DATABASE="" +LOOT_FIXTURE_CHARACTER_HOST="" +LOOT_FIXTURE_CHARACTER_PORT="" +LOOT_FIXTURE_CHARACTER_USER="" +LOOT_FIXTURE_CHARACTER_PASSWORD="" +LOOT_FIXTURE_CHARACTER_DATABASE="" +CAPTURE_SWAPPED=0 +CPP_CAPTURE_BOT_READY=0 +CAPTURE_RESTORE_FAILURE_STATUS=74 +RUST_ORIGINAL_IDENTITY="" +CPP_CAPTURE_IDENTITY="" +CPP_CAPTURE_PID="" +CPP_CAPTURE_LIVE_EXEC="" +CPP_CAPTURE_LIVE_SHA256="" +CPP_CAPTURE_EXPECTED_EXEC="" +CPP_CAPTURE_EXPECTED_SHA256="" +CPP_CAPTURE_SOURCE_EXEC="" +CPP_CAPTURE_SOURCE_SHA256="" +CPP_CAPTURE_HARNESS_REPO_HEAD="" +CPP_CAPTURE_SOURCE_REPO_HEAD="" +CPP_CAPTURE_HARNESS_WORKTREE_CLEAN=0 +CPP_CAPTURE_HARNESS_WORKTREE_SHA256="" +CPP_CAPTURE_SOURCE_WORKTREE_DIRTY=0 +CPP_CAPTURE_SOURCE_WORKTREE_SHA256="" +CPP_CAPTURE_PM2_ENTRY_PID="" +CPP_CAPTURE_PM2_ENTRY_STARTTIME="" +CPP_CAPTURE_PM2_EXEC_PATH="" +CPP_CAPTURE_PM2_EXEC_SHA256="" +CPP_CAPTURE_PM2_PROFILE_SHA256="" +CPP_CAPTURE_RESTART_COUNT="" +CPP_CAPTURE_LISTENER_STARTTIME="" +CPP_CAPTURE_EFFECTIVE_CONFIG_PATH="" +CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256="" +CPP_CAPTURE_PINNED=0 +CAPTURE_ARTIFACT_READY=0 +OUT_PKT_STAGE="" +CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED=0 +CPP_CAPTURE_NORMAL_RUNTIME_RESTORED=0 +CPP_CAPTURE_BOT_EXEC="" +CPP_CAPTURE_BOT_EXEC_SHA256="" +CPP_CAPTURE_BOT_REPORT="" +CPP_CAPTURE_BOT_REPORT_SHA256="" +CPP_CONF_BACKUP_IDENTITY="" +CPP_CONF_BACKUP_SHA256="" + +# shellcheck source=loot-fixture-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" +# shellcheck source=capture-service-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" +capture_validate_world_timeouts || exit 2 + +[[ "$CPP_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((CPP_WORLD_PORT <= 65535)) || { + echo "error: CPP_WORLD_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[[ "$CPP_INSTANCE_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((CPP_INSTANCE_PORT <= 65535)) || { + echo "error: CPP_INSTANCE_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[ "$CPP_WORLD_PORT" != "$CPP_INSTANCE_PORT" ] || { + echo "error: CPP_WORLD_PORT and CPP_INSTANCE_PORT must be distinct" >&2 + exit 2 +} + +if [ "$FLOW" = "loot-single-item-claim" ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" != "1" ]; then + echo "error: loot-single-item-claim requires CPP_CAPTURE_LOOT_FIXTURE_GUARD=1" >&2 + exit 2 +fi + +case "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" in + 0) ;; + 1) + [ "$FLOW" = "loot-single-item-claim" ] || { + echo "error: the C++ loot fixture guard is defined only for loot-single-item-claim" >&2 + exit 2 + } + [ "$CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION" = "1" ] || { + echo "error: CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 requires CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1" >&2 + exit 2 + } + [ -n "$CPP_CAPTURE_EXEC" ] && [ -n "$CPP_CAPTURE_EXEC_SHA256" ] || { + echo "error: guarded C++ evidence requires CPP_CAPTURE_EXEC and CPP_CAPTURE_EXEC_SHA256" >&2 + exit 2 + } + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 + exit 2 + } + [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" + [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ + && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { + echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 + exit 2 + } + WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" + [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ + = "$WOW_BOT_REPORT_PARENT" ] \ + && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { + echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 + exit 2 + } + capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { + echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 + exit 2 + } + validate_fresh_loot_fixture_journal || exit 2 + for dependency in awk dirname jq mysql stat; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 2 + } + done + load_loot_fixture_database_credentials || exit 2 + ;; + *) + echo "error: CPP_CAPTURE_LOOT_FIXTURE_GUARD must be 0 or 1" >&2 + exit 2 + ;; +esac + +if [ -n "$CPP_CAPTURE_EXEC" ]; then + [[ "$CPP_CAPTURE_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: CPP_CAPTURE_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + CPP_CAPTURE_EXEC_SHA256="${CPP_CAPTURE_EXEC_SHA256,,}" + CPP_CAPTURE_PINNED=1 +elif [ -n "$CPP_CAPTURE_EXEC_SHA256" ]; then + echo "error: CPP_CAPTURE_EXEC_SHA256 requires CPP_CAPTURE_EXEC" >&2 + exit 2 +fi + +for dependency in awk chmod cp date dirname flock git grep id jq mkdir mktemp mv \ + pm2 realpath rg sed sha256sum sleep ss stat sync tail; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 2 + } +done + +CPP_CAPTURE_HARNESS_REPO_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve RustyCore harness repository HEAD" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_REPO="$(realpath -e -- "$CPP_CAPTURE_SOURCE_REPO" 2>/dev/null)" || { + echo "error: CPP_CAPTURE_SOURCE_REPO does not resolve" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_REPO_HEAD="$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve legacy C++ source repository HEAD" >&2 + exit 2 +} +capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && CPP_CAPTURE_HARNESS_WORKTREE_CLEAN=1 +CPP_CAPTURE_HARNESS_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$REPO_ROOT" +)" || { + echo "error: cannot fingerprint the RustyCore harness worktree" >&2 + exit 2 +} +CPP_CAPTURE_SOURCE_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO" +)" || { + echo "error: cannot fingerprint CPP_CAPTURE_SOURCE_REPO" >&2 + exit 2 +} +if capture_git_repo_is_dirty "$CPP_CAPTURE_SOURCE_REPO"; then + CPP_CAPTURE_SOURCE_WORKTREE_DIRTY=1 +fi +if [ "$CPP_CAPTURE_HARNESS_WORKTREE_CLEAN" -ne 1 ]; then + echo "error: capture evidence requires a clean committed RustyCore harness worktree (including untracked files)" >&2 + exit 2 +fi + +if [ "$CPP_CAPTURE_PINNED" -eq 1 ] \ + && ! capture_exec_source_matches "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256"; then + echo "error: CPP_CAPTURE_EXEC is not canonical/executable or does not match its pinned SHA-256" >&2 + exit 2 +fi +if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + CPP_CAPTURE_EXPECTED_EXEC="$CPP_CAPTURE_EXEC" + CPP_CAPTURE_EXPECTED_SHA256="$CPP_CAPTURE_EXEC_SHA256" + CPP_CAPTURE_SOURCE_EXEC="$CPP_CAPTURE_EXEC" + CPP_CAPTURE_SOURCE_SHA256="$CPP_CAPTURE_EXEC_SHA256" +fi + +CPP_CONF_CANONICAL="$(realpath -e -- "$CPP_CONF" 2>/dev/null)" || { + echo "error: CPP_CONF does not resolve" >&2 + exit 2 +} +[ "$CPP_CONF_CANONICAL" = "$CPP_CONF" ] \ + && [ -f "$CPP_CONF" ] && [ ! -L "$CPP_CONF" ] || { + echo "error: CPP_CONF must be an absolute canonical regular non-symlink file" >&2 + exit 2 +} +CPP_PROFILE_EFFECTIVE_CONFIG="$(capture_pm2_effective_config_path "$PM2_CPP_WORLD")" || { + echo "error: cannot derive the effective C++ config from the PM2 profile/entrypoint" >&2 + exit 2 +} +[ "$CPP_PROFILE_EFFECTIVE_CONFIG" = "$CPP_CONF" ] || { + echo "error: CPP_CONF (${CPP_CONF}) differs from PM2's effective config (${CPP_PROFILE_EFFECTIVE_CONFIG})" >&2 + exit 2 +} +CPP_CAPTURE_PM2_PROFILE_SHA256="$(capture_pm2_profile_redacted_sha256 "$PM2_CPP_WORLD")" || { + echo "error: cannot hash the stable redacted C++ PM2 profile" >&2 + exit 2 +} if [ -z "${CPP_LOGS_DIR+x}" ]; then CONFIGURED_LOGS_DIR="$({ @@ -49,14 +321,262 @@ if [ -z "${CPP_LOGS_DIR+x}" ]; then CPP_LOGS_DIR="${CPP_RUNTIME_DIR}/${CONFIGURED_LOGS_DIR}" fi fi +CPP_LOGS_DIR_CANONICAL="$(realpath -e -- "$CPP_LOGS_DIR" 2>/dev/null)" || { + echo "error: CPP_LOGS_DIR does not resolve" >&2 + exit 2 +} +[ "$CPP_LOGS_DIR_CANONICAL" = "$CPP_LOGS_DIR" ] \ + && [ -d "$CPP_LOGS_DIR" ] && [ ! -L "$CPP_LOGS_DIR" ] || { + echo "error: CPP_LOGS_DIR must be an absolute canonical non-symlink directory" >&2 + exit 2 +} PKT_NAME="rustycore-capture-${FLOW}.pkt" OUT_DIR="${REPO_ROOT}/target/captures/${FLOW}" OUT_PKT="${OUT_DIR}/cpp.pkt" +OUT_MANIFEST="${OUT_DIR}/cpp.capture-manifest.json" +capture_require_canonical_directory "${REPO_ROOT}/target/captures" \ + && capture_require_canonical_directory "$OUT_DIR" || { + echo "error: capture output root is not canonical or contains a symlink" >&2 + exit 2 +} +[ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || { + echo "error: raw C++ capture output already exists; archive/remove cpp.pkt and cpp.capture-manifest.json before recording a new generation" >&2 + exit 2 + } + +accredit_cpp_capture_executable() { + local proc_exe="/proc/${CPP_CAPTURE_PID}/exe" + local live_exec live_sha pm2_identity pm2_pid pm2_restart pm2_exec pm2_exec_sha + + [ -L "$proc_exe" ] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + live_sha="$(capture_sha256_of_file "$proc_exe")" || return 1 + if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + [ "$live_exec" = "$CPP_CAPTURE_EXEC" ] \ + && [ "$live_sha" = "$CPP_CAPTURE_EXEC_SHA256" ] \ + && capture_live_exec_matches \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256" \ + || return 1 + fi + pm2_identity="$(capture_pm2_entrypoint_identity \ + "$PM2_CPP_WORLD" "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + || return 1 + IFS=$'\t' read -r pm2_pid pm2_restart pm2_exec pm2_exec_sha <<<"$pm2_identity" + [ "$pm2_pid" = "$CPP_CAPTURE_PM2_ENTRY_PID" ] \ + && [[ "$pm2_restart" =~ ^[0-9]+$ ]] \ + && capture_pid_is_self_or_descendant \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_PM2_ENTRY_PID" \ + || return 1 + CPP_CAPTURE_PM2_EXEC_PATH="$pm2_exec" + CPP_CAPTURE_PM2_EXEC_SHA256="$pm2_exec_sha" + CPP_CAPTURE_RESTART_COUNT="$pm2_restart" + CPP_CAPTURE_PM2_ENTRY_STARTTIME="$(capture_pid_starttime "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + || return 1 + CPP_CAPTURE_LISTENER_STARTTIME="$(capture_pid_starttime "$CPP_CAPTURE_PID")" \ + || return 1 + CPP_CAPTURE_LIVE_EXEC="$live_exec" + CPP_CAPTURE_LIVE_SHA256="$live_sha" + if [ "$CPP_CAPTURE_PINNED" -eq 0 ]; then + CPP_CAPTURE_EXPECTED_EXEC="$live_exec" + CPP_CAPTURE_EXPECTED_SHA256="$live_sha" + CPP_CAPTURE_SOURCE_EXEC="$live_exec" + CPP_CAPTURE_SOURCE_SHA256="$live_sha" + fi +} + +cpp_capture_effective_config_sha256() { + capture_effective_config_redacted_sha256 \ + "$CPP_CAPTURE_EFFECTIVE_CONFIG_PATH" \ + "capture.world_port=${CPP_WORLD_PORT} +capture.instance_port=${CPP_INSTANCE_PORT} +capture.packet_log=enabled" \ + PacketLogFile LogsDir Bot.AccountPrefix WorldServerPort InstanceServerPort \ + LoginDatabaseInfo WorldDatabaseInfo CharacterDatabaseInfo \ + Rate.Drop.Item.Poor Rate.Drop.Item.Normal Rate.Drop.Item.Uncommon \ + Rate.Drop.Item.Rare Rate.Drop.Item.Epic Rate.Drop.Item.Legendary \ + Rate.Drop.Item.Artifact Rate.Drop.Item.Referenced Rate.Drop.Money +} + +cpp_capture_executable_unchanged() { + [ -n "$CPP_CAPTURE_LIVE_EXEC" ] \ + && [ -n "$CPP_CAPTURE_LIVE_SHA256" ] \ + && capture_live_exec_matches \ + "$CPP_CAPTURE_PID" "$CPP_CAPTURE_LIVE_EXEC" "$CPP_CAPTURE_LIVE_SHA256" \ + && [ "$(capture_pm2_entrypoint_identity \ + "$PM2_CPP_WORLD" "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + = "${CPP_CAPTURE_PM2_ENTRY_PID}"$'\t'"${CPP_CAPTURE_RESTART_COUNT}"$'\t'"${CPP_CAPTURE_PM2_EXEC_PATH}"$'\t'"${CPP_CAPTURE_PM2_EXEC_SHA256}" ] \ + && [ "$(capture_world_ready_once "$PM2_CPP_WORLD")" = "$CPP_CAPTURE_IDENTITY" ] \ + && [ "$(capture_pid_starttime "$CPP_CAPTURE_PM2_ENTRY_PID")" \ + = "$CPP_CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && [ "$(capture_pid_starttime "$CPP_CAPTURE_PID")" \ + = "$CPP_CAPTURE_LISTENER_STARTTIME" ] \ + && [ "$(capture_pm2_profile_redacted_sha256 "$PM2_CPP_WORLD")" \ + = "$CPP_CAPTURE_PM2_PROFILE_SHA256" ] \ + && [ "$(capture_pm2_effective_config_path "$PM2_CPP_WORLD")" \ + = "$CPP_CONF" ] \ + && [ "$(cpp_capture_effective_config_sha256)" \ + = "$CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256" ] +} + +finalize_cpp_capture_artifact() { + [ "$CAPTURE_ARTIFACT_READY" -eq 1 ] && [ -f "$OUT_PKT_STAGE" ] || return 1 + + local created_at manifest_stage packet_sha packet_size bot_evidence + [ "$CPP_CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] || return 1 + capture_fixture_cleanup_verified_for_publication \ + "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" \ + "$CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED" || return 1 + if [ "$FLOW" = "loot-single-item-claim" ]; then + bot_evidence="$(capture_loot_item_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + IFS=$'\t' read -r CPP_CAPTURE_BOT_EXEC CPP_CAPTURE_BOT_EXEC_SHA256 \ + CPP_CAPTURE_BOT_REPORT CPP_CAPTURE_BOT_REPORT_SHA256 <<<"$bot_evidence" + fi + packet_sha="$(capture_sha256_of_file "$OUT_PKT_STAGE")" || return 1 + packet_size="$(stat -c '%s' -- "$OUT_PKT_STAGE")" || return 1 + created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" || return 1 + capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" ] \ + && [ "$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" \ + = "$CPP_CAPTURE_SOURCE_REPO_HEAD" ] \ + && [ "$(capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO")" \ + = "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" ] || return 1 + capture_require_canonical_directory "$OUT_DIR" \ + && [ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || return 1 + manifest_stage="$(mktemp "${OUT_DIR}/.cpp.capture-manifest.partial.XXXXXX")" \ + || return 1 + if ! jq -n \ + --arg flow "$FLOW" \ + --arg created_at "$created_at" \ + --arg harness_repo_head "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + --arg source_repo_head "$CPP_CAPTURE_SOURCE_REPO_HEAD" \ + --arg harness_worktree_sha256 "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" \ + --arg source_worktree_sha256 "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" \ + --arg expected_exec_path "$CPP_CAPTURE_EXPECTED_EXEC" \ + --arg expected_exec_sha256 "$CPP_CAPTURE_EXPECTED_SHA256" \ + --arg source_exec_path "$CPP_CAPTURE_SOURCE_EXEC" \ + --arg source_exec_sha256 "$CPP_CAPTURE_SOURCE_SHA256" \ + --arg live_exec_path "$CPP_CAPTURE_LIVE_EXEC" \ + --arg live_exec_sha256 "$CPP_CAPTURE_LIVE_SHA256" \ + --arg pm2_exec_path "$CPP_CAPTURE_PM2_EXEC_PATH" \ + --arg pm2_exec_sha256 "$CPP_CAPTURE_PM2_EXEC_SHA256" \ + --arg pm2_profile_sha256 "$CPP_CAPTURE_PM2_PROFILE_SHA256" \ + --arg effective_config_path "$CPP_CAPTURE_EFFECTIVE_CONFIG_PATH" \ + --arg effective_config_sha256 "$CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256" \ + --arg bot_exec_path "$CPP_CAPTURE_BOT_EXEC" \ + --arg bot_exec_sha256 "$CPP_CAPTURE_BOT_EXEC_SHA256" \ + --arg bot_report_path "$CPP_CAPTURE_BOT_REPORT" \ + --arg bot_report_sha256 "$CPP_CAPTURE_BOT_REPORT_SHA256" \ + --arg packet_sha256 "$packet_sha" \ + --argjson pm2_entry_pid "$CPP_CAPTURE_PM2_ENTRY_PID" \ + --argjson pm2_entry_starttime "$CPP_CAPTURE_PM2_ENTRY_STARTTIME" \ + --argjson listener_runtime_pid "$CPP_CAPTURE_PID" \ + --argjson listener_runtime_starttime "$CPP_CAPTURE_LISTENER_STARTTIME" \ + --argjson restart_count "$CPP_CAPTURE_RESTART_COUNT" \ + --argjson packet_size "$packet_size" \ + --argjson guarded "$([ "$FLOW" = "loot-single-item-claim" ] && printf true || printf false)" \ + --argjson pinned "$([ "$CPP_CAPTURE_PINNED" -eq 1 ] && printf true || printf false)" \ + --argjson source_worktree_dirty \ + "$([ "$CPP_CAPTURE_SOURCE_WORKTREE_DIRTY" -eq 1 ] && printf true || printf false)" \ + '{ + version: 3, + flow: $flow, + side: "cpp", + completed: true, + created_at: $created_at, + harness_repo_head: $harness_repo_head, + source_repo_head: $source_repo_head, + harness_worktree_clean: true, + harness_worktree_state_sha256: $harness_worktree_sha256, + source_worktree_dirty: $source_worktree_dirty, + source_worktree_state_sha256: $source_worktree_sha256, + worktree_state_algorithm: "git-head-path-mode-content-sha256-v1", + expected_exec_path: $expected_exec_path, + expected_exec_sha256: $expected_exec_sha256, + source_exec_path: $source_exec_path, + source_exec_sha256: $source_exec_sha256, + live_exec_path: $live_exec_path, + live_exec_sha256: $live_exec_sha256, + executable_pin_enforced: $pinned, + pm2_entry_pid: $pm2_entry_pid, + pm2_entry_starttime: $pm2_entry_starttime, + pm2_exec_path: $pm2_exec_path, + pm2_exec_sha256: $pm2_exec_sha256, + pm2_profile_redacted_sha256: $pm2_profile_sha256, + listener_runtime_pid: $listener_runtime_pid, + listener_runtime_starttime: $listener_runtime_starttime, + listener_relationship_verified: true, + restart_count: $restart_count, + effective_config_path: $effective_config_path, + effective_config_redacted_sha256: $effective_config_sha256, + effective_config_algorithm: "capture-relevant-redacted-v1", + runtime_cleanup_verified: true, + normal_runtime_restored: true, + fixture_guard: (if $guarded then { + enabled: true, + contract: "loot-single-item-claim-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + creature_entry: 21779, + creature_spawn_guid: 1117, + item_entry: 30712, + cleanup_verified: true + } else null end), + bot_report: (if $guarded then { + contract: "wow-test-bot-loot-item-capture-report-v1", + exec_path: $bot_exec_path, + exec_sha256: $bot_exec_sha256, + report_path: $bot_report_path, + report_sha256: $bot_report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } else null end), + artifact: { + path: "cpp.pkt", + size: $packet_size, + sha256: $packet_sha256 + } + }' >"$manifest_stage"; then + rm -f -- "$manifest_stage" + return 1 + fi + chmod 600 "$OUT_PKT_STAGE" "$manifest_stage" || { + rm -f -- "$manifest_stage" + return 1 + } + sync -f "$OUT_PKT_STAGE" && sync -f "$manifest_stage" || { + rm -f -- "$manifest_stage" + return 1 + } + capture_publish_noreplace "$OUT_PKT_STAGE" "$OUT_PKT" || { + rm -f -- "$manifest_stage" + return 1 + } + OUT_PKT_STAGE="" + capture_publish_noreplace "$manifest_stage" "$OUT_MANIFEST" || return 1 + sync -f "$OUT_DIR" || return 1 + CAPTURE_ARTIFACT_READY=0 + echo "collected ${packet_size} bytes -> ${OUT_PKT}" + echo "provenance -> ${OUT_MANIFEST}" +} echo "flow : ${FLOW}" echo "C++ conf : ${CPP_CONF}" echo "C++ logs dir : ${CPP_LOGS_DIR}" +if [ "$CPP_CAPTURE_PINNED" -eq 1 ]; then + echo "C++ exec : ${CPP_CAPTURE_EXEC}" + echo "C++ SHA-256 : ${CPP_CAPTURE_EXEC_SHA256}" +fi echo "pkt file : ${CPP_LOGS_DIR}/${PKT_NAME}" echo "output : ${OUT_PKT}" echo @@ -67,6 +587,26 @@ if [ "$CONFIRM" != "--yes" ]; then [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { echo "aborted"; exit 1; } fi +capture_acquire_orchestration_lock "$CAPTURE_ORCHESTRATION_LOCK" || { + echo "error: another capture/QA process holds ${CAPTURE_ORCHESTRATION_LOCK}" >&2 + exit 1 +} +if [ "$CPP_CAPTURE_PINNED" -eq 1 ] \ + && ! capture_exec_source_matches "$CPP_CAPTURE_EXEC" "$CPP_CAPTURE_EXEC_SHA256"; then + echo "error: pinned C++ executable changed before service mutation" >&2 + exit 1 +fi +capture_git_repo_clean_at_head "$REPO_ROOT" "$CPP_CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CPP_CAPTURE_HARNESS_WORKTREE_SHA256" ] \ + && [ "$(git -C "$CPP_CAPTURE_SOURCE_REPO" rev-parse HEAD 2>/dev/null)" \ + = "$CPP_CAPTURE_SOURCE_REPO_HEAD" ] \ + && [ "$(capture_git_worktree_state_sha256 "$CPP_CAPTURE_SOURCE_REPO")" \ + = "$CPP_CAPTURE_SOURCE_WORKTREE_SHA256" ] || { + echo "error: harness/source worktree provenance changed before service mutation" >&2 + exit 1 +} + [ -f "$CPP_CONF" ] || { echo "error: conf not found: $CPP_CONF" >&2; exit 1; } [ -d "$CPP_LOGS_DIR" ] || { echo "error: packet log directory not found: $CPP_LOGS_DIR" >&2; exit 1; } @@ -74,25 +614,132 @@ CONF_BAK="${CPP_CONF}.capture-diff.bak" # A leftover backup means a prior run was killed before restoring. Refuse to # overwrite it with the (possibly already-edited) conf — that would lose the # pristine original. The operator must inspect/restore it manually first. -if [ -e "$CONF_BAK" ]; then +if [ -e "$CONF_BAK" ] || [ -L "$CONF_BAK" ]; then echo "error: stale backup ${CONF_BAK} exists (a prior run did not restore)." >&2 echo " restore it over ${CPP_CONF} and delete it before re-running." >&2 exit 1 fi +RUST_ORIGINAL_IDENTITY="$(capture_wait_for_world_ready "$PM2_RUST_WORLD")" || { + echo "error: ${PM2_RUST_WORLD} is not one exact online PM2 process owning both configured listeners" >&2 + exit 1 +} +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} must be one exact stopped PM2 process before capture" >&2 + exit 1 +} # Preserve the active config's ownership/mode/timestamps exactly. A restrictive # caller umask must not turn the restored worldserver.conf into a different # runtime file after the capture. cp -a "$CPP_CONF" "$CONF_BAK" +[ -f "$CONF_BAK" ] && [ ! -L "$CONF_BAK" ] \ + && [ "$(realpath -e -- "$CONF_BAK" 2>/dev/null)" = "$CONF_BAK" ] || { + echo "error: failed to create a canonical regular config backup" >&2 + exit 1 +} +CPP_CONF_BACKUP_IDENTITY="$(stat -c '%d:%i' -- "$CONF_BAK")" || exit 1 +CPP_CONF_BACKUP_SHA256="$(capture_sha256_of_file "$CONF_BAK")" || exit 1 restore() { - echo "restoring ${PM2_CPP_WORLD} -> ${PM2_RUST_WORLD} and conf..." - pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1 || true - if ! mv -f "$CONF_BAK" "$CPP_CONF"; then + local capture_status=$? + local restored_rust_pid="" + local restore_status=0 + trap - EXIT HUP INT TERM + trap '' HUP INT TERM + set +e + + if [ "$CAPTURE_SWAPPED" -eq 1 ]; then + echo "restoring ${PM2_CPP_WORLD} -> ${PM2_RUST_WORLD} and conf..." + if ! pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1; then + echo "WARNING: failed to stop ${PM2_CPP_WORLD}; refusing fixture restoration while it may still own runtime state" >&2 + restore_status=1 + fi + if ! capture_wait_for_world_stopped "$PM2_CPP_WORLD" "$CPP_CAPTURE_IDENTITY"; then + echo "WARNING: ${PM2_CPP_WORLD} PID/PM2 entry or ports ${CPP_WORLD_PORT}/${CPP_INSTANCE_PORT} remain active; refusing DB restoration" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! loot_fixture_wait_until_all_characters_offline; then + echo "WARNING: characters remain online after stopping C++; refusing fixture restoration" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! restore_creature_health_fixture_guard; then + echo "WARNING: failed to restore the bounded Doctor loot fixture" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && ! loot_fixture_bot_cleanup_safe_for_capture_state \ + "$CPP_CAPTURE_BOT_READY"; then + echo "WARNING: bot fixture cleanup is unproven; the normal Rust world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + CPP_CAPTURE_FIXTURE_CLEANUP_VERIFIED=1 + fi + fi + + if [ ! -f "$CONF_BAK" ] || [ -L "$CONF_BAK" ] \ + || [ "$(stat -c '%d:%i' -- "$CONF_BAK" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_IDENTITY" ] \ + || [ "$(capture_sha256_of_file "$CONF_BAK" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_SHA256" ]; then + echo "WARNING: ${CONF_BAK} changed or became unsafe; refusing to install it over ${CPP_CONF}" >&2 + restore_status=1 + elif ! mv -f "$CONF_BAK" "$CPP_CONF"; then echo "WARNING: failed to restore ${CPP_CONF} from ${CONF_BAK} — packet logging may still be ON; restore it manually." >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && { [ ! -f "$CPP_CONF" ] || [ -L "$CPP_CONF" ] \ + || [ "$(stat -c '%d:%i' -- "$CPP_CONF" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_IDENTITY" ] \ + || [ "$(capture_sha256_of_file "$CPP_CONF" 2>/dev/null)" \ + != "$CPP_CONF_BACKUP_SHA256" ]; }; then + echo "WARNING: restored ${CPP_CONF} does not match the accredited backup; normal Rust will remain stopped" >&2 + restore_status=1 + fi + if [ "$CAPTURE_SWAPPED" -eq 1 ] && [ "$restore_status" -eq 0 ]; then + if ! pm2 start "$PM2_RUST_WORLD" >/dev/null 2>&1; then + echo "WARNING: failed to restart ${PM2_RUST_WORLD}; inspect PM2 before another capture" >&2 + restore_status=1 + elif ! restored_rust_pid="$(capture_wait_for_world_ready "$PM2_RUST_WORLD")"; then + echo "WARNING: restored ${PM2_RUST_WORLD} did not become one stable PID owning both configured listeners" >&2 + restore_status=1 + else + CPP_CAPTURE_NORMAL_RUNTIME_RESTORED=1 + fi + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$CAPTURE_SWAPPED" -eq 1 ] \ + && [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! rm -f -- "$LOOT_FIXTURE_CLEANUP_MARKER"; then + echo "WARNING: failed to remove the consumed bot cleanup marker" >&2 + restore_status=1 fi - pm2 start "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + if [ "$restore_status" -eq 0 ] && [ "$capture_status" -eq 0 ]; then + if ! finalize_cpp_capture_artifact; then + echo "WARNING: capture cleanup succeeded, but atomic packet/manifest publication failed" >&2 + restore_status=1 + fi + fi + if [ "$restore_status" -ne 0 ] || [ "$capture_status" -ne 0 ]; then + [ -z "$OUT_PKT_STAGE" ] || rm -f -- "$OUT_PKT_STAGE" + CAPTURE_ARTIFACT_READY=0 + fi + capture_release_orchestration_lock + if [ "$restore_status" -ne 0 ]; then + echo "WARNING: guarded C++ capture cleanup or provenance publication failed; recover explicitly before reuse" >&2 + exit "$CAPTURE_RESTORE_FAILURE_STATUS" + fi + exit "$capture_status" } trap restore EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM # Enable PacketLogFile in the conf (replace existing line or append). if grep -qE '^[[:space:]]*PacketLogFile' "$CPP_CONF"; then @@ -111,17 +758,74 @@ else printf '\nBot.AccountPrefix = ""\n' >>"$CPP_CONF" fi +CPP_CAPTURE_EFFECTIVE_CONFIG_PATH="$(realpath -e -- "$CPP_CONF" 2>/dev/null)" || { + echo "error: cannot canonicalize effective C++ capture config" >&2 + exit 1 +} +CPP_CAPTURE_EFFECTIVE_CONFIG_SHA256="$(cpp_capture_effective_config_sha256)" || { + echo "error: cannot hash the canonical redacted effective C++ capture config" >&2 + exit 1 +} + rm -f "${CPP_LOGS_DIR}/${PKT_NAME}" echo "swapping to C++ world server..." -pm2 stop "$PM2_RUST_WORLD" >/dev/null 2>&1 || true +CAPTURE_SWAPPED=1 +pm2 stop "$PM2_RUST_WORLD" >/dev/null 2>&1 +capture_wait_for_world_stopped "$PM2_RUST_WORLD" "$RUST_ORIGINAL_IDENTITY" || { + echo "error: ${PM2_RUST_WORLD} PID/PM2 entry or ports ${CPP_WORLD_PORT}/${CPP_INSTANCE_PORT} remain active after stop" >&2 + exit 1 +} +if [ "$CPP_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + loot_fixture_wait_until_all_characters_offline + apply_creature_health_fixture_guard +fi pm2 start "$PM2_CPP_WORLD" +CPP_CAPTURE_IDENTITY="$(capture_wait_for_world_ready "$PM2_CPP_WORLD")" || { + echo "error: ${PM2_CPP_WORLD} did not become one stable PID owning both configured listeners" >&2 + exit 1 +} +IFS=$'\t' read -r CPP_CAPTURE_PM2_ENTRY_PID CPP_CAPTURE_PID \ + <<<"$CPP_CAPTURE_IDENTITY" +[[ "$CPP_CAPTURE_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$CPP_CAPTURE_PID" =~ ^[1-9][0-9]*$ ]] || { + echo "error: ${PM2_CPP_WORLD} returned an invalid PM2-parent/listener identity" >&2 + exit 1 +} +accredit_cpp_capture_executable || { + echo "error: ${PM2_CPP_WORLD} live /proc executable does not match the pinned C++ path/SHA-256" >&2 + exit 1 +} +CPP_CAPTURE_BOT_READY=1 echo echo ">>> Perform the '${FLOW}' flow with the client now." read -r -p ">>> Press ENTER when the flow is complete to collect the capture... " _ -mkdir -p "$OUT_DIR" -cp -f "${CPP_LOGS_DIR}/${PKT_NAME}" "$OUT_PKT" -echo "collected $(stat -c%s "$OUT_PKT" 2>/dev/null || echo '?') bytes -> ${OUT_PKT}" -echo "next: crates/capture-diff/scripts/capture-rust.sh ${FLOW}" +[ "$(capture_world_ready_once "$PM2_CPP_WORLD")" = "$CPP_CAPTURE_IDENTITY" ] || { + echo "error: ${PM2_CPP_WORLD} PID/listener identity changed during capture" >&2 + exit 1 +} +cpp_capture_executable_unchanged || { + echo "error: ${PM2_CPP_WORLD} executable path/bytes changed during capture" >&2 + exit 1 +} + +CPP_PKT_SOURCE="${CPP_LOGS_DIR}/${PKT_NAME}" +[ -f "$CPP_PKT_SOURCE" ] && [ ! -L "$CPP_PKT_SOURCE" ] \ + && [ "$(realpath -e -- "$CPP_PKT_SOURCE" 2>/dev/null)" \ + = "$CPP_PKT_SOURCE" ] || { + echo "error: C++ packet logger output is missing, non-canonical, or a symlink" >&2 + exit 1 +} +capture_require_canonical_directory "$OUT_DIR" \ + && [ ! -e "$OUT_PKT" ] && [ ! -L "$OUT_PKT" ] \ + && [ ! -e "$OUT_MANIFEST" ] && [ ! -L "$OUT_MANIFEST" ] || { + echo "error: C++ capture output path changed or became unsafe during capture" >&2 + exit 1 +} +OUT_PKT_STAGE="$(mktemp "${OUT_DIR}/.cpp.pkt.partial.XXXXXX")" +cp -f -- "$CPP_PKT_SOURCE" "$OUT_PKT_STAGE" +CAPTURE_ARTIFACT_READY=1 +echo "packet artifact staged; it will publish only after guarded cleanup succeeds" +echo "next (after this command exits 0): crates/capture-diff/scripts/capture-rust.sh ${FLOW}" diff --git a/crates/capture-diff/scripts/capture-rust.sh b/crates/capture-diff/scripts/capture-rust.sh index 8445cb3c8..eddb4b7c5 100755 --- a/crates/capture-diff/scripts/capture-rust.sh +++ b/crates/capture-diff/scripts/capture-rust.sh @@ -6,7 +6,9 @@ # the dump in place and restarts the server cleanly. # # Usage: crates/capture-diff/scripts/capture-rust.sh [--yes] -# Output: target/captures//rust/ (gitignored; .bin/.meta per packet) +# Output: target/captures//rust/ (gitignored; .bin/.meta per packet, +# rust.capture-manifest.json, plus the retained race bot report for +# loot-two-session-atomic-race) # # Honored env vars: # PM2_RUST_WORLD pm2 name of the Rust world (default: rustycore-world) @@ -17,6 +19,34 @@ # capturing; the original PM2 executable is still restored # RUST_CAPTURE_EXEC_SHA256 mandatory 64-hex SHA-256 when RUST_CAPTURE_EXEC is # set; both the file and live /proc executable must match +# RUST_CAPTURE_LOOT_FIXTURE_GUARD set to 1 only for the versioned +# loot-single-item-claim or loot-two-session-atomic-race +# fixtures; the single-item flow temporarily lowers +# its creature HealthModifier, while the two-session +# flow installs a guarded shared QA chest. Every +# mutation is restored before the original PM2 +# profile starts again +# RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION must be 1 with the fixture guard +# RUST_CAPTURE_DB_CONF worldserver.conf containing WorldDatabaseInfo and +# CharacterDatabaseInfo (default: legacy runtime conf) +# RUST_CAPTURE_EFFECTIVE_CONFIG exact config file used by the Rust capture +# process for capture-relevant settings; required +# explicitly for guarded evidence (defaults to +# RUST_CAPTURE_DB_CONF for non-guarded captures) +# WOW_BOT_FIXTURE_JOURNAL absolute path to the bot's mode-0600 recovery +# journal. Guarded loot captures require the bot to +# remove this pending journal and atomically create +# ${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete before +# the normal PM2 world may be restored +# CAPTURE_ORCHESTRATION_LOCK optional absolute private lock directory shared +# with capture-cpp.sh (default: /tmp, keyed by uid+ports) +# CAPTURE_WORLD_READY_TIMEOUT_SECONDS bounded wait for a stable ready world +# (default: 180, range: 3 through 3600) +# WOW_BOT_EXEC / WOW_BOT_EXEC_SHA256 pinned bot executable used for both +# guarded #106 loot evidence flows +# WOW_BOT_REPORT fresh absolute bot JSON report path. The guarded +# wrapper independently validates the exact selected +# single-item or two-session contract before publish # # This restarts the live world server (disconnecting players). Pass --yes to skip # the confirmation prompt. @@ -37,8 +67,179 @@ RUST_WORLD_PORT="${RUST_WORLD_PORT:-8085}" RUST_INSTANCE_PORT="${RUST_INSTANCE_PORT:-8086}" RUST_CAPTURE_EXEC="${RUST_CAPTURE_EXEC:-}" RUST_CAPTURE_EXEC_SHA256="${RUST_CAPTURE_EXEC_SHA256:-}" +CAPTURE_WORLD_PORT="$RUST_WORLD_PORT" +CAPTURE_INSTANCE_PORT="$RUST_INSTANCE_PORT" +CAPTURE_ORCHESTRATION_LOCK="${CAPTURE_ORCHESTRATION_LOCK:-${XDG_RUNTIME_DIR:-/tmp}/rustycore-capture-$(id -u)-${RUST_WORLD_PORT}-${RUST_INSTANCE_PORT}.lock.d}" +CAPTURE_ORCHESTRATION_LOCK_FD="" +RUST_CAPTURE_LOOT_FIXTURE_GUARD="${RUST_CAPTURE_LOOT_FIXTURE_GUARD:-0}" +LOOT_FIXTURE_GUARD_ENABLED="$RUST_CAPTURE_LOOT_FIXTURE_GUARD" +RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION="${RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-0}" +RUST_CAPTURE_DB_CONF="${RUST_CAPTURE_DB_CONF:-/home/server/trinity-legacy-install/bin/worldserver.conf}" +RUST_CAPTURE_EFFECTIVE_CONFIG_WAS_SET="${RUST_CAPTURE_EFFECTIVE_CONFIG+x}" +RUST_CAPTURE_EFFECTIVE_CONFIG="${RUST_CAPTURE_EFFECTIVE_CONFIG:-$RUST_CAPTURE_DB_CONF}" +LOOT_FIXTURE_DB_CONF="$RUST_CAPTURE_DB_CONF" +WOW_BOT_FIXTURE_JOURNAL="${WOW_BOT_FIXTURE_JOURNAL:-}" +WOW_BOT_EXEC="${WOW_BOT_EXEC:-}" +WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256:-}" +WOW_BOT_REPORT="${WOW_BOT_REPORT:-}" CAPTURE_EXEC="" CAPTURE_EXEC_SHA256="" +CAPTURE_EXPECTED_EXEC="" +CAPTURE_EXPECTED_SHA256="" +CAPTURE_SOURCE_EXEC="" +CAPTURE_SOURCE_SHA256="" +CAPTURE_HARNESS_REPO_HEAD="" +CAPTURE_SOURCE_REPO_HEAD="" +CAPTURE_HARNESS_WORKTREE_SHA256="" +CAPTURE_PM2_ENTRY_PID="" +CAPTURE_PM2_ENTRY_STARTTIME="" +CAPTURE_PM2_EXEC_PATH="" +CAPTURE_PM2_EXEC_SHA256="" +CAPTURE_PM2_PROFILE_SHA256="" +CAPTURE_RESTART_COUNT="" +CAPTURE_LISTENER_STARTTIME="" +CAPTURE_EFFECTIVE_CONFIG_PATH="" +CAPTURE_EFFECTIVE_CONFIG_SHA256="" +CAPTURE_RESTORE_FAILURE_STATUS=74 +LOOT_FIXTURE_KIND="" +LOOT_FIXTURE_ENTRY="" +LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER="" +LOOT_FIXTURE_TEMP_HEALTH_MODIFIER="0.0001" +LOOT_FIXTURE_SNAPSHOT_READY=0 +LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY=2846 +LOOT_FIXTURE_CHEST_LOOT_ENTRY=2278 +LOOT_FIXTURE_CHEST_ITEM=38 +LOOT_FIXTURE_CHEST_GUID=9106001 +LOOT_FIXTURE_CHEST_ADDON_FACTION=101 +LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 +LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 +LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 +LOOT_FIXTURE_CLEANUP_MARKER="" +LOOT_FIXTURE_WORLD_HOST="" +LOOT_FIXTURE_WORLD_PORT="" +LOOT_FIXTURE_WORLD_USER="" +LOOT_FIXTURE_WORLD_PASSWORD="" +LOOT_FIXTURE_WORLD_DATABASE="" +LOOT_FIXTURE_CHARACTER_HOST="" +LOOT_FIXTURE_CHARACTER_PORT="" +LOOT_FIXTURE_CHARACTER_USER="" +LOOT_FIXTURE_CHARACTER_PASSWORD="" +LOOT_FIXTURE_CHARACTER_DATABASE="" +CAPTURE_PROCESS_PID="" +CAPTURE_LIVE_EXEC="" +CAPTURE_LIVE_SHA256="" +CAPTURE_ARTIFACT_READY=0 +DUMP_STAGE_DIR="" +CAPTURE_PROCESS_TREE_IDENTITIES="" +ORIGINAL_PM2_ENTRY_PID="" +ORIGINAL_PM2_ENTRY_STARTTIME="" +ORIGINAL_LISTENER_PID="" +ORIGINAL_LISTENER_STARTTIME="" +ORIGINAL_PROCESS_TREE_IDENTITIES="" +CAPTURE_RUNTIME_CLEANUP_VERIFIED=0 +CAPTURE_NORMAL_RUNTIME_RESTORED=0 +CAPTURE_FIXTURE_CLEANUP_VERIFIED=0 +CAPTURE_BOT_EXEC="" +CAPTURE_BOT_EXEC_SHA256="" +CAPTURE_BOT_REPORT="" +CAPTURE_BOT_REPORT_SHA256="" +CAPTURE_BOT_READY=0 +RESTORE_FILE_SHA256="" +CAPTURE_CONFIG_FILE_SHA256="" + +# Keep the bounded SQL mutation and durable cleanup-marker contract identical +# for C++ and Rust recordings. +# shellcheck source=loot-fixture-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/loot-fixture-common.sh" +# shellcheck source=capture-service-common.sh +source "$(dirname "${BASH_SOURCE[0]}")/capture-service-common.sh" +capture_validate_world_timeouts || exit 2 + +[[ "$RUST_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((RUST_WORLD_PORT <= 65535)) || { + echo "error: RUST_WORLD_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[[ "$RUST_INSTANCE_PORT" =~ ^[1-9][0-9]*$ ]] \ + && ((RUST_INSTANCE_PORT <= 65535)) || { + echo "error: RUST_INSTANCE_PORT must be an integer from 1 through 65535" >&2 + exit 2 + } +[ "$RUST_WORLD_PORT" != "$RUST_INSTANCE_PORT" ] || { + echo "error: RUST_WORLD_PORT and RUST_INSTANCE_PORT must be distinct" >&2 + exit 2 +} + +if [ "$FLOW" = "loot-single-item-claim" ] \ + && [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" != "1" ]; then + echo "error: loot-single-item-claim requires RUST_CAPTURE_LOOT_FIXTURE_GUARD=1" >&2 + exit 2 +fi + +case "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" in + 0) ;; + 1) + [ "$RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION" = "1" ] || { + echo "error: RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 requires RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1" >&2 + exit 2 + } + [ -n "$RUST_CAPTURE_EXEC" ] && [ -n "$RUST_CAPTURE_EXEC_SHA256" ] || { + echo "error: guarded Rust evidence requires RUST_CAPTURE_EXEC and RUST_CAPTURE_EXEC_SHA256" >&2 + exit 2 + } + [ "$RUST_CAPTURE_EFFECTIVE_CONFIG_WAS_SET" = "x" ] \ + && [ -n "$RUST_CAPTURE_EFFECTIVE_CONFIG" ] || { + echo "error: guarded Rust evidence requires RUST_CAPTURE_EFFECTIVE_CONFIG" >&2 + exit 2 + } + case "$FLOW" in + loot-single-item-claim) + LOOT_FIXTURE_KIND=creature-health + LOOT_FIXTURE_ENTRY=21779 + LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 + ;; + loot-two-session-atomic-race) + LOOT_FIXTURE_KIND=shared-chest + ;; + *) + echo "error: the loot fixture guard is not defined for flow '${FLOW}'" >&2 + exit 2 + ;; + esac + [ -n "$WOW_BOT_EXEC" ] && [ -n "$WOW_BOT_EXEC_SHA256" ] \ + && [ -n "$WOW_BOT_REPORT" ] || { + echo "error: guarded #106 evidence requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT" >&2 + exit 2 + } + [[ "$WOW_BOT_EXEC_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "error: WOW_BOT_EXEC_SHA256 must contain exactly 64 hexadecimal characters" >&2 + exit 2 + } + WOW_BOT_EXEC_SHA256="${WOW_BOT_EXEC_SHA256,,}" + [[ "$WOW_BOT_REPORT" = /* && "$WOW_BOT_REPORT" != *$'\n'* ]] \ + && [ -d "$(dirname -- "$WOW_BOT_REPORT")" ] \ + && [ ! -e "$WOW_BOT_REPORT" ] && [ ! -L "$WOW_BOT_REPORT" ] || { + echo "error: WOW_BOT_REPORT must be a fresh absolute path with an existing parent" >&2 + exit 2 + } + WOW_BOT_REPORT_PARENT="$(dirname -- "$WOW_BOT_REPORT")" + [ "$(realpath -e -- "$WOW_BOT_REPORT_PARENT" 2>/dev/null)" \ + = "$WOW_BOT_REPORT_PARENT" ] \ + && [ ! -L "$WOW_BOT_REPORT_PARENT" ] || { + echo "error: WOW_BOT_REPORT parent must be canonical and non-symlink" >&2 + exit 2 + } + capture_exec_source_matches "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" || { + echo "error: WOW_BOT_EXEC is not a canonical pinned executable" >&2 + exit 2 + } + validate_fresh_loot_fixture_journal || exit 2 + ;; + *) + echo "error: RUST_CAPTURE_LOOT_FIXTURE_GUARD must be 0 or 1" >&2 + exit 2 + ;; +esac sha256_of_file() { local output digest @@ -48,13 +249,468 @@ sha256_of_file() { printf '%s\n' "$digest" } +apply_loot_fixture_guard() { + [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] || return 0 + + loot_fixture_wait_until_all_characters_offline || return 1 + + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + apply_shared_chest_fixture_guard + return + fi + apply_creature_health_fixture_guard +} + +restore_loot_fixture_guard() { + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + restore_shared_chest_fixture_guard + return + fi + + restore_creature_health_fixture_guard +} + +shared_chest_spawn_exact_count() { + loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + AND id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0" +} + +shared_chest_spawn_cleanup_counts() { + loot_fixture_world_mysql -e \ + "SELECT + COUNT(*), + COALESCE(SUM( + id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0 + ), 0), + (SELECT COUNT(*) FROM pool_members + WHERE type = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM game_event_gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM linked_respawn + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + OR linkedGuid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_addon + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_overrides + WHERE spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM spawn_group + WHERE spawnType = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}) + FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}" +} + +shared_chest_addon_exact_count() { + local min_gold="$1" + local max_gold="$2" + loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template_addon + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = ${min_gold} + AND maxgold = ${max_gold} + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0" +} + +shared_chest_spawn_metadata_counts() { + loot_fixture_world_mysql -e \ + "SELECT + (SELECT COUNT(*) FROM pool_members + WHERE type = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM game_event_gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM linked_respawn + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + OR linkedGuid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_addon + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM gameobject_overrides + WHERE spawnId = ${LOOT_FIXTURE_CHEST_GUID}), + (SELECT COUNT(*) FROM spawn_group + WHERE spawnType = 1 AND spawnId = ${LOOT_FIXTURE_CHEST_GUID})" +} + +apply_shared_chest_fixture_guard() { + local total matching conditions inserted ownership + + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND type = 3 + AND Data1 = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} + AND Data15 = 1")" || return 1 + [ "$matching" = "1" ] || { + echo "error: shared chest template ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} must exist with type=3, Data1=${LOOT_FIXTURE_CHEST_LOOT_ENTRY}, Data15=1" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_loot_template + WHERE Entry = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY}")" || return 1 + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_loot_template + WHERE Entry = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} + AND Item = ${LOOT_FIXTURE_CHEST_ITEM} + AND Reference = 0 + AND Chance = 100 + AND QuestRequired = 0 + AND LootMode = 1 + AND GroupId = 0 + AND MinCount = 1 + AND MaxCount = 1")" || return 1 + [ "$total" = "1" ] && [ "$matching" = "1" ] || { + echo "error: shared chest loot ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} must contain exactly item ${LOOT_FIXTURE_CHEST_ITEM} at 100% with the pinned normal-loot fields" >&2 + return 1 + } + + conditions="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM conditions + WHERE SourceTypeOrReferenceId = 4 + AND SourceGroup = ${LOOT_FIXTURE_CHEST_LOOT_ENTRY}")" || return 1 + [ "$conditions" = "0" ] || { + echo "error: shared chest loot ${LOOT_FIXTURE_CHEST_LOOT_ENTRY} must not have gameobject-loot conditions" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject_template_addon + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}")" || return 1 + matching="$(shared_chest_addon_exact_count 0 0)" || return 1 + [ "$total" = "1" ] && [ "$matching" = "1" ] || { + echo "error: shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} does not match the pinned faction/flags/artkits/effects and 0/0 money contract" >&2 + return 1 + } + + total="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}")" || return 1 + [ "$total" = "0" ] || { + echo "error: refusing to replace pre-existing gameobject guid ${LOOT_FIXTURE_CHEST_GUID}" >&2 + return 1 + } + + # Reject every spawn-scoped row loaded by ObjectMgr for this GameObject. A + # nominally absent `gameobject` row is not safe to claim when orphan addon, + # override, spawn-group, pool, event, or linked-respawn metadata still exists. + ownership="$(shared_chest_spawn_metadata_counts)" || return 1 + [ "$ownership" = $'0\t0\t0\t0\t0\t0' ] || { + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} has spawn-scoped metadata (pool/event/linked/addon/override/spawn-group: ${ownership:-query-failed})" >&2 + return 1 + } + + total="$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" || return 1 + [ "$total" = "0" ] || { + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} already owns ${total} gameobject respawn row(s)" >&2 + return 1 + } + + # The exact world process may create this row after the chest is consumed. + # Absence was proven above; arm its fail-closed cleanup before that process + # can start, even if a later fixture mutation or verification is interrupted. + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=1 + + # Arm restoration before each write so EXIT/HUP/INT/TERM between the write + # and verification still runs a fail-closed cleanup. + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=1 + matching="$(loot_fixture_world_mysql -e \ + "UPDATE gameobject_template_addon + SET mingold = 10, maxgold = 10 + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = 0 + AND maxgold = 0 + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0; + SELECT ROW_COUNT();")" || return 1 + [ "$matching" = "1" ] \ + && [ "$(shared_chest_addon_exact_count 10 10)" = "1" ] || { + echo "error: failed to activate shared chest money fixture" >&2 + return 1 + } + + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=1 + inserted="$(loot_fixture_world_mysql -e \ + "INSERT INTO gameobject + (guid, id, map, zoneId, areaId, spawnDifficulties, phaseUseFlags, + PhaseId, PhaseGroup, terrainSwapMap, position_x, position_y, position_z, + orientation, rotation0, rotation1, rotation2, rotation3, spawntimesecs, + animprogress, state, ScriptName, StringId, VerifiedBuild) + SELECT ${LOOT_FIXTURE_CHEST_GUID}, ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}, + 0, 0, 0, '0', 0, 0, 0, -1, + CAST(-8946.95 AS FLOAT), CAST(-132.493 AS FLOAT), + CAST(83.5312 AS FLOAT), + 0, 0, 0, 0, 0, 300, 255, 1, '', NULL, 0 + WHERE NOT EXISTS + (SELECT 1 FROM gameobject WHERE guid = ${LOOT_FIXTURE_CHEST_GUID}); + SELECT ROW_COUNT();")" || return 1 + [ "$inserted" = "1" ] || { + # A successful zero-row statement proves this invocation did not own the + # row; disarm deletion so cleanup never removes somebody else's spawn. + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + echo "error: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} appeared before fixture insertion" >&2 + return 1 + } + matching="$(shared_chest_spawn_exact_count)" || return 1 + [ "$matching" = "1" ] || { + echo "error: failed to verify exact shared chest spawn ${LOOT_FIXTURE_CHEST_GUID}" >&2 + return 1 + } + + echo "loot fixture: installed shared chest guid ${LOOT_FIXTURE_CHEST_GUID} (item ${LOOT_FIXTURE_CHEST_ITEM}, money 10; restore armed)" +} + +restore_shared_chest_fixture_guard() { + local total matching respawn_time ownership deleted spawn_counts + + # Preflight every wrapper-owned surface before the first cleanup write. In + # particular, do not erase a generated respawn and only afterwards discover + # that the world spawn's persisted `state` or spawn metadata drifted. + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + spawn_counts="$(shared_chest_spawn_cleanup_counts)" || spawn_counts="" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + # The flag is armed before INSERT. A failure between those operations, + # or a successful prior DELETE whose verification query failed, already + # satisfies the exact owned-spawn cleanup postcondition. + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + elif [ "$spawn_counts" != $'1\t1\t0\t0\t0\t0\t0\t0' ]; then + echo "WARNING: shared chest spawn/state/metadata drifted; refusing every cleanup write (${spawn_counts:-query-failed})" >&2 + return 1 + fi + fi + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ] \ + && [ "$(shared_chest_addon_exact_count 0 0 2>/dev/null || true)" != "1" ] \ + && [ "$(shared_chest_addon_exact_count 10 10 2>/dev/null || true)" != "1" ]; then + echo "WARNING: shared chest template addon drifted; refusing every cleanup write" >&2 + return 1 + fi + + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 1 ]; then + total="$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" || total="" + if [ "$total" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + elif [ "$total" = "1" ]; then + respawn_time="$(loot_fixture_character_mysql -e \ + "SELECT respawnTime FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID} + AND mapId = 0 + AND instanceId = 0")" || respawn_time="" + if [[ "$respawn_time" =~ ^[1-9][0-9]*$ ]]; then + deleted="$(loot_fixture_character_mysql -e \ + "DELETE FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID} + AND respawnTime = ${respawn_time} + AND mapId = 0 + AND instanceId = 0; + SELECT ROW_COUNT();")" || deleted="" + else + deleted="" + fi + if [ "$deleted" = "1" ] \ + && [ "$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}")" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + echo "loot fixture: removed generated respawn for shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" + else + echo "WARNING: shared chest respawn ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing a non-exact delete" >&2 + fi + else + echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} has unexpected respawn ownership (${total:-query-failed}); refusing to delete it" >&2 + fi + fi + + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + spawn_counts="$(shared_chest_spawn_cleanup_counts)" || spawn_counts="" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + elif [ "$spawn_counts" = $'1\t1\t0\t0\t0\t0\t0\t0' ]; then + deleted="$(loot_fixture_world_mysql -e \ + "DELETE FROM gameobject + WHERE guid = ${LOOT_FIXTURE_CHEST_GUID} + AND id = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND map = 0 + AND zoneId = 0 + AND areaId = 0 + AND spawnDifficulties = '0' + AND phaseUseFlags = 0 + AND PhaseId = 0 + AND PhaseGroup = 0 + AND terrainSwapMap = -1 + AND position_x = CAST(-8946.95 AS FLOAT) + AND position_y = CAST(-132.493 AS FLOAT) + AND position_z = CAST(83.5312 AS FLOAT) + AND orientation = 0 + AND rotation0 = 0 + AND rotation1 = 0 + AND rotation2 = 0 + AND rotation3 = 0 + AND spawntimesecs = 300 + AND animprogress = 255 + AND state = 1 + AND ScriptName = '' + AND StringId IS NULL + AND VerifiedBuild = 0; + SELECT ROW_COUNT();")" \ + || deleted="" + if [ "$deleted" = "1" ] \ + && [ "$(shared_chest_spawn_cleanup_counts)" \ + = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + echo "loot fixture: removed shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" + else + echo "WARNING: failed to remove shared chest guid ${LOOT_FIXTURE_CHEST_GUID}" >&2 + fi + else + echo "WARNING: shared chest guid ${LOOT_FIXTURE_CHEST_GUID} changed externally; refusing to delete it" >&2 + fi + fi + + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ]; then + matching="$(shared_chest_addon_exact_count 0 0)" || matching="" + if [ "$matching" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + else + matching="$(shared_chest_addon_exact_count 10 10)" || matching="" + if [ "$matching" != "1" ]; then + echo "WARNING: shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} changed externally; refusing to overwrite it" >&2 + else + deleted="$(loot_fixture_world_mysql -e \ + "UPDATE gameobject_template_addon + SET mingold = 0, maxgold = 0 + WHERE entry = ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} + AND faction = ${LOOT_FIXTURE_CHEST_ADDON_FACTION} + AND flags = 0 + AND mingold = 10 + AND maxgold = 10 + AND artkit0 = 0 + AND artkit1 = 0 + AND artkit2 = 0 + AND artkit3 = 0 + AND artkit4 = 0 + AND WorldEffectID = 0 + AND AIAnimKitID = 0; + SELECT ROW_COUNT();")" || deleted="" + if [ "$deleted" = "1" ] \ + && [ "$(shared_chest_addon_exact_count 0 0)" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + echo "loot fixture: restored chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY} mingold/maxgold 0/0" + else + echo "WARNING: failed to restore shared chest addon ${LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY}" >&2 + fi + fi + fi + fi + + # Reconcile each still-armed surface from fresh DB postconditions. This + # self-heals a cleanup write that committed successfully when only its first + # verification query failed, without ever treating a present/drifted row as + # clean. + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 1 ] \ + && [ "$(loot_fixture_character_mysql -e \ + "SELECT COUNT(*) FROM respawn + WHERE type = 1 + AND spawnId = ${LOOT_FIXTURE_CHEST_GUID}" 2>/dev/null || true)" = "0" ]; then + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + fi + if [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 1 ]; then + spawn_counts="$(shared_chest_spawn_cleanup_counts 2>/dev/null || true)" + if [ "$spawn_counts" = $'0\t0\t0\t0\t0\t0\t0\t0' ]; then + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + fi + fi + if [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 1 ] \ + && [ "$(shared_chest_addon_exact_count 0 0 2>/dev/null || true)" = "1" ]; then + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + fi + + # The armed flags are the durable cleanup invariants: each one is cleared + # only after its owned row is absent or its exact original value is restored. + # Derive success from those postconditions instead of a parallel accumulator, + # so the wrapper cannot report failure after every owned surface is clean. + if [ "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" -eq 0 ] \ + && [ "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" -eq 0 ] \ + && [ "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" -eq 0 ]; then + return 0 + fi + + echo "WARNING: shared chest cleanup remains armed (respawn=${LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY}, spawn=${LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY}, addon=${LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY})" >&2 + return 1 +} + capture_exec_source_matches() { [ -n "$CAPTURE_EXEC" ] || return 0 local canonical digest canonical="$(realpath -e -- "$CAPTURE_EXEC" 2>/dev/null)" || return 1 [ "$canonical" = "$CAPTURE_EXEC" ] || return 1 - [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] || return 1 + [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] \ + && [ ! -L "$CAPTURE_EXEC" ] || return 1 digest="$(sha256_of_file "$CAPTURE_EXEC")" || return 1 [ "$digest" = "$CAPTURE_EXEC_SHA256" ] } @@ -63,9 +719,11 @@ capture_process_exec_matches() { local identity="$1" [ -n "$CAPTURE_EXEC" ] || return 0 - local pid proc_exe live_exec source_digest live_digest + local pm2_pid pid proc_exe live_exec source_digest live_digest [[ "$identity" == *$'\t'* ]] || return 1 - pid="${identity%%$'\t'*}" + pm2_pid="${identity%%$'\t'*}" + pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$pid" "$pm2_pid" || return 1 [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 proc_exe="/proc/${pid}/exe" [ -L "$proc_exe" ] || return 1 @@ -100,7 +758,8 @@ if [ -n "$RUST_CAPTURE_EXEC" ]; then echo "error: RUST_CAPTURE_EXEC must already be canonical: ${CAPTURE_EXEC}" >&2 exit 1 } - [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] || { + [ -f "$CAPTURE_EXEC" ] && [ -x "$CAPTURE_EXEC" ] \ + && [ ! -L "$CAPTURE_EXEC" ] || { echo "error: RUST_CAPTURE_EXEC is not an executable regular file" >&2 exit 1 } @@ -121,12 +780,243 @@ if [ -n "$RUST_CAPTURE_EXEC" ]; then echo "error: RUST_CAPTURE_EXEC does not match RUST_CAPTURE_EXEC_SHA256" >&2 exit 1 fi + CAPTURE_EXPECTED_EXEC="$CAPTURE_EXEC" + CAPTURE_EXPECTED_SHA256="$CAPTURE_EXEC_SHA256" + CAPTURE_SOURCE_EXEC="$CAPTURE_EXEC" + CAPTURE_SOURCE_SHA256="$CAPTURE_EXEC_SHA256" elif [ -n "$RUST_CAPTURE_EXEC_SHA256" ]; then echo "error: RUST_CAPTURE_EXEC_SHA256 requires RUST_CAPTURE_EXEC" >&2 exit 1 fi -DUMP_DIR="${REPO_ROOT}/target/captures/${FLOW}/rust" +DUMP_PARENT_DIR="${REPO_ROOT}/target/captures/${FLOW}" +DUMP_DIR="${DUMP_PARENT_DIR}/rust" +capture_require_canonical_directory "${REPO_ROOT}/target/captures" \ + && capture_require_canonical_directory "$DUMP_PARENT_DIR" || { + echo "error: capture output root is not canonical or contains a symlink" >&2 + exit 2 +} +[ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || { + echo "error: raw Rust capture directory already exists; archive/remove it before recording a new atomic generation: ${DUMP_DIR}" >&2 + exit 2 +} + +CAPTURE_HARNESS_REPO_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null)" || { + echo "error: cannot resolve RustyCore harness repository HEAD" >&2 + exit 1 +} +CAPTURE_SOURCE_REPO_HEAD="$CAPTURE_HARNESS_REPO_HEAD" +capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" || { + echo "error: capture evidence requires a clean committed RustyCore harness/source worktree (including untracked files)" >&2 + exit 1 +} +CAPTURE_HARNESS_WORKTREE_SHA256="$( + capture_git_worktree_state_sha256 "$REPO_ROOT" +)" || { + echo "error: cannot fingerprint the RustyCore harness/source worktree" >&2 + exit 1 +} +CAPTURE_EFFECTIVE_CONFIG_PATH="$(realpath -e -- "$RUST_CAPTURE_EFFECTIVE_CONFIG" 2>/dev/null)" || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG does not resolve" >&2 + exit 1 +} +[ "$CAPTURE_EFFECTIVE_CONFIG_PATH" = "$RUST_CAPTURE_EFFECTIVE_CONFIG" ] \ + && [ -f "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] \ + && [ ! -L "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG must be an absolute canonical regular non-symlink file" >&2 + exit 1 +} + +rust_capture_effective_config_sha256() { + capture_effective_config_redacted_sha256 \ + "$CAPTURE_EFFECTIVE_CONFIG_PATH" \ + "capture.world_port=${RUST_WORLD_PORT} +capture.instance_port=${RUST_INSTANCE_PORT} +capture.packet_dump=enabled" \ + PacketLogFile LogsDir Bot.AccountPrefix WorldServerPort InstanceServerPort \ + LoginDatabaseInfo WorldDatabaseInfo CharacterDatabaseInfo \ + Rate.Drop.Item.Poor Rate.Drop.Item.Normal Rate.Drop.Item.Uncommon \ + Rate.Drop.Item.Rare Rate.Drop.Item.Epic Rate.Drop.Item.Legendary \ + Rate.Drop.Item.Artifact Rate.Drop.Item.Referenced Rate.Drop.Money +} + +CAPTURE_EFFECTIVE_CONFIG_SHA256="$(rust_capture_effective_config_sha256)" || { + echo "error: cannot hash the canonical redacted effective Rust capture config" >&2 + exit 1 +} + +rust_capture_tree_digest() { + local directory="$1" + local output digest file_sha path + rust_capture_flat_tree_is_safe "$directory" || return 1 + output="$({ + cd "$directory" || exit 1 + while IFS= read -r -d '' path; do + file_sha="$(sha256_of_file "$path")" || exit 1 + printf '%s\0%s\0' "${path#./}" "$file_sha" + done < <(find . -mindepth 1 -maxdepth 1 -type f -print0 | LC_ALL=C sort -z) + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +rust_capture_flat_tree_is_safe() { + local directory="$1" + local path base + [ -d "$directory" ] && [ ! -L "$directory" ] || return 1 + while IFS= read -r -d '' path; do + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + base="${path##*/}" + case "$base" in + *.bin|*.meta|rust.capture-manifest.json) ;; + race.bot-report.json) + [ "$FLOW" = "loot-two-session-atomic-race" ] || return 1 + ;; + *) return 1 ;; + esac + done < <(find "$directory" -mindepth 1 -maxdepth 1 -print0) +} + +finalize_rust_capture_artifact() { + [ "$CAPTURE_ARTIFACT_READY" -eq 1 ] \ + && [ -d "$DUMP_STAGE_DIR" ] \ + && [ -n "$CAPTURE_LIVE_EXEC" ] \ + && [ -n "$CAPTURE_LIVE_SHA256" ] || return 1 + + local bot_evidence="" capture_evidence created_at manifest packet_count + local retained_bot_report path tree_sha + [ "$CAPTURE_RUNTIME_CLEANUP_VERIFIED" -eq 1 ] \ + && [ "$CAPTURE_NORMAL_RUNTIME_RESTORED" -eq 1 ] || return 1 + capture_fixture_cleanup_verified_for_publication \ + "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" \ + "$CAPTURE_FIXTURE_CLEANUP_VERIFIED" || return 1 + case "$FLOW" in + loot-single-item-claim) + bot_evidence="$(capture_loot_item_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + ;; + loot-two-session-atomic-race) + bot_evidence="$(capture_loot_race_bot_evidence \ + "$WOW_BOT_REPORT" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256")" || return 1 + ;; + esac + if [ -n "$bot_evidence" ]; then + IFS=$'\t' read -r CAPTURE_BOT_EXEC CAPTURE_BOT_EXEC_SHA256 \ + CAPTURE_BOT_REPORT CAPTURE_BOT_REPORT_SHA256 <<<"$bot_evidence" + fi + if [ "$FLOW" = "loot-two-session-atomic-race" ]; then + retained_bot_report="$DUMP_STAGE_DIR/race.bot-report.json" + [ ! -e "$retained_bot_report" ] && [ ! -L "$retained_bot_report" ] \ + && cp --no-clobber -- "$CAPTURE_BOT_REPORT" "$retained_bot_report" \ + && chmod 600 "$retained_bot_report" \ + && [ "$(sha256_of_file "$retained_bot_report")" \ + = "$CAPTURE_BOT_REPORT_SHA256" ] \ + && [ "$(sha256_of_file "$CAPTURE_BOT_REPORT")" \ + = "$CAPTURE_BOT_REPORT_SHA256" ] || return 1 + CAPTURE_BOT_REPORT="$DUMP_DIR/race.bot-report.json" + fi + capture_evidence="$(capture_bot_manifest_evidence \ + "$FLOW" "$CAPTURE_BOT_EXEC" "$CAPTURE_BOT_EXEC_SHA256" \ + "$CAPTURE_BOT_REPORT" "$CAPTURE_BOT_REPORT_SHA256")" || return 1 + tree_sha="$(rust_capture_tree_digest "$DUMP_STAGE_DIR")" || return 1 + packet_count="$(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 \ + -type f -name '*.meta' -print | wc -l)" \ + || return 1 + [[ "$packet_count" =~ ^[0-9]+$ ]] || return 1 + created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" || return 1 + capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CAPTURE_HARNESS_WORKTREE_SHA256" ] || return 1 + manifest="$DUMP_STAGE_DIR/rust.capture-manifest.json" + if ! jq -n \ + --arg flow "$FLOW" \ + --arg created_at "$created_at" \ + --arg harness_repo_head "$CAPTURE_HARNESS_REPO_HEAD" \ + --arg source_repo_head "$CAPTURE_SOURCE_REPO_HEAD" \ + --arg harness_worktree_sha256 "$CAPTURE_HARNESS_WORKTREE_SHA256" \ + --arg expected_exec_path "$CAPTURE_EXPECTED_EXEC" \ + --arg expected_exec_sha256 "$CAPTURE_EXPECTED_SHA256" \ + --arg source_exec_path "$CAPTURE_SOURCE_EXEC" \ + --arg source_exec_sha256 "$CAPTURE_SOURCE_SHA256" \ + --arg live_exec_path "$CAPTURE_LIVE_EXEC" \ + --arg live_exec_sha256 "$CAPTURE_LIVE_SHA256" \ + --arg pm2_exec_path "$CAPTURE_PM2_EXEC_PATH" \ + --arg pm2_exec_sha256 "$CAPTURE_PM2_EXEC_SHA256" \ + --arg pm2_profile_sha256 "$CAPTURE_PM2_PROFILE_SHA256" \ + --arg effective_config_path "$CAPTURE_EFFECTIVE_CONFIG_PATH" \ + --arg effective_config_sha256 "$CAPTURE_EFFECTIVE_CONFIG_SHA256" \ + --arg tree_sha256 "$tree_sha" \ + --argjson capture_evidence "$capture_evidence" \ + --argjson pm2_entry_pid "$CAPTURE_PM2_ENTRY_PID" \ + --argjson pm2_entry_starttime "$CAPTURE_PM2_ENTRY_STARTTIME" \ + --argjson listener_runtime_pid "$CAPTURE_PROCESS_PID" \ + --argjson listener_runtime_starttime "$CAPTURE_LISTENER_STARTTIME" \ + --argjson restart_count "$CAPTURE_RESTART_COUNT" \ + --argjson packet_count "$packet_count" \ + --argjson pinned "$([ -n "$CAPTURE_EXEC" ] && printf true || printf false)" \ + '{ + version: 3, + flow: $flow, + side: "rust", + completed: true, + created_at: $created_at, + harness_repo_head: $harness_repo_head, + source_repo_head: $source_repo_head, + harness_worktree_clean: true, + harness_worktree_state_sha256: $harness_worktree_sha256, + source_worktree_dirty: false, + source_worktree_state_sha256: $harness_worktree_sha256, + worktree_state_algorithm: "git-head-path-mode-content-sha256-v1", + expected_exec_path: $expected_exec_path, + expected_exec_sha256: $expected_exec_sha256, + source_exec_path: $source_exec_path, + source_exec_sha256: $source_exec_sha256, + live_exec_path: $live_exec_path, + live_exec_sha256: $live_exec_sha256, + executable_pin_enforced: $pinned, + pm2_entry_pid: $pm2_entry_pid, + pm2_entry_starttime: $pm2_entry_starttime, + pm2_exec_path: $pm2_exec_path, + pm2_exec_sha256: $pm2_exec_sha256, + pm2_profile_redacted_sha256: $pm2_profile_sha256, + listener_runtime_pid: $listener_runtime_pid, + listener_runtime_starttime: $listener_runtime_starttime, + listener_relationship_verified: true, + restart_count: $restart_count, + effective_config_path: $effective_config_path, + effective_config_redacted_sha256: $effective_config_sha256, + effective_config_algorithm: "capture-relevant-redacted-v1", + runtime_cleanup_verified: true, + normal_runtime_restored: true, + fixture_guard: $capture_evidence.fixture_guard, + bot_report: $capture_evidence.bot_report, + artifact: { + path: "rust", + packet_count: $packet_count, + tree_sha256: $tree_sha256 + } + }' >"$manifest"; then + rm -f -- "$manifest" + return 1 + fi + chmod 600 "$manifest" || return 1 + rust_capture_flat_tree_is_safe "$DUMP_STAGE_DIR" || return 1 + while IFS= read -r -d '' path; do + sync -f "$path" || return 1 + done < <(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 -type f -print0) + sync -f "$DUMP_STAGE_DIR" || return 1 + capture_require_canonical_directory "$DUMP_PARENT_DIR" \ + && [ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || return 1 + capture_publish_noreplace "$DUMP_STAGE_DIR" "$DUMP_DIR" || return 1 + DUMP_STAGE_DIR="" + sync -f "$(dirname -- "$DUMP_DIR")" || return 1 + CAPTURE_ARTIFACT_READY=0 + echo "collected ${packet_count} packets -> ${DUMP_DIR}" + echo "provenance -> ${DUMP_DIR}/rust.capture-manifest.json" + echo "diff against the C++ golden:" + echo " cargo run -p capture-diff -- diff ${FLOW} --rust ${DUMP_DIR}" +} command -v jq >/dev/null 2>&1 || { echo "error: jq is required to snapshot and safely restore the PM2 process" >&2 @@ -140,10 +1030,35 @@ command -v ss >/dev/null 2>&1 || { echo "error: ss is required to verify Rust world listener readiness" >&2 exit 1 } +command -v rg >/dev/null 2>&1 || { + echo "error: rg is required to verify Rust world listener readiness" >&2 + exit 1 +} +for dependency in awk chmod date dirname find flock git id mkdir mktemp mv \ + realpath sed sha256sum sleep sort stat sync wc; do + command -v "$dependency" >/dev/null 2>&1 || { + echo "error: required command not found: $dependency" >&2 + exit 1 + } +done +if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + command -v mysql >/dev/null 2>&1 || { + echo "error: mysql is required by the loot fixture guard" >&2 + exit 1 + } + load_loot_fixture_database_credentials || exit 1 +fi echo "flow : ${FLOW}" echo "dump dir : ${DUMP_DIR}" echo "pm2 world : ${PM2_RUST_WORLD}" +if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + if [ "$LOOT_FIXTURE_KIND" = "shared-chest" ]; then + echo "DB fixture : shared chest guid ${LOOT_FIXTURE_CHEST_GUID}, item ${LOOT_FIXTURE_CHEST_ITEM}, money 10" + else + echo "DB fixture : entry ${LOOT_FIXTURE_ENTRY}, temporary HealthModifier ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}" + fi +fi echo echo "This will restart ${PM2_RUST_WORLD} with RUSTYCORE_PACKET_DUMP_DIR set." @@ -152,6 +1067,15 @@ if [ "$CONFIRM" != "--yes" ]; then [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { echo "aborted"; exit 1; } fi +capture_acquire_orchestration_lock "$CAPTURE_ORCHESTRATION_LOCK" || { + echo "error: another capture/QA process holds ${CAPTURE_ORCHESTRATION_LOCK}" >&2 + exit 1 +} +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} must be one exact stopped PM2 process before Rust capture" >&2 + exit 1 +} + # `pm2 restart --update-env` merges the caller's whole environment and does not # remove a key merely because the caller later unsets it. After confirmation, # snapshot a supported one-process ecosystem entry, then derive a second config @@ -243,17 +1167,127 @@ snapshot_process_identity() { ' } +resolve_rust_profile_effective_config() { + local snapshot_file="$1" + local cwd config_arg="" config_dir_arg="" candidate index + local -a args=() + + cwd="$(jq -er '.apps[0].cwd | select(type == "string" and length > 0)' \ + "$snapshot_file")" || return 1 + jq -e '.apps[0].args | type == "array"' "$snapshot_file" >/dev/null \ + || return 1 + mapfile -t args < <(jq -r '.apps[0].args[]' "$snapshot_file") + for ((index = 0; index < ${#args[@]}; index++)); do + case "${args[index]}" in + -c|--config) + ((index + 1 < ${#args[@]})) || return 1 + config_arg="${args[index + 1]}" + index=$((index + 1)) + ;; + --config=*) config_arg="${args[index]#--config=}" ;; + -cd|--config-dir) + ((index + 1 < ${#args[@]})) || return 1 + config_dir_arg="${args[index + 1]}" + index=$((index + 1)) + ;; + --config-dir=*) config_dir_arg="${args[index]#--config-dir=}" ;; + esac + done + + if [ -z "$config_arg" ]; then + for candidate in worldserver.conf worldserver.conf.dist WorldServer.conf WorldServer.conf.dist; do + if [ -f "$cwd/$candidate" ]; then + config_arg="$cwd/$candidate" + break + fi + done + elif [[ "$config_arg" != /* ]]; then + config_arg="$cwd/$config_arg" + fi + [ -n "$config_arg" ] || return 1 + + if [ -z "$config_dir_arg" ]; then + config_dir_arg="$cwd/worldserver.conf.d" + elif [[ "$config_dir_arg" != /* ]]; then + config_dir_arg="$cwd/$config_dir_arg" + fi + if [ -d "$config_dir_arg" ] \ + && find "$config_dir_arg" -type f -name '*.conf' -print -quit | rg -q .; then + echo "error: guarded capture provenance does not yet support additional config overlays in ${config_dir_arg}" >&2 + return 1 + fi + if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! jq -e ' + (.apps[0].env // {}) | keys + | map(select(startswith("TC_"))) | length == 0 + ' "$snapshot_file" >/dev/null; then + echo "error: guarded capture provenance does not permit unrecorded TC_* config overrides" >&2 + return 1 + fi + realpath -e -- "$config_arg" +} + rust_world_ports_ready() { - ss -H -ltn | rg -q ":${RUST_WORLD_PORT}\\b" \ - && ss -H -ltn | rg -q ":${RUST_INSTANCE_PORT}\\b" + local identity="$1" + local pm2_pid listener_pid + pm2_pid="${identity%%$'\t'*}" + [[ "$pm2_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + listener_pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$listener_pid" "$pm2_pid" \ + && capture_world_ports_owned_by_pid "$listener_pid" +} + +rust_world_ports_absent() { + local sockets + sockets="$(ss -H -ltn)" || return 1 + ! rg -q ":(${RUST_WORLD_PORT}|${RUST_INSTANCE_PORT})\\b" <<<"$sockets" +} + +rust_world_pm2_process_absent() { + pm2 jlist | jq -e --arg name "$PM2_RUST_WORLD" \ + '[.[] | select(.name == $name)] | length == 0' >/dev/null +} + +rust_remove_world_and_verify() { + local process_tree="$1" + local listener_identity="$2" + local current_root="" current_tree="" + + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + || capture_terminate_process_tree "$process_tree" || true + current_root="$(capture_pm2_online_pid "$PM2_RUST_WORLD" 2>/dev/null || true)" + if [ -n "$current_root" ]; then + current_tree="$(capture_process_tree_identity "$current_root" 2>/dev/null || true)" + process_tree="$(printf '%s\n%s\n' "$process_tree" "$current_tree" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + # If PM2 retained an autorestart-capable entry while its old tree was being + # terminated, delete that registration again before accepting absence. + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + || capture_terminate_process_tree "$process_tree" || true + pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1 || true + capture_process_tree_absent "$process_tree" \ + && { [ -z "$listener_identity" ] \ + || capture_pid_identity_absent "$listener_identity"; } \ + && rust_world_pm2_process_absent \ + && rust_world_ports_absent } cleanup() { local capture_status=$? + local restore_status=0 + local current_root="" current_tree="" listener_identity="" + local process_tree_to_stop="$(printf '%s\n%s\n' \ + "$ORIGINAL_PROCESS_TREE_IDENTITIES" "$CAPTURE_PROCESS_TREE_IDENTITIES" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" trap - EXIT trap '' HUP INT TERM if [ "$CAPTURE_MUTATED" -eq 0 ]; then rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + capture_release_orchestration_lock exit "$capture_status" fi echo "recreating ${PM2_RUST_WORLD} without RUSTYCORE_PACKET_DUMP_DIR..." @@ -261,26 +1295,82 @@ cleanup() { if [ "$RESTORE_READY" -ne 1 ]; then echo "WARNING: PM2 restore snapshot is incomplete; inspect PM2 manually" >&2 rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" - exit 1 + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + capture_release_orchestration_lock + exit "$CAPTURE_RESTORE_FAILURE_STATUS" fi unset RUSTYCORE_PACKET_DUMP_DIR - local restore_status=0 - if ! pm2 delete "$PM2_RUST_WORLD" >/dev/null 2>&1; then + if [ -n "$CAPTURE_PM2_ENTRY_PID" ] \ + && [ -n "$CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && capture_pid_identity_is_live \ + "${CAPTURE_PM2_ENTRY_PID}:${CAPTURE_PM2_ENTRY_STARTTIME}"; then + current_root="$CAPTURE_PM2_ENTRY_PID" + else + current_root="$(capture_pm2_online_pid "$PM2_RUST_WORLD" 2>/dev/null || true)" + fi + if [ -n "$current_root" ]; then + current_tree="$(capture_process_tree_identity "$current_root" 2>/dev/null || true)" + process_tree_to_stop="$(printf '%s\n%s\n' \ + "$process_tree_to_stop" "$current_tree" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + if [ -n "$CAPTURE_PROCESS_PID" ] && [ -n "$CAPTURE_LISTENER_STARTTIME" ]; then + listener_identity="${CAPTURE_PROCESS_PID}:${CAPTURE_LISTENER_STARTTIME}" + elif [ -n "$ORIGINAL_LISTENER_PID" ] \ + && [ -n "$ORIGINAL_LISTENER_STARTTIME" ]; then + listener_identity="${ORIGINAL_LISTENER_PID}:${ORIGINAL_LISTENER_STARTTIME}" + else + CAPTURE_PROCESS_PID="$(capture_world_listener_pid 2>/dev/null || true)" + if [ -n "$CAPTURE_PROCESS_PID" ]; then + listener_identity="$(capture_pid_identity "$CAPTURE_PROCESS_PID" 2>/dev/null || true)" + process_tree_to_stop="$(printf '%s\n%s\n' \ + "$process_tree_to_stop" "$listener_identity" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + fi + fi + if ! rust_remove_world_and_verify \ + "$process_tree_to_stop" "$listener_identity"; then + echo "WARNING: capture PM2 entry/listener/descendant tree is still present or serving; refusing fixture restoration while it may still own runtime state" >&2 + restore_status=1 + else + CAPTURE_RUNTIME_CLEANUP_VERIFIED=1 + fi + if [ "$restore_status" -eq 0 ] && ! restore_loot_fixture_guard; then + echo "WARNING: failed to restore the loot fixture; the normal world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && ! loot_fixture_bot_cleanup_safe_for_capture_state \ + "$CAPTURE_BOT_READY"; then + echo "WARNING: bot fixture cleanup is unproven; the normal world will remain stopped" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] \ + && [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ]; then + CAPTURE_FIXTURE_CLEANUP_VERIFIED=1 + fi + if [ "$restore_status" -eq 0 ] \ + && { [ ! -f "$RESTORE_FILE" ] || [ -L "$RESTORE_FILE" ] \ + || [ "$(sha256_of_file "$RESTORE_FILE" 2>/dev/null)" \ + != "$RESTORE_FILE_SHA256" ]; }; then + echo "WARNING: PM2 restore snapshot changed or became unsafe; fixture is clean but the normal world will remain stopped" >&2 restore_status=1 - elif ! env -i \ + fi + if [ "$restore_status" -eq 0 ] && ! env -i \ HOME="$HOME" \ PATH="$PATH" \ PM2_HOME="${PM2_HOME:-$HOME/.pm2}" \ "$PM2_BIN" start "$RESTORE_FILE" --only "$PM2_RUST_WORLD" >/dev/null 2>&1; then restore_status=1 - else + elif [ "$restore_status" -eq 0 ]; then restore_status=1 local last_identity="" local stable_samples=0 local identity="" - for _ in $(seq 1 40); do + local restore_deadline=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) + while ((SECONDS < restore_deadline)); do if identity="$(snapshot_process_identity "$RESTORE_FILE" 2>/dev/null)" \ - && rust_world_ports_ready; then + && rust_world_ports_ready "$identity"; then if [ "$identity" = "$last_identity" ]; then stable_samples=$((stable_samples + 1)) else @@ -289,6 +1379,7 @@ cleanup() { fi if [ "$stable_samples" -ge 4 ]; then restore_status=0 + CAPTURE_NORMAL_RUNTIME_RESTORED=1 break fi else @@ -302,9 +1393,29 @@ cleanup() { echo "WARNING: failed to restore ${PM2_RUST_WORLD} exactly; inspect PM2 before another capture" >&2 echo "WARNING: mode-0600 recovery snapshot retained at ${RESTORE_FILE}" >&2 rm -f "$CAPTURE_CONFIG_FILE" - exit 1 + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + CAPTURE_ARTIFACT_READY=0 + capture_release_orchestration_lock + exit "$CAPTURE_RESTORE_FAILURE_STATUS" fi rm -f "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" + if [ "$RUST_CAPTURE_LOOT_FIXTURE_GUARD" = "1" ] \ + && ! rm -f -- "$LOOT_FIXTURE_CLEANUP_MARKER"; then + echo "WARNING: failed to remove the consumed bot cleanup marker" >&2 + restore_status=1 + fi + if [ "$restore_status" -eq 0 ] && [ "$capture_status" -eq 0 ]; then + if ! finalize_rust_capture_artifact; then + echo "WARNING: capture cleanup succeeded, but atomic dump/manifest publication failed" >&2 + restore_status=1 + fi + fi + if [ "$restore_status" -ne 0 ] || [ "$capture_status" -ne 0 ]; then + [ -z "$DUMP_STAGE_DIR" ] || rm -rf -- "$DUMP_STAGE_DIR" + CAPTURE_ARTIFACT_READY=0 + fi + capture_release_orchestration_lock + [ "$restore_status" -eq 0 ] || exit "$CAPTURE_RESTORE_FAILURE_STATUS" exit "$capture_status" } trap cleanup EXIT @@ -397,8 +1508,29 @@ fi echo "error: failed to create PM2 restore snapshot" >&2 exit 1 } +PROFILE_EFFECTIVE_CONFIG="$(resolve_rust_profile_effective_config "$RESTORE_FILE")" || { + echo "error: cannot resolve the exact effective Rust config from the PM2 profile" >&2 + exit 1 +} +[ "$PROFILE_EFFECTIVE_CONFIG" = "$CAPTURE_EFFECTIVE_CONFIG_PATH" ] || { + echo "error: RUST_CAPTURE_EFFECTIVE_CONFIG does not match the config selected by the PM2 cwd/args" >&2 + exit 1 +} +capture_require_canonical_directory "$DUMP_PARENT_DIR" \ + && [ ! -e "$DUMP_DIR" ] && [ ! -L "$DUMP_DIR" ] || { + echo "error: Rust capture output path changed or became unsafe" >&2 + exit 1 +} +DUMP_STAGE_DIR="$(mktemp -d "${DUMP_DIR}.partial.XXXXXX")" || { + echo "error: failed to create private Rust capture staging directory" >&2 + exit 1 +} +chmod 700 "$DUMP_STAGE_DIR" || { + echo "error: failed to make Rust capture staging directory private" >&2 + exit 1 +} if ! jq \ - --arg dump_dir "$DUMP_DIR" \ + --arg dump_dir "$DUMP_STAGE_DIR" \ --arg capture_exec "$CAPTURE_EXEC" ' .apps[0].env.RUSTYCORE_PACKET_DUMP_DIR = $dump_dir | if $capture_exec == "" then . @@ -412,6 +1544,12 @@ fi echo "error: failed to create PM2 capture snapshot" >&2 exit 1 } +chmod 600 "$RESTORE_FILE" "$CAPTURE_CONFIG_FILE" || { + echo "error: failed to keep PM2 snapshots private" >&2 + exit 1 +} +RESTORE_FILE_SHA256="$(sha256_of_file "$RESTORE_FILE")" || exit 1 +CAPTURE_CONFIG_FILE_SHA256="$(sha256_of_file "$CAPTURE_CONFIG_FILE")" || exit 1 RESTORE_READY=1 # Recheck immediately before touching either server. The earlier validation is @@ -420,17 +1558,73 @@ if ! capture_exec_source_matches; then echo "error: RUST_CAPTURE_EXEC changed or no longer matches its pinned SHA-256" >&2 exit 1 fi +capture_git_repo_clean_at_head "$REPO_ROOT" "$CAPTURE_HARNESS_REPO_HEAD" \ + && [ "$(capture_git_worktree_state_sha256 "$REPO_ROOT")" \ + = "$CAPTURE_HARNESS_WORKTREE_SHA256" ] || { + echo "error: RustyCore harness/source worktree changed before service mutation" >&2 + exit 1 +} +[ "$(sha256_of_file "$RESTORE_FILE")" = "$RESTORE_FILE_SHA256" ] \ + && [ "$(sha256_of_file "$CAPTURE_CONFIG_FILE")" \ + = "$CAPTURE_CONFIG_FILE_SHA256" ] || { + echo "error: PM2 capture/restore snapshot changed before service mutation" >&2 + exit 1 +} -# Make sure the C++ swap server is not holding the ports. -pm2 stop "$PM2_CPP_WORLD" >/dev/null 2>&1 || true - -# Fresh dump directory so the capture only contains this flow. -rm -rf "$DUMP_DIR" -mkdir -p "$DUMP_DIR" +# Revalidate the mutually exclusive C++ service state immediately before the +# first PM2/fixture mutation. Never silently stop an unexpected C++ process. +capture_pm2_process_stopped "$PM2_CPP_WORLD" || { + echo "error: ${PM2_CPP_WORLD} changed state before Rust capture mutation" >&2 + exit 1 +} echo "recreating ${PM2_RUST_WORLD} from the clean snapshot with dump enabled..." +ORIGINAL_SNAPSHOT_IDENTITY="$(snapshot_process_identity "$RESTORE_FILE")" || { + echo "error: original Rust PM2 profile changed before capture mutation" >&2 + exit 1 +} +rust_world_ports_ready "$ORIGINAL_SNAPSHOT_IDENTITY" || { + echo "error: original Rust PM2 profile is not the sole owner of both listeners" >&2 + exit 1 +} +ORIGINAL_PM2_ENTRY_PID="${ORIGINAL_SNAPSHOT_IDENTITY%%$'\t'*}" +ORIGINAL_LISTENER_PID="$(capture_world_listener_pid)" || { + echo "error: cannot discover the original Rust listener PID" >&2 + exit 1 +} +[[ "$ORIGINAL_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$ORIGINAL_LISTENER_PID" =~ ^[1-9][0-9]*$ ]] \ + && capture_pid_is_self_or_descendant \ + "$ORIGINAL_LISTENER_PID" "$ORIGINAL_PM2_ENTRY_PID" || { + echo "error: original Rust PM2 entry/listener relationship is invalid" >&2 + exit 1 +} +ORIGINAL_PM2_ENTRY_STARTTIME="$( + capture_pid_starttime "$ORIGINAL_PM2_ENTRY_PID" +)" || { + echo "error: cannot bind the original PM2 entry PID to its start time" >&2 + exit 1 +} +ORIGINAL_LISTENER_STARTTIME="$( + capture_pid_starttime "$ORIGINAL_LISTENER_PID" +)" || { + echo "error: cannot bind the original listener PID to its start time" >&2 + exit 1 +} +ORIGINAL_PROCESS_TREE_IDENTITIES="$( + capture_process_tree_identity "$ORIGINAL_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit the original Rust process tree" >&2 + exit 1 +} CAPTURE_MUTATED=1 -pm2 delete "$PM2_RUST_WORLD" >/dev/null +rust_remove_world_and_verify \ + "$ORIGINAL_PROCESS_TREE_IDENTITIES" \ + "${ORIGINAL_LISTENER_PID}:${ORIGINAL_LISTENER_STARTTIME}" || { + echo "error: original Rust PM2 entry/listener/descendants did not stop; refusing fixture mutation" >&2 + exit 1 +} +apply_loot_fixture_guard env -i \ HOME="$HOME" \ PATH="$PATH" \ @@ -439,27 +1633,114 @@ env -i \ CAPTURE_READY=0 CAPTURE_IDENTITY="" -for _ in $(seq 1 40); do +CAPTURE_LAST_IDENTITY="" +CAPTURE_STABLE_SAMPLES=0 +CAPTURE_START_DEADLINE=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) +while ((SECONDS < CAPTURE_START_DEADLINE)); do if CAPTURE_IDENTITY="$(snapshot_process_identity "$CAPTURE_CONFIG_FILE" 2>/dev/null)" \ - && rust_world_ports_ready \ + && rust_world_ports_ready "$CAPTURE_IDENTITY" \ && capture_process_exec_matches "$CAPTURE_IDENTITY"; then - CAPTURE_READY=1 - break + if [ "$CAPTURE_IDENTITY" = "$CAPTURE_LAST_IDENTITY" ]; then + CAPTURE_STABLE_SAMPLES=$((CAPTURE_STABLE_SAMPLES + 1)) + else + CAPTURE_LAST_IDENTITY="$CAPTURE_IDENTITY" + CAPTURE_STABLE_SAMPLES=1 + fi + if [ "$CAPTURE_STABLE_SAMPLES" -ge 4 ]; then + CAPTURE_READY=1 + break + fi + else + CAPTURE_LAST_IDENTITY="" + CAPTURE_STABLE_SAMPLES=0 fi - sleep 0.25 + sleep 0.5 done [ "$CAPTURE_READY" -eq 1 ] || { echo "error: ${PM2_RUST_WORLD} did not start online with the pinned capture executable and packet dumping enabled" >&2 exit 1 } +CAPTURE_PM2_ENTRY_PID="${CAPTURE_IDENTITY%%$'\t'*}" +CAPTURE_PROCESS_PID="$(capture_world_listener_pid)" || { + echo "error: cannot discover the unique Rust world/instance listener PID" >&2 + exit 1 +} +[[ "$CAPTURE_PM2_ENTRY_PID" =~ ^[1-9][0-9]*$ \ + && "$CAPTURE_PROCESS_PID" =~ ^[1-9][0-9]*$ ]] \ + && capture_pid_is_self_or_descendant \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_PM2_ENTRY_PID" || { + echo "error: capture PM2 entry/listener process relationship is invalid" >&2 + exit 1 +} +CAPTURE_LIVE_EXEC="$(realpath -e -- "/proc/${CAPTURE_PROCESS_PID}/exe" 2>/dev/null)" \ + || { + echo "error: cannot resolve the live Rust capture executable" >&2 + exit 1 + } +CAPTURE_LIVE_SHA256="$(sha256_of_file "/proc/${CAPTURE_PROCESS_PID}/exe")" || { + echo "error: cannot hash the live Rust capture executable" >&2 + exit 1 +} +capture_live_exec_matches \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_LIVE_EXEC" "$CAPTURE_LIVE_SHA256" || { + echo "error: live Rust capture executable provenance is unstable" >&2 + exit 1 + } +CAPTURE_RESTART_COUNT="${CAPTURE_IDENTITY#*$'\t'}" +[[ "$CAPTURE_RESTART_COUNT" =~ ^[0-9]+$ ]] || { + echo "error: capture PM2 restart count is invalid" >&2 + exit 1 +} +CAPTURE_PM2_METADATA="$( + capture_pm2_entrypoint_identity "$PM2_RUST_WORLD" "$CAPTURE_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit Rust PM2 entrypoint metadata" >&2 + exit 1 +} +IFS=$'\t' read -r _pm2_pid _pm2_restart \ + CAPTURE_PM2_EXEC_PATH CAPTURE_PM2_EXEC_SHA256 <<<"$CAPTURE_PM2_METADATA" +[ "$_pm2_pid" = "$CAPTURE_PM2_ENTRY_PID" ] \ + && [ "$_pm2_restart" = "$CAPTURE_RESTART_COUNT" ] || { + echo "error: Rust PM2 entry PID/restart metadata is inconsistent" >&2 + exit 1 + } +CAPTURE_PM2_ENTRY_STARTTIME="$(capture_pid_starttime "$CAPTURE_PM2_ENTRY_PID")" \ + || { + echo "error: cannot bind Rust PM2 entry PID to its process start time" >&2 + exit 1 + } +CAPTURE_LISTENER_STARTTIME="$(capture_pid_starttime "$CAPTURE_PROCESS_PID")" \ + || { + echo "error: cannot bind Rust listener PID to its process start time" >&2 + exit 1 + } +CAPTURE_PM2_PROFILE_SHA256="$( + capture_pm2_profile_redacted_sha256 "$PM2_RUST_WORLD" +)" || { + echo "error: cannot hash the stable redacted Rust capture PM2 profile" >&2 + exit 1 +} +CAPTURE_PROCESS_TREE_IDENTITIES="$( + capture_process_tree_identity "$CAPTURE_PM2_ENTRY_PID" +)" || { + echo "error: cannot accredit the Rust capture process tree" >&2 + exit 1 +} +if [ -z "$CAPTURE_EXEC" ]; then + CAPTURE_EXPECTED_EXEC="$CAPTURE_LIVE_EXEC" + CAPTURE_EXPECTED_SHA256="$CAPTURE_LIVE_SHA256" + CAPTURE_SOURCE_EXEC="$CAPTURE_LIVE_EXEC" + CAPTURE_SOURCE_SHA256="$CAPTURE_LIVE_SHA256" +fi +CAPTURE_BOT_READY=1 echo echo ">>> Perform the '${FLOW}' flow with the client now." read -r -p ">>> Press ENTER when the flow is complete to finish the capture... " _ FINAL_CAPTURE_IDENTITY="" if ! FINAL_CAPTURE_IDENTITY="$(snapshot_process_identity "$CAPTURE_CONFIG_FILE" 2>/dev/null)" \ - || ! rust_world_ports_ready \ + || ! rust_world_ports_ready "$FINAL_CAPTURE_IDENTITY" \ || ! capture_process_exec_matches "$FINAL_CAPTURE_IDENTITY"; then echo "error: ${PM2_RUST_WORLD} changed configuration, executable provenance, or stopped serving during capture" >&2 exit 1 @@ -468,9 +1749,53 @@ if [ "$FINAL_CAPTURE_IDENTITY" != "$CAPTURE_IDENTITY" ]; then echo "error: ${PM2_RUST_WORLD} restarted during capture; refusing a mixed packet dump" >&2 exit 1 fi +capture_live_exec_matches \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_LIVE_EXEC" "$CAPTURE_LIVE_SHA256" || { + echo "error: ${PM2_RUST_WORLD} executable path/bytes changed during capture" >&2 + exit 1 + } +[ "$(capture_pm2_entrypoint_identity \ + "$PM2_RUST_WORLD" "$CAPTURE_PM2_ENTRY_PID")" \ + = "$CAPTURE_PM2_METADATA" ] \ + && [ "$(capture_world_listener_pid)" = "$CAPTURE_PROCESS_PID" ] \ + && capture_pid_is_self_or_descendant \ + "$CAPTURE_PROCESS_PID" "$CAPTURE_PM2_ENTRY_PID" \ + && [ "$(capture_pid_starttime "$CAPTURE_PM2_ENTRY_PID")" \ + = "$CAPTURE_PM2_ENTRY_STARTTIME" ] \ + && [ "$(capture_pid_starttime "$CAPTURE_PROCESS_PID")" \ + = "$CAPTURE_LISTENER_STARTTIME" ] \ + && [ "$(capture_pm2_profile_redacted_sha256 "$PM2_RUST_WORLD")" \ + = "$CAPTURE_PM2_PROFILE_SHA256" ] || { + echo "error: ${PM2_RUST_WORLD} PM2 entry/listener metadata changed during capture" >&2 + exit 1 + } +[ "$(rust_capture_effective_config_sha256)" = "$CAPTURE_EFFECTIVE_CONFIG_SHA256" ] || { + echo "error: effective Rust capture configuration changed during capture" >&2 + exit 1 +} +[ "$(sha256_of_file "$CAPTURE_CONFIG_FILE")" \ + = "$CAPTURE_CONFIG_FILE_SHA256" ] || { + echo "error: PM2 capture snapshot changed during capture" >&2 + exit 1 +} -COUNT=$(find "$DUMP_DIR" -name '*.meta' | wc -l) -echo "collected ${COUNT} packets -> ${DUMP_DIR}" -echo -echo "diff against the C++ golden:" -echo " cargo run -p capture-diff -- diff ${FLOW} --rust ${DUMP_DIR}" +CURRENT_CAPTURE_TREE="$(capture_process_tree_identity "$CAPTURE_PM2_ENTRY_PID")" || { + echo "error: cannot re-accredit the Rust capture process tree" >&2 + exit 1 +} +CAPTURE_PROCESS_TREE_IDENTITIES="$(printf '%s\n%s\n' \ + "$CAPTURE_PROCESS_TREE_IDENTITIES" "$CURRENT_CAPTURE_TREE" \ + | sed '/^$/d' | LC_ALL=C sort -t: -k1,1n -k2,2n -u)" + +rust_capture_flat_tree_is_safe "$DUMP_STAGE_DIR" || { + echo "error: Rust packet dump contains a symlink, subdirectory, special file, or unexpected filename" >&2 + exit 1 +} +COUNT=$(find "$DUMP_STAGE_DIR" -mindepth 1 -maxdepth 1 \ + -type f -name '*.meta' -print | wc -l) +[[ "$COUNT" =~ ^[0-9]+$ ]] || { + echo "error: failed to count staged Rust packets" >&2 + exit 1 +} +CAPTURE_ARTIFACT_READY=1 +echo "${COUNT} packet(s) staged; dump and provenance publish only after guarded cleanup succeeds" diff --git a/crates/capture-diff/scripts/capture-service-common.sh b/crates/capture-diff/scripts/capture-service-common.sh new file mode 100644 index 000000000..e318e458b --- /dev/null +++ b/crates/capture-diff/scripts/capture-service-common.sh @@ -0,0 +1,926 @@ +#!/usr/bin/env bash +# Source-only PM2/PID/listener accreditation shared by capture wrapper tests. +# Callers define distinct CAPTURE_WORLD_PORT and CAPTURE_INSTANCE_PORT. + +CAPTURE_WORLD_STOP_TIMEOUT_SECONDS="${CAPTURE_WORLD_STOP_TIMEOUT_SECONDS:-30}" +CAPTURE_WORLD_READY_TIMEOUT_SECONDS="${CAPTURE_WORLD_READY_TIMEOUT_SECONDS:-180}" +CAPTURE_WORLD_TIMEOUT_MAX_SECONDS=3600 + +capture_validate_world_timeouts() { + local name value minimum + for name in \ + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS \ + CAPTURE_WORLD_READY_TIMEOUT_SECONDS; do + value="${!name:-}" + minimum=1 + [ "$name" != CAPTURE_WORLD_READY_TIMEOUT_SECONDS ] || minimum=3 + if [[ ! "$value" =~ ^[1-9][0-9]*$ ]] \ + || ((${#value} > 4)) \ + || ((10#$value < minimum)) \ + || ((10#$value > CAPTURE_WORLD_TIMEOUT_MAX_SECONDS)); then + echo "error: ${name} must be an integer from ${minimum} through ${CAPTURE_WORLD_TIMEOUT_MAX_SECONDS}" >&2 + return 1 + fi + done +} + +capture_fixture_cleanup_verified_for_publication() { + local guard_enabled="$1" + local cleanup_verified="$2" + + case "$guard_enabled:$cleanup_verified" in + 0:0|0:1|1:1) return 0 ;; + *) return 1 ;; + esac +} + +capture_pm2_online_pid() { + local process_name="$1" + pm2 jlist | jq -er --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 + and $entries[0].pm2_env.status == "online" + and ($entries[0].pid | type) == "number" + and $entries[0].pid > 0 + then $entries[0].pid + else empty + end + ' +} + +capture_pm2_runtime_metadata() { + local process_name="$1" + local expected_pm2_pid="$2" + pm2 jlist | jq -er --arg name "$process_name" --argjson pid "$expected_pm2_pid" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 + and $entries[0].pm2_env.status == "online" + and $entries[0].pid == $pid + and (($entries[0].pm2_env.restart_time // 0) | type) == "number" + and ($entries[0].pm2_env.restart_time // 0) >= 0 + and ($entries[0].pm2_env.pm_exec_path | type) == "string" + and ($entries[0].pm2_env.pm_exec_path | length) > 0 + then [$entries[0].pid, ($entries[0].pm2_env.restart_time // 0), $entries[0].pm2_env.pm_exec_path] | @tsv + else empty + end + ' +} + +capture_parent_pid() { + local runtime_pid="$1" + local parent_pid + [[ "$runtime_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + parent_pid="$(awk '$1 == "PPid:" { print $2 }' \ + "${CAPTURE_PROC_ROOT:-/proc}/${runtime_pid}/status" 2>/dev/null)" \ + || return 1 + [[ "$parent_pid" =~ ^[0-9]+$ ]] && [ "$parent_pid" != "$runtime_pid" ] \ + || return 1 + printf '%s\n' "$parent_pid" +} + +# PM2's reported PID is the configured entry process. For a direct binary it +# also owns the listeners; for shell wrappers which do not `exec` (the legacy +# C++ profile), the listener is a descendant. Walk the live process tree rather +# than assuming either topology. +capture_pid_is_self_or_descendant() { + local candidate_pid="$1" + local ancestor_pid="$2" + local current_pid="$candidate_pid" + local parent_pid attempt + + [[ "$candidate_pid" =~ ^[1-9][0-9]*$ \ + && "$ancestor_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + for ((attempt = 0; attempt < 64; attempt++)); do + [ "$current_pid" = "$ancestor_pid" ] && return 0 + parent_pid="$(capture_parent_pid "$current_pid")" || return 1 + [ "$parent_pid" != 0 ] || return 1 + current_pid="$parent_pid" + done + return 1 +} + +capture_pm2_process_stopped() { + local process_name="$1" + pm2 jlist | jq -e --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | ($entries | length) == 1 + and $entries[0].pm2_env.status == "stopped" + and (($entries[0].pid // 0) == 0) + ' >/dev/null +} + +capture_world_ports_owned_by_pid() { + local pid="$1" + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + capture_port_owned_exclusively_by_pid "$CAPTURE_WORLD_PORT" "$pid" \ + && capture_port_owned_exclusively_by_pid "$CAPTURE_INSTANCE_PORT" "$pid" +} + +capture_unique_listener_pid_for_port() { + local port="$1" + local sockets socket remaining seen_pid matched_pid listener_pid="" + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [ -n "$sockets" ] || return 1 + + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [[ "$matched_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + if [ -z "$listener_pid" ]; then + listener_pid="$matched_pid" + else + [ "$matched_pid" = "$listener_pid" ] || return 1 + fi + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + [ "$seen_pid" -eq 1 ] || return 1 + done <<<"$sockets" + [ -n "$listener_pid" ] || return 1 + printf '%s\n' "$listener_pid" +} + +capture_world_listener_pid() { + local world_pid instance_pid + world_pid="$(capture_unique_listener_pid_for_port "$CAPTURE_WORLD_PORT")" \ + || return 1 + instance_pid="$(capture_unique_listener_pid_for_port "$CAPTURE_INSTANCE_PORT")" \ + || return 1 + [ "$world_pid" = "$instance_pid" ] || return 1 + printf '%s\n' "$world_pid" +} + +capture_port_owned_exclusively_by_pid() { + local port="$1" + local pid="$2" + local sockets socket remaining seen_pid matched_pid + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [ -n "$sockets" ] || return 1 + + # SO_REUSEPORT can expose more than one listener for the same local port. + # Every row must therefore identify the accredited PID; finding merely one + # matching row would let an additional process share the capture endpoint. + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [ "$matched_pid" = "$pid" ] || return 1 + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + [ "$seen_pid" -eq 1 ] || return 1 + done <<<"$sockets" +} + +capture_sha256_of_file() { + local output digest + output="$(sha256sum <"$1")" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Bind PM2's entry PID to the exact configured entrypoint file. For an +# interpreted wrapper this is the wrapper script from pm_exec_path (not +# /proc//exe, which is the shell interpreter); the listener executable is +# accredited separately through /proc below. +capture_pm2_entrypoint_identity() { + local process_name="$1" + local expected_pm2_pid="$2" + local metadata pm2_pid restart_count configured_path canonical_path digest + + metadata="$(capture_pm2_runtime_metadata "$process_name" "$expected_pm2_pid")" \ + || return 1 + IFS=$'\t' read -r pm2_pid restart_count configured_path <<<"$metadata" + [ "$pm2_pid" = "$expected_pm2_pid" ] \ + && [[ "$restart_count" =~ ^[0-9]+$ ]] || return 1 + canonical_path="$(realpath -e -- "$configured_path" 2>/dev/null)" || return 1 + [ "$canonical_path" = "$configured_path" ] \ + && [ -f "$canonical_path" ] \ + && [ ! -L "$canonical_path" ] || return 1 + digest="$(capture_sha256_of_file "$canonical_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$pm2_pid" "$restart_count" "$canonical_path" "$digest" +} + +# Hash the stable PM2 launch profile without serializing environment values. +# PID/status/restart counters are runtime state and intentionally excluded; the +# executable, cwd, interpreter, argv, and environment key names define how PM2 +# will start the process. Secret values can therefore never enter the digest +# preimage emitted by this helper. +capture_pm2_profile_redacted_sha256() { + local process_name="$1" + local output digest + output="$(pm2 jlist | jq -cSe --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) != 1 then empty else $entries[0] end + | { + name: .name, + pm_exec_path: .pm2_env.pm_exec_path, + pm_cwd: (.pm2_env.pm_cwd // ""), + exec_interpreter: (.pm2_env.exec_interpreter // ""), + args: (.pm2_env.args // []), + env_keys: ((.pm2_env.env // {}) | keys | sort) + } + ' | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Resolve the config actually selected by PM2. Prefer an exact -c/--config +# argument. For the legacy shell entrypoint, accept exactly one absolute +# literal config argument in the wrapper body; variable expansion or multiple +# candidates are ambiguous and fail closed. +_capture_pm2_effective_config_path_untraced() { + local process_name="$1" + local entry_json configured_path configured_cwd configured_exec canonical + + entry_json="$(pm2 jlist | jq -cSe --arg name "$process_name" ' + [.[] | select(.name == $name)] as $entries + | if ($entries | length) == 1 then $entries[0] else empty end + ')" || return 1 + configured_exec="$(jq -er '.pm2_env.pm_exec_path | select(type == "string" and length > 0)' <<<"$entry_json")" \ + || return 1 + configured_cwd="$(jq -er '.pm2_env.pm_cwd // "" | select(type == "string")' <<<"$entry_json")" \ + || return 1 + configured_path="$(jq -er ' + (.pm2_env.args // []) as $args + | if ($args | type) != "array" + or any($args[]; type != "string") + then error("PM2 args must be an array of strings") + else + ([range(0; $args | length) as $i + | if ($args[$i] == "-c" or $args[$i] == "--config") + then if ($i + 1) < ($args | length) + then $args[$i + 1] + else error("config switch has no value") + end + elif ($args[$i] | startswith("--config=")) + then ($args[$i] | sub("^--config="; "")) + else empty end] + | if length == 0 then "__CAPTURE_NO_CONFIG_ARG__" + elif length == 1 and (.[0] | length) > 0 then .[0] + else error("PM2 config selection is empty or ambiguous") end) + end + ' <<<"$entry_json" 2>/dev/null)" || return 1 + + if [ "$configured_path" != "__CAPTURE_NO_CONFIG_ARG__" ]; then + if [[ "$configured_path" != /* ]]; then + [ -n "$configured_cwd" ] || return 1 + configured_path="$configured_cwd/$configured_path" + fi + else + canonical="$(realpath -e -- "$configured_exec" 2>/dev/null)" || return 1 + [ "$canonical" = "$configured_exec" ] \ + && [ -f "$canonical" ] && [ ! -L "$canonical" ] || return 1 + configured_path="$(awk ' + /^[[:space:]]*#/ { next } + { + for (i = 1; i <= NF; i++) { + if ($i == "-c" || $i == "--config") { + if (i == NF) exit 2 + candidate = $(i + 1) + gsub(/^["\047]|["\047]$/, "", candidate) + found[++count] = candidate + } else if ($i ~ /^--config=/) { + candidate = $i + sub(/^--config=/, "", candidate) + gsub(/^["\047]|["\047]$/, "", candidate) + found[++count] = candidate + } + } + } + END { + if (count != 1 || found[1] !~ /^\/[A-Za-z0-9._\/-]+$/) exit 3 + print found[1] + } + ' "$canonical")" || return 1 + fi + + canonical="$(realpath -e -- "$configured_path" 2>/dev/null)" || return 1 + [ "$canonical" = "$configured_path" ] \ + && [ -f "$canonical" ] && [ ! -L "$canonical" ] || return 1 + printf '%s\n' "$canonical" +} + +capture_pm2_effective_config_path() { + local restore_xtrace=0 result status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + result="$(_capture_pm2_effective_config_path_untraced "$@")" + status=$? + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + [ "$status" -eq 0 ] || return "$status" + printf '%s\n' "$result" +} + +capture_git_repo_clean_at_head() { + local repository="$1" + local expected_head="$2" + local current_head status + + current_head="$(git -C "$repository" rev-parse HEAD 2>/dev/null)" || return 1 + [ "$current_head" = "$expected_head" ] || return 1 + status="$(git -C "$repository" status --porcelain=v1 \ + --untracked-files=all --ignore-submodules=none 2>/dev/null)" || return 1 + [ -z "$status" ] +} + +capture_git_repo_is_dirty() { + local repository="$1" + local status + status="$(git -C "$repository" status --porcelain=v1 \ + --untracked-files=all --ignore-submodules=none 2>/dev/null)" || return 1 + [ -n "$status" ] +} + +# Fingerprint the complete non-ignored worktree state without putting file +# contents into a manifest. The canonical stream contains HEAD plus each +# changed/untracked path, file type/mode, and a SHA-256 of its current bytes. +# Ignored runtime configs/credentials are deliberately outside Git's reported +# state. Special files and submodules fail closed rather than receiving an +# ambiguous identity. +capture_git_worktree_state_sha256() { + local repository="$1" + local head output digest path absolute kind mode content_sha + + head="$(git -C "$repository" rev-parse HEAD 2>/dev/null)" || return 1 + output="$({ + printf 'HEAD\0%s\0' "$head" + { + git -C "$repository" diff --name-only -z HEAD -- || exit 1 + git -C "$repository" ls-files --others --exclude-standard -z || exit 1 + } | LC_ALL=C sort -zu | while IFS= read -r -d '' path; do + absolute="$repository/$path" + if [ -L "$absolute" ]; then + kind=L + mode="$(stat -c '%a' -- "$absolute")" || exit 1 + content_sha="$(readlink -- "$absolute" | sha256sum)" || exit 1 + content_sha="${content_sha%% *}" + elif [ -f "$absolute" ]; then + kind=F + mode="$(stat -c '%a' -- "$absolute")" || exit 1 + content_sha="$(capture_sha256_of_file "$absolute")" || exit 1 + elif [ ! -e "$absolute" ]; then + kind=D + mode=- + content_sha=- + else + exit 1 + fi + printf '%s\0%s\0%s\0%s\0' "$kind" "$path" "$mode" "$content_sha" + done + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +# Hash only an explicit ordered set of capture-relevant effective config keys. +# The last assignment wins, matching the server config parser. Values for any +# credential-like key are replaced with the literal ; missing +# keys become . `extra_canonical` contains only caller-owned non-secret +# facts such as listener ports and whether packet dumping is enabled. The +# canonical plaintext is piped directly into sha256sum and is never printed or +# stored, so the digest cannot be used to dictionary-attack DB credentials. +capture_effective_config_redacted_sha256() { + local config_file="$1" + local extra_canonical="$2" + shift 2 + local wanted="$*" + local output digest + + [ -f "$config_file" ] && [ ! -L "$config_file" ] || return 1 + output="$({ + awk -v wanted="$wanted" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + /^[[:space:]]*[#;]/ { next } + { + separator = index($0, "=") + if (separator == 0) next + key = trim(substr($0, 1, separator - 1)) + value = trim(substr($0, separator + 1)) + values[key] = value + } + END { + count = split(wanted, keys, " ") + for (i = 1; i <= count; i++) { + key = keys[i] + if (key == "") continue + if (!(key in values)) value = "" + else if (key ~ /(DatabaseInfo|Password|Secret|Token|PrivateKey|SessionKey)/) + value = "" + else value = values[key] + printf "%s=%s\n", key, value + } + } + ' "$config_file" || exit 1 + printf '%s\n' "$extra_canonical" + } | sha256sum)" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$digest" +} + +capture_exec_source_matches() { + local expected_exec="$1" + local expected_sha="$2" + local canonical digest + + canonical="$(realpath -e -- "$expected_exec" 2>/dev/null)" || return 1 + [ "$canonical" = "$expected_exec" ] || return 1 + [ -f "$expected_exec" ] && [ -x "$expected_exec" ] \ + && [ ! -L "$expected_exec" ] || return 1 + digest="$(capture_sha256_of_file "$expected_exec")" || return 1 + [ "$digest" = "$expected_sha" ] +} + +capture_live_exec_matches() { + local pid="$1" + local expected_exec="$2" + local expected_sha="$3" + local proc_exe live_exec source_sha live_sha + + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + proc_exe="/proc/${pid}/exe" + [ -L "$proc_exe" ] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + [ "$live_exec" = "$expected_exec" ] || return 1 + source_sha="$(capture_sha256_of_file "$expected_exec")" || return 1 + live_sha="$(capture_sha256_of_file "$proc_exe")" || return 1 + [ "$source_sha" = "$expected_sha" ] && [ "$live_sha" = "$expected_sha" ] +} + +capture_acquire_orchestration_lock() { + local lock_dir="$1" + local parent canonical_parent canonical_dir owner mode before after fd_target + local path_after fd_target_after + + [[ "$lock_dir" = /* && "$lock_dir" != *$'\n'* ]] || return 1 + parent="$(dirname -- "$lock_dir")" + canonical_parent="$(realpath -e -- "$parent" 2>/dev/null)" || return 1 + [ "$canonical_parent" = "$parent" ] || return 1 + if [ ! -e "$lock_dir" ] && [ ! -L "$lock_dir" ]; then + mkdir -m 700 -- "$lock_dir" || return 1 + fi + [ -d "$lock_dir" ] && [ ! -L "$lock_dir" ] || return 1 + canonical_dir="$(realpath -e -- "$lock_dir" 2>/dev/null)" || return 1 + [ "$canonical_dir" = "$lock_dir" ] || return 1 + owner="$(stat -c '%u' -- "$lock_dir")" || return 1 + mode="$(stat -c '%a' -- "$lock_dir")" || return 1 + [ "$owner" = "$(id -u)" ] && [ "$mode" = 700 ] || return 1 + before="$(stat -c '%d:%i' -- "$lock_dir")" || return 1 + exec {CAPTURE_ORCHESTRATION_LOCK_FD}<"$lock_dir" || return 1 + fd_target="$(realpath -e -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}" 2>/dev/null)" \ + || return 1 + after="$(stat -Lc '%d:%i' -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}")" \ + || return 1 + [ "$fd_target" = "$lock_dir" ] && [ "$before" = "$after" ] || { + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } + flock -n "$CAPTURE_ORCHESTRATION_LOCK_FD" || { + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } + path_after="$(stat -c '%d:%i' -- "$lock_dir" 2>/dev/null)" || path_after="" + fd_target_after="$(realpath -e -- "/proc/${BASHPID:-$$}/fd/${CAPTURE_ORCHESTRATION_LOCK_FD}" 2>/dev/null)" \ + || fd_target_after="" + [ "$path_after" = "$before" ] && [ "$fd_target_after" = "$lock_dir" ] || { + flock -u "$CAPTURE_ORCHESTRATION_LOCK_FD" 2>/dev/null || true + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- + CAPTURE_ORCHESTRATION_LOCK_FD="" + return 1 + } +} + +capture_require_canonical_directory() { + local directory="$1" + local canonical + [[ "$directory" = /* && "$directory" != *$'\n'* ]] || return 1 + mkdir -p -- "$directory" || return 1 + [ -d "$directory" ] && [ ! -L "$directory" ] || return 1 + canonical="$(realpath -e -- "$directory" 2>/dev/null)" || return 1 + [ "$canonical" = "$directory" ] +} + +capture_publish_noreplace() { + local source="$1" + local target="$2" + local source_identity target_identity + [ -e "$source" ] && [ ! -L "$source" ] || return 1 + [ ! -e "$target" ] && [ ! -L "$target" ] || return 1 + source_identity="$(stat -c '%d:%i' -- "$source")" || return 1 + mv --no-clobber --no-target-directory -- "$source" "$target" || return 1 + [ ! -e "$source" ] && [ ! -L "$source" ] || return 1 + [ -e "$target" ] && [ ! -L "$target" ] || return 1 + target_identity="$(stat -c '%d:%i' -- "$target")" || return 1 + [ "$target_identity" = "$source_identity" ] +} + +capture_loot_item_bot_evidence() { + local report_path="$1" + local bot_exec="$2" + local expected_bot_sha="$3" + local canonical_report canonical_exec report_sha bot_sha account_id + + [[ "$report_path" = /* && "$bot_exec" = /* \ + && "$expected_bot_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + canonical_report="$(realpath -e -- "$report_path" 2>/dev/null)" || return 1 + canonical_exec="$(realpath -e -- "$bot_exec" 2>/dev/null)" || return 1 + [ "$canonical_report" = "$report_path" ] \ + && [ -f "$report_path" ] && [ ! -L "$report_path" ] \ + && [ "$canonical_exec" = "$bot_exec" ] \ + && [ -f "$bot_exec" ] && [ -x "$bot_exec" ] && [ ! -L "$bot_exec" ] \ + || return 1 + bot_sha="$(capture_sha256_of_file "$bot_exec")" || return 1 + [ "$bot_sha" = "$expected_bot_sha" ] || return 1 + account_id="$(jq -er ' + if .loot_item_capture != true + or .loot_race_smoke != false + or (.results | type) != "array" + or (.results | length) != 1 + then empty + else .results[0] + | select( + .account == "TESTBOT2@bot.local" + and .account_id == 9 + and .character_guid == 15 + and .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_target_entry == 21779 + and .loot_race_target_spawn_guid == 1117 + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and .loot_race_item_push_seen == true + and .loot_race_loot_removed_seen == true + and .loot_race_loot_coins == 0 + and .loot_race_coin_removed_seen == false + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 0 + and .loot_race_relog_verified == true + and .loot_race_failure == null) + | .account_id + end + ' "$report_path")" || return 1 + [ "$account_id" = 9 ] || return 1 + report_sha="$(capture_sha256_of_file "$report_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$canonical_exec" "$bot_sha" "$canonical_report" "$report_sha" +} + +capture_loot_race_report_proves_exact_success() { + local report_path="$1" + + jq -e ' + .loot_race_smoke == true + and .loot_item_capture == false + and (.results | type == "array" and length == 2) + and ([.results[] | [.account, .account_id, .character_guid]] | sort + == [["TESTBOT2@bot.local", 9, 15], ["TESTBOT3@bot.local", 10, 16]]) + and all(.results[]; + .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_failure == null + and .loot_race_target_entry == 2846 + and .loot_race_target_spawn_guid == 9106001 + and (.loot_race_target_runtime_counter | type == "number" and . > 0) + and .loot_race_party_confirmed == true + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and (.loot_race_loot_list_id | type == "number" and . >= 0 and . <= 255) + and .loot_race_loot_coins == 10 + and .loot_race_loot_removed_seen == true + and .loot_race_coin_removed_seen == true + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 10 + and .loot_race_relog_verified == true) + and ([.results[].loot_race_target_runtime_counter] | unique | length == 1) + and ([.results[].loot_race_loot_list_id] | unique | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == true)] | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == false)] | length == 1) + and ([.results[].loot_race_money_notify_amount] | sort == [0, 10]) + ' "$report_path" >/dev/null +} + +capture_loot_race_bot_evidence() { + local report_path="$1" + local bot_exec="$2" + local expected_bot_sha="$3" + local canonical_report canonical_exec report_sha bot_sha + + [[ "$report_path" = /* && "$bot_exec" = /* \ + && "$expected_bot_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + canonical_report="$(realpath -e -- "$report_path" 2>/dev/null)" || return 1 + canonical_exec="$(realpath -e -- "$bot_exec" 2>/dev/null)" || return 1 + [ "$canonical_report" = "$report_path" ] \ + && [ -f "$report_path" ] && [ ! -L "$report_path" ] \ + && [ "$canonical_exec" = "$bot_exec" ] \ + && [ -f "$bot_exec" ] && [ -x "$bot_exec" ] && [ ! -L "$bot_exec" ] \ + || return 1 + bot_sha="$(capture_sha256_of_file "$bot_exec")" || return 1 + [ "$bot_sha" = "$expected_bot_sha" ] || return 1 + capture_loot_race_report_proves_exact_success "$report_path" || return 1 + report_sha="$(capture_sha256_of_file "$report_path")" || return 1 + printf '%s\t%s\t%s\t%s\n' \ + "$canonical_exec" "$bot_sha" "$canonical_report" "$report_sha" +} + +capture_bot_manifest_evidence() { + local flow="$1" + local bot_exec="$2" + local bot_exec_sha256="$3" + local bot_report="$4" + local bot_report_sha256="$5" + + case "$flow" in + loot-single-item-claim) + jq -n \ + --arg exec_path "$bot_exec" \ + --arg exec_sha256 "$bot_exec_sha256" \ + --arg report_path "$bot_report" \ + --arg report_sha256 "$bot_report_sha256" ' + { + fixture_guard: { + enabled: true, + contract: "loot-single-item-claim-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + creature_entry: 21779, + creature_spawn_guid: 1117, + item_entry: 30712, + cleanup_verified: true + }, + bot_report: { + contract: "wow-test-bot-loot-item-capture-report-v1", + exec_path: $exec_path, + exec_sha256: $exec_sha256, + report_path: $report_path, + report_sha256: $report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } + } + ' + ;; + loot-two-session-atomic-race) + jq -n \ + --arg exec_path "$bot_exec" \ + --arg exec_sha256 "$bot_exec_sha256" \ + --arg report_path "$bot_report" \ + --arg report_sha256 "$bot_report_sha256" ' + { + fixture_guard: { + enabled: true, + contract: "loot-two-session-atomic-race-fixture-v1", + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + peer_account: "TESTBOT3@bot.local", + peer_account_id: 10, + peer_character_guid: 16, + gameobject_entry: 2846, + gameobject_spawn_guid: 9106001, + item_entry: 38, + cleanup_verified: true + }, + bot_report: { + contract: "wow-test-bot-loot-two-session-atomic-race-report-v1", + exec_path: $exec_path, + exec_sha256: $exec_sha256, + report_path: $report_path, + report_sha256: $report_sha256, + account: "TESTBOT2@bot.local", + account_id: 9, + character_guid: 15, + report_validated: true + } + } + ' + ;; + *) + printf '%s\n' '{"fixture_guard":null,"bot_report":null}' + ;; + esac +} + +capture_release_orchestration_lock() { + if [[ "${CAPTURE_ORCHESTRATION_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then + flock -u "$CAPTURE_ORCHESTRATION_LOCK_FD" 2>/dev/null || true + exec {CAPTURE_ORCHESTRATION_LOCK_FD}>&- || true + fi + CAPTURE_ORCHESTRATION_LOCK_FD="" +} + +capture_world_ports_absent() { + local sockets + sockets="$(ss -H -ltn)" || return 1 + ! rg -q ":(${CAPTURE_WORLD_PORT}|${CAPTURE_INSTANCE_PORT})\\b" <<<"$sockets" +} + +capture_pid_starttime() { + local pid="$1" + local stat_line remainder + local -a fields + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + stat_line="$(<"${CAPTURE_PROC_ROOT:-/proc}/${pid}/stat")" || return 1 + [[ "$stat_line" == *") "* ]] || return 1 + remainder="${stat_line##*) }" + read -r -a fields <<<"$remainder" + # remainder starts at proc field 3; array index 19 is field 22/starttime. + [ "${#fields[@]}" -ge 20 ] || return 1 + [[ "${fields[19]}" =~ ^[1-9][0-9]*$ ]] || return 1 + printf '%s\n' "${fields[19]}" +} + +capture_pid_identity() { + local pid="$1" + local starttime + starttime="$(capture_pid_starttime "$pid")" || return 1 + printf '%s:%s\n' "$pid" "$starttime" +} + +capture_pid_identity_is_live() { + local identity="$1" + local pid expected actual + IFS=: read -r pid expected <<<"$identity" + [[ "$pid" =~ ^[1-9][0-9]*$ && "$expected" =~ ^[1-9][0-9]*$ ]] || return 1 + actual="$(capture_pid_starttime "$pid" 2>/dev/null)" || return 1 + [ "$actual" = "$expected" ] && kill -0 -- "$pid" 2>/dev/null +} + +capture_pid_identity_absent() { + ! capture_pid_identity_is_live "$1" +} + +capture_process_tree_identity() { + local root_pid="$1" + local proc_dir pid parent identity + local -a descendants=("$root_pid") + local changed=1 candidate known + [[ "$root_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + + while ((changed)); do + changed=0 + for proc_dir in "${CAPTURE_PROC_ROOT:-/proc}"/[0-9]*; do + [ -d "$proc_dir" ] || continue + pid="${proc_dir##*/}" + parent="$(capture_parent_pid "$pid" 2>/dev/null)" || continue + candidate=0 + for known in "${descendants[@]}"; do + if [ "$parent" = "$known" ]; then + candidate=1 + break + fi + done + [ "$candidate" -eq 1 ] || continue + candidate=1 + for known in "${descendants[@]}"; do + if [ "$pid" = "$known" ]; then + candidate=0 + break + fi + done + if [ "$candidate" -eq 1 ]; then + descendants+=("$pid") + changed=1 + fi + done + done + + for pid in "${descendants[@]}"; do + identity="$(capture_pid_identity "$pid")" || return 1 + printf '%s\n' "$identity" + done | sort -t: -k1,1n +} + +capture_process_tree_absent() { + local identities="$1" + local identity + while IFS= read -r identity; do + [ -z "$identity" ] && continue + capture_pid_identity_absent "$identity" || return 1 + done <<<"$identities" +} + +capture_terminate_process_tree() { + local identities="$1" + local signal identity pid attempt + for signal in TERM KILL; do + while IFS= read -r identity; do + [ -z "$identity" ] && continue + if capture_pid_identity_is_live "$identity"; then + pid="${identity%%:*}" + kill -s "$signal" -- "$pid" 2>/dev/null || true + fi + done < <(sort -t: -k1,1nr <<<"$identities") + for ((attempt = 0; attempt < 20; attempt++)); do + capture_process_tree_absent "$identities" && return 0 + sleep 0.1 + done + done + capture_process_tree_absent "$identities" +} + +capture_saved_pid_absent() { + local pid="$1" + [ -z "$pid" ] || ! kill -0 -- "$pid" 2>/dev/null +} + +capture_saved_identity_absent() { + local identity="$1" + local pm2_pid listener_pid + if [[ "$identity" == *:* ]]; then + capture_process_tree_absent "$identity" + return + fi + if [[ "$identity" == *$'\t'* ]]; then + IFS=$'\t' read -r pm2_pid listener_pid <<<"$identity" + else + pm2_pid="$identity" + listener_pid="$identity" + fi + capture_saved_pid_absent "$pm2_pid" \ + && { [ "$listener_pid" = "$pm2_pid" ] \ + || capture_saved_pid_absent "$listener_pid"; } +} + +capture_world_stopped_once() { + local process_name="$1" + local saved_identity="$2" + capture_saved_identity_absent "$saved_identity" \ + && capture_pm2_process_stopped "$process_name" \ + && capture_world_ports_absent +} + +capture_world_ready_once() { + local process_name="$1" + local pm2_pid listener_pid + pm2_pid="$(capture_pm2_online_pid "$process_name")" || return 1 + listener_pid="$(capture_world_listener_pid)" || return 1 + capture_pid_is_self_or_descendant "$listener_pid" "$pm2_pid" || return 1 + capture_world_ports_owned_by_pid "$listener_pid" || return 1 + printf '%s\t%s\n' "$pm2_pid" "$listener_pid" +} + +capture_wait_for_world_stopped() { + local process_name="$1" + local saved_pid="$2" + local deadline=$((SECONDS + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS)) + while ((SECONDS < deadline)); do + capture_world_stopped_once "$process_name" "$saved_pid" && return 0 + sleep 0.5 + done + return 1 +} + +capture_wait_for_world_ready() { + local process_name="$1" + local identity="" last_identity="" stable_samples=0 + local deadline=$((SECONDS + CAPTURE_WORLD_READY_TIMEOUT_SECONDS)) + while ((SECONDS < deadline)); do + if identity="$(capture_world_ready_once "$process_name")"; then + if [ "$identity" = "$last_identity" ]; then + stable_samples=$((stable_samples + 1)) + else + last_identity="$identity" + stable_samples=1 + fi + if [ "$stable_samples" -ge 4 ]; then + printf '%s\n' "$identity" + return 0 + fi + else + last_identity="" + stable_samples=0 + fi + sleep 0.5 + done + return 1 +} diff --git a/crates/capture-diff/scripts/loot-fixture-common.sh b/crates/capture-diff/scripts/loot-fixture-common.sh new file mode 100644 index 000000000..95233890e --- /dev/null +++ b/crates/capture-diff/scripts/loot-fixture-common.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env bash +# Shared, source-only guard for the deterministic loot capture fixtures. +# +# Callers own service lifecycle and define these globals before using it: +# LOOT_FIXTURE_DB_CONF +# WOW_BOT_FIXTURE_JOURNAL +# LOOT_FIXTURE_CLEANUP_MARKER +# LOOT_FIXTURE_ENTRY +# LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER +# LOOT_FIXTURE_TEMP_HEALTH_MODIFIER +# LOOT_FIXTURE_SNAPSHOT_READY + +validate_fresh_loot_fixture_journal() { + local journal_parent canonical_parent owner mode + [ -n "$WOW_BOT_FIXTURE_JOURNAL" ] || { + echo "error: guarded loot capture requires WOW_BOT_FIXTURE_JOURNAL" >&2 + return 1 + } + [[ "$WOW_BOT_FIXTURE_JOURNAL" = /* \ + && "$WOW_BOT_FIXTURE_JOURNAL" != *$'\n'* ]] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL must be an absolute single-line path" >&2 + return 1 + } + journal_parent="$(dirname -- "$WOW_BOT_FIXTURE_JOURNAL")" + [ -d "$journal_parent" ] && [ ! -L "$journal_parent" ] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL parent directory does not exist" >&2 + return 1 + } + canonical_parent="$(realpath -e -- "$journal_parent" 2>/dev/null)" \ + || return 1 + owner="$(stat -c '%u' -- "$journal_parent" 2>/dev/null)" || return 1 + mode="$(stat -c '%a' -- "$journal_parent" 2>/dev/null)" || return 1 + [ "$canonical_parent" = "$journal_parent" ] \ + && [ "$owner" = "$(id -u)" ] && [ "$mode" = 700 ] || { + echo "error: WOW_BOT_FIXTURE_JOURNAL parent must be canonical, owned by this uid, mode 0700, and non-symlink" >&2 + return 1 + } + LOOT_FIXTURE_CLEANUP_MARKER="${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete" + [ ! -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + && [ ! -L "$WOW_BOT_FIXTURE_JOURNAL" ] \ + && [ ! -e "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + && [ ! -L "$LOOT_FIXTURE_CLEANUP_MARKER" ] || { + echo "error: fixture journal/cleanup marker already exists; recover or remove it explicitly before capture" >&2 + return 1 + } +} + +read_database_info() { + local key="$1" + local value + value="$({ + awk -F '"' -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { print $2; exit } + ' "$LOOT_FIXTURE_DB_CONF" + })" + [ -n "$value" ] || return 1 + printf '%s\n' "$value" +} + +_load_loot_fixture_database_credentials_untraced() { + local world_info character_info extra + [ -f "$LOOT_FIXTURE_DB_CONF" ] || { + echo "error: loot fixture DB conf not found: ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + world_info="$(read_database_info WorldDatabaseInfo)" || { + echo "error: WorldDatabaseInfo not found in ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + character_info="$(read_database_info CharacterDatabaseInfo)" || { + echo "error: CharacterDatabaseInfo not found in ${LOOT_FIXTURE_DB_CONF}" >&2 + return 1 + } + IFS=';' read -r \ + LOOT_FIXTURE_WORLD_HOST \ + LOOT_FIXTURE_WORLD_PORT \ + LOOT_FIXTURE_WORLD_USER \ + LOOT_FIXTURE_WORLD_PASSWORD \ + LOOT_FIXTURE_WORLD_DATABASE \ + extra <<<"$world_info" + [ -z "${extra:-}" ] || { + echo "error: WorldDatabaseInfo has unsupported extra fields" >&2 + return 1 + } + IFS=';' read -r \ + LOOT_FIXTURE_CHARACTER_HOST \ + LOOT_FIXTURE_CHARACTER_PORT \ + LOOT_FIXTURE_CHARACTER_USER \ + LOOT_FIXTURE_CHARACTER_PASSWORD \ + LOOT_FIXTURE_CHARACTER_DATABASE \ + extra <<<"$character_info" + [ -z "${extra:-}" ] || { + echo "error: CharacterDatabaseInfo has unsupported extra fields" >&2 + return 1 + } + [ -n "$LOOT_FIXTURE_WORLD_HOST" ] \ + && [[ "$LOOT_FIXTURE_WORLD_PORT" =~ ^[1-9][0-9]*$ ]] \ + && [ -n "$LOOT_FIXTURE_WORLD_USER" ] \ + && [ -n "$LOOT_FIXTURE_WORLD_DATABASE" ] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_HOST" ] \ + && [[ "$LOOT_FIXTURE_CHARACTER_PORT" =~ ^[1-9][0-9]*$ ]] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_USER" ] \ + && [ -n "$LOOT_FIXTURE_CHARACTER_DATABASE" ] || { + echo "error: loot fixture DatabaseInfo is incomplete" >&2 + return 1 + } +} + +load_loot_fixture_database_credentials() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if _load_loot_fixture_database_credentials_untraced; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_world_mysql() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if MYSQL_PWD="$LOOT_FIXTURE_WORLD_PASSWORD" mysql \ + --protocol=TCP \ + -h "$LOOT_FIXTURE_WORLD_HOST" \ + -P "$LOOT_FIXTURE_WORLD_PORT" \ + -u "$LOOT_FIXTURE_WORLD_USER" \ + --batch --raw --skip-column-names \ + "$LOOT_FIXTURE_WORLD_DATABASE" "$@"; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_character_mysql() { + local restore_xtrace=0 status + if [[ "$-" == *x* ]]; then + restore_xtrace=1 + set +x + fi + if MYSQL_PWD="$LOOT_FIXTURE_CHARACTER_PASSWORD" mysql \ + --protocol=TCP \ + -h "$LOOT_FIXTURE_CHARACTER_HOST" \ + -P "$LOOT_FIXTURE_CHARACTER_PORT" \ + -u "$LOOT_FIXTURE_CHARACTER_USER" \ + --batch --raw --skip-column-names \ + "$LOOT_FIXTURE_CHARACTER_DATABASE" "$@"; then + status=0 + else + status=$? + fi + if [ "$restore_xtrace" -eq 1 ]; then + set -x + fi + return "$status" +} + +loot_fixture_wait_until_all_characters_offline() { + local attempt online="" + for ((attempt = 0; attempt < 300; attempt++)); do + online="$(loot_fixture_character_mysql \ + -e 'SELECT COUNT(*) FROM characters WHERE online <> 0')" || return 1 + [ "$online" = "0" ] && return 0 + sleep 0.1 + done + echo "error: refusing loot fixture mutation while ${online:-unknown} character(s) remain online" >&2 + return 1 +} + +apply_creature_health_fixture_guard() { + local matching updated + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$matching" = "1" ] || { + echo "error: loot fixture ${LOOT_FIXTURE_ENTRY} is missing or its original HealthModifier is not ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}" >&2 + return 1 + } + + # Arm cleanup before the CAS. If the caller exits after the UPDATE but before + # verification, restoration still inspects the exact temporary value. + LOOT_FIXTURE_SNAPSHOT_READY=1 + updated="$(loot_fixture_world_mysql -e \ + "UPDATE creature_template_difficulty + SET HealthModifier = ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER} + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001; + SELECT ROW_COUNT();")" || return 1 + [ "$updated" = "1" ] || { + echo "error: loot fixture ${LOOT_FIXTURE_ENTRY} changed during activation (ROW_COUNT=${updated:-unknown})" >&2 + return 1 + } + matching="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$matching" = "1" ] || { + echo "error: failed to activate loot fixture ${LOOT_FIXTURE_ENTRY}" >&2 + return 1 + } + echo "loot fixture: entry ${LOOT_FIXTURE_ENTRY} HealthModifier ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER} -> ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER} (restore armed)" +} + +restore_creature_health_fixture_guard() { + [ "$LOOT_FIXTURE_SNAPSHOT_READY" -eq 1 ] || return 0 + + local original_matches temporary_matches updated + original_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + if [ "$original_matches" = "1" ]; then + LOOT_FIXTURE_SNAPSHOT_READY=0 + return 0 + fi + temporary_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$temporary_matches" = "1" ] || { + echo "WARNING: loot fixture ${LOOT_FIXTURE_ENTRY} changed externally; refusing to overwrite it" >&2 + return 1 + } + updated="$(loot_fixture_world_mysql -e \ + "UPDATE creature_template_difficulty + SET HealthModifier = ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER} + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_TEMP_HEALTH_MODIFIER}) < 0.0000001; + SELECT ROW_COUNT();")" || return 1 + [ "$updated" = "1" ] || { + echo "WARNING: loot fixture ${LOOT_FIXTURE_ENTRY} changed during restoration (ROW_COUNT=${updated:-unknown})" >&2 + return 1 + } + original_matches="$(loot_fixture_world_mysql -e \ + "SELECT COUNT(*) FROM creature_template_difficulty + WHERE Entry = ${LOOT_FIXTURE_ENTRY} + AND DifficultyID = 0 + AND ABS(HealthModifier - ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}) < 0.0000001")" \ + || return 1 + [ "$original_matches" = "1" ] || { + echo "WARNING: failed to verify restoration of loot fixture ${LOOT_FIXTURE_ENTRY}" >&2 + return 1 + } + LOOT_FIXTURE_SNAPSHOT_READY=0 + echo "loot fixture: restored entry ${LOOT_FIXTURE_ENTRY} HealthModifier ${LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER}" +} + +loot_fixture_bot_cleanup_complete() { + [ "${LOOT_FIXTURE_GUARD_ENABLED:-0}" = "1" ] || return 0 + + if [ -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -L "$WOW_BOT_FIXTURE_JOURNAL" ]; then + echo "WARNING: bot fixture recovery journal is still pending at ${WOW_BOT_FIXTURE_JOURNAL}; refusing to start the normal PM2 world" >&2 + return 1 + fi + if [ ! -f "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + || [ -L "$LOOT_FIXTURE_CLEANUP_MARKER" ]; then + echo "WARNING: bot cleanup-complete marker is missing or unsafe at ${LOOT_FIXTURE_CLEANUP_MARKER}; refusing to start the normal PM2 world" >&2 + return 1 + fi + if [ "$(stat -c '%a' -- "$LOOT_FIXTURE_CLEANUP_MARKER" 2>/dev/null)" != "600" ] \ + || ! jq -e ' + .version == 1 + and (.cleanup_pid | type == "number" and . > 0) + and (.journal_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + ' "$LOOT_FIXTURE_CLEANUP_MARKER" >/dev/null 2>&1; then + echo "WARNING: bot cleanup-complete marker failed its mode-0600/schema contract; refusing to start the normal PM2 world" >&2 + return 1 + fi +} + +# Before the flow is exposed to the client/bot, absence of both recovery files +# proves that no bot-side mutation needs recovery. Once exposed, require the +# durable cleanup marker contract above. Ambiguous state always fails closed. +loot_fixture_bot_cleanup_safe_for_capture_state() { + local capture_bot_ready="${1:-}" + + [ "${LOOT_FIXTURE_GUARD_ENABLED:-0}" = "1" ] || return 0 + + [ -n "${WOW_BOT_FIXTURE_JOURNAL:-}" ] \ + && [ -n "${LOOT_FIXTURE_CLEANUP_MARKER:-}" ] \ + && [ "$LOOT_FIXTURE_CLEANUP_MARKER" \ + = "${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete" ] || { + echo "WARNING: bot fixture cleanup paths are missing or inconsistent; refusing to start the normal PM2 world" >&2 + return 1 + } + + case "$capture_bot_ready" in + 0) + if [ -e "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -L "$WOW_BOT_FIXTURE_JOURNAL" ] \ + || [ -e "$LOOT_FIXTURE_CLEANUP_MARKER" ] \ + || [ -L "$LOOT_FIXTURE_CLEANUP_MARKER" ]; then + echo "WARNING: bot fixture recovery state appeared before capture readiness; refusing to start the normal PM2 world" >&2 + return 1 + fi + ;; + 1) + loot_fixture_bot_cleanup_complete + ;; + *) + echo "WARNING: invalid capture bot-readiness state; refusing to start the normal PM2 world" >&2 + return 1 + ;; + esac +} diff --git a/crates/capture-diff/src/flow.rs b/crates/capture-diff/src/flow.rs index b3b972264..010a46803 100644 --- a/crates/capture-diff/src/flow.rs +++ b/crates/capture-diff/src/flow.rs @@ -9,6 +9,9 @@ //! flows//rust/ # reference Rust dump (.bin/.meta) //! flows//expected-divergences.json # accepted-divergence baseline //! flows//flow.json # optional: description + directions +//! flows//requirement.json # optional: fail-closed capture contract +//! flows//capture-lineage.json # exact RAW-to-derived hash contract +//! flows//capture-provenance/ # retained exact RAW manifests //! ``` use std::path::{Path, PathBuf}; @@ -16,7 +19,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::Deserialize; -use crate::model::Direction; +use crate::model::{Capture, CapturedPacket, Direction, PacketBoundary}; +use crate::semantic; /// Root of the committed flow fixtures, resolved at compile time so the tool /// and tests work regardless of the current working directory. @@ -37,6 +41,7 @@ pub struct Flow { } #[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] struct FlowConfig { #[serde(default)] description: Option, @@ -44,11 +49,273 @@ struct FlowConfig { directions: Option>, } +/// Whether an operator has attested that reviewable artifacts are installed. +/// +/// `AwaitingRealCaptures` is deliberately not a soft warning: a required flow +/// in this state must fail its preflight gate. The tool validates the declared +/// pair's bytes and shape; it cannot independently prove how those bytes were +/// recorded, so promotion to `Ready` remains an explicit review action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementStatus { + AwaitingRealCaptures, + Ready, +} + +/// Flow-specific semantic contract applied after the exact packet topology. +/// +/// Required captures are acceptance evidence, so an opcode-only contract is +/// insufficient: two equally malformed or unrelated packets would otherwise +/// compare byte-clean. Every required flow must name a reviewed semantic +/// contract explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementSemanticContract { + LootSingleItemClaimV1, +} + +/// One routing/order anchor that must occur in a required capture. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredPacket { + pub direction: Direction, + pub connection_id: u32, + pub opcode: u16, + pub label: String, +} + +impl RequiredPacket { + fn matches(&self, packet: &CapturedPacket) -> bool { + self.direction == packet.direction + && self.connection_id == packet.connection_id + && self.opcode == packet.opcode + } + + fn render(&self) -> String { + format!( + "{} conn={} 0x{:04X} {}", + self.direction, self.connection_id, self.opcode, self.label + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RequirementConfig { + issue: String, + status: RequirementStatus, + description: String, + #[serde(default)] + blocked_reason: Option, + directions: Vec, + import_selection: RequiredImportSelection, + require_each_anchor_exactly_once: bool, + semantic_contract: RequirementSemanticContract, + required_order: Vec, +} + +/// One directional action boundary pinned by a required import contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredImportBoundary { + pub direction: Direction, + pub opcode: u16, +} + +impl From for PacketBoundary { + fn from(value: RequiredImportBoundary) -> Self { + Self { + direction: Some(value.direction), + opcode: value.opcode, + } + } +} + +/// The only filtering contract allowed to derive one required flow. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequiredImportSelection { + pub from_opcode: RequiredImportBoundary, + pub until_opcode: RequiredImportBoundary, + pub ignored_opcodes: Vec, +} + +/// A fail-closed contract for a capture that is required by a milestone PR. +/// +/// `required_order` is the exact complete packet sequence after reviewed +/// ambient filtering. It is not a subsequence: an extra packet, including a +/// duplicate on the wrong direction or socket, invalidates the evidence. +#[derive(Debug, Clone)] +pub struct FlowRequirement { + pub name: String, + pub issue: String, + pub status: RequirementStatus, + pub description: String, + pub blocked_reason: Option, + pub directions: Vec, + pub import_selection: RequiredImportSelection, + pub require_each_anchor_exactly_once: bool, + pub semantic_contract: RequirementSemanticContract, + pub required_order: Vec, + pub directory: PathBuf, +} + +impl FlowRequirement { + /// Refuse to validate a flow until its manifest explicitly says that the + /// real C++ and Rust artifacts have been installed and reviewed. + pub fn require_ready(&self) -> Result<()> { + if self.status == RequirementStatus::Ready { + return Ok(()); + } + + let reason = self + .blocked_reason + .as_deref() + .unwrap_or("the required real capture pair has not been installed"); + bail!( + "required flow '{}' for {} is BLOCKED: {reason} See {}", + self.name, + self.issue, + self.directory.join("README.md").display() + ) + } + + /// Reject any import flags that differ from the reviewed action window. + pub fn validate_import_selection( + &self, + directions: &[Direction], + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: &[PacketBoundary], + strict: bool, + ) -> Result<()> { + let required_from: PacketBoundary = self.import_selection.from_opcode.into(); + let required_until: PacketBoundary = self.import_selection.until_opcode.into(); + let required_ignores = self + .import_selection + .ignored_opcodes + .iter() + .copied() + .map(PacketBoundary::from) + .collect::>(); + if !strict + || directions != self.directions + || from_opcode != Some(required_from) + || until_opcode != Some(required_until) + || ignored_opcodes != required_ignores + { + bail!( + "required flow '{}' import selection differs from its reviewed contract: strict=true, directions {:?}, from {}, until {}, ignores {:?}", + self.name, + self.directions, + required_from, + required_until, + required_ignores + .iter() + .map(ToString::to_string) + .collect::>() + ); + } + Ok(()) + } + + /// Validate the action boundary, connection topology, and C++-anchored + /// packet order of one side of a required capture pair. + pub fn validate_capture_shape(&self, capture: &Capture) -> Result<()> { + if !self.require_each_anchor_exactly_once { + bail!( + "required flow '{}' disables exact packet cardinality", + self.name + ); + } + let Some(first_required) = self.required_order.first() else { + bail!("required flow '{}' has no packet-order contract", self.name); + }; + let last_required = self + .required_order + .last() + .expect("first required packet already proved the list is nonempty"); + let Some(first_actual) = capture.packets.first() else { + bail!("{} is empty", capture.source); + }; + let last_actual = capture + .packets + .last() + .expect("first packet already proved the capture is nonempty"); + + if !first_required.matches(first_actual) { + bail!( + "{} starts with {} conn={} 0x{:04X}; required boundary is {}", + capture.source, + first_actual.direction, + first_actual.connection_id, + first_actual.opcode, + first_required.render() + ); + } + if !last_required.matches(last_actual) { + bail!( + "{} ends with {} conn={} 0x{:04X}; required fence is {}", + capture.source, + last_actual.direction, + last_actual.connection_id, + last_actual.opcode, + last_required.render() + ); + } + + if capture.packets.len() != self.required_order.len() { + bail!( + "{} contains {} packet(s); required flow '{}' permits exactly {}", + capture.source, + capture.packets.len(), + self.name, + self.required_order.len() + ); + } + + for (index, (required, actual)) in + self.required_order.iter().zip(&capture.packets).enumerate() + { + if !required.matches(actual) { + bail!( + "{} packet {index} is {} conn={} 0x{:04X}; required packet is {}", + capture.source, + actual.direction, + actual.connection_id, + actual.opcode, + required.render() + ); + } + } + + Ok(()) + } + + /// Validate both the exact wire topology and the action's correlated + /// payload semantics. + pub fn validate_capture(&self, capture: &Capture) -> Result<()> { + self.validate_capture_shape(capture)?; + match self.semantic_contract { + RequirementSemanticContract::LootSingleItemClaimV1 => { + semantic::validate_loot_single_item_claim_capture(capture) + .map_err(anyhow::Error::msg)?; + } + } + Ok(()) + } +} + /// Load a flow by name from the committed fixtures root. pub fn load_flow(name: &str) -> Result { load_flow_from(&flows_root(), name) } +/// Load a required-flow contract from the committed fixtures root. +pub fn load_requirement(name: &str) -> Result { + load_requirement_from(&flows_root(), name) +} + /// Validate one path-component-safe flow name before joining it under a /// fixture/capture root. This guards every later write or recursive removal /// from absolute paths and `..` traversal. @@ -106,12 +373,156 @@ pub fn load_flow_from(root: &Path, name: &str) -> Result { }) } +/// Load and structurally validate a required-flow contract from an explicit +/// root (the explicit form is used by unit tests). +pub fn load_requirement_from(root: &Path, name: &str) -> Result { + validate_flow_name(name)?; + let directory = root.join(name); + let path = directory.join("requirement.json"); + if !path.is_file() { + bail!( + "flow '{name}' has no required-flow contract at {}", + path.display() + ); + } + + let text = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let config: RequirementConfig = + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; + + if config.issue.trim().is_empty() { + bail!("{} has an empty issue identifier", path.display()); + } + if config.description.trim().is_empty() { + bail!("{} has an empty description", path.display()); + } + if config.directions.is_empty() { + bail!("{} has no comparison directions", path.display()); + } + if config.import_selection.from_opcode.opcode == 0 + || config.import_selection.until_opcode.opcode == 0 + || config + .import_selection + .ignored_opcodes + .iter() + .any(|boundary| boundary.opcode == 0) + { + bail!("{} contains a zero import-selection opcode", path.display()); + } + let import_directions = std::iter::once(config.import_selection.from_opcode.direction) + .chain(std::iter::once( + config.import_selection.until_opcode.direction, + )) + .chain( + config + .import_selection + .ignored_opcodes + .iter() + .map(|boundary| boundary.direction), + ); + if import_directions + .into_iter() + .any(|direction| !config.directions.contains(&direction)) + { + bail!( + "{} import selection uses a direction outside the comparison contract", + path.display() + ); + } + let mut unique_ignores = Vec::new(); + for ignored in &config.import_selection.ignored_opcodes { + if unique_ignores.contains(ignored) { + bail!("{} contains a duplicate ignored opcode", path.display()); + } + unique_ignores.push(*ignored); + } + if config.directions.len() > 2 + || config + .directions + .iter() + .enumerate() + .any(|(index, direction)| config.directions[..index].contains(direction)) + { + bail!("{} has duplicate comparison directions", path.display()); + } + if config.required_order.len() < 2 { + bail!( + "{} must pin at least an action boundary and an end fence", + path.display() + ); + } + if !config.require_each_anchor_exactly_once { + bail!( + "{} must set require_each_anchor_exactly_once=true", + path.display() + ); + } + for packet in &config.required_order { + if packet.opcode == 0 { + bail!("{} contains a zero required opcode", path.display()); + } + if packet.label.trim().is_empty() { + bail!("{} contains an unlabeled required packet", path.display()); + } + if !config.directions.contains(&packet.direction) { + bail!( + "{} requires {} but does not compare that direction", + path.display(), + packet.render() + ); + } + } + match config.status { + RequirementStatus::AwaitingRealCaptures => { + if config + .blocked_reason + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + bail!( + "{} is awaiting real captures but has no blocked_reason", + path.display() + ); + } + } + RequirementStatus::Ready if config.blocked_reason.is_some() => { + bail!( + "{} is ready but still carries blocked_reason; remove stale blocked evidence", + path.display() + ); + } + RequirementStatus::Ready => {} + } + + Ok(FlowRequirement { + name: name.to_string(), + issue: config.issue, + status: config.status, + description: config.description, + blocked_reason: config.blocked_reason, + directions: config.directions, + import_selection: config.import_selection, + require_each_anchor_exactly_once: config.require_each_anchor_exactly_once, + semantic_contract: config.semantic_contract, + required_order: config.required_order, + directory, + }) +} + /// List the known flow names under the committed fixtures root. #[must_use] pub fn list_flows() -> Vec { list_flows_from(&flows_root()) } +/// List committed fail-closed capture contracts, including contracts that are +/// still awaiting real artifacts and therefore are not yet normal flows. +#[must_use] +pub fn list_requirements() -> Vec { + list_requirements_from(&flows_root()) +} + fn list_flows_from(root: &Path) -> Vec { let mut names = Vec::new(); if let Ok(entries) = std::fs::read_dir(root) { @@ -127,10 +538,110 @@ fn list_flows_from(root: &Path) -> Vec { names } +fn list_requirements_from(root: &Path) -> Vec { + let mut names = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + if entry.path().join("requirement.json").is_file() + && let Some(name) = entry.file_name().to_str() + { + names.push(name.to_string()); + } + } + } + names.sort(); + names +} + #[cfg(test)] mod tests { use super::*; + fn packet(direction: Direction, connection_id: u32, opcode: u16) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body: Vec::new(), + } + } + + fn requirement() -> FlowRequirement { + FlowRequirement { + name: "loot-single-item-claim".to_string(), + issue: "#106".to_string(), + status: RequirementStatus::Ready, + description: "test contract".to_string(), + blocked_reason: None, + directions: vec![Direction::S2C, Direction::C2S], + import_selection: RequiredImportSelection { + from_opcode: RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3211, + }, + until_opcode: RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3768, + }, + ignored_opcodes: vec![ + RequiredImportBoundary { + direction: Direction::S2C, + opcode: 0x2DD2, + }, + RequiredImportBoundary { + direction: Direction::C2S, + opcode: 0x3A3D, + }, + RequiredImportBoundary { + direction: Direction::S2C, + opcode: 0x2DD4, + }, + ], + }, + require_each_anchor_exactly_once: true, + semantic_contract: RequirementSemanticContract::LootSingleItemClaimV1, + required_order: vec![ + RequiredPacket { + direction: Direction::C2S, + connection_id: 1, + opcode: 0x3211, + label: "CMSG_LOOT_ITEM".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x27CB, + label: "SMSG_UPDATE_OBJECT item CreateObject".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x2615, + label: "SMSG_LOOT_REMOVED".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 0, + opcode: 0x2623, + label: "SMSG_ITEM_PUSH_RESULT".to_string(), + }, + RequiredPacket { + direction: Direction::S2C, + connection_id: 1, + opcode: 0x27CB, + label: "SMSG_UPDATE_OBJECT InvSlots VALUES".to_string(), + }, + RequiredPacket { + direction: Direction::C2S, + connection_id: 1, + opcode: 0x3768, + label: "CMSG_PING".to_string(), + }, + ], + directory: PathBuf::from("flows/loot-single-item-claim"), + } + } + #[test] fn flow_names_are_single_safe_path_components() { for valid in ["stand-state", "login_3.4.3", "flow.01"] { @@ -151,4 +662,255 @@ mod tests { assert!(validate_flow_name(invalid).is_err(), "accepted {invalid:?}"); } } + + #[test] + fn required_shape_pins_loot_order_routes_and_boundaries() { + let requirement = requirement(); + let capture = Capture::new( + "real capture", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + + requirement.validate_capture_shape(&capture).unwrap(); + + let mut reversed = capture.clone(); + reversed.packets.swap(2, 3); + let error = requirement + .validate_capture_shape(&reversed) + .expect_err("ItemPushResult before LootRemoved must fail C++ order"); + assert!(error.to_string().contains("SMSG_LOOT_REMOVED")); + + let mut wrong_route = capture.clone(); + wrong_route.packets[3].connection_id = 1; + let error = requirement + .validate_capture_shape(&wrong_route) + .expect_err("ItemPushResult on the instance socket must fail"); + assert!(error.to_string().contains("SMSG_ITEM_PUSH_RESULT")); + + let mut duplicate_anchor = capture; + duplicate_anchor + .packets + .insert(4, packet(Direction::S2C, 0, 0x2623)); + let error = requirement + .validate_capture_shape(&duplicate_anchor) + .expect_err("a second item-push anchor must fail single-session provenance"); + assert!(error.to_string().contains("contains 7 packet(s)")); + + let mut wrong_route_duplicate = Capture::new( + "wrong-route duplicate", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + let error = requirement + .validate_capture_shape(&wrong_route_duplicate) + .expect_err("an ItemPushResult duplicate on the wrong socket must fail"); + assert!(error.to_string().contains("contains 7 packet(s)")); + + wrong_route_duplicate.packets.remove(4); + wrong_route_duplicate.packets[1].direction = Direction::C2S; + let error = requirement + .validate_capture_shape(&wrong_route_duplicate) + .expect_err("replacing an exact packet without changing count must fail"); + assert!(error.to_string().contains("packet 1")); + } + + #[test] + fn required_shape_rejects_capture_outside_action_boundaries() { + let requirement = requirement(); + let prefixed = Capture::new( + "prefixed capture", + vec![ + packet(Direction::S2C, 0, 0x2DD2), + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + ], + ); + assert!( + requirement + .validate_capture_shape(&prefixed) + .unwrap_err() + .to_string() + .contains("required boundary") + ); + + let suffixed = Capture::new( + "suffixed capture", + vec![ + packet(Direction::C2S, 1, 0x3211), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::S2C, 1, 0x2615), + packet(Direction::S2C, 0, 0x2623), + packet(Direction::S2C, 1, 0x27CB), + packet(Direction::C2S, 1, 0x3768), + packet(Direction::S2C, 1, 0x304E), + ], + ); + assert!( + requirement + .validate_capture_shape(&suffixed) + .unwrap_err() + .to_string() + .contains("required fence") + ); + } + + #[test] + fn awaiting_required_flow_fails_closed_with_recorded_reason() { + let root = std::env::temp_dir().join(format!( + "capture-diff-required-flow-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("loot-single-item-claim"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("requirement.json"), + r##"{ + "issue": "#106", + "status": "awaiting-real-captures", + "description": "single-session loot claim", + "blocked_reason": "no matched real C++ capture exists", + "directions": ["s2c", "c2s"], + "import_selection": { + "from_opcode": {"direction": "c2s", "opcode": 12817}, + "until_opcode": {"direction": "c2s", "opcode": 14184}, + "ignored_opcodes": [] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "CMSG_LOOT_ITEM"}, + {"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "CMSG_PING"} + ] +}"##, + ) + .unwrap(); + + let requirement = + load_requirement_from(&root, "loot-single-item-claim").expect("valid requirement"); + let error = requirement + .require_ready() + .expect_err("missing real pair must block preflight"); + assert!(error.to_string().contains("BLOCKED")); + assert!(error.to_string().contains("no matched real C++ capture")); + assert!(error.to_string().contains("README.md")); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn required_flow_cannot_omit_or_disable_exact_cardinality() { + let root = std::env::temp_dir().join(format!( + "capture-diff-required-cardinality-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("loot-single-item-claim"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + + let manifest = |cardinality: Option| { + let cardinality = cardinality.map_or_else(String::new, |value| { + format!("\n \"require_each_anchor_exactly_once\": {value},") + }); + format!( + r##"{{ + "issue": "#106", + "status": "ready", + "description": "single-session loot claim", + "directions": ["s2c", "c2s"],{cardinality} + "import_selection": {{ + "from_opcode": {{"direction": "c2s", "opcode": 12817}}, + "until_opcode": {{"direction": "c2s", "opcode": 14184}}, + "ignored_opcodes": [] + }}, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {{"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "CMSG_LOOT_ITEM"}}, + {{"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "CMSG_PING"}} + ] +}}"## + ) + }; + + std::fs::write(directory.join("requirement.json"), manifest(None)).unwrap(); + let missing = load_requirement_from(&root, "loot-single-item-claim") + .expect_err("omitting exact cardinality must fail"); + assert!(missing.to_string().contains("parsing")); + + std::fs::write(directory.join("requirement.json"), manifest(Some(false))).unwrap(); + let disabled = load_requirement_from(&root, "loot-single-item-claim") + .expect_err("disabling exact cardinality must fail"); + assert!( + disabled + .to_string() + .contains("require_each_anchor_exactly_once=true") + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_and_requirement_json_reject_unknown_fields_at_every_contract_layer() { + let root = std::env::temp_dir().join(format!( + "capture-diff-deny-unknown-flow-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("unnamed") + )); + let directory = root.join("strict-schema"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("cpp.pkt"), b"fixture marker").unwrap(); + std::fs::write( + directory.join("flow.json"), + r#"{"description":"schema","directions":["s2c","c2s"],"typo":true}"#, + ) + .unwrap(); + let error = + load_flow_from(&root, "strict-schema").expect_err("unknown flow.json field must fail"); + assert!(error.to_string().contains("parsing")); + + let requirement = r##"{ + "issue": "#106", + "status": "ready", + "description": "strict schema", + "directions": ["s2c", "c2s"], + "import_selection": { + "from_opcode": {"direction": "c2s", "opcode": 12817, "typo": true}, + "until_opcode": {"direction": "c2s", "opcode": 14184}, + "ignored_opcodes": [] + }, + "require_each_anchor_exactly_once": true, + "semantic_contract": "loot-single-item-claim-v1", + "required_order": [ + {"direction": "c2s", "connection_id": 1, "opcode": 12817, "label": "request"}, + {"direction": "c2s", "connection_id": 1, "opcode": 14184, "label": "fence"} + ] +}"##; + std::fs::write(directory.join("requirement.json"), requirement).unwrap(); + let error = load_requirement_from(&root, "strict-schema") + .expect_err("unknown nested requirement field must fail"); + assert!(error.to_string().contains("parsing")); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/capture-diff/src/lib.rs b/crates/capture-diff/src/lib.rs index 62032c028..710a4af5a 100644 --- a/crates/capture-diff/src/lib.rs +++ b/crates/capture-diff/src/lib.rs @@ -1,8 +1,8 @@ //! # capture-diff — the port's acceptance gate (issue `[01]` / #66) //! //! "Done" across the whole port plan means the Rust wire output is byte/opcode -//! clean versus a C++ capture of the same action (STATE.md §5), except for a -//! narrowly reviewed runtime identifier handled by [`semantic`]. This crate is +//! clean versus a C++ capture of the same action (STATE.md §5), except for +//! narrowly reviewed runtime identifiers handled by [`semantic`]. This crate is //! that harness: it parses a C++ **PKT 3.1** capture ([`pkt`]) and a RustyCore //! packet dump ([`rustdump`]), normalizes both to a common model ([`model`]), //! and diffs them opcode-by-opcode ([`diff`]). Flows ([`flow`]) pin a golden @@ -22,6 +22,7 @@ pub mod diff; pub mod flow; +pub mod lineage; pub mod model; pub mod pkt; pub mod rustdump; @@ -31,8 +32,14 @@ pub use diff::{ AlignedOp, BaselineDelta, BodyDiff, ConnectionDiff, DiffCounts, DiffReport, DivergenceKind, DivergenceSignature, OpKind, baseline_delta, }; -pub use flow::{Flow, list_flows, load_flow}; +pub use flow::{ + Flow, FlowRequirement, RequiredImportBoundary, RequiredImportSelection, RequiredPacket, + RequirementSemanticContract, RequirementStatus, list_flows, list_requirements, load_flow, + load_requirement, +}; pub use model::{Capture, CapturedPacket, Direction, PacketBoundary, opcode_name}; pub use semantic::{ - LogXpGainBody, SemanticBodyDiff, SemanticBodySide, StableObjectGuid, decode_log_xp_gain_body, + ExactObjectGuid, InvSlotValue, LogXpGainBody, LootRemovedBody, SemanticBodyDiff, + SemanticBodySide, StableObjectGuid, UpdateObjectInvSlotsBody, decode_log_xp_gain_body, + decode_loot_removed_body, validate_loot_single_item_claim_capture, }; diff --git a/crates/capture-diff/src/lineage.rs b/crates/capture-diff/src/lineage.rs new file mode 100644 index 000000000..7386a9f53 --- /dev/null +++ b/crates/capture-diff/src/lineage.rs @@ -0,0 +1,2486 @@ +//! Fail-closed provenance for capture imports. +//! +//! Capture wrappers publish a raw artifact and a side-specific manifest only +//! after the accredited process is gone and the runtime has been restored. +//! Import validates those manifests against the raw bytes, keeps exact copies +//! beside the committed fixture, and publishes the complete derived flow with +//! one atomic directory exchange. [`verify_required_lineage`] then binds the +//! copied raw manifests to every filtered output used by `verify-required`. + +use std::collections::BTreeMap; +use std::ffi::CString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::model::{Direction, PacketBoundary}; + +/// Completion marker for one fully derived capture flow. +pub const LINEAGE_FILE: &str = "capture-lineage.json"; +/// Exact raw manifests retained with the derived fixture. +pub const RAW_PROVENANCE_DIR: &str = "capture-provenance"; + +const CPP_RAW_MANIFEST_FILE: &str = "cpp.capture-manifest.json"; +const RUST_RAW_MANIFEST_FILE: &str = "rust.capture-manifest.json"; +const CPP_BOT_REPORT_FILE: &str = "cpp.bot-report.json"; +const RUST_BOT_REPORT_FILE: &str = "rust.bot-report.json"; +const SHA256_HEX_LEN: usize = 64; +const LINEAGE_VERSION: u32 = 3; +const RAW_MANIFEST_VERSION: u32 = 3; + +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum RawSide { + Cpp, + Rust, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawArtifact { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + packet_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tree_sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureGuardEvidence { + enabled: bool, + contract: String, + account: String, + account_id: u32, + character_guid: u64, + peer_account: String, + peer_account_id: u32, + peer_character_guid: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + creature_entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + creature_spawn_guid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gameobject_entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + gameobject_spawn_guid: Option, + item_entry: u32, + cleanup_verified: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BotReportEvidence { + contract: String, + exec_path: String, + exec_sha256: String, + report_path: String, + report_sha256: String, + account: String, + account_id: u32, + character_guid: u64, + report_validated: bool, +} + +/// Schema emitted by `capture-cpp.sh` and `capture-rust.sh`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCaptureManifest { + version: u32, + flow: String, + side: RawSide, + completed: bool, + created_at: String, + harness_repo_head: String, + source_repo_head: String, + harness_worktree_clean: bool, + harness_worktree_state_sha256: String, + source_worktree_dirty: bool, + source_worktree_state_sha256: String, + worktree_state_algorithm: String, + expected_exec_path: String, + expected_exec_sha256: String, + source_exec_path: String, + source_exec_sha256: String, + live_exec_path: String, + live_exec_sha256: String, + executable_pin_enforced: bool, + pm2_entry_pid: u32, + pm2_entry_starttime: u64, + pm2_exec_path: String, + pm2_exec_sha256: String, + pm2_profile_redacted_sha256: String, + listener_runtime_pid: u32, + listener_runtime_starttime: u64, + listener_relationship_verified: bool, + restart_count: u64, + effective_config_path: String, + effective_config_redacted_sha256: String, + effective_config_algorithm: String, + runtime_cleanup_verified: bool, + normal_runtime_restored: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + fixture_guard: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bot_report: Option, + artifact: RawArtifact, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ValidatedRawSide { + manifest_bytes: Vec, + manifest_sha256: String, + manifest: RawCaptureManifest, + bot_report_bytes: Option>, +} + +/// A pair of raw manifests whose declared artifacts matched the supplied raw +/// capture bytes at import time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedRawPair { + cpp: ValidatedRawSide, + rust: ValidatedRawSide, +} + +/// One boundary recorded in the derived import contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LineageBoundary { + direction: Option, + opcode: u16, +} + +impl From for LineageBoundary { + fn from(value: PacketBoundary) -> Self { + Self { + direction: value.direction, + opcode: value.opcode, + } + } +} + +/// Exact selection applied symmetrically to the two raw captures. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportSelection { + directions: Vec, + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: Vec, + strict: bool, +} + +impl ImportSelection { + /// Record all flags that can change the derived evidence. + #[must_use] + pub fn new( + directions: Vec, + from_opcode: Option, + until_opcode: Option, + ignored_opcodes: &[PacketBoundary], + strict: bool, + ) -> Self { + Self { + directions, + from_opcode: from_opcode.map(Into::into), + until_opcode: until_opcode.map(Into::into), + ignored_opcodes: ignored_opcodes.iter().copied().map(Into::into).collect(), + strict, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceLineage { + manifest_path: String, + manifest_sha256: String, + raw_artifact_sha256: String, + raw_artifact_size: Option, + raw_packet_count: Option, + harness_repo_head: String, + source_repo_head: String, + harness_worktree_clean: bool, + harness_worktree_state_sha256: String, + source_worktree_dirty: bool, + source_worktree_state_sha256: String, + worktree_state_algorithm: String, + expected_exec_path: String, + expected_exec_sha256: String, + source_exec_path: String, + source_exec_sha256: String, + live_exec_path: String, + live_exec_sha256: String, + executable_pin_enforced: bool, + pm2_entry_pid: u32, + pm2_entry_starttime: u64, + pm2_exec_path: String, + pm2_exec_sha256: String, + pm2_profile_redacted_sha256: String, + listener_runtime_pid: u32, + listener_runtime_starttime: u64, + listener_relationship_verified: bool, + restart_count: u64, + effective_config_path: String, + effective_config_redacted_sha256: String, + effective_config_algorithm: String, + runtime_cleanup_verified: bool, + normal_runtime_restored: bool, + fixture_guard: Option, + bot_report: Option, + retained_bot_report_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourcePairLineage { + cpp: SourceLineage, + rust: SourceLineage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileLineage { + path: String, + size: u64, + sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TreeLineage { + path: String, + file_count: u64, + packet_count: u64, + tree_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DerivedOutputs { + cpp_pkt: FileLineage, + rust: TreeLineage, + expected_divergences: FileLineage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DerivedLineage { + version: u32, + flow: String, + completed: bool, + sources: SourcePairLineage, + selection: ImportSelection, + outputs: DerivedOutputs, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TreeDigest { + sha256: String, + file_count: u64, + packet_count: u64, +} + +/// Validate raw capture manifests and bind them to the exact raw artifacts. +/// Required flows additionally require executable pinning on both sides. +pub fn validate_raw_pair( + flow: &str, + cpp_capture: &Path, + cpp_manifest: &Path, + rust_capture: &Path, + rust_manifest: &Path, + require_pinned_execs: bool, +) -> Result { + let mut cpp = + read_and_validate_raw_manifest(cpp_manifest, flow, RawSide::Cpp, require_pinned_execs) + .with_context(|| format!("validating C++ raw manifest {}", cpp_manifest.display()))?; + let mut rust = + read_and_validate_raw_manifest(rust_manifest, flow, RawSide::Rust, require_pinned_execs) + .with_context(|| format!("validating Rust raw manifest {}", rust_manifest.display()))?; + + validate_cpp_artifact(&cpp.manifest, cpp_capture)?; + validate_rust_artifact(&rust.manifest, rust_capture, Some(rust_manifest))?; + cpp.bot_report_bytes = validate_bot_report_artifact(&cpp.manifest)?; + rust.bot_report_bytes = validate_bot_report_artifact(&rust.manifest)?; + validate_cross_side_identity(flow, &cpp.manifest, &rust.manifest)?; + + Ok(ValidatedRawPair { cpp, rust }) +} + +fn read_and_validate_raw_manifest( + path: &Path, + flow: &str, + side: RawSide, + require_pinned_exec: bool, +) -> Result { + let bytes = read_regular_file(path)?; + let manifest: RawCaptureManifest = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing raw manifest {}", path.display()))?; + validate_raw_manifest_schema(&manifest, flow, side, require_pinned_exec) + .with_context(|| format!("invalid raw manifest {}", path.display()))?; + Ok(ValidatedRawSide { + manifest_sha256: sha256_bytes(&bytes), + manifest_bytes: bytes, + manifest, + bot_report_bytes: None, + }) +} + +fn validate_raw_manifest_schema( + manifest: &RawCaptureManifest, + flow: &str, + side: RawSide, + require_pinned_exec: bool, +) -> Result<()> { + ensure!( + manifest.version == RAW_MANIFEST_VERSION, + "unsupported raw manifest version {}", + manifest.version + ); + ensure!( + manifest.flow == flow, + "flow is {:?}, expected {flow:?}", + manifest.flow + ); + ensure!( + manifest.side == side, + "side is {:?}, expected {side:?}", + manifest.side + ); + ensure!(manifest.completed, "completed must be true"); + validate_utc_timestamp(&manifest.created_at)?; + ensure!( + valid_git_oid(&manifest.harness_repo_head), + "harness_repo_head must be a lowercase 40- or 64-hex object id" + ); + ensure!( + valid_git_oid(&manifest.source_repo_head), + "source_repo_head must be a lowercase 40- or 64-hex object id" + ); + for (label, path) in [ + ("expected_exec_path", manifest.expected_exec_path.as_str()), + ("source_exec_path", manifest.source_exec_path.as_str()), + ("live_exec_path", manifest.live_exec_path.as_str()), + ("pm2_exec_path", manifest.pm2_exec_path.as_str()), + ( + "effective_config_path", + manifest.effective_config_path.as_str(), + ), + ] { + ensure!(Path::new(path).is_absolute(), "{label} must be absolute"); + } + if let Some(bot) = &manifest.bot_report { + ensure!( + Path::new(&bot.exec_path).is_absolute(), + "bot_report.exec_path must be absolute" + ); + ensure!( + Path::new(&bot.report_path).is_absolute(), + "bot_report.report_path must be absolute" + ); + validate_sha256(&bot.exec_sha256, "bot_report.exec_sha256")?; + validate_sha256(&bot.report_sha256, "bot_report.report_sha256")?; + } + validate_sha256(&manifest.expected_exec_sha256, "expected_exec_sha256")?; + validate_sha256(&manifest.source_exec_sha256, "source_exec_sha256")?; + validate_sha256(&manifest.live_exec_sha256, "live_exec_sha256")?; + validate_sha256( + &manifest.harness_worktree_state_sha256, + "harness_worktree_state_sha256", + )?; + validate_sha256( + &manifest.source_worktree_state_sha256, + "source_worktree_state_sha256", + )?; + validate_sha256(&manifest.pm2_exec_sha256, "pm2_exec_sha256")?; + validate_sha256( + &manifest.pm2_profile_redacted_sha256, + "pm2_profile_redacted_sha256", + )?; + validate_sha256( + &manifest.effective_config_redacted_sha256, + "effective_config_redacted_sha256", + )?; + ensure!( + manifest.expected_exec_path == manifest.source_exec_path + && manifest.source_exec_path == manifest.live_exec_path, + "expected/source/live executable paths must be identical after canonicalization" + ); + ensure!( + manifest.expected_exec_sha256 == manifest.source_exec_sha256 + && manifest.source_exec_sha256 == manifest.live_exec_sha256, + "expected/source/live executable SHA-256 values must be identical" + ); + ensure!( + manifest.harness_worktree_clean, + "capture harness worktree must be clean" + ); + ensure!( + manifest.worktree_state_algorithm == "git-head-path-mode-content-sha256-v1", + "unsupported worktree_state_algorithm {:?}", + manifest.worktree_state_algorithm + ); + ensure!(manifest.pm2_entry_pid != 0, "pm2_entry_pid must be nonzero"); + ensure!( + manifest.pm2_entry_starttime != 0, + "pm2_entry_starttime must be nonzero" + ); + ensure!( + manifest.listener_runtime_pid != 0, + "listener_runtime_pid must be nonzero" + ); + ensure!( + manifest.listener_runtime_starttime != 0, + "listener_runtime_starttime must be nonzero" + ); + ensure!( + manifest.listener_relationship_verified, + "PM2 entry/listener self-or-descendant relationship was not verified" + ); + ensure!( + manifest.effective_config_algorithm == "capture-relevant-redacted-v1", + "unsupported effective_config_algorithm {:?}", + manifest.effective_config_algorithm + ); + ensure!( + manifest.runtime_cleanup_verified, + "runtime_cleanup_verified must be true" + ); + ensure!( + manifest.normal_runtime_restored, + "normal_runtime_restored must be true" + ); + if require_pinned_exec { + ensure!( + manifest.executable_pin_enforced, + "required evidence must set executable_pin_enforced=true" + ); + } + match flow { + "loot-single-item-claim" => validate_canonical_loot_identity(manifest)?, + "loot-two-session-atomic-race" => { + validate_canonical_loot_race_identity(manifest)?; + } + _ => {} + } + + match side { + RawSide::Cpp => { + ensure!( + manifest.artifact.path == "cpp.pkt", + "C++ artifact path must be cpp.pkt" + ); + ensure!( + manifest.artifact.size.is_some(), + "C++ artifact size is missing" + ); + validate_sha256( + manifest.artifact.sha256.as_deref().unwrap_or_default(), + "C++ artifact sha256", + )?; + ensure!( + manifest.artifact.packet_count.is_none() && manifest.artifact.tree_sha256.is_none(), + "C++ artifact contains Rust-only fields" + ); + } + RawSide::Rust => { + ensure!( + manifest.harness_repo_head == manifest.source_repo_head, + "Rust harness/source repository HEAD values must match" + ); + ensure!( + !manifest.source_worktree_dirty + && manifest.harness_worktree_state_sha256 + == manifest.source_worktree_state_sha256, + "Rust harness/source worktree must be the same clean state" + ); + ensure!( + manifest.artifact.path == "rust", + "Rust artifact path must be rust" + ); + ensure!( + manifest.artifact.packet_count.is_some(), + "Rust artifact packet_count is missing" + ); + validate_sha256( + manifest.artifact.tree_sha256.as_deref().unwrap_or_default(), + "Rust artifact tree_sha256", + )?; + ensure!( + manifest.artifact.size.is_none() && manifest.artifact.sha256.is_none(), + "Rust artifact contains C++-only fields" + ); + } + } + Ok(()) +} + +fn validate_canonical_loot_identity(manifest: &RawCaptureManifest) -> Result<()> { + let fixture = manifest + .fixture_guard + .as_ref() + .context("loot-single-item-claim requires fixture_guard evidence")?; + ensure!(fixture.enabled, "fixture_guard.enabled must be true"); + ensure!( + fixture.contract == "loot-single-item-claim-fixture-v1", + "unexpected fixture_guard contract" + ); + ensure!( + fixture.account == "TESTBOT2@bot.local" + && fixture.account_id == 9 + && fixture.character_guid == 15 + && fixture.peer_account == "TESTBOT3@bot.local" + && fixture.peer_account_id == 10 + && fixture.peer_character_guid == 16, + "fixture_guard bot identity is not the canonical TESTBOT2/TESTBOT3 fixture" + ); + ensure!( + fixture.creature_entry == Some(21_779) + && fixture.creature_spawn_guid == Some(1_117) + && fixture.gameobject_entry.is_none() + && fixture.gameobject_spawn_guid.is_none() + && fixture.item_entry == 30_712, + "fixture_guard world/item identity is not the canonical Doctor Maleficus fixture" + ); + ensure!( + fixture.cleanup_verified, + "fixture_guard cleanup was not verified" + ); + + let bot = manifest + .bot_report + .as_ref() + .context("loot-single-item-claim requires bot_report evidence")?; + ensure!( + bot.contract == "wow-test-bot-loot-item-capture-report-v1", + "unexpected bot_report contract" + ); + ensure!(bot.report_validated, "bot_report was not validated"); + ensure!( + bot.account == fixture.account + && bot.account_id == fixture.account_id + && bot.character_guid == fixture.character_guid, + "bot_report identity does not match fixture_guard identity" + ); + Ok(()) +} + +fn validate_canonical_loot_race_identity(manifest: &RawCaptureManifest) -> Result<()> { + let fixture = manifest + .fixture_guard + .as_ref() + .context("loot-two-session-atomic-race requires fixture_guard evidence")?; + ensure!(fixture.enabled, "fixture_guard.enabled must be true"); + ensure!( + fixture.contract == "loot-two-session-atomic-race-fixture-v1", + "unexpected fixture_guard contract" + ); + ensure!( + fixture.account == "TESTBOT2@bot.local" + && fixture.account_id == 9 + && fixture.character_guid == 15 + && fixture.peer_account == "TESTBOT3@bot.local" + && fixture.peer_account_id == 10 + && fixture.peer_character_guid == 16, + "fixture_guard bot identity is not the canonical TESTBOT2/TESTBOT3 fixture" + ); + ensure!( + fixture.creature_entry.is_none() + && fixture.creature_spawn_guid.is_none() + && fixture.gameobject_entry == Some(2_846) + && fixture.gameobject_spawn_guid == Some(9_106_001) + && fixture.item_entry == 38, + "fixture_guard world/item identity is not the canonical shared-chest race fixture" + ); + ensure!( + fixture.cleanup_verified, + "fixture_guard cleanup was not verified" + ); + + let bot = manifest + .bot_report + .as_ref() + .context("loot-two-session-atomic-race requires bot_report evidence")?; + ensure!( + bot.contract == "wow-test-bot-loot-two-session-atomic-race-report-v1", + "unexpected bot_report contract" + ); + ensure!(bot.report_validated, "bot_report was not validated"); + ensure!( + bot.account == fixture.account + && bot.account_id == fixture.account_id + && bot.character_guid == fixture.character_guid, + "bot_report identity does not match fixture_guard identity" + ); + Ok(()) +} + +fn validate_cross_side_identity( + flow: &str, + cpp: &RawCaptureManifest, + rust: &RawCaptureManifest, +) -> Result<()> { + ensure!( + cpp.harness_repo_head == rust.harness_repo_head, + "C++ and Rust captures were produced from different harness HEAD values" + ); + ensure!( + cpp.harness_worktree_state_sha256 == rust.harness_worktree_state_sha256, + "C++ and Rust captures were produced from different harness worktree digests" + ); + ensure!( + cpp.worktree_state_algorithm == rust.worktree_state_algorithm, + "C++ and Rust harness digest algorithms differ" + ); + if matches!( + flow, + "loot-single-item-claim" | "loot-two-session-atomic-race" + ) { + ensure!( + cpp.fixture_guard == rust.fixture_guard, + "C++ and Rust guarded-loot fixture identities differ" + ); + let cpp_bot = cpp.bot_report.as_ref().context("C++ bot report missing")?; + let rust_bot = rust + .bot_report + .as_ref() + .context("Rust bot report missing")?; + ensure!( + cpp_bot.contract == rust_bot.contract + && cpp_bot.exec_path == rust_bot.exec_path + && cpp_bot.exec_sha256 == rust_bot.exec_sha256 + && cpp_bot.account == rust_bot.account + && cpp_bot.account_id == rust_bot.account_id + && cpp_bot.character_guid == rust_bot.character_guid, + "C++ and Rust captures used different canonical bot identities" + ); + } + Ok(()) +} + +fn validate_bot_report_artifact(manifest: &RawCaptureManifest) -> Result>> { + let Some(evidence) = &manifest.bot_report else { + return Ok(None); + }; + let bytes = read_regular_file(Path::new(&evidence.report_path)) + .with_context(|| format!("reading bot report evidence {}", evidence.report_path))?; + ensure!( + sha256_bytes(&bytes) == evidence.report_sha256, + "bot report SHA-256 does not match its manifest" + ); + validate_bot_report_json(&bytes, evidence)?; + Ok(Some(bytes)) +} + +fn validate_bot_report_json(bytes: &[u8], evidence: &BotReportEvidence) -> Result<()> { + let report: serde_json::Value = + serde_json::from_slice(bytes).context("parsing bot report evidence")?; + match evidence.contract.as_str() { + "wow-test-bot-loot-item-capture-report-v1" => { + validate_loot_item_bot_report_json(&report, evidence) + } + "wow-test-bot-loot-two-session-atomic-race-report-v1" => { + validate_loot_race_bot_report_json(&report, evidence) + } + contract => bail!("unsupported bot report contract {contract:?}"), + } +} + +fn validate_loot_item_bot_report_json( + report: &serde_json::Value, + evidence: &BotReportEvidence, +) -> Result<()> { + let results = report + .get("results") + .and_then(serde_json::Value::as_array) + .context("bot report results must be an array")?; + ensure!( + report + .get("loot_item_capture") + .and_then(serde_json::Value::as_bool) + == Some(true) + && report + .get("loot_race_smoke") + .and_then(serde_json::Value::as_bool) + == Some(false) + && results.len() == 1, + "bot report is not a single-session loot-item capture" + ); + let result = &results[0]; + let string = |key: &str| result.get(key).and_then(serde_json::Value::as_str); + let u64_value = |key: &str| result.get(key).and_then(serde_json::Value::as_u64); + let boolean = |key: &str| result.get(key).and_then(serde_json::Value::as_bool); + ensure!( + string("account") == Some(evidence.account.as_str()) + && u64_value("account_id") == Some(u64::from(evidence.account_id)) + && u64_value("character_guid") == Some(evidence.character_guid), + "bot report subject does not match manifest identity" + ); + ensure!( + boolean("world_auth") == Some(true) + && boolean("enum_characters") == Some(true) + && boolean("player_login_verified") == Some(true) + && boolean("loot_race_smoke") == Some(true) + && boolean("loot_race_smoke_passed") == Some(true) + && u64_value("loot_race_target_entry") == Some(21_779) + && u64_value("loot_race_target_spawn_guid") == Some(1_117) + && boolean("loot_race_target_discovered") == Some(true) + && boolean("loot_race_loot_opened") == Some(true) + && boolean("loot_race_item_push_seen") == Some(true) + && boolean("loot_race_loot_removed_seen") == Some(true) + && u64_value("loot_race_loot_coins") == Some(0) + && boolean("loot_race_coin_removed_seen") == Some(false) + && u64_value("loot_race_db_item_total") == Some(1) + && u64_value("loot_race_db_money_delta") == Some(0) + && boolean("loot_race_relog_verified") == Some(true) + && result + .get("loot_race_failure") + .is_some_and(serde_json::Value::is_null), + "bot report does not prove the canonical successful loot-item flow" + ); + Ok(()) +} + +fn validate_loot_race_bot_report_json( + report: &serde_json::Value, + evidence: &BotReportEvidence, +) -> Result<()> { + let results = report + .get("results") + .and_then(serde_json::Value::as_array) + .context("bot report results must be an array")?; + ensure!( + report + .get("loot_item_capture") + .and_then(serde_json::Value::as_bool) + == Some(false) + && report + .get("loot_race_smoke") + .and_then(serde_json::Value::as_bool) + == Some(true) + && results.len() == 2, + "bot report is not a two-session loot race capture" + ); + ensure!( + evidence.account == "TESTBOT2@bot.local" + && evidence.account_id == 9 + && evidence.character_guid == 15, + "race bot report manifest identity is not canonical TESTBOT2" + ); + + let mut by_account = BTreeMap::new(); + let mut runtime_counters = Vec::with_capacity(2); + let mut loot_list_ids = Vec::with_capacity(2); + let mut item_pushes = Vec::with_capacity(2); + let mut money_notifications = Vec::with_capacity(2); + for result in results { + let string = |key: &str| result.get(key).and_then(serde_json::Value::as_str); + let u64_value = |key: &str| result.get(key).and_then(serde_json::Value::as_u64); + let boolean = |key: &str| result.get(key).and_then(serde_json::Value::as_bool); + let account = string("account").context("race result account must be a string")?; + ensure!( + by_account.insert(account, result).is_none(), + "race bot report contains a duplicate account" + ); + let (account_id, character_guid) = match account { + "TESTBOT2@bot.local" => (9, 15), + "TESTBOT3@bot.local" => (10, 16), + _ => bail!("race bot report contains unexpected account {account:?}"), + }; + ensure!( + u64_value("account_id") == Some(account_id) + && u64_value("character_guid") == Some(character_guid), + "race bot report account identity does not match the canonical fixture" + ); + ensure!( + boolean("world_auth") == Some(true) + && boolean("enum_characters") == Some(true) + && boolean("player_login_verified") == Some(true) + && boolean("loot_race_smoke") == Some(true) + && boolean("loot_race_smoke_passed") == Some(true) + && result + .get("loot_race_failure") + .is_some_and(serde_json::Value::is_null) + && u64_value("loot_race_target_entry") == Some(2_846) + && u64_value("loot_race_target_spawn_guid") == Some(9_106_001) + && u64_value("loot_race_target_runtime_counter").is_some_and(|value| value > 0) + && boolean("loot_race_party_confirmed") == Some(true) + && boolean("loot_race_target_discovered") == Some(true) + && boolean("loot_race_loot_opened") == Some(true) + && u64_value("loot_race_loot_list_id").is_some_and(|value| value <= 255) + && u64_value("loot_race_loot_coins") == Some(10) + && boolean("loot_race_loot_removed_seen") == Some(true) + && boolean("loot_race_coin_removed_seen") == Some(true) + && u64_value("loot_race_db_item_total") == Some(1) + && u64_value("loot_race_db_money_delta") == Some(10) + && boolean("loot_race_relog_verified") == Some(true), + "bot report does not prove the exact successful two-session loot race" + ); + runtime_counters.push(u64_value("loot_race_target_runtime_counter").unwrap()); + loot_list_ids.push(u64_value("loot_race_loot_list_id").unwrap()); + item_pushes.push( + boolean("loot_race_item_push_seen") + .context("race result item-push observation must be boolean")?, + ); + money_notifications.push( + u64_value("loot_race_money_notify_amount") + .context("race result money notification must be unsigned")?, + ); + } + ensure!( + by_account.len() == 2 + && by_account.contains_key("TESTBOT2@bot.local") + && by_account.contains_key("TESTBOT3@bot.local"), + "race bot report does not contain the exact two canonical accounts" + ); + runtime_counters.sort_unstable(); + runtime_counters.dedup(); + loot_list_ids.sort_unstable(); + loot_list_ids.dedup(); + item_pushes.sort_unstable(); + money_notifications.sort_unstable(); + ensure!( + runtime_counters.len() == 1 + && loot_list_ids.len() == 1 + && item_pushes == [false, true] + && money_notifications == [0, 10], + "race bot report does not prove one shared target/list, one item winner, and 0/10 money fanout" + ); + Ok(()) +} + +fn validate_cpp_artifact(manifest: &RawCaptureManifest, path: &Path) -> Result<()> { + let bytes = read_regular_file(path) + .with_context(|| format!("reading raw C++ capture {}", path.display()))?; + let actual_size = u64::try_from(bytes.len()).context("C++ capture size does not fit u64")?; + ensure!( + manifest.artifact.size == Some(actual_size), + "raw C++ capture size is {actual_size}, manifest declares {:?}", + manifest.artifact.size + ); + ensure!( + manifest.artifact.sha256.as_deref() == Some(sha256_bytes(&bytes).as_str()), + "raw C++ capture SHA-256 does not match its manifest" + ); + Ok(()) +} + +fn validate_rust_artifact( + manifest: &RawCaptureManifest, + path: &Path, + excluded_manifest: Option<&Path>, +) -> Result<()> { + let digest = digest_tree(path, excluded_manifest) + .with_context(|| format!("hashing raw Rust capture {}", path.display()))?; + ensure!( + manifest.artifact.tree_sha256.as_deref() == Some(digest.sha256.as_str()), + "raw Rust capture tree SHA-256 does not match its manifest" + ); + ensure!( + manifest.artifact.packet_count == Some(digest.packet_count), + "raw Rust capture contains {} packet(s), manifest declares {:?}", + digest.packet_count, + manifest.artifact.packet_count + ); + Ok(()) +} + +/// Copy exact raw manifests and write a derived lineage completion marker into +/// an otherwise complete staging flow. +pub fn write_derived_lineage( + flow: &str, + flow_dir: &Path, + raw: &ValidatedRawPair, + selection: ImportSelection, +) -> Result<()> { + let provenance_dir = flow_dir.join(RAW_PROVENANCE_DIR); + fs::create_dir_all(&provenance_dir) + .with_context(|| format!("creating {}", provenance_dir.display()))?; + write_synced_file( + &provenance_dir.join(CPP_RAW_MANIFEST_FILE), + &raw.cpp.manifest_bytes, + )?; + write_synced_file( + &provenance_dir.join(RUST_RAW_MANIFEST_FILE), + &raw.rust.manifest_bytes, + )?; + if let Some(bytes) = &raw.cpp.bot_report_bytes { + write_synced_file(&provenance_dir.join(CPP_BOT_REPORT_FILE), bytes)?; + } + if let Some(bytes) = &raw.rust.bot_report_bytes { + write_synced_file(&provenance_dir.join(RUST_BOT_REPORT_FILE), bytes)?; + } + + let lineage = build_lineage(flow, flow_dir, raw, selection)?; + let bytes = serde_json::to_vec_pretty(&lineage).context("serializing derived lineage")?; + atomic_write(&flow_dir.join(LINEAGE_FILE), &bytes)?; + sync_directory(&provenance_dir)?; + sync_directory(flow_dir)?; + Ok(()) +} + +fn build_lineage( + flow: &str, + flow_dir: &Path, + raw: &ValidatedRawPair, + selection: ImportSelection, +) -> Result { + let cpp_output = file_lineage(flow_dir, "cpp.pkt")?; + let expected_output = file_lineage(flow_dir, "expected-divergences.json")?; + let rust_digest = digest_tree(&flow_dir.join("rust"), None)?; + + Ok(DerivedLineage { + version: LINEAGE_VERSION, + flow: flow.to_string(), + completed: true, + sources: SourcePairLineage { + cpp: source_lineage( + &raw.cpp, + format!("{RAW_PROVENANCE_DIR}/{CPP_RAW_MANIFEST_FILE}"), + )?, + rust: source_lineage( + &raw.rust, + format!("{RAW_PROVENANCE_DIR}/{RUST_RAW_MANIFEST_FILE}"), + )?, + }, + selection, + outputs: DerivedOutputs { + cpp_pkt: cpp_output, + rust: TreeLineage { + path: "rust".to_string(), + file_count: rust_digest.file_count, + packet_count: rust_digest.packet_count, + tree_sha256: rust_digest.sha256, + }, + expected_divergences: expected_output, + }, + }) +} + +fn source_lineage(raw: &ValidatedRawSide, manifest_path: String) -> Result { + let (raw_artifact_sha256, raw_artifact_size, raw_packet_count) = match raw.manifest.side { + RawSide::Cpp => ( + raw.manifest + .artifact + .sha256 + .clone() + .context("validated C++ SHA missing")?, + raw.manifest.artifact.size, + None, + ), + RawSide::Rust => ( + raw.manifest + .artifact + .tree_sha256 + .clone() + .context("validated Rust tree SHA missing")?, + None, + raw.manifest.artifact.packet_count, + ), + }; + Ok(SourceLineage { + manifest_path, + manifest_sha256: raw.manifest_sha256.clone(), + raw_artifact_sha256, + raw_artifact_size, + raw_packet_count, + harness_repo_head: raw.manifest.harness_repo_head.clone(), + source_repo_head: raw.manifest.source_repo_head.clone(), + harness_worktree_clean: raw.manifest.harness_worktree_clean, + harness_worktree_state_sha256: raw.manifest.harness_worktree_state_sha256.clone(), + source_worktree_dirty: raw.manifest.source_worktree_dirty, + source_worktree_state_sha256: raw.manifest.source_worktree_state_sha256.clone(), + worktree_state_algorithm: raw.manifest.worktree_state_algorithm.clone(), + expected_exec_path: raw.manifest.expected_exec_path.clone(), + expected_exec_sha256: raw.manifest.expected_exec_sha256.clone(), + source_exec_path: raw.manifest.source_exec_path.clone(), + source_exec_sha256: raw.manifest.source_exec_sha256.clone(), + live_exec_path: raw.manifest.live_exec_path.clone(), + live_exec_sha256: raw.manifest.live_exec_sha256.clone(), + executable_pin_enforced: raw.manifest.executable_pin_enforced, + pm2_entry_pid: raw.manifest.pm2_entry_pid, + pm2_exec_path: raw.manifest.pm2_exec_path.clone(), + pm2_exec_sha256: raw.manifest.pm2_exec_sha256.clone(), + listener_runtime_pid: raw.manifest.listener_runtime_pid, + listener_relationship_verified: raw.manifest.listener_relationship_verified, + restart_count: raw.manifest.restart_count, + effective_config_path: raw.manifest.effective_config_path.clone(), + effective_config_redacted_sha256: raw.manifest.effective_config_redacted_sha256.clone(), + effective_config_algorithm: raw.manifest.effective_config_algorithm.clone(), + pm2_entry_starttime: raw.manifest.pm2_entry_starttime, + pm2_profile_redacted_sha256: raw.manifest.pm2_profile_redacted_sha256.clone(), + listener_runtime_starttime: raw.manifest.listener_runtime_starttime, + runtime_cleanup_verified: raw.manifest.runtime_cleanup_verified, + normal_runtime_restored: raw.manifest.normal_runtime_restored, + fixture_guard: raw.manifest.fixture_guard.clone(), + bot_report: raw.manifest.bot_report.clone(), + retained_bot_report_path: raw.manifest.bot_report.as_ref().map(|_| { + format!( + "{RAW_PROVENANCE_DIR}/{}", + match raw.manifest.side { + RawSide::Cpp => CPP_BOT_REPORT_FILE, + RawSide::Rust => RUST_BOT_REPORT_FILE, + } + ) + }), + }) +} + +/// Verify the schema and every retained source/output hash for a required +/// flow. The raw captures themselves stay gitignored; their exact manifests +/// are committed and cross-bound to the hashes verified during import. +pub fn verify_required_lineage( + flow: &str, + flow_dir: &Path, + expected_selection: &ImportSelection, +) -> Result<()> { + let path = flow_dir.join(LINEAGE_FILE); + let bytes = read_regular_file(&path) + .with_context(|| format!("reading required lineage {}", path.display()))?; + let lineage: DerivedLineage = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing required lineage {}", path.display()))?; + ensure!( + lineage.version == LINEAGE_VERSION, + "required lineage has unsupported version {}", + lineage.version + ); + ensure!( + lineage.flow == flow, + "required lineage flow does not match {flow:?}" + ); + ensure!(lineage.completed, "required lineage completed must be true"); + ensure!( + lineage.selection.strict, + "required lineage import was not strict" + ); + ensure!( + lineage.selection == *expected_selection, + "required lineage selection does not match the reviewed import contract" + ); + + verify_retained_source(flow, flow_dir, RawSide::Cpp, &lineage.sources.cpp)?; + verify_retained_source(flow, flow_dir, RawSide::Rust, &lineage.sources.rust)?; + ensure!( + lineage.sources.cpp.harness_repo_head == lineage.sources.rust.harness_repo_head + && lineage.sources.cpp.harness_worktree_state_sha256 + == lineage.sources.rust.harness_worktree_state_sha256 + && lineage.sources.cpp.worktree_state_algorithm + == lineage.sources.rust.worktree_state_algorithm, + "required lineage C++/Rust harness identities differ" + ); + if matches!( + flow, + "loot-single-item-claim" | "loot-two-session-atomic-race" + ) { + ensure!( + lineage.sources.cpp.fixture_guard == lineage.sources.rust.fixture_guard, + "required lineage C++/Rust guarded-loot fixture identities differ" + ); + let cpp_bot = lineage + .sources + .cpp + .bot_report + .as_ref() + .context("required lineage C++ source is missing canonical bot report identity")?; + let rust_bot = lineage + .sources + .rust + .bot_report + .as_ref() + .context("required lineage Rust source is missing canonical bot report identity")?; + ensure!( + cpp_bot.contract == rust_bot.contract + && cpp_bot.exec_path == rust_bot.exec_path + && cpp_bot.exec_sha256 == rust_bot.exec_sha256 + && cpp_bot.account == rust_bot.account + && cpp_bot.account_id == rust_bot.account_id + && cpp_bot.character_guid == rust_bot.character_guid, + "required lineage C++/Rust bot identities differ" + ); + } + verify_file_lineage(flow_dir, &lineage.outputs.cpp_pkt, "cpp.pkt")?; + verify_file_lineage( + flow_dir, + &lineage.outputs.expected_divergences, + "expected-divergences.json", + )?; + + ensure!( + lineage.outputs.rust.path == "rust", + "Rust output path must be rust" + ); + let rust_digest = digest_tree(&flow_dir.join("rust"), None)?; + ensure!( + rust_digest.sha256 == lineage.outputs.rust.tree_sha256, + "derived Rust output tree SHA-256 does not match lineage" + ); + ensure!( + rust_digest.file_count == lineage.outputs.rust.file_count, + "derived Rust output file count does not match lineage" + ); + ensure!( + rust_digest.packet_count == lineage.outputs.rust.packet_count, + "derived Rust output packet count does not match lineage" + ); + Ok(()) +} + +fn verify_retained_source( + flow: &str, + flow_dir: &Path, + side: RawSide, + source: &SourceLineage, +) -> Result<()> { + let expected_path = match side { + RawSide::Cpp => format!("{RAW_PROVENANCE_DIR}/{CPP_RAW_MANIFEST_FILE}"), + RawSide::Rust => format!("{RAW_PROVENANCE_DIR}/{RUST_RAW_MANIFEST_FILE}"), + }; + ensure!( + source.manifest_path == expected_path, + "retained {side:?} manifest path is not canonical" + ); + validate_sha256(&source.manifest_sha256, "retained raw manifest SHA-256")?; + validate_sha256(&source.raw_artifact_sha256, "retained raw artifact SHA-256")?; + validate_sha256( + &source.expected_exec_sha256, + "retained expected executable SHA-256", + )?; + validate_sha256( + &source.source_exec_sha256, + "retained source executable SHA-256", + )?; + validate_sha256(&source.live_exec_sha256, "retained live executable SHA-256")?; + validate_sha256( + &source.harness_worktree_state_sha256, + "retained harness worktree state SHA-256", + )?; + validate_sha256( + &source.source_worktree_state_sha256, + "retained source worktree state SHA-256", + )?; + validate_sha256(&source.pm2_exec_sha256, "retained PM2 executable SHA-256")?; + validate_sha256( + &source.pm2_profile_redacted_sha256, + "retained PM2 profile SHA-256", + )?; + validate_sha256( + &source.effective_config_redacted_sha256, + "retained effective config SHA-256", + )?; + ensure!( + source.executable_pin_enforced, + "required raw executable was not pinned" + ); + + let path = flow_dir.join(&source.manifest_path); + let raw = read_and_validate_raw_manifest(&path, flow, side, true)?; + ensure!( + raw.manifest_sha256 == source.manifest_sha256, + "retained {side:?} raw manifest SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.harness_repo_head == source.harness_repo_head + && raw.manifest.source_repo_head == source.source_repo_head + && raw.manifest.harness_worktree_clean == source.harness_worktree_clean + && raw.manifest.harness_worktree_state_sha256 == source.harness_worktree_state_sha256 + && raw.manifest.source_worktree_dirty == source.source_worktree_dirty + && raw.manifest.source_worktree_state_sha256 == source.source_worktree_state_sha256 + && raw.manifest.worktree_state_algorithm == source.worktree_state_algorithm + && raw.manifest.expected_exec_path == source.expected_exec_path + && raw.manifest.expected_exec_sha256 == source.expected_exec_sha256 + && raw.manifest.source_exec_path == source.source_exec_path + && raw.manifest.source_exec_sha256 == source.source_exec_sha256 + && raw.manifest.live_exec_path == source.live_exec_path + && raw.manifest.live_exec_sha256 == source.live_exec_sha256 + && raw.manifest.executable_pin_enforced == source.executable_pin_enforced + && raw.manifest.pm2_entry_pid == source.pm2_entry_pid + && raw.manifest.pm2_entry_starttime == source.pm2_entry_starttime + && raw.manifest.pm2_exec_path == source.pm2_exec_path + && raw.manifest.pm2_exec_sha256 == source.pm2_exec_sha256 + && raw.manifest.pm2_profile_redacted_sha256 == source.pm2_profile_redacted_sha256 + && raw.manifest.listener_runtime_pid == source.listener_runtime_pid + && raw.manifest.listener_runtime_starttime == source.listener_runtime_starttime + && raw.manifest.listener_relationship_verified == source.listener_relationship_verified + && raw.manifest.restart_count == source.restart_count + && raw.manifest.effective_config_path == source.effective_config_path + && raw.manifest.effective_config_redacted_sha256 + == source.effective_config_redacted_sha256 + && raw.manifest.effective_config_algorithm == source.effective_config_algorithm + && raw.manifest.runtime_cleanup_verified == source.runtime_cleanup_verified + && raw.manifest.normal_runtime_restored == source.normal_runtime_restored + && raw.manifest.fixture_guard == source.fixture_guard + && raw.manifest.bot_report == source.bot_report, + "retained {side:?} source/process/config provenance does not match lineage" + ); + match (&raw.manifest.bot_report, &source.retained_bot_report_path) { + (Some(evidence), Some(relative)) => { + let canonical = match side { + RawSide::Cpp => format!("{RAW_PROVENANCE_DIR}/{CPP_BOT_REPORT_FILE}"), + RawSide::Rust => format!("{RAW_PROVENANCE_DIR}/{RUST_BOT_REPORT_FILE}"), + }; + ensure!( + relative == &canonical, + "retained {side:?} bot report path is not canonical" + ); + let bytes = read_regular_file(&flow_dir.join(relative))?; + ensure!( + sha256_bytes(&bytes) == evidence.report_sha256, + "retained {side:?} bot report SHA-256 does not match manifest" + ); + validate_bot_report_json(&bytes, evidence)?; + } + (None, None) => {} + _ => bail!("retained {side:?} bot report presence does not match manifest"), + } + match side { + RawSide::Cpp => { + ensure!( + raw.manifest.artifact.sha256.as_deref() + == Some(source.raw_artifact_sha256.as_str()), + "retained C++ raw artifact SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.artifact.size == source.raw_artifact_size, + "retained C++ raw artifact size does not match lineage" + ); + ensure!( + source.raw_packet_count.is_none(), + "C++ lineage has a packet count" + ); + } + RawSide::Rust => { + ensure!( + raw.manifest.artifact.tree_sha256.as_deref() + == Some(source.raw_artifact_sha256.as_str()), + "retained Rust raw tree SHA-256 does not match lineage" + ); + ensure!( + raw.manifest.artifact.packet_count == source.raw_packet_count, + "retained Rust raw packet count does not match lineage" + ); + ensure!( + source.raw_artifact_size.is_none(), + "Rust lineage has an artifact size" + ); + } + } + Ok(()) +} + +fn file_lineage(root: &Path, relative: &str) -> Result { + let bytes = read_regular_file(&root.join(relative))?; + Ok(FileLineage { + path: relative.to_string(), + size: u64::try_from(bytes.len()).context("derived file size does not fit u64")?, + sha256: sha256_bytes(&bytes), + }) +} + +fn verify_file_lineage(root: &Path, expected: &FileLineage, canonical: &str) -> Result<()> { + ensure!( + expected.path == canonical, + "derived output path must be {canonical}" + ); + validate_sha256(&expected.sha256, "derived output SHA-256")?; + let actual = file_lineage(root, canonical)?; + ensure!( + actual == *expected, + "derived {canonical} size or SHA-256 does not match lineage" + ); + Ok(()) +} + +fn validate_sha256(value: &str, label: &str) -> Result<()> { + ensure!( + value.len() == SHA256_HEX_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')), + "{label} must be exactly 64 lowercase hexadecimal characters" + ); + Ok(()) +} + +fn valid_git_oid(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn validate_utc_timestamp(value: &str) -> Result<()> { + let bytes = value.as_bytes(); + ensure!( + bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7 | 10 | 13 | 16 | 19) + || byte.is_ascii_digit()), + "created_at must be canonical UTC RFC3339 (YYYY-MM-DDTHH:MM:SSZ)" + ); + let number = |start: usize, end: usize| -> Result { + std::str::from_utf8(&bytes[start..end])? + .parse::() + .map_err(Into::into) + }; + let year = number(0, 4)?; + let month = number(5, 7)?; + let day = number(8, 10)?; + let hour = number(11, 13)?; + let minute = number(14, 16)?; + let second = number(17, 19)?; + ensure!(year >= 1970, "created_at year is before 1970"); + ensure!((1..=12).contains(&month), "created_at month is invalid"); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let days = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + ensure!((1..=days).contains(&day), "created_at day is invalid"); + ensure!(hour < 24, "created_at hour is invalid"); + ensure!(minute < 60, "created_at minute is invalid"); + ensure!(second < 60, "created_at second is invalid"); + Ok(()) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn read_regular_file(path: &Path) -> Result> { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; + ensure!( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + "{} is not a regular non-symlink file", + path.display() + ); + let mut file = open_read_no_follow(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("reading {}", path.display()))?; + Ok(bytes) +} + +#[cfg(unix)] +fn open_read_no_follow(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt as _; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("opening {} without following symlinks", path.display())) +} + +#[cfg(not(unix))] +fn open_read_no_follow(path: &Path) -> Result { + File::open(path).with_context(|| format!("opening {}", path.display())) +} + +fn digest_tree(root: &Path, excluded_manifest: Option<&Path>) -> Result { + let root_meta = fs::symlink_metadata(root) + .with_context(|| format!("inspecting tree {}", root.display()))?; + ensure!( + root_meta.file_type().is_dir() && !root_meta.file_type().is_symlink(), + "{} is not a non-symlink directory", + root.display() + ); + let excluded = excluded_manifest.and_then(|path| path.canonicalize().ok()); + let mut files = BTreeMap::, PathBuf>::new(); + collect_tree_files(root, root, excluded.as_deref(), &mut files)?; + ensure!( + !files.is_empty(), + "{} contains no capture files", + root.display() + ); + + let mut hasher = Sha256::new(); + let mut packet_count = 0_u64; + for (relative, path) in &files { + let bytes = read_regular_file(path)?; + hasher.update(relative); + hasher.update([0]); + hasher.update(sha256_bytes(&bytes).as_bytes()); + hasher.update([0]); + if path.extension().and_then(|extension| extension.to_str()) == Some("meta") { + packet_count = packet_count + .checked_add(1) + .context("packet count overflow")?; + } + } + Ok(TreeDigest { + sha256: format!("{:x}", hasher.finalize()), + file_count: u64::try_from(files.len()).context("tree file count does not fit u64")?, + packet_count, + }) +} + +fn collect_tree_files( + root: &Path, + directory: &Path, + excluded: Option<&Path>, + files: &mut BTreeMap, PathBuf>, +) -> Result<()> { + let entries = fs::read_dir(directory) + .with_context(|| format!("reading directory {}", directory.display()))?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + ensure!( + !metadata.file_type().is_symlink(), + "capture tree contains symlink {}", + path.display() + ); + if metadata.file_type().is_dir() { + collect_tree_files(root, &path, excluded, files)?; + } else if metadata.file_type().is_file() { + if path.file_name().and_then(|name| name.to_str()) == Some(RUST_RAW_MANIFEST_FILE) { + if excluded.is_some_and(|excluded| { + path.canonicalize() + .is_ok_and(|canonical| canonical == excluded) + }) { + continue; + } + bail!( + "capture tree contains unexpected or nested {RUST_RAW_MANIFEST_FILE} at {}", + path.display() + ); + } + if excluded.is_some_and(|excluded| { + path.canonicalize() + .is_ok_and(|canonical| canonical == excluded) + }) { + continue; + } + let relative = relative_path_bytes(root, &path)?; + ensure!( + files.insert(relative, path).is_none(), + "capture tree contains duplicate path bytes" + ); + } else { + bail!("capture tree contains unsupported entry {}", path.display()); + } + } + Ok(()) +} + +#[cfg(unix)] +fn relative_path_bytes(root: &Path, path: &Path) -> Result> { + use std::os::unix::ffi::OsStrExt as _; + Ok(path + .strip_prefix(root) + .with_context(|| format!("{} is outside {}", path.display(), root.display()))? + .as_os_str() + .as_bytes() + .to_vec()) +} + +#[cfg(not(unix))] +fn relative_path_bytes(root: &Path, path: &Path) -> Result> { + Ok(path + .strip_prefix(root) + .with_context(|| format!("{} is outside {}", path.display(), root.display()))? + .to_string_lossy() + .replace('\\', "/") + .into_bytes()) +} + +fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", path.display()))?; + file.sync_all() + .with_context(|| format!("syncing {}", path.display()))?; + Ok(()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("atomic output has no parent")?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .context("atomic output name is not UTF-8")?; + for attempt in 0..100_u64 { + let temp = parent.join(format!( + ".{name}.partial.{}.{}.{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed), + attempt + )); + match write_synced_file(&temp, bytes) { + Ok(()) => { + if let Err(error) = fs::rename(&temp, path) { + let _ = fs::remove_file(&temp); + return Err(error).with_context(|| format!("publishing {}", path.display())); + } + sync_directory(parent)?; + return Ok(()); + } + Err(error) + if error + .downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::AlreadyExists) => {} + Err(error) => return Err(error), + } + } + bail!( + "could not allocate an atomic staging file for {}", + path.display() + ) +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path) + .with_context(|| format!("opening directory {} for sync", path.display()))? + .sync_all() + .with_context(|| format!("syncing directory {}", path.display())) +} + +/// A complete flow prepared outside its published path. Dropping this value +/// before [`AtomicFlowImport::publish`] is equivalent to an interrupted import: +/// the old flow remains byte-for-byte visible and the staging tree is removed. +pub struct AtomicFlowImport { + root: PathBuf, + target: PathBuf, + staging: PathBuf, + target_existed_at_prepare: bool, + published: bool, +} + +impl AtomicFlowImport { + /// Prepare a private complete-tree staging directory, copying only the + /// flow's hand-reviewed metadata from the previous generation. + pub fn prepare(root: &Path, flow: &str) -> Result { + fs::create_dir_all(root) + .with_context(|| format!("creating flow root {}", root.display()))?; + let target = root.join(flow); + let target_existed_at_prepare = match fs::symlink_metadata(&target) { + Ok(metadata) => { + ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "published flow {} is not a non-symlink directory", + target.display() + ); + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).context("inspecting published flow target"), + }; + let staging = allocate_staging_directory(root, flow)?; + let mut transaction = Self { + root: root.to_path_buf(), + target, + staging, + target_existed_at_prepare, + published: false, + }; + if transaction.target_existed_at_prepare + && let Err(error) = copy_reviewed_metadata(&transaction.target, &transaction.staging) + { + let _ = fs::remove_dir_all(&transaction.staging); + transaction.published = true; + return Err(error); + } + Ok(transaction) + } + + /// Directory into which all derived artifacts and the lineage marker must + /// be written and validated before publication. + #[must_use] + pub fn staging_dir(&self) -> &Path { + &self.staging + } + + /// Atomically publish the entire complete flow. Existing flows use Linux + /// `RENAME_EXCHANGE`, so a process death exposes either the old complete + /// tree or the new complete tree, never a mixture of their files. + pub fn publish(mut self) -> Result<()> { + sync_tree(&self.staging)?; + if self.target_existed_at_prepare { + atomic_exchange_directories(&self.staging, &self.target)?; + self.published = true; + sync_directory(&self.root)?; + if let Err(error) = fs::remove_dir_all(&self.staging) { + eprintln!( + "capture-diff: warning: imported flow is complete, but old staging tree {} could not be removed: {error}", + self.staging.display() + ); + } else { + sync_directory(&self.root)?; + } + } else { + atomic_publish_new_directory(&self.staging, &self.target)?; + self.published = true; + sync_directory(&self.root)?; + } + Ok(()) + } +} + +impl Drop for AtomicFlowImport { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_dir_all(&self.staging); + } + } +} + +fn allocate_staging_directory(root: &Path, flow: &str) -> Result { + for _ in 0..100_u64 { + let candidate = root.join(format!( + ".{flow}.import-partial.{}.{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .with_context(|| format!("creating staging flow {}", candidate.display())); + } + } + } + bail!("could not allocate a staging directory for flow {flow:?}") +} + +fn is_generated_entry(name: &str) -> bool { + matches!( + name, + "cpp.pkt" | "rust" | "expected-divergences.json" | LINEAGE_FILE | RAW_PROVENANCE_DIR + ) +} + +fn copy_reviewed_metadata(source: &Path, destination: &Path) -> Result<()> { + for entry in fs::read_dir(source)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("flow metadata filename is not UTF-8"))?; + if is_generated_entry(&name) { + continue; + } + ensure!( + matches!( + name.as_str(), + "README.md" | "flow.json" | "requirement.json" + ), + "flow contains unknown non-generated entry {name:?}; import copies only README.md, flow.json, and requirement.json" + ); + copy_tree_entry(&entry.path(), &destination.join(name))?; + } + Ok(()) +} + +fn copy_tree_entry(source: &Path, destination: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(source)?; + ensure!( + !metadata.file_type().is_symlink(), + "flow metadata contains symlink {}", + source.display() + ); + if metadata.file_type().is_file() { + fs::copy(source, destination).with_context(|| { + format!( + "copying flow metadata {} to {}", + source.display(), + destination.display() + ) + })?; + Ok(()) + } else { + bail!( + "reviewed flow metadata must be a regular file: {}", + source.display() + ) + } +} + +fn sync_tree(root: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(root)?; + ensure!( + metadata.file_type().is_dir(), + "{} is not a directory", + root.display() + ); + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + ensure!( + !metadata.file_type().is_symlink(), + "staging tree contains symlink {}", + path.display() + ); + if metadata.file_type().is_dir() { + sync_tree(&path)?; + } else if metadata.file_type().is_file() { + File::open(&path)?.sync_all()?; + } else { + bail!("staging tree contains unsupported entry {}", path.display()); + } + } + sync_directory(root) +} + +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn atomic_exchange_directories(left: &Path, right: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt as _; + + let left = CString::new(left.as_os_str().as_bytes()).context("staging path contains NUL")?; + let right = CString::new(right.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings remain alive for the call, contain terminating + // NUL bytes supplied by `CString`, and `renameat2` does not retain them. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + left.as_ptr(), + libc::AT_FDCWD, + right.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "atomically exchanging staged flow {} with {}", + left.to_string_lossy(), + right.to_string_lossy() + ) + }) + } +} + +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn atomic_publish_new_directory(source: &Path, target: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt as _; + + let source = + CString::new(source.as_os_str().as_bytes()).context("staging path contains NUL")?; + let target = CString::new(target.as_os_str().as_bytes()).context("target path contains NUL")?; + // SAFETY: both C strings remain alive and NUL-terminated for renameat2; + // RENAME_NOREPLACE makes a concurrent target creation fail atomically. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()).with_context(|| { + format!( + "atomically publishing staged flow {} as new target {} without replacement", + source.to_string_lossy(), + target.to_string_lossy() + ) + }) + } +} + +#[cfg(not(target_os = "linux"))] +fn atomic_exchange_directories(_left: &Path, _right: &Path) -> Result<()> { + bail!("replacing an existing flow atomically requires Linux renameat2(RENAME_EXCHANGE)") +} + +#[cfg(not(target_os = "linux"))] +fn atomic_publish_new_directory(_source: &Path, _target: &Path) -> Result<()> { + bail!("publishing a new flow without replacement requires Linux renameat2(RENAME_NOREPLACE)") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_root(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "capture-diff-lineage-{label}-{}-{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + root + } + + fn fake_oid() -> String { + "a".repeat(40) + } + + fn make_raw_pair(root: &Path, flow: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let raw = root.join("raw"); + let rust = raw.join("rust"); + fs::create_dir_all(&rust).unwrap(); + let cpp = raw.join("cpp.pkt"); + fs::write(&cpp, b"raw-cpp").unwrap(); + fs::write( + rust.join("one.meta"), + b"direction=c2s\nseq=0\nopcode=0x0001\n", + ) + .unwrap(); + fs::write(rust.join("one.bin"), [1_u8, 0]).unwrap(); + let cpp_manifest = raw.join(CPP_RAW_MANIFEST_FILE); + let rust_manifest = rust.join(RUST_RAW_MANIFEST_FILE); + let rust_digest = digest_tree(&rust, Some(&rust_manifest)).unwrap(); + let cpp_json = serde_json::json!({ + "version": 3, + "flow": flow, + "side": "cpp", + "completed": true, + "created_at": "2026-07-19T00:00:00Z", + "harness_repo_head": fake_oid(), + "source_repo_head": "d".repeat(40), + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "1".repeat(64), + "source_worktree_dirty": true, + "source_worktree_state_sha256": "2".repeat(64), + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/opt/trinity/worldserver", + "expected_exec_sha256": "b".repeat(64), + "source_exec_path": "/opt/trinity/worldserver", + "source_exec_sha256": "b".repeat(64), + "live_exec_path": "/opt/trinity/worldserver", + "live_exec_sha256": "b".repeat(64), + "executable_pin_enforced": true, + "pm2_entry_pid": 122, + "pm2_entry_starttime": 1001, + "pm2_exec_path": "/opt/trinity/worldserver-wrapper.sh", + "pm2_exec_sha256": "3".repeat(64), + "pm2_profile_redacted_sha256": "5".repeat(64), + "listener_runtime_pid": 123, + "listener_runtime_starttime": 1002, + "listener_relationship_verified": true, + "restart_count": 2, + "effective_config_path": "/etc/trinity/worldserver.conf", + "effective_config_redacted_sha256": "e".repeat(64), + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "artifact": { + "path": "cpp.pkt", + "size": 7, + "sha256": sha256_bytes(b"raw-cpp") + } + }); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&cpp_json).unwrap()).unwrap(); + let rust_json = serde_json::json!({ + "version": 3, + "flow": flow, + "side": "rust", + "completed": true, + "created_at": "2026-07-19T00:00:01Z", + "harness_repo_head": fake_oid(), + "source_repo_head": fake_oid(), + "harness_worktree_clean": true, + "harness_worktree_state_sha256": "1".repeat(64), + "source_worktree_dirty": false, + "source_worktree_state_sha256": "1".repeat(64), + "worktree_state_algorithm": "git-head-path-mode-content-sha256-v1", + "expected_exec_path": "/opt/rustycore/world-server", + "expected_exec_sha256": "c".repeat(64), + "source_exec_path": "/opt/rustycore/world-server", + "source_exec_sha256": "c".repeat(64), + "live_exec_path": "/opt/rustycore/world-server", + "live_exec_sha256": "c".repeat(64), + "executable_pin_enforced": true, + "pm2_entry_pid": 456, + "pm2_entry_starttime": 2001, + "pm2_exec_path": "/opt/rustycore/world-server", + "pm2_exec_sha256": "c".repeat(64), + "pm2_profile_redacted_sha256": "6".repeat(64), + "listener_runtime_pid": 456, + "listener_runtime_starttime": 2001, + "listener_relationship_verified": true, + "restart_count": 3, + "effective_config_path": "/etc/rustycore/worldserver.conf", + "effective_config_redacted_sha256": "f".repeat(64), + "effective_config_algorithm": "capture-relevant-redacted-v1", + "runtime_cleanup_verified": true, + "normal_runtime_restored": true, + "artifact": { + "path": "rust", + "packet_count": rust_digest.packet_count, + "tree_sha256": rust_digest.sha256 + } + }); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&rust_json).unwrap(), + ) + .unwrap(); + if flow == "loot-single-item-claim" { + let fixture = serde_json::json!({ + "enabled": true, + "contract": "loot-single-item-claim-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "creature_entry": 21779, + "creature_spawn_guid": 1117, + "item_entry": 30712, + "cleanup_verified": true + }); + let report_json = serde_json::json!({ + "loot_item_capture": true, + "loot_race_smoke": false, + "results": [{ + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_target_entry": 21779, + "loot_race_target_spawn_guid": 1117, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_item_push_seen": true, + "loot_race_loot_removed_seen": true, + "loot_race_loot_coins": 0, + "loot_race_coin_removed_seen": false, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 0, + "loot_race_relog_verified": true, + "loot_race_failure": null + }] + }); + let report_bytes = serde_json::to_vec_pretty(&report_json).unwrap(); + let cpp_report = raw.join("cpp-report.json"); + let rust_report = raw.join("rust-report.json"); + fs::write(&cpp_report, &report_bytes).unwrap(); + fs::write(&rust_report, &report_bytes).unwrap(); + + for (manifest_path, report_path) in + [(&cpp_manifest, cpp_report), (&rust_manifest, rust_report)] + { + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(manifest_path).unwrap()).unwrap(); + manifest["fixture_guard"] = fixture.clone(); + manifest["bot_report"] = serde_json::json!({ + "contract": "wow-test-bot-loot-item-capture-report-v1", + "exec_path": "/opt/rustycore/wow-test-bot", + "exec_sha256": "7".repeat(64), + "report_path": report_path.to_string_lossy(), + "report_sha256": sha256_bytes(&report_bytes), + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }); + fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + } + } else if flow == "loot-two-session-atomic-race" { + let fixture = serde_json::json!({ + "enabled": true, + "contract": "loot-two-session-atomic-race-fixture-v1", + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "peer_account": "TESTBOT3@bot.local", + "peer_account_id": 10, + "peer_character_guid": 16, + "gameobject_entry": 2846, + "gameobject_spawn_guid": 9106001, + "item_entry": 38, + "cleanup_verified": true + }); + let result = |account: &str, + account_id: u32, + character_guid: u64, + item_push: bool, + money: u64| { + serde_json::json!({ + "account": account, + "account_id": account_id, + "character_guid": character_guid, + "world_auth": true, + "enum_characters": true, + "player_login_verified": true, + "loot_race_smoke": true, + "loot_race_smoke_passed": true, + "loot_race_failure": null, + "loot_race_target_entry": 2846, + "loot_race_target_spawn_guid": 9106001, + "loot_race_target_runtime_counter": 40, + "loot_race_party_confirmed": true, + "loot_race_target_discovered": true, + "loot_race_loot_opened": true, + "loot_race_loot_list_id": 0, + "loot_race_loot_coins": 10, + "loot_race_item_push_seen": item_push, + "loot_race_loot_removed_seen": true, + "loot_race_money_notify_amount": money, + "loot_race_coin_removed_seen": true, + "loot_race_db_item_total": 1, + "loot_race_db_money_delta": 10, + "loot_race_relog_verified": true + }) + }; + let report_json = serde_json::json!({ + "loot_item_capture": false, + "loot_race_smoke": true, + "results": [ + result("TESTBOT2@bot.local", 9, 15, true, 10), + result("TESTBOT3@bot.local", 10, 16, false, 0) + ] + }); + let report_bytes = serde_json::to_vec_pretty(&report_json).unwrap(); + let cpp_report = raw.join("cpp-race-report.json"); + let rust_report = raw.join("rust-race-report.json"); + fs::write(&cpp_report, &report_bytes).unwrap(); + fs::write(&rust_report, &report_bytes).unwrap(); + + for (manifest_path, report_path) in + [(&cpp_manifest, cpp_report), (&rust_manifest, rust_report)] + { + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(manifest_path).unwrap()).unwrap(); + manifest["fixture_guard"] = fixture.clone(); + manifest["bot_report"] = serde_json::json!({ + "contract": "wow-test-bot-loot-two-session-atomic-race-report-v1", + "exec_path": "/opt/rustycore/wow-test-bot", + "exec_sha256": "7".repeat(64), + "report_path": report_path.to_string_lossy(), + "report_sha256": sha256_bytes(&report_bytes), + "account": "TESTBOT2@bot.local", + "account_id": 9, + "character_guid": 15, + "report_validated": true + }); + fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + } + } + (cpp, cpp_manifest, rust, rust_manifest) + } + + fn make_derived_flow(root: &Path, flow: &str, raw: &ValidatedRawPair) -> PathBuf { + let flow_dir = root.join(flow); + fs::create_dir_all(flow_dir.join("rust")).unwrap(); + fs::write(flow_dir.join("cpp.pkt"), b"filtered-cpp").unwrap(); + fs::write(flow_dir.join("rust/one.meta"), b"derived-meta").unwrap(); + fs::write(flow_dir.join("rust/one.bin"), b"derived-bin").unwrap(); + fs::write(flow_dir.join("expected-divergences.json"), b"[]").unwrap(); + write_derived_lineage( + flow, + &flow_dir, + raw, + ImportSelection::new(vec![Direction::S2C, Direction::C2S], None, None, &[], true), + ) + .unwrap(); + flow_dir + } + + fn required_selection() -> ImportSelection { + ImportSelection::new(vec![Direction::S2C, Direction::C2S], None, None, &[], true) + } + + #[test] + fn raw_manifest_or_artifact_tamper_is_rejected() { + let root = test_root("raw-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + fs::write(&cpp, b"tampered").unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("raw artifact tamper must fail"); + assert!(error.to_string().contains("size") || error.to_string().contains("SHA-256")); + fs::write(&cpp, b"raw-cpp").unwrap(); + + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + json["artifact"]["tree_sha256"] = serde_json::Value::String("d".repeat(64)); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("raw manifest hash tamper must fail"); + assert!(error.to_string().contains("tree SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn raw_manifest_requires_complete_consistent_process_provenance() { + let root = test_root("raw-provenance"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let original = fs::read(&cpp_manifest).unwrap(); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json.as_object_mut().unwrap().remove("source_repo_head"); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing source HEAD must fail schema validation"); + assert!(format!("{error:#}").contains("parsing raw manifest")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["live_exec_sha256"] = serde_json::Value::String("9".repeat(64)); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("source/live executable mismatch must fail"); + assert!(format!("{error:#}").contains("expected/source/live executable SHA-256")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["harness_worktree_clean"] = serde_json::Value::Bool(false); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("dirty capture harness must fail"); + assert!(format!("{error:#}").contains("harness worktree must be clean")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json.as_object_mut().unwrap().remove("pm2_exec_sha256"); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing PM2 entrypoint hash must fail schema validation"); + assert!(format!("{error:#}").contains("parsing raw manifest")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["created_at"] = serde_json::Value::String("yesterday".to_string()); + fs::write(&cpp_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("non-RFC3339 timestamp must fail"); + assert!(format!("{error:#}").contains("RFC3339")); + + fs::write(&cpp_manifest, &original).unwrap(); + let mut rust_json: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + rust_json["source_worktree_dirty"] = serde_json::Value::Bool(true); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&rust_json).unwrap(), + ) + .unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("dirty Rust source worktree must fail"); + assert!(format!("{error:#}").contains("same clean state")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn nested_raw_manifest_is_rejected_instead_of_excluded_from_tree_hash() { + let root = test_root("nested-raw-manifest"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let nested = rust.join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join(RUST_RAW_MANIFEST_FILE), b"{}").unwrap(); + + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("nested raw manifest must fail rather than disappear from hashing"); + assert!(format!("{error:#}").contains("unexpected or nested")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_raw_or_derived_manifest_is_rejected() { + let root = test_root("missing-manifest"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + fs::remove_file(&cpp_manifest).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("missing C++ raw manifest must fail"); + assert!(error.to_string().contains("C++ raw manifest")); + + let flow_dir = make_derived_flow(&root, flow, &raw); + fs::remove_file(flow_dir.join(LINEAGE_FILE)).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("missing derived lineage must fail"); + assert!(error.to_string().contains("reading required lineage")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn verify_rejects_retained_manifest_and_output_tamper() { + let root = test_root("derived-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + verify_required_lineage(flow, &flow_dir, &required_selection()).unwrap(); + + fs::write(flow_dir.join("rust/one.bin"), b"tampered-output").unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("derived output tamper must fail"); + assert!(error.to_string().contains("tree SHA-256")); + fs::write(flow_dir.join("rust/one.bin"), b"derived-bin").unwrap(); + + let retained = flow_dir + .join(RAW_PROVENANCE_DIR) + .join(CPP_RAW_MANIFEST_FILE); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&retained).unwrap()).unwrap(); + json["artifact"]["sha256"] = serde_json::Value::String("e".repeat(64)); + fs::write(&retained, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("retained raw manifest tamper must fail"); + assert!(error.to_string().contains("raw manifest SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn verify_rejects_lineage_schema_and_hash_tamper() { + let root = test_root("lineage-tamper"); + let flow = "required-flow"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + + let path = flow_dir.join(LINEAGE_FILE); + let original = fs::read(&path).unwrap(); + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["outputs"]["cpp_pkt"]["sha256"] = serde_json::Value::String("f".repeat(64)); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("lineage output hash tamper must fail"); + assert!(error.to_string().contains("cpp.pkt")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["selection"]["ignored_opcodes"] = serde_json::json!([{ + "direction": "s2c", + "opcode": 11732 + }]); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("an extra derived-flow filter must fail the reviewed contract"); + assert!(error.to_string().contains("reviewed import contract")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["unexpected"] = serde_json::Value::Bool(true); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("unknown lineage fields must fail schema validation"); + assert!(error.to_string().contains("parsing required lineage")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn interrupted_import_before_exchange_leaves_old_flow_untouched() { + let root = test_root("interrupted-import"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed metadata").unwrap(); + fs::write(target.join("cpp.pkt"), b"old-complete-flow").unwrap(); + + let staging_path; + { + let transaction = AtomicFlowImport::prepare(&root, "required-flow").unwrap(); + staging_path = transaction.staging_dir().to_path_buf(); + fs::write( + transaction.staging_dir().join("cpp.pkt"), + b"new-partial-flow", + ) + .unwrap(); + // A signal/error before publish drops the transaction here. + } + + assert_eq!( + fs::read(target.join("cpp.pkt")).unwrap(), + b"old-complete-flow" + ); + assert_eq!( + fs::read(target.join("README.md")).unwrap(), + b"reviewed metadata" + ); + assert!(!staging_path.exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn import_rejects_unknown_existing_metadata_instead_of_carrying_it_forward() { + let root = test_root("unknown-metadata"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed").unwrap(); + fs::write(target.join("unreviewed.secret"), b"must not propagate").unwrap(); + + let error = AtomicFlowImport::prepare(&root, "required-flow") + .err() + .expect("unknown metadata must fail closed"); + assert!(error.to_string().contains("unknown non-generated entry")); + assert_eq!( + fs::read(target.join("unreviewed.secret")).unwrap(), + b"must not propagate" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn complete_existing_flow_is_exchanged_as_one_generation() { + let root = test_root("atomic-exchange"); + let target = root.join("required-flow"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("README.md"), b"reviewed metadata").unwrap(); + fs::write(target.join("cpp.pkt"), b"old").unwrap(); + + let transaction = AtomicFlowImport::prepare(&root, "required-flow").unwrap(); + fs::write(transaction.staging_dir().join("cpp.pkt"), b"new").unwrap(); + fs::write(transaction.staging_dir().join(LINEAGE_FILE), b"complete").unwrap(); + transaction.publish().unwrap(); + + assert_eq!(fs::read(target.join("cpp.pkt")).unwrap(), b"new"); + assert_eq!(fs::read(target.join(LINEAGE_FILE)).unwrap(), b"complete"); + assert_eq!( + fs::read(target.join("README.md")).unwrap(), + b"reviewed metadata" + ); + assert!(fs::read_dir(&root).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("partial") + })); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn loot_raw_pair_requires_canonical_guard_bot_report_and_cross_side_identity() { + let root = test_root("loot-identity"); + let flow = "loot-single-item-claim"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + let original = fs::read(&rust_manifest).unwrap(); + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["fixture_guard"]["enabled"] = serde_json::Value::Bool(false); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("disabled fixture guard must fail"); + assert!(format!("{error:#}").contains("fixture_guard.enabled")); + + let mut json: serde_json::Value = serde_json::from_slice(&original).unwrap(); + json["bot_report"]["exec_sha256"] = serde_json::Value::String("8".repeat(64)); + fs::write(&rust_manifest, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("different bot binary identity must fail"); + assert!(format!("{error:#}").contains("different canonical bot identities")); + + fs::write(&rust_manifest, &original).unwrap(); + let report_path = serde_json::from_slice::(&original).unwrap() + ["bot_report"]["report_path"] + .as_str() + .unwrap() + .to_string(); + fs::write(&report_path, b"{}").unwrap(); + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("bot report tamper must fail"); + assert!(format!("{error:#}").contains("bot report SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn loot_race_raw_pair_accepts_gameobject_guard_and_rejects_split_runtime_target() { + let root = test_root("loot-race-identity"); + let flow = "loot-two-session-atomic-race"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&rust_manifest).unwrap()).unwrap(); + let report_path = PathBuf::from(manifest["bot_report"]["report_path"].as_str().unwrap()); + let mut report: serde_json::Value = + serde_json::from_slice(&fs::read(&report_path).unwrap()).unwrap(); + report["results"][1]["loot_race_target_runtime_counter"] = serde_json::Value::from(41); + let report_bytes = serde_json::to_vec_pretty(&report).unwrap(); + fs::write(&report_path, &report_bytes).unwrap(); + manifest["bot_report"]["report_sha256"] = + serde_json::Value::String(sha256_bytes(&report_bytes)); + fs::write( + &rust_manifest, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let error = validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true) + .expect_err("split live target counter must fail"); + assert!( + format!("{error:#}").contains("one shared target/list"), + "unexpected error: {error:#}" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn derived_loot_lineage_retains_and_revalidates_bot_reports() { + let root = test_root("loot-report-retention"); + let flow = "loot-single-item-claim"; + let (cpp, cpp_manifest, rust, rust_manifest) = make_raw_pair(&root, flow); + let raw = + validate_raw_pair(flow, &cpp, &cpp_manifest, &rust, &rust_manifest, true).unwrap(); + let flow_dir = make_derived_flow(&root, flow, &raw); + verify_required_lineage(flow, &flow_dir, &required_selection()).unwrap(); + + fs::write( + flow_dir.join(RAW_PROVENANCE_DIR).join(RUST_BOT_REPORT_FILE), + b"{}", + ) + .unwrap(); + let error = verify_required_lineage(flow, &flow_dir, &required_selection()) + .expect_err("retained bot report tamper must fail"); + assert!(error.to_string().contains("bot report SHA-256")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn new_flow_publication_is_atomic_noreplace_under_target_race() { + let root = test_root("atomic-noreplace"); + let transaction = AtomicFlowImport::prepare(&root, "new-flow").unwrap(); + fs::write(transaction.staging_dir().join("cpp.pkt"), b"candidate").unwrap(); + + let target = root.join("new-flow"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("sentinel"), b"concurrent owner").unwrap(); + let error = transaction + .publish() + .expect_err("concurrent target must never be replaced"); + assert!(format!("{error:#}").contains("without replacement")); + assert_eq!( + fs::read(target.join("sentinel")).unwrap(), + b"concurrent owner" + ); + assert!(!target.join("cpp.pkt").exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/capture-diff/src/main.rs b/crates/capture-diff/src/main.rs index 3e8f3ba46..95ac867e2 100644 --- a/crates/capture-diff/src/main.rs +++ b/crates/capture-diff/src/main.rs @@ -8,19 +8,21 @@ //! capture-diff diff --cpp A.pkt --rust DIR [...] # ad-hoc, no flow //! capture-diff show # list a capture //! capture-diff list # list known flows +//! capture-diff verify-required # require clean real artifacts //! capture-diff update-baseline [--rust DIR] # rewrite baseline //! ``` // Product names (RustyCore, TrinityCore) appear throughout the docs as prose. #![allow(clippy::doc_markdown)] +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::ExitCode; use anyhow::{Context, Result, bail}; use capture_diff::diff::{self, BaselineDelta, DivergenceSignature}; -use capture_diff::{Capture, DiffReport, Direction, PacketBoundary, flow, pkt, rustdump}; +use capture_diff::{Capture, DiffReport, Direction, PacketBoundary, flow, lineage, pkt, rustdump}; fn main() -> ExitCode { match run() { @@ -45,12 +47,15 @@ fn run() -> Result { "show" => cmd_show(&rest), "list" => cmd_list(), "import" => cmd_import(&rest), + "verify-required" => cmd_verify_required(&rest), "update-baseline" => cmd_update_baseline(&rest), "-h" | "--help" | "help" => { print_usage(); Ok(ExitCode::SUCCESS) } - other => bail!("unknown command '{other}' (try: diff, show, list, update-baseline)"), + other => bail!( + "unknown command '{other}' (try: diff, show, list, verify-required, update-baseline)" + ), } } @@ -58,7 +63,9 @@ fn run() -> Result { struct Opts { positional: Option, cpp: Option, + cpp_manifest: Option, rust: Option, + rust_manifest: Option, direction: Option>, baseline: Option, from_opcode: Option, @@ -72,7 +79,9 @@ fn parse_opts(args: &[String]) -> Result { let mut opts = Opts { positional: None, cpp: None, + cpp_manifest: None, rust: None, + rust_manifest: None, direction: None, baseline: None, from_opcode: None, @@ -85,7 +94,13 @@ fn parse_opts(args: &[String]) -> Result { while let Some(arg) = it.next() { match arg.as_str() { "--cpp" => opts.cpp = Some(PathBuf::from(next(&mut it, "--cpp")?)), + "--cpp-manifest" => { + opts.cpp_manifest = Some(PathBuf::from(next(&mut it, "--cpp-manifest")?)); + } "--rust" => opts.rust = Some(PathBuf::from(next(&mut it, "--rust")?)), + "--rust-manifest" => { + opts.rust_manifest = Some(PathBuf::from(next(&mut it, "--rust-manifest")?)); + } "--baseline" => opts.baseline = Some(PathBuf::from(next(&mut it, "--baseline")?)), "--from-opcode" => { let raw = next(&mut it, "--from-opcode")?; @@ -169,6 +184,10 @@ const APPROVED_AMBIENT_IGNORES: &[PacketBoundary] = &[ direction: Some(Direction::S2C), opcode: 0x2DD2, // SMSG_TIME_SYNC_REQUEST }, + PacketBoundary { + direction: Some(Direction::C2S), + opcode: 0x3A3D, // CMSG_TIME_SYNC_RESPONSE paired with the request above + }, PacketBoundary { direction: Some(Direction::S2C), opcode: 0x2DD4, // SMSG_ON_MONSTER_MOVE @@ -183,6 +202,19 @@ fn boundaries_overlap(left: PacketBoundary, right: PacketBoundary) -> bool { } fn validate_ignored_opcodes(opts: &Opts) -> Result<()> { + let ignores_time_request = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD2, + }); + let ignores_time_response = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::C2S), + opcode: 0x3A3D, + }); + if ignores_time_request != ignores_time_response { + bail!( + "time-sync ambient traffic may be ignored only as the reviewed s2c:0x2DD2 + c2s:0x3A3D request/response pair" + ); + } for ignored in &opts.ignored_opcodes { if opts .from_opcode @@ -195,10 +227,15 @@ fn validate_ignored_opcodes(opts: &Opts) -> Result<()> { } if !APPROVED_AMBIENT_IGNORES.contains(ignored) { bail!( - "--ignore-opcode {ignored} is not approved ambient traffic; reviewed filters are s2c:0x2DD2 and s2c:0x2DD4" + "--ignore-opcode {ignored} is not approved ambient traffic; reviewed filters are s2c:0x2DD2, c2s:0x3A3D, and s2c:0x2DD4" ); } } + for (index, ignored) in opts.ignored_opcodes.iter().enumerate() { + if opts.ignored_opcodes[..index].contains(ignored) { + bail!("--ignore-opcode {ignored} is duplicated"); + } + } Ok(()) } @@ -231,22 +268,299 @@ fn apply_ignored_opcodes(mut capture: Capture, opts: &Opts) -> Capture { capture } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct AmbientIgnoreCounts { + time_sync_request: usize, + time_sync_response: usize, + monster_move: usize, +} + +fn read_le_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +fn monster_take<'a>(body: &'a [u8], cursor: &mut usize, len: usize) -> Option<&'a [u8]> { + let end = cursor.checked_add(len)?; + let bytes = body.get(*cursor..end)?; + *cursor = end; + Some(bytes) +} + +fn monster_read_u32(body: &[u8], cursor: &mut usize) -> Option { + let bytes: [u8; 4] = monster_take(body, cursor, 4)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +fn monster_read_f32(body: &[u8], cursor: &mut usize) -> Option<()> { + let value = f32::from_bits(monster_read_u32(body, cursor)?); + value.is_finite().then_some(()) +} + +fn monster_read_packed_guid(body: &[u8], cursor: &mut usize) -> Option { + let low_mask = *monster_take(body, cursor, 1)?.first()?; + let high_mask = *monster_take(body, cursor, 1)?.first()?; + let encoded_len = (low_mask.count_ones() + high_mask.count_ones()) as usize; + let encoded = monster_take(body, cursor, encoded_len)?; + // A set mask bit encoding zero is non-canonical; empty masks are valid for + // optional GUID fields such as an absent transport. + encoded.iter().all(|byte| *byte != 0).then_some(())?; + Some(low_mask != 0 || high_mask != 0) +} + +/// Parse the exact C++ `MonsterMove::Write` / `MovementMonsterSpline` layout +/// far enough to prove that an ignored `SMSG_ON_MONSTER_MOVE` is one complete, +/// canonical movement packet rather than arbitrary bytes under an allowlisted +/// opcode. Counts and optional sections are consumed to the exact body end. +fn valid_monster_move_body(body: &[u8]) -> bool { + let mut cursor = 0usize; + if monster_read_packed_guid(body, &mut cursor) != Some(true) { + return false; + } + for _ in 0..3 { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + if monster_read_u32(body, &mut cursor).is_none() { + return false; + } + for _ in 0..3 { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + // CrzTeleport(1) + StopDistanceTolerance(3) are MSB-first and flushed; + // the low nibble is canonical zero padding. + let Some(spline_bits) = monster_take(body, &mut cursor, 1) + .and_then(|b| b.first()) + .copied() + else { + return false; + }; + if spline_bits & 0x0F != 0 || monster_take(body, &mut cursor, 17).is_none() { + return false; + } + if monster_read_packed_guid(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 1).is_none() + { + return false; + } + let Some(bits) = monster_take(body, &mut cursor, 5) else { + return false; + }; + let header = bits + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)); + let face = ((header >> 38) & 0x03) as u8; + let point_count = ((header >> 22) & 0xFFFF) as usize; + let packed_delta_count = ((header >> 4) & 0xFFFF) as usize; + let has_filter = header & (1 << 3) != 0; + let has_spell_extra = header & (1 << 2) != 0; + let has_jump_extra = header & (1 << 1) != 0; + let has_anim_transition = header & 1 != 0; + + if has_filter { + let Some(filter_count) = monster_read_u32(body, &mut cursor).map(|v| v as usize) else { + return false; + }; + if monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 2).is_none() + || monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 2).is_none() + || monster_take(body, &mut cursor, filter_count.saturating_mul(4)).is_none() + { + return false; + } + let Some(filter_flags) = monster_take(body, &mut cursor, 1) + .and_then(|bytes| bytes.first()) + .copied() + else { + return false; + }; + if filter_flags & 0x3F != 0 { + return false; + } + } + + let face_ok = match face { + 0 => true, + 1 => (0..3).all(|_| monster_read_f32(body, &mut cursor).is_some()), + 2 => { + monster_read_f32(body, &mut cursor).is_some() + && monster_read_packed_guid(body, &mut cursor).is_some() + } + 3 => monster_read_f32(body, &mut cursor).is_some(), + _ => false, + }; + if !face_ok { + return false; + } + for _ in 0..point_count.saturating_mul(3) { + if monster_read_f32(body, &mut cursor).is_none() { + return false; + } + } + if monster_take(body, &mut cursor, packed_delta_count.saturating_mul(4)).is_none() { + return false; + } + if has_spell_extra + && (monster_read_packed_guid(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 12).is_none() + || monster_read_f32(body, &mut cursor).is_none()) + { + return false; + } + if has_jump_extra + && (monster_read_f32(body, &mut cursor).is_none() + || monster_take(body, &mut cursor, 8).is_none()) + { + return false; + } + if has_anim_transition && monster_take(body, &mut cursor, 13).is_none() { + return false; + } + cursor == body.len() +} + +fn validate_ambient_ignore_evidence(capture: &Capture, opts: &Opts) -> Result { + let ignores_time = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD2, + }); + let ignores_movement = opts.ignored_opcodes.contains(&PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD4, + }); + let mut counts = AmbientIgnoreCounts::default(); + let mut pending_time_sync = BTreeMap::<(u32, u32), usize>::new(); + let mut completed_time_sync = BTreeMap::<(u32, u32), usize>::new(); + + for (index, packet) in capture.packets.iter().enumerate() { + match (packet.direction, packet.opcode) { + (Direction::S2C, 0x2DD2) if ignores_time => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} time-sync request is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if packet.body.len() != 4 { + bail!( + "{} packet {index} time-sync request has malformed {}-byte body, expected 4", + capture.source, + packet.body.len() + ); + } + let key = (packet.connection_id, read_le_u32(&packet.body)); + if pending_time_sync.contains_key(&key) || completed_time_sync.contains_key(&key) { + bail!( + "{} packet {index} duplicates time-sync request sequence {} on connection {}", + capture.source, + key.1, + key.0 + ); + } + pending_time_sync.insert(key, index); + counts.time_sync_request += 1; + } + (Direction::C2S, 0x3A3D) if ignores_time => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} time-sync response is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if packet.body.len() != 8 { + bail!( + "{} packet {index} time-sync response has malformed {}-byte body, expected 8", + capture.source, + packet.body.len() + ); + } + let key = (packet.connection_id, read_le_u32(&packet.body)); + let Some(request_index) = pending_time_sync.remove(&key) else { + bail!( + "{} packet {index} has orphan or duplicate time-sync response sequence {} on connection {}", + capture.source, + key.1, + key.0 + ); + }; + completed_time_sync.insert(key, request_index); + counts.time_sync_response += 1; + } + (Direction::S2C, 0x2DD4) if ignores_movement => { + if packet.connection_id != 1 { + bail!( + "{} packet {index} monster movement is on connection {}, expected instance connection 1", + capture.source, + packet.connection_id + ); + } + if !valid_monster_move_body(&packet.body) { + bail!( + "{} packet {index} has malformed monster-movement body ({} bytes)", + capture.source, + packet.body.len() + ); + } + counts.monster_move += 1; + } + _ => {} + } + } + + if let Some(((connection_id, sequence), request_index)) = pending_time_sync.into_iter().next() { + bail!( + "{} packet {request_index} has no matching time-sync response for sequence {sequence} on connection {connection_id}", + capture.source + ); + } + Ok(counts) +} + +#[cfg(test)] fn apply_capture_selection(capture: Capture, opts: &Opts) -> Result { validate_ignored_opcodes(opts)?; - Ok(apply_ignored_opcodes( - apply_capture_boundaries(capture, opts)?, - opts, + let bounded = apply_capture_boundaries(capture, opts)?; + validate_ambient_ignore_evidence(&bounded, opts)?; + Ok(apply_ignored_opcodes(bounded, opts)) +} + +fn apply_capture_pair_selection( + cpp: Capture, + rust: Capture, + opts: &Opts, +) -> Result<(Capture, Capture)> { + validate_ignored_opcodes(opts)?; + let cpp = apply_capture_boundaries(cpp, opts)?; + let rust = apply_capture_boundaries(rust, opts)?; + let cpp_counts = validate_ambient_ignore_evidence(&cpp, opts)?; + let rust_counts = validate_ambient_ignore_evidence(&rust, opts)?; + if cpp_counts != rust_counts { + bail!( + "ambient ignore evidence count mismatch: C++ {cpp_counts:?}, Rust {rust_counts:?}; refusing asymmetric deletion" + ); + } + Ok(( + apply_ignored_opcodes(cpp, opts), + apply_ignored_opcodes(rust, opts), )) } fn cmd_diff(args: &[String]) -> Result { let opts = parse_opts(args)?; + if opts.cpp_manifest.is_some() || opts.rust_manifest.is_some() { + bail!("raw manifest flags are accepted only by import"); + } // Resolve cpp/rust/directions/baseline either from a flow or explicit paths. let (cpp_path, rust_path, directions, baseline) = resolve_sources(&opts)?; - let cpp = apply_capture_selection(load_capture(&cpp_path)?, &opts)?; - let rust = apply_capture_selection(load_capture(&rust_path)?, &opts)?; + let (cpp, rust) = + apply_capture_pair_selection(load_capture(&cpp_path)?, load_capture(&rust_path)?, &opts)?; let report = DiffReport::compute(&cpp, &rust, &directions); if opts.json { @@ -328,17 +642,83 @@ fn cmd_show(args: &[String]) -> Result { #[allow(clippy::unnecessary_wraps)] fn cmd_list() -> Result { let flows = flow::list_flows(); - if flows.is_empty() { + let requirements = flow::list_requirements(); + if flows.is_empty() && requirements.is_empty() { println!("no flows found under {}", flow::flows_root().display()); - } else { + return Ok(ExitCode::SUCCESS); + } + if !flows.is_empty() { println!("known flows:"); - for name in flows { - match flow::load_flow(&name) { + for name in &flows { + match flow::load_flow(name) { Ok(f) => println!(" {name:<16} {}", f.description), Err(_) => println!(" {name}"), } } } + if !requirements.is_empty() { + println!("required flows:"); + for name in requirements { + match flow::load_requirement(&name) { + Ok(requirement) => println!( + " {name:<24} {:?} ({})", + requirement.status, requirement.issue + ), + Err(_) => println!(" {name:<24} INVALID"), + } + } + } + Ok(ExitCode::SUCCESS) +} + +/// Enforce a milestone's capture contract, not merely its accepted-divergence +/// baseline. A required flow must have real artifacts, an empty baseline, the +/// reviewed packet topology/order, and a fully clean C++↔Rust diff. +fn cmd_verify_required(args: &[String]) -> Result { + if args.len() != 1 { + bail!("usage: capture-diff verify-required "); + } + let name = &args[0]; + let requirement = flow::load_requirement(name)?; + requirement.require_ready()?; + + let pinned = flow::load_flow(name)?; + if pinned.directions != requirement.directions { + bail!( + "required flow '{name}' directions {:?} do not match flow.json {:?}", + requirement.directions, + pinned.directions + ); + } + let reviewed_selection = lineage_selection_for_requirement(&requirement); + lineage::verify_required_lineage(name, &requirement.directory, &reviewed_selection)?; + + let cpp = load_capture(&pinned.golden_pkt)?; + let rust = load_capture(&pinned.reference_rust)?; + requirement.validate_capture(&cpp)?; + requirement.validate_capture(&rust)?; + + let expected_text = std::fs::read_to_string(&pinned.expected) + .with_context(|| format!("reading baseline {}", pinned.expected.display()))?; + let expected: Vec = serde_json::from_str(&expected_text) + .with_context(|| format!("parsing baseline {}", pinned.expected.display()))?; + if !expected.is_empty() { + bail!( + "required flow '{name}' pins {} accepted divergence(s); a milestone capture must be clean", + expected.len() + ); + } + + let report = DiffReport::compute(&cpp, &rust, &requirement.directions); + if !report.is_clean() { + print!("{}", report.render_text()); + return Ok(ExitCode::FAILURE); + } + + println!( + "required flow '{name}' CLEAN: {} matched packet(s), exact topology/order and correlated payload semantics present", + report.counts.matched + ); Ok(ExitCode::SUCCESS) } @@ -347,7 +727,7 @@ fn cmd_list() -> Result { fn cmd_import(args: &[String]) -> Result { let opts = parse_opts(args)?; let name = opts.positional.as_deref().context( - "usage: capture-diff import --cpp --rust [--from-opcode c2s:0xNNNN --until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN]", + "usage: capture-diff import --cpp --rust [--cpp-manifest ] [--rust-manifest ] [--from-opcode c2s:0xNNNN --until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN]", )?; flow::validate_flow_name(name)?; let cpp_path = opts.cpp.clone().context("import requires --cpp ")?; @@ -355,14 +735,40 @@ fn cmd_import(args: &[String]) -> Result { .rust .clone() .context("import requires --rust ")?; + let cpp_manifest_path = opts.cpp_manifest.clone().unwrap_or_else(|| { + cpp_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("cpp.capture-manifest.json") + }); + let rust_manifest_path = opts + .rust_manifest + .clone() + .unwrap_or_else(|| rust_path.join("rust.capture-manifest.json")); + let requirement_path = flow::flows_root().join(name).join("requirement.json"); + let is_required = requirement_path.is_file(); + if is_required && !opts.strict { + bail!("required flow '{name}' can be imported only with --strict"); + } + let raw = lineage::validate_raw_pair( + name, + &cpp_path, + &cpp_manifest_path, + &rust_path, + &rust_manifest_path, + is_required, + )?; - let cpp = apply_capture_selection(load_capture(&cpp_path)?, &opts)?; - let rust = apply_capture_selection(load_capture(&rust_path)?, &opts)?; + let (cpp, rust) = + apply_capture_pair_selection(load_capture(&cpp_path)?, load_capture(&rust_path)?, &opts)?; let directions = opts .direction .clone() .unwrap_or_else(|| vec![Direction::S2C, Direction::C2S]); + if opts.strict { + validate_required_import(name, &directions, &opts, &cpp, &rust)?; + } let report = DiffReport::compute(&cpp, &rust, &directions); if opts.strict && !report.is_clean() { print!("{}", report.render_text()); @@ -372,34 +778,111 @@ fn cmd_import(args: &[String]) -> Result { return Ok(ExitCode::FAILURE); } - let dir = flow::flows_root().join(name); - std::fs::create_dir_all(&dir)?; + let transaction = lineage::AtomicFlowImport::prepare(&flow::flows_root(), name)?; + let dir = transaction.staging_dir(); std::fs::write(dir.join("cpp.pkt"), pkt::write_pkt_bytes(&cpp)) - .with_context(|| format!("writing {}/cpp.pkt", dir.display()))?; + .with_context(|| format!("writing staged {}/cpp.pkt", dir.display()))?; let rust_dir = dir.join("rust"); - if rust_dir.exists() { - std::fs::remove_dir_all(&rust_dir)?; - } rustdump::write_rust_dump(&rust_dir, &rust)?; std::fs::write( dir.join("expected-divergences.json"), serde_json::to_string_pretty(&report.signatures())?, )?; + let raw_after_import = lineage::validate_raw_pair( + name, + &cpp_path, + &cpp_manifest_path, + &rust_path, + &rust_manifest_path, + is_required, + )?; + if raw != raw_after_import { + bail!("raw capture manifests or artifacts changed while import was running"); + } + let selection = lineage::ImportSelection::new( + directions.clone(), + opts.from_opcode, + opts.until_opcode, + &opts.ignored_opcodes, + opts.strict, + ); + lineage::write_derived_lineage(name, dir, &raw, selection)?; + if is_required { + let requirement = flow::load_requirement(name)?; + lineage::verify_required_lineage( + name, + dir, + &lineage_selection_for_requirement(&requirement), + )?; + } + transaction.publish()?; + let published_dir = flow::flows_root().join(name); println!( "imported flow '{name}': cpp={} rust={} packets, {} divergence(s) -> {}", cpp.packets.len(), rust.packets.len(), report.signatures().len(), - dir.display() + published_dir.display() ); print!("{}", report.render_text()); Ok(ExitCode::SUCCESS) } +/// A strict import for a milestone flow must satisfy its reviewed capture +/// contract before any fixture is written. `AwaitingRealCaptures` is accepted +/// here deliberately: importing the real pair is the operation that allows the +/// manifest to be reviewed and promoted to `ready` afterwards. +fn validate_required_import( + name: &str, + directions: &[Direction], + opts: &Opts, + cpp: &Capture, + rust: &Capture, +) -> Result<()> { + let requirement_path = flow::flows_root().join(name).join("requirement.json"); + if !requirement_path.is_file() { + return Ok(()); + } + + let requirement = flow::load_requirement(name)?; + requirement.validate_import_selection( + directions, + opts.from_opcode, + opts.until_opcode, + &opts.ignored_opcodes, + opts.strict, + )?; + requirement.validate_capture(cpp)?; + requirement.validate_capture(rust)?; + Ok(()) +} + +fn lineage_selection_for_requirement( + requirement: &flow::FlowRequirement, +) -> lineage::ImportSelection { + let ignored = requirement + .import_selection + .ignored_opcodes + .iter() + .copied() + .map(PacketBoundary::from) + .collect::>(); + lineage::ImportSelection::new( + requirement.directions.clone(), + Some(requirement.import_selection.from_opcode.into()), + Some(requirement.import_selection.until_opcode.into()), + &ignored, + true, + ) +} + fn cmd_update_baseline(args: &[String]) -> Result { let opts = parse_opts(args)?; + if opts.cpp_manifest.is_some() || opts.rust_manifest.is_some() { + bail!("raw manifest flags are accepted only by import"); + } if opts.from_opcode.is_some() || opts.until_opcode.is_some() || !opts.ignored_opcodes.is_empty() { bail!( @@ -481,12 +964,15 @@ fn print_usage() { \x20 capture-diff diff --cpp A.pkt --rust DIR [...]\n\ \x20 capture-diff show \n\ \x20 capture-diff list\n\ - \x20 capture-diff import --cpp PKT --rust DIR [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--strict]\n\ + \x20 capture-diff verify-required \n\ + \x20 capture-diff import --cpp PKT --rust DIR [--cpp-manifest JSON] [--rust-manifest JSON] [--from-opcode c2s:0xNNNN] [--until-opcode s2c:0xNNNN] [--ignore-opcode s2c:0xNNNN] [--strict]\n\ \x20 capture-diff update-baseline [--rust DIR]\n\ \n\ A flow resolves its golden C++ capture, reference Rust dump, and accepted-divergence\n\ baseline from crates/capture-diff/flows//. --strict exits non-zero when the diff\n\ - deviates from that baseline (the milestone regression gate)." + deviates from that baseline. verify-required additionally requires an operator-marked\n\ + ready manifest and refuses missing fixtures, accepted divergences, or a capture that\n\ + violates its reviewed wire shape." ); } @@ -503,6 +989,170 @@ mod tests { } } + fn routed_packet( + direction: Direction, + connection_id: u32, + opcode: u16, + ) -> capture_diff::CapturedPacket { + capture_diff::CapturedPacket { + direction, + connection_id, + opcode, + body: Vec::new(), + } + } + + fn ambient_packet( + direction: Direction, + connection_id: u32, + opcode: u16, + body: Vec, + ) -> capture_diff::CapturedPacket { + capture_diff::CapturedPacket { + direction, + connection_id, + opcode, + body, + } + } + + fn minimal_monster_move_body() -> Vec { + let mut body = vec![0x01, 0x00, 0x01]; // canonical non-empty mover GUID + body.extend_from_slice(&[0; 12]); // current XYZ + body.extend_from_slice(&[0; 4]); // spline id + body.extend_from_slice(&[0; 12]); // destination XYZ + body.push(0); // CrzTeleport + tolerance + zero padding + body.extend_from_slice(&[0; 17]); // flags/elapsed/time/fade/mode + body.extend_from_slice(&[0, 0]); // empty transport GUID + body.push(0xFF); // vehicle seat + body.extend_from_slice(&[0; 5]); // normal face, zero path/options + assert!(valid_monster_move_body(&body)); + body + } + + fn valid_required_loot_capture(source: &str) -> Capture { + let pinned = flow::load_flow("loot-single-item-claim").expect("committed loot flow"); + let mut capture = load_capture(&pinned.reference_rust).expect("committed Rust fixture"); + capture.source = source.to_string(); + capture + } + + fn reviewed_required_import_opts() -> Opts { + parse_opts(&[ + "loot-single-item-claim".into(), + "--from-opcode".into(), + "c2s:0x3211".into(), + "--until-opcode".into(), + "c2s:0x3768".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + "--direction".into(), + "both".into(), + "--strict".into(), + ]) + .unwrap() + } + + #[test] + fn strict_import_accepts_awaiting_required_contract_with_valid_shape() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + + validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .unwrap(); + } + + #[test] + fn strict_import_rejects_required_contract_direction_mismatch() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow directions must be exact"); + + assert!(error.to_string().contains("reviewed contract")); + } + + #[test] + fn strict_import_rejects_required_contract_wrong_route() { + let cpp = valid_required_loot_capture("cpp"); + let mut rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + rust.packets[2].connection_id = 0; + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow route must be exact"); + + let message = error.to_string(); + assert!(message.contains("packet 2")); + assert!(message.contains("s2c conn=1 0x2615")); + } + + #[test] + fn strict_import_rejects_required_contract_wrong_boundary() { + let mut cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let opts = reviewed_required_import_opts(); + cpp.packets + .insert(0, routed_packet(Direction::S2C, 1, 0x2DD4)); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("required flow boundary must be exact"); + + assert!(error.to_string().contains("required boundary")); + } + + #[test] + fn strict_import_rejects_an_extra_approved_ignore_for_required_flow() { + let cpp = valid_required_loot_capture("cpp"); + let rust = valid_required_loot_capture("rust"); + let mut opts = reviewed_required_import_opts(); + opts.ignored_opcodes.push(PacketBoundary { + direction: Some(Direction::S2C), + opcode: 0x2DD4, + }); + + let error = validate_required_import( + "loot-single-item-claim", + &[Direction::S2C, Direction::C2S], + &opts, + &cpp, + &rust, + ) + .expect_err("an extra filter must not be able to hide required-flow traffic"); + assert!(error.to_string().contains("reviewed contract")); + } + #[test] fn parses_directional_packet_boundary() { assert_eq!( @@ -555,7 +1205,7 @@ mod tests { vec![ packet(Direction::S2C, 0x256D), packet(Direction::C2S, 0x318C), - packet(Direction::S2C, 0x2DD4), + ambient_packet(Direction::S2C, 1, 0x2DD4, minimal_monster_move_body()), packet(Direction::S2C, 0x271C), packet(Direction::C2S, 0x3768), packet(Direction::S2C, 0x304E), @@ -577,6 +1227,50 @@ mod tests { ); } + #[test] + fn import_can_remove_only_the_reviewed_time_sync_request_response_pair() { + let opts = parse_opts(&[ + "loot-single-item-claim".into(), + "--from-opcode".into(), + "c2s:0x3211".into(), + "--until-opcode".into(), + "c2s:0x3768".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + ]) + .unwrap(); + let capture = Capture::new( + "loot-window", + vec![ + packet(Direction::C2S, 0x3211), + ambient_packet(Direction::S2C, 1, 0x2DD2, 7_u32.to_le_bytes().to_vec()), + ambient_packet(Direction::C2S, 1, 0x3A3D, { + let mut body = 7_u32.to_le_bytes().to_vec(); + body.extend_from_slice(&1234_u32.to_le_bytes()); + body + }), + packet(Direction::S2C, 0x2615), + packet(Direction::C2S, 0x3768), + ], + ); + + let selected = apply_capture_selection(capture, &opts).unwrap(); + assert_eq!( + selected + .packets + .iter() + .map(|packet| (packet.direction, packet.opcode)) + .collect::>(), + vec![ + (Direction::C2S, 0x3211), + (Direction::S2C, 0x2615), + (Direction::C2S, 0x3768), + ] + ); + } + #[test] fn ignored_opcode_requires_an_explicit_direction() { let error = parse_opts(&[ @@ -623,6 +1317,140 @@ mod tests { ); } + #[test] + fn ignored_time_sync_requires_a_well_formed_matched_pair_on_instance_socket() { + let opts = parse_opts(&[ + "loot-single-item-claim".into(), + "--ignore-opcode".into(), + "s2c:0x2DD2".into(), + "--ignore-opcode".into(), + "c2s:0x3A3D".into(), + ]) + .unwrap(); + let request = ambient_packet(Direction::S2C, 1, 0x2DD2, 9_u32.to_le_bytes().to_vec()); + let response = ambient_packet(Direction::C2S, 1, 0x3A3D, { + let mut body = 9_u32.to_le_bytes().to_vec(); + body.extend_from_slice(&44_u32.to_le_bytes()); + body + }); + assert!( + apply_capture_selection( + Capture::new("valid", vec![request.clone(), response.clone()]), + &opts + ) + .unwrap() + .packets + .is_empty() + ); + + let mut wrong_socket = request.clone(); + wrong_socket.connection_id = 0; + let error = apply_capture_selection( + Capture::new("wrong socket", vec![wrong_socket, response.clone()]), + &opts, + ) + .expect_err("wrong socket must fail"); + assert!(error.to_string().contains("expected instance connection 1")); + + let error = apply_capture_selection(Capture::new("orphan", vec![response.clone()]), &opts) + .expect_err("orphan response must fail"); + assert!(error.to_string().contains("orphan or duplicate")); + + let error = apply_capture_selection( + Capture::new( + "duplicate", + vec![request.clone(), request.clone(), response], + ), + &opts, + ) + .expect_err("duplicate request must fail"); + assert!(error.to_string().contains("duplicates time-sync request")); + + let malformed = ambient_packet(Direction::S2C, 1, 0x2DD2, vec![9, 0, 0]); + let error = apply_capture_selection(Capture::new("malformed", vec![malformed]), &opts) + .expect_err("malformed request must fail"); + assert!(error.to_string().contains("malformed 3-byte body")); + } + + #[test] + fn paired_selection_rejects_asymmetric_ambient_counts() { + let opts = parse_opts(&[ + "stand-state".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + let body = minimal_monster_move_body(); + let cpp = Capture::new("cpp", vec![ambient_packet(Direction::S2C, 1, 0x2DD4, body)]); + let rust = Capture::new("rust", Vec::new()); + let error = apply_capture_pair_selection(cpp, rust, &opts) + .expect_err("different ignore cardinality must fail"); + assert!(error.to_string().contains("count mismatch")); + } + + #[test] + fn ignored_monster_move_requires_instance_route_and_structural_body() { + let opts = parse_opts(&[ + "stand-state".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + let malformed = ambient_packet(Direction::S2C, 1, 0x2DD4, vec![1; 12]); + let error = apply_capture_selection(Capture::new("malformed", vec![malformed]), &opts) + .expect_err("truncated movement must fail"); + assert!( + error + .to_string() + .contains("malformed monster-movement body") + ); + + let valid_body = minimal_monster_move_body(); + let wrong_route = ambient_packet(Direction::S2C, 0, 0x2DD4, valid_body); + let error = apply_capture_selection(Capture::new("wrong route", vec![wrong_route]), &opts) + .expect_err("realm-routed movement must fail"); + assert!(error.to_string().contains("expected instance connection 1")); + + let mut trailing = minimal_monster_move_body(); + trailing.push(0); + let error = apply_capture_selection( + Capture::new( + "trailing bytes", + vec![ambient_packet(Direction::S2C, 1, 0x2DD4, trailing)], + ), + &opts, + ) + .expect_err("trailing movement bytes must fail"); + assert!(error.to_string().contains("malformed monster-movement")); + } + + #[test] + fn time_sync_ignore_cannot_be_declared_one_sided_or_duplicated() { + let one_sided = + parse_opts(&["flow".into(), "--ignore-opcode".into(), "s2c:0x2DD2".into()]).unwrap(); + assert!( + apply_capture_selection(Capture::new("capture", Vec::new()), &one_sided) + .unwrap_err() + .to_string() + .contains("request/response pair") + ); + + let duplicate = parse_opts(&[ + "flow".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + "--ignore-opcode".into(), + "s2c:0x2DD4".into(), + ]) + .unwrap(); + assert!( + apply_capture_selection(Capture::new("capture", Vec::new()), &duplicate) + .unwrap_err() + .to_string() + .contains("duplicated") + ); + } + #[test] fn shared_boundaries_reject_start_without_end() { let opts = parse_opts(&[ diff --git a/crates/capture-diff/src/pkt.rs b/crates/capture-diff/src/pkt.rs index c12280988..54c31efab 100644 --- a/crates/capture-diff/src/pkt.rs +++ b/crates/capture-diff/src/pkt.rs @@ -139,11 +139,17 @@ pub fn parse_pkt_bytes(bytes: &[u8]) -> Result { bail!("PKT packet Length {length} < 4 (no room for opcode)"); } let opcode = cur.u32()?; + let opcode = u16::try_from(opcode).with_context(|| { + format!( + "PKT opcode 0x{opcode:08X} exceeds the 16-bit world opcode space at offset {}", + cur.pos.saturating_sub(4) + ) + })?; let body = cur.take(length - 4)?.to_vec(); packets.push(CapturedPacket { direction, connection_id, - opcode: opcode as u16, + opcode, body, }); } diff --git a/crates/capture-diff/src/rustdump.rs b/crates/capture-diff/src/rustdump.rs index 8eef31fe9..9616fb3c7 100644 --- a/crates/capture-diff/src/rustdump.rs +++ b/crates/capture-diff/src/rustdump.rs @@ -8,18 +8,20 @@ //! packet bytes **including** the 2-byte little-endian opcode prefix. //! - `...meta` — `key=value` lines: `direction`, `addr`, `seq`, `counter`, //! `connection_id`, `opcode=0x....`, `name`, `len`. `len` is the full `.bin` -//! length (opcode prefix + body); the parser ignores it and derives the body -//! from `bin[2..]`. Legacy metadata without `connection_id` defaults to the -//! realm connection (`0`). +//! length (opcode prefix + body). All fields and the canonical filename are +//! checked; incomplete legacy sidecars are deliberately rejected because a +//! required capture is acceptance evidence, not a best-effort log import. //! `direction` is `c2s`/`s2c`, or `c2s-unencrypted`/`s2c-unencrypted` for the //! pre-encryption handshake packets — both normalize via [`Direction::from_tag`]. //! //! Packets are ordered by the global monotonic `seq` (c2s and s2c counters reset //! independently, so `seq` — not `counter` — is the cross-direction order). -use std::path::Path; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; use crate::model::{Capture, CapturedPacket, Direction}; @@ -28,85 +30,280 @@ struct Meta { direction: Direction, connection_id: u32, seq: u64, + len: usize, opcode: u16, - bin_path: std::path::PathBuf, + bin_path: PathBuf, } -fn parse_meta(path: &Path) -> Result { +const META_KEYS: [&str; 8] = [ + "direction", + "connection_id", + "addr", + "seq", + "counter", + "opcode", + "name", + "len", +]; +const RUST_CAPTURE_MANIFEST_FILE: &str = "rust.capture-manifest.json"; +const RACE_BOT_REPORT_FILE: &str = "race.bot-report.json"; + +fn validate_race_bot_report_sidecar(dir: &Path, report_path: &Path) -> Result<()> { + let manifest_path = dir.join(RUST_CAPTURE_MANIFEST_FILE); + let manifest_metadata = std::fs::symlink_metadata(&manifest_path).with_context(|| { + format!( + "race bot report requires capture manifest {}", + manifest_path.display() + ) + })?; + if manifest_metadata.file_type().is_symlink() || !manifest_metadata.file_type().is_file() { + bail!( + "race bot report capture manifest is not a regular non-symlink file: {}", + manifest_path.display() + ); + } + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(&manifest_path) + .with_context(|| format!("reading capture manifest {}", manifest_path.display()))?, + ) + .with_context(|| format!("parsing capture manifest {}", manifest_path.display()))?; + let evidence = manifest + .get("bot_report") + .and_then(serde_json::Value::as_object) + .context("race capture manifest is missing bot_report evidence")?; + let expected_sha = evidence + .get("report_sha256") + .and_then(serde_json::Value::as_str) + .context("race capture manifest bot_report SHA-256 is missing")?; + let declared_path = evidence + .get("report_path") + .and_then(serde_json::Value::as_str) + .context("race capture manifest bot_report path is missing")?; + if manifest.get("flow").and_then(serde_json::Value::as_str) + != Some("loot-two-session-atomic-race") + || evidence.get("contract").and_then(serde_json::Value::as_str) + != Some("wow-test-bot-loot-two-session-atomic-race-report-v1") + || Path::new(declared_path) + .file_name() + .and_then(|name| name.to_str()) + != Some(RACE_BOT_REPORT_FILE) + { + bail!("race bot report sidecar is not declared by the matching race manifest contract"); + } + let report = std::fs::read(report_path) + .with_context(|| format!("reading race bot report {}", report_path.display()))?; + let actual_sha = format!("{:x}", Sha256::digest(&report)); + if actual_sha != expected_sha { + bail!("race bot report SHA-256 does not match its capture manifest"); + } + Ok(()) +} + +fn parse_decimal(path: &Path, key: &str, value: &str) -> Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + value.parse::().map_err(|error| { + anyhow::anyhow!( + "meta {} has invalid {key} {value:?}: {error}", + path.display() + ) + }) +} + +fn parse_meta(path: &Path, expected_stem: &str) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("reading meta {}", path.display()))?; - let mut direction = None; - let mut connection_id = None; - let mut seq = None; - let mut opcode = None; + let mut fields = BTreeMap::new(); for line in text.lines() { let Some((key, value)) = line.split_once('=') else { - continue; + bail!("meta {} has malformed line {line:?}", path.display()); }; - match key.trim() { - "direction" => direction = Direction::from_tag(value), - "connection_id" => { - connection_id = Some(value.trim().parse::().with_context(|| { - format!( - "meta {} has invalid connection_id {:?}", - path.display(), - value.trim() - ) - })?); - } - "seq" => seq = value.trim().parse::().ok(), - "opcode" => { - let v = value - .trim() - .trim_start_matches("0x") - .trim_start_matches("0X"); - opcode = u16::from_str_radix(v, 16).ok(); - } - _ => {} + if !META_KEYS.contains(&key) { + bail!("meta {} has unknown field {key:?}", path.display()); + } + if value.trim() != value || value.is_empty() { + bail!( + "meta {} has empty or non-canonical value for {key}", + path.display() + ); } + if fields.insert(key, value).is_some() { + bail!("meta {} repeats field {key:?}", path.display()); + } + } + + for key in META_KEYS { + if !fields.contains_key(key) { + bail!("meta {} is missing field {key:?}", path.display()); + } + } + + let direction_tag = fields["direction"]; + if !matches!( + direction_tag, + "c2s" | "s2c" | "c2s-unencrypted" | "s2c-unencrypted" + ) { + bail!("meta {} has invalid direction", path.display()); + } + let direction = Direction::from_tag(direction_tag) + .with_context(|| format!("meta {} has invalid direction", path.display()))?; + fields["addr"] + .parse::() + .with_context(|| { + format!( + "meta {} has invalid socket address {:?}", + path.display(), + fields["addr"] + ) + })?; + let connection_id = parse_decimal::(path, "connection_id", fields["connection_id"])?; + if connection_id > 1 { + bail!( + "meta {} has unsupported world connection_id {connection_id}", + path.display() + ); + } + let seq = parse_decimal::(path, "seq", fields["seq"])?; + let counter = parse_decimal::(path, "counter", fields["counter"])?; + let len = parse_decimal::(path, "len", fields["len"])?; + if len < 2 { + bail!( + "meta {} declares len {len}, smaller than opcode", + path.display() + ); + } + let opcode_text = fields["opcode"]; + let Some(hex) = opcode_text.strip_prefix("0x") else { + bail!("meta {} has non-canonical opcode", path.display()); + }; + let opcode = u16::from_str_radix(hex, 16) + .with_context(|| format!("meta {} has invalid opcode", path.display()))?; + if opcode_text != format!("0x{opcode:04X}") { + bail!("meta {} has non-canonical opcode", path.display()); + } + let name = fields["name"]; + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + bail!("meta {} has unsafe packet name {name:?}", path.display()); + } + let canonical_stem = + format!("rust-{direction_tag}-{seq:08}-counter{counter}-0x{opcode:04X}-{name}-len{len}"); + if expected_stem != canonical_stem { + bail!( + "meta {} filename stem disagrees with its metadata; expected {canonical_stem:?}", + path.display() + ); } - let direction = - direction.with_context(|| format!("meta {} missing/invalid direction", path.display()))?; - let seq = seq.with_context(|| format!("meta {} missing seq", path.display()))?; - let opcode = opcode.with_context(|| format!("meta {} missing opcode", path.display()))?; let bin_path = path.with_extension("bin"); Ok(Meta { direction, - connection_id: connection_id.unwrap_or(0), + connection_id, seq, + len, opcode, bin_path, }) } +fn require_plain_directory(path: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("inspecting rust dump path {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() { + bail!( + "rust dump path is not a regular directory: {}", + path.display() + ); + } + Ok(()) +} + /// Parse a RustyCore packet dump directory into a normalized [`Capture`]. pub fn parse_rust_dump(dir: &Path) -> Result { - if !dir.is_dir() { - bail!("rust dump path is not a directory: {}", dir.display()); - } + require_plain_directory(dir)?; - let mut metas = Vec::new(); + let mut meta_paths = BTreeMap::::new(); + let mut bin_paths = BTreeMap::::new(); + let mut race_bot_report_path = None; for entry in std::fs::read_dir(dir).with_context(|| format!("reading dump dir {}", dir.display()))? { let entry = entry?; let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("meta") { - metas.push(parse_meta(&path)?); + let metadata = std::fs::symlink_metadata(&path) + .with_context(|| format!("inspecting dump entry {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + bail!( + "rust dump entry is not a regular non-symlink file: {}", + path.display() + ); + } + let file_name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("rust dump contains a non-UTF-8 filename"))?; + if file_name == RUST_CAPTURE_MANIFEST_FILE { + continue; + } + if file_name == RACE_BOT_REPORT_FILE { + race_bot_report_path = Some(path); + continue; + } + let (stem, paths) = if let Some(stem) = file_name.strip_suffix(".meta") { + (stem, &mut meta_paths) + } else if let Some(stem) = file_name.strip_suffix(".bin") { + (stem, &mut bin_paths) + } else { + bail!("unexpected file in rust dump: {}", path.display()); + }; + if stem.is_empty() || paths.insert(stem.to_string(), path.clone()).is_some() { + bail!("duplicate or empty packet stem in rust dump: {file_name}"); } } - if metas.is_empty() { + if let Some(report_path) = race_bot_report_path { + validate_race_bot_report_sidecar(dir, &report_path)?; + } + + if meta_paths.is_empty() { bail!("no .meta files found in dump dir {}", dir.display()); } + let meta_stems = meta_paths.keys().cloned().collect::>(); + let bin_stems = bin_paths.keys().cloned().collect::>(); + if meta_stems != bin_stems { + let orphan_meta = meta_stems + .difference(&bin_stems) + .cloned() + .collect::>(); + let orphan_bin = bin_stems + .difference(&meta_stems) + .cloned() + .collect::>(); + bail!( + "rust dump has unpaired packet files (orphan .meta: {orphan_meta:?}; orphan .bin: {orphan_bin:?})" + ); + } + + let mut metas = meta_paths + .iter() + .map(|(stem, path)| parse_meta(path, stem)) + .collect::>>()?; + // Global seq is the canonical cross-direction wire order. metas.sort_by_key(|m| m.seq); for pair in metas.windows(2) { - if pair[0].seq == pair[1].seq { + let Some(expected_next) = pair[0].seq.checked_add(1) else { + bail!("packet dump seq overflow after {}", pair[0].seq); + }; + if pair[1].seq != expected_next { bail!( - "duplicate packet dump seq {} in {} and {}; the world process may have restarted during capture", + "non-contiguous packet dump seq {} then {} in {} and {}; the world process may have restarted or files are missing", pair[0].seq, + pair[1].seq, pair[0].bin_path.display(), pair[1].bin_path.display(), ); @@ -117,6 +314,14 @@ pub fn parse_rust_dump(dir: &Path) -> Result { for meta in metas { let bin = std::fs::read(&meta.bin_path) .with_context(|| format!("reading dump body {}", meta.bin_path.display()))?; + if bin.len() != meta.len { + bail!( + "dump body {} has {} bytes but metadata declares {}", + meta.bin_path.display(), + bin.len(), + meta.len + ); + } if bin.len() < 2 { bail!( "dump body {} too short ({} bytes) to hold an opcode", diff --git a/crates/capture-diff/src/semantic.rs b/crates/capture-diff/src/semantic.rs index d85871b9a..ecb2854f6 100644 --- a/crates/capture-diff/src/semantic.rs +++ b/crates/capture-diff/src/semantic.rs @@ -1,23 +1,68 @@ //! Narrow semantic packet comparators for fields that are intentionally -//! runtime-allocated and therefore cannot be compared byte-for-byte. +//! runtime-allocated, or for a specifically proven accumulated update-mask +//! artifact, and therefore cannot be compared byte-for-byte. //! //! Keep this module deliberately small. A semantic comparator is allowed to //! omit only a field whose value cannot be made stable across equivalent C++ -//! and Rust runs; every other decoded bit remains part of the comparison. +//! and Rust runs, or one exact empty mask fragment whose cadence was reproduced +//! independently; every other decoded bit remains part of the comparison. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::model::Direction; +use crate::model::{Capture, Direction}; + +/// `CMSG_LOOT_ITEM` in the 3.4.3 opcode table. +pub const CMSG_LOOT_ITEM: u16 = 0x3211; /// `SMSG_LOG_XP_GAIN` in the 3.4.3 opcode table. pub const SMSG_LOG_XP_GAIN: u16 = 0x26E5; +/// `SMSG_LOOT_REMOVED` in the 3.4.3 opcode table. +pub const SMSG_LOOT_REMOVED: u16 = 0x2615; + +/// `SMSG_ITEM_PUSH_RESULT` in the 3.4.3 opcode table. +pub const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; + +/// `SMSG_UPDATE_OBJECT` in the 3.4.3 opcode table. +pub const SMSG_UPDATE_OBJECT: u16 = 0x27CB; + +/// `CMSG_PING` used as the deterministic end fence of the issue-#106 flow. +pub const CMSG_PING: u16 = 0x3768; + const OBJECT_GUID_COUNTER_MASK: u64 = 0x0000_00FF_FFFF_FFFF; const HIGH_GUID_CREATURE: u8 = 8; +const HIGH_GUID_ITEM: u8 = 3; +const HIGH_GUID_LOOT_OBJECT: u8 = 15; +const HIGH_GUID_PLAYER: u8 = 2; +const GLOBAL_GUID_RESERVED_HIGH_BITS_MASK: u64 = (1_u64 << 42) - 1; const XP_GAIN_REASON_KILL: u8 = 0; +const VALUES_TYPE_UNIT: u32 = 1 << 5; +const VALUES_TYPE_ACTIVE_PLAYER: u32 = 1 << 7; +const UNIT_POWER_PARENT_BLOCKS_MASK: u8 = 1 << 3; +const UNIT_POWER_PARENT_BLOCK_3: u32 = 1 << 20; +const ISSUE_106_CAPTURE_PLAYER_LOW: u64 = 15; +const ISSUE_106_CAPTURE_PLAYER_HIGH: u64 = 0x0800_0400_0000_0000; +const ISSUE_106_CAPTURE_ITEM_HIGH: u64 = 0x0C00_0400_0000_0000; +const ISSUE_106_CREATURE_IDENTITY: StableObjectGuid = StableObjectGuid { + high_type: HIGH_GUID_CREATURE, + realm_id: 1, + map_id: 530, + entry: 21_779, + subtype: 0, + server_id: 0, +}; +const ISSUE_106_ITEM_ENTRY: i32 = 30_712; +// Doctor Maleficus' key is stored in the first keyring slot in the restored +// issue-#106 fixture. Pinning the observed C++ destination keeps a bank, +// equipment, or generic inventory update from satisfying this capture. +const ISSUE_106_ITEM_SLOT: i32 = 106; +const ISSUE_106_ITEM_DYNAMIC_FLAGS: u32 = 0x0020_0001; +const ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN: usize = 220; +const ISSUE_106_PING_BODY: [u8; 8] = [b'T', b'O', b'O', b'L', 0, 0, 0, 0]; -/// Stable identity fields of the victim ObjectGuid in `SMSG_LOG_XP_GAIN`. +/// Stable identity fields of a world-object `ObjectGuid` whose map-runtime +/// counter has one narrowly reviewed normalization. /// /// TrinityCore creates world-object GUIDs as: /// @@ -50,6 +95,47 @@ pub struct LogXpGainBody { pub group_bonus_bits: u32, } +/// Exact identity of a packed 128-bit `ObjectGuid` whose runtime component is +/// part of the reviewed wire contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExactObjectGuid { + pub low: u64, + pub high: u64, +} + +/// Stable semantic representation of a 3.4.3 `SMSG_LOOT_REMOVED` body. +/// +/// Only the lower 40-bit map-runtime counter of a Creature `owner` is absent. +/// The complete loot-object GUID (including its own counter) and list id remain +/// exact so this comparator cannot hide allocation or slot divergences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LootRemovedBody { + pub owner: StableObjectGuid, + pub loot_obj: ExactObjectGuid, + pub loot_list_id: u8, +} + +/// One exact `ActivePlayerData::InvSlots` value in a normalized player VALUES +/// update. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InvSlotValue { + pub slot: u16, + pub item: ExactObjectGuid, +} + +/// Stable representation of the one-player, one-block VALUES update observed +/// after the issue-#106 item claim. +/// +/// C++ carried an empty UnitData parent bit 116 while Rust did not. That one +/// cadence-only mask is absent here; map, player and the complete ActivePlayer +/// InvSlots update remain exact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateObjectInvSlotsBody { + pub map_id: u16, + pub player: ExactObjectGuid, + pub inv_slots: Vec, +} + /// One side of a semantic comparison. A malformed body remains an explicit /// divergence even when both sides happen to contain the same malformed bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -57,16 +143,24 @@ pub struct SemanticBodySide { #[serde(skip_serializing_if = "Option::is_none", default)] pub log_xp_gain: Option, #[serde(skip_serializing_if = "Option::is_none", default)] + pub loot_removed: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub update_object_inv_slots: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] pub decode_error: Option, /// Strict identity for a raw body that is not eligible for runtime-counter - /// normalization. Only a valid creature-kill body with a nonzero counter - /// omits this digest; invalid and valid non-kill sides retain it. + /// normalization. Only a valid reviewed Creature shape with a nonzero + /// counter omits this digest; invalid and valid non-eligible sides retain + /// it. #[serde(skip_serializing_if = "Option::is_none", default)] pub raw_body_sha256: Option, } impl SemanticBodySide { - fn from_decoded(decoded: Result, raw_body: &[u8]) -> Self { + fn from_decoded_log_xp_gain( + decoded: Result, + raw_body: &[u8], + ) -> Self { match decoded { Ok(decoded) if decoded.body.reason == XP_GAIN_REASON_KILL @@ -75,6 +169,8 @@ impl SemanticBodySide { { Self { log_xp_gain: Some(decoded.body), + loot_removed: None, + update_object_inv_slots: None, decode_error: Some( "kill XP creature victim has a zero runtime GUID counter".to_string(), ), @@ -83,11 +179,76 @@ impl SemanticBodySide { } Ok(decoded) => Self { log_xp_gain: Some(decoded.body), + loot_removed: None, + update_object_inv_slots: None, decode_error: None, raw_body_sha256: (!decoded.is_creature_kill()).then(|| raw_body_sha256(raw_body)), }, Err(error) => Self { log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(error), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + } + } + + fn from_decoded_loot_removed( + decoded: Result, + raw_body: &[u8], + ) -> Self { + match decoded { + Ok(decoded) => { + let shape_error = decoded.issue_106_shape_error(); + let reviewed_shape = decoded.is_issue_106_reviewed_shape(); + Self { + log_xp_gain: None, + loot_removed: Some(decoded.body), + update_object_inv_slots: None, + decode_error: shape_error, + // Only the exact issue-#106 Doctor/LootObject/list shape may + // omit the Creature runtime counter. A different Creature + // remains byte-strict even if its stable high fields happen + // to match another packet. + raw_body_sha256: (!reviewed_shape).then(|| raw_body_sha256(raw_body)), + } + } + Err(error) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(error), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + } + } + + fn from_update_object_inv_slots_decode( + decoded: UpdateObjectInvSlotsDecode, + raw_body: &[u8], + ) -> Self { + match decoded { + UpdateObjectInvSlotsDecode::Candidate(decoded) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: Some(decoded.body), + decode_error: None, + raw_body_sha256: None, + }, + UpdateObjectInvSlotsDecode::NotEligible(reason) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, + decode_error: Some(format!( + "not the reviewed single-player InvSlots VALUES shape: {reason}" + )), + raw_body_sha256: Some(raw_body_sha256(raw_body)), + }, + UpdateObjectInvSlotsDecode::Malformed(error) => Self { + log_xp_gain: None, + loot_removed: None, + update_object_inv_slots: None, decode_error: Some(error), raw_body_sha256: Some(raw_body_sha256(raw_body)), }, @@ -115,6 +276,8 @@ impl SemanticBodyDiff { self.cpp.decode_error.is_none() && self.rust.decode_error.is_none() && self.cpp.log_xp_gain == self.rust.log_xp_gain + && self.cpp.loot_removed == self.rust.loot_removed + && self.cpp.update_object_inv_slots == self.rust.update_object_inv_slots && self.cpp.raw_body_sha256 == self.rust.raw_body_sha256 } @@ -128,57 +291,150 @@ impl SemanticBodyDiff { return format!("Rust decode error: {error}"); } - let Some(cpp) = self.cpp.log_xp_gain else { - return "C++ semantic body is missing".to_string(); - }; - let Some(rust) = self.rust.log_xp_gain else { - return "Rust semantic body is missing".to_string(); - }; - - let mut fields = Vec::new(); - if cpp.victim.high_type != rust.victim.high_type { - fields.push("victim.high_type"); - } - if cpp.victim.realm_id != rust.victim.realm_id { - fields.push("victim.realm_id"); + if let (Some(cpp), Some(rust)) = (self.cpp.log_xp_gain, self.rust.log_xp_gain) { + return mismatch_log_xp_gain(cpp, rust); } - if cpp.victim.map_id != rust.victim.map_id { - fields.push("victim.map_id"); - } - if cpp.victim.entry != rust.victim.entry { - fields.push("victim.entry"); - } - if cpp.victim.subtype != rust.victim.subtype { - fields.push("victim.subtype"); - } - if cpp.victim.server_id != rust.victim.server_id { - fields.push("victim.server_id"); - } - if cpp.original != rust.original { - fields.push("original"); - } - if cpp.reason != rust.reason { - fields.push("reason"); - } - if cpp.amount != rust.amount { - fields.push("amount"); + + if let (Some(cpp), Some(rust)) = (self.cpp.loot_removed, self.rust.loot_removed) { + return mismatch_loot_removed(cpp, rust, &self.cpp, &self.rust); } - if cpp.group_bonus_bits != rust.group_bonus_bits { - fields.push("group_bonus"); + + if let (Some(cpp), Some(rust)) = ( + self.cpp.update_object_inv_slots.as_ref(), + self.rust.update_object_inv_slots.as_ref(), + ) { + return mismatch_update_object_inv_slots(cpp, rust); } - if fields.is_empty() { + "semantic body shape differs or is missing".to_string() + } +} + +fn mismatch_log_xp_gain(cpp: LogXpGainBody, rust: LogXpGainBody) -> String { + let mut fields = Vec::new(); + if cpp.victim.high_type != rust.victim.high_type { + fields.push("victim.high_type"); + } + if cpp.victim.realm_id != rust.victim.realm_id { + fields.push("victim.realm_id"); + } + if cpp.victim.map_id != rust.victim.map_id { + fields.push("victim.map_id"); + } + if cpp.victim.entry != rust.victim.entry { + fields.push("victim.entry"); + } + if cpp.victim.subtype != rust.victim.subtype { + fields.push("victim.subtype"); + } + if cpp.victim.server_id != rust.victim.server_id { + fields.push("victim.server_id"); + } + if cpp.original != rust.original { + fields.push("original"); + } + if cpp.reason != rust.reason { + fields.push("reason"); + } + if cpp.amount != rust.amount { + fields.push("amount"); + } + if cpp.group_bonus_bits != rust.group_bonus_bits { + fields.push("group_bonus"); + } + + if fields.is_empty() { + "semantic values are equal".to_string() + } else { + format!("mismatched field(s): {}", fields.join(", ")) + } +} + +fn mismatch_loot_removed( + cpp: LootRemovedBody, + rust: LootRemovedBody, + cpp_side: &SemanticBodySide, + rust_side: &SemanticBodySide, +) -> String { + let mut fields = Vec::new(); + if cpp.owner.high_type != rust.owner.high_type { + fields.push("owner.high_type"); + } + if cpp.owner.realm_id != rust.owner.realm_id { + fields.push("owner.realm_id"); + } + if cpp.owner.map_id != rust.owner.map_id { + fields.push("owner.map_id"); + } + if cpp.owner.entry != rust.owner.entry { + fields.push("owner.entry"); + } + if cpp.owner.subtype != rust.owner.subtype { + fields.push("owner.subtype"); + } + if cpp.owner.server_id != rust.owner.server_id { + fields.push("owner.server_id"); + } + if cpp.loot_obj.low != rust.loot_obj.low { + fields.push("loot_obj.low"); + } + if cpp.loot_obj.high != rust.loot_obj.high { + fields.push("loot_obj.high"); + } + if cpp.loot_list_id != rust.loot_list_id { + fields.push("loot_list_id"); + } + + if fields.is_empty() { + if cpp_side.raw_body_sha256 == rust_side.raw_body_sha256 { "semantic values are equal".to_string() } else { - format!("mismatched field(s): {}", fields.join(", ")) + "raw body identity differs outside the normalized Creature-owner shape".to_string() } + } else { + format!("mismatched field(s): {}", fields.join(", ")) + } +} + +fn mismatch_update_object_inv_slots( + cpp: &UpdateObjectInvSlotsBody, + rust: &UpdateObjectInvSlotsBody, +) -> String { + let mut fields = Vec::new(); + if cpp.map_id != rust.map_id { + fields.push("map_id"); + } + if cpp.player.low != rust.player.low || cpp.player.high != rust.player.high { + fields.push("player"); + } + if cpp.inv_slots.len() == rust.inv_slots.len() { + for (cpp_slot, rust_slot) in cpp.inv_slots.iter().zip(&rust.inv_slots) { + if cpp_slot.slot != rust_slot.slot { + fields.push("inv_slots.slot"); + } + if cpp_slot.item != rust_slot.item { + fields.push("inv_slots.item"); + } + } + } else { + fields.push("inv_slots.length"); + } + + fields.sort_unstable(); + fields.dedup(); + if fields.is_empty() { + "semantic values are equal".to_string() + } else { + format!("mismatched field(s): {}", fields.join(", ")) } } /// Compare packet bodies semantically when a reviewed narrow comparator exists. /// /// Routing is not normalized here: [`crate::diff::DiffReport`] still compares -/// `connection_id`, so `SMSG_LOG_XP_GAIN` must use the realm socket like C++. +/// `connection_id`, so `SMSG_LOG_XP_GAIN` must use the realm socket and +/// `SMSG_LOOT_REMOVED` / the reviewed `SMSG_UPDATE_OBJECT` must use the +/// instance socket like C++. #[must_use] pub fn compare_packet_bodies( direction: Direction, @@ -186,10 +442,19 @@ pub fn compare_packet_bodies( cpp: &[u8], rust: &[u8], ) -> Option { - if direction != Direction::S2C || opcode != SMSG_LOG_XP_GAIN { + if direction != Direction::S2C { return None; } + match opcode { + SMSG_LOG_XP_GAIN => compare_log_xp_gain_bodies(cpp, rust), + SMSG_LOOT_REMOVED => compare_loot_removed_bodies(cpp, rust), + SMSG_UPDATE_OBJECT => compare_update_object_inv_slots_bodies(cpp, rust), + _ => None, + } +} + +fn compare_log_xp_gain_bodies(cpp: &[u8], rust: &[u8]) -> Option { let cpp_decoded = decode_log_xp_gain_body_with_counter(cpp); let rust_decoded = decode_log_xp_gain_body_with_counter(rust); @@ -212,8 +477,105 @@ pub fn compare_packet_bodies( Some(SemanticBodyDiff { comparator: "smsg_log_xp_gain_without_runtime_guid_counter".to_string(), - cpp: SemanticBodySide::from_decoded(cpp_decoded, cpp), - rust: SemanticBodySide::from_decoded(rust_decoded, rust), + cpp: SemanticBodySide::from_decoded_log_xp_gain(cpp_decoded, cpp), + rust: SemanticBodySide::from_decoded_log_xp_gain(rust_decoded, rust), + }) +} + +fn compare_loot_removed_bodies(cpp: &[u8], rust: &[u8]) -> Option { + let cpp_decoded = decode_loot_removed_body_with_counter(cpp); + let rust_decoded = decode_loot_removed_body_with_counter(rust); + + // Normalize only the exact Doctor/LootObject/list shape proven by the + // paired issue-#106 capture. A generic Creature predicate would conflate + // two same-entry world objects because the omitted counter is their only + // per-instance wire identity. The bot's fixture preflight additionally + // proves that this entry has one SQL spawn on map 530. + let cpp_has_reviewed_owner = cpp_decoded + .as_ref() + .is_ok_and(DecodedLootRemovedBody::has_issue_106_owner_identity); + let rust_has_reviewed_owner = rust_decoded + .as_ref() + .is_ok_and(DecodedLootRemovedBody::has_issue_106_owner_identity); + if cpp_decoded.is_ok() + && rust_decoded.is_ok() + && !cpp_has_reviewed_owner + && !rust_has_reviewed_owner + { + return None; + } + + Some(SemanticBodyDiff { + comparator: "smsg_loot_removed_without_creature_owner_runtime_guid_counter".to_string(), + cpp: SemanticBodySide::from_decoded_loot_removed(cpp_decoded, cpp), + rust: SemanticBodySide::from_decoded_loot_removed(rust_decoded, rust), + }) +} + +fn compare_update_object_inv_slots_bodies(cpp: &[u8], rust: &[u8]) -> Option { + let cpp_decoded = decode_update_object_inv_slots_candidate(cpp); + let rust_decoded = decode_update_object_inv_slots_candidate(rust); + + let cpp_candidate = cpp_decoded.candidate(); + let rust_candidate = rust_decoded.candidate(); + match (cpp_candidate, rust_candidate) { + // This is the only accepted asymmetry: C++ accumulated UnitData parent + // bit 116 with no child or payload; Rust emitted only the identical + // ActivePlayer InvSlots delta. + (Some(cpp), Some(rust)) + if cpp.has_empty_unit_power_parent && !rust.has_empty_unit_power_parent => {} + // Identical orientation has no normalization to perform. Leave these + // UpdateObject bodies under ordinary byte comparison. + (Some(cpp), Some(rust)) + if cpp.has_empty_unit_power_parent == rust.has_empty_unit_power_parent => + { + return None; + } + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) => + { + return None; + } + // A non-candidate packet paired with a malformed candidate-shaped + // packet is not evidence that this narrowly reviewed asymmetry is in + // play. Keep the ordinary raw-body diff in either orientation. Two + // malformed candidate-shaped packets remain fail-closed below. + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::Malformed(_)) => + { + return None; + } + (None, None) + if matches!(cpp_decoded, UpdateObjectInvSlotsDecode::Malformed(_)) + && matches!(rust_decoded, UpdateObjectInvSlotsDecode::NotEligible(_)) => + { + return None; + } + _ => {} + } + + let reverse_orientation = matches!( + (cpp_candidate, rust_candidate), + (Some(cpp), Some(rust)) + if !cpp.has_empty_unit_power_parent && rust.has_empty_unit_power_parent + ); + let mut cpp_side = SemanticBodySide::from_update_object_inv_slots_decode(cpp_decoded, cpp); + let mut rust_side = SemanticBodySide::from_update_object_inv_slots_decode(rust_decoded, rust); + if reverse_orientation { + let error = "empty UnitData Power parent appears only on Rust; normalization is C++-only" + .to_string(); + cpp_side.decode_error = Some(error.clone()); + cpp_side.raw_body_sha256 = Some(raw_body_sha256(cpp)); + rust_side.decode_error = Some(error); + rust_side.raw_body_sha256 = Some(raw_body_sha256(rust)); + } + + Some(SemanticBodyDiff { + comparator: "smsg_update_object_without_cpp_empty_unit_power_parent".to_string(), + cpp: cpp_side, + rust: rust_side, }) } @@ -223,12 +585,830 @@ struct DecodedLogXpGainBody { runtime_counter: u64, } +#[derive(Debug, Clone, Copy)] +struct DecodedLootRemovedBody { + body: LootRemovedBody, + owner_runtime_counter: u64, +} + +#[derive(Debug, Clone)] +struct DecodedUpdateObjectInvSlotsBody { + body: UpdateObjectInvSlotsBody, + has_empty_unit_power_parent: bool, +} + +#[derive(Debug)] +enum UpdateObjectInvSlotsDecode { + Candidate(DecodedUpdateObjectInvSlotsBody), + NotEligible(&'static str), + Malformed(String), +} + +impl UpdateObjectInvSlotsDecode { + fn candidate(&self) -> Option<&DecodedUpdateObjectInvSlotsBody> { + match self { + Self::Candidate(decoded) => Some(decoded), + Self::NotEligible(_) | Self::Malformed(_) => None, + } + } +} + +enum UpdateObjectInvSlotsFailure { + NotEligible(&'static str), + Malformed(String), +} + +impl DecodedLootRemovedBody { + fn has_issue_106_owner_identity(&self) -> bool { + self.body.owner == ISSUE_106_CREATURE_IDENTITY + } + + fn issue_106_shape_error(&self) -> Option { + if !self.has_issue_106_owner_identity() { + return None; + } + if self.owner_runtime_counter == 0 { + return Some( + "loot-removed issue-#106 Creature owner has a zero runtime GUID counter" + .to_string(), + ); + } + + if let Some(error) = issue_106_loot_object_error(self.body.loot_obj) { + return Some(error); + } + if self.body.loot_list_id != 0 { + return Some("loot_list_id is not the reviewed deterministic slot 0".to_string()); + } + None + } + + fn is_issue_106_reviewed_shape(&self) -> bool { + self.has_issue_106_owner_identity() && self.issue_106_shape_error().is_none() + } +} + +fn issue_106_loot_object_error(loot_obj: ExactObjectGuid) -> Option { + let stable = stable_object_guid(loot_obj.low, loot_obj.high); + if stable.high_type != HIGH_GUID_LOOT_OBJECT + || stable.realm_id != ISSUE_106_CREATURE_IDENTITY.realm_id + || stable.map_id != ISSUE_106_CREATURE_IDENTITY.map_id + || stable.entry != 0 + || stable.subtype != 0 + || stable.server_id != 0 + { + return Some("loot_obj.high is not the reviewed map-530 LootObject identity".to_string()); + } + if loot_obj.low & OBJECT_GUID_COUNTER_MASK == 0 { + return Some("loot_obj.low has a zero runtime GUID counter".to_string()); + } + None +} + impl DecodedLogXpGainBody { fn is_creature_kill(&self) -> bool { self.body.reason == XP_GAIN_REASON_KILL && self.body.victim.high_type == HIGH_GUID_CREATURE } } +fn decode_update_object_inv_slots_candidate(body: &[u8]) -> UpdateObjectInvSlotsDecode { + match decode_update_object_inv_slots_candidate_inner(body) { + Ok(decoded) => UpdateObjectInvSlotsDecode::Candidate(decoded), + Err(UpdateObjectInvSlotsFailure::NotEligible(reason)) => { + UpdateObjectInvSlotsDecode::NotEligible(reason) + } + Err(UpdateObjectInvSlotsFailure::Malformed(error)) => { + UpdateObjectInvSlotsDecode::Malformed(error) + } + } +} + +fn decode_update_object_inv_slots_candidate_inner( + body: &[u8], +) -> Result { + let mut cursor = 0usize; + let num_updates = read_u32(body, &mut cursor, "NumObjUpdates").map_err(|_| { + UpdateObjectInvSlotsFailure::NotEligible("body is too short for NumObjUpdates") + })?; + if num_updates != 1 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "NumObjUpdates is not exactly one", + )); + } + + let map_id = + read_u16(body, &mut cursor, "MapID").map_err(UpdateObjectInvSlotsFailure::Malformed)?; + let destroy_or_out_of_range = read_u8(body, &mut cursor, "HasDestroyOrOutOfRange byte") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if destroy_or_out_of_range == 0x80 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "packet carries destroy or out-of-range GUIDs", + )); + } + if destroy_or_out_of_range != 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "HasDestroyOrOutOfRange byte has non-canonical padding bits: 0x{destroy_or_out_of_range:02X}" + ))); + } + + let declared_blocks_len = read_u32(body, &mut cursor, "update blocks length") + .map_err(UpdateObjectInvSlotsFailure::Malformed)? as usize; + let actual_blocks_len = body.len().saturating_sub(cursor); + if declared_blocks_len != actual_blocks_len { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "update blocks length declares {declared_blocks_len} bytes but {actual_blocks_len} remain" + ))); + } + let blocks = &body[cursor..]; + let mut block_cursor = 0usize; + let update_type = read_u8(blocks, &mut block_cursor, "UpdateType") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if update_type != 0 { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "the sole update block is not UpdateType::Values", + )); + } + + let (player_low, player_high) = read_packed_guid(blocks, &mut block_cursor, "Player") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if ((player_high >> 58) & 0x3F) as u8 != HIGH_GUID_PLAYER { + return Err(UpdateObjectInvSlotsFailure::NotEligible( + "the VALUES owner is not a Player GUID", + )); + } + if player_low == 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed( + "Player GUID has a zero low identity".to_string(), + )); + } + if player_high & GLOBAL_GUID_RESERVED_HIGH_BITS_MASK != 0 { + return Err(UpdateObjectInvSlotsFailure::Malformed( + "Player GUID sets reserved high-word bits 0..41".to_string(), + )); + } + + let declared_values_len = read_u32(blocks, &mut block_cursor, "values length") + .map_err(UpdateObjectInvSlotsFailure::Malformed)? as usize; + let actual_values_len = blocks.len().saturating_sub(block_cursor); + if declared_values_len != actual_values_len { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "values length declares {declared_values_len} bytes but {actual_values_len} remain" + ))); + } + let values = &blocks[block_cursor..]; + let mut values_cursor = 0usize; + let has_empty_unit_power_parent = + decode_reviewed_changed_object_type_mask(values, &mut values_cursor)?; + + if has_empty_unit_power_parent { + decode_empty_unit_power_parent(values, &mut values_cursor)?; + } + + let inv_slots = decode_active_player_inv_slots(values, &mut values_cursor) + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if values_cursor != values.len() { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "trailing bytes after ActivePlayer InvSlots: decoded {values_cursor} of {} bytes", + values.len() + ))); + } + + Ok(DecodedUpdateObjectInvSlotsBody { + body: UpdateObjectInvSlotsBody { + map_id, + player: ExactObjectGuid { + low: player_low, + high: player_high, + }, + inv_slots, + }, + has_empty_unit_power_parent, + }) +} + +fn decode_reviewed_changed_object_type_mask( + values: &[u8], + cursor: &mut usize, +) -> Result { + let changed_object_type_mask = read_u32(values, cursor, "ChangedObjectTypeMask") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + match changed_object_type_mask { + VALUES_TYPE_ACTIVE_PLAYER => Ok(false), + mask if mask == VALUES_TYPE_UNIT | VALUES_TYPE_ACTIVE_PLAYER => Ok(true), + _ => Err(UpdateObjectInvSlotsFailure::NotEligible( + "ChangedObjectTypeMask is not ActivePlayer-only or Unit+ActivePlayer", + )), + } +} + +fn decode_empty_unit_power_parent( + values: &[u8], + cursor: &mut usize, +) -> Result<(), UpdateObjectInvSlotsFailure> { + let blocks_mask = read_u8(values, cursor, "UnitData blocks mask") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if blocks_mask != UNIT_POWER_PARENT_BLOCKS_MASK { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "UnitData blocks mask is 0x{blocks_mask:02X}, expected only block 3" + ))); + } + + let block_3 = read_u32_be(values, cursor, "UnitData block 3") + .map_err(UpdateObjectInvSlotsFailure::Malformed)?; + if block_3 != UNIT_POWER_PARENT_BLOCK_3 { + return Err(UpdateObjectInvSlotsFailure::Malformed(format!( + "UnitData block 3 is 0x{block_3:08X}, expected only parent bit 116" + ))); + } + Ok(()) +} + +fn decode_active_player_inv_slots( + values: &[u8], + cursor: &mut usize, +) -> Result, String> { + let blocks_group_0 = read_u32(values, cursor, "ActivePlayer blocks group 0")?; + let blocks_group_1 = read_u16_be(values, cursor, "ActivePlayer blocks group 1")?; + if blocks_group_1 != 0 { + return Err(format!( + "ActivePlayer blocks group 1 is 0x{blocks_group_1:04X}; InvSlots uses only blocks 3-8" + )); + } + + let mut blocks = [0u32; 32]; + for (index, block) in blocks.iter_mut().enumerate() { + if blocks_group_0 & (1 << index) != 0 { + *block = read_u32_be(values, cursor, "ActivePlayer block")?; + if *block == 0 { + return Err(format!("ActivePlayer group mask names empty block {index}")); + } + } + } + + let mut has_inv_slots_parent = false; + let mut changed_slots = Vec::new(); + for (block_index, block) in blocks.iter().copied().enumerate() { + for bit in 0..32u32 { + if block & (1 << bit) == 0 { + continue; + } + let field = block_index as u32 * 32 + bit; + match field { + 124 => has_inv_slots_parent = true, + 125..=265 => changed_slots.push((field - 125) as u16), + _ => { + return Err(format!( + "ActivePlayer mask contains non-InvSlots field {field}" + )); + } + } + } + } + if !has_inv_slots_parent { + return Err("ActivePlayer InvSlots parent bit 124 is absent".to_string()); + } + if changed_slots.len() != 1 { + return Err(format!( + "reviewed loot update requires exactly one InvSlots child, found {}", + changed_slots.len() + )); + } + + let mut inv_slots = Vec::with_capacity(1); + for slot in changed_slots { + let (item_low, item_high) = read_packed_guid(values, cursor, "InvSlots item")?; + if ((item_high >> 58) & 0x3F) as u8 != HIGH_GUID_ITEM || item_low == 0 { + return Err(format!("InvSlots[{slot}] is not a non-empty Item GUID")); + } + if item_high & GLOBAL_GUID_RESERVED_HIGH_BITS_MASK != 0 { + return Err(format!( + "InvSlots[{slot}] Item GUID sets reserved high-word bits 0..41" + )); + } + inv_slots.push(InvSlotValue { + slot, + item: ExactObjectGuid { + low: item_low, + high: item_high, + }, + }); + } + Ok(inv_slots) +} + +#[derive(Debug, Clone, Copy)] +struct DecodedSingleLootItemRequest { + loot_obj: ExactObjectGuid, + loot_list_id: u8, +} + +#[derive(Debug, Clone, Copy)] +struct DecodedIssue106ItemPushResult { + player: ExactObjectGuid, + slot_in_bag: i32, + quantity: i32, + item_guid: ExactObjectGuid, + item_entry: i32, +} + +#[derive(Debug, Clone, Copy)] +struct DecodedIssue106ItemCreate { + map_id: u16, + item_guid: ExactObjectGuid, + owner: ExactObjectGuid, + contained_in: ExactObjectGuid, + item_entry: i32, + stack_count: u32, +} + +/// Validate the complete, correlated payload contract of the issue-#106 +/// single-item capture. +/// +/// Opcode equality alone is insufficient evidence: an unrelated item push or +/// two identically malformed packets could otherwise compare clean. This pins +/// the one-request count, deterministic fixture identities, request/removal +/// LootObject and list id, recipient, quantity, awarded Item GUID, inventory +/// item CreateObject, slot update, and fixed ping fence on each side +/// independently. +pub fn validate_loot_single_item_claim_capture(capture: &Capture) -> Result<(), String> { + validate_issue_106_capture_topology(capture)?; + + let request = decode_single_loot_item_request(&capture.packets[0].body)?; + let created = decode_issue_106_item_create(&capture.packets[1].body)?; + let removed = decode_loot_removed_body_with_counter(&capture.packets[2].body)?; + validate_issue_106_request_removal(request, removed)?; + + let pushed = decode_issue_106_item_push_result(&capture.packets[3].body)?; + validate_issue_106_created_grant(created, pushed)?; + validate_issue_106_inventory_update(&capture.packets[4].body, pushed)?; + + if capture.packets[5].body != ISSUE_106_PING_BODY { + return Err(format!( + "CMSG_PING fence body is {:02X?}, expected fixed TOOL/zero-latency body {:02X?}", + capture.packets[5].body, ISSUE_106_PING_BODY + )); + } + Ok(()) +} + +fn validate_issue_106_capture_topology(capture: &Capture) -> Result<(), String> { + const EXPECTED: [(Direction, u32, u16); 6] = [ + (Direction::C2S, 1, CMSG_LOOT_ITEM), + (Direction::S2C, 1, SMSG_UPDATE_OBJECT), + (Direction::S2C, 1, SMSG_LOOT_REMOVED), + (Direction::S2C, 0, SMSG_ITEM_PUSH_RESULT), + (Direction::S2C, 1, SMSG_UPDATE_OBJECT), + (Direction::C2S, 1, CMSG_PING), + ]; + if capture.packets.len() != EXPECTED.len() { + return Err(format!( + "{} contains {} packet(s); the semantic loot-claim contract requires exactly {}", + capture.source, + capture.packets.len(), + EXPECTED.len() + )); + } + for (index, (packet, (direction, connection_id, opcode))) in + capture.packets.iter().zip(EXPECTED).enumerate() + { + if packet.direction != direction + || packet.connection_id != connection_id + || packet.opcode != opcode + { + return Err(format!( + "{} packet {index} is {} conn={} 0x{:04X}; semantic contract requires {} conn={} 0x{opcode:04X}", + capture.source, + packet.direction, + packet.connection_id, + packet.opcode, + direction, + connection_id + )); + } + } + Ok(()) +} + +fn validate_issue_106_request_removal( + request: DecodedSingleLootItemRequest, + removed: DecodedLootRemovedBody, +) -> Result<(), String> { + if !removed.is_issue_106_reviewed_shape() { + return Err(removed.issue_106_shape_error().unwrap_or_else(|| { + format!( + "loot-removed owner {:?} is not the reviewed issue-#106 Doctor identity", + removed.body.owner + ) + })); + } + if request.loot_obj != removed.body.loot_obj { + return Err(format!( + "CMSG_LOOT_ITEM LootObj {:?} does not match SMSG_LOOT_REMOVED {:?}", + request.loot_obj, removed.body.loot_obj + )); + } + if request.loot_list_id != removed.body.loot_list_id { + return Err(format!( + "CMSG_LOOT_ITEM LootListID {} does not match SMSG_LOOT_REMOVED {}", + request.loot_list_id, removed.body.loot_list_id + )); + } + Ok(()) +} + +fn validate_issue_106_created_grant( + created: DecodedIssue106ItemCreate, + pushed: DecodedIssue106ItemPushResult, +) -> Result<(), String> { + if created.map_id != ISSUE_106_CREATURE_IDENTITY.map_id { + return Err(format!( + "item CreateObject map {} does not match loot owner map {}", + created.map_id, ISSUE_106_CREATURE_IDENTITY.map_id + )); + } + if created.owner != pushed.player || created.contained_in != pushed.player { + return Err(format!( + "item CreateObject owner/contained {:?}/{:?} does not match ItemPushResult player {:?}", + created.owner, created.contained_in, pushed.player + )); + } + if created.item_guid != pushed.item_guid { + return Err(format!( + "item CreateObject GUID {:?} does not match ItemPushResult {:?}", + created.item_guid, pushed.item_guid + )); + } + if created.item_entry != pushed.item_entry { + return Err(format!( + "item CreateObject entry {} does not match ItemPushResult {}", + created.item_entry, pushed.item_entry + )); + } + if created.stack_count != pushed.quantity as u32 { + return Err(format!( + "item CreateObject stack {} does not match ItemPushResult quantity {}", + created.stack_count, pushed.quantity + )); + } + if pushed.quantity != 1 || pushed.item_entry != ISSUE_106_ITEM_ENTRY { + return Err(format!( + "ItemPushResult is item {}/quantity {}, expected fixture item {ISSUE_106_ITEM_ENTRY}/quantity 1", + pushed.item_entry, pushed.quantity + )); + } + Ok(()) +} + +fn validate_issue_106_inventory_update( + body: &[u8], + pushed: DecodedIssue106ItemPushResult, +) -> Result<(), String> { + let inventory_update = match decode_update_object_inv_slots_candidate(body) { + UpdateObjectInvSlotsDecode::Candidate(decoded) => decoded, + UpdateObjectInvSlotsDecode::NotEligible(reason) => { + return Err(format!( + "post-claim SMSG_UPDATE_OBJECT is not the reviewed one-slot InvSlots update: {reason}" + )); + } + UpdateObjectInvSlotsDecode::Malformed(error) => { + return Err(format!( + "post-claim SMSG_UPDATE_OBJECT is malformed: {error}" + )); + } + }; + if inventory_update.body.map_id != ISSUE_106_CREATURE_IDENTITY.map_id { + return Err(format!( + "post-claim map {} does not match loot owner map {}", + inventory_update.body.map_id, ISSUE_106_CREATURE_IDENTITY.map_id + )); + } + if inventory_update.body.player != pushed.player { + return Err(format!( + "ItemPushResult player {:?} does not match InvSlots player {:?}", + pushed.player, inventory_update.body.player + )); + } + let [slot] = inventory_update.body.inv_slots.as_slice() else { + return Err(format!( + "post-claim InvSlots update contains {} child values, expected one", + inventory_update.body.inv_slots.len() + )); + }; + if i32::from(slot.slot) != pushed.slot_in_bag { + return Err(format!( + "ItemPushResult SlotInBag {} does not match InvSlots child {}", + pushed.slot_in_bag, slot.slot + )); + } + if slot.item != pushed.item_guid { + return Err(format!( + "ItemPushResult ItemGUID {:?} does not match InvSlots item {:?}", + pushed.item_guid, slot.item + )); + } + Ok(()) +} + +/// Decode only the deterministic one-item CreateObject shape captured for +/// issue #106. +/// +/// C++ anchors: +/// +/// - `UpdateData::BuildPacket`: one update, map, destroy bit, data length; +/// - `Object::BuildCreateUpdateBlockForPlayer`: type, GUID, TypeID, movement, +/// values; +/// - `Item::BuildValuesCreate`: value length and Owner visibility flag; +/// - `ObjectData::WriteCreate` / `ItemData::WriteCreate`: entry, owner, +/// contained-in GUID and StackCount in that order. +fn decode_issue_106_item_create(body: &[u8]) -> Result { + let mut cursor = 0usize; + let num_updates = read_u32(body, &mut cursor, "NumObjUpdates")?; + if num_updates != 1 { + return Err(format!( + "item CreateObject contains {num_updates} object updates, expected exactly one" + )); + } + let map_id = read_u16(body, &mut cursor, "MapID")?; + let destroy_or_out_of_range = read_u8(body, &mut cursor, "HasDestroyOrOutOfRange byte")?; + if destroy_or_out_of_range != 0 { + return Err(format!( + "item CreateObject HasDestroyOrOutOfRange byte is 0x{destroy_or_out_of_range:02X}, expected canonical false" + )); + } + + let declared_blocks_len = read_u32(body, &mut cursor, "update blocks length")? as usize; + let actual_blocks_len = body.len().saturating_sub(cursor); + if declared_blocks_len != actual_blocks_len { + return Err(format!( + "item CreateObject update blocks length declares {declared_blocks_len} bytes but {actual_blocks_len} remain" + )); + } + + let update_type = read_u8(body, &mut cursor, "UpdateType")?; + if update_type != 1 { + return Err(format!( + "item CreateObject UpdateType is {update_type}, expected CreateObject (1)" + )); + } + let (item_low, item_high) = read_packed_guid(body, &mut cursor, "created Item")?; + let item_guid = ExactObjectGuid { + low: item_low, + high: item_high, + }; + if let Some(error) = issue_106_item_guid_error(item_guid) { + return Err(format!("item CreateObject {error}")); + } + + let type_id = read_u8(body, &mut cursor, "TypeID")?; + if type_id != 1 { + return Err(format!( + "item CreateObject TypeID is {type_id}, expected Item (1)" + )); + } + let create_bits: [u8; 3] = read_array(body, &mut cursor, "CreateObjectBits")?; + if create_bits != [0; 3] { + return Err(format!( + "item CreateObject flags are {create_bits:02X?}, expected all 18 Item flags clear" + )); + } + let pause_times = read_i32(body, &mut cursor, "PauseTimes count")?; + if pause_times != 0 { + return Err(format!( + "item CreateObject PauseTimes count is {pause_times}, expected zero" + )); + } + + let declared_values_len = read_u32(body, &mut cursor, "values length")? as usize; + let actual_values_len = body.len().saturating_sub(cursor); + if declared_values_len != actual_values_len { + return Err(format!( + "item CreateObject values length declares {declared_values_len} bytes but {actual_values_len} remain" + )); + } + decode_issue_106_item_create_values(&body[cursor..], map_id, item_guid) +} + +fn decode_issue_106_item_create_values( + values: &[u8], + map_id: u16, + item_guid: ExactObjectGuid, +) -> Result { + let mut values_cursor = 0usize; + let visibility_flags = read_u8(values, &mut values_cursor, "UpdateFieldFlags")?; + let item_entry = read_i32(values, &mut values_cursor, "ObjectData.EntryID")?; + let object_dynamic_flags = read_u32(values, &mut values_cursor, "ObjectData.DynamicFlags")?; + let object_scale_bits = read_u32(values, &mut values_cursor, "ObjectData.Scale")?; + if visibility_flags != 0x01 + || object_dynamic_flags != 0 + || object_scale_bits != 1.0_f32.to_bits() + { + return Err(format!( + "item CreateObject ObjectData is not the reviewed owner-visible shape: flags=0x{visibility_flags:02X} dynamic=0x{object_dynamic_flags:08X} scale_bits=0x{object_scale_bits:08X}" + )); + } + if item_entry != ISSUE_106_ITEM_ENTRY { + return Err(format!( + "item CreateObject entry is {item_entry}, expected fixture item {ISSUE_106_ITEM_ENTRY}" + )); + } + + let (owner_low, owner_high) = read_packed_guid(values, &mut values_cursor, "ItemData.Owner")?; + let owner = ExactObjectGuid { + low: owner_low, + high: owner_high, + }; + let (contained_low, contained_high) = + read_packed_guid(values, &mut values_cursor, "ItemData.ContainedIn")?; + let contained_in = ExactObjectGuid { + low: contained_low, + high: contained_high, + }; + let creator = read_packed_guid(values, &mut values_cursor, "ItemData.Creator")?; + let gift_creator = read_packed_guid(values, &mut values_cursor, "ItemData.GiftCreator")?; + let expected_player = ExactObjectGuid { + low: ISSUE_106_CAPTURE_PLAYER_LOW, + high: ISSUE_106_CAPTURE_PLAYER_HIGH, + }; + if owner != expected_player || contained_in != expected_player { + return Err(format!( + "item CreateObject Owner/ContainedIn is {owner:?}/{contained_in:?}, expected capture player {expected_player:?}" + )); + } + if creator != (0, 0) || gift_creator != (0, 0) { + return Err(format!( + "item CreateObject Creator/GiftCreator is {creator:?}/{gift_creator:?}, expected empty GUIDs" + )); + } + + let stack_count = read_u32(values, &mut values_cursor, "ItemData.StackCount")?; + let expiration = read_u32(values, &mut values_cursor, "ItemData.Expiration")?; + let mut spell_charges = [0_i32; 5]; + for charge in &mut spell_charges { + *charge = read_i32(values, &mut values_cursor, "ItemData.SpellCharges")?; + } + let item_dynamic_flags = read_u32(values, &mut values_cursor, "ItemData.DynamicFlags")?; + if stack_count != 1 + || expiration != 0 + || spell_charges != [0; 5] + || item_dynamic_flags != ISSUE_106_ITEM_DYNAMIC_FLAGS + { + return Err(format!( + "item CreateObject ItemData is not the deterministic one-key shape: stack={stack_count} expiration={expiration} charges={spell_charges:?} dynamic=0x{item_dynamic_flags:08X}" + )); + } + + let zero_tail = &values[values_cursor..]; + if zero_tail.len() != ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN + || zero_tail.iter().any(|byte| *byte != 0) + { + return Err(format!( + "item CreateObject deterministic ItemData tail has length {} and {} nonzero byte(s), expected {} zero bytes", + zero_tail.len(), + zero_tail.iter().filter(|byte| **byte != 0).count(), + ISSUE_106_ITEM_CREATE_ZERO_TAIL_LEN + )); + } + + Ok(DecodedIssue106ItemCreate { + map_id, + item_guid, + owner, + contained_in, + item_entry, + stack_count, + }) +} + +fn decode_single_loot_item_request(body: &[u8]) -> Result { + let mut cursor = 0usize; + let count = read_u32(body, &mut cursor, "Loot request count")?; + if count != 1 { + return Err(format!( + "CMSG_LOOT_ITEM contains {count} request(s), expected exactly one" + )); + } + let (loot_obj_low, loot_obj_high) = read_packed_guid(body, &mut cursor, "Loot Object")?; + let loot_obj = ExactObjectGuid { + low: loot_obj_low, + high: loot_obj_high, + }; + if let Some(error) = issue_106_loot_object_error(loot_obj) { + return Err(format!("CMSG_LOOT_ITEM {error}")); + } + let loot_list_id = read_u8(body, &mut cursor, "LootListID")?; + if loot_list_id != 0 { + return Err(format!( + "CMSG_LOOT_ITEM LootListID is {loot_list_id}, expected deterministic slot 0" + )); + } + let soft_interact = read_u8(body, &mut cursor, "IsSoftInteract bit byte")?; + if soft_interact != 0 { + return Err(format!( + "CMSG_LOOT_ITEM IsSoftInteract/padding byte is 0x{soft_interact:02X}, expected 0" + )); + } + if cursor != body.len() { + return Err(format!( + "trailing bytes after CMSG_LOOT_ITEM: decoded {cursor} of {} bytes", + body.len() + )); + } + Ok(DecodedSingleLootItemRequest { + loot_obj, + loot_list_id, + }) +} + +fn decode_issue_106_item_push_result(body: &[u8]) -> Result { + let mut cursor = 0usize; + let (player_low, player_high) = read_packed_guid(body, &mut cursor, "PlayerGUID")?; + let player = ExactObjectGuid { + low: player_low, + high: player_high, + }; + if player.low != ISSUE_106_CAPTURE_PLAYER_LOW || player.high != ISSUE_106_CAPTURE_PLAYER_HIGH { + return Err(format!( + "ItemPushResult PlayerGUID is {player:?}, expected capture character low={ISSUE_106_CAPTURE_PLAYER_LOW} high=0x{ISSUE_106_CAPTURE_PLAYER_HIGH:016X}" + )); + } + + let slot = read_u8(body, &mut cursor, "Slot")?; + let slot_in_bag = read_i32(body, &mut cursor, "SlotInBag")?; + let quest_log_item_id = read_i32(body, &mut cursor, "QuestLogItemID")?; + let quantity = read_i32(body, &mut cursor, "Quantity")?; + let quantity_in_inventory = read_i32(body, &mut cursor, "QuantityInInventory")?; + let dungeon_encounter_id = read_i32(body, &mut cursor, "DungeonEncounterID")?; + let battle_pet_species_id = read_i32(body, &mut cursor, "BattlePetSpeciesID")?; + let battle_pet_breed_id = read_i32(body, &mut cursor, "BattlePetBreedID")?; + let battle_pet_breed_quality = read_u32(body, &mut cursor, "BattlePetBreedQuality")?; + let battle_pet_level = read_i32(body, &mut cursor, "BattlePetLevel")?; + let (item_low, item_high) = read_packed_guid(body, &mut cursor, "ItemGUID")?; + let item_guid = ExactObjectGuid { + low: item_low, + high: item_high, + }; + if let Some(error) = issue_106_item_guid_error(item_guid) { + return Err(format!("ItemPushResult {error}")); + } + + let result_flags = read_u8(body, &mut cursor, "ItemPushResult bit flags")?; + if result_flags != 0x08 { + return Err(format!( + "ItemPushResult flags are 0x{result_flags:02X}, expected non-created/non-pushed normal-display loot flags 0x08" + )); + } + let item_entry = read_i32(body, &mut cursor, "ItemInstance.ItemID")?; + let random_properties_seed = read_i32(body, &mut cursor, "ItemInstance.RandomPropertiesSeed")?; + let random_properties_id = read_i32(body, &mut cursor, "ItemInstance.RandomPropertiesID")?; + let item_bonus_bits = read_u8(body, &mut cursor, "ItemInstance ItemBonus bit byte")?; + let modifications_bits = read_u8(body, &mut cursor, "ItemInstance modifications bit byte")?; + if slot != u8::MAX + || slot_in_bag != ISSUE_106_ITEM_SLOT + || quest_log_item_id != 0 + || quantity != 1 + || quantity_in_inventory != 1 + || dungeon_encounter_id != 0 + || battle_pet_species_id != 0 + || battle_pet_breed_id != 0 + || battle_pet_breed_quality != 0 + || battle_pet_level != 0 + || random_properties_seed != 0 + || random_properties_id != 0 + || item_bonus_bits != 0 + || modifications_bits != 0 + { + return Err(format!( + "ItemPushResult is not the deterministic one-item fixture shape: slot={slot} slot_in_bag={slot_in_bag} quest={quest_log_item_id} quantity={quantity}/{quantity_in_inventory} encounter={dungeon_encounter_id} battle_pet={battle_pet_species_id}/{battle_pet_breed_id}/{battle_pet_breed_quality}/{battle_pet_level} random={random_properties_seed}/{random_properties_id} bonus_bits=0x{item_bonus_bits:02X} mod_bits=0x{modifications_bits:02X}" + )); + } + if cursor != body.len() { + return Err(format!( + "trailing bytes after ItemPushResult ItemInstance: decoded {cursor} of {} bytes", + body.len() + )); + } + + Ok(DecodedIssue106ItemPushResult { + player, + slot_in_bag, + quantity, + item_guid, + item_entry, + }) +} + +fn issue_106_item_guid_error(item_guid: ExactObjectGuid) -> Option { + if item_guid.low == 0 + || item_guid.low & !OBJECT_GUID_COUNTER_MASK != 0 + || item_guid.high != ISSUE_106_CAPTURE_ITEM_HIGH + { + return Some(format!( + "ItemGUID {item_guid:?} is not the canonical non-empty realm-1 Item GUID with server id 0" + )); + } + None +} + /// Decode the opcode-less body emitted by C++ /// `WorldPackets::Character::LogXPGain::Write`. /// @@ -244,6 +1424,45 @@ pub fn decode_log_xp_gain_body(body: &[u8]) -> Result { decode_log_xp_gain_body_with_counter(body).map(|decoded| decoded.body) } +/// Decode the opcode-less body emitted by C++ +/// `WorldPackets::Loot::LootRemoved::Write`. +/// +/// Source anchors: +/// +/// - `LootPackets.cpp`: packed Owner, packed LootObj, LootListID; +/// - `ObjectGuid.cpp::operator<<`: low mask, high mask, packed low bytes, +/// packed high bytes; +/// - `ObjectGuidFactory::CreateWorldObject`: lower 40 low-word bits are the +/// map-runtime counter. +pub fn decode_loot_removed_body(body: &[u8]) -> Result { + decode_loot_removed_body_with_counter(body).map(|decoded| decoded.body) +} + +fn decode_loot_removed_body_with_counter(body: &[u8]) -> Result { + let mut cursor = 0usize; + let (owner_low, owner_high) = read_packed_guid(body, &mut cursor, "Owner")?; + let (loot_obj_low, loot_obj_high) = read_packed_guid(body, &mut cursor, "LootObj")?; + let loot_list_id = read_u8(body, &mut cursor, "LootListID")?; + if cursor != body.len() { + return Err(format!( + "trailing bytes after LootListID: decoded {cursor} of {} bytes", + body.len() + )); + } + + Ok(DecodedLootRemovedBody { + body: LootRemovedBody { + owner: stable_object_guid(owner_low, owner_high), + loot_obj: ExactObjectGuid { + low: loot_obj_low, + high: loot_obj_high, + }, + loot_list_id, + }, + owner_runtime_counter: owner_low & OBJECT_GUID_COUNTER_MASK, + }) +} + fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result { let mut cursor = 0usize; let low_mask = read_u8(body, &mut cursor, "Victim low mask")?; @@ -264,17 +1483,9 @@ fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result> 58) & 0x3F) as u8, - realm_id: ((high >> 42) & 0xFFFF) as u16, - map_id: ((high >> 29) & 0x1FFF) as u16, - entry: ((high >> 6) & 0x7F_FFFF) as u32, - subtype: (high & 0x3F) as u8, - server_id: ((stable_low >> 40) & 0xFF_FFFF) as u32, - }, + victim: stable_object_guid(low, high), original, reason, amount, @@ -284,6 +1495,28 @@ fn decode_log_xp_gain_body_with_counter(body: &[u8]) -> Result StableObjectGuid { + let stable_low = low & !OBJECT_GUID_COUNTER_MASK; + StableObjectGuid { + high_type: ((high >> 58) & 0x3F) as u8, + // Keeping all 16 bits between map and high type retains the three + // reserved bits as part of strict identity for non-canonical inputs. + realm_id: ((high >> 42) & 0xFFFF) as u16, + map_id: ((high >> 29) & 0x1FFF) as u16, + entry: ((high >> 6) & 0x7F_FFFF) as u32, + subtype: (high & 0x3F) as u8, + server_id: ((stable_low >> 40) & 0xFF_FFFF) as u32, + } +} + +fn read_packed_guid(body: &[u8], cursor: &mut usize, field: &str) -> Result<(u64, u64), String> { + let low_mask = read_u8(body, cursor, &format!("{field} low mask"))?; + let high_mask = read_u8(body, cursor, &format!("{field} high mask"))?; + let low = read_packed_u64(body, cursor, low_mask, &format!("{field} low word"))?; + let high = read_packed_u64(body, cursor, high_mask, &format!("{field} high word"))?; + Ok((low, high)) +} + fn read_packed_u64(body: &[u8], cursor: &mut usize, mask: u8, field: &str) -> Result { let mut bytes = [0u8; 8]; for (index, byte) in bytes.iter_mut().enumerate() { @@ -311,10 +1544,22 @@ fn read_i32(body: &[u8], cursor: &mut usize, field: &str) -> Result Ok(i32::from_le_bytes(read_array(body, cursor, field)?)) } +fn read_u16(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u16::from_le_bytes(read_array(body, cursor, field)?)) +} + +fn read_u16_be(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u16::from_be_bytes(read_array(body, cursor, field)?)) +} + fn read_u32(body: &[u8], cursor: &mut usize, field: &str) -> Result { Ok(u32::from_le_bytes(read_array(body, cursor, field)?)) } +fn read_u32_be(body: &[u8], cursor: &mut usize, field: &str) -> Result { + Ok(u32::from_be_bytes(read_array(body, cursor, field)?)) +} + fn read_array( body: &[u8], cursor: &mut usize, diff --git a/crates/capture-diff/tests/diff_engine.rs b/crates/capture-diff/tests/diff_engine.rs index 494812c60..1b170c823 100644 --- a/crates/capture-diff/tests/diff_engine.rs +++ b/crates/capture-diff/tests/diff_engine.rs @@ -9,6 +9,7 @@ use std::path::Path; use capture_diff::diff::{DiffReport, DivergenceKind, baseline_delta}; use capture_diff::model::{Capture, CapturedPacket, Direction, PacketBoundary}; use capture_diff::{pkt, rustdump}; +use sha2::{Digest, Sha256}; fn s2c(opcode: u16, body: &[u8]) -> CapturedPacket { CapturedPacket { @@ -30,6 +31,33 @@ fn c2s(opcode: u16, body: &[u8]) -> CapturedPacket { const ALL: &[Direction] = &[Direction::S2C, Direction::C2S]; +#[allow(clippy::too_many_arguments)] +fn write_dump_record( + dir: &Path, + direction: &str, + seq: u64, + counter: u64, + connection_id: &str, + opcode: u16, + name: &str, + body: &[u8], +) -> String { + std::fs::create_dir_all(dir).unwrap(); + let len = body.len() + 2; + let stem = format!("rust-{direction}-{seq:08}-counter{counter}-0x{opcode:04X}-{name}-len{len}"); + let mut bin = opcode.to_le_bytes().to_vec(); + bin.extend_from_slice(body); + std::fs::write(dir.join(format!("{stem}.bin")), bin).unwrap(); + std::fs::write( + dir.join(format!("{stem}.meta")), + format!( + "direction={direction}\nconnection_id={connection_id}\naddr=127.0.0.1:0\nseq={seq}\ncounter={counter}\nopcode=0x{opcode:04X}\nname={name}\nlen={len}\n" + ), + ) + .unwrap(); + stem +} + #[test] fn identical_captures_are_clean() { let pkts = vec![s2c(0x0001, &[1, 2, 3]), s2c(0x0002, &[4, 5])]; @@ -273,6 +301,16 @@ fn pkt_rejects_wrong_version() { assert!(err.to_string().contains("version"), "got: {err}"); } +#[test] +fn pkt_rejects_opcode_wider_than_world_opcode() { + let cap = Capture::new("wide", vec![s2c(0x1234, &[])]); + let mut bytes = pkt::write_pkt_bytes(&cap); + // PKT 3.1 log header (66) + packet header (20) + optional address (20). + bytes[106..110].copy_from_slice(&0x0001_0000_u32.to_le_bytes()); + let error = pkt::parse_pkt_bytes(&bytes).expect_err("wide opcode must fail closed"); + assert!(error.to_string().contains("16-bit world opcode space")); +} + #[test] fn rust_dump_round_trips() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("rust_dump_round_trips"); @@ -328,18 +366,17 @@ fn rust_dump_accepts_unencrypted_handshake_tags() { // parser MUST accept them — a real login dump always contains them. let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("unencrypted_tags"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); // SMSG_AUTH_CHALLENGE-ish: opcode 0x256D, body [9], dumped pre-encryption. - std::fs::write( - dir.join("rust-s2c-unencrypted-00000000-counter0-0x256D-x-len3.bin"), - [0x6D, 0x25, 0x09], - ) - .unwrap(); - std::fs::write( - dir.join("rust-s2c-unencrypted-00000000-counter0-0x256D-x-len3.meta"), - "direction=s2c-unencrypted\nseq=0\nopcode=0x256D\nlen=3\n", - ) - .unwrap(); + write_dump_record( + &dir, + "s2c-unencrypted", + 0, + 0, + "0", + 0x256D, + "AuthResponse", + &[9], + ); let cap = rustdump::parse_rust_dump(&dir).expect("must parse -unencrypted tags"); assert_eq!(cap.packets.len(), 1); assert_eq!(cap.packets[0].direction, Direction::S2C); @@ -352,17 +389,7 @@ fn rust_dump_accepts_unencrypted_handshake_tags() { fn rust_dump_rejects_invalid_explicit_connection_id() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("invalid_connection_id"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("rust-s2c-00000000-counter0-0x256D-x-len3.bin"), - [0x6D, 0x25, 0x09], - ) - .unwrap(); - std::fs::write( - dir.join("rust-s2c-00000000-counter0-0x256D-x-len3.meta"), - "direction=s2c\nconnection_id=instance\nseq=0\nopcode=0x256D\nlen=3\n", - ) - .unwrap(); + write_dump_record(&dir, "s2c", 0, 0, "instance", 0x256D, "AuthResponse", &[9]); let error = rustdump::parse_rust_dump(&dir).unwrap_err(); assert!(error.to_string().contains("invalid connection_id")); @@ -372,25 +399,182 @@ fn rust_dump_rejects_invalid_explicit_connection_id() { fn rust_dump_rejects_duplicate_global_sequence_after_process_restart() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("duplicate_global_sequence"); let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - for (name, opcode) in [("first", 0x271C_u16), ("second", 0x27CB_u16)] { - let stem = format!("rust-s2c-{name}-0x{opcode:04X}"); - let mut bin = opcode.to_le_bytes().to_vec(); - bin.push(0); - std::fs::write(dir.join(format!("{stem}.bin")), bin).unwrap(); - std::fs::write( - dir.join(format!("{stem}.meta")), - format!("direction=s2c\nconnection_id=1\nseq=7\nopcode=0x{opcode:04X}\nlen=3\n"), - ) - .unwrap(); - } + write_dump_record(&dir, "s2c", 7, 0, "1", 0x271C, "StandStateUpdate", &[0]); + write_dump_record(&dir, "s2c", 7, 1, "1", 0x27CB, "UpdateObject", &[0]); let error = rustdump::parse_rust_dump(&dir).expect_err("duplicate seq must fail closed"); - assert!(error.to_string().contains("duplicate packet dump seq 7")); + assert!( + error + .to_string() + .contains("non-contiguous packet dump seq 7 then 7") + ); assert!(error.to_string().contains("may have restarted")); } +#[test] +fn rust_dump_requires_contiguous_sequence_but_not_zero_origin() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("sequence_contract"); + let _ = std::fs::remove_dir_all(&dir); + write_dump_record(&dir, "c2s", 41, 5, "1", 0x3211, "LootItem", &[0]); + write_dump_record(&dir, "s2c", 42, 9, "0", 0x2615, "LootRemoved", &[0]); + assert_eq!(rustdump::parse_rust_dump(&dir).unwrap().packets.len(), 2); + + let stem = write_dump_record(&dir, "s2c", 44, 10, "1", 0x27CB, "UpdateObject", &[0]); + let error = rustdump::parse_rust_dump(&dir).expect_err("sequence gap must fail"); + assert!(error.to_string().contains("seq 42 then 44")); + std::fs::remove_file(dir.join(format!("{stem}.meta"))).unwrap(); + std::fs::remove_file(dir.join(format!("{stem}.bin"))).unwrap(); +} + +#[test] +fn rust_dump_rejects_orphans_extras_subdirectories_and_symlinks() { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("flat_inventory_contract"); + let reset = || { + let _ = std::fs::remove_dir_all(&root); + write_dump_record(&root, "c2s", 0, 0, "1", 0x3211, "LootItem", &[0]) + }; + + let stem = reset(); + std::fs::remove_file(root.join(format!("{stem}.bin"))).unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("orphan .meta") + ); + + reset(); + std::fs::write(root.join("notes.txt"), b"not evidence").unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("unexpected file") + ); + + reset(); + std::fs::create_dir(root.join("nested")).unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("not a regular") + ); + + reset(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(root.join(format!("{stem}.bin")), root.join("linked.bin")) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("non-symlink") + ); + } +} + +#[test] +fn rust_dump_accepts_only_manifest_bound_race_bot_report_sidecar() { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("race_report_sidecar_contract"); + let _ = std::fs::remove_dir_all(&root); + write_dump_record(&root, "c2s", 0, 0, "1", 0x3211, "LootItem", &[0]); + let report_path = root.join("race.bot-report.json"); + let report = br#"{"loot_race_smoke":true}"#; + std::fs::write(&report_path, report).unwrap(); + let report_sha = format!("{:x}", Sha256::digest(report)); + let manifest = serde_json::json!({ + "flow": "loot-two-session-atomic-race", + "bot_report": { + "contract": "wow-test-bot-loot-two-session-atomic-race-report-v1", + "report_path": report_path.to_string_lossy(), + "report_sha256": report_sha + } + }); + std::fs::write( + root.join("rust.capture-manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + assert_eq!(rustdump::parse_rust_dump(&root).unwrap().packets.len(), 1); + + std::fs::write(&report_path, b"{}").unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("SHA-256") + ); + + std::fs::write(&report_path, report).unwrap(); + let mut wrong_flow = manifest; + wrong_flow["flow"] = serde_json::Value::String("login".to_string()); + std::fs::write( + root.join("rust.capture-manifest.json"), + serde_json::to_vec_pretty(&wrong_flow).unwrap(), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&root) + .unwrap_err() + .to_string() + .contains("matching race manifest contract") + ); +} + +#[test] +fn rust_dump_binds_metadata_filename_and_binary_length() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("metadata_binding_contract"); + let _ = std::fs::remove_dir_all(&dir); + let stem = write_dump_record(&dir, "c2s", 0, 0, "1", 0x3211, "LootItem", &[1, 2]); + let meta = dir.join(format!("{stem}.meta")); + let original = std::fs::read_to_string(&meta).unwrap(); + + std::fs::write(&meta, original.replace("len=4", "len=5")).unwrap(); + let error = rustdump::parse_rust_dump(&dir).expect_err("filename/meta mismatch must fail"); + assert!(error.to_string().contains("filename stem disagrees")); + + std::fs::write(&meta, &original).unwrap(); + let bin = dir.join(format!("{stem}.bin")); + std::fs::write(&bin, [0x11, 0x32, 1]).unwrap(); + let error = rustdump::parse_rust_dump(&dir).expect_err("body length mismatch must fail"); + assert!(error.to_string().contains("metadata declares 4")); + + std::fs::write(&bin, [0x11, 0x32, 1, 2]).unwrap(); + std::fs::write(&meta, format!("{original}unknown=value\n")).unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("unknown field") + ); + + std::fs::write( + &meta, + original.replace("addr=127.0.0.1:0", "addr=not-a-socket"), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("invalid socket address") + ); + + std::fs::write( + &meta, + original.replace("direction=c2s", "direction=c2s-unencrypted-unencrypted"), + ) + .unwrap(); + assert!( + rustdump::parse_rust_dump(&dir) + .unwrap_err() + .to_string() + .contains("invalid direction") + ); +} + #[test] fn baseline_delta_is_count_aware() { use capture_diff::DivergenceSignature; diff --git a/crates/capture-diff/tests/loot_removed_semantic.rs b/crates/capture-diff/tests/loot_removed_semantic.rs new file mode 100644 index 000000000..f49211e2d --- /dev/null +++ b/crates/capture-diff/tests/loot_removed_semantic.rs @@ -0,0 +1,431 @@ +//! Focused tests for the issue-#106 `SMSG_LOOT_REMOVED` normalization. +//! +//! Paired real C++/Rust captures proved that the same creature spawn receives +//! a different map-runtime GUID counter in the two independently started +//! servers. The comparator may omit exactly that lower 40-bit owner counter; +//! every other GUID bit, the complete loot-object GUID, list id, body shape, +//! canonical packed encoding, opcode, direction, and routing remain strict. + +use capture_diff::diff::DiffReport; +use capture_diff::model::{Capture, CapturedPacket, Direction}; +use capture_diff::semantic::{SMSG_LOOT_REMOVED, decode_loot_removed_body}; + +const REAL_CPP_BODY: [u8; 19] = [ + 0x03, 0xBF, 0x0C, 0x01, 0xC0, 0x44, 0x15, 0x40, 0x42, 0x04, 0x20, 0x01, 0xB8, 0x01, 0x40, 0x42, + 0x04, 0x3C, 0x00, +]; +const REAL_RUST_BODY: [u8; 18] = [ + 0x01, 0xBF, 0x01, 0xC0, 0x44, 0x15, 0x40, 0x42, 0x04, 0x20, 0x01, 0xB8, 0x01, 0x40, 0x42, 0x04, + 0x3C, 0x00, +]; + +#[derive(Clone, Copy)] +struct GuidFields { + high_type: u8, + realm_id: u16, + map_id: u16, + entry: u32, + subtype: u8, + server_id: u32, + counter: u64, +} + +impl GuidFields { + fn doctor(counter: u64) -> Self { + Self { + high_type: 8, // HighGuid::Creature + realm_id: 1, + map_id: 530, + entry: 21_779, + subtype: 0, + server_id: 0, + counter, + } + } + + fn loot_object(counter: u64) -> Self { + Self { + high_type: 15, // HighGuid::LootObject + realm_id: 1, + map_id: 530, + entry: 0, + subtype: 0, + server_id: 0, + counter, + } + } + + fn words(self) -> (u64, u64) { + let high = (u64::from(self.high_type) << 58) + | (u64::from(self.realm_id) << 42) + | (u64::from(self.map_id) << 29) + | (u64::from(self.entry) << 6) + | u64::from(self.subtype); + let low = (u64::from(self.server_id) << 40) | self.counter; + (low, high) + } +} + +fn packed_guid(low: u64, high: u64) -> Vec { + let low_bytes = low.to_le_bytes(); + let high_bytes = high.to_le_bytes(); + let mut low_mask = 0u8; + let mut high_mask = 0u8; + for (index, byte) in low_bytes.iter().enumerate() { + if *byte != 0 { + low_mask |= 1 << index; + } + } + for (index, byte) in high_bytes.iter().enumerate() { + if *byte != 0 { + high_mask |= 1 << index; + } + } + + let mut body = vec![low_mask, high_mask]; + body.extend( + low_bytes + .iter() + .enumerate() + .filter_map(|(index, byte)| (low_mask & (1 << index) != 0).then_some(*byte)), + ); + body.extend( + high_bytes + .iter() + .enumerate() + .filter_map(|(index, byte)| (high_mask & (1 << index) != 0).then_some(*byte)), + ); + body +} + +fn loot_removed_body(owner: GuidFields, loot_obj: GuidFields, loot_list_id: u8) -> Vec { + let (owner_low, owner_high) = owner.words(); + let (loot_low, loot_high) = loot_obj.words(); + let mut body = packed_guid(owner_low, owner_high); + body.extend(packed_guid(loot_low, loot_high)); + body.push(loot_list_id); + body +} + +fn packet(direction: Direction, connection_id: u32, opcode: u16, body: Vec) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body, + } +} + +fn report(cpp: CapturedPacket, rust: CapturedPacket, direction: Direction) -> DiffReport { + DiffReport::compute( + &Capture::new("cpp", vec![cpp]), + &Capture::new("rust", vec![rust]), + &[direction], + ) +} + +fn s2c_report(cpp_body: Vec, rust_body: Vec) -> DiffReport { + report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, cpp_body), + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, rust_body), + Direction::S2C, + ) +} + +#[test] +fn decoder_pins_real_cpp_loot_removed_bytes_and_field_order() { + let decoded = decode_loot_removed_body(&REAL_CPP_BODY).expect("decode real C++ body"); + assert_eq!(decoded.owner.high_type, 8); + assert_eq!(decoded.owner.realm_id, 1); + assert_eq!(decoded.owner.map_id, 530); + assert_eq!(decoded.owner.entry, 21_779); + assert_eq!(decoded.owner.subtype, 0); + assert_eq!(decoded.owner.server_id, 0); + assert_eq!(decoded.loot_obj.low, 1); + assert_eq!(decoded.loot_obj.high, 0x3C00_0442_4000_0000); + assert_eq!(decoded.loot_list_id, 0); + + let rust = decode_loot_removed_body(&REAL_RUST_BODY).expect("decode real Rust body"); + assert_eq!(decoded, rust, "only the omitted owner counter may differ"); +} + +#[test] +fn real_paired_bodies_ignore_only_creature_owner_runtime_counter() { + let report = s2c_report(REAL_CPP_BODY.to_vec(), REAL_RUST_BODY.to_vec()); + + assert!(report.is_clean(), "{}", report.render_text()); + assert_eq!(report.counts.matched, 1); + let body = report.ops[0].body.as_ref().expect("matched body"); + assert_ne!(body.cpp_len, body.rust_len); + assert_eq!(body.first_diff_offset, None); + let semantic = body.semantic.as_ref().expect("semantic comparator"); + assert_eq!( + semantic.comparator, + "smsg_loot_removed_without_creature_owner_runtime_guid_counter" + ); + assert_eq!(semantic.cpp.raw_body_sha256, None); + assert_eq!(semantic.rust.raw_body_sha256, None); +} + +#[test] +fn loot_removed_compares_every_stable_owner_component() { + let cpp = GuidFields::doctor(268); + let variants = [ + ( + GuidFields { + high_type: 9, + ..cpp + }, + "owner.high_type", + ), + (GuidFields { realm_id: 2, ..cpp }, "owner.realm_id"), + (GuidFields { map_id: 571, ..cpp }, "owner.map_id"), + ( + GuidFields { + entry: 21_780, + ..cpp + }, + "owner.entry", + ), + (GuidFields { subtype: 1, ..cpp }, "owner.subtype"), + ( + GuidFields { + server_id: 1, + ..cpp + }, + "owner.server_id", + ), + ]; + + for (changed, expected_field) in variants { + let report = s2c_report( + loot_removed_body(cpp, GuidFields::loot_object(1), 0), + loot_removed_body(changed, GuidFields::loot_object(1), 0), + ); + assert!(!report.is_clean(), "accepted {expected_field} mismatch"); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("semantic mismatch"); + assert!( + semantic.mismatch_summary().contains(expected_field), + "unexpected summary: {}", + semantic.mismatch_summary() + ); + } +} + +#[test] +fn loot_removed_keeps_complete_loot_object_and_list_id_strict() { + let owner_cpp = GuidFields::doctor(268); + let owner_rust = GuidFields::doctor(1); + let loot_obj = GuidFields::loot_object(1); + let variants = [ + ( + GuidFields { + counter: 2, + ..loot_obj + }, + 0, + "loot_obj.low", + ), + ( + GuidFields { + map_id: 571, + ..loot_obj + }, + 0, + "loot_obj.high", + ), + (loot_obj, 1, "loot_list_id"), + ]; + + for (changed_loot_obj, changed_list_id, expected_field) in variants { + let report = s2c_report( + loot_removed_body(owner_cpp, loot_obj, 0), + loot_removed_body(owner_rust, changed_loot_obj, changed_list_id), + ); + assert!(!report.is_clean(), "accepted {expected_field} mismatch"); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("semantic mismatch"); + assert!(semantic.mismatch_summary().contains(expected_field)); + } +} + +#[test] +fn creature_owner_requires_nonzero_runtime_counter_on_both_sides() { + for (cpp_counter, rust_counter) in [(0, 1), (1, 0), (0, 0)] { + let report = s2c_report( + loot_removed_body( + GuidFields::doctor(cpp_counter), + GuidFields::loot_object(1), + 0, + ), + loot_removed_body( + GuidFields::doctor(rust_counter), + GuidFields::loot_object(1), + 0, + ), + ); + assert!( + !report.is_clean(), + "accepted owner counters {cpp_counter}/{rust_counter}" + ); + assert!(report.render_text().contains("zero runtime GUID counter")); + } +} + +#[test] +fn malformed_noncanonical_and_trailing_bodies_never_compare_clean() { + let mut noncanonical = REAL_RUST_BODY.to_vec(); + noncanonical[2] = 0; // Owner low mask promises a byte that must be nonzero. + let mut trailing = REAL_RUST_BODY.to_vec(); + trailing.push(0xAA); + + for malformed in [vec![0x01], noncanonical, trailing] { + let report = s2c_report(malformed.clone(), malformed); + assert!(!report.is_clean(), "accepted malformed body"); + assert_eq!(report.counts.body_mismatches, 1); + let semantic = report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("malformed body must select fail-closed comparator"); + assert!(semantic.cpp.decode_error.is_some()); + assert!(semantic.rust.decode_error.is_some()); + assert!(semantic.cpp.raw_body_sha256.is_some()); + assert!(semantic.rust.raw_body_sha256.is_some()); + } +} + +#[test] +fn two_non_creature_owners_keep_raw_counter_comparison() { + let cpp_owner = GuidFields { + high_type: 11, // HighGuid::GameObject + ..GuidFields::doctor(1) + }; + let rust_owner = GuidFields { + counter: 2, + ..cpp_owner + }; + let report = s2c_report( + loot_removed_body(cpp_owner, GuidFields::loot_object(1), 0), + loot_removed_body(rust_owner, GuidFields::loot_object(1), 0), + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn another_creature_identity_keeps_its_instance_counter_strict() { + let first = GuidFields { + entry: 21_780, + counter: 1, + ..GuidFields::doctor(1) + }; + let second = GuidFields { + counter: 2, + ..first + }; + let report = s2c_report( + loot_removed_body(first, GuidFields::loot_object(1), 0), + loot_removed_body(second, GuidFields::loot_object(1), 0), + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!( + report.ops[0].body.as_ref().unwrap().semantic.is_none(), + "only the exact unique issue-#106 Doctor fixture may omit its instance counter" + ); +} + +#[test] +fn no_other_opcode_or_direction_receives_loot_owner_normalization() { + let cpp = REAL_CPP_BODY.to_vec(); + let rust = REAL_RUST_BODY.to_vec(); + + let wrong_opcode = report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED - 1, cpp.clone()), + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED - 1, rust.clone()), + Direction::S2C, + ); + assert!(!wrong_opcode.is_clean()); + assert!( + wrong_opcode.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_direction = report( + packet(Direction::C2S, 1, SMSG_LOOT_REMOVED, cpp), + packet(Direction::C2S, 1, SMSG_LOOT_REMOVED, rust), + Direction::C2S, + ); + assert!(!wrong_direction.is_clean()); + assert!( + wrong_direction.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); +} + +#[test] +fn loot_removed_still_requires_cpp_instance_socket_routing() { + let report = report( + packet(Direction::S2C, 1, SMSG_LOOT_REMOVED, REAL_CPP_BODY.to_vec()), + packet( + Direction::S2C, + 0, + SMSG_LOOT_REMOVED, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 0); + assert_eq!(report.counts.connection_mismatches, 1); +} + +#[test] +fn semantic_baseline_identity_excludes_only_owner_counter_dependent_lengths() { + let expected = s2c_report( + loot_removed_body(GuidFields::doctor(1), GuidFields::loot_object(1), 0), + loot_removed_body(GuidFields::doctor(2), GuidFields::loot_object(2), 0), + ); + let current = s2c_report( + loot_removed_body( + GuidFields::doctor(0x01_0203_0405), + GuidFields::loot_object(1), + 0, + ), + loot_removed_body( + GuidFields::doctor(0x05_0403_0201), + GuidFields::loot_object(2), + 0, + ), + ); + + assert_ne!( + expected.ops[0].body.as_ref().unwrap().cpp_len, + current.ops[0].body.as_ref().unwrap().cpp_len + ); + assert_eq!(expected.signatures(), current.signatures()); + let signature = &expected.signatures()[0]; + assert_eq!(signature.cpp_body_len, None); + assert_eq!(signature.rust_body_len, None); + assert_eq!(signature.first_diff_offset, None); +} diff --git a/crates/capture-diff/tests/loot_single_item_claim_contract.rs b/crates/capture-diff/tests/loot_single_item_claim_contract.rs new file mode 100644 index 000000000..31a162e45 --- /dev/null +++ b/crates/capture-diff/tests/loot_single_item_claim_contract.rs @@ -0,0 +1,234 @@ +//! Fail-closed tests for the complete issue-#106 required-flow contract. + +use capture_diff::flow::{load_flow, load_requirement}; +use capture_diff::{Capture, Direction, pkt, rustdump}; + +fn committed_pair() -> (Capture, Capture) { + let flow = load_flow("loot-single-item-claim").expect("committed loot flow"); + let cpp = pkt::parse_pkt_file(&flow.golden_pkt).expect("C++ fixture"); + let rust = rustdump::parse_rust_dump(&flow.reference_rust).expect("Rust fixture"); + (cpp, rust) +} + +fn packet_index(capture: &Capture, direction: Direction, opcode: u16) -> usize { + capture + .packets + .iter() + .position(|packet| packet.direction == direction && packet.opcode == opcode) + .expect("required packet") +} + +#[test] +fn committed_pair_satisfies_correlated_single_claim_semantics() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (cpp, rust) = committed_pair(); + + requirement.validate_capture(&cpp).expect("C++ evidence"); + requirement.validate_capture(&rust).expect("Rust evidence"); +} + +#[test] +fn extra_item_push_on_wrong_socket_cannot_hide_behind_exact_anchor_count() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let push_index = packet_index(&rust, Direction::S2C, 0x2623); + let mut duplicate = rust.packets[push_index].clone(); + duplicate.connection_id = 1; + rust.packets.insert(push_index + 1, duplicate); + + let error = requirement + .validate_capture(&rust) + .expect_err("wrong-route duplicate must invalidate evidence"); + assert!(error.to_string().contains("contains 7 packet(s)")); +} + +#[test] +fn loot_request_must_contain_one_correlated_object_and_list() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let request_index = packet_index(&rust, Direction::C2S, 0x3211); + + let mut multiple = rust.clone(); + multiple.packets[request_index].body[..4].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&multiple) + .expect_err("Count=2 must fail"); + assert!(error.to_string().contains("contains 2 request(s)")); + + let mut other_object = rust.clone(); + // Count (4), packed low/high masks (2), then the LootObject low counter. + other_object.packets[request_index].body[6] = 2; + let error = requirement + .validate_capture(&other_object) + .expect_err("request/removal LootObj mismatch must fail"); + assert!( + error + .to_string() + .contains("does not match SMSG_LOOT_REMOVED") + ); + + let mut other_list = rust; + other_list.packets[request_index].body[11] = 1; + let error = requirement + .validate_capture(&other_list) + .expect_err("a different LootListID must fail"); + assert!(error.to_string().contains("LootListID is 1")); +} + +#[test] +fn item_create_is_one_owned_item_and_correlates_with_the_grant() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let create_index = rust + .packets + .iter() + .enumerate() + .find_map(|(index, packet)| { + (packet.direction == Direction::S2C && packet.opcode == 0x27CB).then_some(index) + }) + .expect("item CreateObject packet"); + + let mut two_updates = rust.clone(); + two_updates.packets[create_index].body[..4].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&two_updates) + .expect_err("a multi-block create packet must fail"); + assert!(error.to_string().contains("2 object updates")); + + let mut values_instead_of_create = rust.clone(); + values_instead_of_create.packets[create_index].body[11] = 0; + let error = requirement + .validate_capture(&values_instead_of_create) + .expect_err("a non-CreateObject update must fail"); + assert!(error.to_string().contains("expected CreateObject (1)")); + + let mut non_item = rust.clone(); + non_item.packets[create_index].body[19] = 5; + let error = requirement + .validate_capture(&non_item) + .expect_err("a Unit CreateObject must fail"); + assert!(error.to_string().contains("expected Item (1)")); + + let mut other_item_guid = rust.clone(); + // Top-level fields (11), UpdateType (1), packed GUID masks (2), then item low. + other_item_guid.packets[create_index].body[14] ^= 1; + let error = requirement + .validate_capture(&other_item_guid) + .expect_err("created and granted Item GUIDs must correlate"); + assert!(error.to_string().contains("does not match ItemPushResult")); + + let mut other_entry = rust.clone(); + other_entry.packets[create_index].body[32..36].copy_from_slice(&30_713_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_entry) + .expect_err("a foreign created item entry must fail"); + assert!(error.to_string().contains("expected fixture item 30712")); + + let mut other_owner = rust.clone(); + // Values start at 31; Owner masks start at 44 and its low byte is 46. + other_owner.packets[create_index].body[46] = 16; + let error = requirement + .validate_capture(&other_owner) + .expect_err("a foreign item owner must fail"); + assert!(error.to_string().contains("expected capture player")); + + let mut other_container = rust.clone(); + // ContainedIn immediately follows the five-byte Owner packed GUID; its + // first nonzero low byte is body byte 51. + other_container.packets[create_index].body[51] = 16; + let error = requirement + .validate_capture(&other_container) + .expect_err("a foreign containing player must fail"); + assert!(error.to_string().contains("expected capture player")); + + let mut stack_two = rust; + stack_two.packets[create_index].body[58..62].copy_from_slice(&2_u32.to_le_bytes()); + let error = requirement + .validate_capture(&stack_two) + .expect_err("StackCount=2 must fail the single-item contract"); + assert!(error.to_string().contains("stack=2")); +} + +#[test] +fn item_push_recipient_quantity_item_and_inventory_value_are_correlated() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, rust) = committed_pair(); + let push_index = packet_index(&rust, Direction::S2C, 0x2623); + + let mut other_player = rust.clone(); + // Packed PlayerGUID low byte follows its two masks. + other_player.packets[push_index].body[2] = 16; + let error = requirement + .validate_capture(&other_player) + .expect_err("wrong recipient must fail"); + assert!(error.to_string().contains("PlayerGUID")); + + let mut quantity_two = rust.clone(); + quantity_two.packets[push_index].body[14..18].copy_from_slice(&2_i32.to_le_bytes()); + let error = requirement + .validate_capture(&quantity_two) + .expect_err("Quantity=2 must fail"); + assert!(error.to_string().contains("quantity=2/1")); + + let mut other_slot = rust.clone(); + // Packed PlayerGUID occupies five bytes; Slot is byte 5 and SlotInBag is + // bytes 6..10. The restored Doctor-key fixture uses keyring slot 106. + other_slot.packets[push_index].body[6..10].copy_from_slice(&35_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_slot) + .expect_err("a backpack/bank/equipment destination must not satisfy this fixture"); + assert!(error.to_string().contains("slot_in_bag=35")); + + let mut other_item_guid = rust.clone(); + // ItemGUID masks start at 42; byte 44 is its first nonzero low byte. + other_item_guid.packets[push_index].body[44] ^= 1; + let error = requirement + .validate_capture(&other_item_guid) + .expect_err("CreateObject/ItemPush/InvSlots ItemGUID mismatch must fail"); + assert!(error.to_string().contains("does not match ItemPushResult")); + + let mut other_item_entry = rust; + other_item_entry.packets[push_index].body[50..54].copy_from_slice(&30_713_i32.to_le_bytes()); + let error = requirement + .validate_capture(&other_item_entry) + .expect_err("wrong ItemInstance entry must fail"); + assert!( + error + .to_string() + .contains("item CreateObject entry 30712 does not match ItemPushResult 30713") + ); +} + +#[test] +fn inventory_update_must_name_the_same_awarded_item() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let inventory_index = rust + .packets + .iter() + .enumerate() + .rfind(|(_, packet)| packet.direction == Direction::S2C && packet.opcode == 0x27CB) + .map(|(index, _)| index) + .expect("post-claim InvSlots packet"); + // The InvSlots packed Item masks are bytes 39/40 and its first low byte is + // 41 in the reviewed 46-byte Rust body. + rust.packets[inventory_index].body[41] ^= 1; + + let error = requirement + .validate_capture(&rust) + .expect_err("InvSlots must contain the ItemPush/CreateObject GUID"); + assert!(error.to_string().contains("does not match InvSlots item")); +} + +#[test] +fn deterministic_ping_payload_is_part_of_the_claim_boundary() { + let requirement = load_requirement("loot-single-item-claim").expect("required contract"); + let (_, mut rust) = committed_pair(); + let ping_index = packet_index(&rust, Direction::C2S, 0x3768); + rust.packets[ping_index].body[0] ^= 1; + + let error = requirement + .validate_capture(&rust) + .expect_err("a different fence serial must fail"); + assert!(error.to_string().contains("fixed TOOL/zero-latency")); +} diff --git a/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs b/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs new file mode 100644 index 000000000..17db5d521 --- /dev/null +++ b/crates/capture-diff/tests/update_object_empty_unit_parent_semantic.rs @@ -0,0 +1,501 @@ +//! Fail-closed tests for the issue-#106 `SMSG_UPDATE_OBJECT` cadence filter. +//! +//! Two independent C++ captures emitted the same 51-byte one-player VALUES +//! body: UnitData contained only parent bit 116 (`Power`) with no child and no +//! payload, followed by one ActivePlayer InvSlots value. Rust emitted the same +//! update without that empty parent in 46 bytes. The comparator may remove only +//! that C++-side five-byte mask fragment; this is capture cadence noise, not +//! evidence that power-regeneration gameplay is equivalent. + +use capture_diff::diff::DiffReport; +use capture_diff::model::{Capture, CapturedPacket, Direction}; +use capture_diff::semantic::SMSG_UPDATE_OBJECT; + +const REAL_CPP_BODY: [u8; 51] = [ + 0x01, 0x00, 0x00, 0x00, 0x12, 0x02, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x0F, 0x04, + 0x08, 0x1E, 0x00, 0x00, 0x00, 0xA0, 0x00, 0x00, 0x00, 0x08, 0x00, 0x10, 0x00, 0x00, 0x88, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0xA0, 0xFA, 0x84, + 0x1E, 0x04, 0x0C, +]; +const REAL_RUST_BODY: [u8; 46] = [ + 0x01, 0x00, 0x00, 0x00, 0x12, 0x02, 0x00, 0x23, 0x00, 0x00, 0x00, 0x00, 0x01, 0xA0, 0x0F, 0x04, + 0x08, 0x19, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0xA0, 0xFA, 0x84, 0x1E, 0x04, 0x0C, +]; + +fn packet(direction: Direction, connection_id: u32, opcode: u16, body: Vec) -> CapturedPacket { + CapturedPacket { + direction, + connection_id, + opcode, + body, + } +} + +fn report(cpp: CapturedPacket, rust: CapturedPacket, direction: Direction) -> DiffReport { + DiffReport::compute( + &Capture::new("cpp", vec![cpp]), + &Capture::new("rust", vec![rust]), + &[direction], + ) +} + +fn s2c_report(cpp: Vec, rust: Vec) -> DiffReport { + report( + packet(Direction::S2C, 1, SMSG_UPDATE_OBJECT, cpp), + packet(Direction::S2C, 1, SMSG_UPDATE_OBJECT, rust), + Direction::S2C, + ) +} + +fn semantic(report: &DiffReport) -> &capture_diff::SemanticBodyDiff { + report.ops[0] + .body + .as_ref() + .and_then(|body| body.semantic.as_ref()) + .expect("reviewed UpdateObject comparator") +} + +fn set_u32_le(body: &mut [u8], offset: usize, value: u32) { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +#[test] +fn real_51_and_46_byte_bodies_remove_only_cpp_empty_unit_power_parent() { + let report = s2c_report(REAL_CPP_BODY.to_vec(), REAL_RUST_BODY.to_vec()); + + assert!(report.is_clean(), "{}", report.render_text()); + assert_eq!(report.counts.matched, 1); + let body = report.ops[0].body.as_ref().expect("body comparison"); + assert_eq!(body.cpp_len, 51); + assert_eq!(body.rust_len, 46); + assert_eq!(body.first_diff_offset, None); + let semantic = semantic(&report); + assert_eq!( + semantic.comparator, + "smsg_update_object_without_cpp_empty_unit_power_parent" + ); + assert_eq!(semantic.cpp.raw_body_sha256, None); + assert_eq!(semantic.rust.raw_body_sha256, None); + + let cpp = semantic + .cpp + .update_object_inv_slots + .as_ref() + .expect("decoded C++ update"); + let rust = semantic + .rust + .update_object_inv_slots + .as_ref() + .expect("decoded Rust update"); + assert_eq!(cpp, rust); + assert_eq!(cpp.map_id, 530); + assert_eq!(cpp.player.low, 15); + assert_eq!(cpp.player.high, 0x0800_0400_0000_0000); + assert_eq!(cpp.inv_slots.len(), 1); + assert_eq!(cpp.inv_slots[0].slot, 106); + assert_eq!(cpp.inv_slots[0].item.low, 0x001E_84FA); + assert_eq!(cpp.inv_slots[0].item.high, 0x0C00_0400_0000_0000); +} + +#[test] +fn map_and_player_identity_remain_strict() { + let mut changed_map = REAL_RUST_BODY.to_vec(); + changed_map[4] = 0x13; + let map_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_map); + assert!(!map_report.is_clean()); + assert!(semantic(&map_report).mismatch_summary().contains("map_id")); + + let mut changed_player = REAL_RUST_BODY.to_vec(); + changed_player[14] = 0x10; + let player_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_player); + assert!(!player_report.is_clean()); + assert!( + semantic(&player_report) + .mismatch_summary() + .contains("player") + ); +} + +#[test] +fn inv_slot_and_item_value_remain_strict() { + let mut changed_slot = REAL_RUST_BODY.to_vec(); + // ActivePlayer block 7: field 231 / slot 106 -> field 232 / slot 107. + changed_slot[37] = 0x01; + changed_slot[38] = 0x00; + let slot_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_slot); + assert!(!slot_report.is_clean()); + assert!( + semantic(&slot_report) + .mismatch_summary() + .contains("inv_slots.slot") + ); + + let mut changed_item = REAL_RUST_BODY.to_vec(); + changed_item[41] = 0xFB; + let item_report = s2c_report(REAL_CPP_BODY.to_vec(), changed_item); + assert!(!item_report.is_clean()); + assert!( + semantic(&item_report) + .mismatch_summary() + .contains("inv_slots.item") + ); +} + +#[test] +fn destroy_out_of_range_and_noncanonical_outer_bit_padding_fail() { + let mut destroy = REAL_RUST_BODY.to_vec(); + destroy[6] = 0x80; + let report = s2c_report(REAL_CPP_BODY.to_vec(), destroy); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut padding = REAL_RUST_BODY.to_vec(); + padding[6] = 0x01; + let report = s2c_report(REAL_CPP_BODY.to_vec(), padding); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("padding") + ); +} + +#[test] +fn another_object_type_parent_or_active_mask_fails() { + let mut another_type = REAL_RUST_BODY.to_vec(); + another_type[21] = 0x81; + let report = s2c_report(REAL_CPP_BODY.to_vec(), another_type); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + // Add ActivePlayer block 0 / field 0 alongside the real InvSlots blocks. + let mut another_active_mask = REAL_RUST_BODY.to_vec(); + another_active_mask[25] = 0x89; + another_active_mask.splice(31..31, [0x00, 0x00, 0x00, 0x01]); + set_u32_le(&mut another_active_mask, 7, 39); + set_u32_le(&mut another_active_mask, 17, 29); + let report = s2c_report(REAL_CPP_BODY.to_vec(), another_active_mask); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-InvSlots") + ); +} + +#[test] +fn any_unit_child_other_parent_or_payload_fails() { + let mut another_parent = REAL_CPP_BODY.to_vec(); + another_parent[27] = 0x08; + let report = s2c_report(another_parent, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("parent bit 116") + ); + + // Add child bit 117 and a four-byte field payload before ActivePlayer. + let mut child_and_payload = REAL_CPP_BODY.to_vec(); + child_and_payload[27] = 0x30; + child_and_payload.splice(30..30, [0x00, 0x00, 0x00, 0x00]); + set_u32_le(&mut child_and_payload, 7, 44); + set_u32_le(&mut child_and_payload, 17, 34); + let report = s2c_report(child_and_payload, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("parent bit 116") + ); + + // Even payload bytes without a child mask cannot be swallowed. + let mut payload_only = REAL_CPP_BODY.to_vec(); + payload_only.splice(30..30, [0xAA, 0xBB, 0xCC, 0xDD]); + set_u32_le(&mut payload_only, 7, 44); + set_u32_le(&mut payload_only, 17, 34); + let report = s2c_report(payload_only, REAL_RUST_BODY.to_vec()); + assert!(!report.is_clean()); + assert!(semantic(&report).cpp.decode_error.is_some()); +} + +#[test] +fn length_truncation_trailing_bytes_and_packed_guid_noncanonicality_fail() { + let mut bad_outer_len = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut bad_outer_len, 7, 34); + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_outer_len); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut bad_values_len = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut bad_values_len, 17, 24); + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_values_len); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut truncated = REAL_RUST_BODY.to_vec(); + truncated.pop(); + let report = s2c_report(REAL_CPP_BODY.to_vec(), truncated); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut trailing = REAL_RUST_BODY.to_vec(); + trailing.push(0xAA); + let report = s2c_report(REAL_CPP_BODY.to_vec(), trailing); + assert!(!report.is_clean()); + assert!(semantic(&report).rust.decode_error.is_some()); + + let mut bad_player_packing = REAL_RUST_BODY.to_vec(); + bad_player_packing[14] = 0; + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_player_packing); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-canonical") + ); + + let mut bad_item_packing = REAL_RUST_BODY.to_vec(); + bad_item_packing[41] = 0; + let report = s2c_report(REAL_CPP_BODY.to_vec(), bad_item_packing); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("non-canonical") + ); +} + +#[test] +fn player_and_item_global_guid_reserved_high_bits_fail_without_length_changes() { + // ObjectGuidFactory::CreatePlayer uses only HighGuid bits 58..63 and realm + // bits 42..57. Changing byte 5 from 0x04 to 0x05 sets forbidden bit 40 + // while preserving the packed length and HighGuid::Player. + let mut cpp_bad_player = REAL_CPP_BODY.to_vec(); + cpp_bad_player[15] = 0x05; + let mut rust_bad_player = REAL_RUST_BODY.to_vec(); + rust_bad_player[15] = 0x05; + let report = s2c_report(cpp_bad_player, rust_bad_player); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + + // ObjectGuidFactory::CreateItem has the same high-word layout. These are + // the matching high-byte positions in the literal 51/46-byte bodies. + let mut cpp_bad_item = REAL_CPP_BODY.to_vec(); + cpp_bad_item[49] = 0x05; + let mut rust_bad_item = REAL_RUST_BODY.to_vec(); + rust_bad_item[44] = 0x05; + let report = s2c_report(cpp_bad_item, rust_bad_item); + assert!(!report.is_clean()); + assert!( + semantic(&report) + .cpp + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); + assert!( + semantic(&report) + .rust + .decode_error + .as_deref() + .unwrap() + .contains("reserved high-word bits 0..41") + ); +} + +#[test] +fn identical_malformed_candidate_is_never_capture_clean() { + let mut malformed = REAL_RUST_BODY.to_vec(); + malformed[14] = 0; + let report = s2c_report(malformed.clone(), malformed); + + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + assert!(semantic(&report).cpp.decode_error.is_some()); + assert!(semantic(&report).rust.decode_error.is_some()); + assert!(semantic(&report).cpp.raw_body_sha256.is_some()); + assert!(semantic(&report).rust.raw_body_sha256.is_some()); +} + +#[test] +fn mixed_not_eligible_and_malformed_shapes_keep_raw_comparison_in_both_orientations() { + let mut not_eligible = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut not_eligible, 0, 2); + let mut malformed = REAL_RUST_BODY.to_vec(); + malformed[14] = 0; + + for report in [ + s2c_report(not_eligible.clone(), malformed.clone()), + s2c_report(malformed.clone(), not_eligible.clone()), + ] { + assert!(!report.is_clean()); + assert_eq!(report.counts.body_mismatches, 1); + let body = report.ops[0].body.as_ref().expect("raw body comparison"); + assert!(body.semantic.is_none()); + assert!(body.first_diff_offset.is_some()); + } +} + +#[test] +fn cpp_only_orientation_is_mandatory() { + let report = s2c_report(REAL_RUST_BODY.to_vec(), REAL_CPP_BODY.to_vec()); + + assert!(!report.is_clean()); + assert!(semantic(&report).mismatch_summary().contains("C++-only")); +} + +#[test] +fn create_object_and_other_update_object_shapes_receive_no_normalization() { + let mut cpp_create = REAL_CPP_BODY.to_vec(); + let mut rust_create = REAL_RUST_BODY.to_vec(); + cpp_create[11] = 1; + rust_create[11] = 1; + let report = s2c_report(cpp_create, rust_create); + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); + + let mut cpp_many = REAL_CPP_BODY.to_vec(); + let mut rust_many = REAL_RUST_BODY.to_vec(); + set_u32_le(&mut cpp_many, 0, 2); + set_u32_le(&mut rust_many, 0, 2); + let report = s2c_report(cpp_many, rust_many); + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn same_side_shape_receives_no_normalization() { + let mut changed = REAL_CPP_BODY.to_vec(); + changed[14] = 0x10; + let report = s2c_report(REAL_CPP_BODY.to_vec(), changed); + + assert!(!report.is_clean()); + assert!(report.ops[0].body.as_ref().unwrap().semantic.is_none()); +} + +#[test] +fn opcode_direction_and_instance_routing_remain_strict() { + let wrong_opcode = report( + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT - 1, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT - 1, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + assert!(!wrong_opcode.is_clean()); + assert!( + wrong_opcode.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_direction = report( + packet( + Direction::C2S, + 1, + SMSG_UPDATE_OBJECT, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::C2S, + 1, + SMSG_UPDATE_OBJECT, + REAL_RUST_BODY.to_vec(), + ), + Direction::C2S, + ); + assert!(!wrong_direction.is_clean()); + assert!( + wrong_direction.ops[0] + .body + .as_ref() + .unwrap() + .semantic + .is_none() + ); + + let wrong_route = report( + packet( + Direction::S2C, + 1, + SMSG_UPDATE_OBJECT, + REAL_CPP_BODY.to_vec(), + ), + packet( + Direction::S2C, + 0, + SMSG_UPDATE_OBJECT, + REAL_RUST_BODY.to_vec(), + ), + Direction::S2C, + ); + assert!(!wrong_route.is_clean()); + assert_eq!(wrong_route.counts.body_mismatches, 0); + assert_eq!(wrong_route.counts.connection_mismatches, 1); +} + +#[test] +fn semantic_mismatch_baseline_keeps_all_stable_values() { + let mut changed_slot = REAL_RUST_BODY.to_vec(); + changed_slot[37] = 0x01; + changed_slot[38] = 0x00; + let slot_signature = s2c_report(REAL_CPP_BODY.to_vec(), changed_slot).signatures(); + + let mut changed_item = REAL_RUST_BODY.to_vec(); + changed_item[41] = 0xFB; + let item_signature = s2c_report(REAL_CPP_BODY.to_vec(), changed_item).signatures(); + + assert_eq!(slot_signature.len(), 1); + assert_eq!(item_signature.len(), 1); + assert_ne!(slot_signature, item_signature); + assert_eq!(slot_signature[0].cpp_body_len, None); + assert_eq!(slot_signature[0].rust_body_len, None); + assert_eq!(slot_signature[0].first_diff_offset, None); +} diff --git a/crates/world-server/src/creature_loaded_grid.rs b/crates/world-server/src/creature_loaded_grid.rs index ce4dc27b9..3a139a236 100644 --- a/crates/world-server/src/creature_loaded_grid.rs +++ b/crates/world-server/src/creature_loaded_grid.rs @@ -74,6 +74,10 @@ pub struct ResolvedCreatureTemplateLikeCpp { pub static_flags: [u32; 8], pub creature_type: u32, pub type_flags: u32, + pub loot_id: u32, + pub skin_loot_id: u32, + pub gold_min: u32, + pub gold_max: u32, pub movement_type: MovementGeneratorType, pub ground_movement_type: u8, pub swim_allowed: bool, @@ -421,6 +425,10 @@ pub fn build_loaded_grid_creature_inputs_from_db_like_cpp( static_flags: difficulty.static_flags, creature_type: template.creature_type, type_flags: difficulty.type_flags, + loot_id: difficulty.loot_id, + skin_loot_id: difficulty.skin_loot_id, + gold_min: difficulty.gold_min, + gold_max: difficulty.gold_max, movement_type, ground_movement_type: runtime_row.ground_movement_type, swim_allowed: runtime_row.swim_allowed, @@ -583,6 +591,10 @@ fn template_lifecycle_record( static_flags: template.static_flags, creature_type: template.creature_type, type_flags: template.type_flags, + loot_id: template.loot_id, + skin_loot_id: template.skin_loot_id, + gold_min: template.gold_min, + gold_max: template.gold_max, movement_type: template.movement_type, ground_movement_type: template.ground_movement_type, swim_allowed: template.swim_allowed, @@ -694,6 +706,10 @@ mod tests { static_flags: [0; 8], creature_type: 0, type_flags: 0x20, + loot_id: 7_001, + skin_loot_id: 7_002, + gold_min: 17, + gold_max: 29, movement_type: MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -904,6 +920,17 @@ mod tests { fn db_backed_difficulty_store_with_static_flags( entry: u32, static_flags: [u32; 8], + ) -> CreatureDifficultyStoreLikeCpp { + db_backed_difficulty_store_with_static_flags_and_loot(entry, static_flags, 0, 0, 0, 0) + } + + fn db_backed_difficulty_store_with_static_flags_and_loot( + entry: u32, + static_flags: [u32; 8], + loot_id: u32, + skin_loot_id: u32, + gold_min: u32, + gold_max: u32, ) -> CreatureDifficultyStoreLikeCpp { CreatureDifficultyStoreLikeCpp::from_records( [wow_data::CreatureDifficultyRecordLikeCpp { @@ -919,14 +946,48 @@ mod tests { creature_difficulty_id: 0, type_flags: 0x55, type_flags2: 0, - loot_id: 0, + loot_id, + pickpocket_loot_id: 0, + skin_loot_id, + gold_min, + gold_max, + static_flags, + }], + |_| 1.0, + ) + } + + fn db_backed_fallback_difficulty_store_with_loot( + entry: u32, + static_flags: [u32; 8], + loot_id: u32, + skin_loot_id: u32, + gold_min: u32, + gold_max: u32, + ) -> CreatureDifficultyStoreLikeCpp { + CreatureDifficultyStoreLikeCpp::from_records_with_difficulty_fallbacks( + [wow_data::CreatureDifficultyRecordLikeCpp { + entry, + difficulty_id: 0, + min_level: 18, + max_level: 20, + health_scaling_expansion: -1, + health_modifier: 2.0, + mana_modifier: 3.0, + armor_modifier: 1.0, + damage_modifier: 4.0, + creature_difficulty_id: 0, + type_flags: 0x55, + type_flags2: 0, + loot_id, pickpocket_loot_id: 0, - skin_loot_id: 0, - gold_min: 0, - gold_max: 0, + skin_loot_id, + gold_min, + gold_max, static_flags, }], |_| 1.0, + [(2, 0)], ) } @@ -1042,7 +1103,14 @@ mod tests { &spawn, &runtime_row, &db_backed_template_store(entry), - &db_backed_difficulty_store_with_static_flags(entry, static_flags), + &db_backed_fallback_difficulty_store_with_loot( + entry, + static_flags, + 21_779, + 21_780, + 13, + 31, + ), &db_backed_base_stats_store(), &CreatureClassificationHealthRatesLikeCpp::default(), &display_store, @@ -1080,6 +1148,10 @@ mod tests { "C++ ObjectMgr strips disallowed creature `unit_flags3` SQL bits before UpdateEntry" ); assert_eq!(template.static_flags[0], static_flags[0]); + assert_eq!(template.loot_id, 21_779); + assert_eq!(template.skin_loot_id, 21_780); + assert_eq!((template.gold_min, template.gold_max), (13, 31)); + assert_eq!(template.difficulty_id, 2); assert_eq!(template.display_id, 999); assert_eq!( template.flight_movement_type, @@ -1122,6 +1194,31 @@ mod tests { assert_eq!(runtime.stats.mana, 150); assert_eq!(runtime.stats.min_damage, 20.0); assert_eq!(runtime.stats.max_damage, 30.0); + + let (default_loot_template, _, _) = build_loaded_grid_creature_inputs_from_db_like_cpp( + &spawn, + &runtime_row, + &db_backed_template_store(entry), + &CreatureDifficultyStoreLikeCpp::default(), + &db_backed_base_stats_store(), + &CreatureClassificationHealthRatesLikeCpp::default(), + &display_store, + &model_store, + &model_info_store, + None, + &CreatureAddonStoreLikeCpp::default(), + 2, + 9, + 123, + true, + None, + &mut random, + ) + .expect("missing difficulty rows should use C++'s zero-valued fallback record"); + assert_eq!(default_loot_template.loot_id, 0); + assert_eq!(default_loot_template.skin_loot_id, 0); + assert_eq!(default_loot_template.gold_min, 0); + assert_eq!(default_loot_template.gold_max, 0); } #[test] @@ -2114,6 +2211,66 @@ mod tests { ); } + #[test] + fn loaded_grid_creature_lifecycle_resolver_applies_difficulty_loot_metadata_like_cpp() { + let entry = 12_345; + let resolver = CreatureLoadedGridLifecycleResolverLikeCpp::new( + [template(entry)], + [spawn(55, entry, true)], + [selection(entry)], + ); + + let resolved = resolver + .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 55)) + .expect("resolver should carry selected difficulty loot metadata"); + + assert_eq!(resolved.lifecycle_record.create.template.loot_id, 7_001); + assert_eq!( + resolved.lifecycle_record.create.template.skin_loot_id, + 7_002 + ); + assert_eq!( + ( + resolved.lifecycle_record.create.template.gold_min, + resolved.lifecycle_record.create.template.gold_max, + ), + (17, 29) + ); + assert_eq!(resolved.creature.ai_ownership().loot_id, 7_001); + assert_eq!(resolved.creature.ai_ownership().skin_loot_id, 7_002); + assert_eq!( + ( + resolved.creature.ai_ownership().gold_min, + resolved.creature.ai_ownership().gold_max, + ), + (17, 29) + ); + } + + #[test] + fn loaded_grid_creature_lifecycle_resolver_preserves_absent_loot_metadata_like_cpp() { + let entry = 12_345; + let mut no_loot_template = template(entry); + no_loot_template.loot_id = 0; + no_loot_template.skin_loot_id = 0; + no_loot_template.gold_min = 0; + no_loot_template.gold_max = 0; + let resolver = CreatureLoadedGridLifecycleResolverLikeCpp::new( + [no_loot_template], + [spawn(55, entry, true)], + [selection(entry)], + ); + + let resolved = resolver + .resolve_loaded_grid_creature_like_cpp(55, map_creature_guid(entry, 571, 55)) + .expect("zero difficulty loot metadata remains a valid no-loot creature"); + + assert_eq!(resolved.creature.ai_ownership().loot_id, 0); + assert_eq!(resolved.creature.ai_ownership().skin_loot_id, 0); + assert_eq!(resolved.creature.ai_ownership().gold_min, 0); + assert_eq!(resolved.creature.ai_ownership().gold_max, 0); + } + #[test] fn loaded_grid_creature_lifecycle_resolver_applies_sparring_health_pct_like_cpp() { let entry = 12_345; diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index 1f58428d1..2513cce46 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -33,9 +33,9 @@ use wow_core::{ use wow_database::{ CharStatements, CharacterDatabase, DATABASE_CHARACTER_LIKE_CPP, DATABASE_HOTFIX_LIKE_CPP, DATABASE_LOGIN_LIKE_CPP, DATABASE_MASK_ALL_LIKE_CPP, DATABASE_WORLD_LIKE_CPP, HotfixDatabase, - LoginDatabase, LoginStatements, PreparedStatement, SqlParam, SqlResult, SqlTransaction, - StatementDef, WorldDatabase, WorldStatements, escape_string_like_cpp, - warn_about_sync_queries_scope_like_cpp, + ItemGuidAllocatorAdvisoryLockLikeCpp, LoginDatabase, LoginStatements, PreparedStatement, + SqlParam, SqlResult, SqlTransaction, StatementDef, WorldDatabase, WorldStatements, + escape_string_like_cpp, warn_about_sync_queries_scope_like_cpp, }; use wow_instances::{InstanceLockMgr, MapDb2Entries, ResetSchedule}; use wow_loot::{ @@ -81,6 +81,48 @@ const WORLD_CONFIG_CANDIDATES: &[&str] = &[ "WorldServer.conf", "WorldServer.conf.dist", ]; + +fn next_item_guid_allocator_start_like_cpp(max_persisted_guid: Option) -> Result { + let next = max_persisted_guid + .unwrap_or(0) + .checked_add(1) + .context("item_instance GUID counter overflow")?; + let next = i64::try_from(next) + .context("item_instance GUID counter exceeds the supported integer range")?; + let generator_limit = ObjectGuid::max_counter(HighGuid::Item) - 1; + if next >= generator_limit { + bail!( + "item_instance GUID allocator start {next} is outside HighGuid::Item generator range (must be below {generator_limit})" + ); + } + Ok(next) +} + +// The first four statements mirror C++ `ObjectMgr::SetHighestGuids`. C++ does +// not clean orphaned stored-loot rows, but Rust loads those rows by item GUID on +// demand; clean them before publishing the allocator so a future item cannot +// inherit loot left behind by a deleted container. +const ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP: [CharStatements; 6] = [ + CharStatements::DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS, + CharStatements::DEL_INVALID_MAIL_ITEM_GUIDS, + CharStatements::DEL_INVALID_AUCTION_ITEM_GUIDS, + CharStatements::DEL_INVALID_GUILD_BANK_ITEM_GUIDS, + CharStatements::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS, + CharStatements::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS, +]; + +fn item_guid_reference_cleanup_transaction_like_cpp( + char_db: &CharacterDatabase, + next_item_guid: u64, +) -> SqlTransaction { + let mut transaction = SqlTransaction::new(); + for statement_id in ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP { + let mut statement = char_db.prepare(statement_id); + statement.set_u64(0, next_item_guid); + transaction.append(statement); + } + transaction +} const WORLD_CONFIG_DIR: &str = "worldserver.conf.d"; const RUSTYCORE_LEGACY_CREATURE_GLOBAL_RUNTIME_CONFIG: &str = "RustyCore.LegacyCreatureGlobalRuntime"; @@ -1336,6 +1378,50 @@ async fn main() -> Result { let guid_generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Player, max_guid)); info!("GUID generator initialized, next counter: {max_guid}"); + // A process-local atomic generator is safe only while one world-server can + // allocate for this character database. Hold a connection-scoped MySQL + // advisory lock for the complete server lifetime, failing startup if a + // rolling/duplicate process already owns that allocation domain. + let mut item_guid_allocator_advisory_lock = + ItemGuidAllocatorAdvisoryLockLikeCpp::acquire_like_cpp(char_db.pool()) + .await + .context("failed to acquire the character DB item GUID allocator lock")?; + + // C++ `ObjectMgr::SetHighestGuids` initializes one process-wide item + // generator from `MAX(item_instance.guid) + 1`. Sharing the atomic Rust + // mirror across every session prevents concurrent loot grants from + // selecting the same database GUID. + let next_item_guid = { + let stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); + match char_db.query(&stmt).await { + Ok(result) => { + if result.is_empty() || result.is_null(0) { + next_item_guid_allocator_start_like_cpp(None)? + } else { + let max_val: u64 = result + .try_read(0) + .context("failed to decode MAX(item_instance.guid)")?; + next_item_guid_allocator_start_like_cpp(Some(max_val))? + } + } + Err(error) => { + return Err(error) + .context("failed to initialize item GUID allocator from item_instance"); + } + } + }; + let next_item_guid_u64 = u64::try_from(next_item_guid) + .context("item GUID allocator start must be a positive database counter")?; + char_db + .commit_transaction(item_guid_reference_cleanup_transaction_like_cpp( + &char_db, + next_item_guid_u64, + )) + .await + .context("failed to clean dangling item GUID references before allocator publication")?; + let item_guid_generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, next_item_guid)); + info!("Item GUID generator initialized, next counter: {next_item_guid}"); + let char_db = Arc::new(char_db); // Load Item.db2 for inventory_type lookups (replaces item_type_cache table) @@ -5019,6 +5105,7 @@ async fn main() -> Result { login_db: Some(Arc::clone(&login_db)), world_db: Some(Arc::clone(&world_db)), guid_generator: Some(Arc::clone(&guid_generator)), + item_guid_generator: Some(Arc::clone(&item_guid_generator)), instance_lock_mgr: Some(Arc::clone(&instance_lock_mgr)), bank_bag_slot_prices_store: Some(Arc::clone(&bank_bag_slot_prices_store)), currency_types_store: Some(Arc::clone(¤cy_types_store)), @@ -5473,7 +5560,7 @@ async fn main() -> Result { realm_addr, lookup, resources, - move |account, pkt_rx, send_tx, res| { + move |account, pkt_rx, send_tx, send_write_fence_like_cpp, res| { let mgr = Arc::clone(&mgr); let smap = Arc::clone(&smap); let canonical_map = Arc::clone(&canonical_map); @@ -5488,6 +5575,7 @@ async fn main() -> Result { account, pkt_rx, send_tx, + send_write_fence_like_cpp, res, mgr, smap, @@ -5723,6 +5811,18 @@ async fn main() -> Result { } } } + result = item_guid_allocator_advisory_lock.wait_until_lost_like_cpp() => { + world_runtime_state.stop_now_like_cpp(ERROR_EXIT_CODE_LIKE_CPP); + match result { + Ok(()) => tracing::error!( + "Item GUID allocator advisory-lock monitor stopped unexpectedly" + ), + Err(error) => tracing::error!( + %error, + "Item GUID allocator advisory lock was lost; stopping before another GUID allocation" + ), + } + } } // Close registration under the same mutex used by `try_register`. An @@ -5870,6 +5970,11 @@ async fn main() -> Result { tracing::error!("Failed to mark realm {realm_id} offline: {e}"); } + if let Err(error) = item_guid_allocator_advisory_lock.release_like_cpp().await { + world_runtime_state.stop_now_like_cpp(ERROR_EXIT_CODE_LIKE_CPP); + tracing::error!(%error, "Failed to release item GUID allocator advisory lock"); + } + info!( exit_code = world_runtime_state.get_exit_code_like_cpp(), "World server stopped." @@ -7031,13 +7136,20 @@ async fn load_loot_template_rows_like_cpp( } loop { + // Trinity's QuestRequired column is a signed TINYINT(1). Reading it + // as u8 makes sqlx reject the signed MySQL type; defaulting that + // decode failure to zero turns every quest-only drop into normal + // loot. Fail startup instead of silently disabling the quest gate. + let quest_required = result + .try_read::(4) + .with_context(|| format!("failed to decode {kind:?} loot QuestRequired as TINYINT"))?; rows.push(LootTemplateRow { entry: result.try_read::(0).unwrap_or(0), item: wow_loot::LootStoreItem { item_id: result.try_read::(1).unwrap_or(0), reference: result.try_read::(2).unwrap_or(0), chance: result.try_read::(3).unwrap_or(0.0), - needs_quest: result.try_read::(4).unwrap_or(0) != 0, + needs_quest: loot_quest_required_from_signed_db_like_cpp(quest_required), loot_mode: result.try_read::(5).unwrap_or(0), group_id: result.try_read::(6).unwrap_or(0), min_count: result.try_read::(7).unwrap_or(0), @@ -7053,6 +7165,10 @@ async fn load_loot_template_rows_like_cpp( Ok(rows) } +const fn loot_quest_required_from_signed_db_like_cpp(value: i8) -> bool { + value != 0 +} + fn loot_store_all_rows_statement_like_cpp(kind: LootStoreKind) -> WorldStatements { match kind { LootStoreKind::Creature => WorldStatements::SEL_CREATURE_LOOT_TEMPLATE_ALL_ROWS, @@ -12358,6 +12474,7 @@ async fn create_session( account: AccountInfo, pkt_rx: flume::Receiver, send_tx: flume::Sender>, + send_write_fence_like_cpp: wow_network::SocketWriteFenceLikeCpp, resources: Arc, session_mgr: Arc, shared_map: SharedMapManager, @@ -12403,6 +12520,7 @@ async fn create_session( pkt_rx, send_tx, ); + session.set_send_write_fence_like_cpp(send_write_fence_like_cpp); let Some((active_session_id, session_cancellation)) = active_session_registry.try_register(account.id, session.session_command_tx()) else { @@ -12432,6 +12550,9 @@ async fn create_session( if let Some(ref generator) = resources.guid_generator { session.set_guid_generator(Arc::clone(generator)); } + if let Some(ref generator) = resources.item_guid_generator { + session.set_item_guid_generator_like_cpp(Arc::clone(generator)); + } if let Some(ref mgr) = resources.instance_lock_mgr { session.set_instance_lock_mgr(Arc::clone(mgr)); } @@ -14226,9 +14347,10 @@ mod tests { GameEventLiveUpdateActionLikeCpp, GameEventLiveUpdateSideEffectSummaryLikeCpp, GameEventQuestCompleteConditionSaveDbStatementKindLikeCpp, GameEventWorldEventStateDbOperationKindLikeCpp, GameEventWorldEventStateDbOperationLikeCpp, - GameEventWorldEventStateDbStatementKindLikeCpp, LoadedGridCreatureRespawnCachesLikeCpp, - PersistedRespawnLoadReportLikeCpp, PersistedRespawnRowLikeCpp, - PersistedRespawnTimesLikeCpp, REQUIRED_TDB_CACHE_ID_LIKE_CPP, + GameEventWorldEventStateDbStatementKindLikeCpp, + ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP, + LoadedGridCreatureRespawnCachesLikeCpp, PersistedRespawnLoadReportLikeCpp, + PersistedRespawnRowLikeCpp, PersistedRespawnTimesLikeCpp, REQUIRED_TDB_CACHE_ID_LIKE_CPP, REQUIRED_TDB_VERSION_LIKE_CPP, RESTART_EXIT_CODE_LIKE_CPP, RespawnDbDeleteQueueOutcomeLikeCpp, RespawnDbRetryQueueLikeCpp, RespawnDbSaveQueueOutcomeLikeCpp, RespawnDbSubmitErrorLikeCpp, @@ -14269,16 +14391,18 @@ mod tests { legacy_creature_aggro_config_like_cpp, legacy_creature_global_runtime_enabled_from_config_like_cpp, load_loaded_grid_area_triggers_like_cpp, load_world_config_from, loot_drop_rates_like_cpp, + loot_quest_required_from_signed_db_like_cpp, materialize_game_event_quest_complete_db_bridge_like_cpp, materialize_game_event_world_event_state_db_bridge_like_cpp, max_core_stuck_time_ms_like_cpp, max_core_stuck_time_secs_like_cpp, min_world_update_time_ms_like_cpp, mmap_runtime_config_like_cpp, - normalize_realm_security_level_like_cpp, normalize_realm_type_like_cpp, - normalized_realm_name_like_cpp, persisted_respawn_info_from_row_like_cpp, - process_exit_code_like_cpp, queue_respawn_db_delete_like_cpp, - queue_respawn_db_save_like_cpp, realm_id_like_cpp, realm_list_entry_from_row_like_cpp, - repair_cost_rate_like_cpp, reputation_rates_like_cpp, reset_schedule_like_cpp, - respawn_db_retry_delay, run_legacy_creature_lifecycle_tick_and_refresh_once_like_cpp, + next_item_guid_allocator_start_like_cpp, normalize_realm_security_level_like_cpp, + normalize_realm_type_like_cpp, normalized_realm_name_like_cpp, + persisted_respawn_info_from_row_like_cpp, process_exit_code_like_cpp, + queue_respawn_db_delete_like_cpp, queue_respawn_db_save_like_cpp, realm_id_like_cpp, + realm_list_entry_from_row_like_cpp, repair_cost_rate_like_cpp, reputation_rates_like_cpp, + reset_schedule_like_cpp, respawn_db_retry_delay, + run_legacy_creature_lifecycle_tick_and_refresh_once_like_cpp, run_legacy_creature_melee_tick_and_deliver_once_like_cpp, run_legacy_creature_movement_tick_and_deliver_once_like_cpp, run_legacy_creature_runtime_tick_and_deliver_once_like_cpp, @@ -14300,7 +14424,7 @@ mod tests { use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use wow_constants::{ConditionSourceType, ConditionType}; - use wow_core::{ObjectGuid, Position, guid::HighGuid}; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; use wow_data::{Condition, ConditionEntriesByTypeStore}; use wow_database::{ CharStatements, DATABASE_CHARACTER_LIKE_CPP, DATABASE_HOTFIX_LIKE_CPP, @@ -14323,6 +14447,13 @@ mod tests { packets::chat::{ChatMsg, ChatPkt}, }; + #[test] + fn signed_tinyint_quest_required_preserves_cpp_boolean_semantics() { + assert!(!loot_quest_required_from_signed_db_like_cpp(0)); + assert!(loot_quest_required_from_signed_db_like_cpp(1)); + assert!(loot_quest_required_from_signed_db_like_cpp(-1)); + } + fn legacy_runtime_world_map_store_like_cpp() -> wow_data::MapStore { wow_data::MapStore::from_entries([wow_data::MapEntry { id: 0, @@ -14347,6 +14478,52 @@ mod tests { })) } + #[test] + fn item_guid_allocator_start_is_max_plus_one_and_fails_before_generator_panic_like_cpp() { + assert_eq!(next_item_guid_allocator_start_like_cpp(None).unwrap(), 1); + let start = next_item_guid_allocator_start_like_cpp(Some(41)).unwrap(); + assert_eq!(start, 42); + let generator = ObjectGuidGenerator::new(HighGuid::Item, start); + assert_eq!( + generator.generate(), + 42, + "fetch_add returns the configured MAX+1 start before advancing" + ); + + let generator_limit = ObjectGuid::max_counter(HighGuid::Item) - 1; + assert_eq!( + next_item_guid_allocator_start_like_cpp(Some((generator_limit - 2) as u64)).unwrap(), + generator_limit - 1 + ); + assert!( + next_item_guid_allocator_start_like_cpp(Some((generator_limit - 1) as u64)).is_err(), + "startup must reject the value that ObjectGuidGenerator::generate would panic on" + ); + assert!(next_item_guid_allocator_start_like_cpp(Some(u64::MAX)).is_err()); + } + + #[test] + fn item_guid_allocator_cleans_every_dangling_reference_before_publication() { + let sql = ITEM_GUID_DANGLING_REFERENCE_CLEANUP_STATEMENTS_LIKE_CPP + .map(|statement| statement.sql()); + assert_eq!(sql.len(), 6); + assert!(sql[0].contains("character_inventory")); + assert!(sql[1].contains("mail_items")); + assert!(sql[2].contains("auctionhouse")); + assert!(sql[3].contains("guild_bank_item")); + assert_eq!( + sql[4], + "DELETE FROM item_loot_items WHERE container_id >= ?" + ); + assert_eq!( + sql[5], + "DELETE FROM item_loot_money WHERE container_id >= ?" + ); + for statement in sql { + assert!(statement.contains(">= ?")); + } + } + #[test] fn target_icon_raw_from_db_bytes_preserves_cpp_binary_guid_shape() { assert_eq!(target_icon_raw_from_db_bytes_like_cpp(&[]), [0u8; 16]); @@ -14376,6 +14553,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -20981,6 +21159,7 @@ mmap.enablePathFinding = 0 is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-database/src/lib.rs b/crates/wow-database/src/lib.rs index 8d7393345..b1e7b1d42 100644 --- a/crates/wow-database/src/lib.rs +++ b/crates/wow-database/src/lib.rs @@ -65,7 +65,10 @@ pub use statements::{ CharStatements, HOTFIX_STATEMENT_STRATEGY_LIKE_CPP, HotfixStatementStrategyLikeCpp, HotfixStatements, LoginStatements, StatementDef, WorldStatements, }; -pub use transaction::SqlTransaction; +pub use transaction::{ + ItemGuidAllocatorAdvisoryLockLikeCpp, SqlTransaction, SqlTransactionCommitError, + is_database_deadlock_like_cpp, retry_deadlocked_operation_like_cpp, +}; /// Type aliases for each database connection. pub type LoginDatabase = Database; diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 990624af8..cf5d77d4c 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1179,6 +1179,8 @@ pub enum CharStatements { /// UPDATE characters SET money = ? WHERE guid = ? UPD_CHAR_MONEY, + /// SELECT money FROM characters WHERE guid = ? FOR UPDATE + SEL_CHAR_MONEY_FOR_UPDATE, /// C++ `CHAR_UPD_CHARACTER` persists this field immediately before powers. /// UPDATE characters SET health = ? WHERE guid = ? UPD_CHAR_HEALTH, @@ -1208,12 +1210,22 @@ pub enum CharStatements { /// SELECT MAX(guid) FROM item_instance SEL_MAX_ITEM_GUID, + /// C++ ObjectMgr::SetHighestGuids startup cleanup. + DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS, + DEL_INVALID_MAIL_ITEM_GUIDS, + DEL_INVALID_AUCTION_ITEM_GUIDS, + DEL_INVALID_GUILD_BANK_ITEM_GUIDS, + /// Rust safety extension to C++ `ObjectMgr::SetHighestGuids`: stored loot + /// has no foreign key to `item_instance`, so orphan rows at or above the + /// next allocator value must not be inherited by a reused item GUID. + DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS, + DEL_INVALID_ITEM_LOOT_MONEY_GUIDS, /// INSERT INTO item_instance (guid, itemEntry, owner_guid, count, durability, enchantments, charges) /// VALUES (?, ?, ?, ?, ?, '', '') INS_ITEM_INSTANCE, - /// INSERT INTO item_instance preserving generated loot random property/context metadata. + /// INSERT INTO item_instance preserving generated loot flags/random/context metadata. INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT, /// INSERT INTO item_instance with the C++ Item::CloneItem persisted field subset. @@ -1671,6 +1683,8 @@ pub enum CharStatements { /// SELECT money FROM item_loot_money WHERE container_id = ? SEL_ITEMCONTAINER_MONEY, + /// SELECT money FROM item_loot_money WHERE container_id = ? FOR UPDATE + SEL_ITEMCONTAINER_MONEY_FOR_UPDATE, /// INSERT INTO item_loot_money (container_id, money) VALUES (?, ?) INS_ITEMCONTAINER_MONEY, @@ -2778,6 +2792,9 @@ impl StatementDef for CharStatements { Self::UPD_CHAR_XP => "UPDATE characters SET xp = ? WHERE guid = ?", Self::UPD_CHAR_LEVEL => "UPDATE characters SET level = ?, xp = ? WHERE guid = ?", Self::UPD_CHAR_MONEY => "UPDATE characters SET money = ? WHERE guid = ?", + Self::SEL_CHAR_MONEY_FOR_UPDATE => { + "SELECT money FROM characters WHERE guid = ? FOR UPDATE" + } Self::UPD_CHAR_HEALTH => "UPDATE characters SET health = ? WHERE guid = ?", Self::UPD_CHAR_POWERS => { "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" @@ -2798,6 +2815,22 @@ impl StatementDef for CharStatements { "UPDATE characters SET exploredZones = ? WHERE guid = ?" } Self::SEL_MAX_ITEM_GUID => "SELECT MAX(guid) FROM item_instance", + Self::DEL_INVALID_CHAR_INVENTORY_ITEM_GUIDS => { + "DELETE FROM character_inventory WHERE item >= ?" + } + Self::DEL_INVALID_MAIL_ITEM_GUIDS => "DELETE FROM mail_items WHERE item_guid >= ?", + Self::DEL_INVALID_AUCTION_ITEM_GUIDS => { + "DELETE a, ab, ai FROM auctionhouse a LEFT JOIN auction_bidders ab ON ab.auctionId = a.id LEFT JOIN auction_items ai ON ai.auctionId = a.id WHERE ai.itemGuid >= ?" + } + Self::DEL_INVALID_GUILD_BANK_ITEM_GUIDS => { + "DELETE FROM guild_bank_item WHERE item_guid >= ?" + } + Self::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS => { + "DELETE FROM item_loot_items WHERE container_id >= ?" + } + Self::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS => { + "DELETE FROM item_loot_money WHERE container_id >= ?" + } Self::INS_ITEM_INSTANCE => { "INSERT INTO item_instance \ (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, \ @@ -2810,7 +2843,7 @@ impl StatementDef for CharStatements { (guid, itemEntry, owner_guid, creatorGuid, giftCreatorGuid, count, \ durability, enchantments, charges, flags, randomPropertiesId, \ randomPropertiesSeed, context) \ - VALUES (?, ?, ?, 0, 0, ?, ?, '', '', 0, ?, ?, ?)" + VALUES (?, ?, ?, 0, 0, ?, ?, '', '', ?, ?, ?, ?)" } Self::INS_ITEM_INSTANCE_CLONE => { "INSERT INTO item_instance \ @@ -3177,6 +3210,9 @@ impl StatementDef for CharStatements { Self::SEL_ITEMCONTAINER_MONEY => { "SELECT money FROM item_loot_money WHERE container_id = ? LIMIT 1" } + Self::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE => { + "SELECT money FROM item_loot_money WHERE container_id = ? FOR UPDATE" + } Self::INS_ITEMCONTAINER_MONEY => { "INSERT INTO item_loot_money (container_id, money) VALUES (?, ?)" } @@ -6114,7 +6150,7 @@ mod tests { .sql() .matches('?') .count(), - 8 + 9 ); assert_eq!( CharStatements::INS_ITEM_INSTANCE_CLONE @@ -6214,6 +6250,20 @@ mod tests { .count(), 1 ); + assert_eq!( + CharStatements::DEL_INVALID_ITEM_LOOT_MONEY_GUIDS + .sql() + .matches('?') + .count(), + 1 + ); + assert_eq!( + CharStatements::DEL_INVALID_ITEM_LOOT_ITEMS_GUIDS + .sql() + .matches('?') + .count(), + 1 + ); assert_eq!( CharStatements::DEL_ITEMCONTAINER_ITEMS .sql() diff --git a/crates/wow-database/src/statements/mod.rs b/crates/wow-database/src/statements/mod.rs index 534674bb6..4ada3307d 100644 --- a/crates/wow-database/src/statements/mod.rs +++ b/crates/wow-database/src/statements/mod.rs @@ -179,6 +179,20 @@ mod tests { .count(), 1 ); + // C++ ObjectMgr loads these unsigned columns directly. MariaDB + // promotes LEAST/GREATEST over them to NEWDECIMAL, which sqlx cannot + // decode as the u32 expected by the loot handlers and would silently + // turn deterministic money into zero through `try_read(...).unwrap_or(0)`. + // The item loader performs C++'s post-read min/max swap separately; + // gameobject addon bounds intentionally remain unnormalized. + let item_money_sql = WorldStatements::SEL_ITEM_TEMPLATE_ADDON_MONEY_LOOT.sql(); + assert!(item_money_sql.starts_with("SELECT MinMoneyLoot, MaxMoneyLoot ")); + assert!(!item_money_sql.contains("LEAST")); + assert!(!item_money_sql.contains("GREATEST")); + let gameobject_money_sql = WorldStatements::SEL_GAMEOBJECT_TEMPLATE_ADDON_MONEY_LOOT.sql(); + assert!(gameobject_money_sql.starts_with("SELECT mingold, maxgold ")); + assert!(!gameobject_money_sql.contains("LEAST")); + assert!(!gameobject_money_sql.contains("GREATEST")); assert_eq!( WorldStatements::SEL_ITEM_TEMPLATE_ADDON_LOOT_METADATA .sql() diff --git a/crates/wow-database/src/statements/world.rs b/crates/wow-database/src/statements/world.rs index 9e73c3dc3..9b9c62f6b 100644 --- a/crates/wow-database/src/statements/world.rs +++ b/crates/wow-database/src/statements/world.rs @@ -922,13 +922,11 @@ impl StatementDef for WorldStatements { "SELECT COALESCE(SellPrice, 0) FROM hotfixes.item_sparse WHERE ID = ? LIMIT 1" } Self::SEL_ITEM_TEMPLATE_ADDON_MONEY_LOOT => concat!( - "SELECT LEAST(COALESCE(MinMoneyLoot, 0), COALESCE(MaxMoneyLoot, 0)), ", - "GREATEST(COALESCE(MinMoneyLoot, 0), COALESCE(MaxMoneyLoot, 0)) ", + "SELECT MinMoneyLoot, MaxMoneyLoot ", "FROM item_template_addon WHERE Id = ? LIMIT 1", ), Self::SEL_GAMEOBJECT_TEMPLATE_ADDON_MONEY_LOOT => concat!( - "SELECT LEAST(COALESCE(mingold, 0), COALESCE(maxgold, 0)), ", - "GREATEST(COALESCE(mingold, 0), COALESCE(maxgold, 0)) ", + "SELECT mingold, maxgold ", "FROM gameobject_template_addon WHERE entry = ? LIMIT 1", ), Self::SEL_ITEM_TEMPLATE_ADDON_LOOT_METADATA => concat!( diff --git a/crates/wow-database/src/transaction.rs b/crates/wow-database/src/transaction.rs index 1331138e8..9ad4614a8 100644 --- a/crates/wow-database/src/transaction.rs +++ b/crates/wow-database/src/transaction.rs @@ -2,25 +2,295 @@ use crate::error::DatabaseError; use crate::params::{PreparedStatement, SqlParam}; -use sqlx::MySqlPool; +use sqlx::{MySql, MySqlPool, pool::PoolConnection}; +use std::future::Future; use std::sync::LazyLock; use std::time::{Duration, Instant}; -use tokio::sync::Mutex; +use tokio::{ + sync::{Mutex, oneshot, watch}, + task::JoinHandle, + time::MissedTickBehavior, +}; const DEADLOCK_MAX_RETRY_TIME_LIKE_CPP: Duration = Duration::from_secs(60); static DEADLOCK_RETRY_LOCK_LIKE_CPP: LazyLock> = LazyLock::new(|| Mutex::new(())); +const ITEM_GUID_ALLOCATOR_LOCK_PREFIX_LIKE_CPP: &str = "rustycore:item-guid:"; +const ITEM_GUID_ALLOCATOR_LOCK_VERIFY_INTERVAL_LIKE_CPP: Duration = Duration::from_secs(30); + +/// Retry an operation whose caller can distinguish MySQL deadlock 1213 from +/// all other failures. This extends TrinityCore's serialized 60-second retry +/// discipline to transactions that perform dynamic `SELECT ... FOR UPDATE` +/// reads and therefore cannot be represented by [`SqlTransaction`]. +pub async fn retry_deadlocked_operation_like_cpp( + mut operation: F, + is_deadlock: IsDeadlock, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, + IsDeadlock: Fn(&E) -> bool, +{ + let mut result = operation().await; + if result + .as_ref() + .err() + .is_none_or(|error| !is_deadlock(error)) + { + return result; + } + + let _deadlock_guard = DEADLOCK_RETRY_LOCK_LIKE_CPP.lock().await; + let start = Instant::now(); + loop { + if start.elapsed() > DEADLOCK_MAX_RETRY_TIME_LIKE_CPP { + tracing::error!( + target: "sql.sql", + "Fatal deadlocked SQL operation, it will not be retried anymore" + ); + return result; + } + + result = operation().await; + if result + .as_ref() + .err() + .is_none_or(|error| !is_deadlock(error)) + { + return result; + } + tracing::warn!( + target: "sql.sql", + loop_timer_ms = start.elapsed().as_millis(), + "Deadlocked SQL operation, retrying" + ); + } +} + +/// Dedicated MySQL connection holding the process-lifetime item GUID allocator +/// lock. `close_on_drop` is set after acquisition so early-return and panic +/// paths close the connection instead of returning a still-locked connection +/// to the pool. +#[derive(Debug)] +pub struct ItemGuidAllocatorAdvisoryLockLikeCpp { + stop_tx: Option>, + loss_rx: watch::Receiver>, + monitor_handle: Option>>, +} + +impl ItemGuidAllocatorAdvisoryLockLikeCpp { + pub async fn acquire_like_cpp(pool: &MySqlPool) -> Result { + let mut connection = pool.acquire().await.map_err(DatabaseError::from)?; + // From this point onward even an ambiguous GET_LOCK response must not + // return a potentially locked connection to the pool. + connection.close_on_drop(); + let database_name = sqlx::query_scalar::<_, Option>("SELECT DATABASE()") + .fetch_one(&mut *connection) + .await + .map_err(DatabaseError::from)? + .ok_or_else(|| { + DatabaseError::Transaction( + "item GUID allocator lock requires a selected character database".to_string(), + ) + })?; + let lock_name = item_guid_allocator_lock_name_like_cpp(&database_name); + let acquired = sqlx::query_scalar::<_, Option>("SELECT GET_LOCK(?, 0)") + .bind(&lock_name) + .fetch_one(&mut *connection) + .await + .map_err(DatabaseError::from)?; + if acquired != Some(1) { + return Err(DatabaseError::Transaction(format!( + "another world-server owns the item GUID allocator lock for character database {database_name}" + ))); + } + let (stop_tx, stop_rx) = oneshot::channel(); + let (loss_tx, loss_rx) = watch::channel(None); + let monitor_handle = tokio::spawn(run_item_guid_allocator_lock_monitor_like_cpp( + connection, lock_name, stop_rx, loss_tx, + )); + Ok(Self { + stop_tx: Some(stop_tx), + loss_rx, + monitor_handle: Some(monitor_handle), + }) + } + + /// Wait until the dedicated MySQL session no longer demonstrably owns the + /// lock. A world process must treat this as fatal because its process-local + /// allocator is no longer exclusive after that instant. + pub async fn wait_until_lost_like_cpp(&mut self) -> Result<(), DatabaseError> { + loop { + if let Some(message) = self.loss_rx.borrow().clone() { + return Err(DatabaseError::Transaction(message)); + } + if self.loss_rx.changed().await.is_err() { + return Err(DatabaseError::Transaction( + "item GUID allocator advisory-lock monitor stopped unexpectedly".to_string(), + )); + } + } + } + + /// Release explicitly during orderly shutdown. If the query fails, the + /// connection's close-on-drop fallback still releases this session lock. + pub async fn release_like_cpp(mut self) -> Result<(), DatabaseError> { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + let Some(monitor_handle) = self.monitor_handle.take() else { + return Ok(()); + }; + monitor_handle.await.map_err(|error| { + DatabaseError::Transaction(format!( + "item GUID allocator advisory-lock monitor task failed: {error}" + )) + })? + } + + #[cfg(test)] + pub(crate) fn lock_name_for_test(database_name: &str) -> String { + item_guid_allocator_lock_name_like_cpp(database_name) + } +} + +async fn run_item_guid_allocator_lock_monitor_like_cpp( + mut connection: PoolConnection, + lock_name: String, + mut stop_rx: oneshot::Receiver<()>, + loss_tx: watch::Sender>, +) -> Result<(), DatabaseError> { + let mut verify_interval = + tokio::time::interval(ITEM_GUID_ALLOCATOR_LOCK_VERIFY_INTERVAL_LIKE_CPP); + verify_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + _ = &mut stop_rx => { + return release_item_guid_allocator_lock_like_cpp(&mut connection, &lock_name).await; + } + _ = verify_interval.tick() => { + let ownership = sqlx::query_scalar::<_, Option>( + "SELECT IS_USED_LOCK(?) = CONNECTION_ID()", + ) + .bind(&lock_name) + .fetch_one(&mut *connection) + .await; + match ownership { + Ok(Some(1)) => {} + Ok(_) => { + let message = format!( + "dedicated MySQL session lost item GUID allocator advisory lock {lock_name}" + ); + let _ = loss_tx.send(Some(message.clone())); + return Err(DatabaseError::Transaction(message)); + } + Err(error) => { + let error = DatabaseError::from(error); + let message = format!( + "could not verify item GUID allocator advisory lock {lock_name}: {error}" + ); + let _ = loss_tx.send(Some(message)); + return Err(error); + } + } + } + } + } +} + +async fn release_item_guid_allocator_lock_like_cpp( + connection: &mut PoolConnection, + lock_name: &str, +) -> Result<(), DatabaseError> { + let released = sqlx::query_scalar::<_, Option>("SELECT RELEASE_LOCK(?)") + .bind(lock_name) + .fetch_one(&mut **connection) + .await + .map_err(DatabaseError::from)?; + if released != Some(1) { + return Err(DatabaseError::Transaction(format!( + "item GUID allocator advisory lock {lock_name} was not owned at shutdown" + ))); + } + // The monitor owns this dedicated close-on-drop connection. Returning + // drops it immediately rather than putting it back into the shared pool. + Ok(()) +} + +fn item_guid_allocator_lock_name_like_cpp(database_name: &str) -> String { + // Stable FNV-1a avoids exposing a possibly sensitive database name while + // keeping independent character databases in independent lock domains. + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in database_name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{ITEM_GUID_ALLOCATOR_LOCK_PREFIX_LIKE_CPP}{hash:016x}") +} + +/// Whether a failed transaction is known to have rolled back or crossed the +/// point where MySQL may have committed before the connection lost the reply. +/// Consume-and-grant callers must never reopen a claim on the latter result. +#[derive(Debug)] +pub enum SqlTransactionCommitError { + DefinitelyRolledBack(DatabaseError), + CommitOutcomeUnknown(DatabaseError), +} + +impl SqlTransactionCommitError { + #[must_use] + pub fn is_commit_outcome_unknown_like_cpp(&self) -> bool { + matches!(self, Self::CommitOutcomeUnknown(_)) + } + + #[must_use] + pub fn into_database_error(self) -> DatabaseError { + match self { + Self::DefinitelyRolledBack(error) | Self::CommitOutcomeUnknown(error) => error, + } + } +} + +impl std::fmt::Display for SqlTransactionCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DefinitelyRolledBack(error) => { + write!(formatter, "transaction rolled back: {error}") + } + Self::CommitOutcomeUnknown(error) => { + write!(formatter, "transaction COMMIT outcome is unknown: {error}") + } + } + } +} + +impl std::error::Error for SqlTransactionCommitError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(match self { + Self::DefinitelyRolledBack(error) | Self::CommitOutcomeUnknown(error) => error, + }) + } +} + /// A batch of SQL statements to be executed atomically within a transaction. /// /// Matches the TC `TransactionBase` / `Transaction` pattern: collect /// prepared statements or raw SQL strings, then commit them all at once. #[derive(Debug, Default)] pub struct SqlTransaction { - statements: Vec, + statements: Vec, cleaned_up_like_cpp: bool, } +#[derive(Debug)] +struct TransactionStatement { + statement: PreparedStatement, + expected_rows_affected: Option, +} + impl SqlTransaction { /// Create a new empty transaction batch. pub fn new() -> Self { @@ -32,7 +302,23 @@ impl SqlTransaction { /// Append a prepared statement to this transaction. pub fn append(&mut self, stmt: PreparedStatement) { - self.statements.push(stmt); + self.statements.push(TransactionStatement { + statement: stmt, + expected_rows_affected: None, + }); + } + + /// Append a prepared statement whose affected-row count is part of the + /// transaction's correctness contract. + /// + /// A mismatch aborts the transaction before `COMMIT`. Consume-and-grant + /// operations must not treat an `UPDATE` or `DELETE` that matched no + /// durable row as a durable gameplay success. + pub fn append_expect_rows_affected(&mut self, stmt: PreparedStatement, expected: u64) { + self.statements.push(TransactionStatement { + statement: stmt, + expected_rows_affected: Some(expected), + }); } /// Append a raw SQL statement like TC `TransactionBase::Append(char const*)`. @@ -40,8 +326,7 @@ impl SqlTransaction { /// Prefer prepared statements for user input. This is for C++ parity with /// existing raw-SQL transaction call sites and test fixtures. pub fn append_raw_sql_like_cpp(&mut self, sql: impl Into) { - self.statements - .push(PreparedStatement::raw_sql_like_cpp(sql)); + self.append(PreparedStatement::raw_sql_like_cpp(sql)); } /// Clear queued statements once, mirroring TC `TransactionBase::Cleanup`. @@ -76,13 +361,25 @@ impl SqlTransaction { /// TrinityCore's `TransactionTask::_deadlockLock` and /// `DEADLOCK_MAX_RETRY_TIME_MS`. pub async fn commit(self, pool: &MySqlPool) -> Result<(), DatabaseError> { + self.commit_with_outcome_like_cpp(pool) + .await + .map_err(SqlTransactionCommitError::into_database_error) + } + + /// Commit while preserving the ambiguity of a transport error returned by + /// `COMMIT`. Query/validation failures and MySQL deadlock 1213 are definite + /// rollbacks; a non-deadlock COMMIT error must be reconciled by the caller. + pub async fn commit_with_outcome_like_cpp( + self, + pool: &MySqlPool, + ) -> Result<(), SqlTransactionCommitError> { if self.statements.is_empty() { return Ok(()); } let result = self.try_commit(pool).await; - if !is_deadlock_like_cpp(&result) { + if !is_outcome_deadlock_like_cpp(&result) { return result; } @@ -102,6 +399,9 @@ impl SqlTransaction { if retry.is_ok() { return retry; } + if !is_outcome_deadlock_like_cpp(&retry) { + return retry; + } tracing::warn!( target: "sql.sql", @@ -132,32 +432,85 @@ impl SqlTransaction { #[cfg(test)] pub(crate) fn sqls_for_test(&self) -> Vec<&str> { - self.statements.iter().map(PreparedStatement::sql).collect() + self.statements + .iter() + .map(|statement| statement.statement.sql()) + .collect() + } + + #[cfg(test)] + pub(crate) fn expected_rows_for_test(&self) -> Vec> { + self.statements + .iter() + .map(|statement| statement.expected_rows_affected) + .collect() } - async fn try_commit(&self, pool: &MySqlPool) -> Result<(), DatabaseError> { + async fn try_commit(&self, pool: &MySqlPool) -> Result<(), SqlTransactionCommitError> { self.try_commit_inner(pool).await } - async fn try_commit_inner(&self, pool: &MySqlPool) -> Result<(), DatabaseError> { - let mut tx = pool.begin().await?; + async fn try_commit_inner(&self, pool: &MySqlPool) -> Result<(), SqlTransactionCommitError> { + let mut tx = pool + .begin() + .await + .map_err(DatabaseError::from) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; - for stmt in &self.statements { + for (statement_index, transaction_statement) in self.statements.iter().enumerate() { + let stmt = &transaction_statement.statement; let mut query = sqlx::query(stmt.sql()); for param in stmt.params() { query = bind_param(query, param); } - query.execute(&mut *tx).await?; + let result = query + .execute(&mut *tx) + .await + .map_err(DatabaseError::from) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; + if let Some(expected) = transaction_statement.expected_rows_affected { + validate_rows_affected(statement_index, expected, result.rows_affected()) + .map_err(SqlTransactionCommitError::DefinitelyRolledBack)?; + } } - tx.commit().await?; + if let Err(error) = tx.commit().await { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + return Err(SqlTransactionCommitError::DefinitelyRolledBack(error)); + } + return Err(SqlTransactionCommitError::CommitOutcomeUnknown(error)); + } Ok(()) } } -fn is_deadlock_like_cpp(result: &Result<(), DatabaseError>) -> bool { +fn validate_rows_affected( + statement_index: usize, + expected: u64, + actual: u64, +) -> Result<(), DatabaseError> { + if actual == expected { + return Ok(()); + } + + Err(DatabaseError::Transaction(format!( + "statement {statement_index} affected {actual} rows; expected exactly {expected}" + ))) +} + +fn is_outcome_deadlock_like_cpp(result: &Result<(), SqlTransactionCommitError>) -> bool { match result { - Err(DatabaseError::Query(sqlx::Error::Database(db_err))) => { + Err(SqlTransactionCommitError::DefinitelyRolledBack(error)) => { + is_database_deadlock_like_cpp(error) + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(_)) | Ok(()) => false, + } +} + +pub fn is_database_deadlock_like_cpp(error: &DatabaseError) -> bool { + match error { + DatabaseError::Query(sqlx::Error::Database(db_err)) => { db_err.code().as_deref() == Some("1213") || db_err.message().contains("Deadlock") || db_err.message().contains("deadlock") @@ -191,7 +544,12 @@ pub(crate) fn bind_param<'q>( #[cfg(test)] mod tests { - use super::SqlTransaction; + use super::{ + ItemGuidAllocatorAdvisoryLockLikeCpp, SqlTransaction, SqlTransactionCommitError, + retry_deadlocked_operation_like_cpp, validate_rows_affected, + }; + use crate::{DatabaseError, PreparedStatement}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::sync::oneshot; @@ -214,6 +572,25 @@ mod tests { release_tx.send(()).unwrap(); holder.await.unwrap(); assert!(SqlTransaction::deadlock_retry_lock_probe_for_test().await); + + let attempts = AtomicUsize::new(0); + let value = retry_deadlocked_operation_like_cpp( + || { + let attempt = attempts.fetch_add(1, Ordering::Relaxed); + async move { + if attempt < 2 { + Err(DatabaseError::Transaction("synthetic deadlock".to_string())) + } else { + Ok(7u8) + } + } + }, + |error| error.to_string().contains("deadlock"), + ) + .await + .unwrap(); + assert_eq!(value, 7); + assert_eq!(attempts.load(Ordering::Relaxed), 3); } #[test] @@ -245,4 +622,58 @@ mod tests { tx.cleanup_like_cpp(); assert_eq!(tx.len(), 1); } + + #[test] + fn transaction_tracks_affected_row_contracts_fail_closed() { + let mut tx = SqlTransaction::new(); + tx.append(PreparedStatement::raw_sql_like_cpp( + "UPDATE item_instance SET count = 2 WHERE guid = 1", + )); + tx.append_expect_rows_affected( + PreparedStatement::raw_sql_like_cpp( + "DELETE FROM item_loot_items WHERE container_id = 1", + ), + 1, + ); + + assert_eq!(tx.expected_rows_for_test(), vec![None, Some(1)]); + assert!(validate_rows_affected(1, 1, 1).is_ok()); + let error = validate_rows_affected(1, 1, 0).unwrap_err(); + assert!(matches!(error, DatabaseError::Transaction(_))); + assert_eq!( + error.to_string(), + "transaction failed: statement 1 affected 0 rows; expected exactly 1" + ); + } + + #[test] + fn item_guid_allocator_lock_domain_is_stable_private_and_database_scoped() { + let first = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters"); + let same = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters"); + let other = ItemGuidAllocatorAdvisoryLockLikeCpp::lock_name_for_test("characters_qa"); + + assert_eq!(first, same); + assert_ne!(first, other); + assert!(first.starts_with("rustycore:item-guid:")); + assert!(!first.contains("characters")); + assert!( + first.len() <= 64, + "MySQL named locks are limited to 64 bytes" + ); + } + + #[test] + fn commit_outcome_unknown_remains_distinct_from_definite_rollback() { + let unknown = SqlTransactionCommitError::CommitOutcomeUnknown(DatabaseError::Transaction( + "connection lost after COMMIT".to_string(), + )); + let rollback = SqlTransactionCommitError::DefinitelyRolledBack(DatabaseError::Transaction( + "statement failed before COMMIT".to_string(), + )); + + assert!(unknown.is_commit_outcome_unknown_like_cpp()); + assert!(!rollback.is_commit_outcome_unknown_like_cpp()); + assert!(unknown.to_string().contains("outcome is unknown")); + assert!(rollback.to_string().contains("rolled back")); + } } diff --git a/crates/wow-entities/Cargo.toml b/crates/wow-entities/Cargo.toml index d28a9fb12..ccd3d82fa 100644 --- a/crates/wow-entities/Cargo.toml +++ b/crates/wow-entities/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true bitflags = { workspace = true } wow-core = { workspace = true } wow-constants = { workspace = true } +wow-loot = { workspace = true } [lints] workspace = true diff --git a/crates/wow-entities/src/creature.rs b/crates/wow-entities/src/creature.rs index eeaf71c2e..b7fa87d24 100644 --- a/crates/wow-entities/src/creature.rs +++ b/crates/wow-entities/src/creature.rs @@ -11,6 +11,10 @@ use wow_constants::{ UnitPvpFlags, UnitStandStateType, UnitState, WeaponAttackType, movement::MovementFlag, }; use wow_core::{ObjectGuid, Position}; +use wow_loot::{ + CreatureLoot, LootInstallOutcome, OwnedLootAuthority, OwnedLootAuthorityStamp, + OwnedLootSnapshot, +}; use crate::{ BASE_MAXDAMAGE, BASE_MINDAMAGE, MoveFallPlan, MovementGeneratorKind, @@ -274,6 +278,10 @@ pub struct CreatureTemplateLifecycleRecord { pub static_flags: [u32; 8], pub creature_type: u32, pub type_flags: u32, + pub loot_id: u32, + pub skin_loot_id: u32, + pub gold_min: u32, + pub gold_max: u32, pub movement_type: MovementGeneratorType, pub ground_movement_type: u8, pub swim_allowed: bool, @@ -870,6 +878,10 @@ impl CreatureOwnedLoot { } } +fn creature_owned_loot_from_snapshot(snapshot: &OwnedLootSnapshot) -> CreatureOwnedLoot { + CreatureOwnedLoot::new(snapshot.loot.coins, u32::from(snapshot.loot.unlooted_count)) +} + #[derive(Debug, Clone, PartialEq)] pub struct Creature { unit: Unit, @@ -926,6 +938,13 @@ pub struct Creature { attack_reputation_faction_id: Option, is_contested_guard_faction: bool, spell_focus: CreatureSpellFocusStateLikeCpp, + /// Monotonic identity for the creature loot-producing lifetime. Async + /// `Unit::Kill` generation captures this value and may install its pools + /// only while the same death lifetime is still current. Corpse removal + /// and respawn advance it, preventing an old generator from winning an + /// ABA race against a later incarnation of the same spawn GUID. + loot_lifecycle_revision: u64, + loot_authority: OwnedLootAuthority, shared_loot: Option, personal_loot: HashMap, } @@ -995,6 +1014,8 @@ impl Creature { attack_reputation_faction_id: None, is_contested_guard_faction: false, spell_focus: CreatureSpellFocusStateLikeCpp::default(), + loot_lifecycle_revision: 0, + loot_authority: OwnedLootAuthority::new(), shared_loot: None, personal_loot: HashMap::new(), } @@ -1156,6 +1177,15 @@ impl Creature { self.ai_ownership.unit_flags3 = template.unit_flags3; self.ai_ownership.min_damage = record.stats.min_damage.max(0.0) as u32; self.ai_ownership.max_damage = record.stats.max_damage.max(0.0) as u32; + // C++ `Creature::GetLootId` first honors an object-local `m_lootId` override and then + // reads the selected `CreatureDifficulty::LootID`; death/skinning generation reads + // GoldMin/GoldMax/SkinLootID from that same difficulty (`Creature.cpp:1317-1323`, + // `Unit.cpp:10545-10575,10675`). Keep those values on the runtime creature instead of + // falling back to `CreatureAiOwnershipState`'s zero defaults. + self.ai_ownership.loot_id = template.loot_id; + self.ai_ownership.skin_loot_id = template.skin_loot_id; + self.ai_ownership.gold_min = template.gold_min; + self.ai_ownership.gold_max = template.gold_max; self.unit .set_npc_flags_like_cpp(self.ai_ownership.npc_flags); self.unit @@ -1805,6 +1835,12 @@ impl Creature { } pub fn reset_ai_combat(&mut self, now_ms: u64) { + if self.ai_ownership.state == CreatureAiState::Dead + || self.unit.is_dead() + || self.unit.data().health == 0 + { + self.advance_loot_lifecycle_revision_like_cpp(); + } self.ai_ownership.state = CreatureAiState::Returning; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = Some(self.ai_ownership.home_position); @@ -1878,6 +1914,7 @@ impl Creature { game_time_secs.saturating_add(MAX_AGGRO_RESET_TIME_SECS_LIKE_CPP); } if remaining == 0 { + self.advance_loot_lifecycle_revision_like_cpp(); self.ai_ownership.state = CreatureAiState::Dead; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = None; @@ -1895,6 +1932,9 @@ impl Creature { } pub fn mark_ai_dead_at_game_time_like_cpp(&mut self, now_ms: u64, game_time_secs: i64) { + if self.ai_ownership.state != CreatureAiState::Dead { + self.advance_loot_lifecycle_revision_like_cpp(); + } self.ai_ownership.state = CreatureAiState::Dead; self.ai_ownership.combat_target = None; self.ai_ownership.move_target = None; @@ -2788,6 +2828,17 @@ impl Creature { &self.runtime_state } + pub const fn loot_lifecycle_revision_like_cpp(&self) -> u64 { + self.loot_lifecycle_revision + } + + fn advance_loot_lifecycle_revision_like_cpp(&mut self) -> u64 { + // Never wrap to an earlier lifetime: even an unreachable counter + // exhaustion must remain fail-closed for async generation tokens. + self.loot_lifecycle_revision = self.loot_lifecycle_revision.saturating_add(1).max(1); + self.loot_lifecycle_revision + } + pub fn runtime_state_mut(&mut self) -> &mut CreatureRuntimeState { &mut self.runtime_state } @@ -2800,6 +2851,128 @@ impl Creature { self.runtime_state.has_loot_recipient } + pub const fn loot_authority_like_cpp(&self) -> &OwnedLootAuthority { + &self.loot_authority + } + + /// Bind this runtime mirror to the same C++ `Creature::loot` authority as + /// another coexisting map model. + pub fn rebind_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) -> bool { + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return false; + } + + // Invalidate leases retained against a displaced mirror before the + // replacement authority can serve another claim for the same C++ + // object. + self.loot_authority.detach_like_cpp(); + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + true + } + + /// Rebinds only if this mirror still owns the authority observed by the + /// caller. This is the object-local compare/exchange boundary used when + /// the legacy and canonical map models are reconciled without holding + /// both map locks at once. + pub fn rebind_loot_authority_if_current_like_cpp( + &mut self, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + if !self.loot_authority.shares_storage_like_cpp(expected) { + return None; + } + + if self.loot_authority.stamp_like_cpp() != expected_stamp { + return None; + } + + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return Some(false); + } + + if !self.loot_authority.detach_if_stamp_like_cpp(expected_stamp) { + return None; + } + + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + Some(true) + } + + /// Assigns authority identity to a temporary whole-entity snapshot. + /// + /// The snapshot may share its old `Arc` with a live legacy entity, so it + /// must not detach that allocation. Only the later CAS against the actual + /// owning entity is allowed to invalidate a displaced authority. + pub fn adopt_loot_authority_for_snapshot_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + } + + pub fn share_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.rebind_loot_authority_like_cpp(authority); + } + + pub fn initialize_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn initialize_shared_loot_authority_like_cpp( + &mut self, + loot: CreatureLoot, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_shared_like_cpp(loot); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn upsert_personal_loot_authority_like_cpp( + &mut self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn replace_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> u64 { + let generation = self.loot_authority.replace_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + generation + } + + pub fn sync_loot_summaries_from_authority_like_cpp(&mut self) { + self.shared_loot = self + .loot_authority + .shared_snapshot_like_cpp() + .map(|snapshot| creature_owned_loot_from_snapshot(&snapshot)); + self.personal_loot = self + .loot_authority + .personal_snapshots_like_cpp() + .into_iter() + .map(|(player, snapshot)| (player, creature_owned_loot_from_snapshot(&snapshot))) + .collect(); + } + pub const fn shared_loot_like_cpp(&self) -> Option<&CreatureOwnedLoot> { self.shared_loot.as_ref() } @@ -2837,11 +3010,17 @@ impl Creature { } pub fn clear_loot_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); self.shared_loot = None; self.personal_loot.clear(); } pub fn is_fully_looted_like_cpp(&self) -> bool { + if !self.loot_authority.is_pristine_like_cpp() { + return self.loot_authority.is_fully_looted_like_cpp(); + } + if self .shared_loot .as_ref() @@ -2947,6 +3126,9 @@ impl Creature { match state { DeathState::JustDied => { + if self.ai_ownership.state != CreatureAiState::Dead { + self.advance_loot_lifecycle_revision_like_cpp(); + } let needs_falling = death_fall.filter(|context| { (self.is_flying_like_cpp() || self.is_hovering_like_cpp()) && !context.is_underwater @@ -3046,6 +3228,7 @@ impl Creature { self.unit.set_death_state(DeathState::Corpse); } DeathState::JustRespawned => { + self.advance_loot_lifecycle_revision_like_cpp(); let motion_initialize_outcome = self.aim_initialize_like_cpp(); let is_pet = self.unit.world().object().guid().is_pet(); if is_pet { @@ -3203,6 +3386,11 @@ impl Creature { return plan; } + // C++ drops the corpse's object-owned Loot during removal (directly + // in compatibility mode and via object destruction otherwise). Rust + // leases can outlive the map reference, so retire it at the lifecycle + // boundary before an in-flight async claim can commit. + self.clear_loot_like_cpp(); self.runtime_state.remove_corpse_requested = true; self.runtime_state.corpse_removed_count = self.runtime_state.corpse_removed_count.saturating_add(1); @@ -3546,6 +3734,44 @@ mod tests { } } + fn owned_loot_fixture_like_cpp( + coins: u32, + unlooted_count: u8, + allowed_looters: Vec, + ) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters, + items: Vec::new(), + looted_by_player: false, + } + } + + fn poll_immediately_ready(future: F) -> F::Output { + struct NoopWake; + + impl std::task::Wake for NoopWake { + fn wake(self: std::sync::Arc) {} + } + + let waker = std::task::Waker::from(std::sync::Arc::new(NoopWake)); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut context) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("expected the uncontended claim to be ready"), + } + } + #[test] fn creature_search_formation_like_cpp_requests_only_with_spawn_and_info() { let mut creature = Creature::new(false); @@ -4490,6 +4716,10 @@ mod tests { static_flags: [0; 8], creature_type: 9, type_flags: 0x20, + loot_id: 7_001, + skin_loot_id: 7_002, + gold_min: 17, + gold_max: 29, movement_type: MovementGeneratorType::Idle, ground_movement_type: CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -5895,6 +6125,109 @@ mod tests { assert!(!CreatureOwnedLoot::new(1, 1).is_looted_like_cpp()); } + #[test] + fn creature_owns_full_loot_authority_and_clear_retires_it() { + let mut creature = Creature::new(false); + let full_loot = CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins: 17, + unlooted_count: 2, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: Vec::new(), + looted_by_player: false, + }; + creature.initialize_shared_loot_authority_like_cpp(full_loot); + + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 2)) + ); + assert!(!creature.loot_authority_like_cpp().is_retired_like_cpp()); + creature.clear_loot_like_cpp(); + assert!(creature.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + creature + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .is_none() + ); + } + + #[test] + fn creature_rebind_retires_displaced_authority_and_its_lease() { + let player = ObjectGuid::create_player(1, 77); + let displaced = OwnedLootAuthority::new(); + displaced.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(17, 0, vec![player])), + HashMap::new(), + ); + let mut creature = Creature::new(false); + assert!(creature.rebind_loot_authority_like_cpp(displaced.clone())); + let lease = poll_immediately_ready(displaced.reserve_money_like_cpp(player)) + .expect("the allowed player reserves the displaced authority"); + + let replacement = OwnedLootAuthority::new(); + replacement.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(23, 0, vec![player])), + HashMap::new(), + ); + assert!(creature.rebind_loot_authority_like_cpp(replacement.clone())); + + assert!(displaced.is_retired_like_cpp()); + assert!( + creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement) + ); + assert_eq!( + lease.commit_like_cpp(), + Err(wow_loot::LootClaimCommitError::StaleGeneration), + "a lease against the displaced Arc must not commit after rebind" + ); + assert_eq!( + replacement.shared_snapshot_like_cpp().unwrap().loot.coins, + 23 + ); + } + + #[test] + fn creature_fully_looted_reads_active_authority_without_summary_refresh() { + let authority = OwnedLootAuthority::new(); + authority.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(17, 0, Vec::new())), + HashMap::new(), + ); + let mut creature = Creature::new(false); + creature.rebind_loot_authority_like_cpp(authority.clone()); + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 0)) + ); + assert!(!creature.is_fully_looted_like_cpp()); + + authority.replace_like_cpp( + Some(owned_loot_fixture_like_cpp(0, 0, Vec::new())), + HashMap::new(), + ); + + assert_eq!( + creature.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::new(17, 0)), + "the compatibility summary remains deliberately stale" + ); + assert!( + creature.is_fully_looted_like_cpp(), + "lifecycle decisions must read the active object-owned authority" + ); + } + #[test] fn creature_is_fully_looted_checks_shared_and_personal_loot_like_cpp() { let looted_player = ObjectGuid::create_player(1, 7); @@ -6167,6 +6500,23 @@ mod tests { assert!(non_compat_plan.contains(CreatureRuntimeAction::RequestObjectRemove)); } + #[test] + fn creature_corpse_removal_retires_arc_held_loot_authority() { + let mut creature = Creature::new(false); + creature.replace_loot_authority_like_cpp(None, HashMap::new()); + let authority = creature.loot_authority_like_cpp().clone(); + creature.unit_mut().set_death_state(DeathState::Corpse); + assert!(!authority.is_retired_like_cpp()); + + let plan = creature.remove_corpse_runtime(20_000, true, false); + + assert!(plan.contains(CreatureRuntimeAction::RemoveLoot)); + assert!( + authority.is_retired_like_cpp(), + "corpse removal must invalidate claims that outlive the map object" + ); + } + #[test] fn creature_runtime_all_loot_removed_updates_corpse_and_respawn_like_trinity() { let now = 1_000; diff --git a/crates/wow-entities/src/game_object.rs b/crates/wow-entities/src/game_object.rs index fcde3b541..9d099fcfb 100644 --- a/crates/wow-entities/src/game_object.rs +++ b/crates/wow-entities/src/game_object.rs @@ -2,6 +2,10 @@ use std::collections::{HashMap, HashSet}; use wow_constants::{TypeId, TypeMask}; use wow_core::{ObjectGuid, Position}; +use wow_loot::{ + CreatureLoot, LootInstallOutcome, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + OwnedLootAuthorityStamp, OwnedLootSnapshot, +}; use crate::{ CreateObjectFlags, MapBindingError, ObjectChangedFields, ObjectDataUpdate, UpdateMask, @@ -212,6 +216,10 @@ impl GameObjectOwnedLoot { } } +fn game_object_owned_loot_from_snapshot(snapshot: &OwnedLootSnapshot) -> GameObjectOwnedLoot { + GameObjectOwnedLoot::new(snapshot.loot.coins, u32::from(snapshot.loot.unlooted_count)) +} + impl GameObjectLootSource { pub const fn is_empty(&self) -> bool { self.loot_id == 0 && self.personal_loot_id == 0 && self.push_loot_id == 0 @@ -229,8 +237,16 @@ impl GameObjectLootSource { self.open_loot_id_like_cpp() != 0 } + /// C++ stores every `chestPersonalLoot` result in `m_personalLoot` when + /// there is no shared `GetLootId()` result. `DungeonEncounter` only + /// chooses how the personal pools are generated; it does not decide + /// whether the loot is personal (`GameObject.cpp:2584-2613`). + pub const fn uses_personal_loot_like_cpp(&self) -> bool { + self.loot_id == 0 && self.personal_loot_id != 0 + } + pub const fn is_personal_encounter_loot_like_cpp(&self) -> bool { - self.loot_id == 0 && self.personal_loot_id != 0 && self.dungeon_encounter_id != 0 + self.uses_personal_loot_like_cpp() && self.dungeon_encounter_id != 0 } pub const fn should_autostore_push_loot_like_cpp(&self) -> bool { @@ -1005,6 +1021,12 @@ pub struct GameObject { restock_time: i64, loot_state: LootState, loot_state_unit_guid: ObjectGuid, + /// Monotonic identity for one C++ `GameObject::loot` lifetime. `ClearLoot` + /// advances it before a restock can generate another pool, so async work + /// captured for the previous use cannot install into the next use of the + /// same spawn GUID. + loot_lifecycle_revision: u64, + loot_authority: OwnedLootAuthority, shared_loot: Option, personal_loot: HashMap, unique_users: HashSet, @@ -1098,6 +1120,8 @@ impl GameObject { restock_time: 0, loot_state: LootState::NotReady, loot_state_unit_guid: ObjectGuid::EMPTY, + loot_lifecycle_revision: 0, + loot_authority: OwnedLootAuthority::new(), shared_loot: None, personal_loot: HashMap::new(), unique_users: HashSet::new(), @@ -1493,6 +1517,17 @@ impl GameObject { self.loot_state_unit_guid } + pub const fn loot_lifecycle_revision_like_cpp(&self) -> u64 { + self.loot_lifecycle_revision + } + + fn advance_loot_lifecycle_revision_like_cpp(&mut self) -> u64 { + // A restock identity may stop advancing only at exhaustion; it must + // never wrap and become equal to a stale async observation. + self.loot_lifecycle_revision = self.loot_lifecycle_revision.saturating_add(1).max(1); + self.loot_lifecycle_revision + } + pub fn set_loot_state(&mut self, state: LootState, unit: Option) { self.loot_state = state; self.loot_state_unit_guid = unit.unwrap_or(ObjectGuid::EMPTY); @@ -1612,7 +1647,247 @@ impl GameObject { self.use_times = 0; } + pub const fn loot_authority_like_cpp(&self) -> &OwnedLootAuthority { + &self.loot_authority + } + + pub fn rebind_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) -> bool { + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return false; + } + + self.loot_authority.detach_like_cpp(); + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + true + } + + /// Compare/exchange variant for callers that cannot keep the canonical + /// map lock between observing and rebinding this object. + pub fn rebind_loot_authority_if_current_like_cpp( + &mut self, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + if !self.loot_authority.shares_storage_like_cpp(expected) { + return None; + } + + if self.loot_authority.stamp_like_cpp() != expected_stamp { + return None; + } + + if self.loot_authority.shares_storage_like_cpp(&authority) { + self.sync_loot_summaries_from_authority_like_cpp(); + return Some(false); + } + + if !self.loot_authority.detach_if_stamp_like_cpp(expected_stamp) { + return None; + } + + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + Some(true) + } + + /// Non-owning snapshot adoption counterpart to the entity-local CAS. + pub fn adopt_loot_authority_for_snapshot_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.loot_authority = authority; + self.sync_loot_summaries_from_authority_like_cpp(); + } + + pub fn share_loot_authority_like_cpp(&mut self, authority: OwnedLootAuthority) { + self.rebind_loot_authority_like_cpp(authority); + } + + pub fn initialize_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn initialize_shared_loot_authority_like_cpp( + &mut self, + loot: CreatureLoot, + ) -> LootInstallOutcome { + let outcome = self.loot_authority.initialize_shared_like_cpp(loot); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + pub fn upsert_personal_loot_authority_like_cpp( + &mut self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + self.sync_loot_summaries_from_authority_like_cpp(); + outcome + } + + /// Installs the complete shared/personal pool topology only while this is + /// still the exact `ClearLoot`/restock lifetime observed before async + /// template generation. + /// + /// C++ creates chest loot synchronously from `GameObject::Use`, on the map + /// thread (`GameObject.cpp:2559-2575`). Rust releases the map lock for DB + /// work, so identity, object generation, and the entity-local lifecycle + /// revision form the equivalent compare/exchange boundary. If another + /// opener already installed this same lifecycle, its first-writer result is + /// accepted without replacing it. + pub fn install_loot_authority_if_lifecycle_like_cpp( + &mut self, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + shared: Option, + personal: HashMap, + ) -> bool { + if self.loot_lifecycle_revision != expected_lifecycle_revision + || self.loot_state == LootState::JustDeactivated + || !self + .loot_authority + .shares_storage_like_cpp(expected_authority) + { + return false; + } + + let stamp = self.loot_authority.stamp_like_cpp(); + let installed = match stamp.lifecycle { + OwnedLootAuthorityLifecycle::Pristine + if expected_object_generation == 0 && stamp.object_generation == 0 => + { + self.loot_authority + .initialize_pristine_like_cpp(shared, personal) + .installed() + } + OwnedLootAuthorityLifecycle::Retired + if stamp.object_generation == expected_object_generation => + { + self.loot_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + shared, + personal, + ) + .is_some() + } + OwnedLootAuthorityLifecycle::Active + if stamp.object_generation == expected_object_generation.wrapping_add(1).max(1) => + { + // A concurrent opener won the same map-object lifecycle. C++ + // keeps the first `m_loot`; do not replace its generated pools. + true + } + _ => false, + }; + + if installed { + self.sync_loot_summaries_from_authority_like_cpp(); + } + installed + } + + /// Installs one personal pool only while the object is still in the exact + /// `ClearLoot`/restock lifetime observed before async template generation. + /// + /// A retired non-pristine authority is a valid new generation only when + /// its object generation still matches the tombstone captured by the + /// caller. Once another player has started that same lifecycle, additional + /// personal pools may join the active authority without replacing it. + pub fn install_personal_loot_if_lifecycle_like_cpp( + &mut self, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> bool { + if self.loot_lifecycle_revision != expected_lifecycle_revision + || self.loot_state == LootState::JustDeactivated + || !self + .loot_authority + .shares_storage_like_cpp(expected_authority) + { + return false; + } + + let current_generation = self.loot_authority.generation_like_cpp(); + let installed = if self.loot_authority.is_retired_like_cpp() && current_generation != 0 { + if current_generation != expected_object_generation { + return false; + } + self.loot_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + None, + HashMap::from([(player, loot)]), + ) + .is_some() + } else { + if current_generation < expected_object_generation { + return false; + } + let outcome = self + .loot_authority + .upsert_personal_like_cpp(player, loot, replace); + outcome.installed() + || self + .loot_authority + .snapshot_for_player_like_cpp(player) + .is_some() + }; + + self.sync_loot_summaries_from_authority_like_cpp(); + installed + } + + pub fn replace_loot_authority_like_cpp( + &mut self, + shared: Option, + personal: HashMap, + ) -> u64 { + let generation = self.loot_authority.replace_like_cpp(shared, personal); + self.sync_loot_summaries_from_authority_like_cpp(); + generation + } + + pub fn sync_loot_summaries_from_authority_like_cpp(&mut self) { + self.shared_loot = self + .loot_authority + .shared_snapshot_like_cpp() + .map(|snapshot| game_object_owned_loot_from_snapshot(&snapshot)); + self.personal_loot = self + .loot_authority + .personal_snapshots_like_cpp() + .into_iter() + .map(|(player, snapshot)| (player, game_object_owned_loot_from_snapshot(&snapshot))) + .collect(); + } + + /// Invalidates every outstanding async claim without eagerly applying + /// `ClearLoot` side effects to the still map-resident object. C++ + /// `GameObject::Delete` queues physical removal after setting + /// `GO_NOT_READY`; its loot members remain observable until destruction. + pub fn retire_loot_authority_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); + } + pub fn clear_loot_like_cpp(&mut self) { + self.advance_loot_lifecycle_revision_like_cpp(); + self.loot_authority.retire_like_cpp(); self.shared_loot = None; self.personal_loot.clear(); self.unique_users.clear(); @@ -1620,6 +1895,10 @@ impl GameObject { } pub fn is_fully_looted_like_cpp(&self) -> bool { + if !self.loot_authority.is_pristine_like_cpp() { + return self.loot_authority.is_fully_looted_like_cpp(); + } + if self .shared_loot .as_ref() @@ -2009,6 +2288,24 @@ mod tests { use super::*; use wow_core::guid::HighGuid; + fn owned_loot_fixture_like_cpp(coins: u32, unlooted_count: u8) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: Vec::new(), + looted_by_player: false, + } + } + #[test] fn gameobject_constructor_matches_cpp_base_state() { let go = GameObject::new(); @@ -3312,6 +3609,7 @@ mod tests { assert!(!source.is_empty()); assert_eq!(source.open_loot_id_like_cpp(), 10); assert!(source.has_open_loot_like_cpp()); + assert!(!source.uses_personal_loot_like_cpp()); assert!(!source.is_personal_encounter_loot_like_cpp()); assert!(!source.should_autostore_push_loot_like_cpp()); @@ -3329,6 +3627,7 @@ mod tests { .expect("chest templates expose a chest loot source"); assert!(!push_source.is_empty()); assert!(!push_source.has_open_loot_like_cpp()); + assert!(!push_source.uses_personal_loot_like_cpp()); assert!(!push_source.is_personal_encounter_loot_like_cpp()); assert!(push_source.should_autostore_push_loot_like_cpp()); @@ -3338,7 +3637,16 @@ mod tests { .expect("chest templates expose a chest loot source"); assert_eq!(personal_encounter_source.open_loot_id_like_cpp(), 25); assert!(personal_encounter_source.has_open_loot_like_cpp()); + assert!(personal_encounter_source.uses_personal_loot_like_cpp()); assert!(personal_encounter_source.is_personal_encounter_loot_like_cpp()); + + data[GAMEOBJECT_DATA_CHEST_DUNGEON_ENCOUNTER] = 0; + let personal_non_encounter_source = + GameObjectTemplateData::new(GAMEOBJECT_TYPE_CHEST, data) + .chest_loot_source_like_cpp() + .expect("chest templates expose a chest loot source"); + assert!(personal_non_encounter_source.uses_personal_loot_like_cpp()); + assert!(!personal_non_encounter_source.is_personal_encounter_loot_like_cpp()); assert_eq!( GameObjectTemplateData::new(GAMEOBJECT_TYPE_FISHING_HOLE, data) .chest_loot_source_like_cpp(), @@ -3478,6 +3786,252 @@ mod tests { assert!(!GameObjectOwnedLoot::new(1, 1).is_looted_like_cpp()); } + #[test] + fn gameobject_personal_authority_updates_summary_and_clear_retires_it() { + let player = ObjectGuid::create_player(1, 77); + let mut gameobject = GameObject::new(); + let full_loot = CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins: 23, + unlooted_count: 1, + loot_type: 9, + dungeon_encounter_id: 0, + loot_method: 5, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player], + items: Vec::new(), + looted_by_player: false, + }; + gameobject.upsert_personal_loot_authority_like_cpp(player, full_loot, false); + + assert_eq!( + gameobject.personal_loot_like_cpp(player), + Some(&GameObjectOwnedLoot::new(23, 1)) + ); + assert!( + gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .is_some() + ); + gameobject.clear_loot_like_cpp(); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert_eq!(gameobject.personal_loot_count_like_cpp(), 0); + } + + #[test] + fn clear_loot_restock_starts_second_personal_generation_for_same_gameobject() { + let player = ObjectGuid::create_player(1, 78); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.upsert_personal_loot_authority_like_cpp( + player, + owned_loot_fixture_like_cpp(7, 0), + false, + ); + let first_generation = gameobject.loot_authority_like_cpp().generation_like_cpp(); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let authority = gameobject.loot_authority_like_cpp().clone(); + let tombstone_generation = authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + assert!(gameobject.install_personal_loot_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + player, + owned_loot_fixture_like_cpp(19, 0), + false, + )); + let second = gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .expect("restocked personal pool"); + assert_eq!(second.loot.coins, 19); + assert!(gameobject.loot_authority_like_cpp().generation_like_cpp() > first_generation); + assert!(!gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + } + + #[test] + fn stale_personal_generator_cannot_cross_gameobject_clear_loot_lifecycle() { + let player = ObjectGuid::create_player(1, 79); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.upsert_personal_loot_authority_like_cpp( + player, + owned_loot_fixture_like_cpp(3, 0), + false, + ); + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let stale_lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + + assert!(!gameobject.install_personal_loot_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + stale_lifecycle_revision, + player, + owned_loot_fixture_like_cpp(99, 0), + false, + )); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player) + .is_none() + ); + } + + #[test] + fn clear_loot_restock_installs_shared_generation_once_for_same_lifecycle() { + let player = ObjectGuid::create_player(1, 80); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(7, 0)); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let authority = gameobject.loot_authority_like_cpp().clone(); + let tombstone_generation = authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + assert!(gameobject.install_loot_authority_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(19, 0)), + HashMap::new(), + )); + let installed_generation = authority.generation_like_cpp(); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .expect("restocked shared pool") + .loot + .coins, + 19 + ); + + assert!(gameobject.install_loot_authority_if_lifecycle_like_cpp( + &authority, + tombstone_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(99, 0)), + HashMap::new(), + )); + assert_eq!(authority.generation_like_cpp(), installed_generation); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .expect("the first shared install remains authoritative") + .loot + .coins, + 19, + "a concurrent generator for the same lifecycle must not replace the first pool" + ); + } + + #[test] + fn stale_shared_generator_cannot_cross_second_clear_of_retired_gameobject_lifecycle() { + let player = ObjectGuid::create_player(1, 81); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(3, 0)); + + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let stale_lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + + // `OwnedLootAuthority::retire_like_cpp` is intentionally idempotent for + // an existing tombstone. The GameObject revision must still reject the + // async generator captured before this second C++ `ClearLoot`. + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + assert_eq!(stale_authority.generation_like_cpp(), stale_generation); + assert!( + gameobject.loot_lifecycle_revision_like_cpp() > stale_lifecycle_revision, + "the entity-local lifetime advances even when the authority tombstone does not" + ); + + assert!(!gameobject.install_loot_authority_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + stale_lifecycle_revision, + Some(owned_loot_fixture_like_cpp(99, 0)), + HashMap::new(), + )); + assert!(gameobject.loot_authority_like_cpp().is_retired_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .is_none() + ); + assert_eq!(gameobject.shared_loot_like_cpp(), None); + } + + #[test] + fn shared_generator_cannot_install_after_gameobject_authority_rebind() { + let player = ObjectGuid::create_player(1, 82); + let mut gameobject = GameObject::new(); + gameobject.set_loot_state(LootState::Activated, Some(player)); + gameobject.initialize_shared_loot_authority_like_cpp(owned_loot_fixture_like_cpp(5, 0)); + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + + let stale_authority = gameobject.loot_authority_like_cpp().clone(); + let stale_generation = stale_authority.generation_like_cpp(); + let lifecycle_revision = gameobject.loot_lifecycle_revision_like_cpp(); + let replacement = OwnedLootAuthority::new(); + assert!(gameobject.rebind_loot_authority_like_cpp(replacement.clone())); + + assert!(!gameobject.install_loot_authority_if_lifecycle_like_cpp( + &stale_authority, + stale_generation, + lifecycle_revision, + Some(owned_loot_fixture_like_cpp(77, 0)), + HashMap::new(), + )); + assert!(replacement.is_pristine_like_cpp()); + assert!(replacement.shared_snapshot_like_cpp().is_none()); + } + + #[test] + fn gameobject_fully_looted_reads_active_authority_without_summary_refresh() { + let authority = OwnedLootAuthority::new(); + authority.replace_like_cpp(Some(owned_loot_fixture_like_cpp(31, 0)), HashMap::new()); + let mut gameobject = GameObject::new(); + gameobject.rebind_loot_authority_like_cpp(authority.clone()); + assert_eq!( + gameobject.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(31, 0)) + ); + assert!(!gameobject.is_fully_looted_like_cpp()); + + authority.replace_like_cpp(Some(owned_loot_fixture_like_cpp(0, 0)), HashMap::new()); + + assert_eq!( + gameobject.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(31, 0)), + "the compatibility summary remains deliberately stale" + ); + assert!( + gameobject.is_fully_looted_like_cpp(), + "lifecycle decisions must read the active object-owned authority" + ); + } + #[test] fn gameobject_is_fully_looted_checks_shared_and_personal_loot_like_cpp() { let mut go = GameObject::new(); diff --git a/crates/wow-loot/Cargo.toml b/crates/wow-loot/Cargo.toml index 25257157f..0d798d7c6 100644 --- a/crates/wow-loot/Cargo.toml +++ b/crates/wow-loot/Cargo.toml @@ -9,3 +9,4 @@ license.workspace = true wow-core = { workspace = true } wow-constants = { workspace = true } rand = { workspace = true } +tokio = { workspace = true } diff --git a/crates/wow-loot/src/authority.rs b/crates/wow-loot/src/authority.rs new file mode 100644 index 000000000..e0481bbae --- /dev/null +++ b/crates/wow-loot/src/authority.rs @@ -0,0 +1,3515 @@ +// Copyright (c) 2026 alseif0x +// RustyCore — WoW WotLK 3.4.3 server in Rust +// Based on TrinityCore protocol research (https://github.com/TrinityCore/TrinityCore) +// Licensed under GPL v3 — https://www.gnu.org/licenses/gpl-3.0.html + +//! Map-object-owned runtime loot and cancellation-safe claims. +//! +//! TrinityCore owns one shared `Loot` plus an optional GUID-keyed personal-loot map on +//! `Creature` and `GameObject`. `GetLootForPlayer` uses shared loot only while the personal +//! map is empty (`Creature.cpp:1377-1386`, `GameObject.cpp:3898-3907`). The C++ world-session +//! scheduler serializes loot handlers globally. Rust sessions run concurrently, so this module +//! preserves the same single-owner result with short synchronous critical sections and async +//! waiters. No authority lock is held across an `.await`. + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use tokio::sync::watch; +use wow_core::ObjectGuid; + +use crate::LOOT_SLOT_TYPE_OWNER_LIKE_CPP; + +/// Server-side loot state for one loot object. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatureLoot { + pub loot_guid: ObjectGuid, + pub coins: u32, + pub unlooted_count: u8, + pub loot_type: u8, + pub dungeon_encounter_id: u32, + pub loot_method: u8, + pub loot_master: ObjectGuid, + pub round_robin_player: ObjectGuid, + pub player_ffa_items: Vec<(ObjectGuid, Vec)>, + pub players_looting: Vec, + pub allowed_looters: Vec, + pub items: Vec, + pub looted_by_player: bool, +} + +impl CreatureLoot { + #[must_use] + pub const fn is_looted_like_cpp(&self) -> bool { + self.coins == 0 && self.unlooted_count == 0 + } + + #[must_use] + pub fn item_like_cpp(&self, loot_list_id: u8) -> Option<&LootEntry> { + self.items + .iter() + .find(|entry| entry.loot_list_id == loot_list_id) + } + + #[must_use] + pub fn player_has_unlooted_ffa_item_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> bool { + self.player_ffa_items + .iter() + .find(|(looter, _)| *looter == player) + .is_some_and(|(_, items)| { + items + .iter() + .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) + }) + } + + #[must_use] + pub fn item_is_looted_for_player_like_cpp(&self, loot_list_id: u8, player: ObjectGuid) -> bool { + self.item_like_cpp(loot_list_id) + .is_none_or(|entry| entry.is_looted_for_player_like_cpp(player)) + } + + fn mark_item_looted_for_player_like_cpp( + &mut self, + loot_list_id: u8, + player: ObjectGuid, + ) -> bool { + let Some(index) = self + .items + .iter() + .position(|entry| entry.loot_list_id == loot_list_id) + else { + return false; + }; + + if self.items[index].is_looted_for_player_like_cpp(player) { + return false; + } + + let free_for_all = self.items[index].flags.freeforall; + self.items[index].mark_looted_for_player_like_cpp(player); + if free_for_all + && let Some((_, items)) = self + .player_ffa_items + .iter_mut() + .find(|(looter, _)| *looter == player) + && let Some(item) = items + .iter_mut() + .find(|item| item.loot_list_id == loot_list_id) + { + item.is_looted = true; + } + + self.unlooted_count = self.unlooted_count.saturating_sub(1); + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotNormalLootItem { + pub loot_list_id: u8, + pub is_looted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootEntry { + pub loot_list_id: u8, + pub item_id: u32, + pub quantity: u32, + pub random_properties_id: i32, + pub random_properties_seed: i32, + pub item_context: u8, + pub flags: LootEntryFlags, + pub allowed_looters: Vec, + pub roll_winner: ObjectGuid, + pub ffa_looted_by: Vec, + pub taken: bool, +} + +impl LootEntry { + #[must_use] + pub const fn free_for_all_ui_type_like_cpp(&self) -> u8 { + LOOT_SLOT_TYPE_OWNER_LIKE_CPP + } + + #[must_use] + pub const fn is_over_threshold_like_cpp(&self) -> bool { + !self.flags.under_threshold && !self.flags.freeforall + } + + #[must_use] + pub fn visible_in_represented_free_for_all_view_like_cpp(&self, player: ObjectGuid) -> bool { + !self.is_looted_for_player_like_cpp(player) && self.has_allowed_looter_like_cpp(player) + } + + pub fn add_allowed_looter_like_cpp(&mut self, player: ObjectGuid) { + if !player.is_empty() && !self.allowed_looters.contains(&player) { + self.allowed_looters.push(player); + } + } + + #[must_use] + pub fn has_allowed_looter_like_cpp(&self, player: ObjectGuid) -> bool { + self.allowed_looters.contains(&player) + } + + #[must_use] + pub fn roll_winner_allows_like_cpp(&self, player: ObjectGuid) -> bool { + self.roll_winner.is_empty() || self.roll_winner == player + } + + #[must_use] + pub fn is_looted_for_player_like_cpp(&self, player: ObjectGuid) -> bool { + if self.flags.freeforall { + self.ffa_looted_by.contains(&player) + } else { + self.taken + } + } + + pub fn mark_looted_for_player_like_cpp(&mut self, player: ObjectGuid) { + if self.flags.freeforall { + if !player.is_empty() && !self.ffa_looted_by.contains(&player) { + self.ffa_looted_by.push(player); + } + } else { + self.taken = true; + } + } + + #[must_use] + pub fn fully_looted_like_cpp(&self) -> bool { + if self.flags.freeforall { + !self.allowed_looters.is_empty() + && self + .allowed_looters + .iter() + .all(|player| self.ffa_looted_by.contains(player)) + } else { + self.taken + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LootEntryFlags { + pub follow_loot_rules: bool, + pub freeforall: bool, + pub blocked: bool, + pub counted: bool, + pub under_threshold: bool, + pub needs_quest: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OwnedLootScope { + Shared, + Personal(ObjectGuid), +} + +/// Observable lifetime state of one object-owned loot authority. +/// +/// Keeping this classification behind one mutex acquisition matters during +/// mirror reconciliation: a retired authority from an older object lifetime +/// must not be treated like a newly constructed authority merely because both +/// currently expose no loot pools. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnedLootAuthorityLifecycle { + Pristine, + Active, + Retired, + /// Conflicting live mirrors were observed for one C++ object. This + /// attached tombstone is terminal until the object is destroyed. + Quarantined, + /// This allocation was displaced from its owning entity mirror. It may + /// still be held by an async task, but can never own loot again. + Detached, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnedLootAuthorityStamp { + pub lifecycle: OwnedLootAuthorityLifecycle, + pub object_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedLootSnapshot { + pub generation: u64, + pub scope: OwnedLootScope, + pub loot: CreatureLoot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootInstallOutcome { + Installed { generation: u64 }, + AlreadyInitialized { generation: u64 }, +} + +impl LootInstallOutcome { + #[must_use] + pub const fn generation(self) -> u64 { + match self { + Self::Installed { generation } | Self::AlreadyInitialized { generation } => generation, + } + } + + #[must_use] + pub const fn installed(self) -> bool { + matches!(self, Self::Installed { .. }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootViewerOpenOutcome { + pub generation: u64, + pub scope: OwnedLootScope, + pub inserted: bool, + pub first_viewer: bool, +} + +/// Coherent close-time view of one selected `Loot` and its whole C++ owner. +/// +/// `Loot::isLooted()` controls the releasing player's branch, while +/// `Creature::IsFullyLooted()` / `GameObject::IsFullyLooted()` inspect every +/// shared and personal pool before a global lifecycle transition. Keeping both +/// observations under the authority mutex prevents mixing different pool +/// states when concurrent sessions release personal loot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootViewerCloseOutcome { + pub snapshot: OwnedLootSnapshot, + pub removed: bool, + pub whole_object_fully_looted: bool, + /// Whole-owner generation observed with the selected pool. Lifecycle + /// callers must revalidate this together with `lifecycle_revision` before + /// mutating the map object. + pub object_generation: u64, + /// Advances whenever an install, replacement, or personal-pool upsert can + /// make a previously complete owner incomplete again. + pub lifecycle_revision: u64, + /// C++ `Creature::AllLootRemovedFromCorpse` scans the shared pool and every + /// personal pool rather than inferring skinning from the releasing pool. + pub whole_object_fully_skinned: bool, +} + +/// Whole-owner completion observed without requiring an open client view. +/// Detached durable workers use this after COMMIT when the player already +/// released the window while the claim was still `Persisting`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootFullyLootedLifecycleObservation { + pub object_generation: u64, + pub lifecycle_revision: u64, + pub whole_object_fully_skinned: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LootRoundRobinReleaseOutcome { + pub snapshot: OwnedLootSnapshot, + pub cleared: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LootItemClaimKey { + pub scope: OwnedLootScope, + pub loot_list_id: u8, + /// `Some(player)` for FFA items; `None` for globally unique items. + pub claimant: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LootClaimPayload { + Item(LootEntry), + Money(u32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootClaimError { + Retired, + StaleGeneration, + NoLootForPlayer, + /// The selected session could not enqueue its opening response. Rust + /// must not retain a C++ `PlayersLooting`/`_wasOpened` transition for a + /// window the client never had a chance to observe. + ResponseEnqueueFailed, + ItemNotFound, + ItemAlreadyLooted, + PlayerNotAllowed, + ItemBlocked, + WrongRollWinner, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LootClaimCommitError { + RolledBack, + StaleGeneration, + StateChanged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ReservationKey { + Item(LootItemClaimKey), + Money(OwnedLootScope), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AuthorityState { + /// Whole-object lifecycle counter retained for first-install/retire + /// arbitration. Claims use the selected pool's epoch below. + generation: u64, + retired: bool, + detached: bool, + quarantined: bool, + shared: Option, + personal: HashMap, + /// Unique identity of each concrete C++ `Loot` pool. Replacing one + /// personal pool advances only that scope and leaves peer claims valid. + scope_epochs: HashMap, + next_scope_epoch: u64, + lifecycle_revision: u64, + reservations: HashMap, + /// Claims that crossed the durable-persistence boundary. Lifecycle close + /// may reject every new claim, but must retain these reservations until + /// their SQL result commits or rolls back. + persisting: HashMap, + next_token: u64, +} + +impl Default for AuthorityState { + fn default() -> Self { + Self { + generation: 0, + retired: true, + detached: false, + quarantined: false, + shared: None, + personal: HashMap::new(), + scope_epochs: HashMap::new(), + next_scope_epoch: 1, + lifecycle_revision: 0, + reservations: HashMap::new(), + persisting: HashMap::new(), + next_token: 1, + } + } +} + +fn retain_only_persisting_reservations(state: &mut AuthorityState) { + let persisting = &state.persisting; + state + .reservations + .retain(|key, token| persisting.get(key) == Some(token)); +} + +fn finalize_closed_state_if_drained(state: &mut AuthorityState) { + if !state.retired || !state.persisting.is_empty() { + return; + } + state.shared = None; + state.personal.clear(); + state.scope_epochs.clear(); + state.reservations.clear(); +} + +fn scope_has_persisting_claim(state: &AuthorityState, scope: OwnedLootScope) -> bool { + state + .persisting + .keys() + .any(|key| reservation_scope(*key) == scope) +} + +struct OwnedLootAuthorityInner { + state: Mutex, + changed: watch::Sender, +} + +/// Shared, object-owned authority for one creature or game-object loot lifetime. +#[derive(Clone)] +pub struct OwnedLootAuthority { + inner: Arc, +} + +impl Default for OwnedLootAuthority { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for OwnedLootAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OwnedLootAuthority") + .field("state", &self.state_snapshot()) + .finish() + } +} + +impl PartialEq for OwnedLootAuthority { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) || self.state_snapshot() == other.state_snapshot() + } +} + +impl Eq for OwnedLootAuthority {} + +impl OwnedLootAuthority { + #[must_use] + pub fn new() -> Self { + let (changed, _) = watch::channel(0); + Self { + inner: Arc::new(OwnedLootAuthorityInner { + state: Mutex::new(AuthorityState::default()), + changed, + }), + } + } + + /// Whether both handles address the same object-owned loot state. + /// + /// State equality is insufficient here: two independently allocated + /// authorities can contain identical loot while still allowing separate + /// claims. Runtime mirror reconciliation must compare the backing `Arc`. + #[must_use] + pub fn shares_storage_like_cpp(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } + + /// A newly constructed authority that has never owned a loot lifetime. + #[must_use] + pub fn is_pristine_like_cpp(&self) -> bool { + self.lifecycle_like_cpp() == OwnedLootAuthorityLifecycle::Pristine + } + + /// Classifies this backing allocation without racing two separate state + /// reads. `Pristine` means it has never owned a C++ `Loot` lifetime; + /// `Retired` means an earlier lifetime was explicitly invalidated. + #[must_use] + pub fn lifecycle_like_cpp(&self) -> OwnedLootAuthorityLifecycle { + let state = self.lock_state(); + if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + } + } + + #[must_use] + pub fn stamp_like_cpp(&self) -> OwnedLootAuthorityStamp { + let state = self.lock_state(); + let lifecycle = if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + }; + OwnedLootAuthorityStamp { + lifecycle, + object_generation: state.generation, + } + } + + /// Creates an attached fail-closed tombstone for mirror conflicts. Unlike + /// a pristine authority, this cannot be initialized through the legacy + /// first-generation bridge. + #[must_use] + pub fn new_retired_tombstone_like_cpp() -> Self { + let authority = Self::new(); + { + let mut state = authority.lock_state(); + state.generation = 1; + state.quarantined = true; + } + authority + } + + /// Permanently invalidates a displaced backing allocation. Retiring alone + /// is intentionally reversible for respawn/restock; detaching is not. + pub fn detach_like_cpp(&self) -> u64 { + let generation = { + let mut state = self.lock_state(); + if state.detached { + return state.generation; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + state.detached = true; + state.quarantined = false; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + state.generation + }; + self.notify_changed(); + generation + } + + /// Conditional detach used by entity-local compare/exchange. A stale + /// observer cannot detach a replacement generation on the same `Arc`. + pub fn detach_if_stamp_like_cpp(&self, expected: OwnedLootAuthorityStamp) -> bool { + let detached = { + let mut state = self.lock_state(); + let current_lifecycle = if state.detached { + OwnedLootAuthorityLifecycle::Detached + } else if state.quarantined { + OwnedLootAuthorityLifecycle::Quarantined + } else if !state.retired { + OwnedLootAuthorityLifecycle::Active + } else if state.generation == 0 { + OwnedLootAuthorityLifecycle::Pristine + } else { + OwnedLootAuthorityLifecycle::Retired + }; + if current_lifecycle != expected.lifecycle + || state.generation != expected.object_generation + { + return false; + } + if state.detached { + return true; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + state.detached = true; + state.quarantined = false; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + true + }; + if detached { + self.notify_changed(); + } + detached + } + + /// Reads lifecycle decisions directly from the authoritative pools. + #[must_use] + pub fn is_fully_looted_like_cpp(&self) -> bool { + let state = self.lock_state(); + state.retired || active_loot_pools_fully_looted_like_cpp(&state) + } + + /// Waits until every claim that crossed its durable boundary has resolved. + /// The authority mutex is never held across the await. Disconnect/logout + /// uses this before C++ `DoLootReleaseAll`, so a cancelled packet waiter + /// cannot leave a post-commit owner without its release lifecycle. + pub async fn wait_for_persisting_claims_like_cpp(&self) { + let mut changed = self.inner.changed.subscribe(); + loop { + if self.lock_state().persisting.is_empty() { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + /// Runs one map-object lifecycle mutation only while the exact whole-owner + /// generation and pool topology observed by `close_viewer...` are still + /// current and fully looted. Callers must acquire the owning map lock first; + /// the callback must not re-enter this authority. + pub fn with_fully_looted_lifecycle_observation_like_cpp( + &self, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + apply_before_unlock: impl FnOnce() -> R, + ) -> Option { + let state = self.lock_state(); + if state.retired + || state.generation != expected_object_generation + || state.lifecycle_revision != expected_lifecycle_revision + || !active_loot_pools_fully_looted_like_cpp(&state) + { + return None; + } + Some(apply_before_unlock()) + } + + /// Runs lifecycle work for a detached durable claim only while every + /// authoritative `Loot::PlayersLooting` set is empty. Unlike the normal + /// `DoLootRelease` path, the worker is not itself closing a client view; + /// it must therefore leave the object active for any other open viewer. + /// The no-viewer check belongs in the same critical section as the map + /// mutation so a concurrent open cannot slip between observation and use. + pub fn with_unviewed_fully_looted_lifecycle_observation_like_cpp( + &self, + expected_object_generation: u64, + expected_lifecycle_revision: u64, + apply_before_unlock: impl FnOnce() -> R, + ) -> Option { + let state = self.lock_state(); + if state.retired + || state.generation != expected_object_generation + || state.lifecycle_revision != expected_lifecycle_revision + || !active_loot_pools_fully_looted_like_cpp(&state) + || !active_loot_pools_have_no_viewers_like_cpp(&state) + { + return None; + } + Some(apply_before_unlock()) + } + + /// Captures the exact fully-looted, globally unviewed generation for + /// lifecycle work triggered after a detached durable claim commits. + /// Unlike `close_viewer_if_generation_like_cpp`, this does not mutate + /// `players_looting`; any open shared or personal view rejects the work. + #[must_use] + pub fn fully_looted_unviewed_lifecycle_observation_like_cpp( + &self, + ) -> Option { + let state = self.lock_state(); + if state.retired + || !active_loot_pools_fully_looted_like_cpp(&state) + || !active_loot_pools_have_no_viewers_like_cpp(&state) + { + return None; + } + Some(LootFullyLootedLifecycleObservation { + object_generation: state.generation, + lifecycle_revision: state.lifecycle_revision, + whole_object_fully_skinned: active_loot_pools_fully_skinned_like_cpp(&state), + }) + } + + /// Installs the first loot generation, or returns the active generation unchanged. + pub fn initialize_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + self.initialize_pristine_like_cpp(shared, personal) + } + + /// Bridges only a newly constructed object whose authority has never + /// owned loot. The pristine check and installation share one lock, so a + /// concurrent retirement cannot be followed by resurrection of a stale + /// session cache. + pub fn initialize_pristine_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> LootInstallOutcome { + let outcome = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || !state.retired + || state.generation != 0 + { + LootInstallOutcome::AlreadyInitialized { + generation: any_scope_epoch(&state).unwrap_or(0), + } + } else { + state.generation = 1; + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + LootInstallOutcome::Installed { generation: epoch } + } + }; + if outcome.installed() { + self.notify_changed(); + } + outcome + } + + /// Starts an explicitly generated new object lifetime only if the caller + /// still observes the same retired lifetime it inspected before any async + /// loot-template/database work. + pub fn replace_retired_generation_like_cpp( + &self, + expected_object_generation: u64, + shared: Option, + personal: HashMap, + ) -> Option { + let epoch = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || !state.retired + || state.generation != expected_object_generation + { + return None; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + epoch + }; + self.notify_changed(); + Some(epoch) + } + + /// Replaces all pools and starts a new generation. + pub fn replace_like_cpp( + &self, + shared: Option, + personal: HashMap, + ) -> u64 { + let epoch = { + let mut state = self.lock_state(); + if state.detached || state.quarantined || !state.persisting.is_empty() { + return 0; + } + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = shared; + state.personal = personal; + state.reservations.clear(); + state.persisting.clear(); + let epoch = next_scope_epoch(&mut state); + install_all_scope_epochs(&mut state, epoch); + bump_lifecycle_revision(&mut state); + epoch + }; + self.notify_changed(); + epoch + } + + /// Installs shared loot without replacing already-installed personal pools. + pub fn initialize_shared_like_cpp(&self, loot: CreatureLoot) -> LootInstallOutcome { + let (outcome, changed) = { + let mut state = self.lock_state(); + if state.detached + || state.quarantined + || !state.persisting.is_empty() + || (state.retired && state.generation != 0) + { + ( + LootInstallOutcome::AlreadyInitialized { generation: 0 }, + false, + ) + } else if !state.retired && state.shared.is_some() { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Shared).unwrap_or(0), + }, + false, + ) + } else { + if state.retired { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.personal.clear(); + state.scope_epochs.clear(); + state.reservations.clear(); + state.persisting.clear(); + } + let epoch = next_scope_epoch(&mut state); + state.shared = Some(loot); + state.scope_epochs.insert(OwnedLootScope::Shared, epoch); + bump_lifecycle_revision(&mut state); + (LootInstallOutcome::Installed { generation: epoch }, true) + } + }; + if changed { + self.notify_changed(); + } + outcome + } + + /// Adds a personal pool to the active generation. + /// + /// With `replace == false`, an existing pool is first-writer-wins. Replacing an existing + /// pool invalidates only that player's leases; independent personal pools keep their claims. + pub fn upsert_personal_like_cpp( + &self, + player: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> LootInstallOutcome { + let (outcome, changed) = { + let mut state = self.lock_state(); + if state.detached || state.quarantined || (state.retired && state.generation != 0) { + ( + LootInstallOutcome::AlreadyInitialized { generation: 0 }, + false, + ) + } else if scope_has_persisting_claim(&state, OwnedLootScope::Personal(player)) + || (state.personal.is_empty() + && state.shared.is_some() + && !state.persisting.is_empty()) + { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Personal(player)) + .or_else(|| scope_epoch(&state, OwnedLootScope::Shared)) + .unwrap_or(0), + }, + false, + ) + } else if !state.retired && state.personal.contains_key(&player) && !replace { + ( + LootInstallOutcome::AlreadyInitialized { + generation: scope_epoch(&state, OwnedLootScope::Personal(player)) + .unwrap_or(0), + }, + false, + ) + } else { + if state.retired { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = false; + state.shared = None; + state.scope_epochs.clear(); + state.reservations.clear(); + state.persisting.clear(); + } else if state.personal.is_empty() && state.shared.is_some() { + // Switching the C++ owner from shared selection to a + // personal-loot map changes the scope for every player. + state.generation = state.generation.wrapping_add(1).max(1); + state.scope_epochs.remove(&OwnedLootScope::Shared); + state.reservations.clear(); + } else if state.personal.contains_key(&player) { + // Replacing P1's independent Loot must not invalidate an + // in-flight P2 claim. Token removal makes only P1's old + // leases stale without advancing the object generation. + state.reservations.retain(|key, _| match key { + ReservationKey::Item(item) => { + item.scope != OwnedLootScope::Personal(player) + } + ReservationKey::Money(scope) => *scope != OwnedLootScope::Personal(player), + }); + } + let epoch = next_scope_epoch(&mut state); + state.personal.insert(player, loot); + state + .scope_epochs + .insert(OwnedLootScope::Personal(player), epoch); + bump_lifecycle_revision(&mut state); + (LootInstallOutcome::Installed { generation: epoch }, true) + } + }; + if changed { + self.notify_changed(); + } + outcome + } + + /// Invalidates all leases and removes every pool. Retiring a pristine + /// allocation advances it to a real tombstone so an async first-generation + /// installer captured before the clear cannot install afterwards. + pub fn retire_like_cpp(&self) -> u64 { + let (generation, changed) = { + let mut state = self.lock_state(); + if state.retired && state.generation != 0 { + (state.generation, false) + } else { + state.generation = state.generation.wrapping_add(1).max(1); + state.retired = true; + retain_only_persisting_reservations(&mut state); + finalize_closed_state_if_drained(&mut state); + (state.generation, true) + } + }; + if changed { + self.notify_changed(); + } + generation + } + + #[must_use] + pub fn generation_like_cpp(&self) -> u64 { + self.lock_state().generation + } + + #[must_use] + pub fn scope_generation_like_cpp(&self, scope: OwnedLootScope) -> Option { + let state = self.lock_state(); + (!state.retired) + .then(|| scope_epoch(&state, scope)) + .flatten() + } + + #[must_use] + pub fn is_retired_like_cpp(&self) -> bool { + self.lock_state().retired + } + + #[must_use] + pub fn snapshot_for_player_like_cpp(&self, player: ObjectGuid) -> Option { + let state = self.lock_state(); + let scope = selected_scope_like_cpp(&state, player)?; + snapshot_for_scope(&state, scope) + } + + #[must_use] + pub fn shared_snapshot_like_cpp(&self) -> Option { + let state = self.lock_state(); + snapshot_for_scope(&state, OwnedLootScope::Shared) + } + + #[must_use] + pub fn personal_snapshot_like_cpp(&self, player: ObjectGuid) -> Option { + let state = self.lock_state(); + snapshot_for_scope(&state, OwnedLootScope::Personal(player)) + } + + #[must_use] + pub fn personal_snapshots_like_cpp(&self) -> HashMap { + let state = self.lock_state(); + if state.retired { + return HashMap::new(); + } + state + .personal + .iter() + .filter_map(|(player, loot)| { + let scope = OwnedLootScope::Personal(*player); + Some(( + *player, + OwnedLootSnapshot { + generation: scope_epoch(&state, scope)?, + scope, + loot: loot.clone(), + }, + )) + }) + .collect() + } + + pub fn add_viewer_like_cpp( + &self, + player: ObjectGuid, + ) -> Result { + self.open_view_with_snapshot_like_cpp(player, |_, _| ()) + .map(|(outcome, ())| outcome) + } + + /// Registers a viewer, snapshots the exact pool, and executes one + /// synchronous response-enqueue callback before claims may commit again. + /// + /// The callback must not re-enter this authority. This explicit critical + /// section replaces C++'s globally serialized world-session scheduler: a + /// client cannot receive a stale `LootResponse` while being omitted from + /// the corresponding removal fanout. + pub fn open_view_with_snapshot_like_cpp( + &self, + player: ObjectGuid, + observe_before_unlock: impl FnOnce(&OwnedLootSnapshot, &LootViewerOpenOutcome) -> R, + ) -> Result<(LootViewerOpenOutcome, R), LootClaimError> { + self.try_open_view_with_snapshot_like_cpp(player, |snapshot, outcome| { + Some(observe_before_unlock(snapshot, outcome)) + }) + } + + /// Transactionally opens one client view and attempts its response + /// publication while the authority remains locked. + /// + /// C++ writes `LootResponse` before `Loot::OnLootOpened` and processes the + /// thread-unsafe loot handlers serially (`Player.cpp:8747-8773`, + /// `Opcodes.cpp:587-590`). Rust sessions are concurrent, so the enqueue + /// must share this critical section with `players_looting`: a successful + /// enqueue is ordered before any claim-removal fanout. If the nonblocking + /// callback rejects publication (for example, a full/disconnected socket + /// queue), both tentative open mutations are rolled back before unlock. + /// + /// The callback must not re-enter this authority or perform blocking work. + pub fn try_open_view_with_snapshot_like_cpp( + &self, + player: ObjectGuid, + try_observe_before_unlock: impl FnOnce(&OwnedLootSnapshot, &LootViewerOpenOutcome) -> Option, + ) -> Result<(LootViewerOpenOutcome, R), LootClaimError> { + let (outcome, observed) = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = + selected_scope_like_cpp(&state, player).ok_or(LootClaimError::NoLootForPlayer)?; + let generation = scope_epoch(&state, scope).ok_or(LootClaimError::StaleGeneration)?; + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let first_viewer = !loot.looted_by_player; + if first_viewer { + loot.looted_by_player = true; + } + let inserted = if loot.players_looting.contains(&player) { + false + } else { + loot.players_looting.push(player); + true + }; + let outcome = LootViewerOpenOutcome { + generation, + scope, + inserted, + first_viewer, + }; + let snapshot = OwnedLootSnapshot { + generation, + scope, + loot: loot.clone(), + }; + let Some(observed) = try_observe_before_unlock(&snapshot, &outcome) else { + // No other authority mutation can interleave while the + // callback runs. Restore exactly the fields tentatively + // changed by this open and fail closed. + let loot = loot_for_scope_mut(&mut state, scope) + .expect("selected loot scope remains present under its authority lock"); + if inserted { + loot.players_looting.retain(|viewer| *viewer != player); + } + if first_viewer { + loot.looted_by_player = false; + } + return Err(LootClaimError::ResponseEnqueueFailed); + }; + (outcome, observed) + }; + if outcome.inserted || outcome.first_viewer { + self.notify_changed(); + } + Ok((outcome, observed)) + } + + pub fn remove_viewer_like_cpp(&self, player: ObjectGuid) -> bool { + let removed = { + let mut state = self.lock_state(); + let Some(scope) = selected_scope_like_cpp(&state, player) else { + return false; + }; + let Some(loot) = loot_for_scope_mut(&mut state, scope) else { + return false; + }; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + old_len != loot.players_looting.len() + }; + if removed { + self.notify_changed(); + } + removed + } + + /// Removes a viewer only from the exact object lifetime that the session + /// opened. `None` means the authority was retired or replaced before the + /// release reached it; callers must then avoid touching replacement + /// lifecycle state. + pub fn remove_viewer_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let removed = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let loot = loot_for_scope_mut(&mut state, scope)?; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + old_len != loot.players_looting.len() + }; + if removed { + self.notify_changed(); + } + Some(removed) + } + + /// Closes one exact viewer generation and observes both the selected pool + /// and whole-object completion in the same critical section. + /// + /// C++ `WorldSession::DoLootRelease` first tests the selected + /// `Loot::isLooted()`, then separately gates global creature/gameobject + /// lifecycle changes on `IsFullyLooted()`, which walks every personal pool. + pub fn close_viewer_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let outcome = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let (removed, snapshot) = { + let loot = loot_for_scope_mut(&mut state, scope)?; + let old_len = loot.players_looting.len(); + loot.players_looting.retain(|viewer| *viewer != player); + ( + old_len != loot.players_looting.len(), + OwnedLootSnapshot { + generation: expected_generation, + scope, + loot: loot.clone(), + }, + ) + }; + LootViewerCloseOutcome { + snapshot, + removed, + whole_object_fully_looted: active_loot_pools_fully_looted_like_cpp(&state), + object_generation: state.generation, + lifecycle_revision: state.lifecycle_revision, + whole_object_fully_skinned: active_loot_pools_fully_skinned_like_cpp(&state), + } + }; + if outcome.removed { + self.notify_changed(); + } + Some(outcome) + } + + /// Clears C++ `Loot::roundRobinPlayer` on the exact selected pool that the + /// session opened. A stale release cannot mutate a replacement pool. + pub fn clear_round_robin_if_generation_like_cpp( + &self, + expected_generation: u64, + player: ObjectGuid, + ) -> Option { + let outcome = { + let mut state = self.lock_state(); + if state.retired { + return None; + } + let scope = selected_scope_like_cpp(&state, player)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return None; + } + let loot = loot_for_scope_mut(&mut state, scope)?; + let cleared = loot.round_robin_player == player; + if cleared { + loot.round_robin_player = ObjectGuid::EMPTY; + } + LootRoundRobinReleaseOutcome { + snapshot: OwnedLootSnapshot { + generation: expected_generation, + scope, + loot: loot.clone(), + }, + cleared, + } + }; + if outcome.cleared { + self.notify_changed(); + } + Some(outcome) + } + + #[must_use] + pub fn viewers_for_player_like_cpp(&self, player: ObjectGuid) -> Vec { + self.snapshot_for_player_like_cpp(player) + .map_or_else(Vec::new, |snapshot| snapshot.loot.players_looting) + } + + /// Publishes the final group-roll state on the object-owned item. + /// + /// C++ mutates the same `LootItem` that later reaches `StoreLootItem`: a + /// one-candidate roll becomes under-threshold/unblocked, while a completed + /// roll becomes unblocked and records its winner. Session-local packet + /// views must therefore not be the owner of these fields. + pub fn finish_item_roll_like_cpp( + &self, + player: ObjectGuid, + expected_generation: u64, + loot_list_id: u8, + under_threshold: bool, + winner: Option, + ) -> Result { + let changed = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = + selected_scope_like_cpp(&state, player).ok_or(LootClaimError::NoLootForPlayer)?; + if scope_epoch(&state, scope) != Some(expected_generation) { + return Err(LootClaimError::StaleGeneration); + } + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let entry = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + .ok_or(LootClaimError::ItemNotFound)?; + let winner = winner.unwrap_or(ObjectGuid::EMPTY); + let changed = entry.flags.blocked + || entry.flags.under_threshold != under_threshold + || entry.roll_winner != winner; + entry.flags.blocked = false; + entry.flags.under_threshold = under_threshold; + entry.roll_winner = winner; + changed + }; + if changed { + self.notify_changed(); + } + Ok(changed) + } + + /// Publishes a winning roll and acquires its item claim in the same + /// authority critical section. C++ gets this serialization from the world + /// update thread; Rust session tasks need an explicit atomic boundary so a + /// respawn cannot land between `LootRoll::Finish` and winner storage. + pub fn finish_item_roll_and_reserve_award_like_cpp( + &self, + scope_player: ObjectGuid, + expected_generation: u64, + loot_list_id: u8, + winner: ObjectGuid, + ) -> Result { + let acquired = { + let mut state = self.lock_state(); + if state.retired { + return Err(LootClaimError::Retired); + } + let scope = selected_scope_like_cpp(&state, scope_player) + .ok_or(LootClaimError::NoLootForPlayer)?; + if selected_scope_like_cpp(&state, winner) != Some(scope) { + return Err(LootClaimError::NoLootForPlayer); + } + if scope_epoch(&state, scope) != Some(expected_generation) { + return Err(LootClaimError::StaleGeneration); + } + + let previous = { + let loot = + loot_for_scope_mut(&mut state, scope).ok_or(LootClaimError::NoLootForPlayer)?; + let entry = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + .ok_or(LootClaimError::ItemNotFound)?; + let previous = ( + entry.flags.blocked, + entry.flags.under_threshold, + entry.roll_winner, + ); + entry.flags.blocked = false; + entry.flags.under_threshold = false; + entry.roll_winner = winner; + previous + }; + + match reserve_item_once( + &mut state, + winner, + loot_list_id, + LootItemClaimMode::Award, + Some(expected_generation), + ) { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => Ok((generation, token, key, payload)), + ReserveAttempt::Wait | ReserveAttempt::Rejected(_) => { + if let Some(entry) = loot_for_scope_mut(&mut state, scope).and_then(|loot| { + loot.items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + }) { + entry.flags.blocked = previous.0; + entry.flags.under_threshold = previous.1; + entry.roll_winner = previous.2; + } + Err(LootClaimError::ItemAlreadyLooted) + } + } + }?; + + self.notify_changed(); + Ok(LootClaimLease::new( + self.clone(), + acquired.0, + acquired.1, + acquired.2, + winner, + acquired.3, + )) + } + + /// Waits until the selected slot is unreserved, then reserves it atomically. + pub async fn reserve_item_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> Result { + self.reserve_item_with_mode_like_cpp(player, loot_list_id, LootItemClaimMode::Direct, None) + .await + } + + pub async fn reserve_item_for_generation_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + expected_generation: u64, + ) -> Result { + self.reserve_item_with_mode_like_cpp( + player, + loot_list_id, + LootItemClaimMode::Direct, + Some(expected_generation), + ) + .await + } + + /// Uses the same atomic claim boundary for master-loot and completed-roll awards. + /// Award paths may consume a group-roll-blocked slot, but still enforce the selected winner. + pub async fn reserve_item_for_award_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + ) -> Result { + self.reserve_item_with_mode_like_cpp(player, loot_list_id, LootItemClaimMode::Award, None) + .await + } + + pub async fn reserve_item_for_award_generation_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + expected_generation: u64, + ) -> Result { + self.reserve_item_with_mode_like_cpp( + player, + loot_list_id, + LootItemClaimMode::Award, + Some(expected_generation), + ) + .await + } + + async fn reserve_item_with_mode_like_cpp( + &self, + player: ObjectGuid, + loot_list_id: u8, + mode: LootItemClaimMode, + expected_generation: Option, + ) -> Result { + let mut changed = self.inner.changed.subscribe(); + loop { + let attempt = { + let mut state = self.lock_state(); + reserve_item_once(&mut state, player, loot_list_id, mode, expected_generation) + }; + match attempt { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => { + return Ok(LootClaimLease::new( + self.clone(), + generation, + token, + key, + player, + payload, + )); + } + ReserveAttempt::Wait => { + // The sender is owned by the authority, so closure only occurs when the + // authority itself is gone (which this method's `&self` prevents). + let _ = changed.changed().await; + } + ReserveAttempt::Rejected(error) => return Err(error), + } + } + } + + /// Waits until the selected money pool is unreserved, then reserves it atomically. + pub async fn reserve_money_like_cpp( + &self, + player: ObjectGuid, + ) -> Result { + self.reserve_money_for_optional_generation_like_cpp(player, None) + .await + } + + pub async fn reserve_money_for_generation_like_cpp( + &self, + player: ObjectGuid, + expected_generation: u64, + ) -> Result { + self.reserve_money_for_optional_generation_like_cpp(player, Some(expected_generation)) + .await + } + + async fn reserve_money_for_optional_generation_like_cpp( + &self, + player: ObjectGuid, + expected_generation: Option, + ) -> Result { + let mut changed = self.inner.changed.subscribe(); + loop { + let attempt = { + let mut state = self.lock_state(); + reserve_money_once(&mut state, player, expected_generation) + }; + match attempt { + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } => { + return Ok(LootClaimLease::new( + self.clone(), + generation, + token, + key, + player, + payload, + )); + } + ReserveAttempt::Wait => { + let _ = changed.changed().await; + } + ReserveAttempt::Rejected(error) => return Err(error), + } + } + } + + fn lock_state(&self) -> MutexGuard<'_, AuthorityState> { + self.inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn state_snapshot(&self) -> AuthorityState { + self.lock_state().clone() + } + + fn notify_changed(&self) { + self.inner + .changed + .send_modify(|version| *version = version.wrapping_add(1)); + } + + fn begin_claim_persistence( + &self, + lease: &LootClaimLeaseInner, + ) -> Result<(), LootClaimCommitError> { + let mut state = self.lock_state(); + // The status check belongs under the same authority lock as the + // reservation transition. Otherwise a cloned lease can commit after + // an optimistic check and before this lock is acquired, letting a + // stale persistence begin overwrite a terminal status (ABA). + match lease.status.load(Ordering::Acquire) { + LEASE_COMMITTED => return Err(LootClaimCommitError::StateChanged), + LEASE_ROLLED_BACK => return Err(LootClaimCommitError::RolledBack), + LEASE_ACTIVE => {} + _ => return Err(LootClaimCommitError::StateChanged), + } + if state.persisting.get(&lease.key) == Some(&lease.token) { + return Err(LootClaimCommitError::StateChanged); + } + if state.retired + || scope_epoch(&state, reservation_scope(lease.key)) != Some(lease.generation) + { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + return Err(LootClaimCommitError::StaleGeneration); + } + if state.reservations.get(&lease.key) != Some(&lease.token) { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + return Err(LootClaimCommitError::StateChanged); + } + state.persisting.insert(lease.key, lease.token); + Ok(()) + } + + fn abort_claim_persistence(&self, lease: &LootClaimLeaseInner) -> bool { + let aborted = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE + || state.persisting.get(&lease.key) != Some(&lease.token) + { + return false; + } + state.persisting.remove(&lease.key); + state.reservations.remove(&lease.key); + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + true + }; + if aborted { + self.notify_changed(); + } + aborted + } + + fn quarantine_claim_persistence_commit_unknown(&self, lease: &LootClaimLeaseInner) -> bool { + let quarantined = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE + || state.persisting.get(&lease.key) != Some(&lease.token) + { + return false; + } + + // A transport error after COMMIT was submitted cannot prove + // whether SQL applied the durable side effect. Remove the local + // reservation so persistence waiters drain, but never make it + // claimable again: the whole authority becomes an attached, + // retired quarantine until DB reconciliation decides the result. + state.persisting.remove(&lease.key); + state.reservations.remove(&lease.key); + lease.status.store(LEASE_QUARANTINED, Ordering::Release); + state.retired = true; + state.quarantined = true; + finalize_closed_state_if_drained(&mut state); + true + }; + if quarantined { + self.notify_changed(); + } + quarantined + } + + fn commit_claim( + &self, + lease: &LootClaimLeaseInner, + persistence_owner: bool, + ) -> Result { + self.commit_claim_with_snapshot(lease, persistence_owner) + .map(|outcome| outcome.first_commit) + } + + fn commit_claim_with_snapshot( + &self, + lease: &LootClaimLeaseInner, + persistence_owner: bool, + ) -> Result { + if lease.status.load(Ordering::Acquire) == LEASE_COMMITTED { + return Ok(LootClaimCommitOutcome { + first_commit: false, + snapshot: None, + }); + } + if lease.status.load(Ordering::Acquire) == LEASE_QUARANTINED { + return Err(LootClaimCommitError::StateChanged); + } + + let result = { + let mut state = self.lock_state(); + match lease.status.load(Ordering::Acquire) { + LEASE_COMMITTED => { + return Ok(LootClaimCommitOutcome { + first_commit: false, + snapshot: None, + }); + } + LEASE_ROLLED_BACK => return Err(LootClaimCommitError::RolledBack), + LEASE_QUARANTINED => return Err(LootClaimCommitError::StateChanged), + _ => {} + } + + let scope = reservation_scope(lease.key); + let persistence_protected = state.persisting.get(&lease.key) == Some(&lease.token); + if persistence_protected && !persistence_owner { + return Err(LootClaimCommitError::StateChanged); + } + if (state.retired && !persistence_protected) + || scope_epoch(&state, scope) != Some(lease.generation) + { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + if persistence_protected { + state.persisting.remove(&lease.key); + finalize_closed_state_if_drained(&mut state); + } + Err(LootClaimCommitError::StaleGeneration) + } else if state.reservations.get(&lease.key) != Some(&lease.token) { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + if persistence_protected { + state.persisting.remove(&lease.key); + finalize_closed_state_if_drained(&mut state); + } + Err(LootClaimCommitError::StateChanged) + } else { + let committed = match lease.key { + ReservationKey::Item(key) => loot_for_scope_mut(&mut state, key.scope) + .is_some_and(|loot| { + loot.mark_item_looted_for_player_like_cpp( + key.loot_list_id, + lease.player, + ) + }), + ReservationKey::Money(scope) => loot_for_scope_mut(&mut state, scope) + .is_some_and(|loot| { + loot.coins = 0; + true + }), + }; + + // Capture the exact post-mutation pool while still holding + // the authority mutex. This is the C++ serialization point + // used by money fanout: a viewer captured here necessarily + // opened before coins became zero; a later opener observes + // zero directly and must not receive a spurious removal. + let committed_snapshot = committed.then(|| { + scope_epoch(&state, scope).and_then(|generation| { + loot_for_scope(&state, scope) + .cloned() + .map(|loot| OwnedLootSnapshot { + generation, + scope, + loot, + }) + }) + }); + + state.reservations.remove(&lease.key); + state.persisting.remove(&lease.key); + if committed { + lease.status.store(LEASE_COMMITTED, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + Ok(LootClaimCommitOutcome { + first_commit: true, + snapshot: committed_snapshot.flatten(), + }) + } else { + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + Err(LootClaimCommitError::StateChanged) + } + } + }; + self.notify_changed(); + result + } + + fn rollback_claim(&self, lease: &LootClaimLeaseInner) -> bool { + let removed = { + let mut state = self.lock_state(); + if lease.status.load(Ordering::Acquire) != LEASE_ACTIVE { + return false; + } + if state.persisting.get(&lease.key) == Some(&lease.token) { + return false; + } + let removed = scope_epoch(&state, reservation_scope(lease.key)) + == Some(lease.generation) + && state.reservations.get(&lease.key) == Some(&lease.token) + && state.reservations.remove(&lease.key).is_some(); + if state.persisting.get(&lease.key) == Some(&lease.token) { + state.persisting.remove(&lease.key); + } + lease.status.store(LEASE_ROLLED_BACK, Ordering::Release); + finalize_closed_state_if_drained(&mut state); + removed + }; + if removed { + self.notify_changed(); + } + removed + } +} + +enum ReserveAttempt { + Acquired { + generation: u64, + token: u64, + key: ReservationKey, + payload: LootClaimPayload, + }, + Wait, + Rejected(LootClaimError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LootItemClaimMode { + Direct, + Award, +} + +fn reserve_item_once( + state: &mut AuthorityState, + player: ObjectGuid, + loot_list_id: u8, + mode: LootItemClaimMode, + expected_generation: Option, +) -> ReserveAttempt { + if state.retired { + return ReserveAttempt::Rejected(LootClaimError::Retired); + } + let Some(scope) = selected_scope_like_cpp(state, player) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(generation) = scope_epoch(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + }; + if expected_generation.is_some_and(|expected| expected != generation) { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + } + let Some(loot) = loot_for_scope(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(entry) = loot.item_like_cpp(loot_list_id).cloned() else { + return ReserveAttempt::Rejected(LootClaimError::ItemNotFound); + }; + if entry.is_looted_for_player_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::ItemAlreadyLooted); + } + if !entry.has_allowed_looter_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::PlayerNotAllowed); + } + if mode == LootItemClaimMode::Direct && entry.flags.blocked { + return ReserveAttempt::Rejected(LootClaimError::ItemBlocked); + } + if !entry.roll_winner_allows_like_cpp(player) { + return ReserveAttempt::Rejected(LootClaimError::WrongRollWinner); + } + + let key = ReservationKey::Item(LootItemClaimKey { + scope, + loot_list_id, + claimant: entry.flags.freeforall.then_some(player), + }); + if state.reservations.contains_key(&key) { + return ReserveAttempt::Wait; + } + + let token = next_token(state); + state.reservations.insert(key, token); + ReserveAttempt::Acquired { + generation, + token, + key, + payload: LootClaimPayload::Item(entry), + } +} + +fn reserve_money_once( + state: &mut AuthorityState, + player: ObjectGuid, + expected_generation: Option, +) -> ReserveAttempt { + if state.retired { + return ReserveAttempt::Rejected(LootClaimError::Retired); + } + let Some(scope) = selected_scope_like_cpp(state, player) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + let Some(generation) = scope_epoch(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + }; + if expected_generation.is_some_and(|expected| expected != generation) { + return ReserveAttempt::Rejected(LootClaimError::StaleGeneration); + } + let Some(loot) = loot_for_scope(state, scope) else { + return ReserveAttempt::Rejected(LootClaimError::NoLootForPlayer); + }; + if !loot.allowed_looters.contains(&player) { + return ReserveAttempt::Rejected(LootClaimError::PlayerNotAllowed); + } + let key = ReservationKey::Money(scope); + if state.reservations.contains_key(&key) { + return ReserveAttempt::Wait; + } + + let payload = LootClaimPayload::Money(loot.coins); + let token = next_token(state); + state.reservations.insert(key, token); + ReserveAttempt::Acquired { + generation, + token, + key, + payload, + } +} + +fn next_token(state: &mut AuthorityState) -> u64 { + let token = state.next_token; + state.next_token = state.next_token.wrapping_add(1).max(1); + token +} + +fn next_scope_epoch(state: &mut AuthorityState) -> u64 { + let epoch = state.next_scope_epoch; + state.next_scope_epoch = state.next_scope_epoch.wrapping_add(1).max(1); + epoch +} + +fn bump_lifecycle_revision(state: &mut AuthorityState) { + // A retired authority must never wrap back to a lifecycle token retained + // by a detached worker. + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1).max(1); +} + +fn install_all_scope_epochs(state: &mut AuthorityState, epoch: u64) { + state.scope_epochs.clear(); + if state.shared.is_some() { + state.scope_epochs.insert(OwnedLootScope::Shared, epoch); + } + let players = state.personal.keys().copied().collect::>(); + for player in players { + state + .scope_epochs + .insert(OwnedLootScope::Personal(player), epoch); + } +} + +fn any_scope_epoch(state: &AuthorityState) -> Option { + state.scope_epochs.values().copied().min() +} + +fn scope_epoch(state: &AuthorityState, scope: OwnedLootScope) -> Option { + state.scope_epochs.get(&scope).copied() +} + +const fn reservation_scope(key: ReservationKey) -> OwnedLootScope { + match key { + ReservationKey::Item(item) => item.scope, + ReservationKey::Money(scope) => scope, + } +} + +fn selected_scope_like_cpp(state: &AuthorityState, player: ObjectGuid) -> Option { + if state.retired { + None + } else if state.personal.is_empty() { + state.shared.as_ref().map(|_| OwnedLootScope::Shared) + } else { + state + .personal + .contains_key(&player) + .then_some(OwnedLootScope::Personal(player)) + } +} + +fn active_loot_pools_fully_looted_like_cpp(state: &AuthorityState) -> bool { + state + .shared + .as_ref() + .is_none_or(CreatureLoot::is_looted_like_cpp) + && state + .personal + .values() + .all(CreatureLoot::is_looted_like_cpp) +} + +fn active_loot_pools_have_no_viewers_like_cpp(state: &AuthorityState) -> bool { + state + .shared + .as_ref() + .is_none_or(|loot| loot.players_looting.is_empty()) + && state + .personal + .values() + .all(|loot| loot.players_looting.is_empty()) +} + +fn active_loot_pools_fully_skinned_like_cpp(state: &AuthorityState) -> bool { + let skinning = wow_constants::LootType::Skinning as u8; + if state + .shared + .as_ref() + .is_some_and(|loot| loot.loot_type == skinning && loot.is_looted_like_cpp()) + { + return true; + } + + let mut has_personal_skinning_loot = false; + for loot in state.personal.values() { + if loot.loot_type != skinning { + continue; + } + if !loot.is_looted_like_cpp() { + return false; + } + has_personal_skinning_loot = true; + } + has_personal_skinning_loot +} + +fn loot_for_scope(state: &AuthorityState, scope: OwnedLootScope) -> Option<&CreatureLoot> { + match scope { + OwnedLootScope::Shared => state.shared.as_ref(), + OwnedLootScope::Personal(player) => state.personal.get(&player), + } +} + +fn loot_for_scope_mut( + state: &mut AuthorityState, + scope: OwnedLootScope, +) -> Option<&mut CreatureLoot> { + match scope { + OwnedLootScope::Shared => state.shared.as_mut(), + OwnedLootScope::Personal(player) => state.personal.get_mut(&player), + } +} + +fn snapshot_for_scope(state: &AuthorityState, scope: OwnedLootScope) -> Option { + if state.retired { + return None; + } + let generation = scope_epoch(state, scope)?; + loot_for_scope(state, scope) + .cloned() + .map(|loot| OwnedLootSnapshot { + generation, + scope, + loot, + }) +} + +const LEASE_ACTIVE: u8 = 0; +const LEASE_COMMITTED: u8 = 1; +const LEASE_ROLLED_BACK: u8 = 2; +const LEASE_QUARANTINED: u8 = 3; + +struct LootClaimCommitOutcome { + first_commit: bool, + snapshot: Option, +} + +struct LootClaimLeaseInner { + authority: OwnedLootAuthority, + generation: u64, + token: u64, + key: ReservationKey, + player: ObjectGuid, + payload: LootClaimPayload, + status: AtomicU8, +} + +impl Drop for LootClaimLeaseInner { + fn drop(&mut self) { + let authority = self.authority.clone(); + authority.rollback_claim(self); + } +} + +/// A cloneable claim lease. Only dropping the final clone rolls an active claim back. +#[derive(Clone)] +pub struct LootClaimLease { + inner: Arc, +} + +/// RAII owner of the durable phase of one claim. Dropping it before a +/// successful commit is the only operation allowed to reopen a persisting +/// reservation; ordinary clones cannot roll it back concurrently. +pub struct LootClaimPersistenceGuard { + claim: LootClaimLease, + resolved: bool, +} + +impl fmt::Debug for LootClaimPersistenceGuard { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LootClaimPersistenceGuard") + .field("claim", &self.claim) + .field("resolved", &self.resolved) + .finish() + } +} + +impl LootClaimPersistenceGuard { + pub fn commit_like_cpp(&mut self) -> Result { + self.commit_with_snapshot_like_cpp() + .map(|(first_commit, _)| first_commit) + } + + /// Commits and captures the post-mutation pool under the same authority + /// mutex. Consumers use this to distinguish viewers that opened before a + /// money transition from those that opened afterwards and already saw + /// zero in their response. + pub fn commit_with_snapshot_like_cpp( + &mut self, + ) -> Result<(bool, Option), LootClaimCommitError> { + let result = self + .claim + .inner + .authority + .commit_claim_with_snapshot(&self.claim.inner, true); + if result.is_ok() { + self.resolved = true; + } + result.map(|outcome| (outcome.first_commit, outcome.snapshot)) + } + + /// Terminal fail-closed outcome for an indeterminate database COMMIT. + /// The pending claim is removed so shutdown waits can drain, but neither + /// this lease nor its authority can be reserved or reinitialized until an + /// external durable reconciliation resolves the ambiguity. + #[must_use] + pub fn quarantine_commit_unknown_like_cpp(&mut self) -> bool { + let quarantined = self + .claim + .inner + .authority + .quarantine_claim_persistence_commit_unknown(&self.claim.inner); + if quarantined { + self.resolved = true; + } + quarantined + } +} + +impl Drop for LootClaimPersistenceGuard { + fn drop(&mut self) { + if !self.resolved { + self.claim + .inner + .authority + .abort_claim_persistence(&self.claim.inner); + } + } +} + +impl fmt::Debug for LootClaimLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LootClaimLease") + .field("generation", &self.inner.generation) + .field("player", &self.inner.player) + .field("payload", &self.inner.payload) + .field("status", &self.inner.status.load(Ordering::Acquire)) + .finish() + } +} + +impl LootClaimLease { + fn new( + authority: OwnedLootAuthority, + generation: u64, + token: u64, + key: ReservationKey, + player: ObjectGuid, + payload: LootClaimPayload, + ) -> Self { + Self { + inner: Arc::new(LootClaimLeaseInner { + authority, + generation, + token, + key, + player, + payload, + status: AtomicU8::new(LEASE_ACTIVE), + }), + } + } + + #[must_use] + pub fn generation_like_cpp(&self) -> u64 { + self.inner.generation + } + + /// Whether this lease was reserved from the same object-owned authority. + /// Epochs restart in independently allocated authorities, so comparing the + /// numeric generation alone is vulnerable to an ABA across respawns. + #[must_use] + pub fn shares_authority_like_cpp(&self, authority: &OwnedLootAuthority) -> bool { + self.inner.authority.shares_storage_like_cpp(authority) + } + + #[must_use] + pub fn player_like_cpp(&self) -> ObjectGuid { + self.inner.player + } + + #[must_use] + pub fn payload_like_cpp(&self) -> &LootClaimPayload { + &self.inner.payload + } + + /// Protects this reservation across the following durable database + /// operation. Once this succeeds, lifecycle retirement rejects new claims + /// but keeps this exact lease commit-capable until SQL resolves. The raw + /// transition is intentionally not exposed: this RAII guard guarantees + /// cancellation and early-return paths release the reservation. + pub fn begin_persistence_guard_like_cpp( + &self, + ) -> Result { + self.inner.authority.begin_claim_persistence(&self.inner)?; + Ok(LootClaimPersistenceGuard { + claim: self.clone(), + resolved: false, + }) + } + + /// Applies the claim exactly once. Later commits from clones are successful no-ops. + pub fn commit_like_cpp(&self) -> Result { + self.inner.authority.commit_claim(&self.inner, false) + } + + /// Applies the claim and captures the exact post-mutation pool while the + /// authority mutex is still held. Durable fanout must use this cut rather + /// than sampling the authority after commit, when a later viewer may have + /// opened a window that already reflects the consumed item or money. + pub fn commit_with_snapshot_like_cpp( + &self, + ) -> Result<(bool, Option), LootClaimCommitError> { + self.inner + .authority + .commit_claim_with_snapshot(&self.inner, false) + .map(|outcome| (outcome.first_commit, outcome.snapshot)) + } + + /// Releases an active claim. Later rollback calls are no-ops. + pub fn rollback_like_cpp(&self) -> bool { + self.inner.authority.rollback_claim(&self.inner) + } + + #[must_use] + pub fn is_committed_like_cpp(&self) -> bool { + self.inner.status.load(Ordering::Acquire) == LEASE_COMMITTED + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::time::Duration; + + use tokio::sync::Barrier; + use wow_core::ObjectGuid; + + use super::{ + CreatureLoot, LootClaimCommitError, LootClaimError, LootClaimPayload, LootEntry, + LootEntryFlags, LootInstallOutcome, LootItemClaimMode, NotNormalLootItem, + OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootScope, ReserveAttempt, + reserve_item_once, + }; + + fn player(counter: i64) -> ObjectGuid { + ObjectGuid::create_player(1, counter) + } + + fn owner(counter: u32) -> ObjectGuid { + ObjectGuid::create_creature_like_cpp(1, 1, counter, i64::from(counter)) + } + + fn entry(list_id: u8, free_for_all: bool, allowed: Vec) -> LootEntry { + LootEntry { + loot_list_id: list_id, + item_id: 1000 + u32::from(list_id), + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + freeforall: free_for_all, + counted: true, + ..LootEntryFlags::default() + }, + allowed_looters: allowed, + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + } + } + + fn loot(owner_guid: ObjectGuid, coins: u32, items: Vec) -> CreatureLoot { + let unlooted_count = items.len() as u8; + CreatureLoot { + loot_guid: owner_guid, + coins, + unlooted_count, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items, + looted_by_player: false, + } + } + + fn money_loot( + owner_guid: ObjectGuid, + coins: u32, + allowed_looters: Vec, + ) -> CreatureLoot { + let mut loot = loot(owner_guid, coins, Vec::new()); + loot.allowed_looters = allowed_looters; + loot + } + + #[test] + fn equal_authority_state_does_not_imply_shared_storage_like_cpp() { + let first = OwnedLootAuthority::new(); + let second = OwnedLootAuthority::new(); + let shared_loot = loot(owner(1), 17, Vec::new()); + + first.replace_like_cpp(Some(shared_loot.clone()), HashMap::new()); + second.replace_like_cpp(Some(shared_loot), HashMap::new()); + + assert_eq!(first, second, "the independent authority states match"); + assert!( + !first.shares_storage_like_cpp(&second), + "equal state must not hide two independently claimable Arc owners" + ); + assert!(first.shares_storage_like_cpp(&first.clone())); + } + + #[test] + fn personal_map_suppresses_shared_pool_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut personal = HashMap::new(); + personal.insert(first, loot(owner(2), 9, Vec::new())); + authority.replace_like_cpp(Some(loot(owner(1), 5, Vec::new())), personal); + + let first_snapshot = authority + .snapshot_for_player_like_cpp(first) + .expect("personal owner has loot"); + assert_eq!(first_snapshot.scope, OwnedLootScope::Personal(first)); + assert_eq!(first_snapshot.loot.coins, 9); + assert!(authority.snapshot_for_player_like_cpp(second).is_none()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 5); + } + + #[test] + fn initialize_is_first_writer_wins_and_retire_invalidates_generation() { + let authority = OwnedLootAuthority::new(); + let first = + authority.initialize_like_cpp(Some(loot(owner(1), 5, Vec::new())), HashMap::new()); + let second = + authority.initialize_like_cpp(Some(loot(owner(1), 9, Vec::new())), HashMap::new()); + assert!(matches!(first, LootInstallOutcome::Installed { .. })); + assert_eq!(first.generation(), second.generation()); + assert!(!second.installed()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 5); + + let retired_generation = authority.retire_like_cpp(); + assert!(retired_generation > first.generation()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + assert_eq!(authority.retire_like_cpp(), retired_generation); + } + + #[tokio::test] + async fn normal_item_has_one_winner_and_waiter_observes_commit() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(loot( + owner(1), + 0, + vec![entry(1, false, vec![first, second])], + )), + HashMap::new(), + ); + + let first_lease = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_item_like_cpp(second, 1).await }); + tokio::task::yield_now().await; + first_lease.commit_like_cpp().unwrap(); + + assert_eq!( + waiter.await.unwrap().unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + assert!(authority.shared_snapshot_like_cpp().unwrap().loot.items[0].taken); + } + + #[tokio::test] + async fn item_claim_requires_allowed_looter_and_direct_policy_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, Vec::new())])), + HashMap::new(), + ); + assert_eq!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + + let mut blocked = entry(1, false, vec![looter]); + blocked.flags.blocked = true; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![blocked])), HashMap::new()); + assert_eq!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap_err(), + LootClaimError::ItemBlocked + ); + let award = authority + .reserve_item_for_award_like_cpp(looter, 1) + .await + .expect("completed award may claim the blocked slot"); + assert!(award.commit_like_cpp().unwrap()); + assert!(!award.commit_like_cpp().unwrap(), "commit is idempotent"); + + let other = player(2); + let mut won = entry(2, false, vec![looter, other]); + won.roll_winner = other; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![won])), HashMap::new()); + assert_eq!( + authority + .reserve_item_for_award_like_cpp(looter, 2) + .await + .unwrap_err(), + LootClaimError::WrongRollWinner + ); + authority + .reserve_item_for_award_like_cpp(other, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + } + + #[tokio::test] + async fn finished_roll_state_is_published_before_later_direct_claims() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut rolled = entry(1, false, vec![first, second]); + rolled.flags.blocked = true; + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![rolled])), HashMap::new()); + let roll_generation = authority.shared_snapshot_like_cpp().unwrap().generation; + + assert!( + authority + .finish_item_roll_like_cpp(first, roll_generation, 1, false, Some(second)) + .unwrap() + ); + assert_eq!( + authority.reserve_item_like_cpp(first, 1).await.unwrap_err(), + LootClaimError::WrongRollWinner + ); + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + + let mut single_candidate = entry(2, false, vec![first]); + single_candidate.flags.blocked = true; + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![single_candidate])), + HashMap::new(), + ); + let roll_generation = authority.shared_snapshot_like_cpp().unwrap().generation; + authority + .finish_item_roll_like_cpp(first, roll_generation, 2, true, None) + .unwrap(); + let snapshot = authority.shared_snapshot_like_cpp().unwrap(); + assert!(snapshot.loot.items[0].flags.under_threshold); + assert!(!snapshot.loot.items[0].flags.blocked); + authority + .reserve_item_like_cpp(first, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + } + + #[tokio::test] + async fn final_clone_drop_rolls_back_and_wakes_waiter() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + + let lease = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let clone = lease.clone(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_item_like_cpp(looter, 1).await }); + drop(lease); + tokio::task::yield_now().await; + assert!( + !waiter.is_finished(), + "one clone still owns the reservation" + ); + drop(clone); + + let retry = waiter.await.unwrap().unwrap(); + assert!(matches!( + retry.payload_like_cpp(), + LootClaimPayload::Item(_) + )); + retry.commit_like_cpp().unwrap(); + } + + #[tokio::test] + async fn change_between_busy_check_and_wait_poll_is_not_lost() { + use std::time::Duration; + + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let first = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut changed = authority.inner.changed.subscribe(); + let busy = { + let mut state = authority.lock_state(); + reserve_item_once(&mut state, looter, 1, LootItemClaimMode::Direct, None) + }; + assert!(matches!(busy, ReserveAttempt::Wait)); + + // This transition is deliberately between the locked Busy result and the first poll of + // `changed()`. A watch version remembers it; Notify::notify_waiters would lose it. + first.rollback_like_cpp(); + tokio::time::timeout(Duration::from_secs(1), changed.changed()) + .await + .expect("reservation wake must not be lost") + .unwrap(); + let retry = { + let mut state = authority.lock_state(); + reserve_item_once(&mut state, looter, 1, LootItemClaimMode::Direct, None) + }; + assert!(matches!(retry, ReserveAttempt::Acquired { .. })); + authority.retire_like_cpp(); + } + + #[tokio::test] + async fn ffa_item_is_reserved_and_consumed_once_per_player() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut shared = loot(owner(1), 0, vec![entry(1, true, vec![first, second])]); + shared.unlooted_count = 2; + shared.player_ffa_items = vec![ + ( + first, + vec![NotNormalLootItem { + loot_list_id: 1, + is_looted: false, + }], + ), + ( + second, + vec![NotNormalLootItem { + loot_list_id: 1, + is_looted: false, + }], + ), + ]; + authority.replace_like_cpp(Some(shared), HashMap::new()); + + let barrier = Arc::new(Barrier::new(3)); + let mut tasks = Vec::new(); + for looter in [first, second] { + let authority = authority.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let lease = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + lease.commit_like_cpp().unwrap(); + })); + } + barrier.wait().await; + for task in tasks { + task.await.unwrap(); + } + + let shared = authority.shared_snapshot_like_cpp().unwrap().loot; + assert_eq!(shared.unlooted_count, 0); + assert!(shared.items[0].fully_looted_like_cpp()); + assert_eq!( + authority.reserve_item_like_cpp(first, 1).await.unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + assert_eq!( + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap_err(), + LootClaimError::ItemAlreadyLooted + ); + } + + #[tokio::test] + async fn personal_claims_and_ae_owners_are_independent() { + let first = player(1); + let second = player(2); + let authority = OwnedLootAuthority::new(); + let mut personal = HashMap::new(); + personal.insert(first, loot(owner(1), 0, vec![entry(1, false, vec![first])])); + personal.insert( + second, + loot(owner(1), 0, vec![entry(1, false, vec![second])]), + ); + authority.replace_like_cpp(None, personal); + + let first_lease = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let second_lease = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + first_lease.commit_like_cpp().unwrap(); + second_lease.commit_like_cpp().unwrap(); + + let first_ae = OwnedLootAuthority::new(); + let second_ae = OwnedLootAuthority::new(); + first_ae.replace_like_cpp(Some(money_loot(owner(10), 7, vec![first])), HashMap::new()); + second_ae.replace_like_cpp( + Some(money_loot(owner(11), 11, vec![second])), + HashMap::new(), + ); + first_ae + .reserve_money_like_cpp(first) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert_eq!(first_ae.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + assert_eq!(second_ae.shared_snapshot_like_cpp().unwrap().loot.coins, 11); + } + + #[tokio::test] + async fn stale_lease_cannot_touch_replacement_generation() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 5, vec![looter])), HashMap::new()); + let stale = authority.reserve_money_like_cpp(looter).await.unwrap(); + + let retired_generation = authority.retire_like_cpp(); + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .unwrap(); + assert!(stale.commit_like_cpp().is_err()); + drop(stale); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 9); + } + + #[tokio::test] + async fn allowed_player_can_reserve_and_commit_money_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 17, vec![looter])), HashMap::new()); + + let claim = authority.reserve_money_like_cpp(looter).await.unwrap(); + assert_eq!(claim.payload_like_cpp(), &LootClaimPayload::Money(17)); + assert!(claim.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[tokio::test] + async fn money_claim_rejects_player_not_in_allowed_looters_like_cpp() { + let authority = OwnedLootAuthority::new(); + let allowed = player(1); + let denied = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 19, vec![allowed])), + HashMap::new(), + ); + + assert_eq!( + authority.reserve_money_like_cpp(denied).await.unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 19); + } + + #[tokio::test] + async fn stale_player_cannot_claim_replacement_generation_money_like_cpp() { + let authority = OwnedLootAuthority::new(); + let stale_looter = player(1); + let current_looter = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 5, vec![stale_looter])), + HashMap::new(), + ); + let stale_generation = authority.generation_like_cpp(); + + let retired_generation = authority.retire_like_cpp(); + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 23, vec![current_looter])), + HashMap::new(), + ) + .unwrap(); + assert!(authority.generation_like_cpp() > stale_generation); + assert_eq!( + authority + .reserve_money_like_cpp(stale_looter) + .await + .unwrap_err(), + LootClaimError::PlayerNotAllowed + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 23); + + let current_claim = authority + .reserve_money_like_cpp(current_looter) + .await + .unwrap(); + assert!(current_claim.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[tokio::test] + async fn money_claim_rolls_back_and_later_cpp_request_observes_zero() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 25, vec![first, second])), + HashMap::new(), + ); + + let abandoned = authority.reserve_money_like_cpp(first).await.unwrap(); + assert!(abandoned.rollback_like_cpp()); + let winner = authority.reserve_money_like_cpp(second).await.unwrap(); + assert_eq!(winner.payload_like_cpp(), &LootClaimPayload::Money(25)); + assert!(winner.commit_like_cpp().unwrap()); + assert!(!winner.commit_like_cpp().unwrap()); + let zero = authority.reserve_money_like_cpp(first).await.unwrap(); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + } + + #[tokio::test] + async fn concurrent_money_waiter_observes_the_single_committed_winner() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 31, vec![first, second])), + HashMap::new(), + ); + + let winner = authority.reserve_money_like_cpp(first).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = + tokio::spawn(async move { waiting_authority.reserve_money_like_cpp(second).await }); + tokio::task::yield_now().await; + assert!(winner.commit_like_cpp().unwrap()); + let zero = waiter.await.unwrap().unwrap(); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 0); + } + + #[test] + fn personal_pools_can_be_installed_incrementally_without_replacing_peers() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.upsert_personal_like_cpp(first, loot(owner(1), 7, Vec::new()), false); + let generation = authority.generation_like_cpp(); + authority.upsert_personal_like_cpp(second, loot(owner(1), 11, Vec::new()), false); + + assert_eq!(authority.generation_like_cpp(), generation); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 7 + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .loot + .coins, + 11 + ); + let duplicate = + authority.upsert_personal_like_cpp(first, loot(owner(1), 99, Vec::new()), false); + assert!(!duplicate.installed()); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 7 + ); + } + + #[tokio::test] + async fn replacing_one_personal_pool_does_not_stale_another_players_claim() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut first_loot = loot(owner(1), 0, vec![entry(1, false, vec![first])]); + first_loot.allowed_looters = vec![first]; + authority.upsert_personal_like_cpp(first, first_loot, false); + let mut second_loot = loot(owner(1), 0, vec![entry(1, false, vec![second])]); + second_loot.allowed_looters = vec![second]; + authority.upsert_personal_like_cpp(second, second_loot, false); + + let stale_first_item = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let stale_first_money = authority.reserve_money_like_cpp(first).await.unwrap(); + let second_claim = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + let first_epoch = authority + .personal_snapshot_like_cpp(first) + .unwrap() + .generation; + let second_epoch = authority + .personal_snapshot_like_cpp(second) + .unwrap() + .generation; + let generation = authority.generation_like_cpp(); + let mut replacement = loot(owner(1), 9, vec![entry(2, false, vec![first])]); + replacement.allowed_looters = vec![first]; + authority.upsert_personal_like_cpp(first, replacement, true); + + assert_eq!(authority.generation_like_cpp(), generation); + assert_ne!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .generation, + first_epoch + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .generation, + second_epoch + ); + assert_eq!( + stale_first_item.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration) + ); + assert_eq!( + stale_first_money.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration) + ); + assert!(second_claim.commit_like_cpp().unwrap()); + assert!( + authority + .personal_snapshot_like_cpp(second) + .unwrap() + .loot + .items[0] + .taken + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(first) + .unwrap() + .loot + .coins, + 9 + ); + assert!( + authority + .reserve_item_like_cpp(first, 2) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + authority + .reserve_money_like_cpp(first) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + } + + #[tokio::test] + async fn stale_personal_waiter_rejects_replacement_epoch_without_reserving_it() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.upsert_personal_like_cpp( + looter, + loot(owner(1), 0, vec![entry(1, false, vec![looter])]), + false, + ); + let old_epoch = authority + .personal_snapshot_like_cpp(looter) + .unwrap() + .generation; + let held = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let waiting_authority = authority.clone(); + let waiter = tokio::spawn(async move { + waiting_authority + .reserve_item_for_generation_like_cpp(looter, 1, old_epoch) + .await + }); + tokio::task::yield_now().await; + + authority.upsert_personal_like_cpp( + looter, + loot(owner(1), 0, vec![entry(1, false, vec![looter])]), + true, + ); + assert_eq!( + waiter.await.unwrap().unwrap_err(), + LootClaimError::StaleGeneration + ); + drop(held); + assert!( + authority + .reserve_item_like_cpp(looter, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + } + + #[test] + fn pristine_bridge_cannot_resurrect_a_retired_nonzero_generation() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + let first = authority.initialize_pristine_like_cpp( + Some(money_loot(owner(1), 5, vec![looter])), + HashMap::new(), + ); + assert!(first.installed()); + authority.retire_like_cpp(); + + let stale = authority.initialize_pristine_like_cpp( + Some(money_loot(owner(1), 99, vec![looter])), + HashMap::new(), + ); + assert!(!stale.installed()); + assert!( + !authority + .initialize_like_cpp(Some(money_loot(owner(1), 98, vec![looter])), HashMap::new(),) + .installed() + ); + assert!( + !authority + .initialize_shared_like_cpp(money_loot(owner(1), 97, vec![looter])) + .installed() + ); + assert!( + !authority + .upsert_personal_like_cpp(looter, money_loot(owner(1), 96, vec![looter]), true,) + .installed() + ); + assert!(authority.is_retired_like_cpp()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + } + + #[test] + fn viewer_first_open_is_atomic_and_retire_clears_it() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 1, Vec::new())), HashMap::new()); + + let first_open = authority.add_viewer_like_cpp(first).unwrap(); + let duplicate = authority.add_viewer_like_cpp(first).unwrap(); + let second_open = authority.add_viewer_like_cpp(second).unwrap(); + assert!(first_open.first_viewer); + assert!(!duplicate.inserted); + assert!(!second_open.first_viewer); + assert_eq!( + authority.viewers_for_player_like_cpp(first), + vec![first, second] + ); + assert!(authority.remove_viewer_like_cpp(first)); + assert!(!authority.remove_viewer_like_cpp(first)); + assert!(authority.remove_viewer_like_cpp(second)); + let reopened = authority.add_viewer_like_cpp(first).unwrap(); + assert!(reopened.inserted); + assert!( + !reopened.first_viewer, + "C++ Loot::_wasOpened survives an empty active-looter set" + ); + authority.retire_like_cpp(); + assert!(authority.viewers_for_player_like_cpp(second).is_empty()); + } + + #[test] + fn rejected_view_response_rolls_back_viewer_and_first_open_like_cpp() { + let authority = OwnedLootAuthority::new(); + let viewer = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![viewer])])), + HashMap::new(), + ); + + assert_eq!( + authority.try_open_view_with_snapshot_like_cpp(viewer, |_, _| None::<()>), + Err(LootClaimError::ResponseEnqueueFailed) + ); + let rejected = authority.shared_snapshot_like_cpp().unwrap(); + assert!(rejected.loot.players_looting.is_empty()); + assert!(!rejected.loot.looted_by_player); + + let retry = authority.add_viewer_like_cpp(viewer).unwrap(); + assert!(retry.inserted); + assert!( + retry.first_viewer, + "a response the client never observed must not consume C++ Loot::_wasOpened" + ); + } + + #[test] + fn exact_generation_release_removes_only_that_viewer() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let generation = authority.replace_like_cpp( + Some(money_loot(owner(1), 17, vec![first, second])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + authority.add_viewer_like_cpp(second).unwrap(); + + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(generation, first), + Some(true) + ); + assert_eq!(authority.viewers_for_player_like_cpp(second), vec![second]); + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(generation, first), + Some(false) + ); + assert_eq!(authority.shared_snapshot_like_cpp().unwrap().loot.coins, 17); + } + + #[test] + fn retired_generation_release_cannot_touch_replacement_viewer_or_pool() { + let authority = OwnedLootAuthority::new(); + let viewer = player(1); + let old_generation = + authority.replace_like_cpp(Some(money_loot(owner(1), 7, vec![viewer])), HashMap::new()); + authority.add_viewer_like_cpp(viewer).unwrap(); + + authority.retire_like_cpp(); + let replacement_generation = authority + .replace_like_cpp(Some(money_loot(owner(1), 29, vec![viewer])), HashMap::new()); + authority.add_viewer_like_cpp(viewer).unwrap(); + + assert_ne!(replacement_generation, old_generation); + assert_eq!( + authority.remove_viewer_if_generation_like_cpp(old_generation, viewer), + None + ); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + assert_eq!(replacement.loot.coins, 29); + assert_eq!(replacement.loot.players_looting, vec![viewer]); + assert_eq!(replacement.loot.allowed_looters, vec![viewer]); + } + + #[tokio::test] + async fn persistence_guard_survives_retire_and_closes_after_commit_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + let retired_generation = authority.retire_like_cpp(); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Retired + ); + assert_eq!( + claim.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged), + "only the durable-phase owner may resolve a protected reservation" + ); + assert!(!claim.rollback_like_cpp()); + assert!(persistence.commit_like_cpp().unwrap()); + + assert!(authority.is_retired_like_cpp()); + assert!(authority.shared_snapshot_like_cpp().is_none()); + assert!( + authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .is_some(), + "the closed lifetime may respawn only after durable completion" + ); + } + + #[tokio::test] + async fn persistence_guard_survives_detach_but_detached_owner_never_reopens_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + let detached_generation = authority.detach_like_cpp(); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(persistence.commit_like_cpp().unwrap()); + assert_eq!( + authority + .replace_like_cpp(Some(money_loot(owner(1), 9, vec![looter])), HashMap::new(),), + 0 + ); + assert!( + authority + .replace_retired_generation_like_cpp( + detached_generation, + Some(money_loot(owner(1), 9, vec![looter])), + HashMap::new(), + ) + .is_none() + ); + } + + #[tokio::test] + async fn persistence_guard_is_unique_and_external_clones_cannot_resolve_it_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp(Some(money_loot(owner(1), 17, vec![looter])), HashMap::new()); + let claim = authority.reserve_money_like_cpp(looter).await.unwrap(); + let clone = claim.clone(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + + assert_eq!( + clone.begin_persistence_guard_like_cpp().unwrap_err(), + LootClaimCommitError::StateChanged + ); + assert_eq!( + clone.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged) + ); + assert!(!clone.rollback_like_cpp()); + assert!(persistence.commit_like_cpp().unwrap()); + assert_eq!( + claim.begin_persistence_guard_like_cpp().unwrap_err(), + LootClaimCommitError::StateChanged + ); + } + + #[tokio::test] + async fn dropped_persistence_guard_reopens_claim_after_failure_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let failed = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let persistence = failed.begin_persistence_guard_like_cpp().unwrap(); + drop(persistence); + + assert_eq!( + failed.commit_like_cpp(), + Err(LootClaimCommitError::RolledBack) + ); + let retry = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + assert!(retry.commit_like_cpp().unwrap()); + } + + #[tokio::test] + async fn persistence_commit_snapshot_excludes_viewers_opened_after_money_transition_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp( + Some(money_loot(owner(1), 17, vec![first, late])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + + let claim = authority.reserve_money_like_cpp(first).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let (first_commit, committed_snapshot) = + persistence.commit_with_snapshot_like_cpp().unwrap(); + assert!(first_commit); + assert_eq!( + committed_snapshot.unwrap().loot.players_looting, + vec![first], + "the durable fanout snapshot is captured at the same serialized transition as coins=0" + ); + + authority.add_viewer_like_cpp(late).unwrap(); + assert_eq!( + authority + .shared_snapshot_like_cpp() + .unwrap() + .loot + .players_looting, + vec![first, late], + "a later opener observes zero directly and is not retroactively part of the commit fanout" + ); + } + + #[tokio::test] + async fn persistence_commit_snapshot_excludes_viewers_opened_after_item_transition_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![first, late])])), + HashMap::new(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + + let claim = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let (first_commit, committed_snapshot) = + persistence.commit_with_snapshot_like_cpp().unwrap(); + assert!(first_commit); + let committed_snapshot = committed_snapshot.unwrap(); + assert_eq!(committed_snapshot.loot.players_looting, vec![first]); + assert!(committed_snapshot.loot.items[0].taken); + + let (_, late_snapshot) = authority + .open_view_with_snapshot_like_cpp(late, |snapshot, _| snapshot.clone()) + .unwrap(); + assert_eq!(late_snapshot.loot.players_looting, vec![first, late]); + assert!(late_snapshot.loot.items[0].taken); + assert_eq!( + committed_snapshot.loot.players_looting, + vec![first], + "a viewer that first observes the consumed item is outside the commit fanout cut" + ); + } + + #[tokio::test] + async fn one_personal_persistence_does_not_block_peer_scope_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.upsert_personal_like_cpp( + first, + loot(owner(1), 0, vec![entry(1, false, vec![first])]), + false, + ); + let first_claim = authority.reserve_item_like_cpp(first, 1).await.unwrap(); + let mut first_persistence = first_claim.begin_persistence_guard_like_cpp().unwrap(); + + assert!( + authority + .upsert_personal_like_cpp( + second, + loot(owner(1), 0, vec![entry(1, false, vec![second])]), + false, + ) + .installed(), + "P1 durable work must not serialize an independent P2 pool" + ); + let second_claim = authority.reserve_item_like_cpp(second, 1).await.unwrap(); + assert!(second_claim.commit_like_cpp().unwrap()); + + assert!( + !authority + .upsert_personal_like_cpp( + first, + loot(owner(1), 0, vec![entry(2, false, vec![first])]), + true, + ) + .installed(), + "the exact persisting P1 pool cannot be replaced" + ); + assert!(first_persistence.commit_like_cpp().unwrap()); + } + + #[test] + fn lifecycle_observation_is_invalidated_by_late_personal_upsert_like_cpp() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let authority = OwnedLootAuthority::new(); + let first = player(1); + let late = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![])), HashMap::new()); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(close.whole_object_fully_looted); + + let applications = AtomicUsize::new(0); + assert!( + authority + .with_fully_looted_lifecycle_observation_like_cpp( + close.object_generation, + close.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_some() + ); + assert!( + authority + .upsert_personal_like_cpp( + late, + loot(owner(1), 0, vec![entry(1, false, vec![late])]), + false, + ) + .installed() + ); + assert!( + authority + .with_fully_looted_lifecycle_observation_like_cpp( + close.object_generation, + close.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_none(), + "an upsert after close must invalidate the pre-upsert lifecycle observation" + ); + assert_eq!(applications.load(Ordering::SeqCst), 1); + } + + #[test] + fn detached_lifecycle_observation_requires_every_loot_view_to_be_closed_like_cpp() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + authority.replace_like_cpp(Some(loot(owner(1), 0, vec![])), HashMap::new()); + let first_open = authority.add_viewer_like_cpp(first).unwrap(); + let second_open = authority.add_viewer_like_cpp(second).unwrap(); + + authority + .close_viewer_if_generation_like_cpp(second_open.generation, second) + .unwrap(); + assert!( + authority + .fully_looted_unviewed_lifecycle_observation_like_cpp() + .is_none(), + "the remaining viewer belongs to the global authority, not the detached worker session" + ); + + authority + .close_viewer_if_generation_like_cpp(first_open.generation, first) + .unwrap(); + let observation = authority + .fully_looted_unviewed_lifecycle_observation_like_cpp() + .expect("the fully-looted owner is unviewed after both releases"); + let applications = AtomicUsize::new(0); + assert!( + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + observation.object_generation, + observation.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_some() + ); + + authority.add_viewer_like_cpp(first).unwrap(); + assert!( + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + observation.object_generation, + observation.lifecycle_revision, + || applications.fetch_add(1, Ordering::SeqCst), + ) + .is_none(), + "a view opened after the snapshot must still veto the serialized lifecycle mutation" + ); + assert_eq!(applications.load(Ordering::SeqCst), 1); + } + + #[test] + fn round_robin_clear_is_generation_guarded_and_returns_authoritative_snapshot_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut shared = loot(owner(1), 0, vec![entry(1, false, vec![first, second])]); + shared.round_robin_player = first; + authority.replace_like_cpp(Some(shared), HashMap::new()); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + + let cleared = authority + .clear_round_robin_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(cleared.cleared); + assert!(cleared.snapshot.loot.round_robin_player.is_empty()); + + let mut replacement = loot(owner(1), 0, vec![entry(1, false, vec![first, second])]); + replacement.round_robin_player = second; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + assert!( + authority + .clear_round_robin_if_generation_like_cpp(generation, second) + .is_none(), + "a stale release cannot clear round robin on a replacement pool" + ); + assert_eq!( + authority + .snapshot_for_player_like_cpp(second) + .unwrap() + .loot + .round_robin_player, + second + ); + } + + #[tokio::test] + async fn wait_for_persisting_claims_observes_commit_and_failure_boundaries_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(1, false, vec![looter])])), + HashMap::new(), + ); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let waiting = authority.clone(); + let waiter = tokio::spawn(async move { + waiting.wait_for_persisting_claims_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + assert!(persistence.commit_like_cpp().unwrap()); + waiter.await.unwrap(); + + authority.replace_like_cpp( + Some(loot(owner(1), 0, vec![entry(2, false, vec![looter])])), + HashMap::new(), + ); + let failed = authority.reserve_item_like_cpp(looter, 2).await.unwrap(); + let persistence = failed.begin_persistence_guard_like_cpp().unwrap(); + drop(persistence); + authority.wait_for_persisting_claims_like_cpp().await; + assert!(authority.reserve_item_like_cpp(looter, 2).await.is_ok()); + } + + #[tokio::test] + async fn commit_unknown_quarantine_is_terminal_fail_closed_and_drains_waiters_like_cpp() { + let authority = OwnedLootAuthority::new(); + let looter = player(1); + let original = loot(owner(1), 0, vec![entry(1, false, vec![looter])]); + authority.replace_like_cpp(Some(original.clone()), HashMap::new()); + let claim = authority.reserve_item_like_cpp(looter, 1).await.unwrap(); + let mut persistence = claim.begin_persistence_guard_like_cpp().unwrap(); + let waiting = authority.clone(); + let waiter = tokio::spawn(async move { + waiting.wait_for_persisting_claims_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + + assert!(persistence.quarantine_commit_unknown_like_cpp()); + drop(persistence); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("quarantine removes the persisting token") + .unwrap(); + + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Quarantined + ); + assert_eq!( + claim.commit_like_cpp(), + Err(LootClaimCommitError::StateChanged) + ); + assert!(matches!( + authority.reserve_item_like_cpp(looter, 1).await, + Err(LootClaimError::Retired) + )); + assert_eq!( + authority.replace_like_cpp(Some(original.clone()), HashMap::new()), + 0 + ); + assert!( + !authority + .initialize_pristine_like_cpp(Some(original), HashMap::new()) + .installed() + ); + } + + #[tokio::test] + async fn whole_object_skinned_requires_every_active_skinning_pool_like_cpp() { + let authority = OwnedLootAuthority::new(); + let first = player(1); + let second = player(2); + let mut first_pool = loot(owner(1), 0, vec![]); + first_pool.loot_type = wow_constants::LootType::Skinning as u8; + let mut second_pool = loot(owner(1), 0, vec![entry(1, false, vec![second])]); + second_pool.loot_type = wow_constants::LootType::Skinning as u8; + authority.replace_like_cpp( + None, + [(first, first_pool), (second, second_pool)] + .into_iter() + .collect(), + ); + authority.add_viewer_like_cpp(first).unwrap(); + let first_generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let first_close = authority + .close_viewer_if_generation_like_cpp(first_generation, first) + .unwrap(); + assert!(first_close.snapshot.loot.is_looted_like_cpp()); + assert!(!first_close.whole_object_fully_looted); + assert!(!first_close.whole_object_fully_skinned); + + authority + .reserve_item_like_cpp(second, 1) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + authority.add_viewer_like_cpp(second).unwrap(); + let second_generation = authority + .snapshot_for_player_like_cpp(second) + .unwrap() + .generation; + let second_close = authority + .close_viewer_if_generation_like_cpp(second_generation, second) + .unwrap(); + assert!(second_close.whole_object_fully_looted); + assert!(second_close.whole_object_fully_skinned); + } +} diff --git a/crates/wow-loot/src/lib.rs b/crates/wow-loot/src/lib.rs index 79f810c4d..6af372077 100644 --- a/crates/wow-loot/src/lib.rs +++ b/crates/wow-loot/src/lib.rs @@ -15,6 +15,16 @@ use std::collections::{HashMap, HashSet}; use rand::Rng; use wow_core::ObjectGuid; +mod authority; + +pub use authority::{ + CreatureLoot, LootClaimCommitError, LootClaimError, LootClaimLease, LootClaimPayload, + LootClaimPersistenceGuard, LootEntry, LootEntryFlags, LootInstallOutcome, LootItemClaimKey, + LootRoundRobinReleaseOutcome, LootViewerCloseOutcome, LootViewerOpenOutcome, NotNormalLootItem, + OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootAuthorityStamp, OwnedLootScope, + OwnedLootSnapshot, +}; + const MIN_NON_ZERO_LOOT_CHANCE_LIKE_CPP: f32 = 0.000001; pub const MAX_NR_LOOT_ITEMS_LIKE_CPP: usize = 18; pub const LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP: u8 = 0; @@ -93,7 +103,7 @@ pub struct LootStoreItemContext { pub item: LootStoreItem, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct GeneratedLootItem { pub item_id: u32, pub count: u32, @@ -101,6 +111,7 @@ pub struct GeneratedLootItem { pub random_properties_id: i32, pub random_properties_seed: i32, pub context: u8, + pub store_item_context: LootStoreItemContext, pub free_for_all: bool, pub follow_loot_rules: bool, pub needs_quest: bool, @@ -110,7 +121,7 @@ pub struct GeneratedLootItem { pub is_counted: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct GeneratedPersonalLootItem { pub looter: ObjectGuid, pub item: GeneratedLootItem, @@ -1468,14 +1479,18 @@ impl LootTemplate { return; } } - } else if item_allowed(LootStoreItemContext { - store_kind, - entry, - item: *item, - }) { + } else { + let store_item_context = LootStoreItemContext { + store_kind, + entry, + item: *item, + }; + if !item_allowed(store_item_context) { + continue; + } add_generated_loot_item_like_cpp( generated, - *item, + store_item_context, options.item_context, rng, item_template, @@ -1617,7 +1632,11 @@ impl LootTemplate { add_generated_personal_loot_item_like_cpp( generated, chosen_looter, - *item, + LootStoreItemContext { + store_kind, + entry, + item: *item, + }, options.item_context, rng, item_template, @@ -1656,6 +1675,8 @@ impl LootTemplate { random_properties, options.item_context, chosen_looter, + store_kind, + entry, ); } } @@ -1969,7 +1990,11 @@ impl LootGroup { { add_generated_loot_item_like_cpp( generated, - item, + LootStoreItemContext { + store_kind, + entry, + item, + }, item_context, rng, item_template, @@ -2012,18 +2037,24 @@ impl LootGroup { random_properties: &mut FRandom, item_context: u8, looter: ObjectGuid, + store_kind: LootStoreKind, + entry: u32, ) where R: Rng + ?Sized, FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { if let Some(item) = - self.roll_with_context_like_cpp(loot_mode, rng, |_| true, LootStoreKind::Creature, 0) + self.roll_with_context_like_cpp(loot_mode, rng, |_| true, store_kind, entry) { add_generated_personal_loot_item_like_cpp( generated, looter, - item, + LootStoreItemContext { + store_kind, + entry, + item, + }, item_context, rng, item_template, @@ -2035,7 +2066,7 @@ impl LootGroup { fn add_generated_loot_item_like_cpp( generated: &mut Vec, - item: LootStoreItem, + store_item_context: LootStoreItemContext, item_context: u8, rng: &mut R, item_template: &mut FTemplate, @@ -2045,6 +2076,7 @@ fn add_generated_loot_item_like_cpp( FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { + let item = store_item_context.item; let Some(metadata) = item_template(item.item_id) else { return; }; @@ -2070,6 +2102,7 @@ fn add_generated_loot_item_like_cpp( random_properties_id: random_properties.id, random_properties_seed: random_properties.seed, context: item_context, + store_item_context, free_for_all: metadata.has_multi_drop_flag, follow_loot_rules: !item.needs_quest || metadata.has_follow_loot_rules_flag, needs_quest: item.needs_quest, @@ -2085,7 +2118,7 @@ fn add_generated_loot_item_like_cpp( fn add_generated_personal_loot_item_like_cpp( generated: &mut Vec, looter: ObjectGuid, - item: LootStoreItem, + store_item_context: LootStoreItemContext, item_context: u8, rng: &mut R, item_template: &mut FTemplate, @@ -2095,6 +2128,7 @@ fn add_generated_personal_loot_item_like_cpp( FTemplate: FnMut(u32) -> Option, FRandom: FnMut(u32, &mut R) -> LootItemRandomProperties, { + let item = store_item_context.item; let Some(metadata) = item_template(item.item_id) else { return; }; @@ -2122,6 +2156,7 @@ fn add_generated_personal_loot_item_like_cpp( random_properties_id: random_properties.id, random_properties_seed: random_properties.seed, context: item_context, + store_item_context, free_for_all: metadata.has_multi_drop_flag, follow_loot_rules: !item.needs_quest || metadata.has_follow_loot_rules_flag, needs_quest: item.needs_quest, @@ -2175,13 +2210,13 @@ mod tests { LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootConditionId, LootConditionLinkReport, LootConditionReferenceUseLikeCpp, LootConditionRowLikeCpp, LootFillError, LootFillOptions, LootItemRandomProperties, LootItemTemplateMetadata, LootReferenceCheckReport, - LootReferenceUse, LootStore, LootStoreItem, LootStoreKind, LootStoreLoadError, LootStores, - LootTemplate, LootTemplateRow, MissingLootConditionItemTemplate, - MissingLootConditionTemplate, MissingLootConditionTemplateItem, - check_loot_condition_links_like_cpp, check_loot_condition_references_like_cpp, - check_loot_references_like_cpp, condition_compare_values_like_cpp, - generate_money_loot_with_rate_like_cpp, loot_condition_reference_ids_like_cpp, - loot_condition_reference_self_references_like_cpp, + LootReferenceUse, LootStore, LootStoreItem, LootStoreItemContext, LootStoreKind, + LootStoreLoadError, LootStores, LootTemplate, LootTemplateRow, + MissingLootConditionItemTemplate, MissingLootConditionTemplate, + MissingLootConditionTemplateItem, check_loot_condition_links_like_cpp, + check_loot_condition_references_like_cpp, check_loot_references_like_cpp, + condition_compare_values_like_cpp, generate_money_loot_with_rate_like_cpp, + loot_condition_reference_ids_like_cpp, loot_condition_reference_self_references_like_cpp, loot_condition_row_is_loadable_without_external_stores_like_cpp, loot_condition_row_normalize_without_external_stores_like_cpp, loot_conditions_allow_player_like_cpp_representable, @@ -2253,7 +2288,14 @@ mod tests { ) } - fn generated_item(item_id: u32, count: u32, loot_list_id: u32) -> GeneratedLootItem { + fn generated_item( + store_item: LootStoreItem, + count: u32, + loot_list_id: u32, + store_kind: LootStoreKind, + entry: u32, + ) -> GeneratedLootItem { + let item_id = store_item.item_id; GeneratedLootItem { item_id, count, @@ -2261,6 +2303,11 @@ mod tests { random_properties_id: item_id as i32 + 1000, random_properties_seed: item_id as i32 + 2000, context: 0, + store_item_context: LootStoreItemContext { + store_kind, + entry, + item: store_item, + }, free_for_all: false, follow_loot_rules: true, needs_quest: false, @@ -3001,9 +3048,9 @@ mod tests { assert_eq!( generated, vec![ - generated_item(25, 1, 0), - generated_item(27, 1, 1), - generated_item(26, 1, 2), + generated_item(item(25, 0, 100.0, 0), 1, 0, LootStoreKind::Creature, 100,), + generated_item(item(27, 0, 100.0, 0), 1, 1, LootStoreKind::Reference, 700,), + generated_item(item(26, 0, 0.0, 1), 1, 2, LootStoreKind::Creature, 100,), ] ); } @@ -3176,7 +3223,16 @@ mod tests { ) .unwrap(); - assert_eq!(generated, vec![generated_item(27, 1, 0)]); + assert_eq!( + generated, + vec![generated_item( + item(27, 0, 100.0, 0), + 1, + 0, + LootStoreKind::Reference, + 900 + )] + ); assert_eq!(seen, vec![(LootStoreKind::Reference, 900, 27)]); assert_eq!( stores[&LootStoreKind::Creature].condition_ids_for_fill_like_cpp( @@ -3292,6 +3348,14 @@ mod tests { random_properties_id: 4242, random_properties_seed: 2424, context: 4, + store_item_context: LootStoreItemContext { + store_kind: LootStoreKind::Item, + entry: 600, + item: LootStoreItem { + needs_quest: true, + ..item(25, 0, 100.0, 0) + }, + }, free_for_all: true, follow_loot_rules: true, needs_quest: true, @@ -3379,6 +3443,10 @@ mod tests { generate_money_loot_with_rate_like_cpp(120, 100, 1.0, &mut rng), 100 ); + assert_eq!( + generate_money_loot_with_rate_like_cpp(10, 10, 1.0, &mut rng), + 10 + ); assert_eq!( generate_money_loot_with_rate_like_cpp(120, 100, 2.5, &mut rng), 250 diff --git a/crates/wow-map/Cargo.toml b/crates/wow-map/Cargo.toml index 3ed62440d..e9279cab8 100644 --- a/crates/wow-map/Cargo.toml +++ b/crates/wow-map/Cargo.toml @@ -17,3 +17,4 @@ rand = { workspace = true } [dev-dependencies] wow-constants = { workspace = true } +wow-loot = { workspace = true } diff --git a/crates/wow-map/src/map.rs b/crates/wow-map/src/map.rs index 0dc57c6a9..f5e17c722 100644 --- a/crates/wow-map/src/map.rs +++ b/crates/wow-map/src/map.rs @@ -8701,8 +8701,11 @@ where ) -> Result, MapObjectStoreError> { self.validate_map_object(record.object())?; let guid = record.object().guid(); - let previous = self.map_objects.remove(&guid); - if let Some(previous_record) = previous.as_ref() { + let mut previous = self.map_objects.remove(&guid); + if let Some(previous_record) = previous.as_mut() { + if !typed_loot_authorities_share_storage_like_cpp(previous_record, &record) { + detach_typed_loot_authority_like_cpp(previous_record); + } self.unindex_map_object_record_by_spawn_id_like_cpp(previous_record); } self.index_map_object_record_by_spawn_id_like_cpp(&record); @@ -10721,6 +10724,12 @@ where .get_mut(&guid) .and_then(MapObjectRecord::game_object_mut) { + // `GameObject::Delete` queues physical removal without calling + // `ClearLoot`. Terminally detach only the async authority here: + // this prevents both Arc-held claims and an async generator from + // reactivating the deleted lifetime while preserving C++'s + // interim object fields until remove-list drain. + game_object.loot_authority_like_cpp().detach_like_cpp(); game_object.set_loot_state(LootState::NotReady, None); } let remove_from_owner = self.gameobject_remove_from_owner_like_cpp(guid); @@ -10883,6 +10892,7 @@ where .get_mut(&guid) .and_then(MapObjectRecord::game_object_mut) { + game_object.loot_authority_like_cpp().detach_like_cpp(); game_object.set_loot_state(LootState::NotReady, None); } let remove_from_owner = self.gameobject_remove_from_owner_like_cpp(guid); @@ -11337,9 +11347,17 @@ where }) .ok_or(RemoveFromMapError::ObjectNotFound { guid })?; let remove_from_active = was_active.then(|| self.remove_from_active_like_cpp(guid)); - let record = self + let mut record = self .remove_map_object(guid) .ok_or(RemoveFromMapError::ObjectNotFound { guid })?; + // Rust's non-delete outcome retains only the erased + // `WorldObject`; `MapObjectRecord::into_object` still destroys the + // typed Creature/GameObject that owns its Loot. Until this API can + // return the full typed record like C++ retains the object pointer, + // both paths must terminally detach that otherwise orphaned + // authority. A stale lease may finish only if it already crossed + // the protected durable boundary. + detach_typed_loot_authority_like_cpp(&mut record); let was_world_object_like_cpp = map_record_is_world_object_like_cpp(&record); let mut object = record.into_object(); let was_in_world = remove_from_map_was_in_world; @@ -14781,6 +14799,55 @@ where } } +/// Invalidates async claims before a terminal typed object lifetime is dropped. +/// +/// C++ destroys `Creature::loot` / `GameObject::loot` together with the typed +/// object. Rust leases can retain the shared authority after that drop, so the +/// backing allocation must become permanently detached first. +fn detach_typed_loot_authority_like_cpp(record: &mut MapObjectRecord) { + match record.kind() { + AccessorObjectKind::Creature => { + if let Some(creature) = record.creature_mut() { + creature.loot_authority_like_cpp().detach_like_cpp(); + } + } + AccessorObjectKind::GameObject => { + if let Some(game_object) = record.game_object_mut() { + game_object.loot_authority_like_cpp().detach_like_cpp(); + } + } + _ => {} + } +} + +/// A same-GUID whole-entity refresh may replace the record while deliberately +/// retaining the exact object lifetime. Do not detach the shared authority in +/// that case; only a distinct backing allocation is displaced terminally. +fn typed_loot_authorities_share_storage_like_cpp( + previous: &MapObjectRecord, + replacement: &MapObjectRecord, +) -> bool { + match (previous.kind(), replacement.kind()) { + (AccessorObjectKind::Creature, AccessorObjectKind::Creature) => previous + .creature() + .zip(replacement.creature()) + .is_some_and(|(previous, replacement)| { + previous + .loot_authority_like_cpp() + .shares_storage_like_cpp(replacement.loot_authority_like_cpp()) + }), + (AccessorObjectKind::GameObject, AccessorObjectKind::GameObject) => previous + .game_object() + .zip(replacement.game_object()) + .is_some_and(|(previous, replacement)| { + previous + .loot_authority_like_cpp() + .shares_storage_like_cpp(replacement.loot_authority_like_cpp()) + }), + _ => false, + } +} + impl GridUnloadEntityStore for Map { fn creature_mut(&mut self, guid: ObjectGuid) -> Option<&mut Creature> { self.map_objects @@ -15012,6 +15079,7 @@ mod tests { VehicleAccessory, VehicleSeatAddon, VehicleSeatInfo, VehicleSpellImmunity, VehicleSpellImmunityKind, }; + use wow_loot::{CreatureLoot, LootClaimCommitError, OwnedLootAuthorityLifecycle}; const GO_FLAG_MAP_OBJECT: u32 = 0x0010_0000; @@ -18881,6 +18949,44 @@ mod tests { gameobject } + fn money_loot_for_player_like_cpp( + loot_guid: ObjectGuid, + coins: u32, + player: ObjectGuid, + ) -> CreatureLoot { + CreatureLoot { + loot_guid, + coins, + unlooted_count: 0, + loot_type: 1, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player], + items: Vec::new(), + looted_by_player: false, + } + } + + fn poll_immediately_ready(future: F) -> F::Output { + struct NoopWake; + + impl std::task::Wake for NoopWake { + fn wake(self: std::sync::Arc) {} + } + + let waker = std::task::Waker::from(std::sync::Arc::new(NoopWake)); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut context) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("expected the uncontended claim to be ready"), + } + } + fn summon_gameobject_template_like_cpp( entry: u32, go_type: u32, @@ -25350,6 +25456,360 @@ mod tests { assert_eq!(object.current_cell(), None); } + #[test] + fn remove_from_map_delete_detaches_creature_loot_authority_before_typed_drop() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_101); + let mut creature = test_creature_for_spawn(484_101, 4_841_010, true); + let guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 17, player, + )) + .installed() + ); + let retained_authority = creature.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live Creature authority must grant the uncontended lease"); + creature + .unit_mut() + .world_mut() + .object_mut() + .remove_from_world(); + map.add_map_object_record_to_map_like_cpp(MapObjectRecord::new_creature(creature).unwrap()) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, true).unwrap(); + + assert!(removed.object.is_none()); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn remove_from_map_nondelete_detaches_orphaned_typed_loot_authority() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_109); + let mut creature = test_creature_for_spawn(484_109, 4_841_090, true); + let guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 17, player, + )) + .installed() + ); + let retained_authority = creature.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live typed Creature owns the reservation"); + creature + .unit_mut() + .world_mut() + .object_mut() + .remove_from_world(); + map.add_map_object_record_to_map_like_cpp(MapObjectRecord::new_creature(creature).unwrap()) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, false).unwrap(); + + assert!( + removed.object.is_some(), + "the erased WorldObject remains available to the non-delete caller" + ); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached, + "the returned erased object cannot preserve the destroyed typed loot owner" + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn remove_from_map_delete_detaches_gameobject_loot_authority_before_typed_drop() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_102); + let mut gameobject = test_gameobject_for_spawn(484_102, 4_841_020); + let guid = gameobject.world().guid(); + assert!( + gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 19, player, + )) + .installed() + ); + let retained_authority = gameobject.loot_authority_like_cpp().clone(); + let lease = poll_immediately_ready(retained_authority.reserve_money_like_cpp(player)) + .expect("the live GameObject authority must grant the uncontended lease"); + gameobject.world_mut().object_mut().remove_from_world(); + map.add_map_object_record_to_map_like_cpp( + MapObjectRecord::new_game_object(gameobject).unwrap(), + ) + .unwrap(); + + let removed = map.remove_from_map_like_cpp(guid, true).unwrap(); + + assert!(removed.object.is_none()); + assert_eq!( + retained_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_detaches_displaced_creature_authority_for_same_guid() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_103); + let mut displaced_creature = test_creature_for_spawn(484_103, 4_841_030, true); + let guid = displaced_creature.guid(); + assert!( + displaced_creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 23, player, + )) + .installed() + ); + let displaced_authority = displaced_creature.loot_authority_like_cpp().clone(); + map.insert_map_object_record(MapObjectRecord::new_creature(displaced_creature).unwrap()) + .unwrap(); + let lease = poll_immediately_ready(displaced_authority.reserve_money_like_cpp(player)) + .expect("the displaced Creature authority must grant the uncontended lease"); + + let mut replacement = test_creature_for_spawn(484_104, 4_841_030, true); + assert!( + replacement + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 29, player, + )) + .installed() + ); + let replacement_authority = replacement.loot_authority_like_cpp().clone(); + let displaced = map + .insert_map_object_record(MapObjectRecord::new_creature(replacement).unwrap()) + .unwrap() + .expect("same-GUID insert must return the displaced Creature record"); + + assert_eq!( + displaced_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!( + displaced + .creature() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&displaced_authority) + ); + assert!( + map.map_object_record(guid) + .and_then(MapObjectRecord::creature) + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement_authority) + ); + assert_eq!( + replacement_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_detaches_displaced_gameobject_authority_for_same_guid() { + let mut map = test_map(); + let player = ObjectGuid::create_player(1, 484_104); + let mut displaced_gameobject = test_gameobject_for_spawn(484_105, 4_841_040); + let guid = displaced_gameobject.world().guid(); + assert!( + displaced_gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 31, player, + )) + .installed() + ); + let displaced_authority = displaced_gameobject.loot_authority_like_cpp().clone(); + map.insert_map_object_record( + MapObjectRecord::new_game_object(displaced_gameobject).unwrap(), + ) + .unwrap(); + let lease = poll_immediately_ready(displaced_authority.reserve_money_like_cpp(player)) + .expect("the displaced GameObject authority must grant the uncontended lease"); + + let mut replacement = test_gameobject_for_spawn(484_106, 4_841_040); + assert!( + replacement + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + guid, 37, player, + )) + .installed() + ); + let replacement_authority = replacement.loot_authority_like_cpp().clone(); + let displaced = map + .insert_map_object_record(MapObjectRecord::new_game_object(replacement).unwrap()) + .unwrap() + .expect("same-GUID insert must return the displaced GameObject record"); + + assert_eq!( + displaced_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Detached + ); + assert!( + displaced + .game_object() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&displaced_authority) + ); + assert!( + map.map_object_record(guid) + .and_then(MapObjectRecord::game_object) + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&replacement_authority) + ); + assert_eq!( + replacement_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert!(matches!( + lease.commit_like_cpp(), + Err(LootClaimCommitError::StaleGeneration | LootClaimCommitError::RolledBack) + )); + } + + #[test] + fn insert_map_object_record_preserves_shared_typed_authority_for_same_guid_refresh() { + let player = ObjectGuid::create_player(1, 484_105); + + let mut creature_map = test_map(); + let mut creature = test_creature_for_spawn(484_107, 4_841_050, true); + let creature_guid = creature.guid(); + assert!( + creature + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + creature_guid, + 41, + player, + )) + .installed() + ); + let creature_record = MapObjectRecord::new_creature(creature).unwrap(); + let creature_refresh = creature_record.clone(); + let creature_authority = creature_record + .creature() + .unwrap() + .loot_authority_like_cpp() + .clone(); + creature_map + .insert_map_object_record(creature_record) + .unwrap(); + let creature_lease = + poll_immediately_ready(creature_authority.reserve_money_like_cpp(player)) + .expect("the shared Creature authority must grant the uncontended lease"); + + let displaced_creature = creature_map + .insert_map_object_record(creature_refresh) + .unwrap() + .expect("same-GUID refresh must return the prior Creature record"); + + assert!( + displaced_creature + .creature() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&creature_authority) + ); + assert_eq!( + creature_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert_eq!(creature_lease.commit_like_cpp(), Ok(true)); + assert_eq!( + creature_map + .map_object_record(creature_guid) + .and_then(MapObjectRecord::creature) + .unwrap() + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .unwrap() + .loot + .coins, + 0 + ); + + let mut gameobject_map = test_map(); + let mut gameobject = test_gameobject_for_spawn(484_108, 4_841_060); + let gameobject_guid = gameobject.world().guid(); + assert!( + gameobject + .initialize_shared_loot_authority_like_cpp(money_loot_for_player_like_cpp( + gameobject_guid, + 43, + player, + )) + .installed() + ); + let gameobject_record = MapObjectRecord::new_game_object(gameobject).unwrap(); + let gameobject_refresh = gameobject_record.clone(); + let gameobject_authority = gameobject_record + .game_object() + .unwrap() + .loot_authority_like_cpp() + .clone(); + gameobject_map + .insert_map_object_record(gameobject_record) + .unwrap(); + let gameobject_lease = + poll_immediately_ready(gameobject_authority.reserve_money_like_cpp(player)) + .expect("the shared GameObject authority must grant the uncontended lease"); + + let displaced_gameobject = gameobject_map + .insert_map_object_record(gameobject_refresh) + .unwrap() + .expect("same-GUID refresh must return the prior GameObject record"); + + assert!( + displaced_gameobject + .game_object() + .unwrap() + .loot_authority_like_cpp() + .shares_storage_like_cpp(&gameobject_authority) + ); + assert_eq!( + gameobject_authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Active + ); + assert_eq!(gameobject_lease.commit_like_cpp(), Ok(true)); + assert_eq!( + gameobject_map + .map_object_record(gameobject_guid) + .and_then(MapObjectRecord::game_object) + .unwrap() + .loot_authority_like_cpp() + .shared_snapshot_like_cpp() + .unwrap() + .loot + .coins, + 0 + ); + } + #[test] fn remove_from_map_like_cpp_unregisters_personal_phase_tracker_from_object_owner_like_cpp() { let mut map = test_map(); @@ -30885,6 +31345,7 @@ mod tests { guid(HighGuid::Player, 4590291), GameObjectOwnedLoot::new(6, 1), ); + let loot_authority = owner.loot_authority_like_cpp().clone(); assert!(owner.add_unique_use_like_cpp(guid(HighGuid::Player, 4590292))); assert!(owner.schedule_despawn_or_unsummon_like_cpp(1, 0)); @@ -30913,6 +31374,7 @@ mod tests { assert_eq!(owner_after.personal_loot_count_like_cpp(), 1); assert_eq!(owner_after.unique_user_count_like_cpp(), 1); assert_eq!(owner_after.use_times(), 1); + assert!(loot_authority.is_retired_like_cpp()); assert!(map.map_object_record(trap_guid).is_some()); } @@ -31210,6 +31672,9 @@ mod tests { let guid = gameobject.world().guid(); gameobject.set_respawn_compatibility_mode(false); gameobject.set_represented_gameobject_data_present_like_cpp(true); + gameobject.replace_loot_authority_like_cpp(None, HashMap::new()); + let loot_authority = gameobject.loot_authority_like_cpp().clone(); + assert!(!loot_authority.is_retired_like_cpp()); map.add_map_object_record_to_map_like_cpp( MapObjectRecord::new_game_object(gameobject).unwrap(), ) @@ -31236,6 +31701,10 @@ mod tests { ); assert_eq!(map.objects_to_remove_count_like_cpp(), 1); assert_eq!(map.pool_data_like_cpp().get_spawned_objects_like_cpp(56), 0); + assert!( + loot_authority.is_retired_like_cpp(), + "queued GameObject deletion must invalidate Arc-held loot claims" + ); } #[test] diff --git a/crates/wow-network/src/accept.rs b/crates/wow-network/src/accept.rs index 21f895145..998945aca 100644 --- a/crates/wow-network/src/accept.rs +++ b/crates/wow-network/src/accept.rs @@ -20,7 +20,8 @@ use crate::group_registry::{GroupRegistry, PendingInvites}; use crate::player_registry::{GameEventQuestCompleteCommandLikeCpp, PlayerRegistry}; use crate::session_mgr::{InstanceLink, SessionManager}; use crate::world_socket::{ - AccountInfo, AccountLookup, WorldSocket, WorldSocketError, sign_enable_encryption, + AccountInfo, AccountLookup, SocketWriteFenceLikeCpp, WorldSocket, WorldSocketError, + sign_enable_encryption, }; /// C++ `World::rate_values` subset used by loot generation. @@ -191,6 +192,10 @@ pub struct SessionResources { pub login_db: Option>, pub world_db: Option>, pub guid_generator: Option>, + /// Process-wide C++ `sObjectMgr->GetGenerator()` mirror. + /// Every session must share this allocator so concurrent item creation + /// cannot select the same `item_instance.guid`. + pub item_guid_generator: Option>, pub instance_lock_mgr: Option>>, pub bank_bag_slot_prices_store: Option>, pub currency_types_store: Option>, @@ -488,6 +493,7 @@ pub struct SessionResources { /// - `AccountInfo` from the auth handshake /// - `packet_rx` — channel to receive packets from the socket /// - `send_tx` — channel to send responses back through the socket +/// - `send_write_fence` — FIFO completion fence paired with `send_tx` /// - `SessionResources` — shared resources for the session /// /// The callback should create a WorldSession and return a future that runs @@ -505,6 +511,7 @@ where AccountInfo, flume::Receiver, flume::Sender>, + SocketWriteFenceLikeCpp, Arc, ) -> Fut + Send @@ -573,7 +580,7 @@ where }; // Phase 3: Create session channels - let (pkt_rx, send_tx) = socket.create_session_channels(); + let (pkt_rx, send_tx, send_write_fence_like_cpp) = socket.create_session_channels(); // Phase 4: Split socket into read/write halves let pong_tx = send_tx.clone(); @@ -590,7 +597,13 @@ where }); // Phase 6: Spawn session update loop - let session_future = callback(account_info, pkt_rx, send_tx, res); + let session_future = callback( + account_info, + pkt_rx, + send_tx, + send_write_fence_like_cpp, + res, + ); tokio::spawn(session_future); // Phase 7: Run the encrypted read loop (blocks until disconnect) @@ -834,6 +847,7 @@ async fn handle_instance_connection( // send_rx_for_socket → instance writer reads from here let instance_link = InstanceLink { send_tx: send_tx.clone(), + send_write_fence_like_cpp: Some(socket.send_write_fence_like_cpp()), pkt_rx: Some(pkt_rx), }; diff --git a/crates/wow-network/src/group_registry.rs b/crates/wow-network/src/group_registry.rs index b1e3843ef..21a81633b 100644 --- a/crates/wow-network/src/group_registry.rs +++ b/crates/wow-network/src/group_registry.rs @@ -282,7 +282,8 @@ pub struct GroupInfo { pub members: Vec, /// C++ `Group::m_memberSlots` represented metadata. pub member_slots: Vec, - /// 0=FreeForAll, 1=RoundRobin, 2=MasterLoot, 3=GroupLoot, 4=NeedBeforeGreed + /// C++ `LootMethod` (`Loot.h`): 0=FreeForAll, 1=RoundRobin, + /// 2=MasterLoot, 3=GroupLoot, 4=NeedBeforeGreed, 5=PersonalLoot. pub loot_method: u8, pub looter_guid: ObjectGuid, pub loot_threshold: u8, diff --git a/crates/wow-network/src/lib.rs b/crates/wow-network/src/lib.rs index de953b6a0..db2e62db2 100644 --- a/crates/wow-network/src/lib.rs +++ b/crates/wow-network/src/lib.rs @@ -34,14 +34,22 @@ pub use group_registry::{ register_group_db_store_id_like_cpp, tick_all_group_ready_checks_like_cpp, }; pub use player_registry::{ - ApplyCreatureMeleeDamageLikeCppCommand, CreatureAttackStartLikeCppCommand, - GameEventQuestCompleteClientOutcomeLikeCpp, GameEventQuestCompleteCommandLikeCpp, - GameEventQuestCompleteResponseLikeCpp, KickLikeCppCommand, LootRollStoreWinnerCommand, - LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, PlayerBroadcastInfo, - PlayerRegistry, RefreshVisibleWorldCreaturesLikeCppCommand, ResetSeasonalQuestStatusCommand, - SendAddonIfRegisteredLikeCppCommand, SendIfVisibleLikeCppCommand, - SendPartyUpdateLikeCppCommand, SendVisibleObjectValuesUpdateCommand, SessionCommand, - WorldSessionShutdownFlushLikeCppCommand, WorldSessionShutdownFlushResultLikeCpp, + ApplyCreatureMeleeDamageLikeCppCommand, ApplyLootMoneyLikeCppCommand, + ApplyLootMoneyResultLikeCpp, CreatureAttackStartLikeCppCommand, + DurableLootMoneyAdmissionClosedLikeCpp, DurableLootMoneyCompletionLikeCpp, + DurableLootMoneyPersistenceGuardLikeCpp, DurableLootMoneyPersistenceTrackerLikeCpp, + DurableLootMoneySaveFenceLikeCpp, GameEventQuestCompleteClientOutcomeLikeCpp, + GameEventQuestCompleteCommandLikeCpp, GameEventQuestCompleteResponseLikeCpp, + KickLikeCppCommand, LootRollCommandIdentityLikeCpp, LootRollStoreWinnerCommand, + LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, + NotifyLootMoneyRemovedLikeCppCommand, PlayerBroadcastInfo, PlayerRegistry, + RefreshVisibleWorldCreaturesLikeCppCommand, ResetSeasonalQuestStatusCommand, + SendAddonIfRegisteredLikeCppCommand, SendCreatureLootReleaseValuesUpdateLikeCppCommand, + SendIfVisibleLikeCppCommand, SendPartyUpdateLikeCppCommand, SendRealmPacketLikeCppCommand, + SendVisibleObjectValuesUpdateCommand, SessionCommand, WorldSessionShutdownFlushLikeCppCommand, + WorldSessionShutdownFlushResultLikeCpp, }; pub use session_mgr::{InstanceLink, SessionManager}; -pub use world_socket::{AccountInfo, SocketReader, SocketWriter, WorldSocket, WorldSocketError}; +pub use world_socket::{ + AccountInfo, SocketReader, SocketWriteFenceLikeCpp, SocketWriter, WorldSocket, WorldSocketError, +}; diff --git a/crates/wow-network/src/player_registry.rs b/crates/wow-network/src/player_registry.rs index 7e8766f28..6ed74d862 100644 --- a/crates/wow-network/src/player_registry.rs +++ b/crates/wow-network/src/player_registry.rs @@ -11,8 +11,13 @@ use dashmap::DashMap; use std::collections::{HashMap, HashSet}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64}, +}; use std::time::Instant; use wow_core::{ObjectGuid, Position}; +use wow_loot::{LootClaimLease, OwnedLootAuthority}; use wow_packet::packets::loot::LootEntry; use wow_packet::packets::party::{ PartyMemberAuraState, PartyMemberPetStats, PartyMemberPhaseStates, PartyUpdate, @@ -24,12 +29,15 @@ pub enum SessionCommand { WorldSessionShutdownFlushLikeCpp(WorldSessionShutdownFlushLikeCppCommand), ApplyCreatureMeleeDamageLikeCpp(ApplyCreatureMeleeDamageLikeCppCommand), CreatureAttackStartLikeCpp(CreatureAttackStartLikeCppCommand), + ApplyLootMoneyLikeCpp(ApplyLootMoneyLikeCppCommand), + NotifyLootMoneyRemovedLikeCpp(NotifyLootMoneyRemovedLikeCppCommand), MasterLootGive(MasterLootGiveCommand), LootRollStoreWinner(LootRollStoreWinnerCommand), LootRollVote(LootRollVoteCommand), ResetSeasonalQuestStatus(ResetSeasonalQuestStatusCommand), SendVisibleObjectValuesUpdate(SendVisibleObjectValuesUpdateCommand), RefreshVisibleWorldCreaturesLikeCpp(RefreshVisibleWorldCreaturesLikeCppCommand), + SendCreatureLootReleaseValuesUpdateLikeCpp(SendCreatureLootReleaseValuesUpdateLikeCppCommand), RefreshVisibleGameobjectsOrSpellClicksLikeCpp, SyncGatheringNodeGameobjectStateAndRefreshLikeCpp( SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, @@ -41,6 +49,13 @@ pub enum SessionCommand { /// Deliver `PartyUpdate` from the receiver's own session so C++ /// `Player::NextGroupUpdateSequenceNumber` is consumed per player. SendPartyUpdateLikeCpp(SendPartyUpdateLikeCppCommand), + /// Deliver an already-serialized packet on the receiver's realm socket. + /// + /// C++ assigns party-control packets such as `SMSG_PARTY_INVITE` to + /// `CONNECTION_TYPE_REALM`. The shared player registry intentionally owns + /// only the primary (instance after ConnectTo) packet sender, so remote + /// realm delivery must be executed by the target session itself. + SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand), /// Apply C++ `Group::Disband`/`Group::RemoveMember` session-local cleanup /// for a connected remote member. ApplyGroupRemovalLikeCpp(ApplyGroupRemovalLikeCppCommand), @@ -121,10 +136,21 @@ pub struct WorldSessionShutdownFlushResultLikeCpp { /// Payload for C++ `Group::SendUpdateToPlayer`. #[derive(Clone, Debug)] pub struct SendPartyUpdateLikeCppCommand { + /// Character that owned the registry entry when the command was queued. + /// A `WorldSession` survives character logout, so the receiver must reject + /// a delayed update after that session selects another character. + pub recipient: ObjectGuid, pub party_update: PartyUpdate, pub member_full_state_packets: Vec>, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SendRealmPacketLikeCppCommand { + /// Character that owned the registry entry when the packet was queued. + pub recipient: ObjectGuid, + pub packet_bytes: Vec, +} + /// Payload for [`SessionCommand::ApplyGroupRemovalLikeCpp`]. /// /// C++ `Group::RemoveMember` and `Group::Disband` mutate the connected @@ -219,6 +245,20 @@ pub struct SendIfVisibleLikeCppCommand { pub packet_bytes: Vec, } +/// Carries C++ `WorldSession::DoLootRelease`'s forced creature DynamicFlags +/// update to the receiving session. The receiver must apply its own +/// `Player::isAllowedToLoot` view before serialising the VALUES packet; the +/// source session cannot safely predict session-local state such as a pending +/// instance bind. +#[derive(Clone, Debug)] +pub struct SendCreatureLootReleaseValuesUpdateLikeCppCommand { + pub creature_guid: ObjectGuid, + pub map_id: u16, + pub instance_id: u32, + pub unit_values_update: wow_packet::packets::update::UnitDataValuesDeltaUpdate, + pub authority: Option, +} + /// Payload for [`SessionCommand::SendAddonIfRegisteredLikeCpp`]. #[derive(Clone, Debug)] pub struct SendAddonIfRegisteredLikeCppCommand { @@ -421,6 +461,9 @@ pub struct MasterLootGiveCommand { pub loot_list_id: u8, pub dungeon_encounter_id: u32, pub entry: LootEntry, + /// Cloneable claim ownership. If the requester times out, the command's + /// clone keeps the slot reserved until this target commits or drops it. + pub claim: Option, pub result_tx: flume::Sender, } @@ -437,10 +480,388 @@ pub struct LootRollStoreWinnerCommand { pub loot_obj: ObjectGuid, pub loot_list_id: u8, pub dungeon_encounter_id: u32, - pub entry: LootEntry, + /// One normal roll award or the complete generated disenchant result. + /// + /// C++ `LootRoll::Finish` generates every disenchant material before it + /// starts storing them. Keeping the generated result in one command lets + /// the target session preflight and persist the complete result in one + /// transaction instead of acknowledging one material at a time. + pub entries: Vec, + pub is_disenchant: bool, + /// See [`MasterLootGiveCommand::claim`]. + pub claim: Option, pub result_tx: flume::Sender, } +/// Session-local application of one already-durable C++ shared-loot payout. +/// +/// The source-side detached persistence worker creates this command only after +/// the complete group transaction and the object-owned money claim have both +/// committed. No target acknowledgement participates in the persistence +/// decision, so two sessions looting concurrently cannot wait on each other's +/// command loops. +#[derive(Clone, Debug)] +pub struct ApplyLootMoneyLikeCppCommand { + pub recipient: ObjectGuid, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + /// C++ share advertised by `SMSG_LOOT_MONEY_NOTIFY`. + pub amount: u64, + /// Delta that the locked character row actually accepted. This is shared + /// with the detached worker so command delivery order cannot make runtime + /// gold disagree with the durable cap decision. + pub durable_applied_amount: Arc, + /// Target character's directly shared persistence fence. The detached + /// worker registers it before opening SQL; no command acknowledgement is + /// part of the transaction decision. + pub durable_persistence_tracker: Arc, + pub sole_looter: bool, + /// Exact backing allocation whose scope epoch is recorded below. Epochs + /// restart in a newly allocated authority, so the number alone is not an + /// object-lifetime identity. + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + /// True only if the object-owned claim committed for the recorded generation. + /// SQL may already be durable when lifecycle replacement makes this false; + /// the payout still applies, but must not touch the replacement loot pool. + pub authority_committed: Arc, + /// This recipient was also viewing the pool, so its own session emits + /// `CoinRemoved` immediately before `LootMoneyNotify`. + pub send_coin_removed: Arc, + /// The source handler may apply its own share immediately after awaiting + /// the detached worker while the same command remains queued as the + /// cancellation fallback. This gate makes those two paths exact-once. + pub applied: Arc, + /// Packet/criteria publication can occur after a save fence has already + /// reconciled the durable delta. Keep its exact-once gate separate from + /// the balance mutation gate. + pub published: Arc, +} + +/// Durable result of one character-row money mutation. +/// +/// The detached SQL worker records this before publishing its session command. +/// Both paths share [`Self::applied`], so logout reconciliation and normal +/// command delivery cannot apply the same durable delta twice. +#[derive(Clone, Debug)] +pub struct DurableLootMoneyCompletionLikeCpp { + pub durable_money_before: u64, + pub durable_money_after: u64, + pub durable_applied_amount: u64, + pub applied: Arc, +} + +#[derive(Debug, Default)] +struct DurableLootMoneyPersistenceStateLikeCpp { + in_flight: usize, + completions: Vec, + indeterminate: bool, + admission_closed: bool, + permanently_closed: bool, + active_save_fences: usize, +} + +/// Per-character fence for detached loot-money transactions. +/// +/// A tracker is published in [`PlayerBroadcastInfo`] and registered directly +/// by a source session before it opens a transaction for every recipient. This +/// avoids command acknowledgements (and their A↔B deadlocks), while allowing a +/// target session to wait for and reconcile durable completions before an +/// absolute `Player::SaveToDB` money write. +#[derive(Debug)] +pub struct DurableLootMoneyPersistenceTrackerLikeCpp { + state: Mutex, + changed: tokio::sync::watch::Sender, + money_mutation_serial: Arc>, +} + +impl Default for DurableLootMoneyPersistenceTrackerLikeCpp { + fn default() -> Self { + let (changed, _) = tokio::sync::watch::channel(0); + Self { + state: Mutex::new(DurableLootMoneyPersistenceStateLikeCpp::default()), + changed, + money_mutation_serial: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +impl DurableLootMoneyPersistenceTrackerLikeCpp { + /// Serialize DB money mutations for this character across stored-item and + /// group payouts. Multi-recipient workers acquire these locks in sorted + /// GUID order before taking the matching character-row locks. + pub async fn lock_money_mutation_like_cpp(&self) -> tokio::sync::OwnedMutexGuard<()> { + Arc::clone(&self.money_mutation_serial).lock_owned().await + } + + #[must_use] + pub fn begin_like_cpp( + self: &Arc, + ) -> Result + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.admission_closed { + return Err(DurableLootMoneyAdmissionClosedLikeCpp); + } + state.in_flight += 1; + drop(state); + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + Ok(DurableLootMoneyPersistenceGuardLikeCpp { + tracker: Arc::clone(self), + resolved: false, + }) + } + + /// Close admission before observing `in_flight` and keep it closed across + /// snapshot plus SQL commit. Either a source registers first and the save + /// waits, or the save closes first and the source fails before BEGIN. + #[must_use] + pub fn close_admission_for_save_like_cpp(self: &Arc) -> DurableLootMoneySaveFenceLikeCpp { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.active_save_fences = state.active_save_fences.saturating_add(1); + state.admission_closed = true; + DurableLootMoneySaveFenceLikeCpp { + tracker: Arc::clone(self), + } + } + + /// Logout closes the old registry-published tracker permanently. A source + /// that cloned it before unregister cannot mutate the character after its + /// final save. + pub fn close_admission_permanently_like_cpp(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.admission_closed = true; + state.permanently_closed = true; + } + + pub async fn wait_until_idle_like_cpp(&self) { + let mut changed = self.changed.subscribe(); + loop { + if self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .in_flight + == 0 + { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + /// Returns every completion whose shared exact-once gate is still open. + /// Applied entries are pruned only after their CAS is observable. + #[must_use] + pub fn pending_completions_like_cpp(&self) -> Vec { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.completions.retain(|completion| { + !completion + .applied + .load(std::sync::atomic::Ordering::Acquire) + }); + state.completions.clone() + } + + #[must_use] + pub fn is_indeterminate_like_cpp(&self) -> bool { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .indeterminate + } + + pub fn mark_indeterminate_like_cpp(&self) { + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.indeterminate = true; + state.admission_closed = true; + state.permanently_closed = true; + } + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } + + fn finish_like_cpp( + &self, + completion: Option, + indeterminate: bool, + ) { + { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + debug_assert!(state.in_flight != 0); + state.in_flight = state.in_flight.saturating_sub(1); + if let Some(completion) = completion { + state.completions.push(completion); + } + state.indeterminate |= indeterminate; + if indeterminate { + state.admission_closed = true; + state.permanently_closed = true; + } + } + let _ = self.changed.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DurableLootMoneyAdmissionClosedLikeCpp; + +impl std::fmt::Display for DurableLootMoneyAdmissionClosedLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("character money persistence admission is closed") + } +} + +impl std::error::Error for DurableLootMoneyAdmissionClosedLikeCpp {} + +#[derive(Debug)] +pub struct DurableLootMoneySaveFenceLikeCpp { + tracker: Arc, +} + +impl Drop for DurableLootMoneySaveFenceLikeCpp { + fn drop(&mut self) { + let mut state = self + .tracker + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + debug_assert!(state.active_save_fences != 0); + state.active_save_fences = state.active_save_fences.saturating_sub(1); + if state.active_save_fences == 0 && !state.permanently_closed { + state.admission_closed = false; + } + } +} + +/// RAII registration for one recipient's durable money mutation. +#[derive(Debug)] +pub struct DurableLootMoneyPersistenceGuardLikeCpp { + tracker: Arc, + resolved: bool, +} + +impl DurableLootMoneyPersistenceGuardLikeCpp { + pub fn commit_like_cpp(&mut self, completion: DurableLootMoneyCompletionLikeCpp) { + if self.resolved { + return; + } + self.resolved = true; + self.tracker.finish_like_cpp(Some(completion), false); + } + + /// A COMMIT was attempted but its outcome could not be reconciled. The + /// target must skip absolute money saves until it disconnects/reloads. + pub fn mark_indeterminate_like_cpp(&mut self) { + if self.resolved { + return; + } + self.resolved = true; + self.tracker.finish_like_cpp(None, true); + } +} + +impl Drop for DurableLootMoneyPersistenceGuardLikeCpp { + fn drop(&mut self) { + if !self.resolved { + self.tracker.finish_like_cpp(None, false); + self.resolved = true; + } + } +} + +/// Durable `Loot::NotifyMoneyRemoved` delivery for an active viewer that is +/// not itself a payout recipient. +#[derive(Clone, Debug)] +pub struct NotifyLootMoneyRemovedLikeCppCommand { + pub recipient: ObjectGuid, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub authority_committed: Arc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApplyLootMoneyResultLikeCpp { + Applied, + PersistenceFailed, + TargetMismatch, +} + +#[derive(Clone, Debug)] +pub struct LootRollCommandIdentityLikeCpp { + loot_obj: ObjectGuid, + loot_list_id: u8, + authority: OwnedLootAuthority, + authority_generation: u64, + /// Pointer-identity equivalent of the exact C++ `LootRoll*` registered in + /// `Player::m_lootRolls` (`Player.cpp::GetLootRoll` / `RemoveLootRoll` and + /// `Loot.cpp::LootRoll::~LootRoll`). A replacement roll may reuse both + /// packet key and loot generation, so those values alone cannot reject a + /// queued old vote. + roll_instance: Arc<()>, +} + +impl LootRollCommandIdentityLikeCpp { + #[must_use] + pub fn new_like_cpp( + loot_obj: ObjectGuid, + loot_list_id: u8, + authority: OwnedLootAuthority, + authority_generation: u64, + ) -> Self { + Self { + loot_obj, + loot_list_id, + authority, + authority_generation, + roll_instance: Arc::new(()), + } + } + + #[must_use] + pub fn matches_key_like_cpp(&self, loot_obj: ObjectGuid, loot_list_id: u8) -> bool { + self.loot_obj == loot_obj && self.loot_list_id == loot_list_id + } + + /// Mirrors the lifetime identity of the C++ `LootRoll*`, while also + /// fail-closing if an authority allocation or generation was replaced. + #[must_use] + pub fn is_exact_roll_like_cpp(&self, other: &Self) -> bool { + self.matches_key_like_cpp(other.loot_obj, other.loot_list_id) + && self.authority_generation == other.authority_generation + && self.authority.shares_storage_like_cpp(&other.authority) + && Arc::ptr_eq(&self.roll_instance, &other.roll_instance) + } +} + #[derive(Clone, Debug)] pub struct LootRollVoteCommand { pub voter_guid: ObjectGuid, @@ -448,6 +869,9 @@ pub struct LootRollVoteCommand { pub loot_list_id: u8, pub roll_type: u8, pub pass_on_group_loot: bool, + /// Immutable enqueue-time identity of the exact roll instance. C++ queues + /// no cross-session surrogate; its player retains the exact `LootRoll*`. + pub roll_identity: LootRollCommandIdentityLikeCpp, } /// Information stored for each active player session. @@ -472,8 +896,12 @@ pub struct PlayerBroadcastInfo { pub send_tx: flume::Sender>, /// Channel used for C++-style cross-session state mutations. pub command_tx: flume::Sender, - /// Represented pending loot-roll keys owned by this session. - pub active_loot_rolls: Vec<(ObjectGuid, u8)>, + /// Per-character durable loot-money fence used by remote source sessions. + pub durable_loot_money_tracker_like_cpp: Arc, + /// Exact represented pending loot-roll identities owned by this session. + /// The packet key may be reused, so cross-session routing must clone this + /// identity into the queued command rather than publishing keys alone. + pub active_loot_rolls: Vec, /// Current `Player::GetPassOnGroupLoot()` state for group/NBG roll startup. pub pass_on_group_loot: bool, /// Represented `Player::GetSkillValue(SKILL_ENCHANTING)` used by group-roll disenchant masks. @@ -631,6 +1059,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -784,4 +1213,96 @@ mod tests { assert_eq!(cmd.map_id, 571); assert_eq!(cmd.instance_id, 4); } + + #[tokio::test] + async fn durable_money_save_fence_closes_admission_then_waits_for_prior_worker() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let worker = tracker.begin_like_cpp().unwrap(); + let save_fence = tracker.close_admission_for_save_like_cpp(); + + assert!(tracker.begin_like_cpp().is_err()); + let wait_tracker = Arc::clone(&tracker); + let waiter = tokio::spawn(async move { + wait_tracker.wait_until_idle_like_cpp().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + + drop(worker); + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("prior durable worker must release the save wait") + .unwrap(); + assert!(tracker.begin_like_cpp().is_err()); + + drop(save_fence); + assert!(tracker.begin_like_cpp().is_ok()); + } + + #[test] + fn durable_money_permanent_logout_fence_never_reopens() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let save_fence = tracker.close_admission_for_save_like_cpp(); + tracker.close_admission_permanently_like_cpp(); + drop(save_fence); + + assert!(tracker.begin_like_cpp().is_err()); + } + + #[test] + fn overlapping_durable_money_save_fences_reopen_only_after_last_drop() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let first = tracker.close_admission_for_save_like_cpp(); + let second = tracker.close_admission_for_save_like_cpp(); + + drop(first); + assert!( + tracker.begin_like_cpp().is_err(), + "dropping the first fence must not reopen admission under the second" + ); + drop(second); + assert!(tracker.begin_like_cpp().is_ok()); + } + + #[test] + fn durable_money_completion_uses_one_shared_exact_once_gate() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let mut worker = tracker.begin_like_cpp().unwrap(); + let applied = Arc::new(AtomicBool::new(false)); + worker.commit_like_cpp(DurableLootMoneyCompletionLikeCpp { + durable_money_before: 40, + durable_money_after: 47, + durable_applied_amount: 7, + applied: Arc::clone(&applied), + }); + + let pending = tracker.pending_completions_like_cpp(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].durable_money_before, 40); + assert_eq!(pending[0].durable_money_after, 47); + assert_eq!(pending[0].durable_applied_amount, 7); + assert!( + pending[0] + .applied + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + ); + assert!(tracker.pending_completions_like_cpp().is_empty()); + } + + #[test] + fn indeterminate_money_outcome_permanently_closes_admission_until_relogin() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + let fence = tracker.close_admission_for_save_like_cpp(); + tracker.mark_indeterminate_like_cpp(); + drop(fence); + + assert!(tracker.is_indeterminate_like_cpp()); + assert!(tracker.begin_like_cpp().is_err()); + } } diff --git a/crates/wow-network/src/session_mgr.rs b/crates/wow-network/src/session_mgr.rs index 0147acd1d..185e5b90d 100644 --- a/crates/wow-network/src/session_mgr.rs +++ b/crates/wow-network/src/session_mgr.rs @@ -27,6 +27,9 @@ struct PendingEntry { pub struct InstanceLink { /// New send channel — session writes to this, instance socket reads from it. pub send_tx: flume::Sender>, + /// Write fence paired with `send_tx`. `None` is accepted only by local + /// fallback/tests that keep using the already-installed physical socket. + pub send_write_fence_like_cpp: Option, /// Packet receiver — session reads decoded packets from the instance socket here. /// `None` in fallback mode (direct login on realm socket — keep existing packet_rx). pub pkt_rx: Option>, diff --git a/crates/wow-network/src/world_socket.rs b/crates/wow-network/src/world_socket.rs index 1d515009c..5cadc777a 100644 --- a/crates/wow-network/src/world_socket.rs +++ b/crates/wow-network/src/world_socket.rs @@ -17,13 +17,14 @@ //! 7. Client sends `EnterEncryptedModeAck` //! 8. All subsequent packets are AES-128-GCM encrypted +use std::collections::HashMap; use std::fs; use std::future::Future; use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use bytes::BytesMut; @@ -78,6 +79,113 @@ const INSTANCE_CONNECTION_ID_LIKE_CPP: u32 = 1; static PACKET_DUMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); +// Internal transport marker. A real server packet can never use NULL_OPCODE, +// so this byte sequence cannot collide with valid packet bytes. +const WRITE_FENCE_PREFIX_LIKE_CPP: [u8; 8] = [0, 0, b'R', b'C', b'F', b'E', b'N', b'C']; + +#[derive(Default)] +struct SocketWriteFenceStateLikeCpp { + next_id: AtomicU64, + pending: Mutex>>, +} + +/// Cancellation guard for one pending marker acknowledgement. +/// +/// A session handler can be dropped while `send_async` is waiting for bounded +/// channel capacity. In that case no marker will ever reach the writer, so +/// the pending sender must be removed by `Drop` rather than by an async tail. +struct PendingSocketWriteFenceLikeCpp { + state: Arc, + id: u64, +} + +impl Drop for PendingSocketWriteFenceLikeCpp { + fn drop(&mut self) { + self.state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&self.id); + } +} + +/// FIFO write fence for one physical world socket. +/// +/// TrinityCore records/sends `SendDirectMessage` synchronously, while RustyCore +/// has independent realm and instance writer tasks. A fence marker is queued +/// after a realm packet and acknowledged by that socket's writer only after it +/// has fully written every earlier packet. This lets a caller defer an instance +/// update without relying on scheduler timing or `flume::Sender::is_empty()`. +#[derive(Clone, Default)] +pub struct SocketWriteFenceLikeCpp { + state: Arc, +} + +impl SocketWriteFenceLikeCpp { + fn marker_like_cpp(id: u64) -> Vec { + let mut marker = Vec::with_capacity(16); + marker.extend_from_slice(&WRITE_FENCE_PREFIX_LIKE_CPP); + marker.extend_from_slice(&id.to_le_bytes()); + marker + } + + fn marker_id_like_cpp(data: &[u8]) -> Option { + if data.len() != 16 || data[..8] != WRITE_FENCE_PREFIX_LIKE_CPP { + return None; + } + Some(u64::from_le_bytes(data[8..16].try_into().ok()?)) + } + + fn acknowledge_marker_like_cpp(&self, data: &[u8]) -> bool { + let Some(id) = Self::marker_id_like_cpp(data) else { + return false; + }; + let acknowledgement = self + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&id); + if let Some(acknowledgement) = acknowledgement { + let _ = acknowledgement.send(()); + } + true + } + + /// Wait until this socket's writer has written every packet queued before + /// the fence. Returns false if the channel or writer cannot acknowledge the + /// bounded fence; callers must keep durable gameplay state committed. + pub async fn wait_for_prior_packets_written_like_cpp( + &self, + send_tx: &flume::Sender>, + timeout: Duration, + ) -> bool { + let id = self.state.next_id.fetch_add(1, Ordering::Relaxed); + let (acknowledgement_tx, acknowledgement_rx) = tokio::sync::oneshot::channel(); + self.state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(id, acknowledgement_tx); + let _pending = PendingSocketWriteFenceLikeCpp { + state: self.state.clone(), + id, + }; + + if !matches!( + tokio::time::timeout(timeout, send_tx.send_async(Self::marker_like_cpp(id))).await, + Ok(Ok(())) + ) { + return false; + } + + match tokio::time::timeout(timeout, acknowledgement_rx).await { + Ok(Ok(())) => true, + Ok(Err(_)) | Err(_) => false, + } + } +} + // Build-specific auth seeds are loaded from the `build_info` DB table at startup // and stored in SessionResources. They are passed to AccountInfo during lookup. @@ -272,6 +380,8 @@ pub struct WorldSocket { // Outbound channel — receives serialized bytes from WorldSession send_rx: Option>>, + // Sidecar state for internal FIFO markers acknowledged by SocketWriter. + send_write_fence_like_cpp: SocketWriteFenceLikeCpp, // Persistent compression stream for direct encrypted sends. compressor: compression::PacketCompressor, @@ -317,6 +427,7 @@ impl WorldSocket { session_key: None, session_tx: None, send_rx: None, + send_write_fence_like_cpp: SocketWriteFenceLikeCpp::default(), compressor: compression::PacketCompressor::new(), state: SocketState::Uninitialized, account_info: None, @@ -629,6 +740,11 @@ impl WorldSocket { self.send_rx = Some(rx); } + /// Clone the write fence paired with this physical socket's send channel. + pub fn send_write_fence_like_cpp(&self) -> SocketWriteFenceLikeCpp { + self.send_write_fence_like_cpp.clone() + } + // ── Packet I/O ──────────────────────────────────────────────── /// Send a server packet WITHOUT encryption (used during handshake). @@ -852,18 +968,23 @@ impl WorldSocket { /// Set up session channels and return the receivers/sender for session creation. /// - /// Returns `(packet_rx, send_tx)` — the session reads packets from `packet_rx` - /// and writes responses via `send_tx`. + /// Returns `(packet_rx, send_tx, write_fence)` — the session reads packets + /// from `packet_rx`, writes responses via `send_tx`, and can fence that + /// physical writer when cross-connection C++ ordering requires it. pub fn create_session_channels( &mut self, - ) -> (flume::Receiver, flume::Sender>) { + ) -> ( + flume::Receiver, + flume::Sender>, + SocketWriteFenceLikeCpp, + ) { let (pkt_tx, pkt_rx) = flume::bounded(256); let (send_tx, send_rx) = flume::bounded(256); self.session_tx = Some(pkt_tx); self.send_rx = Some(send_rx); - (pkt_rx, send_tx) + (pkt_rx, send_tx, self.send_write_fence_like_cpp()) } /// Run the encrypted read loop, forwarding packets to the session channel. @@ -992,6 +1113,7 @@ impl WorldSocket { writer: write_half, crypt: WorldCrypt::new_with_server_counter(&encrypt_key, self.unencrypted_packets_sent), send_rx, + send_write_fence_like_cpp: self.send_write_fence_like_cpp, addr: self.addr, connection_id: self.connection_id, compressor: compression::PacketCompressor::new(), @@ -1156,6 +1278,7 @@ pub struct SocketWriter { writer: tokio::net::tcp::OwnedWriteHalf, crypt: WorldCrypt, send_rx: flume::Receiver>, + send_write_fence_like_cpp: SocketWriteFenceLikeCpp, addr: SocketAddr, connection_id: u32, compressor: compression::PacketCompressor, @@ -1175,6 +1298,13 @@ impl SocketWriter { } }; + if self + .send_write_fence_like_cpp + .acknowledge_marker_like_cpp(&data) + { + continue; + } + self.write_encrypted(&data).await?; } } @@ -1378,6 +1508,132 @@ fn should_compress_server_packet_like_cpp(data: &[u8]) -> bool { mod tests { use super::*; + #[tokio::test] + async fn write_fence_acknowledges_only_after_prior_fifo_packet_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, send_rx) = flume::bounded::>(4); + let prior_packet = vec![0x23, 0x26, 0xAA]; + send_tx.send(prior_packet.clone()).unwrap(); + + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_millis(250)) + .await + }) + }; + + assert_eq!(send_rx.recv_async().await.unwrap(), prior_packet); + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + assert!(!wait_fence.is_finished()); + assert!(fence.acknowledge_marker_like_cpp(&marker)); + assert!(wait_fence.await.unwrap()); + } + + #[tokio::test] + async fn cancelled_write_fence_removes_pending_acknowledgement_like_cpp() { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, _send_rx) = flume::bounded::>(1); + send_tx.send(vec![0x23, 0x26, 0xAA]).unwrap(); + + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(30)) + .await + }) + }; + + for _ in 0..100 { + if !fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + assert_eq!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len(), + 1 + ); + + wait_fence.abort(); + assert!(wait_fence.await.unwrap_err().is_cancelled()); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); + } + + #[tokio::test] + async fn cancelled_after_marker_dequeued_still_consumes_marker_without_null_opcode_write_like_cpp() + { + let fence = SocketWriteFenceLikeCpp::default(); + let (send_tx, send_rx) = flume::bounded::>(1); + let wait_fence = { + let fence = fence.clone(); + let send_tx = send_tx.clone(); + tokio::spawn(async move { + fence + .wait_for_prior_packets_written_like_cpp(&send_tx, Duration::from_secs(30)) + .await + }) + }; + + // The writer has already dequeued the internal marker. Cancellation + // now removes only the pending oneshot; it cannot remove this byte + // sequence from the writer's local variable. + let marker = send_rx.recv_async().await.unwrap(); + assert!(SocketWriteFenceLikeCpp::marker_id_like_cpp(&marker).is_some()); + assert_eq!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len(), + 1 + ); + wait_fence.abort(); + assert!(wait_fence.await.unwrap_err().is_cancelled()); + assert!( + fence + .state + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + ); + + // This is the exact first branch in `SocketWriter::run`: marker + // recognition is independent of whether an acknowledgement sender is + // still pending, so the bytes are consumed by `continue` and can + // never reach `write_encrypted` as NULL_OPCODE. + let mut bytes_passed_to_writer = Vec::new(); + if !fence.acknowledge_marker_like_cpp(&marker) { + bytes_passed_to_writer.push(marker); + } + assert!(bytes_passed_to_writer.is_empty()); + } + #[test] fn hex_conversion() { let bytes = hex_to_bytes("DEADBEEF"); diff --git a/crates/wow-packet/Cargo.toml b/crates/wow-packet/Cargo.toml index 9b8d84041..d124ac5cc 100644 --- a/crates/wow-packet/Cargo.toml +++ b/crates/wow-packet/Cargo.toml @@ -13,4 +13,5 @@ tracing = { workspace = true } wow-core = { workspace = true } wow-constants = { workspace = true } wow-movement = { workspace = true } +wow-loot = { workspace = true } thiserror = { workspace = true } diff --git a/crates/wow-packet/src/packets/loot.rs b/crates/wow-packet/src/packets/loot.rs index dc8172770..07ec613fa 100644 --- a/crates/wow-packet/src/packets/loot.rs +++ b/crates/wow-packet/src/packets/loot.rs @@ -7,6 +7,7 @@ use wow_constants::{ClientOpcodes, ServerOpcodes}; use wow_core::ObjectGuid; +pub use wow_loot::{CreatureLoot, LootEntry, LootEntryFlags, NotNormalLootItem}; use crate::packets::item::ItemInstance; use crate::world_packet::{PacketError, WorldPacket}; @@ -25,6 +26,7 @@ pub const LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP: u8 = LOOT_ERROR_NO_LOOT pub const LOOT_TYPE_NONE_LIKE_CPP: u8 = 0; pub const LOOT_TYPE_CORPSE_LIKE_CPP: u8 = 1; +pub const LOOT_TYPE_PICKPOCKETING_LIKE_CPP: u8 = 2; pub const LOOT_TYPE_FISHING_LIKE_CPP: u8 = 3; pub const LOOT_TYPE_DISENCHANTING_LIKE_CPP: u8 = 4; pub const LOOT_TYPE_ITEM_LIKE_CPP: u8 = 5; @@ -582,115 +584,8 @@ impl ServerPacket for MasterLootCandidateList { // ── In-memory loot tracking ────────────────────────────────────── -/// Server-side loot state for one dead creature. -#[derive(Debug, Clone)] -pub struct CreatureLoot { - pub loot_guid: ObjectGuid, - pub coins: u32, - pub unlooted_count: u8, - pub loot_type: u8, - pub dungeon_encounter_id: u32, - pub loot_method: u8, - pub loot_master: ObjectGuid, - pub round_robin_player: ObjectGuid, - pub player_ffa_items: Vec<(ObjectGuid, Vec)>, - pub players_looting: Vec, - pub allowed_looters: Vec, - pub items: Vec, - pub looted_by_player: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NotNormalLootItem { - pub loot_list_id: u8, - pub is_looted: bool, -} - -#[derive(Debug, Clone)] -pub struct LootEntry { - pub loot_list_id: u8, - pub item_id: u32, - pub quantity: u32, - pub random_properties_id: i32, - pub random_properties_seed: i32, - pub item_context: u8, - pub flags: LootEntryFlags, - pub allowed_looters: Vec, - pub roll_winner: ObjectGuid, - pub ffa_looted_by: Vec, - pub taken: bool, -} - pub const LOOT_SLOT_TYPE_OWNER_LIKE_CPP: u8 = 4; -impl LootEntry { - pub fn free_for_all_ui_type_like_cpp(&self) -> u8 { - LOOT_SLOT_TYPE_OWNER_LIKE_CPP - } - - pub fn is_over_threshold_like_cpp(&self) -> bool { - !self.flags.under_threshold && !self.flags.freeforall - } - - pub fn visible_in_represented_free_for_all_view_like_cpp(&self, player: ObjectGuid) -> bool { - !self.is_looted_for_player_like_cpp(player) && self.has_allowed_looter_like_cpp(player) - } - - pub fn add_allowed_looter_like_cpp(&mut self, player: ObjectGuid) { - if !player.is_empty() && !self.allowed_looters.contains(&player) { - self.allowed_looters.push(player); - } - } - - pub fn has_allowed_looter_like_cpp(&self, player: ObjectGuid) -> bool { - self.allowed_looters.contains(&player) - } - - pub fn roll_winner_allows_like_cpp(&self, player: ObjectGuid) -> bool { - self.roll_winner.is_empty() || self.roll_winner == player - } - - pub fn is_looted_for_player_like_cpp(&self, player: ObjectGuid) -> bool { - if self.flags.freeforall { - self.ffa_looted_by.contains(&player) - } else { - self.taken - } - } - - pub fn mark_looted_for_player_like_cpp(&mut self, player: ObjectGuid) { - if self.flags.freeforall { - if !player.is_empty() && !self.ffa_looted_by.contains(&player) { - self.ffa_looted_by.push(player); - } - } else { - self.taken = true; - } - } - - pub fn fully_looted_like_cpp(&self) -> bool { - if self.flags.freeforall { - !self.allowed_looters.is_empty() - && self - .allowed_looters - .iter() - .all(|player| self.ffa_looted_by.contains(player)) - } else { - self.taken - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct LootEntryFlags { - pub follow_loot_rules: bool, - pub freeforall: bool, - pub blocked: bool, - pub counted: bool, - pub under_threshold: bool, - pub needs_quest: bool, -} - #[cfg(test)] mod tests { use crate::{ClientPacket, ServerPacket}; diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a07e8886a..6f6f1e896 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -3120,8 +3120,13 @@ pub enum UpdateBlock { guid: ObjectGuid, create_data: ItemCreateData, }, - /// VALUES update for an item: currently only StackCount is needed by direct inventory stores. - ItemValuesUpdate { guid: ObjectGuid, stack_count: u32 }, + /// VALUES update for an item store. `dynamic_flags` is present when the + /// same C++ `_StoreItem` call both grows a stack and binds it. + ItemValuesUpdate { + guid: ObjectGuid, + stack_count: u32, + dynamic_flags: Option, + }, /// VALUES update for a player: only changed InvSlots, VisibleItems, VirtualItems. PlayerValuesUpdate { guid: ObjectGuid, @@ -3532,9 +3537,13 @@ impl UpdateObject { create_data.bounds_radius_2d )); } - UpdateBlock::ItemValuesUpdate { guid, stack_count } => { + UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags, + } => { lines.push(format!( - "#{index:03} item_values guid={guid:?} stack_count={stack_count}" + "#{index:03} item_values guid={guid:?} stack_count={stack_count} dynamic_flags={dynamic_flags:?}" )); } UpdateBlock::PlayerValuesUpdate { @@ -4373,13 +4382,31 @@ impl UpdateObject { /// Each item gets its own block. Sent BEFORE the player CREATE packet /// so the client has item objects when it processes InvSlots. pub fn create_items(items: Vec, map_id: u16) -> Self { + Self::create_items_with_update_type(items, map_id, UpdateType::CreateObject2) + } + + /// Create inventory item blocks from C++ `Player::_StoreItem`. + /// + /// `_StoreItem` calls `Item::AddToWorld` directly and then + /// `SendUpdateToPlayer`; unlike `Map::AddToMap`, that path never raises + /// `Object::m_isNewObject`, so `BuildCreateUpdateBlockForPlayer` writes + /// `CreateObject` rather than `CreateObject2`. + pub fn create_stored_items(items: Vec, map_id: u16) -> Self { + Self::create_items_with_update_type(items, map_id, UpdateType::CreateObject) + } + + fn create_items_with_update_type( + items: Vec, + map_id: u16, + update_type: UpdateType, + ) -> Self { let num = items.len() as u32; let blocks = items .into_iter() .map(|data| { let guid = data.item_guid; UpdateBlock::CreateItem { - update_type: UpdateType::CreateObject2, + update_type, guid, create_data: data, } @@ -4402,7 +4429,32 @@ impl UpdateObject { num_updates: 1, destroy_guids: Vec::new(), out_of_range_guids: Vec::new(), - blocks: vec![UpdateBlock::ItemValuesUpdate { guid, stack_count }], + blocks: vec![UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags: None, + }], + } + } + + /// Create the single ItemData VALUES update emitted by C++ `_StoreItem` + /// when an existing stack changes both count and binding flags. + pub fn item_stack_count_and_flags_update( + guid: ObjectGuid, + map_id: u16, + stack_count: u32, + dynamic_flags: u32, + ) -> Self { + Self { + map_id, + num_updates: 1, + destroy_guids: Vec::new(), + out_of_range_guids: Vec::new(), + blocks: vec![UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags: Some(dynamic_flags), + }], } } } @@ -4550,8 +4602,17 @@ impl ServerPacket for UpdateObject { } => { write_item_create_block(&mut blocks_buf, *update_type, guid, create_data); } - UpdateBlock::ItemValuesUpdate { guid, stack_count } => { - write_item_values_update_block(&mut blocks_buf, guid, *stack_count); + UpdateBlock::ItemValuesUpdate { + guid, + stack_count, + dynamic_flags, + } => { + write_item_values_update_block( + &mut blocks_buf, + guid, + *stack_count, + *dynamic_flags, + ); } UpdateBlock::PlayerValuesUpdate { guid, @@ -5537,13 +5598,19 @@ fn write_item_create_block( // ── VALUES update (UpdateType::Values) ───────────────────────────── -/// Write an ItemData VALUES update containing StackCount only. +/// Write an ItemData VALUES update containing StackCount and, when the store +/// binds an existing stack, DynamicFlags in the same update block. /// /// C++ refs: /// - `Item::SetCount` /// - `Object::BuildValuesUpdate` /// - `UF::ItemData::WriteUpdate` -fn write_item_values_update_block(buf: &mut WorldPacket, guid: &ObjectGuid, stack_count: u32) { +fn write_item_values_update_block( + buf: &mut WorldPacket, + guid: &ObjectGuid, + stack_count: u32, + dynamic_flags: Option, +) { buf.write_uint8(UpdateType::Values as u8); buf.write_packed_guid(guid); @@ -5551,11 +5618,19 @@ fn write_item_values_update_block(buf: &mut WorldPacket, guid: &ObjectGuid, stac val_buf.write_uint32(1 << 1); // TypeId::Item // ItemData has 43 bits: two 32-bit field blocks and a 2-bit blocks mask. - // Parent bit 0 and StackCount bit 7 are set for a count-only update. + // Parent bit 0 and StackCount bit 7 are always set. DynamicFlags bit 9 + // joins the same mask when `_StoreItem` binds the destination stack. val_buf.write_bits(0x01, 2); - val_buf.write_bits((1 << 0) | (1 << 7), 32); + let mut item_mask = (1 << 0) | (1 << 7); + if dynamic_flags.is_some() { + item_mask |= 1 << 9; + } + val_buf.write_bits(item_mask, 32); val_buf.flush_bits(); val_buf.write_int32(stack_count as i32); + if let Some(dynamic_flags) = dynamic_flags { + val_buf.write_uint32(dynamic_flags); + } let val_data = val_buf.into_data(); buf.write_uint32(val_data.len() as u32); @@ -9799,6 +9874,37 @@ mod tests { assert!(bytes.windows(2).any(|window| window == 77u16.to_le_bytes())); } + #[test] + fn stored_item_create_uses_cpp_non_map_create_type() { + let item_guid = ObjectGuid::create_item(1, 900); + let owner_guid = ObjectGuid::create_player(1, 42); + let packet = UpdateObject::create_stored_items( + vec![ItemCreateData { + item_guid, + entry_id: 700, + owner_guid, + contained_in: owner_guid, + stack_count: 1, + dynamic_flags: 0, + durability: 0, + max_durability: 0, + random_properties_seed: 0, + random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], + gems: Vec::new(), + context: 0, + container_slots: 0, + container_item_guids: [ObjectGuid::EMPTY; 36], + }], + 0, + ); + + let UpdateBlock::CreateItem { update_type, .. } = packet.blocks[0] else { + panic!("stored item packet must contain an item create block"); + }; + assert_eq!(update_type, UpdateType::CreateObject); + } + #[test] fn container_create_serializes_cpp_container_data_after_item_data() { let item_guid = ObjectGuid::create_item(1, 900); @@ -9852,6 +9958,32 @@ mod tests { assert!(bytes.windows(4).any(|window| window == 19i32.to_le_bytes())); } + #[test] + fn bound_existing_stack_serializes_count_and_flags_in_one_values_update() { + let item_guid = ObjectGuid::create_item(1, 901); + let dynamic_flags = 0x0000_0001; + let pkt = UpdateObject::item_stack_count_and_flags_update(item_guid, 0, 19, dynamic_flags); + + assert_eq!(pkt.num_updates, 1); + assert_eq!(pkt.blocks.len(), 1); + assert!(matches!( + pkt.blocks.as_slice(), + [UpdateBlock::ItemValuesUpdate { + guid, + stack_count: 19, + dynamic_flags: Some(flags), + }] if *guid == item_guid && *flags == dynamic_flags + )); + + let bytes = pkt.to_bytes(); + assert!(bytes.windows(4).any(|window| window == 19i32.to_le_bytes())); + assert!( + bytes + .windows(4) + .any(|window| window == dynamic_flags.to_le_bytes()) + ); + } + #[test] fn movement_block_default_speeds() { let mv = MovementBlock::default(); diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index 6136f75d0..e50495186 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -71,7 +71,8 @@ use crate::session::{ CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, REST_STATE_NORMAL_LIKE_CPP, REST_STATE_RAF_LINKED_LIKE_CPP, RepresentedAlterAppearanceLikeCpp, RepresentedBankItemMoveLikeCpp, RepresentedConfirmBarbersChoiceLikeCpp, - RepresentedGameObjectUseState, RepresentedHomebindLikeCpp, SpellCastMetadata, + RepresentedGameObjectUseState, RepresentedHomebindLikeCpp, + RepresentedQuestObjectiveProgressEventLikeCpp, SpellCastMetadata, }; // ── Handler registration ──────────────────────────────────────────── @@ -2069,6 +2070,21 @@ fn player_money_gain_like_cpp(current_money: u64, amount: u64) -> Option { } } +const UPD_CHARACTER_MONEY_AND_BANK_SLOTS_LIKE_CPP: &str = + "UPDATE characters SET money = ?, bankSlots = ? WHERE guid = ?"; + +fn bank_slot_purchase_update_statement_like_cpp( + player_guid: ObjectGuid, + new_money: u64, + new_bank_slot_count: u8, +) -> PreparedStatement { + let mut statement = PreparedStatement::new(UPD_CHARACTER_MONEY_AND_BANK_SLOTS_LIKE_CPP); + statement.set_u64(0, new_money); + statement.set_u8(1, new_bank_slot_count); + statement.set_u64(2, player_guid.counter() as u64); + statement +} + fn active_known_spell_for_send_like_cpp(spell_id: u32, active: u8, disabled: u8) -> Option { if spell_id > 0 && active != 0 && disabled == 0 { i32::try_from(spell_id).ok() @@ -4373,6 +4389,11 @@ impl WorldSession { // Complete logout immediately self.logout_time = None; + if let Some(player_guid) = self.player_guid() { + self.wait_for_active_loot_persistence_like_cpp().await; + self.do_loot_release_all_like_cpp(player_guid).await; + } + // Trinity clears buyback slots before SaveToDB; persisted buyback items must not survive logout. self.clear_buyback_on_logout().await; self.save_current_player_to_db_like_cpp().await; @@ -4382,10 +4403,6 @@ impl WorldSession { self.save_account_item_appearances_like_cpp().await; self.save_account_transmog_illusions_like_cpp().await; - if let Some(player_guid) = self.player_guid() { - self.close_active_loot_windows_like_cpp(player_guid); - } - // Mark character offline in DB self.mark_character_offline().await; @@ -6848,6 +6865,7 @@ impl WorldSession { let (tx, rx) = tokio::sync::oneshot::channel(); let link = wow_network::session_mgr::InstanceLink { send_tx: self.send_tx().clone(), + send_write_fence_like_cpp: None, pkt_rx: None, // None = keep using realm socket's packet_rx }; let _ = tx.send(link); @@ -10691,9 +10709,14 @@ impl WorldSession { /// CMSG_BUY_BANK_SLOT — player buys the next personal bank bag slot. /// - /// C++ ref: `WorldSession::HandleBuyBankSlotOpcode`. + /// C++ ref: `WorldSession::HandleBuyBankSlotOpcode`. C++ mutates the bank + /// slot count and money in one serialized session turn; its later + /// `Player::SaveToDB`/`SaveInventoryAndGoldToDB` persists both values from + /// that coherent state. Rust must cross SQL here because detached group + /// payouts use the character money row as their durable cap authority, so + /// persist both fields atomically before publishing either runtime field. pub async fn handle_buy_bank_slot(&mut self, buy: BuyBankSlot) { - let Some(_player_guid) = self.player_guid() else { + let Some(player_guid) = self.player_guid() else { return; }; let Some(_banker) = @@ -10717,6 +10740,30 @@ impl WorldSession { return; }; + #[cfg(test)] + let test_commit_result = self.loot_money_persistence_test_result_for_worker_like_cpp(); + #[cfg(not(test))] + let test_commit_result: Option = None; + + let char_db = if test_commit_result.is_some() { + None + } else { + let Some(char_db) = self.char_db().map(Arc::clone) else { + return; + }; + Some(char_db) + }; + + // Close payout admission before reading money. A previously admitted + // payout either completes and is reconciled first, or this purchase + // closes first and the payout retries its still-available pool. + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; + let old_money = self.player_gold_like_cpp(); if old_money < u64::from(price) { debug!( @@ -10731,20 +10778,64 @@ impl WorldSession { let new_count = u8::try_from(next_slot).unwrap_or(u8::MAX); let new_money = old_money - u64::from(price); + + let mut transaction = SqlTransaction::new(); + transaction.append_expect_rows_affected( + bank_slot_purchase_update_statement_like_cpp(player_guid, new_money, new_count), + 1, + ); + let money_persistence = if let Some(success) = test_commit_result { + if !success { + return; + } + money_persistence + } else { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db + .as_ref() + .expect("production bank-slot purchase retains its database"), + transaction, + old_money, + new_money, + "bank-slot purchase", + ) + .await + else { + return; + }; + money_persistence + }; + + // Publish both runtime fields only after the combined SQL COMMIT. Set + // the values synchronously while admission is still closed, then drop + // the fence before criteria processing can re-enter persistence. + self.set_player_gold_like_cpp(new_money); + // This helper builds a clean snapshot from current runtime and then + // marks the requested count dirty. Emit it while runtime still holds + // the old count, after the durable COMMIT but before storing the new + // count locally. self.send_player_bank_bag_slots_update_like_cpp(new_count); - if let Some(player_guid) = self.player_guid() { - self.send_packet(&UpdateObject::player_money_update( - player_guid, - self.player_map_id_like_cpp(), - new_money, - None, - )); - } self.set_player_bank_bag_slot_count_like_cpp(new_count); - self.apply_player_money_change_like_cpp(old_money, new_money) - .await; + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); self.sync_object_accessor_player(); self.sync_player_registry_state_like_cpp(); + drop(money_persistence); + + self.send_packet(&UpdateObject::player_money_update( + player_guid, + self.player_map_id_like_cpp(), + new_money, + None, + )); + self.drain_represented_quest_objective_progress_like_cpp() + .await; } /// CMSG_CHANGE_BANK_BAG_SLOT_FLAG — player toggles an ActivePlayer bank bag flag. @@ -11675,7 +11766,6 @@ impl WorldSession { Some(g) => g, None => return, }; - let realm_id = self.realm_id(); let map_id = self.player_map_id_like_cpp(); let vendor_slot = match vendor_buy_muid_to_cpp_slot(buy.muid) { Some(slot) => slot, @@ -12100,20 +12190,35 @@ impl WorldSession { return; } - let needs_new_items = store_dest.iter().any(|dest| { - let slot = (dest.pos & 0x00FF) as u8; - !self.inventory_items_like_cpp().contains_key(&slot) - }); - let mut next_item_guid = if needs_new_items { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, - } - } else { - 0 + let new_item_count = store_dest + .iter() + .filter(|dest| { + let slot = (dest.pos & 0x00FF) as u8; + !self.inventory_items_like_cpp().contains_key(&slot) + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + count = new_item_count, + "BuyItem: process-wide item GUID allocator is unavailable" + ); + self.send_buy_error( + BuyResult::CantFindItem, + Some(buy.vendor_guid), + buy.muid as u32, + ); + return; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let old_gold = self.player_gold_like_cpp(); let new_gold = old_gold.saturating_sub(buy_price); @@ -12155,9 +12260,15 @@ impl WorldSession { tx.append(upd_count); existing_updates.push((slot, inv_item.guid, new_count)); } else { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!("BuyItem: preallocated item GUID count did not match store plan"); + self.send_buy_error( + BuyResult::CantFindItem, + Some(buy.vendor_guid), + buy.muid as u32, + ); + return; + }; let mut ins_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE); ins_item.set_u64(0, db_guid); @@ -12220,35 +12331,33 @@ impl WorldSession { } self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); - if let Err(e) = char_db.commit_transaction(tx).await { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor item purchase", + ) + .await + else { self.set_player_currencies_like_cpp(currency_snapshot); - warn!("BuyItem: store transaction failed: {e}"); + warn!("BuyItem: store transaction did not commit"); self.send_buy_error( BuyResult::CantFindItem, Some(buy.vendor_guid), buy.muid as u32, ); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // The SQL transaction also owns the turn-ins, returned inventory + // stacks, currencies and refund metadata. Publish all of that runtime + // state synchronously before reopening payout admission; cancelling + // the handler after COMMIT must not leave runtime at the pre-buy state. + self.stage_player_money_change_like_cpp(old_gold, new_gold); self.apply_item_turnin_changes(player_guid, map_id, &item_turnin_changes); - for &(currency_id, amount) in &extended_cost_currency_costs { - let Some(quantity) = i32::try_from(self.player_currency_quantity(currency_id)).ok() - else { - continue; - }; - let Some(amount) = i32::try_from(amount).ok() else { - continue; - }; - self.send_packet(&SetCurrency::vendor_loss( - currency_id as i32, - quantity, - amount, - )); - } - for &(_, item_guid, new_count) in &existing_updates { self.update_inventory_item_object_like_cpp(item_guid, |item| { item.set_count(new_count); @@ -12291,15 +12400,6 @@ impl WorldSession { .iter() .map(|&(slot, _, item_guid, _)| (slot, item_guid)) .collect(); - - info!( - "BuyItem: player {:?} bought item {} across {} destination(s) for {} copper (remaining: {})", - player_guid, - buy.item_id, - store_dest.len(), - buy_price, - self.player_gold_like_cpp() - ); let new_quantity = if vendor_item.max_count == 0 { -1 } else { @@ -12312,7 +12412,33 @@ impl WorldSession { quantity, ) as i32 }; + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; + for &(currency_id, amount) in &extended_cost_currency_costs { + let Some(quantity) = i32::try_from(self.player_currency_quantity(currency_id)).ok() + else { + continue; + }; + let Some(amount) = i32::try_from(amount).ok() else { + continue; + }; + self.send_packet(&SetCurrency::vendor_loss( + currency_id as i32, + quantity, + amount, + )); + } + info!( + "BuyItem: player {:?} bought item {} across {} destination(s) for {} copper (remaining: {})", + player_guid, + buy.item_id, + store_dest.len(), + buy_price, + self.player_gold_like_cpp() + ); // ── Send BuySucceeded ── self.send_packet(&BuySucceeded { vendor_guid: buy.vendor_guid, @@ -12444,6 +12570,12 @@ impl WorldSession { Some(db) => Arc::clone(db), None => return, }; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let old_gold = self.player_gold_like_cpp(); let new_gold = old_gold.saturating_sub(price); @@ -12520,14 +12652,26 @@ impl WorldSession { tx.append(del_item); } - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("BuyBackItem: transaction failed: {e}"); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor buyback purchase", + ) + .await + else { + warn!("BuyBackItem: transaction did not commit"); self.send_buy_error(BuyResult::CantFindItem, Some(buyback.vendor_guid), 0); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // The same COMMIT moved the buyback item (or merged/deleted it) and + // charged the player. Mirror that entire durable state before the + // guard can reopen admission or this future can be cancelled. + self.stage_player_money_change_like_cpp(old_gold, new_gold); self.remove_buyback_item_like_cpp(buyback_slot); self.clear_buyback_slot_metadata_like_cpp(buyback_slot); if self @@ -12563,6 +12707,10 @@ impl WorldSession { self.remove_inventory_item_object(buyback_item.guid); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; for &(_, item_guid, new_count) in &existing_updates { self.send_packet(&UpdateObject::item_stack_count_update( @@ -12713,6 +12861,12 @@ impl WorldSession { } let money = sell_price.saturating_mul(u64::from(sold_count)); + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let old_gold = self.player_gold_like_cpp(); let Some(new_gold) = player_money_gain_like_cpp(old_gold, money) else { self.send_sell_error( @@ -12761,12 +12915,18 @@ impl WorldSession { upd_count.set_u64(1, item.db_guid); tx.append(upd_count); - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let new_db_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some((new_db_guid, new_item_guid)) = self + .allocate_item_instance_guids_like_cpp(1) + .and_then(|mut allocated| allocated.pop()) + else { + warn!("SellItem: process-wide item GUID allocator is unavailable"); + self.send_sell_error( + SellResult::CantSellItem, + Some(sell.vendor_guid), + sell.item_guid, + ); + return; }; - let new_item_guid = ObjectGuid::create_item(self.realm_id(), new_db_guid as i64); let cloned_item = runtime_item.clone_item_for_store(new_item_guid, Some(player_guid), amount); let cloned_data = cloned_item.data(); @@ -12809,18 +12969,30 @@ impl WorldSession { upd_money.set_u64(1, player_guid.counter() as u64); tx.append(upd_money); - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("SellItem: transaction failed: {e}"); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_gold, + new_gold, + "vendor item sale", + ) + .await + else { + warn!("SellItem: transaction did not commit"); self.send_sell_error( SellResult::CantSellItem, Some(sell.vendor_guid), sell.item_guid, ); return; - } + }; - self.apply_player_money_change_like_cpp(old_gold, new_gold) - .await; + // C++ mutates money, inventory and buyback state as one in-memory + // operation. Our durable-first adaptation must publish the same whole + // state before reopening admission or reaching a cancellation point. + self.stage_player_money_change_like_cpp(old_gold, new_gold); if let Some(old_buyback) = old_buyback { self.remove_buyback_item_like_cpp(buyback_slot); self.remove_inventory_item_object(old_buyback.guid); @@ -12865,6 +13037,10 @@ impl WorldSession { self.set_inventory_item_object_slot(item.guid, buyback_slot); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; info!( "SellItem: player {:?} sold {}x item {} from slot {} for {} copper (total: {})", @@ -13211,6 +13387,12 @@ impl WorldSession { } } + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let mut tx = SqlTransaction::new(); let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); del_refund.set_u64(0, refund_inv_item.db_guid); @@ -13241,20 +13423,24 @@ impl WorldSession { tx.append(upd_count); } - let realm_id = self.realm_id(); let mut created_new_stacks = Vec::new(); if !planned_new_stacks.is_empty() { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let mut next_item_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "ItemPurchaseRefund: process-wide item GUID allocator is unavailable" + ); + self.send_packet(&ItemPurchaseRefundResult { + item_guid: refund.item_guid, + result: REFUND_RESULT_ERR_GENERIC, + contents: Some(contents), + }); + return; }; - for stack in &planned_new_stacks { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); - + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { let mut ins_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE); ins_item.set_u64(0, db_guid); ins_item.set_u32(1, stack.entry_id); @@ -13292,22 +13478,31 @@ impl WorldSession { } self.append_player_currency_save_statements(&mut tx, player_guid.counter() as u64); - if let Err(e) = char_db.commit_transaction(tx).await { + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_money, + new_gold, + "vendor item purchase refund", + ) + .await + else { self.set_player_currencies_like_cpp(currency_snapshot); - warn!("ItemPurchaseRefund: refund transaction failed: {e}"); + warn!("ItemPurchaseRefund: refund transaction did not commit"); self.send_packet(&ItemPurchaseRefundResult { item_guid: refund.item_guid, result: REFUND_RESULT_ERR_GENERIC, contents: Some(contents), }); return; - } + }; - self.apply_player_money_change_like_cpp(old_money, new_gold) - .await; - if money_overflow { - self.send_equip_error(InventoryResult::TooMuchGold, None, None, 0, 0); - } + // Refund COMMIT covers money, currencies, destruction of the refunded + // item, and every restored stack. Publish the corresponding runtime + // inventory before the guard opens or an await permits cancellation. + self.stage_player_money_change_like_cpp(old_money, new_gold); self.remove_inventory_item_like_cpp(refund_slot); self.remove_inventory_item_object(refund.item_guid); @@ -13339,6 +13534,13 @@ impl WorldSession { self.insert_inventory_item_object(item_object); } self.sync_object_accessor_player(); + drop(money_persistence); + + self.drain_represented_quest_objective_progress_like_cpp() + .await; + if money_overflow { + self.send_equip_error(InventoryResult::TooMuchGold, None, None, 0, 0); + } self.send_packet(&ItemPurchaseRefundResult { item_guid: refund.item_guid, @@ -16342,6 +16544,7 @@ mod tests { RepresentedTaxiFlightNodeLikeCpp, }; use wow_constants::{ItemClass, ServerOpcodes}; + use wow_core::ObjectGuidGenerator; use wow_data::character_progression::{ ChrClassesEntry, ChrClassesStore, ChrRacesEntry, ChrRacesStore, }; @@ -16957,21 +17160,23 @@ mod tests { ) -> (WorldSession, flume::Receiver>) { let (_pkt_tx, pkt_rx) = flume::bounded::(1); let (send_tx, send_rx) = flume::bounded::>(capacity); - ( - WorldSession::new( - 1, - "TestAccount".into(), - 0, - 2, - 9, - 54261, - vec![0u8; 40], - "esES".into(), - pkt_rx, - send_tx, - ), - send_rx, - ) + let mut session = WorldSession::new( + 1, + "TestAccount".into(), + 0, + 2, + 9, + 54261, + vec![0u8; 40], + "esES".into(), + pkt_rx, + send_tx, + ); + session.set_item_guid_generator_like_cpp(Arc::new(ObjectGuidGenerator::new( + HighGuid::Item, + 1, + ))); + (session, send_rx) } fn run_login_grid_cleanup_test(test: impl FnOnce() + Send + 'static) { @@ -20141,11 +20346,125 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[test] + fn committed_money_callers_publish_all_runtime_state_before_reopening_admission() { + fn assert_publication_segment( + source: &str, + operation: &str, + publication_start: &str, + required_runtime_publications: &[&str], + ) { + let operation_marker = format!("\"{operation}\""); + let operation_offset = source + .find(&operation_marker) + .unwrap_or_else(|| panic!("missing operation marker {operation_marker}")); + let after_operation = &source[operation_offset..]; + let publication_offset = after_operation.find(publication_start).unwrap_or_else(|| { + panic!("{operation}: missing publication start {publication_start}") + }); + let after_publication = &after_operation[publication_offset..]; + let drop_offset = after_publication + .find("drop(money_persistence);") + .unwrap_or_else(|| panic!("{operation}: missing money guard drop")); + let publication = &after_publication[..drop_offset]; + + assert!( + !publication.contains(".await"), + "{operation}: an await reintroduced a post-COMMIT cancellation point before runtime publication" + ); + for required in required_runtime_publications { + assert!( + publication.contains(required), + "{operation}: runtime publication `{required}` must precede the money guard drop" + ); + } + } + + let character = include_str!("character.rs"); + let trainer = include_str!("trainer.rs"); + let session = include_str!("../session.rs"); + + assert_publication_segment( + trainer, + "trainer spell purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.learn_known_spell_like_cpp(spell_id);", + "self.sync_object_accessor_player();", + "self.sync_player_registry_state_like_cpp();", + ], + ); + assert_publication_segment( + character, + "bank-slot purchase", + "self.set_player_gold_like_cpp(new_money);", + &[ + "self.set_player_bank_bag_slot_count_like_cpp(new_count);", + "self.sync_object_accessor_player();", + "self.sync_player_registry_state_like_cpp();", + ], + ); + assert_publication_segment( + character, + "vendor item purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.apply_item_turnin_changes", + "self.insert_inventory_item_like_cpp", + "self.update_vendor_item_current_count", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor buyback purchase", + "self.stage_player_money_change_like_cpp", + &[ + "self.remove_buyback_item_like_cpp", + "self.insert_inventory_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor item sale", + "self.stage_player_money_change_like_cpp", + &[ + "self.set_buyback_slot_metadata_like_cpp", + "self.insert_buyback_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + character, + "vendor item purchase refund", + "self.stage_player_money_change_like_cpp", + &[ + "self.remove_inventory_item_like_cpp(refund_slot);", + "self.insert_inventory_item_like_cpp", + "self.sync_object_accessor_player();", + ], + ); + assert_publication_segment( + session, + "single item durability repair", + "self.stage_player_money_change_like_cpp", + &["self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid)"], + ); + assert_publication_segment( + session, + "all-items durability repair", + "self.stage_player_money_change_like_cpp", + &["self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid)"], + ); + } + #[tokio::test] async fn buy_bank_slot_buys_next_slot_and_spends_money_like_cpp() { let (mut session, send_rx, canonical) = make_bank_slot_session(4); let banker = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 2456, 1); insert_banker_creature(&canonical, banker, NPCFlags1::BANKER.bits()); + session.set_loot_money_persistence_test_result_like_cpp(true); session .handle_buy_bank_slot(BuyBankSlot { guid: banker }) @@ -20161,6 +20480,52 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[tokio::test] + async fn buy_bank_slot_definite_rollback_keeps_runtime_and_packets_unchanged_like_cpp() { + let (mut session, send_rx, canonical) = make_bank_slot_session(4); + let banker = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 2456, 4); + insert_banker_creature(&canonical, banker, NPCFlags1::BANKER.bits()); + session.set_loot_money_persistence_test_result_like_cpp(false); + + session + .handle_buy_bank_slot(BuyBankSlot { guid: banker }) + .await; + + assert_eq!(session.player_bank_bag_slot_count_like_cpp(), 0); + assert_eq!(session.player_gold_like_cpp(), 150); + assert!(send_rx.try_recv().is_err()); + assert!( + session + .durable_loot_money_persistence_tracker_like_cpp() + .begin_like_cpp() + .is_ok(), + "a definite rollback must reopen payout admission" + ); + } + + #[test] + fn bank_slot_purchase_persists_money_and_slot_in_one_checked_statement_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let statement = bank_slot_purchase_update_statement_like_cpp(player_guid, 12_345, 3); + + assert_eq!( + statement.sql(), + "UPDATE characters SET money = ?, bankSlots = ? WHERE guid = ?" + ); + assert!(matches!( + statement.params()[0], + wow_database::SqlParam::U64(12_345) + )); + assert!(matches!( + statement.params()[1], + wow_database::SqlParam::U8(3) + )); + assert!(matches!( + statement.params()[2], + wow_database::SqlParam::U64(42) + )); + } + #[tokio::test] async fn buy_bank_slot_rejects_non_banker_like_cpp() { let (mut session, send_rx, canonical) = make_bank_slot_session(1); @@ -22434,7 +22799,10 @@ mod tests { wow_constants::ServerOpcodes::LogoutComplete as u16 ); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "full logout release retires the session packet-cache copy like C++" + ); } #[test] diff --git a/crates/wow-world/src/handlers/chat.rs b/crates/wow-world/src/handlers/chat.rs index d77abf13a..98a13d55c 100644 --- a/crates/wow-world/src/handlers/chat.rs +++ b/crates/wow-world/src/handlers/chat.rs @@ -1930,6 +1930,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/group.rs b/crates/wow-world/src/handlers/group.rs index 7ecf81e4f..dda962261 100644 --- a/crates/wow-world/src/handlers/group.rs +++ b/crates/wow-world/src/handlers/group.rs @@ -6,6 +6,7 @@ //! Handlers for Group/Party opcodes: PartyInvite, PartyInviteResponse, LeaveGroup. use rand::Rng; +use std::time::Duration; use tracing::{info, warn}; use wow_constants::ClientOpcodes; use wow_core::{ObjectGuid, guid::HighGuid}; @@ -19,7 +20,8 @@ use wow_network::{ GROUP_ASSIGN_MAINASSIST_LIKE_CPP, GROUP_ASSIGN_MAINTANK_LIKE_CPP, GroupInfo, GroupRegistry, MEMBER_FLAG_ASSISTANT_LIKE_CPP, MEMBER_FLAG_MAINASSIST_LIKE_CPP, MEMBER_FLAG_MAINTANK_LIKE_CPP, PendingInvites, PlayerRegistry, ReadyCheckEventLikeCpp, SendPartyUpdateLikeCppCommand, - SessionCommand, free_group_db_store_id_like_cpp, register_group_db_store_id_like_cpp, + SendRealmPacketLikeCppCommand, SessionCommand, free_group_db_store_id_like_cpp, + register_group_db_store_id_like_cpp, }; use wow_packet::packets::misc::{RandomRoll, RandomRollClient}; use wow_packet::packets::party::{ @@ -39,6 +41,7 @@ use crate::session::{WorldSession, player_team_for_race_cpp}; const SOCIAL_FLAG_FRIEND_LIKE_CPP: u32 = 0x01; const SOCIAL_FLAG_IGNORED_LIKE_CPP: u32 = 0x02; +const PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP: Duration = Duration::from_millis(250); // ── canonical group lookup ──────────────────────────────────────────────────── @@ -551,28 +554,32 @@ fn send_party_update(group: &GroupInfo, registry: &PlayerRegistry, _vra: u32) { } let command = SendPartyUpdateLikeCppCommand { + recipient: member_guid, party_update: update, member_full_state_packets, }; + #[cfg(not(test))] if member_entry .command_tx - .try_send(SessionCommand::SendPartyUpdateLikeCpp(command.clone())) + .try_send(SessionCommand::SendPartyUpdateLikeCpp(command)) .is_err() { - #[cfg(test)] - { - let mut update = command.party_update; - update.sequence_num = group.sequence_num as i32; - let _ = member_entry.send_tx.try_send(update.to_bytes()); - for packet in command.member_full_state_packets { - let _ = member_entry.send_tx.try_send(packet); - } - } + warn!(member = %member_guid, "failed to queue party update for remote session"); + } + #[cfg(test)] + { + member_entry + .command_tx + .send_timeout( + SessionCommand::SendPartyUpdateLikeCpp(command), + Duration::from_secs(1), + ) + .expect("test session dispatcher accepts PartyUpdate command"); } } } -fn send_group_new_leader_like_cpp( +async fn send_group_new_leader_like_cpp( group: &GroupInfo, registry: &PlayerRegistry, new_leader_name: &str, @@ -583,11 +590,18 @@ fn send_group_new_leader_like_cpp( } .to_bytes(); - for &member_guid in &group.members { - let Some(member_entry) = registry.get(&member_guid) else { - continue; - }; - let _ = member_entry.send_tx.try_send(packet.clone()); + let recipients: Vec<_> = group + .members + .iter() + .filter_map(|member_guid| { + registry + .get(member_guid) + .map(|entry| (*member_guid, entry.command_tx.clone())) + }) + .collect(); + for (member_guid, command_tx) in recipients { + let _ = + send_realm_packet_to_player_like_cpp(member_guid, &command_tx, packet.clone()).await; } } @@ -815,7 +829,7 @@ fn send_party_uninvite_result_like_cpp( result: u8, result_guid: ObjectGuid, ) { - session.send_packet(&PartyCommandResult { + session.send_packet_realm(&PartyCommandResult { name: String::new(), command: 1, // C++ PARTY_OP_UNINVITE result, @@ -824,6 +838,78 @@ fn send_party_uninvite_result_like_cpp( }); } +/// Queue a realm-routed packet on the target's owning session. +/// +/// The registry's `send_tx` is the primary socket and becomes INSTANCE after +/// ConnectTo. Legacy C++ routes party-control packets through REALM +/// (`Opcodes.cpp:1826-1832`), so cross-session delivery must not use that +/// primary sender directly. +async fn send_realm_packet_to_player_like_cpp( + recipient: ObjectGuid, + command_tx: &flume::Sender, + packet_bytes: Vec, +) -> bool { + let command = SessionCommand::SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand { + recipient, + packet_bytes, + }); + match tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, + command_tx.send_async(command), + ) + .await + { + Ok(Ok(())) => { + // Test fixtures run a real command dispatcher on another task/thread. + // Yielding here lets that receiver perform the same session-local + // routing before packet assertions without adding a wrong-socket + // production fallback. + #[cfg(test)] + tokio::task::yield_now().await; + true + } + Ok(Err(error)) => { + warn!(recipient = %recipient, %error, "realm-routed party command channel closed"); + false + } + Err(_) => { + warn!(recipient = %recipient, "timed out queueing realm-routed party packet"); + false + } + } +} + +/// Queue the actual invite dialog without treating bounded-channel pressure as +/// an offline player. +/// +/// C++ `HandlePartyInviteOpcode` stores the `GroupInvite` and then calls +/// `invitedPlayer->SendDirectMessage`; it does not turn a busy socket queue into +/// `ERR_BAD_PLAYER_NAME_S`. Rust therefore waits until the owning session drains +/// capacity or disconnects. Other existing group notifications keep their +/// finite timeout in [`send_realm_packet_to_player_like_cpp`]; this stronger +/// delivery rule is intentionally scoped to the invite state transition. +async fn send_realm_party_invite_to_player_like_cpp( + recipient: ObjectGuid, + command_tx: &flume::Sender, + packet_bytes: Vec, +) -> bool { + let command = SessionCommand::SendRealmPacketLikeCpp(SendRealmPacketLikeCppCommand { + recipient, + packet_bytes, + }); + match command_tx.send_async(command).await { + Ok(()) => { + #[cfg(test)] + tokio::task::yield_now().await; + true + } + Err(error) => { + warn!(recipient = %recipient, %error, "realm-routed party invite channel closed"); + false + } + } +} + fn role_changed_inform_like_cpp( party_index: u8, from: ObjectGuid, @@ -885,19 +971,42 @@ fn raid_markers_changed_like_cpp(group: &GroupInfo) -> Vec { .to_bytes() } -fn queue_visible_gameobjects_or_spellclicks_refresh_like_cpp( +async fn queue_visible_gameobjects_or_spellclicks_refresh_like_cpp( group: &GroupInfo, registry: &PlayerRegistry, local_guid: ObjectGuid, ) { - for &member_guid in &group.members { - if member_guid == local_guid { - continue; - } - if let Some(member) = registry.get(&member_guid) { - let _ = member.command_tx.try_send( + let recipients: Vec<_> = group + .members + .iter() + .copied() + .filter(|member_guid| *member_guid != local_guid) + .filter_map(|member_guid| { + registry + .get(&member_guid) + .map(|member| (member_guid, member.command_tx.clone())) + }) + .collect(); + + for (member_guid, command_tx) in recipients { + match tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, + command_tx.send_async( wow_network::SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp, - ); + ), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!( + member = %member_guid, + %error, + "visible gameobject refresh command channel closed" + ), + Err(_) => warn!( + member = %member_guid, + "timed out queueing visible gameobject refresh command" + ), } } } @@ -1005,7 +1114,8 @@ fn group_lfg_data_delete_statement_like_cpp(db_store_id: u32) -> PreparedStateme impl WorldSession { /// CMSG_PARTY_INVITE (0x3604) /// - /// Parse layout (C# reference): + /// Parse layout from C++ `WorldPackets::Party::PartyInviteClient::Read` + /// (`PartyPackets.cpp`): /// HasBit() → has_party_index /// ResetBitPos() /// ReadBits(9) → name_len @@ -1068,7 +1178,7 @@ impl WorldSession { macro_rules! send_result { ($result:expr) => { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: target_name.clone(), command: 0, // Invite result: $result, @@ -1192,7 +1302,10 @@ impl WorldSession { if current_group_guid_like_cpp(group_reg, None, real_target_guid, party_index).is_some() { send_result!(party_result::ALREADY_IN_GROUP); - if let Some(target_entry) = registry.get(&real_target_guid) { + if let Some(target_command_tx) = registry + .get(&real_target_guid) + .map(|entry| entry.command_tx.clone()) + { let invite = PartyInviteServer { can_accept: false, proposed_roles: proposed_roles as u8, @@ -1207,7 +1320,12 @@ impl WorldSession { realm_name: realm_name.clone(), realm_name_normalized: realm_name_normalized.clone(), }; - let _ = target_entry.send_tx.send(invite.to_bytes()); + let _ = send_realm_packet_to_player_like_cpp( + real_target_guid, + &target_command_tx, + invite.to_bytes(), + ) + .await; } return; } @@ -1246,26 +1364,34 @@ impl WorldSession { pending.insert(real_target_guid, invite); // 7. Send invite dialog to the target. - if let Some(target_entry) = registry.get(&real_target_guid) { - let invite = PartyInviteServer { - can_accept: true, - proposed_roles: proposed_roles as u8, - inviter_name: inviter_name.clone(), - inviter_guid: my_guid, - inviter_bnet_account_guid: ObjectGuid::create_global( - HighGuid::WowAccount, - 0, - self.account_id as i64, - ), - virtual_realm_address: vra, - realm_name, - realm_name_normalized, - }; - let _ = target_entry.send_tx.send(invite.to_bytes()); + let invite_packet = PartyInviteServer { + can_accept: true, + proposed_roles: proposed_roles as u8, + inviter_name: inviter_name.clone(), + inviter_guid: my_guid, + inviter_bnet_account_guid: ObjectGuid::create_global( + HighGuid::WowAccount, + 0, + self.account_id as i64, + ), + virtual_realm_address: vra, + realm_name, + realm_name_normalized, + }; + if !send_realm_party_invite_to_player_like_cpp( + real_target_guid, + &target_snapshot.command_tx, + invite_packet.to_bytes(), + ) + .await + { + remove_pending_invite_like_cpp(pending, real_target_guid, invite); + send_result!(party_result::BAD_PLAYER_NAME); + return; } // 7. Confirm back to self. - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: target_name, command: 0, result: party_result::OK, @@ -1331,13 +1457,18 @@ impl WorldSession { // 2. Declined? if !accept { - let leader_send_tx = registry + let leader_command_tx = registry .get(&invite.leader_guid) - .map(|leader| leader.send_tx.clone()); + .map(|leader| leader.command_tx.clone()); remove_pending_invite_like_cpp(&pending, my_guid, invite); - if let Some(leader_send_tx) = leader_send_tx { + if let Some(leader_command_tx) = leader_command_tx { let decline = GroupDecline { name: my_name }; - let _ = leader_send_tx.send(decline.to_bytes()); + let _ = send_realm_packet_to_player_like_cpp( + invite.leader_guid, + &leader_command_tx, + decline.to_bytes(), + ) + .await; } return; } @@ -1361,7 +1492,7 @@ impl WorldSession { let group_guid = if let Some(gid) = invite.group_guid { if let Some(mut g) = group_reg.get_mut(&gid) { if g.is_full_like_cpp() { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::GROUP_FULL, @@ -1643,7 +1774,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&wow_packet::packets::party::GroupDestroyed); + self.send_packet_realm(&wow_packet::packets::party::GroupDestroyed); return; } @@ -1696,7 +1827,7 @@ impl WorldSession { }; if self.player_in_represented_battleground_like_cpp() { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::INVITE_RESTRICTED, @@ -1713,7 +1844,7 @@ impl WorldSession { (pending_invites.as_ref(), pending_invite) { if invite.leader_guid == my_guid { - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: player_name, command: 2, result: party_result::OK, @@ -1727,7 +1858,7 @@ impl WorldSession { } let gid = real_group_guid.expect("checked above"); - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: player_name, command: 2, result: party_result::OK, @@ -1823,7 +1954,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&GroupUninvite); + self.send_packet_realm(&GroupUninvite); return; } @@ -1841,7 +1972,7 @@ impl WorldSession { ); self.sync_player_registry_state_like_cpp(); let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); - self.send_packet(&GroupUninvite); + self.send_packet_realm(&GroupUninvite); } /// CMSG_CONVERT_RAID. @@ -1885,7 +2016,7 @@ impl WorldSession { return; } - self.send_packet(&PartyCommandResult { + self.send_packet_realm(&PartyCommandResult { name: String::new(), command: 0, result: party_result::OK, @@ -1925,9 +2056,13 @@ impl WorldSession { } } - if let Some(group) = group_reg.get(&group_guid) { + // `queue_visible...` may wait on a full member command channel. Clone + // the value and release DashMap's read guard before the first await so + // unrelated group mutations are never stalled behind that backpressure. + if let Some(group) = group_reg.get(&group_guid).map(|group| group.clone()) { send_party_update(&group, ®istry, vra); - queue_visible_gameobjects_or_spellclicks_refresh_like_cpp(&group, ®istry, my_guid); + queue_visible_gameobjects_or_spellclicks_refresh_like_cpp(&group, ®istry, my_guid) + .await; } let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } @@ -2209,8 +2344,8 @@ impl WorldSession { } } - if let Some(group) = group_reg.get(&group_guid) { - send_group_new_leader_like_cpp(&group, ®istry, &target_name); + if let Some(group) = group_reg.get(&group_guid).map(|group| group.clone()) { + send_group_new_leader_like_cpp(&group, ®istry, &target_name).await; send_party_update(&group, ®istry, vra); } } @@ -2903,7 +3038,7 @@ impl WorldSession { let registry = self.player_registry().map(std::sync::Arc::clone); let state = party_member_full_state_like_cpp(request.target_guid, registry.as_deref()); - self.send_packet(&state); + self.send_packet_realm(&state); } /// CMSG_INITIATE_ROLE_POLL. @@ -3164,18 +3299,20 @@ impl WorldSession { #[cfg(test)] mod tests { use super::{ - SOCIAL_FLAG_FRIEND_LIKE_CPP, SOCIAL_FLAG_IGNORED_LIKE_CPP, current_group_guid_like_cpp, + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP, SOCIAL_FLAG_FRIEND_LIKE_CPP, + SOCIAL_FLAG_IGNORED_LIKE_CPP, current_group_guid_like_cpp, first_connected_group_member_like_cpp, group_delete_statement_like_cpp, group_insert_statement_like_cpp, group_leader_update_statement_like_cpp, group_lfg_data_delete_statement_like_cpp, group_member_delete_all_statement_like_cpp, group_member_delete_statement_like_cpp, group_member_flag_update_statement_like_cpp, group_member_insert_statement_like_cpp, group_member_subgroup_update_statement_like_cpp, group_type_update_statement_like_cpp, party_invite_social_friend_match_like_cpp, - party_invite_social_ignore_match_like_cpp, party_player_info_like_cpp, send_party_update, - send_ready_check_events_like_cpp, sender_can_start_ready_check_like_cpp, + party_invite_social_ignore_match_like_cpp, party_player_info_like_cpp, + send_group_new_leader_like_cpp, send_party_update, send_ready_check_events_like_cpp, + sender_can_start_ready_check_like_cpp, }; use flume::bounded; - use std::sync::Arc; + use std::{sync::Arc, time::Duration}; use wow_constants::{ClientOpcodes, ServerOpcodes}; use wow_core::{ObjectGuid, Position, guid::HighGuid}; use wow_database::{CharStatements, SqlParam, StatementDef}; @@ -3184,17 +3321,62 @@ mod tests { use wow_network::{ GroupInfo, GroupMemberCharacterLikeCpp, GroupRegistry, PendingInviteLikeCpp, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReadyCheckEventLikeCpp, - SessionCommand, + SendRealmPacketLikeCppCommand, SessionCommand, }; - use wow_packet::{WorldPacket, packets::party::party_result}; + use wow_packet::{ServerPacket, WorldPacket, packets::party::party_result}; use crate::session::WorldSession; + fn test_session_command_dispatcher( + guid: ObjectGuid, + send_tx: flume::Sender>, + ) -> ( + flume::Sender, + flume::Receiver, + ) { + let (command_tx, command_rx) = flume::bounded(0); + let (observed_tx, observed_rx) = flume::unbounded(); + std::thread::spawn(move || { + let mut party_sequences = std::collections::HashMap::::new(); + while let Ok(command) = command_rx.recv() { + match command { + SessionCommand::SendRealmPacketLikeCpp(command) + if command.recipient == guid => + { + let _ = send_tx.send(command.packet_bytes); + } + SessionCommand::SendPartyUpdateLikeCpp(mut command) + if command.recipient == guid => + { + let sequence = party_sequences + .entry(command.party_update.party_index) + .or_default(); + *sequence += 1; + command.party_update.sequence_num = *sequence; + let _ = send_tx.send(command.party_update.to_bytes()); + for packet in command.member_full_state_packets { + let _ = send_tx.send(packet); + } + } + command => { + let _ = observed_tx.send(command); + } + } + } + }); + (command_tx, observed_rx) + } + fn broadcast_info(guid: ObjectGuid, send_tx: flume::Sender>) -> PlayerBroadcastInfo { - let (command_tx, _command_rx) = flume::bounded(0); + let (command_tx, _observed_rx) = test_session_command_dispatcher(guid, send_tx.clone()); broadcast_info_with_command_tx(guid, send_tx, command_tx) } + fn recv_dispatched_packet(rx: &flume::Receiver>, label: &str) -> Vec { + rx.recv_timeout(std::time::Duration::from_secs(1)) + .unwrap_or_else(|error| panic!("{label}: {error}")) + } + fn broadcast_info_with_command_tx( guid: ObjectGuid, send_tx: flume::Sender>, @@ -3209,6 +3391,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, @@ -3666,7 +3849,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "raid PartyUpdate"); let mut pkt = WorldPacket::from_bytes(&sent); assert_eq!( pkt.read_uint16().unwrap(), @@ -3684,7 +3867,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "non-master-loot PartyUpdate"); assert!( !sent .windows(master_bytes.len()) @@ -3703,7 +3886,7 @@ mod tests { send_party_update(&group, ®istry, 0); - let sent = rx.try_recv().unwrap(); + let sent = recv_dispatched_packet(&rx, "raid-group PartyUpdate"); let mut pkt = WorldPacket::from_bytes(&sent); assert_eq!( pkt.read_uint16().unwrap(), @@ -3931,7 +4114,7 @@ mod tests { pending_invites.get(&target).is_some(), "PartyIndex INSTANCE must not treat the full HOME group as the invite group" ); - let invite = target_rx.try_recv().expect("target invite packet"); + let invite = recv_dispatched_packet(&target_rx, "target invite packet"); assert_eq!( u16::from_le_bytes([invite[0], invite[1]]), ServerOpcodes::PartyInvite as u16 @@ -3968,7 +4151,7 @@ mod tests { .handle_party_invite(party_invite_packet(target, &target_name, None, 0x12)) .await; - let invite = target_rx.try_recv().expect("target invite packet"); + let invite = recv_dispatched_packet(&target_rx, "target invite packet"); let mut packet = WorldPacket::from_bytes(&invite); assert_eq!( packet.read_uint16().expect("opcode"), @@ -4012,6 +4195,205 @@ mod tests { assert!(packet.is_empty()); } + #[tokio::test] + async fn party_invite_and_result_route_through_realm_like_cpp() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = bounded(8); + session.install_realm_send_channel_for_test(realm_tx); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_instance_tx, target_instance_rx) = bounded(8); + let (target_command_tx, target_command_rx) = bounded(8); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_instance_tx, target_command_tx), + ); + session.set_player_registry(player_registry); + session.set_group_registry( + Arc::new(GroupRegistry::default()), + Arc::new(PendingInvites::default()), + ); + + session + .handle_party_invite(party_invite_packet(target, &target_name, None, 0)) + .await; + + let SessionCommand::SendRealmPacketLikeCpp(command) = + target_command_rx.try_recv().expect("remote realm command") + else { + panic!("expected remote realm packet command"); + }; + assert_eq!(command.recipient, target); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::PartyInvite as u16 + ); + assert!(target_instance_rx.try_recv().is_err()); + + let result = realm_rx.try_recv().expect("realm PartyCommandResult"); + assert_eq!( + u16::from_le_bytes([result[0], result[1]]), + ServerOpcodes::PartyCommandResult as u16 + ); + assert!(instance_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn party_invite_waits_through_command_backpressure_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_send_tx, target_send_rx) = bounded(8); + let (target_command_tx, target_command_rx) = bounded(1); + target_command_tx + .try_send(SessionCommand::SendRealmPacketLikeCpp( + SendRealmPacketLikeCppCommand { + recipient: target, + packet_bytes: vec![0xAA], + }, + )) + .expect("fill target command queue"); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_send_tx, target_command_tx.clone()), + ); + let pending = Arc::new(PendingInvites::default()); + session.set_player_registry(player_registry); + session.set_group_registry(Arc::new(GroupRegistry::default()), Arc::clone(&pending)); + + let invite = + session.handle_party_invite(party_invite_packet(target, &target_name, None, 0)); + tokio::pin!(invite); + + assert!( + tokio::time::timeout( + PARTY_REALM_COMMAND_TIMEOUT_LIKE_CPP + Duration::from_millis(50), + &mut invite, + ) + .await + .is_err(), + "temporary command backpressure must not be converted into a failed invite" + ); + assert!(pending.get(&target).is_some()); + assert!(pending.get(&inviter).is_some()); + assert!(send_rx.try_recv().is_err()); + assert!(target_send_rx.try_recv().is_err()); + + let SessionCommand::SendRealmPacketLikeCpp(blocker) = target_command_rx + .try_recv() + .expect("release target command capacity") + else { + panic!("expected command queue blocker"); + }; + assert_eq!(blocker.packet_bytes, vec![0xAA]); + + tokio::time::timeout(Duration::from_secs(1), &mut invite) + .await + .expect("invite resumes when the target command queue drains"); + + let SessionCommand::SendRealmPacketLikeCpp(command) = target_command_rx + .try_recv() + .expect("queued party invite after backpressure") + else { + panic!("expected remote realm packet command"); + }; + assert_eq!(command.recipient, target); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::PartyInvite as u16 + ); + + assert_eq!( + party_command_result_code(&send_rx.try_recv().expect("successful invite result")), + party_result::OK + ); + assert!(pending.get(&target).is_some()); + assert!(pending.get(&inviter).is_some()); + assert!(target_send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn party_invite_closed_command_channel_rolls_back_pending_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let inviter = ObjectGuid::create_player(1, 42); + let target = ObjectGuid::create_player(1, 77); + let target_name = format!("Player{}", target.low_value()); + session.set_player_guid(Some(inviter)); + session.set_loaded_player_name_like_cpp("Leader".to_string()); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (target_send_tx, target_send_rx) = bounded(8); + let (target_command_tx, target_command_rx) = bounded(1); + drop(target_command_rx); + player_registry.insert( + target, + broadcast_info_with_command_tx(target, target_send_tx, target_command_tx), + ); + let pending = Arc::new(PendingInvites::default()); + session.set_player_registry(player_registry); + session.set_group_registry(Arc::new(GroupRegistry::default()), Arc::clone(&pending)); + + session + .handle_party_invite(party_invite_packet(target, &target_name, None, 0)) + .await; + + assert_eq!( + party_command_result_code(&send_rx.try_recv().expect("failed invite result")), + party_result::BAD_PLAYER_NAME + ); + assert!(pending.get(&target).is_none()); + assert!(pending.get(&inviter).is_none()); + assert!(target_send_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn group_new_leader_fanout_queues_realm_commands_like_cpp() { + let leader = ObjectGuid::create_player(1, 42); + let member = ObjectGuid::create_player(1, 77); + let mut group = GroupInfo::new(leader); + group.add_member(member); + let registry = PlayerRegistry::default(); + let (leader_instance_tx, leader_instance_rx) = bounded(4); + let (leader_command_tx, leader_command_rx) = bounded(4); + registry.insert( + leader, + broadcast_info_with_command_tx(leader, leader_instance_tx, leader_command_tx), + ); + let (member_instance_tx, member_instance_rx) = bounded(4); + let (member_command_tx, member_command_rx) = bounded(4); + registry.insert( + member, + broadcast_info_with_command_tx(member, member_instance_tx, member_command_tx), + ); + + send_group_new_leader_like_cpp(&group, ®istry, "NewLeader").await; + + for (expected, command_rx) in [(leader, leader_command_rx), (member, member_command_rx)] { + let SessionCommand::SendRealmPacketLikeCpp(command) = + command_rx.try_recv().expect("realm GroupNewLeader command") + else { + panic!("expected realm command"); + }; + assert_eq!(command.recipient, expected); + assert_eq!( + u16::from_le_bytes([command.packet_bytes[0], command.packet_bytes[1]]), + ServerOpcodes::GroupNewLeader as u16 + ); + } + assert!(leader_instance_rx.try_recv().is_err()); + assert!(member_instance_rx.try_recv().is_err()); + } + #[tokio::test] async fn party_invite_non_leader_rejects_not_leader_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -4079,9 +4461,10 @@ mod tests { party_command_result_code(&send_rx.try_recv().expect("party command result")), party_result::ALREADY_IN_GROUP ); - assert!(!party_invite_can_accept( - &target_rx.try_recv().expect("target failed invite packet") - )); + assert!(!party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target failed invite packet" + ))); assert!(pending_invites.get(&target).is_none()); } @@ -4116,9 +4499,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4217,9 +4601,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4250,9 +4635,10 @@ mod tests { .await; assert!(pending_invites.get(&target).is_some()); - assert!(party_invite_can_accept( - &target_rx.try_recv().expect("target invite packet") - )); + assert!(party_invite_can_accept(&recv_dispatched_packet( + &target_rx, + "target invite packet" + ))); } #[tokio::test] @@ -4551,12 +4937,12 @@ mod tests { assert!(command.send_group_uninvite); assert!(command.refresh_visible_gameobjects_or_spellclicks); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let remaining_update = remaining_rx.try_recv().expect("remaining party update"); + let remaining_update = recv_dispatched_packet(&remaining_rx, "remaining party update"); assert_eq!( u16::from_le_bytes([remaining_update[0], remaining_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -4672,8 +5058,8 @@ mod tests { send_party_update(&group, ®istry, 0); - let _party_update = leader_rx.try_recv().unwrap(); - let full_state = leader_rx.try_recv().unwrap(); + let _party_update = recv_dispatched_packet(&leader_rx, "leader PartyUpdate"); + let full_state = recv_dispatched_packet(&leader_rx, "leader PartyMemberFullState"); assert_eq!( u16::from_le_bytes([full_state[0], full_state[1]]), ServerOpcodes::PartyMemberFullState as u16 @@ -5301,6 +5687,26 @@ mod tests { assert!(target_rx.try_recv().is_err()); } + #[tokio::test] + async fn request_party_member_stats_routes_reply_through_realm_like_cpp() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = bounded(4); + session.install_realm_send_channel_for_test(realm_tx); + let target = ObjectGuid::create_player(1, 77); + session.set_player_registry(Arc::new(PlayerRegistry::default())); + + session + .handle_request_party_member_stats(request_party_member_stats_packet(target, None)) + .await; + + let packet = realm_rx.try_recv().expect("realm PartyMemberFullState"); + assert_eq!( + u16::from_le_bytes([packet[0], packet[1]]), + ServerOpcodes::PartyMemberFullState as u16 + ); + assert!(instance_rx.try_recv().is_err()); + } + #[tokio::test] async fn request_party_member_stats_online_replies_snapshot_without_fanout_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -5501,12 +5907,10 @@ mod tests { assert_eq!(pkt.read_uint8().unwrap(), 1); assert_eq!(pkt.read_uint8().unwrap(), 4); - let leader_update = leader_rx - .try_recv() - .expect("leader PartyUpdate after SetLfgRoles"); - let member_update = member_rx - .try_recv() - .expect("member PartyUpdate after SetLfgRoles"); + let leader_update = + recv_dispatched_packet(&leader_rx, "leader PartyUpdate after SetLfgRoles"); + let member_update = + recv_dispatched_packet(&member_rx, "member PartyUpdate after SetLfgRoles"); let mut leader_update_pkt = WorldPacket::from_bytes(&leader_update); let mut member_update_pkt = WorldPacket::from_bytes(&member_update); assert_eq!( @@ -5713,11 +6117,13 @@ mod tests { let player_registry = Arc::new(PlayerRegistry::default()); let (leader_tx, leader_rx) = bounded(8); let (member_tx, _member_rx) = bounded(8); - let (member_command_tx, member_command_rx) = bounded(8); + let (member_command_tx, member_command_rx) = + test_session_command_dispatcher(member, member_tx.clone()); player_registry.insert(leader, broadcast_info(leader, leader_tx)); - let mut member_info = broadcast_info(member, member_tx); - member_info.command_tx = member_command_tx; - player_registry.insert(member, member_info); + player_registry.insert( + member, + broadcast_info_with_command_tx(member, member_tx, member_command_tx), + ); session.set_player_guid(Some(leader)); session.group_guid = Some(group_guid); @@ -5731,26 +6137,20 @@ mod tests { .get(&group_guid) .is_some_and(|group| group.is_raid_group()) ); - let mut remote_refresh_queued = false; - while let Ok(command) = member_command_rx.try_recv() { - if matches!( - command, - SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp - ) { - remote_refresh_queued = true; - } - } - assert!( - remote_refresh_queued, - "remote member visible refresh command queued" - ); + let remote_refresh = member_command_rx + .recv_timeout(Duration::from_secs(1)) + .expect("remote member visible refresh command queued"); + assert!(matches!( + remote_refresh, + SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp + )); let command_result = send_rx.try_recv().expect("party command result"); assert_eq!( u16::from_le_bytes([command_result[0], command_result[1]]), ServerOpcodes::PartyCommandResult as u16 ); assert!(send_rx.try_recv().is_err()); - let party_update = leader_rx.try_recv().expect("leader party update"); + let party_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([party_update[0], party_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -5761,6 +6161,59 @@ mod tests { ); } + #[tokio::test] + async fn convert_raid_releases_group_guard_before_refresh_backpressure_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let leader = ObjectGuid::create_player(1, 142); + let member = ObjectGuid::create_player(1, 143); + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(leader); + group.add_member(member); + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + + let player_registry = Arc::new(PlayerRegistry::default()); + let (leader_tx, _leader_rx) = bounded(8); + player_registry.insert(leader, broadcast_info(leader, leader_tx)); + let (member_tx, _member_rx) = bounded(8); + // PartyUpdate fills this single slot; the following async refresh then + // waits for its timeout and exposes any DashMap guard held across it. + let (member_command_tx, _member_command_rx) = flume::bounded(1); + player_registry.insert( + member, + broadcast_info_with_command_tx(member, member_tx, member_command_tx), + ); + + session.set_player_guid(Some(leader)); + session.group_guid = Some(group_guid); + session.set_player_registry(player_registry); + session.set_group_registry( + Arc::clone(&group_registry), + Arc::new(PendingInvites::default()), + ); + + let mut conversion = Box::pin(session.handle_convert_raid(convert_raid_packet(true))); + tokio::select! { + () = &mut conversion => panic!("refresh should be waiting on the full command channel"), + () = tokio::time::sleep(Duration::from_millis(20)) => {} + } + + let writer_registry = Arc::clone(&group_registry); + let writer = tokio::task::spawn_blocking(move || { + writer_registry + .get_mut(&group_guid) + .map(|group| group.group_flags) + }); + let observed_flags = tokio::time::timeout(Duration::from_secs(1), writer) + .await + .expect("the group write must not wait for refresh channel backpressure") + .expect("group writer task should not panic") + .expect("converted group should remain registered"); + assert_ne!(observed_flags & wow_network::GROUP_FLAG_RAID_LIKE_CPP, 0); + + conversion.await; + } + #[tokio::test] async fn convert_raid_to_group_rejects_over_five_members_like_cpp() { let (mut session, _send_rx) = make_session_with_send(); @@ -5822,12 +6275,12 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.member_group_like_cpp(member), 2); assert!(group.has_free_slot_sub_group_like_cpp(0)); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -5938,12 +6391,12 @@ mod tests { & wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP, wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6003,7 +6456,7 @@ mod tests { & wow_network::MEMBER_FLAG_MAINASSIST_LIKE_CPP, wow_network::MEMBER_FLAG_MAINASSIST_LIKE_CPP ); - assert!(leader_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); } #[tokio::test] @@ -6097,8 +6550,8 @@ mod tests { .flags, 0 ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); { let mut group = group_registry.get_mut(&group_guid).unwrap(); @@ -6129,8 +6582,8 @@ mod tests { & wow_network::MEMBER_FLAG_MAINTANK_LIKE_CPP, 0 ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6164,8 +6617,8 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.sequence_num, sequence_before); assert_eq!(group.member_slot_like_cpp(member).unwrap().flags, 0); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6207,12 +6660,12 @@ mod tests { wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP ); } - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6258,8 +6711,8 @@ mod tests { 0 ); } - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6434,8 +6887,8 @@ mod tests { group_registry.get(&group_guid).unwrap().sequence_num, sequence_after_apply ); - assert!(leader_rx.try_recv().is_ok()); - assert!(member_rx.try_recv().is_ok()); + let _ = recv_dispatched_packet(&leader_rx, "leader party update"); + let _ = recv_dispatched_packet(&member_rx, "member party update"); } #[tokio::test] @@ -6476,12 +6929,12 @@ mod tests { & wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP, wow_network::MEMBER_FLAG_ASSISTANT_LIKE_CPP ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6543,22 +6996,22 @@ mod tests { ); drop(group); - let leader_new_leader = leader_rx.try_recv().expect("leader new-leader packet"); + let leader_new_leader = recv_dispatched_packet(&leader_rx, "leader new-leader packet"); assert_eq!( u16::from_le_bytes([leader_new_leader[0], leader_new_leader[1]]), ServerOpcodes::GroupNewLeader as u16 ); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let member_new_leader = member_rx.try_recv().expect("member new-leader packet"); + let member_new_leader = recv_dispatched_packet(&member_rx, "member new-leader packet"); assert_eq!( u16::from_le_bytes([member_new_leader[0], member_new_leader[1]]), ServerOpcodes::GroupNewLeader as u16 ); - let member_update = member_rx.try_recv().expect("member party update"); + let member_update = recv_dispatched_packet(&member_rx, "member party update"); assert_eq!( u16::from_le_bytes([member_update[0], member_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6737,17 +7190,17 @@ mod tests { let group = group_registry.get(&group_guid).unwrap(); assert_eq!(group.member_group_like_cpp(first), 2); assert_eq!(group.member_group_like_cpp(second), 0); - let leader_update = leader_rx.try_recv().expect("leader party update"); + let leader_update = recv_dispatched_packet(&leader_rx, "leader party update"); assert_eq!( u16::from_le_bytes([leader_update[0], leader_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let first_update = first_rx.try_recv().expect("first member party update"); + let first_update = recv_dispatched_packet(&first_rx, "first member party update"); assert_eq!( u16::from_le_bytes([first_update[0], first_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let second_update = second_rx.try_recv().expect("second member party update"); + let second_update = recv_dispatched_packet(&second_rx, "second member party update"); assert_eq!( u16::from_le_bytes([second_update[0], second_update[1]]), ServerOpcodes::PartyUpdate as u16 @@ -6834,16 +7287,13 @@ mod tests { .member_group_like_cpp(second), 0 ); - let first_update = first_rx - .try_recv() - .expect("first update after assistant swap"); + let first_update = recv_dispatched_packet(&first_rx, "first update after assistant swap"); assert_eq!( u16::from_le_bytes([first_update[0], first_update[1]]), ServerOpcodes::PartyUpdate as u16 ); - let second_update = second_rx - .try_recv() - .expect("second update after assistant swap"); + let second_update = + recv_dispatched_packet(&second_rx, "second update after assistant swap"); assert_eq!( u16::from_le_bytes([second_update[0], second_update[1]]), ServerOpcodes::PartyUpdate as u16 diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index 5022a492e..eb433f43f 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -5,10 +5,15 @@ //! Loot packet handlers — CMSG_LOOT_UNIT, CMSG_LOOT_ITEM, CMSG_LOOT_RELEASE. //! -//! Reference: C# Game/Handlers/LootHandler.cs +//! References: C++ `WorldSession::HandleLoot*`/`DoLootRelease` in +//! `src/server/game/Handlers/LootHandler.cpp` and `Loot` in +//! `src/server/game/Loot/Loot.cpp`. use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; use std::time::{Duration, Instant}; use rand::{ @@ -19,13 +24,18 @@ use tokio::time::timeout; use tracing::{debug, info, warn}; use wow_constants::{ - ClientOpcodes, InventoryResult, InventoryType, ItemContext, ItemFlags, ItemFlags2, ItemQuality, + ClientOpcodes, InventoryResult, InventoryType, ItemContext, ItemFieldFlags, ItemFlags, + ItemFlags2, ItemQuality, UnitDynFlags, }; use wow_core::{ObjectGuid, guid::HighGuid}; use wow_data::{ItemRandomEnchantmentTemplateEntry, ItemRandomPropertyTemplateEntry}; -use wow_database::{CharStatements, SqlTransaction, WorldStatements}; +use wow_database::{ + CharStatements, CharacterDatabase, DatabaseError, SqlTransaction, SqlTransactionCommitError, + StatementDef, WorldStatements, is_database_deadlock_like_cpp, + retry_deadlocked_operation_like_cpp, +}; use wow_entities::{ - AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, CreatureOwnedLoot, GAMEOBJECT_TYPE_AREADAMAGE, + AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BARBER_CHAIR, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CAMERA, GAMEOBJECT_TYPE_CHAIR, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING, GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY, GAMEOBJECT_TYPE_FISHING_HOLE, @@ -33,16 +43,18 @@ use wow_entities::{ GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_MAILBOX, GAMEOBJECT_TYPE_MAP_OBJECT, GAMEOBJECT_TYPE_MINI_GAME, GAMEOBJECT_TYPE_QUESTGIVER, GAMEOBJECT_TYPE_TEXT, GO_DYNFLAG_LO_NO_INTERACT, - GameObjectLootSource, GameObjectOwnedLoot, GatheringNodeUseSource, GoState, - INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_END, - INVENTORY_SLOT_ITEM_START, Item, ItemPosCount, LootState, make_item_pos, + GameObjectLootSource, GatheringNodeUseSource, GoState, INVENTORY_DEFAULT_SIZE, + INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_ITEM_END, INVENTORY_SLOT_ITEM_START, Item, ItemPosCount, + LootState, MAX_MONEY_AMOUNT, is_bag_pos, make_item_pos, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; use wow_loot::{ - GeneratedLootItem, LootConditionId, LootConditionRowLikeCpp, LootFillError, LootFillOptions, + GeneratedLootItem, LootClaimCommitError, LootClaimError, LootClaimLease, LootClaimPayload, + LootConditionId, LootConditionRowLikeCpp, LootFillError, LootFillOptions, LootItemRandomProperties, LootItemTemplateMetadata, LootStoreItem, LootStoreItemContext, - LootStoreKind, LootTemplate, condition_compare_values_like_cpp, - loot_condition_reference_ids_like_cpp, loot_condition_reference_self_references_like_cpp, + LootStoreKind, LootTemplate, OwnedLootAuthority, OwnedLootAuthorityLifecycle, OwnedLootScope, + OwnedLootSnapshot, condition_compare_values_like_cpp, loot_condition_reference_ids_like_cpp, + loot_condition_reference_self_references_like_cpp, loot_condition_row_normalize_without_external_stores_like_cpp, loot_conditions_allow_player_with_references_like_cpp_representable, loot_item_ui_type_for_player_like_cpp, @@ -51,17 +63,19 @@ use wow_network::player_registry::{ ApplyCreatureMeleeDamageLikeCppCommand, ApplyGroupJoinLikeCppCommand, ApplyGroupRemovalLikeCppCommand, CancelRepresentedTradeLikeCppCommand, CreatureAttackStartLikeCppCommand, RefreshVisibleWorldCreaturesLikeCppCommand, - SendAddonIfRegisteredLikeCppCommand, SendIfVisibleLikeCppCommand, - SendPartyUpdateLikeCppCommand, SendRepeatableTurnInRequestItemsLikeCppCommand, - SendRepresentedDuelCountdownLikeCppCommand, SendRepresentedDuelRequestedLikeCppCommand, - SendRepresentedTradeStatusLikeCppCommand, SetQuestSharingInfoAndSendDetailsCommand, - SyncChestGameobjectStateAndRefreshLikeCppCommand, + SendAddonIfRegisteredLikeCppCommand, SendCreatureLootReleaseValuesUpdateLikeCppCommand, + SendIfVisibleLikeCppCommand, SendPartyUpdateLikeCppCommand, + SendRepeatableTurnInRequestItemsLikeCppCommand, SendRepresentedDuelCountdownLikeCppCommand, + SendRepresentedDuelRequestedLikeCppCommand, SendRepresentedTradeStatusLikeCppCommand, + SetQuestSharingInfoAndSendDetailsCommand, SyncChestGameobjectStateAndRefreshLikeCppCommand, SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, SyncGooberGameobjectStateAndRefreshLikeCppCommand, UnacceptRepresentedTradeLikeCppCommand, WorldSessionShutdownFlushResultLikeCpp, }; use wow_network::{ - LootRollStoreWinnerCommand, LootRollVoteCommand, MasterLootGiveCommand, MasterLootGiveResult, + ApplyLootMoneyLikeCppCommand, ApplyLootMoneyResultLikeCpp, KickLikeCppCommand, + LootRollCommandIdentityLikeCpp, LootRollStoreWinnerCommand, LootRollVoteCommand, + MasterLootGiveCommand, MasterLootGiveResult, NotifyLootMoneyRemovedLikeCppCommand, PlayerRegistry, SessionCommand, }; use wow_packet::packets::item::{ @@ -89,14 +103,20 @@ use crate::conditions::{ QUEST_STATUS_NONE_LIKE_CPP, QUEST_STATUS_REWARDED_LIKE_CPP, }; use crate::session::{ - InventoryItem, RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, + DurableItemLootCompletionLikeCpp, DurableItemLootPersistenceGuardLikeCpp, + DurableLootItemFanoutLikeCpp, InventoryItem, LootMoneyPersistenceErrorLikeCpp, + LootMoneyViewerFanoutLikeCpp, RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, RepresentedLootRollState, RepresentedLootRollVote, RepresentedQuestObjectiveProgressEventLikeCpp, SessionState, WorldSession, + loot_money_durable_outcome_like_cpp, }; +const LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP: u8 = 0; +const LOOT_METHOD_ROUND_ROBIN_LIKE_CPP: u8 = 1; const LOOT_METHOD_MASTER_LIKE_CPP: u8 = 2; const LOOT_METHOD_GROUP_LIKE_CPP: u8 = 3; const LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP: u8 = 4; +const LOOT_METHOD_PERSONAL_LIKE_CPP: u8 = 5; const MAX_NR_LOOT_ITEMS_LIKE_CPP: usize = 18; const LOOT_ROLL_TIMEOUT_MS_LIKE_CPP: u32 = 60_000; #[cfg(test)] @@ -128,6 +148,18 @@ const LOCK_KEY_SPELL_LIKE_CPP: u8 = 3; const SPELL_EFFECT_OPEN_LOCK_LIKE_CPP: u32 = 33; const REMOTE_MASTER_LOOT_COMMAND_TIMEOUT: Duration = Duration::from_millis(250); +#[derive(Clone)] +struct AuthoritativeLootReleaseLikeCpp { + authority: OwnedLootAuthority, + selected_generation: u64, + loot: CreatureLoot, + whole_object_fully_looted: bool, + whole_object_fully_skinned: bool, + object_generation: u64, + lifecycle_revision: u64, + require_no_viewers: bool, +} + // ── Handler registrations ───────────────────────────────────────── inventory::submit! { @@ -263,12 +295,11 @@ impl WorldSession { else { return; }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(req.unit); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(req.unit, player_guid); + self.represented_on_loot_opened_like_cpp(req.unit, player_guid, response); if !ae_owner_guids.is_empty() { self.send_packet(&AELootTargetsAck); @@ -279,8 +310,7 @@ impl WorldSession { .await { self.add_active_loot_view_owner_like_cpp(owner_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(owner_guid, player_guid); + self.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); self.send_packet(&AELootTargetsAck); } } @@ -339,10 +369,22 @@ impl WorldSession { let should_record_generation_effects = source.loot_id != 0 && !self.loot_table.contains_key(&gameobject_guid); + let allowed_looters = if source.is_personal_encounter_loot_like_cpp() { + Vec::new() + } else if source.uses_personal_loot_like_cpp() { + // C++ creates only `m_personalLoot[player]` for a personal chest + // without a DungeonEncounter; group loot rules never widen it. + vec![player_guid] + } else if source.use_group_loot_rules { + self.represented_group_looters_at_reward_distance_like_cpp(player_guid) + } else { + vec![player_guid] + }; self.ensure_represented_gameobject_chest_loot_like_cpp( gameobject_guid, player_guid, source, + &allowed_looters, ) .await; if should_record_generation_effects && self.loot_table.contains_key(&gameobject_guid) { @@ -354,21 +396,31 @@ impl WorldSession { ); } - if !source.is_personal_encounter_loot_like_cpp() - && let Some(loot) = self.loot_table.get_mut(&gameobject_guid) + if self + .sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid) + .is_none() { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); + self.loot_table.remove(&gameobject_guid); + return; } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; }; - if !self.represented_loot_can_be_opened_by_player_like_cpp( - gameobject_guid, - loot, - player_guid, - ) { + // C++ keeps and sends an empty non-encounter + // `m_personalLoot[player]`. Encounter generation instead discards + // empty pools in `GenerateDungeonEncounterPersonalLoot`, so only the + // former bypasses the generic item/money availability gate. + let empty_non_encounter_personal_pool = source.uses_personal_loot_like_cpp() + && !source.is_personal_encounter_loot_like_cpp() + && loot.allowed_looters.contains(&player_guid); + if !empty_non_encounter_personal_pool + && !self.represented_loot_can_be_opened_by_player_like_cpp( + gameobject_guid, + loot, + player_guid, + ) + { return; } @@ -390,12 +442,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } pub(crate) async fn open_represented_fishing_hole_like_cpp( @@ -443,6 +494,11 @@ impl WorldSession { if !self.represented_gameobject_exists_for_loot_like_cpp(gameobject_guid) { return; } + let install_observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid); + if install_observation.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + return; + } let loot_type = if junk { LOOT_TYPE_FISHING_JUNK_LIKE_CPP @@ -467,10 +523,14 @@ impl WorldSession { Vec::new() }); + let Some(loot_guid) = self.next_represented_loot_object_guid_like_cpp(gameobject_guid) + else { + return; + }; self.loot_table.insert( gameobject_guid, CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins: 0, unlooted_count: 0, loot_type, @@ -489,7 +549,25 @@ impl WorldSession { if let Some(loot) = self.loot_table.get_mut(&gameobject_guid) { mark_loot_allowed_for_player_like_cpp(loot, player_guid); } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); + let upserted = self + .loot_table + .get(&gameobject_guid) + .cloned() + .and_then(|loot| { + install_observation.as_ref().and_then(|observation| { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + false, + observation, + ) + }) + }); + if upserted.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&gameobject_guid); + return; + } let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; @@ -516,12 +594,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } pub(crate) async fn open_represented_gathering_node_like_cpp( @@ -803,6 +880,224 @@ impl WorldSession { queued } + fn represented_creature_is_dead_for_loot_visibility_like_cpp( + &self, + creature_guid: ObjectGuid, + ) -> bool { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + if let Some(manager) = self.map_manager.as_ref() + && let Some(creature) = manager + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature(map_id, instance_id, creature_guid) + { + return !creature.is_alive(); + } + + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; + let Some(manager) = self.canonical_map_manager.as_ref() else { + return false; + }; + let Ok(manager) = manager.lock() else { + return false; + }; + manager + .find_map(map_key.map_id, map_key.instance_id) + .and_then(|map| map.map().get_typed_creature(creature_guid)) + .is_some_and(|creature| !creature.is_alive()) + } + + fn creature_loot_release_values_for_viewer_like_cpp( + &self, + creature_guid: ObjectGuid, + viewer_guid: ObjectGuid, + viewer_has_pending_bind: bool, + authority: Option<&OwnedLootAuthority>, + mut update: wow_packet::packets::update::UnitDataValuesDeltaUpdate, + ) -> wow_packet::packets::update::UnitDataValuesDeltaUpdate { + let Some(object_data) = update.object_data.as_mut() else { + return update; + }; + if object_data.dynamic_flags & UnitDynFlags::Lootable as u32 == 0 { + return update; + } + let Some(authority) = authority else { + // The authority-less path exists only for bounded unit fixtures. + // Preserve the canonical flag rather than inventing per-viewer + // ownership without `Creature::GetLootForPlayer` evidence. + return update; + }; + + // C++ `ViewerDependentValue` removes + // UNIT_DYNFLAG_LOOTABLE when the complete `Player::isAllowedToLoot` + // predicate is false. The object-owned authority is the Rust + // equivalent of `Creature::GetLootForPlayer`; one exhausted personal + // pool must not hide a different player's still-live pool. + let creature_is_dead = + self.represented_creature_is_dead_for_loot_visibility_like_cpp(creature_guid); + let viewer_can_still_loot = authority + .snapshot_for_player_like_cpp(viewer_guid) + .is_some_and(|snapshot| { + creature_loot_is_allowed_to_player_like_cpp( + creature_is_dead, + viewer_has_pending_bind, + &snapshot.loot, + viewer_guid, + ) + }); + if !viewer_can_still_loot { + object_data.dynamic_flags &= !(UnitDynFlags::Lootable as u32); + } + update + } + + /// Publishes the dirty DynamicFlags field created by C++ + /// `WorldSession::DoLootRelease` to every same-map session that currently + /// has the creature at the client. The canonical object mutation alone is + /// insufficient until the global `Map::SendObjectUpdates` bridge owns + /// normal VALUES fanout. + fn send_creature_loot_release_dynamic_flags_update_like_cpp( + &self, + creature_guid: ObjectGuid, + values_update: &wow_entities::UnitValuesUpdate, + authority: Option<&OwnedLootAuthority>, + ) -> usize { + let Some(player_guid) = self.player_guid() else { + return 0; + }; + let Some(packet_update) = + crate::entity_update_bridge::unit_values_update_to_packet(values_update) + else { + return 0; + }; + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let mut sent = 0; + + if self.client_visible_guids_like_cpp.contains(&creature_guid) { + let source_update = self.creature_loot_release_values_for_viewer_like_cpp( + creature_guid, + player_guid, + self.pending_bind.is_some(), + authority, + packet_update.clone(), + ); + self.send_packet(&UpdateObject::unit_values_update( + creature_guid, + map_id, + source_update, + )); + sent += 1; + } + + let Some(registry) = self.player_registry() else { + return sent; + }; + let remote_command_txs = registry + .iter() + .filter_map(|entry| { + let (candidate_guid, candidate) = entry.pair(); + (*candidate_guid != player_guid + && candidate.is_in_world + && candidate.map_id == map_id + && candidate.instance_id == instance_id) + .then(|| candidate.command_tx.clone()) + }) + .collect::>(); + for command_tx in remote_command_txs { + // C++'s dirty-field pass cannot silently lose this forced update. + // Do not retain a DashMap guard (or any map/authority lock) while + // queueing the bounded target-session command rail. + if queue_creature_loot_release_command_reliably_like_cpp( + &command_tx, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp( + SendCreatureLootReleaseValuesUpdateLikeCppCommand { + creature_guid, + map_id, + instance_id, + unit_values_update: packet_update.clone(), + authority: authority.cloned(), + }, + ), + ) != CreatureLootReleaseCommandQueueOutcomeLikeCpp::Disconnected + { + sent += 1; + } + } + + sent + } + + /// Receiver-owned half of the loot-release VALUES fanout. Applying + /// `Player::isAllowedToLoot` here preserves session-local pending-bind + /// state and avoids serialising one player's dynamic flags for another. + fn handle_send_creature_loot_release_values_update_command_like_cpp( + &mut self, + command: SendCreatureLootReleaseValuesUpdateLikeCppCommand, + ) { + if self.state() != crate::session::SessionState::LoggedIn + || self.player_map_id_like_cpp() != command.map_id + { + return; + } + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + if instance_id != command.instance_id + || !self + .client_visible_guids_like_cpp + .contains(&command.creature_guid) + { + return; + } + if self.represented_can_receive_creature_message_to_set_by_guid_like_cpp( + command.creature_guid, + command.map_id, + command.instance_id, + false, + ) != Some(true) + { + return; + } + let Some(expected_authority) = command.authority.as_ref() else { + return; + }; + let Some(current_authority) = + self.represented_owned_loot_authority_like_cpp(command.creature_guid) + else { + return; + }; + if !current_authority.shares_storage_like_cpp(expected_authority) { + // The queued update belongs to an older corpse generation. C++ + // publishes synchronously before respawn; Rust must not apply the + // delayed VALUES delta to a replacement creature with the same GUID. + return; + } + let Some(viewer_guid) = self.player_guid() else { + return; + }; + let viewer_update = self.creature_loot_release_values_for_viewer_like_cpp( + command.creature_guid, + viewer_guid, + self.pending_bind.is_some(), + Some(expected_authority), + command.unit_values_update, + ); + self.send_packet(&UpdateObject::unit_values_update( + command.creature_guid, + command.map_id, + viewer_update, + )); + } + fn queue_gathering_node_gameobject_state_refresh_for_same_map_like_cpp( &self, gameobject_guid: ObjectGuid, @@ -1003,12 +1298,39 @@ impl WorldSession { return; } + // Fishing holes replace this player's personal `Loot` in place. Close + // the old C++ view before the upsert so its release cannot detach or + // apply lifecycle state to the freshly generated pool. + if replace_existing && self.has_active_non_item_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } + + // C++ serializes template generation and `ClearLoot` on the map + // thread. Rust awaits database-backed template generation, so retain + // the exact object lifetime and authority tombstone across that await. + let install_observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid); + if install_observation.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + return; + } + + if !replace_existing + && let Some(snapshot) = self + .represented_owned_loot_authority_like_cpp(gameobject_guid) + .and_then(|authority| authority.snapshot_for_player_like_cpp(player_guid)) + { + self.loot_table.insert(gameobject_guid, snapshot.loot); + self.represented_loot_cache_generations_like_cpp + .insert(gameobject_guid, snapshot.generation); + } + if replace_existing || !self.loot_table.contains_key(&gameobject_guid) { let items = self .generate_represented_gameobject_loot_items_for_store_like_cpp( loot_id, LootStoreKind::Gameobject, LOOT_MODE_DEFAULT_LIKE_CPP, + None, ) .await .unwrap_or_else(|| { @@ -1019,10 +1341,14 @@ impl WorldSession { ); Vec::new() }); + let Some(loot_guid) = self.next_represented_loot_object_guid_like_cpp(gameobject_guid) + else { + return; + }; self.loot_table.insert( gameobject_guid, CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins: 0, unlooted_count: 0, loot_type, @@ -1042,7 +1368,25 @@ impl WorldSession { if let Some(loot) = self.loot_table.get_mut(&gameobject_guid) { mark_loot_allowed_for_player_like_cpp(loot, player_guid); } - self.sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid); + self.represented_personal_loot_owners + .insert(gameobject_guid); + if let Some(loot) = self.loot_table.get(&gameobject_guid).cloned() { + let upserted = install_observation.as_ref().and_then(|observation| { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + replace_existing, + observation, + ) + }); + if upserted.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&gameobject_guid); + self.represented_personal_loot_owners + .remove(&gameobject_guid); + return; + } + } let Some(loot) = self.loot_table.get(&gameobject_guid) else { return; @@ -1069,12 +1413,11 @@ impl WorldSession { ae_looting: false, }; - if !self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item() { + if !replace_existing && self.has_active_non_item_loot_views_like_cpp() { self.do_loot_release_all_like_cpp(player_guid).await; } self.set_active_loot_guid(gameobject_guid); - self.send_packet(&response); - self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid); + self.represented_on_loot_opened_like_cpp(gameobject_guid, player_guid, response); } /// CMSG_LOOT_ITEM — player clicks to take a specific item from the loot. @@ -1093,7 +1436,6 @@ impl WorldSession { }; let mut taken_items: Vec<(ObjectGuid, ObjectGuid, u8, u32, u32, bool)> = Vec::new(); - let mut item_release: Vec = Vec::new(); let mut canonical_loot_sync: Vec = Vec::new(); for loot_req in &req.requests { @@ -1106,8 +1448,6 @@ impl WorldSession { continue; }; - self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); - if owner_guid.is_game_object() && !self.represented_gameobject_can_autostore_loot_item_like_cpp( owner_guid, @@ -1146,7 +1486,34 @@ impl WorldSession { } } - let Some((entry, dungeon_encounter_id)) = + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }) + .cloned(); + if authority.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + if let Some(authority) = authority.as_ref() { + if !self.represented_active_loot_generation_matches_like_cpp(owner_guid, authority) + { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + + let Some((cached_entry, dungeon_encounter_id)) = self.loot_table.get(&owner_guid).and_then(|loot| { loot.items .iter() @@ -1166,36 +1533,108 @@ impl WorldSession { continue; }; - if !entry.has_allowed_looter_like_cpp(player_guid) { + if !cached_entry.has_allowed_looter_like_cpp(player_guid) { self.send_packet(&LootReleaseAll); continue; } - if entry.flags.blocked { + if cached_entry.flags.blocked { self.send_packet(&LootReleaseAll); continue; } - if !entry.roll_winner_allows_like_cpp(player_guid) { + if !cached_entry.roll_winner_allows_like_cpp(player_guid) { self.send_packet(&LootReleaseAll); continue; } - if !self - .store_direct_loot_item_like_cpp(&entry, dungeon_encounter_id) + let (entry, claim) = if let Some(authority) = authority { + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + }; + let claim = match authority + .reserve_item_for_generation_like_cpp( + player_guid, + loot_req.loot_list_id, + expected_generation, + ) + .await + { + Ok(claim) => claim, + Err(_) => { + let _ = + self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(owner_guid, &claim) + { + claim.rollback_like_cpp(); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + } + let LootClaimPayload::Item(entry) = claim.payload_like_cpp() else { + claim.rollback_like_cpp(); + self.send_equip_error(InventoryResult::LootGone, None, None, 0, 0); + continue; + }; + (entry.clone(), Some(claim)) + } else { + (cached_entry, None) + }; + + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + loot_req.object, + claim, + ) .await - { + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if !stored { continue; } if owner_guid.is_item() { - self.delete_stored_item_loot_item_like_cpp( - owner_guid, - entry.item_id, - entry.quantity, - entry.loot_list_id, - ) - .await; + // The detached worker published this exact durable removal to + // the session tracker before its JoinHandle completed. Apply + // it here on the normal path; logout/disconnect and the + // session tick drain the same completion after cancellation. + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; + debug!( + account = self.account_id, + item = entry.item_id, + quantity = entry.quantity, + "Looted item" + ); + continue; + } + + if claim.is_some() { + debug!( + account = self.account_id, + item = entry.item_id, + quantity = entry.quantity, + "Looted item" + ); + continue; } if let Some(loot) = self.loot_table.get_mut(&owner_guid) { @@ -1220,10 +1659,6 @@ impl WorldSession { )); canonical_loot_sync.push(owner_guid); } - - if owner_guid.is_item() && loot_is_looted_like_cpp(loot) { - item_release.push(owner_guid); - } } } @@ -1251,18 +1686,6 @@ impl WorldSession { "Looted item" ); } - - item_release.sort_by_key(|guid| guid.counter()); - item_release.dedup(); - for loot_guid in item_release { - self.loot_table.remove(&loot_guid); - self.clear_active_loot_guid_if(loot_guid); - self.send_packet(&SLootRelease { - loot_obj: loot_guid, - owner: player_guid, - }); - self.destroy_fully_looted_direct_item(loot_guid).await; - } } /// CMSG_LOOT_MONEY — player takes money from the current loot view. @@ -1301,9 +1724,11 @@ impl WorldSession { .into_iter() .filter_map(|loot_guid| { let loot = self.loot_table.get(&loot_guid)?; - if loot_guid.is_creature_or_vehicle() - && !loot.allowed_looters.contains(&player_guid) - { + // C++ only places loot in Player::GetAELootView after the + // player passed the source's loot-eligibility gate. Keep the + // same invariant at this represented boundary so a stale or + // forged local view cannot take another player's money. + if !loot.allowed_looters.contains(&player_guid) { return None; } Some(( @@ -1320,36 +1745,312 @@ impl WorldSession { let mut item_release: Vec = Vec::new(); let mut player_money_delta = 0u64; - - for (loot_guid, _loot_obj, money) in &money_by_loot { - self.ensure_represented_player_looting_like_cpp(*loot_guid, player_guid); - self.represented_notify_money_removed_like_cpp(*loot_guid); - - let recipients = self.represented_loot_money_recipients_like_cpp(*loot_guid); - let money = u64::from(*money); - let money_per_player = money / recipients.len() as u64; - let sole_looter = recipients.len() <= 1; - - let notify = LootMoneyNotify { - money: money_per_player, - money_mod: 0, - sole_looter, - }; - - for recipient in recipients { - if recipient == player_guid { - self.send_packet(¬ify); - player_money_delta = player_money_delta.saturating_add(money_per_player); - } else if let Some(registry) = self.player_registry() { - if let Some(member) = registry.get(&recipient) { - let _ = member.send_tx.send(notify.to_bytes()); - } - } + let mut legacy_money_processed = false; + + for (loot_guid, loot_obj, money) in &money_by_loot { + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(*loot_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }) + .cloned(); + if authority.is_none() + && (loot_guid.is_creature_or_vehicle() || loot_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + debug!( + owner = ?loot_guid, + "world-object loot money has no shared authority; refusing session-local fallback" + ); + continue; } - - let personal_money_owner = self.represented_personal_loot_owners.contains(loot_guid); - if let Some(loot) = self.loot_table.get_mut(loot_guid) { - if personal_money_owner { + if let Some(authority) = authority { + if !self.represented_active_loot_generation_matches_like_cpp(*loot_guid, &authority) + { + debug!( + owner = ?loot_guid, + "delayed loot-money request does not belong to the active object generation" + ); + continue; + } + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + self.ensure_represented_player_looting_like_cpp(*loot_guid, player_guid); + + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(loot_guid) + .copied() + else { + continue; + }; + let claim = match authority + .reserve_money_for_generation_like_cpp(player_guid, expected_generation) + .await + { + Ok(claim) => claim, + Err(_) => { + let _ = + self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(*loot_guid, &claim) + { + claim.rollback_like_cpp(); + continue; + } + let LootClaimPayload::Money(reserved_money) = claim.payload_like_cpp() else { + claim.rollback_like_cpp(); + continue; + }; + let authority_generation = claim.generation_like_cpp(); + let mut recipients = self.represented_loot_money_recipients_like_cpp(*loot_guid); + recipients.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + recipients.dedup(); + if recipients.is_empty() { + recipients.push(player_guid); + } + let mut admitted = Vec::with_capacity(recipients.len()); + for recipient in recipients.iter().copied() { + let admission = if recipient == player_guid { + Some(( + self.session_command_tx(), + self.durable_loot_money_persistence_tracker_like_cpp(), + )) + } else { + self.player_registry() + .and_then(|registry| registry.get(&recipient)) + .map(|target| { + ( + target.command_tx.clone(), + Arc::clone(&target.durable_loot_money_tracker_like_cpp), + ) + }) + }; + let Some((command_tx, money_tracker)) = admission else { + admitted.clear(); + break; + }; + admitted.push((recipient, command_tx, money_tracker)); + } + + // Eligibility is chosen once, before persistence. If a + // connected eligible member cannot be admitted, retry the + // original pool instead of silently changing the divisor. + if admitted.len() != recipients.len() || admitted.is_empty() { + claim.rollback_like_cpp(); + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + + let money_per_player = u64::from(*reserved_money) / recipients.len() as u64; + let sole_looter = recipients.len() <= 1; + let payouts = recipients + .iter() + .copied() + .map(|recipient| (recipient, money_per_player)) + .collect::>(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let mut deliveries = Vec::with_capacity(admitted.len()); + let mut local_application = None; + for (recipient, command_tx, money_tracker) in admitted { + let application = ApplyLootMoneyLikeCppCommand { + recipient, + loot_owner: *loot_guid, + loot_obj: *loot_obj, + amount: money_per_player, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: money_tracker, + sole_looter, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + if recipient == player_guid { + local_application = Some(application.clone()); + } + deliveries.push(( + command_tx, + SessionCommand::ApplyLootMoneyLikeCpp(application), + )); + } + + let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: player_guid, + source_player: player_guid, + source_command_tx: self.session_command_tx(), + player_registry: self.player_registry().cloned(), + map_id: current_map, + instance_id: current_instance, + loot_owner: *loot_guid, + loot_obj: *loot_obj, + authority: authority.clone(), + authority_generation, + payout_recipients: recipients.iter().copied().collect(), + }; + + let persistence = match self.spawn_group_loot_money_persistence_like_cpp( + payouts, + claim, + deliveries, + authority_committed, + viewer_fanout, + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!( + owner = ?loot_guid, + recipients = recipients.len(), + amount = money_per_player, + %error, + "atomic loot-money fanout could not start; pool remains available" + ); + let _ = + self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + }; + + if let Err(error) = persistence.await.unwrap_or_else(|join_error| { + warn!( + owner = ?loot_guid, + ?join_error, + "atomic loot-money persistence worker terminated" + ); + Err(crate::session::LootMoneyPersistenceErrorLikeCpp::WorkerTerminated) + }) { + warn!( + owner = ?loot_guid, + recipients = recipients.len(), + amount = money_per_player, + %error, + "atomic loot-money fanout persistence failed; pool remains available" + ); + let _ = self.reconcile_represented_loot_cache_like_cpp(*loot_guid, player_guid); + continue; + } + if let Some(application) = local_application { + self.handle_apply_loot_money_like_cpp_command(application) + .await; + } + // The detached worker has already committed the authority and + // queued the durable runtime applications. Returning to the + // session loop lets this session drain its own command too. + continue; + } + + self.ensure_represented_player_looting_like_cpp(*loot_guid, player_guid); + + if loot_guid.is_item() { + let cached_amount = u64::from(*money); + let Some((balance_applied, publication_applied, applied_delta, notified_amount)) = + self.persist_and_consume_stored_item_money_like_cpp(*loot_guid, cached_amount) + .await + else { + continue; + }; + let apply_balance = balance_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + let publish = publication_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + if !apply_balance && !publish { + continue; + } + + if apply_balance { + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(applied_delta) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if applied_delta != 0 { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + if publish { + self.represented_notify_money_removed_like_cpp(*loot_guid); + self.send_packet(&LootMoneyNotify { + money: notified_amount, + money_mod: 0, + sole_looter: true, + }); + if let Some(loot) = self.loot_table.get_mut(loot_guid) { + loot.coins = 0; + if loot_is_looted_like_cpp(loot) { + item_release.push(*loot_guid); + } + } + } + if apply_balance || publish { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + continue; + } + + // Every live Creature/Vehicle/GameObject source must use its + // object-owned authority, and stored Item money has the atomic + // character/source-row transaction above. The remaining local + // cache path exists only for pre-authority unit fixtures. Refuse + // it in production so an unknown future owner type cannot publish + // CoinRemoved or clear its pool before durable money succeeds. + if !represented_local_loot_fixture_allowed_like_cpp() { + debug!( + owner = ?loot_guid, + "non-authoritative loot-money fallback is disabled in production" + ); + continue; + } + + legacy_money_processed = true; + self.represented_notify_money_removed_like_cpp(*loot_guid); + + let recipients = self.represented_loot_money_recipients_like_cpp(*loot_guid); + let money = u64::from(*money); + let money_per_player = money / recipients.len() as u64; + let sole_looter = recipients.len() <= 1; + + let notify = LootMoneyNotify { + money: money_per_player, + money_mod: 0, + sole_looter, + }; + + for recipient in recipients { + if recipient == player_guid { + self.send_packet(¬ify); + player_money_delta = player_money_delta.saturating_add(money_per_player); + } else if let Some(registry) = self.player_registry() { + if let Some(member) = registry.get(&recipient) { + let _ = member.send_tx.send(notify.to_bytes()); + } + } + } + + let personal_money_owner = self.represented_personal_loot_owners.contains(loot_guid); + if let Some(loot) = self.loot_table.get_mut(loot_guid) { + if personal_money_owner { self.represented_personal_loot_money .insert((*loot_guid, player_guid), 0); } else { @@ -1362,25 +2063,28 @@ impl WorldSession { } } - let old_money = self.player_gold_like_cpp(); - let new_money = old_money.saturating_add(player_money_delta); - if player_money_delta != 0 { - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - } - self.set_player_gold_like_cpp(new_money); - self.save_player_gold().await; - self.drain_represented_quest_objective_progress_like_cpp() - .await; - - for (loot_guid, _, _) in &money_by_loot { - if loot_guid.is_item() { - self.delete_stored_item_money_like_cpp(*loot_guid).await; + if legacy_money_processed { + if let Some((old_money, new_money)) = self + .mutate_and_persist_player_gold_exclusive_like_cpp(|old_money| { + crate::session::loot_money_durable_outcome_like_cpp( + old_money, + player_money_delta, + ) + .0 + }) + .await + { + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } } + self.drain_represented_quest_objective_progress_like_cpp() + .await; } for loot_guid in item_release { @@ -1401,7 +2105,13 @@ impl WorldSession { return Vec::new(); }; - if !loot_guid.is_creature() { + let Some(loot) = self.loot_table.get(&loot_guid) else { + return vec![player_guid]; + }; + // C++ shares only LOOT_CORPSE. Pickpocket money is creature-owned but + // personal; vehicle corpses still share even though their HighGuid is + // not Creature (`LootHandler.cpp::HandleLootMoneyOpcode`). + if loot.loot_type != LOOT_TYPE_CORPSE_LIKE_CPP { return vec![player_guid]; } @@ -1418,10 +2128,14 @@ impl WorldSession { }; let source_position = self.player_position_like_cpp().unwrap_or_default(); + let source_instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let mut recipients = Vec::new(); for member_guid in &group.members { - if !self.represented_loot_money_allowed_for_member_like_cpp(loot_guid, *member_guid) { + if !loot.allowed_looters.contains(member_guid) { continue; } @@ -1434,11 +2148,16 @@ impl WorldSession { continue; }; - if member.map_id != self.player_map_id_like_cpp() { + if !member.is_in_world + || member.map_id != self.player_map_id_like_cpp() + || member.instance_id != source_instance_id + { continue; } - if source_position.is_within_dist(&member.position, 74.0) { + if self.current_map_is_dungeon_like_cpp() + || source_position.is_within_dist(&member.position, 74.0) + { recipients.push(*member_guid); } } @@ -1467,130 +2186,602 @@ impl WorldSession { loot.coins } - fn represented_loot_unlooted_count_for_player_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, - ) -> u32 { - loot.items - .iter() - .filter(|entry| { - entry.has_allowed_looter_like_cpp(player_guid) - && !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) - }) - .count() - .try_into() - .unwrap_or(u32::MAX) - } - - fn sync_represented_gameobject_loot_to_canonical_like_cpp( + /// Clone the object-owned authority while the map/entity lock is held, + /// then release that lock before any reservation can await. + fn represented_owned_loot_authority_like_cpp( &mut self, - gameobject_guid: ObjectGuid, - player_guid: ObjectGuid, - ) -> Option<()> { - let loot = self.loot_table.get(&gameobject_guid)?.clone(); - let is_personal = self - .represented_personal_loot_owners - .contains(&gameobject_guid); - let personal_money = self.represented_personal_loot_money.clone(); - self.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, |gameobject| { - if is_personal { - gameobject.clear_shared_loot_like_cpp(); - let mut represented_looters = loot.allowed_looters.clone(); - if represented_looters.is_empty() && !player_guid.is_empty() { - represented_looters.push(player_guid); + owner_guid: ObjectGuid, + ) -> Option { + if owner_guid.is_creature_or_vehicle() { + // The legacy and canonical maps deliberately use separate locks. + // Reconcile optimistically with object-local compare/exchange; + // blind rebinding can otherwise clobber a newer respawn between + // the read and write phases. + for _ in 0..8 { + let canonical_player_map_key = self.current_canonical_player_map_key_like_cpp(); + let map_key = canonical_player_map_key + .or_else(|| { + self.canonical_object_lookup_map_key_like_cpp(u32::from( + self.player_map_id_like_cpp(), + )) + }) + .unwrap_or_else(|| { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + wow_map::MapKey::new(u32::from(map_id), instance_id) + }); + let map_key_still_valid = |session: &Self| { + session.loot_reconciliation_map_key_still_valid_like_cpp( + map_key, + canonical_player_map_key.is_some(), + ) + }; + let legacy = + self.read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key); + let canonical = self + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key); + let (legacy, canonical) = match (legacy, canonical) { + (Some(legacy), Some(canonical)) => (legacy, canonical), + (None, None) => return None, + (Some(authority), None) | (None, Some(authority)) => { + if !map_key_still_valid(self) { + continue; + } + return Some(authority); + } + }; + if !map_key_still_valid(self) { + continue; } - represented_looters - .sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); - represented_looters.dedup(); - for looter_guid in represented_looters { - let gold = personal_money - .get(&(gameobject_guid, looter_guid)) - .copied() - .unwrap_or(0); - let unlooted_count = Self::represented_loot_unlooted_count_for_player_like_cpp( - &loot, - looter_guid, - ); - gameobject.set_personal_loot_like_cpp( - looter_guid, - GameObjectOwnedLoot::new(gold, unlooted_count), - ); + + let legacy_stamp = legacy.stamp_like_cpp(); + let canonical_stamp = canonical.stamp_like_cpp(); + let selected = crate::session::reconcile_creature_loot_authority_mirrors_like_cpp( + &canonical, + canonical_stamp, + &legacy, + legacy_stamp, + ); + if !map_key_still_valid(self) { + continue; + } + if self + .rebind_canonical_creature_loot_authority_on_map_like_cpp( + owner_guid, + map_key, + &canonical, + canonical_stamp, + selected.clone(), + ) + .is_none() + { + continue; + } + if !map_key_still_valid(self) { + continue; + } + if self + .rebind_legacy_creature_loot_authority_on_map_like_cpp( + owner_guid, + map_key, + &legacy, + legacy_stamp, + selected.clone(), + ) + .is_none() + { + continue; + } + + if !map_key_still_valid(self) { + continue; + } + let converged_legacy = self + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .is_some_and(|authority| authority.shares_storage_like_cpp(&selected)); + let converged_canonical = self + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .is_some_and(|authority| authority.shares_storage_like_cpp(&selected)); + if converged_legacy && converged_canonical { + return Some(selected); } - } else { - gameobject.clear_personal_loot_like_cpp(); - gameobject.set_shared_loot_like_cpp(GameObjectOwnedLoot::new( - loot.coins, - u32::from(loot.unlooted_count), - )); } - }) - .map(|_| ()) - } - fn sync_represented_creature_loot_to_canonical_like_cpp( - &mut self, - creature_guid: ObjectGuid, - _player_guid: ObjectGuid, - ) -> Option<()> { - let loot = self.loot_table.get(&creature_guid)?.clone(); - let summary = CreatureOwnedLoot::new(loot.coins, u32::from(loot.unlooted_count)); - if self - .mutate_world_creature(creature_guid, |world_creature| { - world_creature.creature.clear_personal_loot_like_cpp(); - world_creature.creature.set_shared_loot_like_cpp(summary); - }) - .is_some() - { - return Some(()); + // Continuous concurrent replacement is safer as a failed request + // than as an overwrite of the newest mirror. + return None; } - self.mutate_canonical_creature_by_guid_like_cpp(creature_guid, |creature| { - creature.clear_personal_loot_like_cpp(); - creature.set_shared_loot_like_cpp(summary); - }) - .map(|_| ()) + if owner_guid.is_game_object() { + let canonical_player_map_key = self.current_canonical_player_map_key_like_cpp(); + let map_key = canonical_player_map_key.or_else(|| { + self.canonical_object_lookup_map_key_like_cpp(u32::from( + self.player_map_id_like_cpp(), + )) + })?; + let authority = + self.read_canonical_gameobject_loot_authority_on_map_like_cpp(owner_guid, map_key)?; + let still_valid = self.loot_reconciliation_map_key_still_valid_like_cpp( + map_key, + canonical_player_map_key.is_some(), + ); + return still_valid.then_some(authority); + } + + None } - fn refresh_represented_loot_owner_canonical_summary_like_cpp( + /// Bridge pre-authority represented fixtures (and the equivalent first + /// live generation) into the object-owned source of truth exactly once. + /// A retired non-zero generation is never reinstalled from session cache. + fn prepare_owned_loot_authority_for_active_request_like_cpp( &mut self, owner_guid: ObjectGuid, - player_guid: ObjectGuid, - ) { + scope_player: ObjectGuid, + ) -> Option { + let authority = self.represented_owned_loot_authority_like_cpp(owner_guid)?; + let can_install_first_generation = represented_local_loot_fixture_allowed_like_cpp() + && authority.is_retired_like_cpp() + && authority.generation_like_cpp() == 0 + && self.loot_table.contains_key(&owner_guid) + && (self.active_loot_view_owners.contains(&owner_guid) + || self.is_active_loot_guid(owner_guid)); + if !can_install_first_generation { + return Some(authority); + } + if owner_guid.is_game_object() { let _ = self - .sync_represented_gameobject_loot_to_canonical_like_cpp(owner_guid, player_guid); + .sync_represented_gameobject_loot_to_canonical_like_cpp(owner_guid, scope_player); } else if owner_guid.is_creature_or_vehicle() { let _ = - self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); + self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, scope_player); + } + + let authority = self.represented_owned_loot_authority_like_cpp(owner_guid)?; + if let Some(snapshot) = authority.snapshot_for_player_like_cpp(scope_player) { + self.active_loot_view_generations_like_cpp + .entry(owner_guid) + .or_insert(snapshot.generation); + self.active_loot_view_authorities_like_cpp + .entry(owner_guid) + .or_insert_with(|| authority.clone()); } + Some(authority) } - fn canonical_creature_fully_looted_after_represented_sync_like_cpp( + /// Refresh the session-local window from the object-owned source of truth. + /// The local table remains a packet-building cache only. + fn reconcile_represented_loot_cache_like_cpp( &mut self, - creature_guid: ObjectGuid, + owner_guid: ObjectGuid, player_guid: ObjectGuid, - fallback_fully_looted: bool, ) -> bool { - if self - .sync_represented_creature_loot_to_canonical_like_cpp(creature_guid, player_guid) - .is_some() - { - return self - .mutate_canonical_creature_by_guid_like_cpp(creature_guid, |creature| { - creature.is_fully_looted_like_cpp() - }) - .unwrap_or(fallback_fully_looted); - } + let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) else { + return false; + }; + let Some(snapshot) = authority.snapshot_for_player_like_cpp(player_guid) else { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + return false; + }; + self.cache_represented_owned_loot_snapshot_like_cpp(owner_guid, player_guid, snapshot); + true + } - fallback_fully_looted + /// Rebuild every session-local field derived from one authoritative + /// snapshot. In particular, a reopened personal creature view must restore + /// its personal-owner marker and per-player money mirror; restoring only + /// `loot_table` would make the same pool behave as shared loot. + fn cache_represented_owned_loot_snapshot_like_cpp( + &mut self, + owner_guid: ObjectGuid, + _requested_player_guid: ObjectGuid, + snapshot: OwnedLootSnapshot, + ) { + let OwnedLootSnapshot { + generation, + scope, + loot, + } = snapshot; + // One WorldSession caches exactly one selected pool for an owner. + // Generation scratch may have populated money entries for every + // encounter tapper, but those peer pools now live in the authority; + // retaining their session-local markers can misclassify a later + // shared snapshot as personal loot. + self.represented_personal_loot_money + .retain(|(owner, _), _| *owner != owner_guid); + self.represented_personal_loot_owners.remove(&owner_guid); + match scope { + OwnedLootScope::Personal(scope_player_guid) => { + self.represented_personal_loot_owners.insert(owner_guid); + self.represented_personal_loot_money + .insert((owner_guid, scope_player_guid), loot.coins); + } + OwnedLootScope::Shared => {} + } + self.loot_table.insert(owner_guid, loot); + self.represented_loot_cache_generations_like_cpp + .insert(owner_guid, generation); } - fn canonical_gameobject_fully_looted_after_represented_sync_like_cpp( + /// Drops only this session/player's packet-building mirror. The canonical + /// object-owned authority remains the source of truth and rehydrates a + /// later open. C++ has no session-owned `Loot` clone after a window closes. + fn discard_represented_personal_loot_cache_for_player_like_cpp( &mut self, - gameobject_guid: ObjectGuid, - player_guid: ObjectGuid, - fallback_fully_looted: bool, - ) -> bool { + owner_guid: ObjectGuid, + _player_guid: ObjectGuid, + ) { + self.loot_table.remove(&owner_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&owner_guid); + self.represented_personal_loot_money + .retain(|(owner, _), _| *owner != owner_guid); + self.represented_personal_loot_owners.remove(&owner_guid); + } + + fn refresh_owned_loot_summary_like_cpp(&mut self, owner_guid: ObjectGuid) { + if owner_guid.is_creature_or_vehicle() { + if let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) { + let _ = self.rebind_legacy_creature_loot_authority_like_cpp( + owner_guid, + &authority, + authority.stamp_like_cpp(), + authority.clone(), + ); + let authority_stamp = authority.stamp_like_cpp(); + let _ = self.rebind_canonical_creature_loot_authority_like_cpp( + owner_guid, + &authority, + authority_stamp, + authority.clone(), + ); + } + } else if owner_guid.is_game_object() { + if let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) { + let _ = self.rebind_canonical_gameobject_loot_authority_like_cpp( + owner_guid, + &authority, + authority.stamp_like_cpp(), + authority.clone(), + ); + } + } + } + + fn represented_loot_authority_pools_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + personal: bool, + ) -> Option<(Option, HashMap)> { + if !personal { + return Some((Some(loot), HashMap::new())); + } + + let mut looters = loot.allowed_looters.clone(); + if looters.is_empty() && !player_guid.is_empty() { + looters.push(player_guid); + } + looters.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + + let mut personal_loot = HashMap::new(); + for (index, looter) in looters.into_iter().enumerate() { + let mut pool = loot.clone(); + if index != 0 { + pool.loot_guid = self.next_represented_loot_object_guid_like_cpp(owner_guid)?; + } + pool.coins = self + .represented_personal_loot_money + .get(&(owner_guid, looter)) + .copied() + .unwrap_or(0); + pool.allowed_looters = vec![looter]; + pool.players_looting.retain(|viewer| *viewer == looter); + pool.items.retain(|entry| { + entry.allowed_looters.is_empty() || entry.allowed_looters.contains(&looter) + }); + for entry in &mut pool.items { + entry.allowed_looters = vec![looter]; + } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(&mut pool); + personal_loot.insert(looter, pool); + } + + Some((None, personal_loot)) + } + + /// Runtime-facing allocator. Production has no fallback: the `cfg(test)` + /// branch exists only so older packet-cache fixtures can retain their + /// deterministic owner-derived identity while they are migrated to typed + /// canonical map objects. + fn next_represented_loot_object_guid_like_cpp( + &mut self, + owner_guid: ObjectGuid, + ) -> Option { + let canonical = self.next_canonical_loot_object_guid_like_cpp(owner_guid); + #[cfg(test)] + { + canonical.or_else(|| { + (!owner_guid.is_empty()).then(|| represented_loot_object_guid_like_cpp(owner_guid)) + }) + } + #[cfg(not(test))] + { + canonical + } + } + + /// Mirrors `Loot::Loot(Map*)`: every concrete pool receives a fresh + /// map-owned `HighGuid::LootObject` low GUID. This strict helper always + /// fails closed when the owner's exact canonical map is unavailable. + fn next_canonical_loot_object_guid_like_cpp( + &mut self, + owner_guid: ObjectGuid, + ) -> Option { + (|| { + // Map 0 (Eastern Kingdoms) is a real map, not an unspecified + // sentinel. C++ allocates from the owner's exact `GetMap()`. + let owner_map_id = u32::from(owner_guid.map_id()); + let key = self.canonical_object_lookup_map_key_like_cpp(owner_map_id)?; + if key.map_id != owner_map_id { + return None; + } + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + let map = manager.find_map_mut(key.map_id, key.instance_id)?.map_mut(); + let counter = map.generate_low_guid_like_cpp(HighGuid::LootObject).ok()?; + let map_id = u16::try_from(key.map_id).ok()?; + // C++ passes realm id 0 to ObjectGuidFactory, where + // `GetRealmIdForObjectGuid(0)` substitutes the active realm. + // Rust's factory is explicit, so pass the session realm here. + Some(ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + self.realm_id(), + map_id, + 0, + 0, + counter, + )) + })() + } + + fn sync_represented_gameobject_loot_to_canonical_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + ) -> Option<()> { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(gameobject_guid) + else { + return (represented_local_loot_fixture_allowed_like_cpp() + && self.loot_table.contains_key(&gameobject_guid)) + .then_some(()); + }; + let loot = self.loot_table.get(&gameobject_guid)?.clone(); + let is_personal = self + .represented_personal_loot_owners + .contains(&gameobject_guid); + let (shared, personal) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + is_personal, + )?; + let installed = authority + .initialize_pristine_like_cpp(shared, personal) + .installed(); + if !installed + && authority + .snapshot_for_player_like_cpp(player_guid) + .is_none() + { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return None; + } + self.refresh_owned_loot_summary_like_cpp(gameobject_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid); + Some(()) + } + + fn upsert_represented_personal_gameobject_loot_authority_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + ) -> Option<()> { + let observation = + self.represented_gameobject_loot_install_observation_like_cpp(gameobject_guid)?; + self.upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + gameobject_guid, + player_guid, + loot, + replace, + &observation, + ) + } + + fn represented_gameobject_loot_install_observation_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + ) -> Option { + self.represented_gameobject_loot_install_observation_result_like_cpp(gameobject_guid)? + } + + /// Preserves the distinction between a missing canonical owner (`None`) + /// and an owner whose current lifecycle rejects generation (`Some(None)`). + /// Test-only packet fixtures may fall back only for the former. + fn represented_gameobject_loot_install_observation_result_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + ) -> Option> { + self.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, |gameobject| { + (gameobject.loot_state() != LootState::JustDeactivated).then(|| { + let authority = gameobject.loot_authority_like_cpp().clone(); + RepresentedGameObjectLootInstallObservationLikeCpp { + object_generation: authority.generation_like_cpp(), + authority, + loot_lifecycle_revision: gameobject.loot_lifecycle_revision_like_cpp(), + } + }) + }) + } + + fn upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + observation: &RepresentedGameObjectLootInstallObservationLikeCpp, + ) -> Option<()> { + self.upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + player_guid, + loot, + replace, + false, + observation, + ) + } + + fn upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + loot: CreatureLoot, + replace: bool, + discard_empty_pool: bool, + observation: &RepresentedGameObjectLootInstallObservationLikeCpp, + ) -> Option<()> { + let (_, mut personal) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + true, + )?; + let pool = personal.remove(&player_guid)?; + if discard_empty_pool && loot_is_looted_like_cpp(&pool) { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + gameobject_guid, + player_guid, + ); + return None; + } + let installed = + self.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, move |gameobject| { + gameobject.install_personal_loot_if_lifecycle_like_cpp( + &observation.authority, + observation.object_generation, + observation.loot_lifecycle_revision, + player_guid, + pool, + replace, + ) + }); + if installed != Some(true) { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + gameobject_guid, + player_guid, + ); + return None; + } + if !self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid) { + return None; + } + Some(()) + } + + fn sync_represented_creature_loot_to_canonical_like_cpp( + &mut self, + creature_guid: ObjectGuid, + _player_guid: ObjectGuid, + ) -> Option<()> { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(creature_guid) else { + return (represented_local_loot_fixture_allowed_like_cpp() + && self.loot_table.contains_key(&creature_guid)) + .then_some(()); + }; + let loot = self.loot_table.get(&creature_guid)?.clone(); + let is_personal = self + .represented_personal_loot_owners + .contains(&creature_guid); + let (shared, personal) = self.represented_loot_authority_pools_like_cpp( + creature_guid, + _player_guid, + loot, + is_personal, + )?; + let installed = authority + .initialize_pristine_like_cpp(shared, personal) + .installed(); + if !installed + && authority + .snapshot_for_player_like_cpp(_player_guid) + .is_none() + { + self.loot_table.remove(&creature_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&creature_guid); + return None; + } + self.refresh_owned_loot_summary_like_cpp(creature_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp(creature_guid, _player_guid); + Some(()) + } + + fn refresh_represented_loot_owner_canonical_summary_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + ) { + if owner_guid.is_game_object() { + let _ = self + .sync_represented_gameobject_loot_to_canonical_like_cpp(owner_guid, player_guid); + } else if owner_guid.is_creature_or_vehicle() { + if self + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid) + .is_none() + { + self.loot_table.remove(&owner_guid); + return; + } + } + } + + fn canonical_creature_fully_looted_after_represented_sync_like_cpp( + &mut self, + creature_guid: ObjectGuid, + player_guid: ObjectGuid, + fallback_fully_looted: bool, + ) -> bool { + if self + .sync_represented_creature_loot_to_canonical_like_cpp(creature_guid, player_guid) + .is_some() + { + return self + .mutate_canonical_creature_by_guid_like_cpp(creature_guid, |creature| { + creature.is_fully_looted_like_cpp() + }) + .unwrap_or(fallback_fully_looted); + } + + fallback_fully_looted + } + + fn canonical_gameobject_fully_looted_after_represented_sync_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + fallback_fully_looted: bool, + ) -> bool { if self .sync_represented_gameobject_loot_to_canonical_like_cpp(gameobject_guid, player_guid) .is_some() @@ -1620,33 +2811,11 @@ impl WorldSession { loot_can_be_opened_by_player_like_cpp(loot, player_guid) } - fn represented_loot_money_allowed_for_member_like_cpp( - &self, - loot_guid: ObjectGuid, - member_guid: ObjectGuid, - ) -> bool { - let Some(loot) = self.loot_table.get(&loot_guid) else { - return false; - }; - - if loot - .items - .iter() - .all(|item| item.allowed_looters.is_empty()) - { - return true; - } - - loot.items - .iter() - .any(|item| item.allowed_looters.contains(&member_guid)) - } - /// CMSG_LOOT_RELEASE — player closes the loot window. /// - /// C# ref: `LootHandler.DoLootRelease` (creature branch): - /// if loot.IsLooted() && creature.IsFullyLooted() → RemoveDynamicFlag(Lootable) - /// → creature.AllLootRemovedFromCorpse() → sets `m_corpseRemoveTime = now + decay` + /// C++ `WorldSession::DoLootRelease` creature branch: + /// `loot->isLooted() && creature->IsFullyLooted()` removes the lootable + /// dynamic flag and calls `Creature::AllLootRemovedFromCorpse` for a corpse. pub async fn handle_loot_release(&mut self, mut pkt: wow_packet::WorldPacket) { let req = match LootRelease::read(&mut pkt) { Ok(r) => r, @@ -1708,21 +2877,29 @@ impl WorldSession { }; let roll_key = (roll.loot_obj, roll.loot_list_id); - let mut command_tx = None; + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let mut command_target = None; for owner in registry.iter() { if *owner.key() == player_guid { continue; } - if owner.map_id != self.player_map_id_like_cpp() { + if owner.map_id != self.player_map_id_like_cpp() || owner.instance_id != instance_id { continue; } - if owner.active_loot_rolls.contains(&roll_key) { - command_tx = Some(owner.command_tx.clone()); + if let Some(identity) = owner + .active_loot_rolls + .iter() + .find(|identity| identity.matches_key_like_cpp(roll_key.0, roll_key.1)) + { + command_target = Some((owner.command_tx.clone(), identity.clone())); break; } } - let Some(command_tx) = command_tx else { + let Some((command_tx, roll_identity)) = command_target else { return false; }; @@ -1733,6 +2910,7 @@ impl WorldSession { loot_list_id: roll.loot_list_id, roll_type: roll.roll_type, pass_on_group_loot: self.pass_on_group_loot, + roll_identity, })) .is_ok() } @@ -1756,14 +2934,23 @@ impl WorldSession { player_guid: ObjectGuid, pass_on_group_loot: bool, ) -> bool { - if pass_on_group_loot { + let roll_key = (roll.loot_obj, roll.loot_list_id); + let Some(roll_state) = self.represented_loot_rolls.get(&roll_key).cloned() else { return false; + }; + if self + .represented_current_loot_roll_authority_like_cpp(&roll_state) + .is_none() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, &roll_state); + return true; } - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(roll.loot_obj) - else { + if pass_on_group_loot { return false; - }; + } + + let owner_guid = roll_state.owner_guid; let Some(loot) = self.loot_table.get(&owner_guid) else { return false; @@ -1837,6 +3024,53 @@ impl WorldSession { true } + /// A represented roll is scoped to one lifetime of the object-owned Loot. + /// + /// C++ destroys `LootRoll` together with its owning `Loot`. Rust keeps the + /// packet-facing roll state in the session, so a recycled object GUID must + /// not let that stale state unblock or award an item from a later lifetime. + fn represented_current_loot_roll_authority_like_cpp( + &mut self, + state: &RepresentedLootRollState, + ) -> Option { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(state.owner_guid) + else { + return None; + }; + + if !authority.shares_storage_like_cpp(&state.authority) { + return None; + } + let player_guid = match state.authority_scope { + wow_loot::OwnedLootScope::Shared => self.player_guid()?, + wow_loot::OwnedLootScope::Personal(player_guid) => player_guid, + }; + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| { + snapshot.scope == state.authority_scope + && snapshot.generation == state.authority_generation + && snapshot.loot.loot_guid == state.loot_obj + }) + .then_some(authority) + } + + fn cancel_represented_loot_roll_generation_mismatch_like_cpp( + &mut self, + key: (ObjectGuid, u8), + state: &RepresentedLootRollState, + ) { + debug!( + owner = ?state.owner_guid, + loot_obj = ?state.loot_obj, + loot_list_id = state.loot_list_id, + authority_generation = state.authority_generation, + "represented loot roll cancelled after owner loot generation changed" + ); + self.represented_loot_rolls.remove(&key); + self.publish_represented_loot_roll_ownership_like_cpp(); + } + async fn finish_represented_loot_roll_like_cpp( &mut self, loot_obj: ObjectGuid, @@ -1845,19 +3079,64 @@ impl WorldSession { winner: Option<(ObjectGuid, RepresentedLootRollVote)>, finished_state: Option<&RepresentedLootRollState>, ) { - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(loot_obj) else { + let Some(state) = finished_state else { return; }; + let roll_key = (loot_obj, loot_list_id); + if state.loot_obj != loot_obj || state.loot_list_id != loot_list_id { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + let Some(authority) = self.represented_current_loot_roll_authority_like_cpp(state) else { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + }; + let owner_guid = state.owner_guid; let dungeon_encounter_id = self .loot_table .get(&owner_guid) .map(|loot| loot.dungeon_encounter_id as i32) .unwrap_or(0); - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - if let Some(loot_entry) = loot - .items - .iter_mut() + let winner_guid = winner.as_ref().map(|(guid, _)| *guid); + let scope_player = winner_guid + .or_else(|| self.player_guid()) + .unwrap_or(ObjectGuid::EMPTY); + let claim = if let Some(winner_guid) = winner_guid { + match authority.finish_item_roll_and_reserve_award_like_cpp( + scope_player, + state.authority_generation, + loot_list_id, + winner_guid, + ) { + Ok(claim) => Some(claim), + Err(_) => { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + } + } else { + if authority + .finish_item_roll_like_cpp( + scope_player, + state.authority_generation, + loot_list_id, + false, + None, + ) + .is_err() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp(roll_key, state); + return; + } + None + }; + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, scope_player); + + if let Some(loot) = self.loot_table.get_mut(&owner_guid) { + if let Some(loot_entry) = loot + .items + .iter_mut() .find(|loot_entry| loot_entry.loot_list_id == loot_list_id) { loot_entry.flags.blocked = false; @@ -1928,6 +3207,7 @@ impl WorldSession { entry, winner_guid, winner_vote, + claim, ) .await; } @@ -2111,7 +3391,11 @@ impl WorldSession { let Some(player) = registry.get(&target) else { return; }; - if player.map_id != self.player_map_id_like_cpp() { + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { return; } @@ -2129,6 +3413,10 @@ impl WorldSession { }; let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for looter in &entry.allowed_looters { if Some(*looter) == except { continue; @@ -2145,7 +3433,7 @@ impl WorldSession { let Some(player) = registry.get(looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -2160,6 +3448,10 @@ impl WorldSession { except: Option, ) { let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for (player_guid, vote) in &state.voters { if vote.vote == ROLL_VOTE_NOT_VALID_LIKE_CPP { continue; @@ -2179,7 +3471,7 @@ impl WorldSession { let Some(player) = registry.get(player_guid) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -2243,6 +3535,41 @@ impl WorldSession { return; } + let owned_authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid); + let authority = owned_authority + .as_ref() + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(master_loot_item.target) + .is_some() + }) + .cloned(); + if authority.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && (owned_authority.is_some() || !represented_local_loot_fixture_allowed_like_cpp()) + { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + if let Some(authority) = authority.as_ref() { + if !self.represented_active_loot_generation_matches_like_cpp(owner_guid, authority) + { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + let _ = self + .reconcile_represented_loot_cache_like_cpp(owner_guid, master_loot_item.target); + } + let Some(loot) = self.loot_table.get(&owner_guid) else { return; }; @@ -2286,22 +3613,88 @@ impl WorldSession { return; } - let entry = item.clone(); - if master_loot_item.target == player_guid { - if !self - .store_direct_loot_item_like_cpp(&entry, dungeon_encounter_id) + let mut entry = item.clone(); + let claim = if let Some(authority) = authority { + let Some(expected_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + }; + let claim = match authority + .reserve_item_for_award_generation_like_cpp( + master_loot_item.target, + req.loot_list_id, + expected_generation, + ) .await { + Ok(claim) => claim, + Err(_) => { + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); + return; + } + }; + if !self + .represented_active_loot_claim_generation_matches_like_cpp(owner_guid, &claim) + { + claim.rollback_like_cpp(); + self.send_loot_error_like_cpp( + req.object, + owner_guid, + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + ); return; } - self.mark_represented_master_loot_item_removed_like_cpp( - owner_guid, - req.object, - req.loot_list_id, - master_loot_item.target, - ); + if let LootClaimPayload::Item(reserved_entry) = claim.payload_like_cpp() { + entry = reserved_entry.clone(); + } + Some(claim) + } else { + None + }; + if master_loot_item.target == player_guid { + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + req.object, + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if !stored { + return; + } + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + req.object, + req.loot_list_id, + master_loot_item.target, + ); + } current_session_assignments = current_session_assignments.saturating_add(1); } else { + let authoritative_claim = claim.is_some(); match self .request_represented_remote_master_loot_give_like_cpp( master_loot_item.target, @@ -2310,10 +3703,11 @@ impl WorldSession { req.loot_list_id, dungeon_encounter_id, entry, + claim, ) .await { - MasterLootGiveResult::Stored => { + MasterLootGiveResult::Stored if !authoritative_claim => { self.mark_represented_master_loot_item_removed_like_cpp( owner_guid, req.object, @@ -2321,6 +3715,7 @@ impl WorldSession { master_loot_item.target, ); } + MasterLootGiveResult::Stored => {} MasterLootGiveResult::StoreFailed(error) => { self.send_loot_error_like_cpp(req.object, owner_guid, error); return; @@ -2354,6 +3749,7 @@ impl WorldSession { loot_list_id: u8, dungeon_encounter_id: u32, entry: LootEntry, + claim: Option, ) -> MasterLootGiveResult { let Some(player_guid) = self.player_guid() else { return MasterLootGiveResult::TargetMismatch; @@ -2376,6 +3772,7 @@ impl WorldSession { loot_list_id, dungeon_encounter_id, entry, + claim, result_tx, }); @@ -2398,6 +3795,7 @@ impl WorldSession { entry: &LootEntry, winner_guid: ObjectGuid, winner_vote: RepresentedLootRollVote, + claim: Option, ) { let dungeon_encounter_id = self .loot_table @@ -2405,23 +3803,45 @@ impl WorldSession { .map(|loot| loot.dungeon_encounter_id) .unwrap_or(0); if winner_vote.vote == ROLL_VOTE_DISENCHANT_LIKE_CPP { + let reserved_entry = claim + .as_ref() + .and_then(|claim| match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => Some(entry), + LootClaimPayload::Money(_) => None, + }) + .unwrap_or(entry); if self .store_represented_disenchant_loot_winner_like_cpp( owner_guid, loot_obj, loot_list_id, - entry, + reserved_entry, winner_guid, dungeon_encounter_id, + claim.as_ref(), ) .await { - self.mark_represented_master_loot_item_removed_like_cpp( - owner_guid, - loot_obj, - loot_list_id, - winner_guid, - ); + if self.player_guid() == Some(winner_guid) { + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } + } else if claim.is_none() { + // Object-owned claims are committed and fanned out by the + // remote target session. The legacy cache-only fallback + // still has to be retired by the source session. + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } } return; } @@ -2440,23 +3860,45 @@ impl WorldSession { .cloned() }) .unwrap_or_else(|| entry.clone()); + if let Some(claim) = claim.as_ref() + && let LootClaimPayload::Item(reserved_entry) = claim.payload_like_cpp() + { + store_entry = reserved_entry.clone(); + } store_entry.roll_winner = winner_guid; if self.player_guid() == Some(winner_guid) { - if self - .store_direct_loot_item_like_cpp(&store_entry, dungeon_encounter_id) - .await - { - self.mark_represented_master_loot_item_removed_like_cpp( + let stored = if let Some(claim) = claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &store_entry, + dungeon_encounter_id, owner_guid, loot_obj, - loot_list_id, - winner_guid, - ); + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &store_entry, + dungeon_encounter_id, + owner_guid, + ) + .await + }; + if stored { + if claim.is_none() { + self.mark_represented_master_loot_item_removed_like_cpp( + owner_guid, + loot_obj, + loot_list_id, + winner_guid, + ); + } } return; } + let authoritative_claim = claim.is_some(); match self .request_represented_remote_loot_roll_winner_store_like_cpp( winner_guid, @@ -2464,11 +3906,13 @@ impl WorldSession { loot_obj, loot_list_id, dungeon_encounter_id, - store_entry, + vec![store_entry], + false, + claim, ) .await { - MasterLootGiveResult::Stored => { + MasterLootGiveResult::Stored if !authoritative_claim => { self.mark_represented_master_loot_item_removed_like_cpp( owner_guid, loot_obj, @@ -2476,6 +3920,7 @@ impl WorldSession { winner_guid, ); } + MasterLootGiveResult::Stored => {} MasterLootGiveResult::StoreFailed(error) => { debug!( account = self.account_id, @@ -2506,6 +3951,7 @@ impl WorldSession { entry: &LootEntry, winner_guid: ObjectGuid, dungeon_encounter_id: u32, + claim: Option<&LootClaimLease>, ) -> bool { let Some(template) = self .item_stats_store() @@ -2533,55 +3979,58 @@ impl WorldSession { } if self.player_guid() == Some(winner_guid) { - for disenchant_entry in &disenchant_entries { - if !self - .store_direct_loot_item_like_cpp(disenchant_entry, dungeon_encounter_id) - .await - { - return false; - } - } - return true; + return self + .store_direct_disenchant_batch_like_cpp( + &disenchant_entries, + dungeon_encounter_id, + claim, + claim.map(|_| LootItemClaimCommitContextLikeCpp { + owner_guid, + loot_obj, + loot_list_id, + player_guid: winner_guid, + free_for_all: entry.flags.freeforall, + }), + ) + .await; } - for disenchant_entry in disenchant_entries { - match self - .request_represented_remote_loot_roll_winner_store_like_cpp( - winner_guid, - owner_guid, - loot_obj, + match self + .request_represented_remote_loot_roll_winner_store_like_cpp( + winner_guid, + owner_guid, + loot_obj, + loot_list_id, + dungeon_encounter_id, + disenchant_entries, + true, + claim.cloned(), + ) + .await + { + MasterLootGiveResult::Stored => true, + MasterLootGiveResult::StoreFailed(error) => { + debug!( + account = self.account_id, + winner = ?winner_guid, + loot_obj = ?loot_obj, loot_list_id, - dungeon_encounter_id, - disenchant_entry, - ) - .await - { - MasterLootGiveResult::Stored => {} - MasterLootGiveResult::StoreFailed(error) => { - debug!( - account = self.account_id, - winner = ?winner_guid, - loot_obj = ?loot_obj, - loot_list_id, - error, - "represented disenchant loot winner store failed in target session" - ); - return false; - } - MasterLootGiveResult::TargetMismatch => { - debug!( - account = self.account_id, - winner = ?winner_guid, - loot_obj = ?loot_obj, - loot_list_id, - "represented disenchant loot winner target was not connected" - ); - return false; - } + error, + "represented disenchant loot winner batch failed in target session" + ); + false + } + MasterLootGiveResult::TargetMismatch => { + debug!( + account = self.account_id, + winner = ?winner_guid, + loot_obj = ?loot_obj, + loot_list_id, + "represented disenchant loot winner target was not connected" + ); + false } } - - true } async fn generate_represented_disenchant_loot_template_entries_like_cpp( @@ -2814,7 +4263,9 @@ impl WorldSession { loot_obj: ObjectGuid, loot_list_id: u8, dungeon_encounter_id: u32, - entry: LootEntry, + entries: Vec, + is_disenchant: bool, + claim: Option, ) -> MasterLootGiveResult { let Some(registry) = self.player_registry() else { return MasterLootGiveResult::TargetMismatch; @@ -2832,7 +4283,9 @@ impl WorldSession { loot_obj, loot_list_id, dungeon_encounter_id, - entry, + entries, + is_disenchant, + claim, result_tx, }); @@ -2848,6 +4301,8 @@ impl WorldSession { } pub(crate) async fn process_represented_session_commands_like_cpp(&mut self) { + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; let commands = self.drain_session_commands(); for command in commands { match command { @@ -2868,6 +4323,12 @@ impl WorldSession { SessionCommand::CreatureAttackStartLikeCpp(command) => { self.handle_creature_attack_start_like_cpp_command_like_cpp(command); } + SessionCommand::ApplyLootMoneyLikeCpp(command) => { + self.handle_apply_loot_money_like_cpp_command(command).await; + } + SessionCommand::NotifyLootMoneyRemovedLikeCpp(command) => { + self.handle_notify_loot_money_removed_like_cpp_command(command); + } SessionCommand::MasterLootGive(command) => { self.handle_represented_master_loot_give_command_like_cpp(command) .await; @@ -2893,6 +4354,9 @@ impl WorldSession { self.handle_refresh_visible_world_creatures_like_cpp_command_like_cpp(command) .await; } + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp(command) => { + self.handle_send_creature_loot_release_values_update_command_like_cpp(command); + } SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp => { let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } @@ -2911,6 +4375,13 @@ impl WorldSession { SessionCommand::SendRepeatableTurnInRequestItemsLikeCpp(command) => { self.handle_send_repeatable_turn_in_request_items_command_like_cpp(command); } + SessionCommand::SendRealmPacketLikeCpp(command) => { + if self.state() == SessionState::LoggedIn + && self.player_guid() == Some(command.recipient) + { + self.send_raw_packet_realm(&command.packet_bytes); + } + } SessionCommand::SendPartyUpdateLikeCpp(command) => { self.handle_send_party_update_command_like_cpp(command); } @@ -2971,10 +4442,10 @@ impl WorldSession { let _ = self.update_visible_gameobjects_or_spell_clicks_like_cpp(); } if command.send_group_destroyed { - self.send_packet(&wow_packet::packets::party::GroupDestroyed); + self.send_packet_realm(&wow_packet::packets::party::GroupDestroyed); } if command.send_group_uninvite { - self.send_packet(&wow_packet::packets::party::GroupUninvite); + self.send_packet_realm(&wow_packet::packets::party::GroupUninvite); } } @@ -2998,12 +4469,17 @@ impl WorldSession { if self.state() != SessionState::LoggedIn { return; } + if self.player_guid() != Some(command.recipient) { + return; + } command.party_update.sequence_num = self.next_group_update_sequence_number_like_cpp(command.party_update.party_index); - self.send_packet(&command.party_update); + // `SMSG_PARTY_UPDATE` and `SMSG_PARTY_MEMBER_FULL_STATE` are both + // CONNECTION_TYPE_REALM in legacy C++ Opcodes.cpp:1829/1832. + self.send_packet_realm(&command.party_update); for packet in command.member_full_state_packets { - let _ = self.send_tx().send(packet); + self.send_raw_packet_realm(&packet); } } @@ -3614,6 +5090,17 @@ impl WorldSession { &mut self, command: LootRollVoteCommand, ) { + let roll_key = (command.loot_obj, command.loot_list_id); + let Some(current_roll) = self.represented_loot_rolls.get(&roll_key) else { + return; + }; + if !Self::represented_loot_roll_vote_command_targets_identity_like_cpp( + &command, + ¤t_roll.command_identity, + ) { + return; + } + let roll = LootRoll { loot_obj: command.loot_obj, loot_list_id: command.loot_list_id, @@ -3629,39 +5116,204 @@ impl WorldSession { .await; } - async fn handle_represented_master_loot_give_command_like_cpp( + fn represented_loot_roll_vote_command_targets_identity_like_cpp( + command: &LootRollVoteCommand, + current_identity: &LootRollCommandIdentityLikeCpp, + ) -> bool { + current_identity.matches_key_like_cpp(command.loot_obj, command.loot_list_id) + && current_identity.is_exact_roll_like_cpp(&command.roll_identity) + } + + async fn handle_apply_loot_money_like_cpp_command( &mut self, - command: MasterLootGiveCommand, + command: ApplyLootMoneyLikeCppCommand, ) { - let Some(player_guid) = self.player_guid() else { - let _ = command.result_tx.send(MasterLootGiveResult::TargetMismatch); + if self.player_guid() != Some(command.recipient) { return; - }; + } + let apply_money = !command.applied.swap(true, Ordering::SeqCst); + let publish = !command.published.swap(true, Ordering::SeqCst); + if !apply_money && !publish { + return; + } - if command.entry.allowed_looters.is_empty() - || !command.entry.allowed_looters.contains(&player_guid) + if publish + && command.send_coin_removed.load(Ordering::Acquire) + && command.authority_committed.load(Ordering::Acquire) + && self.represented_loot_money_command_targets_active_generation_like_cpp( + command.loot_owner, + &command.authority, + command.authority_generation, + ) { - let _ = command.result_tx.send(MasterLootGiveResult::StoreFailed( - LOOT_ERROR_MASTER_OTHER_LIKE_CPP, - )); - return; + self.send_packet(&CoinRemoved { + loot_obj: command.loot_obj, + }); + self.refresh_owned_loot_summary_like_cpp(command.loot_owner); + if let Some(player_guid) = self.player_guid() { + let _ = + self.reconcile_represented_loot_cache_like_cpp(command.loot_owner, player_guid); + } } + let durable_applied_amount = command.durable_applied_amount.load(Ordering::Acquire); + let _ = self + .apply_durable_represented_loot_money_payout_like_cpp( + command.amount, + durable_applied_amount, + command.sole_looter, + apply_money, + publish, + ) + .await; + } - if let Some(error) = self.represented_master_loot_can_store_error_like_cpp( - player_guid, - command.entry.item_id, - command.entry.quantity, - ) { - let _ = command - .result_tx - .send(MasterLootGiveResult::StoreFailed(error)); + fn handle_notify_loot_money_removed_like_cpp_command( + &mut self, + command: NotifyLootMoneyRemovedLikeCppCommand, + ) { + if self.player_guid() != Some(command.recipient) + || !command.authority_committed.load(Ordering::Acquire) + || !self.represented_loot_money_command_targets_active_generation_like_cpp( + command.loot_owner, + &command.authority, + command.authority_generation, + ) + { return; } - let result = if self - .store_direct_loot_item_like_cpp(&command.entry, command.dungeon_encounter_id) - .await + self.send_packet(&CoinRemoved { + loot_obj: command.loot_obj, + }); + self.refresh_owned_loot_summary_like_cpp(command.loot_owner); + if let Some(player_guid) = self.player_guid() { + let _ = self.reconcile_represented_loot_cache_like_cpp(command.loot_owner, player_guid); + } + } + + fn represented_loot_money_command_targets_active_generation_like_cpp( + &mut self, + owner_guid: ObjectGuid, + expected_authority: &OwnedLootAuthority, + authority_generation: u64, + ) -> bool { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|active| active.shares_storage_like_cpp(expected_authority)) + || !self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|active| *active == authority_generation) + { + return false; + } + let Some(player_guid) = self.player_guid() else { + return false; + }; + self.represented_owned_loot_authority_like_cpp(owner_guid) + .is_some_and(|authority| { + authority.shares_storage_like_cpp(expected_authority) + && authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| snapshot.generation == authority_generation) + }) + } + + async fn apply_durable_represented_loot_money_payout_like_cpp( + &mut self, + notified_amount: u64, + durable_applied_amount: u64, + sole_looter: bool, + apply_money: bool, + publish: bool, + ) -> ApplyLootMoneyResultLikeCpp { + if self.player_guid().is_none() { + return ApplyLootMoneyResultLikeCpp::TargetMismatch; + } + let old_money = self.player_gold_like_cpp(); + let new_money = if apply_money { + old_money + .checked_add(durable_applied_amount) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money) + } else { + old_money + }; + + if apply_money && durable_applied_amount != 0 { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + if apply_money { + self.set_player_gold_like_cpp(new_money); + } + if publish { + self.send_packet(&LootMoneyNotify { + money: notified_amount, + money_mod: 0, + sole_looter, + }); + } + if apply_money || publish { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + ApplyLootMoneyResultLikeCpp::Applied + } + + async fn handle_represented_master_loot_give_command_like_cpp( + &mut self, + command: MasterLootGiveCommand, + ) { + let Some(player_guid) = self.player_guid() else { + let _ = command.result_tx.send(MasterLootGiveResult::TargetMismatch); + return; + }; + + if command.entry.allowed_looters.is_empty() + || !command.entry.allowed_looters.contains(&player_guid) { + let _ = command.result_tx.send(MasterLootGiveResult::StoreFailed( + LOOT_ERROR_MASTER_OTHER_LIKE_CPP, + )); + return; + } + + if let Some(error) = self.represented_master_loot_can_store_error_like_cpp( + player_guid, + command.entry.item_id, + command.entry.quantity, + ) { + let _ = command + .result_tx + .send(MasterLootGiveResult::StoreFailed(error)); + return; + } + + let stored = if let Some(claim) = command.claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &command.entry, + command.dungeon_encounter_id, + command.loot_owner, + command.loot_obj, + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &command.entry, + command.dungeon_encounter_id, + command.loot_owner, + ) + .await + }; + let result = if stored { MasterLootGiveResult::Stored } else { MasterLootGiveResult::StoreFailed(LOOT_ERROR_MASTER_OTHER_LIKE_CPP) @@ -3689,9 +5341,13 @@ impl WorldSession { return; }; - if command.entry.allowed_looters.is_empty() - || !command.entry.allowed_looters.contains(&player_guid) - || !command.entry.roll_winner_allows_like_cpp(player_guid) + if command.entries.is_empty() + || (!command.is_disenchant && command.entries.len() != 1) + || command.entries.iter().any(|entry| { + entry.allowed_looters.is_empty() + || !entry.allowed_looters.contains(&player_guid) + || !entry.roll_winner_allows_like_cpp(player_guid) + }) { let _ = command.result_tx.send(MasterLootGiveResult::StoreFailed( LOOT_ERROR_MASTER_OTHER_LIKE_CPP, @@ -3699,21 +5355,57 @@ impl WorldSession { return; } - if let Some(error) = self.represented_master_loot_can_store_error_like_cpp( - player_guid, - command.entry.item_id, - command.entry.quantity, - ) { + if let Some(error) = command.entries.iter().find_map(|entry| { + self.represented_master_loot_can_store_error_like_cpp( + player_guid, + entry.item_id, + entry.quantity, + ) + }) { let _ = command .result_tx .send(MasterLootGiveResult::StoreFailed(error)); return; } - let result = if self - .store_direct_loot_item_like_cpp(&command.entry, command.dungeon_encounter_id) + let stored = if command.is_disenchant { + self.store_direct_disenchant_batch_like_cpp( + &command.entries, + command.dungeon_encounter_id, + command.claim.as_ref(), + command + .claim + .as_ref() + .map(|claim| LootItemClaimCommitContextLikeCpp { + owner_guid: command.loot_owner, + loot_obj: command.loot_obj, + loot_list_id: command.loot_list_id, + player_guid, + free_for_all: match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.flags.freeforall, + LootClaimPayload::Money(_) => false, + }, + }), + ) .await - { + } else if let Some(claim) = command.claim.as_ref() { + self.store_claimed_direct_loot_item_from_owner_like_cpp( + &command.entries[0], + command.dungeon_encounter_id, + command.loot_owner, + command.loot_obj, + claim, + ) + .await + } else { + self.store_direct_loot_item_from_owner_like_cpp( + &command.entries[0], + command.dungeon_encounter_id, + command.loot_owner, + ) + .await + }; + let result = if stored { MasterLootGiveResult::Stored } else { MasterLootGiveResult::StoreFailed(LOOT_ERROR_MASTER_OTHER_LIKE_CPP) @@ -3731,6 +5423,348 @@ impl WorldSession { let _ = command.result_tx.send(result); } + fn prepare_durable_loot_item_fanout_like_cpp( + &mut self, + claim: &LootClaimLease, + context: LootItemClaimCommitContextLikeCpp, + ) -> Option { + let authority = self + .represented_owned_loot_authority_like_cpp(context.owner_guid) + .filter(|authority| claim.shares_authority_like_cpp(authority))?; + let precommit_snapshot = authority + .snapshot_for_player_like_cpp(context.player_guid) + .filter(|snapshot| { + snapshot.generation == claim.generation_like_cpp() + && snapshot.loot.loot_guid == context.loot_obj + && snapshot + .loot + .items + .iter() + .any(|entry| entry.loot_list_id == context.loot_list_id) + })?; + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + Some(DurableLootItemFanoutLikeCpp { + owner_guid: context.owner_guid, + loot_obj: context.loot_obj, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + free_for_all: context.free_for_all, + authority, + authority_generation: claim.generation_like_cpp(), + precommit_snapshot, + committed_snapshot: Arc::new(std::sync::OnceLock::new()), + source_send_tx: self.send_tx().clone(), + player_registry: self.player_registry().cloned(), + map_id: self.player_map_id_like_cpp(), + instance_id, + published: Arc::new(AtomicBool::new(false)), + }) + } + + fn publish_durable_loot_item_fanout_like_cpp( + &mut self, + route: &DurableLootItemFanoutLikeCpp, + ) -> bool { + let Some(committed_snapshot) = route.committed_snapshot.get().filter(|snapshot| { + snapshot.generation == route.authority_generation + && snapshot.loot.loot_guid == route.loot_obj + }) else { + // Never replace the serialization cut with a later authority + // sample. The latter may include a viewer that opened after the + // item commit and already received a response without this slot. + return false; + }; + if route + .published + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return true; + } + + // C++ serializes StoreLootItem before a later LootRelease and notifies + // synchronously. Preserve that already ordered cohort across Rust's + // SQL wait, then add only viewers captured by the item mutation. A + // post-COMMIT opener is excluded because it saw the removed slot. + let viewers = durable_loot_item_fanout_viewers_like_cpp( + &route.precommit_snapshot.loot.players_looting, + &committed_snapshot.loot.players_looting, + ); + let Some(entry) = committed_snapshot + .loot + .items + .iter() + .find(|entry| entry.loot_list_id == route.loot_list_id) + else { + return false; + }; + let allowed_looters = entry + .allowed_looters + .iter() + .copied() + .collect::>(); + let packet = LootRemoved { + owner: route.owner_guid, + loot_obj: route.loot_obj, + loot_list_id: route.loot_list_id, + }; + let bytes = packet.to_bytes(); + let mut stale_viewers = Vec::new(); + + if route.free_for_all { + let _ = route.source_send_tx.send(bytes); + } else { + for viewer in viewers { + if !allowed_looters.contains(&viewer) { + continue; + } + if viewer == route.player_guid { + let _ = route.source_send_tx.send(bytes.clone()); + continue; + } + let Some(registry) = route.player_registry.as_ref() else { + stale_viewers.push(viewer); + continue; + }; + let Some(player) = registry.get(&viewer) else { + stale_viewers.push(viewer); + continue; + }; + if player.map_id != route.map_id || player.instance_id != route.instance_id { + stale_viewers.push(viewer); + continue; + } + let _ = player.send_tx.send(bytes.clone()); + } + } + + for viewer in stale_viewers { + let _ = route + .authority + .remove_viewer_if_generation_like_cpp(route.authority_generation, viewer); + } + + self.refresh_owned_loot_summary_like_cpp(route.owner_guid); + if self.player_guid() == Some(route.player_guid) { + let _ = + self.reconcile_represented_loot_cache_like_cpp(route.owner_guid, route.player_guid); + } + self.finalize_unviewed_durable_loot_owner_like_cpp(route); + true + } + + fn finalize_unviewed_durable_loot_owner_like_cpp( + &mut self, + route: &DurableLootItemFanoutLikeCpp, + ) { + let same_view_still_open = self + .active_loot_view_authorities_like_cpp + .get(&route.owner_guid) + .is_some_and(|authority| authority.shares_storage_like_cpp(&route.authority)) + && self + .active_loot_view_generations_like_cpp + .get(&route.owner_guid) + .is_some_and(|generation| *generation == route.authority_generation); + if same_view_still_open { + return; + } + if !self + .represented_owned_loot_authority_like_cpp(route.owner_guid) + .is_some_and(|authority| authority.shares_storage_like_cpp(&route.authority)) + { + return; + } + let Some(observation) = route + .authority + .fully_looted_unviewed_lifecycle_observation_like_cpp() + else { + return; + }; + let Some(snapshot) = route + .authority + .snapshot_for_player_like_cpp(route.player_guid) + .filter(|snapshot| snapshot.generation == route.authority_generation) + else { + return; + }; + + self.loot_table + .insert(route.owner_guid, snapshot.loot.clone()); + self.represented_loot_cache_generations_like_cpp + .insert(route.owner_guid, snapshot.generation); + + if route.owner_guid.is_game_object() { + let release = AuthoritativeLootReleaseLikeCpp { + authority: route.authority.clone(), + selected_generation: route.authority_generation, + loot: snapshot.loot, + whole_object_fully_looted: true, + whole_object_fully_skinned: observation.whole_object_fully_skinned, + object_generation: observation.object_generation, + lifecycle_revision: observation.lifecycle_revision, + require_no_viewers: true, + }; + self.apply_represented_gameobject_loot_release_like_cpp( + route.owner_guid, + route.player_guid, + true, + true, + Some(&release), + ); + let _ = + self.queue_chest_gameobject_state_refresh_for_same_map_like_cpp(route.owner_guid); + self.hide_represented_gameobject_for_player_after_loot_release_like_cpp( + route.owner_guid, + ); + if self + .represented_gameobject_use_states + .get(&route.owner_guid) + .and_then(|state| state.go_type) + .map(u32::from) + == Some(GAMEOBJECT_TYPE_GATHERING_NODE) + { + self.send_gathering_node_loot_release_dynamic_flags_update_like_cpp( + route.owner_guid, + ); + } + self.loot_table.remove(&route.owner_guid); + return; + } + + if route.owner_guid.is_corpse() { + self.remove_canonical_corpse_lootable_dynamic_flag_if_unviewed_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + ); + self.loot_table.remove(&route.owner_guid); + return; + } + + if !route.owner_guid.is_creature_or_vehicle() { + return; + } + + let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; + let whole_object_fully_skinned = observation.whole_object_fully_skinned; + let lifecycle_update = self + .mutate_world_creature_if_unviewed_fully_looted_observation_like_cpp( + route.owner_guid, + &route.authority, + observation.object_generation, + observation.lifecycle_revision, + |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.remove_lootable_dynamic_flag_like_cpp(); + let marked = if creature.is_alive() { + None + } else { + let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( + whole_object_fully_skinned, + creature.corpse_delay_secs_like_cpp(), + creature.ignore_corpse_decay_ratio_like_cpp(), + corpse_decay_looted_rate, + ); + creature + .all_loot_removed_from_corpse_like_cpp( + corpse_decay_looted_rate, + whole_object_fully_skinned, + ) + .then_some((creature.entry(), corpse_decay_secs)) + }; + (marked, creature.creature.unit().values_update()) + }, + ); + self.loot_table.remove(&route.owner_guid); + if let Some((_, values_update)) = lifecycle_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + route.owner_guid, + values_update, + Some(&route.authority), + ); + } + let marked = lifecycle_update.and_then(|(marked, _)| marked); + if let Some((entry, corpse_decay_secs)) = marked { + info!( + "Creature {:?} (entry {}) fully looted after durable claim — despawning in {}s", + route.owner_guid, entry, corpse_decay_secs + ); + } + } + + fn commit_represented_loot_item_claim_like_cpp( + &mut self, + claim: &LootClaimLease, + context: LootItemClaimCommitContextLikeCpp, + fanout: Option<&DurableLootItemFanoutLikeCpp>, + ) -> bool { + if !claim.is_committed_like_cpp() { + match claim.commit_with_snapshot_like_cpp() { + Ok((_, Some(snapshot))) => { + if let Some(fanout) = fanout { + let _ = fanout.committed_snapshot.set(snapshot); + } + } + Ok((_, None)) => { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + "durable loot item committed without an exact authority snapshot" + ); + return false; + } + Err(error) => { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + ?error, + "durable loot item could not commit its object-owned claim" + ); + return false; + } + } + } + let Some(fanout) = fanout.filter(|fanout| { + fanout.owner_guid == context.owner_guid + && fanout.loot_obj == context.loot_obj + && fanout.loot_list_id == context.loot_list_id + && fanout.player_guid == context.player_guid + && fanout.free_for_all == context.free_for_all + && claim.shares_authority_like_cpp(&fanout.authority) + && claim.generation_like_cpp() == fanout.authority_generation + }) else { + warn!( + owner = ?context.owner_guid, + loot_list_id = context.loot_list_id, + "durable loot item committed without its retained fanout route" + ); + return false; + }; + self.publish_durable_loot_item_fanout_like_cpp(fanout) + } + + fn publish_persisted_loot_item_removal_like_cpp( + &mut self, + claim: Option<&LootClaimLease>, + context: Option, + fanout: Option<&DurableLootItemFanoutLikeCpp>, + ) -> bool { + match (claim, context, fanout) { + (None, None, None) => true, + (Some(claim), Some(context), fanout) => { + self.commit_represented_loot_item_claim_like_cpp(claim, context, fanout) + } + _ => { + warn!("durable loot item claim/context mismatch before removal publication"); + false + } + } + } + fn mark_represented_master_loot_item_removed_like_cpp( &mut self, owner_guid: ObjectGuid, @@ -3797,9 +5831,16 @@ impl WorldSession { return true; } + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); self.player_registry() .and_then(|registry| registry.get(&target)) - .is_some_and(|target_info| target_info.map_id == self.player_map_id_like_cpp()) + .is_some_and(|target_info| { + target_info.map_id == self.player_map_id_like_cpp() + && target_info.instance_id == instance_id + }) } fn represented_master_loot_target_eligible_like_cpp(&self, target: ObjectGuid) -> bool { @@ -3866,32 +5907,14 @@ impl WorldSession { if !creature.tappers.is_empty() && !creature.tappers.contains(&player_guid) { continue; } - let loot_owner_guid = creature.tappers.first().copied().unwrap_or(player_guid); - - self.ensure_represented_creature_loot_like_cpp( - owner_guid, - loot_owner_guid, - creature.level, - creature.entry, - creature.loot_id, - creature.gold_min, - creature.gold_max, - creature.dungeon_encounter_id, - ) - .await; - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - if creature.tappers.is_empty() { - if loot.allowed_looters.contains(&player_guid) { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); - } - } else { - for tapper in &creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, *tapper); - } - } + // C++ `CMSG_LOOT_UNIT` only reads the Loot created by + // `Unit::Kill`; it never regenerates a corpse pool. Reconcile the + // active object-owned generation and fail closed if kill-time + // generation is absent or the corpse lifetime was retired. + if !self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid) { + self.loot_table.remove(&owner_guid); + continue; } - let _ = - self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); if self.loot_table.get(&owner_guid).is_some_and(|loot| { self.represented_loot_can_be_opened_by_player_like_cpp( @@ -3917,31 +5940,13 @@ impl WorldSession { if !creature.tappers.is_empty() && !creature.tappers.contains(&player_guid) { return None; } - let loot_owner_guid = creature.tappers.first().copied().unwrap_or(player_guid); - self.ensure_represented_creature_loot_like_cpp( - owner_guid, - loot_owner_guid, - creature.level, - creature.entry, - creature.loot_id, - creature.gold_min, - creature.gold_max, - creature.dungeon_encounter_id, - ) - .await; - - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - if creature.tappers.is_empty() { - if loot.allowed_looters.contains(&player_guid) { - mark_loot_allowed_for_player_like_cpp(loot, player_guid); - } - } else { - for tapper in &creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, *tapper); - } - } + // `Player::isAllowedToLoot` reads `Creature::GetLootForPlayer`; the + // client request is not a generation trigger. A retired/missing + // authority therefore means there is no loot response. + if !self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid) { + self.loot_table.remove(&owner_guid); + return None; } - let _ = self.sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid); let loot = self.loot_table.get(&owner_guid)?; if !self.represented_loot_can_be_opened_by_player_like_cpp(owner_guid, loot, player_guid) { @@ -3973,6 +5978,16 @@ impl WorldSession { let Some(loot_owner_guid) = creature.tappers.first().copied() else { return; }; + let loot_scope_player_guid = if self.current_map_dungeon_state_like_cpp() == Some(false) { + let connected_tappers = + self.represented_connected_creature_tappers_like_cpp(&creature.tappers); + self.player_guid() + .filter(|player_guid| connected_tappers.contains(player_guid)) + .or_else(|| connected_tappers.first().copied()) + .unwrap_or(loot_owner_guid) + } else { + loot_owner_guid + }; self.ensure_represented_creature_loot_like_cpp( creature_guid, @@ -3983,33 +5998,118 @@ impl WorldSession { creature.gold_min, creature.gold_max, creature.dungeon_encounter_id, + &creature.tappers, + creature.loot_lifecycle_revision, ) .await; - - if let Some(loot) = self.loot_table.get_mut(&creature_guid) { - for tapper in creature.tappers { - mark_loot_allowed_for_player_like_cpp(loot, tapper); - } + if self + .sync_represented_creature_loot_to_canonical_like_cpp( + creature_guid, + loot_scope_player_guid, + ) + .is_none() + { + self.loot_table.remove(&creature_guid); } - let _ = self - .sync_represented_creature_loot_to_canonical_like_cpp(creature_guid, loot_owner_guid); } fn represented_on_loot_opened_like_cpp( &mut self, owner_guid: ObjectGuid, player_guid: ObjectGuid, + mut response: LootResponse, ) { - self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + let authority = self + .prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid) + .filter(|authority| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some() + }); + let authoritative_open = if let Some(authority) = authority.as_ref() { + match authority.try_open_view_with_snapshot_like_cpp( + player_guid, + |snapshot, outcome| { + // Enqueue the response while the authority mutex still + // excludes item/money commit. Any later commit therefore + // observes this viewer and its removal packet is ordered + // after the response on this session's send queue. + response.loot_obj = snapshot.loot.loot_guid; + response.acquire_reason = + loot_type_for_client_like_cpp(snapshot.loot.loot_type); + response.loot_method = snapshot.loot.loot_method; + response.coins = snapshot.loot.coins; + response.items = + represented_loot_response_items_like_cpp(&snapshot.loot, player_guid); + + // `flume::Sender::send` may wait indefinitely while this + // lock is held. Reject a saturated/disconnected socket + // queue immediately; the authority method rolls back its + // tentative viewer and first-open mutations before unlock. + if !self.try_send_packet(&response) { + return None; + } + + // Session mirrors become observable only after the client + // response was accepted by its ordered send queue. + self.loot_table.insert(owner_guid, snapshot.loot.clone()); + self.represented_loot_cache_generations_like_cpp + .insert(owner_guid, snapshot.generation); + self.active_loot_view_generations_like_cpp + .insert(owner_guid, outcome.generation); + self.active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + Some(()) + }, + ) { + Ok((outcome, ())) => Some(outcome), + Err(LootClaimError::ResponseEnqueueFailed) => { + // Do not attempt a blocking release on the same saturated + // queue. The client never observed this view, so dropping + // every local mirror is the closed state. + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + self.clear_active_loot_guid_if(owner_guid); + return; + } + Err(_) => None, + } + } else { + None + }; + if authoritative_open.is_none() { + if (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && !represented_local_loot_fixture_allowed_like_cpp() + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return; + } + self.send_packet(&response); + self.ensure_represented_player_looting_like_cpp(owner_guid, player_guid); + } else if let Some(authority) = authority.as_ref() { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(authority)) + { + self.active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + } + } self.represented_notify_loot_list_like_cpp(owner_guid); - let first_open = match self.loot_table.get_mut(&owner_guid) { - Some(loot) if !loot.looted_by_player => { - loot.looted_by_player = true; - true - } - _ => false, + let first_open = match authoritative_open { + Some(outcome) => outcome.first_viewer, + None => match self.loot_table.get_mut(&owner_guid) { + Some(loot) if !loot.looted_by_player => { + loot.looted_by_player = true; + true + } + _ => false, + }, }; if !first_open { return; @@ -4020,7 +6120,6 @@ impl WorldSession { .get(&owner_guid) .map(|loot| loot.loot_method) .unwrap_or_default(); - match loot_method { LOOT_METHOD_GROUP_LIKE_CPP | LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP => { self.represented_start_group_loot_rolls_on_first_open_like_cpp( @@ -4039,6 +6138,42 @@ impl WorldSession { } } + /// True only while a request still belongs to the exact object lifetime + /// whose loot window this session opened. + fn represented_active_loot_generation_matches_like_cpp( + &self, + owner_guid: ObjectGuid, + authority: &OwnedLootAuthority, + ) -> bool { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let current_generation = authority + .snapshot_for_player_like_cpp(player_guid) + .map(|snapshot| snapshot.generation); + self.active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(authority)) + && self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|opened| Some(*opened) == current_generation) + } + + fn represented_active_loot_claim_generation_matches_like_cpp( + &self, + owner_guid: ObjectGuid, + claim: &LootClaimLease, + ) -> bool { + self.active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| claim.shares_authority_like_cpp(opened)) + && self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .is_some_and(|opened| *opened == claim.generation_like_cpp()) + } + fn represented_notify_loot_list_like_cpp(&self, owner_guid: ObjectGuid) { if self.group_guid.is_none() { return; @@ -4064,6 +6199,10 @@ impl WorldSession { .then_some(loot.round_robin_player), }; let bytes = packet.to_bytes(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); for allowed_looter in &loot.allowed_looters { if Some(*allowed_looter) == self.player_guid() { @@ -4077,7 +6216,7 @@ impl WorldSession { let Some(player) = registry.get(allowed_looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() || player.instance_id != instance_id { continue; } @@ -4102,9 +6241,34 @@ impl WorldSession { owner_guid: ObjectGuid, loot_list_id: u8, ) { - let Some(loot) = self.loot_table.get(&owner_guid) else { + let Some(loot) = self.loot_table.get(&owner_guid).cloned() else { return; }; + let snapshot = wow_loot::OwnedLootSnapshot { + generation: self + .represented_loot_cache_generations_like_cpp + .get(&owner_guid) + .copied() + .unwrap_or(0), + scope: wow_loot::OwnedLootScope::Shared, + loot, + }; + self.represented_notify_loot_item_removed_from_snapshot_like_cpp( + owner_guid, + None, + &snapshot, + loot_list_id, + ); + } + + fn represented_notify_loot_item_removed_from_snapshot_like_cpp( + &mut self, + owner_guid: ObjectGuid, + authority: Option<&OwnedLootAuthority>, + snapshot: &wow_loot::OwnedLootSnapshot, + loot_list_id: u8, + ) { + let loot = &snapshot.loot; let Some(entry) = loot .items .iter() @@ -4123,6 +6287,10 @@ impl WorldSession { let allowed_looters = entry.allowed_looters.clone(); let current_player = self.player_guid(); let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let registry = self.player_registry().cloned(); let mut stale_looters = Vec::new(); @@ -4144,7 +6312,7 @@ impl WorldSession { stale_looters.push(*looter); continue; }; - if player.map_id != current_map { + if player.map_id != current_map || player.instance_id != current_instance { stale_looters.push(*looter); continue; } @@ -4158,9 +6326,20 @@ impl WorldSession { loot.players_looting .retain(|looter| !stale_looters.contains(looter)); } + if !stale_looters.is_empty() { + if let Some(authority) = authority { + for looter in stale_looters { + let _ = + authority.remove_viewer_if_generation_like_cpp(snapshot.generation, looter); + } + } + } } fn represented_notify_money_removed_like_cpp(&mut self, owner_guid: ObjectGuid) { + if let Some(player_guid) = self.player_guid() { + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } let Some(loot) = self.loot_table.get(&owner_guid) else { return; }; @@ -4172,6 +6351,10 @@ impl WorldSession { let players_looting = loot.players_looting.clone(); let current_player = self.player_guid(); let current_map = self.player_map_id_like_cpp(); + let current_instance = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let registry = self.player_registry().cloned(); let mut stale_looters = Vec::new(); @@ -4189,7 +6372,7 @@ impl WorldSession { stale_looters.push(*looter); continue; }; - if player.map_id != current_map { + if player.map_id != current_map || player.instance_id != current_instance { stale_looters.push(*looter); continue; } @@ -4203,6 +6386,16 @@ impl WorldSession { loot.players_looting .retain(|looter| !stale_looters.contains(looter)); } + if !stale_looters.is_empty() + && let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) + { + for looter in stale_looters { + authority.remove_viewer_like_cpp(looter); + } + if let Some(player_guid) = current_player { + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + } } fn represented_start_group_loot_rolls_on_first_open_like_cpp( @@ -4210,11 +6403,24 @@ impl WorldSession { owner_guid: ObjectGuid, player_guid: ObjectGuid, ) { + let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) else { + return; + }; + let Some(authority_snapshot) = authority.snapshot_for_player_like_cpp(player_guid) else { + return; + }; + let authority_generation = authority_snapshot.generation; + let authority_scope = authority_snapshot.scope; let current_map_id = self.player_map_id_like_cpp(); + let current_instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); let player_registry = self.player_registry().cloned(); let mut packets = Vec::new(); let mut auto_pass_packets = Vec::new(); let mut pending_rolls = Vec::new(); + let mut unblocked_without_roll = Vec::new(); let item_flags2_by_item_id: HashMap, Option)> = self .loot_table .get(&owner_guid) @@ -4246,12 +6452,13 @@ impl WorldSession { entry, player_guid, current_map_id, + current_instance_id, player_registry.as_deref(), ); - if eligible_looters.len() <= 1 { entry.flags.under_threshold = true; entry.flags.blocked = false; + unblocked_without_roll.push(entry.loot_list_id); continue; } @@ -4268,7 +6475,10 @@ impl WorldSession { .as_deref() .and_then(|registry| registry.get(looter)) { - Some(player) if player.map_id == current_map_id => { + Some(player) + if player.map_id == current_map_id + && player.instance_id == current_instance_id => + { if player.pass_on_group_loot { ROLL_VOTE_PASS_LIKE_CPP } else { @@ -4286,9 +6496,20 @@ impl WorldSession { }, ); } + let command_identity = LootRollCommandIdentityLikeCpp::new_like_cpp( + loot.loot_guid, + entry.loot_list_id, + authority.clone(), + authority_generation, + ); let state = RepresentedLootRollState { + owner_guid, loot_obj: loot.loot_guid, loot_list_id: entry.loot_list_id, + authority: authority.clone(), + authority_generation, + authority_scope, + command_identity, end_time: Instant::now() + Duration::from_millis(u64::from(LOOT_ROLL_TIMEOUT_MS_LIKE_CPP)), voters, @@ -4354,6 +6575,21 @@ impl WorldSession { } } + if !unblocked_without_roll.is_empty() + && let Some(authority) = self.represented_owned_loot_authority_like_cpp(owner_guid) + { + for loot_list_id in unblocked_without_roll { + let _ = authority.finish_item_roll_like_cpp( + player_guid, + authority_generation, + loot_list_id, + true, + None, + ); + } + let _ = self.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid); + } + for roll in pending_rolls { self.represented_loot_rolls .insert((roll.loot_obj, roll.loot_list_id), roll); @@ -4372,7 +6608,9 @@ impl WorldSession { let Some(player) = registry.get(&looter) else { continue; }; - if player.map_id != self.player_map_id_like_cpp() { + if player.map_id != self.player_map_id_like_cpp() + || player.instance_id != current_instance_id + { continue; } @@ -4397,20 +6635,17 @@ impl WorldSession { info.active_loot_rolls = self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(); } pub(crate) async fn tick_represented_loot_rolls_like_cpp(&mut self) { let now = Instant::now(); - let expired: Vec<(ObjectGuid, u8)> = self - .represented_loot_rolls - .iter() - .filter_map(|(key, state)| (state.end_time <= now).then_some(*key)) - .collect(); + let roll_keys: Vec<(ObjectGuid, u8)> = + self.represented_loot_rolls.keys().copied().collect(); - for (loot_obj, loot_list_id) in expired { + for (loot_obj, loot_list_id) in roll_keys { let Some(state) = self .represented_loot_rolls .get(&(loot_obj, loot_list_id)) @@ -4418,12 +6653,21 @@ impl WorldSession { else { continue; }; - let Some(owner_guid) = self.active_loot_owner_for_loot_object_like_cpp(loot_obj) else { - self.represented_loot_rolls - .remove(&(loot_obj, loot_list_id)); - self.publish_represented_loot_roll_ownership_like_cpp(); + if self + .represented_current_loot_roll_authority_like_cpp(&state) + .is_none() + { + self.cancel_represented_loot_roll_generation_mismatch_like_cpp( + (loot_obj, loot_list_id), + &state, + ); continue; - }; + } + if state.end_time > now { + continue; + } + + let owner_guid = state.owner_guid; let Some(entry) = self.loot_table.get(&owner_guid).and_then(|loot| { loot.items .iter() @@ -4508,6 +6752,57 @@ impl WorldSession { }) } + /// Install kill-time pools only while the exact creature death lifetime + /// observed before async template generation is still current. C++ runs + /// `Unit::Kill` and loot creation on one map thread; this lock-scoped CAS + /// is the Rust equivalent and prevents corpse-removal/respawn ABA. + fn install_represented_creature_kill_loot_if_current_like_cpp( + &mut self, + creature_guid: ObjectGuid, + expected_authority: &OwnedLootAuthority, + expected_object_generation: u64, + expected_loot_lifecycle_revision: u64, + shared: Option, + personal: HashMap, + ) -> bool { + let expected_authority = expected_authority.clone(); + self.mutate_world_creature(creature_guid, move |world_creature| { + if world_creature.is_alive() + || world_creature.creature.loot_lifecycle_revision_like_cpp() + != expected_loot_lifecycle_revision + || !world_creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(&expected_authority) + || !expected_authority.is_retired_like_cpp() + || expected_authority.generation_like_cpp() != expected_object_generation + { + return false; + } + + let installed = if expected_object_generation == 0 { + expected_authority + .initialize_pristine_like_cpp(shared, personal) + .installed() + } else { + expected_authority + .replace_retired_generation_like_cpp( + expected_object_generation, + shared, + personal, + ) + .is_some() + }; + if installed { + world_creature + .creature + .sync_loot_summaries_from_authority_like_cpp(); + } + installed + }) + .unwrap_or(false) + } + async fn ensure_represented_creature_loot_like_cpp( &mut self, creature_guid: ObjectGuid, @@ -4518,9 +6813,197 @@ impl WorldSession { gold_min: u32, gold_max: u32, dungeon_encounter_id: u32, + allowed_looters: &[ObjectGuid], + expected_loot_lifecycle_revision: u64, ) { + let authority = self.represented_owned_loot_authority_like_cpp(creature_guid); + if authority.is_none() && !represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.remove(&creature_guid); + return; + } + let mut retired_object_generation = None; + if let Some(authority) = authority.as_ref() { + #[cfg(test)] + if authority.is_pristine_like_cpp() && self.loot_table.contains_key(&creature_guid) { + if !self + .represented_personal_loot_owners + .contains(&creature_guid) + && let Some(loot) = self.loot_table.get_mut(&creature_guid) + { + prepare_represented_shared_creature_loot_generation_like_cpp( + loot, + allowed_looters, + ); + } + if self + .sync_represented_creature_loot_to_canonical_like_cpp( + creature_guid, + loot_owner_guid, + ) + .is_some() + { + // Legacy packet fixtures pre-populate the former session + // cache. Install that value once into the typed object-owned + // authority instead of silently replacing it with generated + // empty loot. This branch does not exist in production. + return; + } + } + let snapshot = self + .player_guid() + .and_then(|player_guid| authority.snapshot_for_player_like_cpp(player_guid)) + .or_else(|| authority.snapshot_for_player_like_cpp(loot_owner_guid)); + if let Some(snapshot) = snapshot { + let cache_player = match snapshot.scope { + OwnedLootScope::Personal(player_guid) => player_guid, + OwnedLootScope::Shared => loot_owner_guid, + }; + self.cache_represented_owned_loot_snapshot_like_cpp( + creature_guid, + cache_player, + snapshot, + ); + return; + } + if !authority.is_retired_like_cpp() { + self.loot_table.remove(&creature_guid); + return; + } + retired_object_generation = Some(authority.generation_like_cpp()); + self.loot_table.remove(&creature_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&creature_guid); + } + + let map_is_dungeon = self.current_map_dungeon_state_like_cpp(); + let connected_tappers = + self.represented_connected_creature_tappers_like_cpp(allowed_looters); + + // C++ `Unit::Kill` has three distinct ownership shapes: + // - overworld: one independently generated personal pool per tapper; + // - dungeon encounter/boss: one independent, lockout-filtered pool per + // tapper (`GenerateDungeonEncounterPersonalLoot`); + // - dungeon trash: exactly one personal pool, keyed by the group's + // selected looter (or the first tapper without a group). + if map_is_dungeon == Some(false) + || (map_is_dungeon == Some(true) && dungeon_encounter_id != 0) + { + let personal_tappers = connected_tappers + .into_iter() + .filter(|tapper| { + dungeon_encounter_id == 0 + || !self + .represented_locked_dungeon_encounters + .contains(&(*tapper, dungeon_encounter_id)) + }) + .collect::>(); + if personal_tappers.is_empty() { + self.loot_table.remove(&creature_guid); + return; + } + + let Some(personal) = self + .generate_represented_creature_personal_loot_like_cpp( + creature_guid, + level, + entry, + loot_id, + gold_min, + gold_max, + dungeon_encounter_id, + &personal_tappers, + ) + .await + else { + return; + }; + let cache_player = self + .player_guid() + .filter(|player_guid| personal.contains_key(player_guid)) + .unwrap_or(personal_tappers[0]); + + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + None, + personal, + ) { + let _ = + self.reconcile_represented_loot_cache_like_cpp(creature_guid, cache_player); + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() + && let Some(pool) = personal.get(&cache_player).cloned() + { + self.loot_table.insert(creature_guid, pool); + } + return; + } + + if map_is_dungeon == Some(true) { + if connected_tappers.is_empty() { + self.loot_table.remove(&creature_guid); + return; + } + let selected_looter = + self.represented_dungeon_trash_looter_like_cpp(&connected_tappers); + let Some(personal) = self + .generate_represented_creature_personal_loot_like_cpp( + creature_guid, + level, + entry, + loot_id, + gold_min, + gold_max, + 0, + &[selected_looter], + ) + .await + else { + return; + }; + let has_loot = personal + .get(&selected_looter) + .is_some_and(|loot| !loot_is_looted_like_cpp(loot)); + + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + None, + personal, + ) { + let _ = self + .reconcile_represented_loot_cache_like_cpp(creature_guid, selected_looter); + if has_loot { + self.advance_represented_dungeon_trash_looter_like_cpp(&connected_tappers); + } + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() + && let Some(pool) = personal.get(&selected_looter).cloned() + { + self.loot_table.insert(creature_guid, pool); + } + return; + } + + // Missing Map.db2 metadata is not proof of either overworld or + // dungeon. Preserve the represented shared fallback for legacy test + // fixtures, but still bind its async install to the exact death token. if !self.loot_table.contains_key(&creature_guid) { - let loot = self + let Some(mut loot) = self .generate_represented_creature_loot_like_cpp( creature_guid, loot_owner_guid, @@ -4531,8 +7014,33 @@ impl WorldSession { gold_max, dungeon_encounter_id, ) - .await; - self.loot_table.insert(creature_guid, loot); + .await + else { + return; + }; + prepare_represented_shared_creature_loot_generation_like_cpp( + &mut loot, + allowed_looters, + ); + if let (Some(authority), Some(expected_generation)) = + (authority.as_ref(), retired_object_generation) + { + if self.install_represented_creature_kill_loot_if_current_like_cpp( + creature_guid, + authority, + expected_generation, + expected_loot_lifecycle_revision, + Some(loot), + HashMap::new(), + ) { + let _ = self + .reconcile_represented_loot_cache_like_cpp(creature_guid, loot_owner_guid); + } else { + self.loot_table.remove(&creature_guid); + } + } else if represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.insert(creature_guid, loot); + } } } @@ -4541,47 +7049,232 @@ impl WorldSession { gameobject_guid: ObjectGuid, player_guid: ObjectGuid, source: GameObjectLootSource, + allowed_looters: &[ObjectGuid], ) { - if !self.loot_table.contains_key(&gameobject_guid) { - let loot = self - .generate_represented_gameobject_chest_loot_like_cpp( - gameobject_guid, - player_guid, - source, - ) - .await; - self.loot_table.insert(gameobject_guid, loot); - } - } - - async fn generate_represented_gameobject_chest_loot_like_cpp( - &mut self, - gameobject_guid: ObjectGuid, - player_guid: ObjectGuid, - source: GameObjectLootSource, - ) -> CreatureLoot { - let (loot_method, loot_master, round_robin_player) = self + // C++ creates `m_loot` synchronously in `GameObject::Use` + // (`GameObject.cpp:2559-2575`). Capture the exact map-owned lifetime + // before async template work, then revalidate it under the map lock at + // install time so `ClearLoot`/restock cannot be crossed. + let install_observation = match self + .represented_gameobject_loot_install_observation_result_like_cpp(gameobject_guid) + { + Some(Some(observation)) => Some(observation), + Some(None) => { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + None if !represented_local_loot_fixture_allowed_like_cpp() => { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + None => None, + }; + let authority = install_observation + .as_ref() + .map(|observation| observation.authority.clone()); + let mut install_single_personal_pool = false; + if let Some(authority) = authority.as_ref() { + #[cfg(test)] + if authority.is_pristine_like_cpp() && self.loot_table.contains_key(&gameobject_guid) { + if !self + .represented_personal_loot_owners + .contains(&gameobject_guid) + && let Some(loot) = self.loot_table.get_mut(&gameobject_guid) + { + prepare_represented_shared_loot_generation_like_cpp(loot, allowed_looters); + } + if self + .sync_represented_gameobject_loot_to_canonical_like_cpp( + gameobject_guid, + player_guid, + ) + .is_some() + { + // Test-only bridge for legacy pre-authority packet fixtures; + // live gameobjects still require their canonical map owner. + return; + } + } + if let Some(snapshot) = authority.snapshot_for_player_like_cpp(player_guid) { + self.cache_represented_owned_loot_snapshot_like_cpp( + gameobject_guid, + player_guid, + snapshot, + ); + return; + } + let active_authority = + authority.stamp_like_cpp().lifecycle == OwnedLootAuthorityLifecycle::Active; + let can_add_personal_pool = active_authority + && source.uses_personal_loot_like_cpp() + && !source.is_personal_encounter_loot_like_cpp(); + if can_add_personal_pool { + // The non-encounter C++ branch adds exactly this opener's + // `m_personalLoot[player]` pool. Encounter loot is different: + // it regenerates one topology from GameObject::GetTapList and + // assigns the whole map. Rust does not yet have that canonical + // script-owned tap list, so an encounter opener absent from + // the installed topology must fail closed rather than receive + // a fabricated singleton pool. + install_single_personal_pool = true; + } + if !authority.is_retired_like_cpp() && !can_add_personal_pool { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + } + + if !self.loot_table.contains_key(&gameobject_guid) { + let single_personal_looter = install_single_personal_pool.then_some([player_guid]); + let generation_allowed_looters = single_personal_looter + .as_ref() + .map_or(allowed_looters, |looters| looters.as_slice()); + let Some(mut loot) = self + .generate_represented_gameobject_chest_loot_like_cpp( + gameobject_guid, + player_guid, + source, + generation_allowed_looters, + ) + .await + else { + return; + }; + let personal = self + .represented_personal_loot_owners + .contains(&gameobject_guid); + if !personal { + prepare_represented_shared_loot_generation_like_cpp(&mut loot, allowed_looters); + } + if let Some(observation) = install_observation { + if source.uses_personal_loot_like_cpp() + && (!source.is_personal_encounter_loot_like_cpp() + || install_single_personal_pool) + { + if self + .upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + player_guid, + loot, + false, + source.is_personal_encounter_loot_like_cpp(), + &observation, + ) + .is_none() + { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + } + return; + } + let Some((shared, mut personal)) = self.represented_loot_authority_pools_like_cpp( + gameobject_guid, + player_guid, + loot, + personal, + ) else { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + }; + if source.is_personal_encounter_loot_like_cpp() { + // `GenerateDungeonEncounterPersonalLoot` drops each + // per-player `Loot` that is already empty after money, + // personal-template and not-normal processing + // (`LootMgr.cpp:933-941`). The non-encounter + // `chestPersonalLoot` branch deliberately keeps its empty + // `m_personalLoot[player]`, so this filter belongs only to + // the encounter topology. + personal.retain(|_, pool| !loot_is_looted_like_cpp(pool)); + self.represented_personal_loot_money + .retain(|(owner, player), _| { + *owner != gameobject_guid || personal.contains_key(player) + }); + if personal.is_empty() { + // C++ assigns an empty `m_personalLoot` map and sends no + // loot window. Keep the authority pristine/retired so + // a later `Use` may generate again instead of leaving + // an active owner with no selectable pool. + self.represented_personal_loot_owners + .remove(&gameobject_guid); + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + } + let installed = self + .mutate_canonical_gameobject_by_guid_like_cpp( + gameobject_guid, + move |gameobject| { + gameobject.install_loot_authority_if_lifecycle_like_cpp( + &observation.authority, + observation.object_generation, + observation.loot_lifecycle_revision, + shared, + personal, + ) + }, + ) + .unwrap_or(false); + if !installed { + self.loot_table.remove(&gameobject_guid); + self.represented_loot_cache_generations_like_cpp + .remove(&gameobject_guid); + return; + } + let _ = + self.reconcile_represented_loot_cache_like_cpp(gameobject_guid, player_guid); + } else if represented_local_loot_fixture_allowed_like_cpp() { + self.loot_table.insert(gameobject_guid, loot); + } + } + } + + async fn generate_represented_gameobject_chest_loot_like_cpp( + &mut self, + gameobject_guid: ObjectGuid, + player_guid: ObjectGuid, + source: GameObjectLootSource, + allowed_looters: &[ObjectGuid], + ) -> Option { + let personal_loot = source.uses_personal_loot_like_cpp(); + let personal_encounter = source.is_personal_encounter_loot_like_cpp(); + let (loot_method, loot_master, round_robin_player) = self .represented_gameobject_chest_group_state_like_cpp( - source.use_group_loot_rules, + source.use_group_loot_rules && !personal_loot, player_guid, ); let loot_id = source.open_loot_id_like_cpp(); - let personal_encounter = source.is_personal_encounter_loot_like_cpp(); let items = if personal_encounter { Vec::new() } else { - self.generate_represented_gameobject_loot_items_like_cpp(loot_id) - .await - .unwrap_or_else(|| { - if loot_id != 0 { - debug!( - loot_id, - gameobject = ?gameobject_guid, - "gameobject loot template unavailable for represented chest" - ); - } - Vec::new() - }) + self.generate_represented_shared_gameobject_loot_items_like_cpp( + loot_id, + allowed_looters, + ) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + loot_id, + gameobject = ?gameobject_guid, + "gameobject loot template unavailable for represented chest" + ); + } + Vec::new() + }) }; let (min_money, max_money) = self .load_gameobject_template_addon_money_loot_like_cpp(gameobject_guid.entry()) @@ -4592,8 +7285,9 @@ impl WorldSession { self.loot_drop_rates_like_cpp().money, ); + let loot_guid = self.next_represented_loot_object_guid_like_cpp(gameobject_guid)?; let mut loot = CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(gameobject_guid), + loot_guid, coins, unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, @@ -4608,18 +7302,36 @@ impl WorldSession { looted_by_player: false, }; - if personal_encounter { + if personal_loot { loot.coins = 0; self.represented_personal_loot_owners .insert(gameobject_guid); self.represented_personal_loot_money .retain(|(owner, _), _| *owner != gameobject_guid); - let represented_tappers = self - .represented_gameobject_personal_encounter_tappers_like_cpp( + let represented_tappers = if personal_encounter && !allowed_looters.is_empty() { + let mut tappers = allowed_looters + .iter() + .copied() + .filter(|guid| { + guid.is_player() + && self.represented_player_is_unlocked_for_dungeon_encounter_like_cpp( + *guid, + source.dungeon_encounter_id, + ) + }) + .collect::>(); + tappers.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + tappers.dedup(); + tappers + } else if personal_encounter { + self.represented_gameobject_personal_encounter_tappers_like_cpp( gameobject_guid, player_guid, source.dungeon_encounter_id, - ); + ) + } else { + vec![player_guid] + }; for tapper in &represented_tappers { if !loot.allowed_looters.contains(tapper) { loot.allowed_looters.push(*tapper); @@ -4632,22 +7344,24 @@ impl WorldSession { self.represented_personal_loot_money .insert((gameobject_guid, *tapper), tapper_money); } - loot.items = self - .generate_represented_gameobject_personal_loot_items_like_cpp( - loot_id, - &represented_tappers, - ) - .await - .unwrap_or_else(|| { - if loot_id != 0 { - debug!( - loot_id, - gameobject = ?gameobject_guid, - "gameobject personal loot template unavailable for represented chest" - ); - } - Vec::new() - }); + if personal_encounter { + loot.items = self + .generate_represented_gameobject_personal_loot_items_like_cpp( + loot_id, + &represented_tappers, + ) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + loot_id, + gameobject = ?gameobject_guid, + "gameobject personal loot template unavailable for represented chest" + ); + } + Vec::new() + }); + } rebuild_represented_personal_loot_counts_like_cpp(&mut loot); if represented_tappers.is_empty() { self.represented_personal_loot_owners @@ -4655,7 +7369,7 @@ impl WorldSession { } } - loot + Some(loot) } fn represented_gameobject_personal_encounter_tappers_like_cpp( @@ -4717,7 +7431,7 @@ impl WorldSession { fn represented_gameobject_chest_group_state_like_cpp( &self, use_group_loot_rules: bool, - player_guid: ObjectGuid, + _player_guid: ObjectGuid, ) -> (u8, ObjectGuid, ObjectGuid) { if !use_group_loot_rules { return (0, ObjectGuid::EMPTY, ObjectGuid::EMPTY); @@ -4732,7 +7446,63 @@ impl WorldSession { return (0, ObjectGuid::EMPTY, ObjectGuid::EMPTY); }; - (group.loot_method, group.master_looter_guid, player_guid) + // C++ `Loot::FillLoot` assigns round robin only for `LOOT_CORPSE`. + ( + group.loot_method, + group.master_looter_guid, + ObjectGuid::EMPTY, + ) + } + + /// C++ `Loot::FillLoot` calls `FillNotNormalLootFor` for every connected + /// group member at reward distance from the opening player before the + /// chest's shared `Loot` becomes visible. + fn represented_group_looters_at_reward_distance_like_cpp( + &self, + player_guid: ObjectGuid, + ) -> Vec { + let Some(group_guid) = self.group_guid else { + return vec![player_guid]; + }; + let Some(group_registry) = self.group_registry() else { + return vec![player_guid]; + }; + let Some(group) = group_registry.get(&group_guid) else { + return vec![player_guid]; + }; + let source_position = self.player_position_like_cpp().unwrap_or_default(); + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let registry = self.player_registry(); + let mut looters = Vec::new(); + + for member_guid in &group.members { + if *member_guid == player_guid { + looters.push(*member_guid); + continue; + } + let Some(member) = registry.and_then(|registry| registry.get(member_guid)) else { + continue; + }; + if member.is_in_world + && member.map_id == map_id + && member.instance_id == instance_id + && (self.current_map_is_dungeon_like_cpp() + || source_position.is_within_dist(&member.position, 74.0)) + { + looters.push(*member_guid); + } + } + + if looters.is_empty() { + looters.push(player_guid); + } + looters.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + looters } async fn generate_represented_gameobject_loot_items_like_cpp( @@ -4743,6 +7513,21 @@ impl WorldSession { loot_id, LootStoreKind::Gameobject, LOOT_MODE_DEFAULT_LIKE_CPP, + None, + ) + .await + } + + async fn generate_represented_shared_gameobject_loot_items_like_cpp( + &mut self, + loot_id: u32, + allowed_looters: &[ObjectGuid], + ) -> Option> { + self.generate_represented_gameobject_loot_items_for_store_like_cpp( + loot_id, + LootStoreKind::Gameobject, + LOOT_MODE_DEFAULT_LIKE_CPP, + Some(allowed_looters), ) .await } @@ -4752,6 +7537,7 @@ impl WorldSession { loot_id: u32, store_kind: LootStoreKind, loot_mode: u16, + shared_allowed_looters: Option<&[ObjectGuid]>, ) -> Option> { if loot_id == 0 { return Some(Vec::new()); @@ -4773,6 +7559,7 @@ impl WorldSession { condition_ids.iter().map(|id| id.source_entry), ) .await; + let defer_eligibility_until_after_roll = shared_allowed_looters.is_some(); let generated = { match store.fill_loot_with_context_like_cpp( loot_id, @@ -4795,12 +7582,13 @@ impl WorldSession { }, |item| self.item_drop_rate_like_cpp(item.item_id), |context| { - self.represented_creature_loot_item_allowed_like_cpp( - context, - &condition_rows, - &condition_references, - &addon_metadata, - ) + defer_eligibility_until_after_roll + || self.represented_creature_loot_item_allowed_like_cpp( + context, + &condition_rows, + &condition_references, + &addon_metadata, + ) }, |item_id, rng| { let random_properties = @@ -4824,7 +7612,24 @@ impl WorldSession { .get(&item.item_id) .copied() .unwrap_or_default(); - generated_creature_loot_item_to_entry_like_cpp(item, metadata) + if let Some(allowed_looters) = shared_allowed_looters { + generated_shared_gameobject_loot_item_to_entry_like_cpp( + item, + metadata, + allowed_looters, + |context, looter| { + self.represented_creature_loot_item_allowed_for_player_like_cpp( + context, + looter, + &condition_rows, + &condition_references, + &addon_metadata, + ) + }, + ) + } else { + generated_creature_loot_item_to_entry_like_cpp(item, metadata) + } }) .collect(), ) @@ -4842,6 +7647,7 @@ impl WorldSession { current_area_id, LootStoreKind::Fishing, loot_mode, + None, ) .await?; if !items.is_empty() { @@ -4861,6 +7667,7 @@ impl WorldSession { 1, LootStoreKind::Fishing, loot_mode, + None, ) .await } @@ -5002,9 +7809,16 @@ impl WorldSession { match world_db.query(&stmt).await { Ok(result) if !result.is_empty() => { - let min_money = result.try_read::(0).unwrap_or(0); - let max_money = result.try_read::(1).unwrap_or(0); - (min_money, max_money) + match (result.try_read::(0), result.try_read::(1)) { + (Some(min_money), Some(max_money)) => (min_money, max_money), + _ => { + warn!( + gameobject_entry, + "failed to decode gameobject_template_addon money loot as C++ uint32 columns" + ); + (0, 0) + } + } } Ok(_) => (0, 0), Err(err) => { @@ -5027,7 +7841,7 @@ impl WorldSession { gold_min: u32, gold_max: u32, dungeon_encounter_id: u32, - ) -> CreatureLoot { + ) -> Option { let (loot_method, loot_master, round_robin_player) = self.represented_creature_loot_group_state_like_cpp(loot_owner_guid); let coins = self.represented_money_loot_with_rate_like_cpp( @@ -5049,8 +7863,9 @@ impl WorldSession { Vec::new() }); - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(creature_guid), + let loot_guid = self.next_represented_loot_object_guid_like_cpp(creature_guid)?; + Some(CreatureLoot { + loot_guid, coins, unlooted_count: 0, loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, @@ -5063,7 +7878,131 @@ impl WorldSession { allowed_looters: Vec::new(), items, looted_by_player: false, + }) + } + + /// C++ `Unit::Kill` first resolves every tap-list GUID through + /// `ObjectAccessor::GetPlayer(*creature, guid)`. Only connected players in + /// the creature's exact map instance receive an overworld personal pool. + fn represented_connected_creature_tappers_like_cpp( + &self, + tappers: &[ObjectGuid], + ) -> Vec { + let current_player = self.player_guid(); + let map_id = self.player_map_id_like_cpp(); + let instance_id = self + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0); + let registry = self.player_registry(); + let mut connected = tappers + .iter() + .copied() + .filter(|tapper| { + if !tapper.is_player() { + return false; + } + if Some(*tapper) == current_player { + return true; + } + registry + .and_then(|registry| registry.get(tapper)) + .is_some_and(|player| { + player.is_in_world + && player.map_id == map_id + && player.instance_id == instance_id + }) + }) + .collect::>(); + connected.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + connected.dedup(); + connected + } + + fn represented_dungeon_trash_looter_like_cpp( + &self, + connected_tappers: &[ObjectGuid], + ) -> ObjectGuid { + let selected = + if let (Some(group_guid), Some(registry)) = (self.group_guid, self.group_registry()) { + registry + .get(&group_guid) + .map(|group| group.looter_guid_like_cpp()) + .filter(|looter| connected_tappers.contains(looter)) + } else { + None + }; + selected.unwrap_or(connected_tappers[0]) + } + + fn advance_represented_dungeon_trash_looter_like_cpp(&self, connected_tappers: &[ObjectGuid]) { + let (Some(group_guid), Some(registry)) = (self.group_guid, self.group_registry()) else { + return; + }; + if let Some(mut group) = registry.get_mut(&group_guid) { + let _ = group.update_looter_guid_like_cpp(connected_tappers.iter().copied(), false); + } + } + + /// Generate one independently rolled C++ personal `Loot` per supplied + /// player. The caller chooses the ownership set for overworld tappers, + /// encounter-eligible dungeon tappers, or the single dungeon-trash + /// selected looter. Every pool is constructed without a Group and remains + /// an object-owned per-view source of truth. + #[allow(clippy::too_many_arguments)] + async fn generate_represented_creature_personal_loot_like_cpp( + &mut self, + creature_guid: ObjectGuid, + _level: u8, + entry: u32, + loot_id: u32, + gold_min: u32, + gold_max: u32, + dungeon_encounter_id: u32, + tappers: &[ObjectGuid], + ) -> Option> { + let mut personal = HashMap::with_capacity(tappers.len()); + for tapper in tappers { + let coins = self.represented_money_loot_with_rate_like_cpp( + gold_min, + gold_max, + self.loot_drop_rates_like_cpp().money, + ); + let items = self + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, *tapper) + .await + .unwrap_or_else(|| { + if loot_id != 0 { + debug!( + entry, + loot_id, + tapper = ?tapper, + "creature personal loot template unavailable for represented overworld corpse" + ); + } + Vec::new() + }); + let loot_guid = self.next_represented_loot_object_guid_like_cpp(creature_guid)?; + let mut loot = CreatureLoot { + loot_guid, + coins, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items, + looted_by_player: false, + }; + mark_loot_allowed_for_player_like_cpp(&mut loot, *tapper); + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(&mut loot); + personal.insert(*tapper, loot); } + Some(personal) } fn represented_creature_loot_group_state_like_cpp( @@ -5086,6 +8025,16 @@ impl WorldSession { async fn generate_represented_creature_loot_items_like_cpp( &mut self, loot_id: u32, + ) -> Option> { + let player_guid = self.player_guid().unwrap_or(ObjectGuid::EMPTY); + self.generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + } + + async fn generate_represented_creature_loot_items_for_player_like_cpp( + &mut self, + loot_id: u32, + player_guid: ObjectGuid, ) -> Option> { if loot_id == 0 { return Some(Vec::new()); @@ -5132,8 +8081,9 @@ impl WorldSession { }, |item| self.item_drop_rate_like_cpp(item.item_id), |context| { - self.represented_creature_loot_item_allowed_like_cpp( + self.represented_creature_loot_item_allowed_for_player_like_cpp( context, + player_guid, &condition_rows, &condition_references, &addon_metadata, @@ -5878,21 +8828,21 @@ impl WorldSession { guid: ObjectGuid, allowed: &[AccessorObjectKind], ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; - let map = manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0)? - .map(); + let map = manager.find_map(map_key.map_id, map_key.instance_id)?.map(); map.map_object_by_kind(guid, allowed) .map(|object| object.position()) } fn canonical_gameobject_owner_for_loot_like_cpp(&self, guid: ObjectGuid) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; - let map = manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0)? - .map(); + let map = manager.find_map(map_key.map_id, map_key.instance_id)?.map(); let owner_guid = map.get_typed_game_object(guid)?.owner_guid(); (!owner_guid.is_empty()).then_some(owner_guid) } @@ -5901,20 +8851,18 @@ impl WorldSession { &mut self, corpse_guid: ObjectGuid, ) -> bool { - let map_id = u32::from(self.player_map_id_like_cpp()); + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; let Some(manager) = self.canonical_map_manager.as_ref().cloned() else { return false; }; let Ok(mut manager) = manager.lock() else { return false; }; - let mut instance_id = None; - manager.do_for_all_maps_with_map_id(map_id, |managed| { - if instance_id.is_none() && managed.map().get_typed_corpse(corpse_guid).is_some() { - instance_id = Some(managed.instance_id()); - } - }); - let Some(map) = manager.find_map_mut(map_id, instance_id.unwrap_or(0)) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return false; }; let Some(corpse) = map.map_mut().get_typed_corpse_mut(corpse_guid) else { @@ -5925,6 +8873,40 @@ impl WorldSession { true } + fn remove_canonical_corpse_lootable_dynamic_flag_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + corpse_guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + ) -> bool { + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return false; + }; + let Some(manager) = self.canonical_map_manager.as_ref().cloned() else { + return false; + }; + let Ok(mut manager) = manager.lock() else { + return false; + }; + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { + return false; + }; + let Some(corpse) = map.map_mut().get_typed_corpse_mut(corpse_guid) else { + return false; + }; + + authority + .with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || corpse.remove_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE), + ) + .is_some() + } + fn represented_creature_loot_state_like_cpp( &mut self, guid: ObjectGuid, @@ -5939,6 +8921,7 @@ impl WorldSession { gold_max: creature.gold_max(), dungeon_encounter_id: creature.dungeon_encounter_id(), tappers: creature.creature.tap_list().to_vec(), + loot_lifecycle_revision: creature.creature.loot_lifecycle_revision_like_cpp(), }) } @@ -6108,7 +9091,9 @@ impl WorldSession { &mut self, guid: ObjectGuid, player_guid: ObjectGuid, - fully_looted: bool, + selected_pool_looted: bool, + mut whole_object_fully_looted: bool, + authoritative_release: Option<&AuthoritativeLootReleaseLikeCpp>, ) { let go_type = self .represented_gameobject_use_states @@ -6132,13 +9117,63 @@ impl WorldSession { .represented_gameobject_use_states .get(&guid) .and_then(|state| state.fishing_hole_max_opens); - let canonical_fishing_hole_use_count_after_release = (go_type - == Some(GAMEOBJECT_TYPE_FISHING_HOLE)) - .then(|| self.add_use_and_get_canonical_gameobject_use_count_like_cpp(guid)) - .flatten(); + let canonical_fishing_hole_release = (go_type == Some(GAMEOBJECT_TYPE_FISHING_HOLE)) + .then(|| { + self.release_canonical_fishing_hole_like_cpp( + guid, + represented_fishing_hole_max_opens, + ) + }) + .flatten(); + let canonical_fishing_hole_use_count_after_release = canonical_fishing_hole_release + .as_ref() + .map(|(use_count, _, _)| *use_count); + + let guarded_global_transition_attempted = selected_pool_looted + && whole_object_fully_looted + && !matches!( + go_type, + Some(GAMEOBJECT_TYPE_FISHING_NODE) + | Some(GAMEOBJECT_TYPE_FISHING_HOLE) + | Some(GAMEOBJECT_TYPE_GATHERING_NODE) + ) + && authoritative_release.is_some(); + let guarded_global_transition = authoritative_release + .filter(|_| guarded_global_transition_attempted) + .and_then(|release| { + if release.require_no_viewers { + self.set_canonical_gameobject_loot_state_if_unviewed_fully_looted_observation_like_cpp( + guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + LootState::JustDeactivated, + None, + represented_chest_restock_time_secs, + false, + ) + } else { + self.set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + LootState::JustDeactivated, + None, + represented_chest_restock_time_secs, + false, + ) + } + }); + if guarded_global_transition_attempted && guarded_global_transition.is_none() { + // An upsert/install/replacement won the serialization point after + // close. Its new pool must keep the object globally active. + whole_object_fully_looted = false; + } let canonical_loot_state_request = match go_type { Some(GAMEOBJECT_TYPE_FISHING_NODE) => Some((LootState::JustDeactivated, None, false)), + Some(GAMEOBJECT_TYPE_FISHING_HOLE) if canonical_fishing_hole_release.is_some() => None, Some(GAMEOBJECT_TYPE_FISHING_HOLE) => { let use_count_after_release = canonical_fishing_hole_use_count_after_release .unwrap_or(represented_personal_loot_uses_after_release); @@ -6151,11 +9186,15 @@ impl WorldSession { }; Some((state, None, false)) } - Some(GAMEOBJECT_TYPE_GATHERING_NODE) if fully_looted => None, - _ if fully_looted => Some((LootState::JustDeactivated, None, false)), + Some(GAMEOBJECT_TYPE_GATHERING_NODE) if selected_pool_looted => None, + _ if guarded_global_transition_attempted => None, + _ if selected_pool_looted && whole_object_fully_looted => { + Some((LootState::JustDeactivated, None, false)) + } + _ if selected_pool_looted => None, _ => Some((LootState::Activated, Some(player_guid), true)), }; - let canonical_loot_state_updated = canonical_loot_state_request.is_some_and( + let requested_loot_state_outcome = canonical_loot_state_request.and_then( |(loot_state, unit_guid, shared_loot_is_changed_like_cpp)| { self.set_canonical_gameobject_loot_state_like_cpp( guid, @@ -6164,18 +9203,33 @@ impl WorldSession { represented_chest_restock_time_secs, shared_loot_is_changed_like_cpp, ) - .is_some_and(|outcome| { - outcome.status == wow_map::map::GameObjectSetLootStateStatusLikeCpp::Updated - }) }, ); + let canonical_applied_loot_state = if guarded_global_transition.is_some() { + Some((LootState::JustDeactivated, None)) + } else if let Some((_, state, _)) = canonical_fishing_hole_release.as_ref() { + Some((*state, None)) + } else { + canonical_loot_state_request.map(|(state, unit_guid, _)| (state, unit_guid)) + }; + let canonical_loot_state_updated = guarded_global_transition + .as_ref() + .or_else(|| { + canonical_fishing_hole_release + .as_ref() + .map(|(_, _, outcome)| outcome) + }) + .or(requested_loot_state_outcome.as_ref()) + .is_some_and(|outcome| { + outcome.status == wow_map::map::GameObjectSetLootStateStatusLikeCpp::Updated + }); let state = self .represented_gameobject_use_states .entry(guid) .or_default(); if canonical_loot_state_updated { - if let Some((loot_state, unit_guid, _)) = canonical_loot_state_request { + if let Some((loot_state, unit_guid)) = canonical_applied_loot_state { state.loot_state = Some(loot_state); state.loot_state_unit_guid = unit_guid.unwrap_or(ObjectGuid::EMPTY); if loot_state == LootState::Activated @@ -6209,9 +9263,10 @@ impl WorldSession { }; state.loot_state_unit_guid = ObjectGuid::EMPTY; } - Some(GAMEOBJECT_TYPE_GATHERING_NODE) if fully_looted => {} + Some(GAMEOBJECT_TYPE_GATHERING_NODE) if selected_pool_looted => {} Some(GAMEOBJECT_TYPE_CHEST) - if fully_looted + if selected_pool_looted + && whole_object_fully_looted && state.chest_consumable == Some(false) && state .chest_personal_loot_id @@ -6226,10 +9281,11 @@ impl WorldSession { state.chest_restock_until = Some(Instant::now() + Duration::from_secs(u64::from(restock_secs))); } - _ if fully_looted => { + _ if selected_pool_looted && whole_object_fully_looted => { state.loot_state = Some(LootState::JustDeactivated); state.loot_state_unit_guid = ObjectGuid::EMPTY; } + _ if selected_pool_looted => {} _ => { state.loot_state = Some(LootState::Activated); state.loot_state_unit_guid = player_guid; @@ -6251,10 +9307,11 @@ impl WorldSession { state.personal_loot_uses = canonical_fishing_hole_use_count_after_release .unwrap_or(represented_personal_loot_uses_after_release); } - if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) { + if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) && selected_pool_looted { state.go_state = Some(GoState::Active); } if go_type == Some(GAMEOBJECT_TYPE_CHEST) + && selected_pool_looted && state.chest_consumable == Some(false) && state .chest_personal_loot_id @@ -6374,26 +9431,231 @@ impl WorldSession { } } - pub(crate) fn close_active_loot_windows_like_cpp(&mut self, player_guid: ObjectGuid) { - let mut active_owners: Vec = - self.active_loot_view_owners.iter().copied().collect(); - if active_owners.is_empty() && !self.active_loot_guid.is_empty() { - active_owners.push(self.active_loot_guid); + /// Publishes successful durable loot transactions that outlived their + /// packet waiter. This replays committed Item-owned money or item grants + /// into runtime state before disconnect can persist a stale snapshot. C++ + /// auto-releases only when `Loot::isLooted()` (zero coins and no visible + /// items) and the owner GUID is an Item. + pub(crate) async fn apply_pending_durable_item_loot_completions_like_cpp(&mut self) { + self.apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp(true) + .await; + } + + pub(crate) async fn apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp( + &mut self, + drain_money_objectives: bool, + ) { + let completions = self.take_durable_item_loot_completions_like_cpp(); + for completion in completions { + if let Some(fanout) = completion.item_fanout.as_ref() { + let _ = self.publish_durable_loot_item_fanout_like_cpp(fanout); + } + let requires_runtime_recovery = + !completion.runtime_inventory_applied.load(Ordering::Acquire); + let targets_current_player = self.player_guid() == Some(completion.player_guid); + + if let Some(applied_delta) = completion.durable_item_money_applied_amount { + let apply_balance = targets_current_player + && completion + .durable_item_money_balance_applied + .as_ref() + .is_some_and(|applied| { + applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }); + let publish = targets_current_player + && completion + .runtime_inventory_applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(); + if apply_balance { + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(applied_delta) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + if publish { + self.represented_notify_money_removed_like_cpp(completion.owner_guid); + self.send_packet(&LootMoneyNotify { + money: completion + .durable_item_money_notified_amount + .expect("durable Item money completion retains notification amount"), + money_mod: 0, + sole_looter: true, + }); + + let fully_looted = + self.loot_table + .get_mut(&completion.owner_guid) + .is_some_and(|loot| { + loot.coins = 0; + loot_is_looted_like_cpp(loot) + }); + if fully_looted { + // Source Item destruction remains owned by the normal + // C++ release phase; the completion only makes the + // already-durable money mutation visible first. + self.do_loot_release_owner_like_cpp( + completion.owner_guid, + completion.player_guid, + ) + .await; + } + } + if drain_money_objectives && (apply_balance || publish) { + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + continue; + } + + if targets_current_player && completion.item_owner_auto_release { + debug_assert!(completion.owner_guid.is_item()); + let removal = self + .loot_table + .get_mut(&completion.owner_guid) + .and_then(|loot| { + let entry = loot + .items + .iter() + .find(|entry| entry.loot_list_id == completion.loot_list_id)?; + let free_for_all = entry.flags.freeforall; + let newly_removed = !loot_item_is_looted_for_player_like_cpp( + loot, + entry, + completion.player_guid, + ); + let loot_obj = loot.loot_guid; + mark_loot_item_looted_for_player_like_cpp( + loot, + completion.loot_list_id, + completion.player_guid, + ); + Some(( + loot_obj, + free_for_all, + newly_removed, + loot_is_looted_like_cpp(loot), + )) + }); + + if let Some((loot_obj, free_for_all, newly_removed, fully_looted)) = removal { + if newly_removed { + if free_for_all { + self.send_packet(&LootRemoved { + owner: completion.owner_guid, + loot_obj, + loot_list_id: completion.loot_list_id, + }); + } else { + self.represented_notify_loot_item_removed_like_cpp( + completion.owner_guid, + completion.loot_list_id, + ); + } + } + + if fully_looted { + self.do_loot_release_owner_like_cpp( + completion.owner_guid, + completion.player_guid, + ) + .await; + } + } + } else if targets_current_player && requires_runtime_recovery { + // The detached worker already committed the authority. Refresh + // the packet cache so disconnect's DoLootReleaseAll observes + // the consumed claim rather than the pre-commit session copy. + self.refresh_owned_loot_summary_like_cpp(completion.owner_guid); + let _ = self.reconcile_represented_loot_cache_like_cpp( + completion.owner_guid, + completion.player_guid, + ); + } + + if requires_runtime_recovery { + // SQL committed after the packet waiter disappeared, before + // its synchronous runtime inventory publication. Do not let + // the player operate on a stale slot; the persisted grant and + // source consumption are reconstructed on the next login. + self.kick("durable loot item completed after handler cancellation; relog required"); + } } - active_owners.sort_by_key(|guid| (guid.high_value(), guid.low_value())); + } - for owner_guid in active_owners { - if let Some(loot) = self.loot_table.get_mut(&owner_guid) { - loot.players_looting.retain(|looter| *looter != player_guid); + pub(crate) async fn wait_for_active_loot_persistence_like_cpp(&mut self) { + let mut authorities = Vec::::new(); + for authority in self.active_loot_view_authorities_like_cpp.values() { + if authorities + .iter() + .any(|existing| existing.shares_storage_like_cpp(authority)) + { + continue; } - self.send_packet(&SLootRelease { - loot_obj: owner_guid, - owner: player_guid, - }); - self.clear_active_loot_guid_if(owner_guid); + authorities.push(authority.clone()); + } + for authority in authorities { + authority.wait_for_persisting_claims_like_cpp().await; + } + self.wait_for_durable_item_loot_persistence_like_cpp().await; + self.apply_pending_durable_item_loot_completions_like_cpp() + .await; + } + + /// Mirrors the observable side of C++ `Loot::~Loot`: once the exact + /// object-owned allocation behind an open view is retired, detached, or + /// replaced, the next session tick releases that stale client window. + /// Each session owns its socket, so global object destruction is fanned + /// out cooperatively without holding a map lock across network work. + pub(crate) fn close_retired_active_loot_windows_like_cpp(&mut self, player_guid: ObjectGuid) { + let mut stale_owners = self + .active_loot_view_authorities_like_cpp + .iter() + .filter_map(|(owner_guid, authority)| { + let generation = self + .active_loot_view_generations_like_cpp + .get(owner_guid) + .copied(); + let still_open = generation.is_some_and(|generation| { + authority + .snapshot_for_player_like_cpp(player_guid) + .is_some_and(|snapshot| snapshot.generation == generation) + }); + (!still_open).then_some(*owner_guid) + }) + .collect::>(); + stale_owners.sort_unstable_by_key(|guid| (guid.high_value(), guid.low_value())); + + for owner_guid in stale_owners { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); } } + fn close_stale_active_loot_view_like_cpp( + &mut self, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + ) { + self.discard_represented_personal_loot_cache_for_player_like_cpp(owner_guid, player_guid); + self.send_packet(&SLootRelease { + loot_obj: owner_guid, + owner: player_guid, + }); + self.clear_active_loot_guid_if(owner_guid); + } + async fn do_loot_release_owner_like_cpp( &mut self, owner_guid: ObjectGuid, @@ -6405,29 +9667,80 @@ impl WorldSession { return false; } - // Check if loot is fully taken (all items picked up). - // Coins are auto-consumed when the loot window opens (sent in LootResponse), - // so we only check items here. - // C# ref: `loot.IsLooted()` → no more non-taken items. - let Some(loot) = self.loot_table.get(&owner_guid) else { + let authoritative_release = if let Some(authority) = + self.prepare_owned_loot_authority_for_active_request_like_cpp(owner_guid, player_guid) + { + if !self + .active_loot_view_authorities_like_cpp + .get(&owner_guid) + .is_some_and(|opened| opened.shares_storage_like_cpp(&authority)) + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + } + let Some(active_generation) = self + .active_loot_view_generations_like_cpp + .get(&owner_guid) + .copied() + else { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + }; + let Some(close) = + authority.close_viewer_if_generation_like_cpp(active_generation, player_guid) + else { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + }; + Some(AuthoritativeLootReleaseLikeCpp { + authority, + selected_generation: active_generation, + loot: close.snapshot.loot, + whole_object_fully_looted: close.whole_object_fully_looted, + whole_object_fully_skinned: close.whole_object_fully_skinned, + object_generation: close.object_generation, + lifecycle_revision: close.lifecycle_revision, + require_no_viewers: false, + }) + } else { + None + }; + + if authoritative_release.is_none() + && (owner_guid.is_creature_or_vehicle() || owner_guid.is_game_object()) + && !represented_local_loot_fixture_allowed_like_cpp() + { + self.close_stale_active_loot_view_like_cpp(owner_guid, player_guid); + return true; + } + + // C++ `Loot::isLooted()` requires both zero gold and zero remaining + // player-visible item count. + let Some(loot) = authoritative_release + .as_ref() + .map(|release| &release.loot) + .or_else(|| self.loot_table.get(&owner_guid)) + else { return false; }; - let represented_fully_looted = loot_is_looted_like_cpp(loot); + let selected_pool_looted = loot_is_looted_like_cpp(loot); let represented_loot_type = loot.loot_type; - let fully_looted = if owner_guid.is_game_object() { + let whole_object_fully_looted = if let Some(release) = authoritative_release.as_ref() { + release.whole_object_fully_looted + } else if owner_guid.is_game_object() { self.canonical_gameobject_fully_looted_after_represented_sync_like_cpp( owner_guid, player_guid, - represented_fully_looted, + selected_pool_looted, ) } else if owner_guid.is_creature_or_vehicle() { self.canonical_creature_fully_looted_after_represented_sync_like_cpp( owner_guid, player_guid, - represented_fully_looted, + selected_pool_looted, ) } else { - represented_fully_looted + selected_pool_looted }; if let Some(loot) = self.loot_table.get_mut(&owner_guid) { @@ -6446,57 +9759,145 @@ impl WorldSession { if !self .represented_gameobject_can_autostore_loot_item_like_cpp(owner_guid, player_guid) { + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } return true; } self.apply_represented_gameobject_loot_release_like_cpp( owner_guid, player_guid, - fully_looted, + selected_pool_looted, + whole_object_fully_looted, + authoritative_release.as_ref(), ); let _ = self.queue_chest_gameobject_state_refresh_for_same_map_like_cpp(owner_guid); - if !fully_looted { - return true; - } - - self.hide_represented_gameobject_for_player_after_loot_release_like_cpp(owner_guid); let go_type = self .represented_gameobject_use_states .get(&owner_guid) .and_then(|state| state.go_type) .map(u32::from); + let selected_release_branch = selected_pool_looted + || matches!( + go_type, + Some(GAMEOBJECT_TYPE_FISHING_NODE) | Some(GAMEOBJECT_TYPE_FISHING_HOLE) + ); + if !selected_release_branch { + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } + return true; + } + + self.hide_represented_gameobject_for_player_after_loot_release_like_cpp(owner_guid); if go_type == Some(GAMEOBJECT_TYPE_GATHERING_NODE) { self.send_gathering_node_loot_release_dynamic_flags_update_like_cpp(owner_guid); } + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } else { + self.loot_table.remove(&owner_guid); + } + return true; + } + + if owner_guid.is_item() + && matches!( + represented_loot_type, + LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP + ) + { + // C++ always clears the generated Loot and consumes at most five + // source items for prospecting/milling, even if the window closes + // before every generated entry was taken. + self.clear_active_loot_guid_if(owner_guid); self.loot_table.remove(&owner_guid); + self.update_inventory_item_object_like_cpp(owner_guid, |item| { + item.set_loot_generated(false); + }); + self.destroy_direct_item_count_after_loot_release_like_cpp(owner_guid, Some(5)) + .await; return true; } - if owner_guid.is_item() && !fully_looted { + if owner_guid.is_item() && !selected_pool_looted { self.clear_active_loot_guid_if(owner_guid); + let item_has_loot_flag = self + .inventory_items_like_cpp() + .values() + .find(|item| item.guid == owner_guid) + .and_then(|item| self.item_template_flags(item.entry_id)) + .map(|flags| flags.contains(wow_constants::ItemFlags::HAS_LOOT)); + if item_has_loot_flag == Some(false) { + self.destroy_fully_looted_direct_item(owner_guid).await; + } return true; } self.clear_active_loot_guid_if(owner_guid); - if !fully_looted { - let round_robin_released = self.loot_table.get_mut(&owner_guid).is_some_and(|loot| { - if loot.round_robin_player == player_guid { - loot.round_robin_player = ObjectGuid::EMPTY; - true - } else { - false - } - }); + if !selected_pool_looted { + let round_robin_released = if let Some(release) = authoritative_release.as_ref() { + release + .authority + .clear_round_robin_if_generation_like_cpp( + release.selected_generation, + player_guid, + ) + .is_some_and(|outcome| { + self.loot_table.insert(owner_guid, outcome.snapshot.loot); + outcome.cleared + }) + } else { + self.loot_table.get_mut(&owner_guid).is_some_and(|loot| { + if loot.round_robin_player == player_guid { + loot.round_robin_player = ObjectGuid::EMPTY; + true + } else { + false + } + }) + }; if round_robin_released { self.represented_notify_loot_list_like_cpp(owner_guid); } + if owner_guid.is_creature_or_vehicle() { + let values_update = self.mutate_world_creature(owner_guid, |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.creature.unit().values_update() + }); + if let Some(values_update) = values_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); + } + } + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } return true; } // Remove loot entry from memory once the represented loot is consumed. self.loot_table.remove(&owner_guid); - if owner_guid.is_item() && fully_looted { + if owner_guid.is_item() && selected_pool_looted { self.destroy_fully_looted_direct_item(owner_guid).await; return true; } @@ -6506,34 +9907,86 @@ impl WorldSession { return true; } + // C++ forces the viewer-dependent DynamicFlags field after every + // creature release, including a selected personal pool that completed + // while another pool remains. + let forced_values_update = self.mutate_world_creature(owner_guid, |creature| { + creature.force_dynamic_flags_update_like_cpp(); + creature.creature.unit().values_update() + }); + + if !whole_object_fully_looted { + if let Some(values_update) = forced_values_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); + } + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, + ); + } + return true; + } + let corpse_decay_looted_rate = self.loot_drop_rates_like_cpp().corpse_decay_looted; // Start corpse despawn timer if fully looted. - let marked = self - .mutate_world_creature(owner_guid, |creature| { - creature.remove_lootable_dynamic_flag_like_cpp(); - if !creature.is_alive() { - let is_fully_skinned = represented_loot_type == LOOT_TYPE_SKINNING_LIKE_CPP; - let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( - is_fully_skinned, - creature.corpse_delay_secs_like_cpp(), - creature.ignore_corpse_decay_ratio_like_cpp(), - corpse_decay_looted_rate, - ); - if !creature.all_loot_removed_from_corpse_like_cpp( - corpse_decay_looted_rate, - is_fully_skinned, - ) { - // C++ returns without resetting an already-expired - // corpse. The lifecycle mirror must remain expired too. - return None; - } - Some((creature.entry(), corpse_decay_secs)) - } else { + let whole_object_fully_skinned = authoritative_release.as_ref().map_or( + represented_loot_type == LOOT_TYPE_SKINNING_LIKE_CPP, + |release| release.whole_object_fully_skinned, + ); + let apply_lifecycle = |creature: &mut crate::map_manager::WorldCreature| { + creature.remove_lootable_dynamic_flag_like_cpp(); + let marked = if !creature.is_alive() { + let corpse_decay_secs = looted_corpse_decay_secs_like_cpp( + whole_object_fully_skinned, + creature.corpse_delay_secs_like_cpp(), + creature.ignore_corpse_decay_ratio_like_cpp(), + corpse_decay_looted_rate, + ); + if !creature.all_loot_removed_from_corpse_like_cpp( + corpse_decay_looted_rate, + whole_object_fully_skinned, + ) { + // C++ returns without resetting an already-expired + // corpse. The lifecycle mirror must remain expired too. None + } else { + Some((creature.entry(), corpse_decay_secs)) } - }) - .flatten(); + } else { + None + }; + (marked, creature.creature.unit().values_update()) + }; + let lifecycle_update = if let Some(release) = authoritative_release.as_ref() { + self.mutate_world_creature_if_fully_looted_observation_like_cpp( + owner_guid, + &release.authority, + release.object_generation, + release.lifecycle_revision, + apply_lifecycle, + ) + } else { + self.mutate_world_creature(owner_guid, apply_lifecycle) + }; + + if let Some((_, values_update)) = lifecycle_update.as_ref() { + self.send_creature_loot_release_dynamic_flags_update_like_cpp( + owner_guid, + values_update, + authoritative_release + .as_ref() + .map(|release| &release.authority), + ); + } + let marked = lifecycle_update.and_then(|(marked, _)| marked); if let Some((entry, corpse_decay_secs)) = marked { info!( @@ -6542,51 +9995,175 @@ impl WorldSession { ); } - true - } - - async fn delete_stored_item_money_like_cpp(&self, item_guid: ObjectGuid) { - let Some(char_db) = self.char_db().map(Arc::clone) else { - return; - }; - - let mut stmt = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_MONEY); - stmt.set_u64(0, item_guid.counter() as u64); - if let Err(e) = char_db.execute(&stmt).await { - warn!( - item_guid = item_guid.counter(), - error = %e, - "failed to delete stored item loot money" + if authoritative_release.is_some() { + self.discard_represented_personal_loot_cache_for_player_like_cpp( + owner_guid, + player_guid, ); } + + true } - async fn delete_stored_item_loot_item_like_cpp( + async fn persist_and_consume_stored_item_money_like_cpp( &self, item_guid: ObjectGuid, - item_id: u32, - count: u32, - loot_list_id: u8, - ) { - let Some(char_db) = self.char_db().map(Arc::clone) else { - return; + cached_notified_amount: u64, + ) -> Option<(Arc, Arc, u64, u64)> { + let (worker, balance_applied, publication_applied) = self + .spawn_stored_item_money_persistence_worker_like_cpp( + item_guid, + cached_notified_amount, + )?; + match worker.await { + Ok(Ok((applied_delta, notified_amount))) => Some(( + balance_applied, + publication_applied, + applied_delta, + notified_amount, + )), + Ok(Err(error)) => { + warn!( + item_guid = item_guid.counter(), + ?error, + "failed to atomically persist and consume stored item loot money" + ); + None + } + Err(error) => { + warn!( + item_guid = item_guid.counter(), + ?error, + "stored item loot-money persistence worker terminated" + ); + None + } + } + } + + fn spawn_stored_item_money_persistence_worker_like_cpp( + &self, + item_guid: ObjectGuid, + cached_notified_amount: u64, + ) -> Option<( + tokio::task::JoinHandle>, + Arc, + Arc, + )> { + let Some(player_guid) = self.player_guid() else { + return None; + }; + let test_result = self.loot_money_persistence_test_result_for_worker_like_cpp(); + let char_db = if test_result.is_some() { + None + } else { + Some(self.char_db().map(Arc::clone)?) }; + let test_current_money = self.player_gold_like_cpp(); + let balance_applied = Arc::new(AtomicBool::new(false)); + let publication_applied = Arc::new(AtomicBool::new(false)); + let mut item_persistence_guard = self.begin_durable_item_loot_persistence_like_cpp(); + let money_persistence_tracker = self.durable_loot_money_persistence_tracker_like_cpp(); + let mut money_persistence_guard = money_persistence_tracker.begin_like_cpp().ok()?; + let command_tx = self.session_command_tx(); + let worker_balance_applied = Arc::clone(&balance_applied); + let worker_publication_applied = Arc::clone(&publication_applied); + let worker = tokio::spawn(async move { + let _money_mutation_lock = money_persistence_tracker + .lock_money_mutation_like_cpp() + .await; + let (before, after, applied_delta, notified_amount) = if let Some(success) = test_result + { + tokio::task::yield_now().await; + if !success { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + let (after, applied_delta) = + loot_money_durable_outcome_like_cpp(test_current_money, cached_notified_amount); + ( + test_current_money, + after, + applied_delta, + cached_notified_amount, + ) + } else { + let char_db = char_db.expect("production stored-money worker has a database"); + let attempt = retry_deadlocked_operation_like_cpp( + || { + attempt_stored_item_money_transaction_like_cpp( + char_db.as_ref(), + player_guid, + item_guid, + cached_notified_amount, + ) + }, + stored_item_money_attempt_is_deadlock_like_cpp, + ) + .await; + let outcome = match attempt { + Ok(outcome) => outcome, + Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack(error)) => { + return Err(error); + } + Err(StoredItemMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { + error, + outcome, + }) => match reconcile_stored_item_money_commit_like_cpp( + char_db.as_ref(), + player_guid, + item_guid, + outcome, + ) + .await + { + Ok(StoredItemMoneyCommitReconciliationLikeCpp::Committed) => outcome, + Ok(StoredItemMoneyCommitReconciliationLikeCpp::RolledBack) => { + return Err(LootMoneyPersistenceErrorLikeCpp::Database( + DatabaseError::Transaction( + "stored Item money COMMIT was reconciled as rolled back" + .to_string(), + ), + )); + } + Ok(StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate) | Err(_) => { + money_persistence_guard.mark_indeterminate_like_cpp(); + queue_stored_item_money_indeterminate_kick_like_cpp(&command_tx); + return Err(LootMoneyPersistenceErrorLikeCpp::CommitOutcomeUnknown( + error, + )); + } + }, + }; + ( + outcome.before, + outcome.after, + outcome.applied_delta, + outcome.notified_amount, + ) + }; - let mut stmt = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); - stmt.set_u64(0, item_guid.counter() as u64); - stmt.set_u32(1, item_id); - stmt.set_u32(2, count); - stmt.set_u32(3, u32::from(loot_list_id)); - if let Err(e) = char_db.execute(&stmt).await { - warn!( - item_guid = item_guid.counter(), - item_id, - count, - loot_list_id, - error = %e, - "failed to delete stored item loot row" + money_persistence_guard.commit_like_cpp( + wow_network::DurableLootMoneyCompletionLikeCpp { + durable_money_before: before, + durable_money_after: after, + durable_applied_amount: applied_delta, + applied: Arc::clone(&worker_balance_applied), + }, ); - } + item_persistence_guard.mark_committed_like_cpp(DurableItemLootCompletionLikeCpp { + owner_guid: item_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(applied_delta), + durable_item_money_notified_amount: Some(notified_amount), + durable_item_money_balance_applied: Some(Arc::clone(&worker_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&worker_publication_applied), + }); + Ok((applied_delta, notified_amount)) + }); + Some((worker, balance_applied, publication_applied)) } async fn store_direct_loot_item_like_cpp( @@ -6594,199 +10171,476 @@ impl WorldSession { loot_entry: &LootEntry, dungeon_encounter_id: u32, ) -> bool { - let item_id = loot_entry.item_id; - let count = loot_entry.quantity; + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + None, + None, + None, + ) + .await + } + + /// Apply the item state established by C++ `Player::StoreNewItem` and + /// `_StoreItem` before the item is persisted or sent to the client. + fn apply_stored_new_item_flags_like_cpp(&self, item_id: u32, slot: u8, item: &mut Item) { + if let Some(template) = self.item_storage_template(item_id) { + item.set_bonding(template.bonding); + } + item.set_item_flag(ItemFieldFlags::NEW_ITEM); + item.bind_if_stored(is_bag_pos(make_item_pos(INVENTORY_SLOT_BAG_0, slot))); + } + + fn stored_new_item_dynamic_flags_like_cpp(&self, item_id: u32, slot: u8) -> u32 { + let mut item = Item::new(0); + self.apply_stored_new_item_flags_like_cpp(item_id, slot, &mut item); + item.item_flags_bits() + } + + /// C++ `_StoreItem` binds the destination object before incrementing an + /// existing stack. Unlike `StoreNewItem`, that historical object must not + /// acquire `ITEM_FIELD_FLAG_NEW_ITEM` merely because more items arrived. + fn stored_existing_item_dynamic_flags_like_cpp( + &self, + item_id: u32, + slot: u8, + existing: &Item, + ) -> u32 { + let mut planned = existing.clone(); + if let Some(template) = self.item_storage_template(item_id) { + planned.set_bonding(template.bonding); + } + planned.bind_if_stored(is_bag_pos(make_item_pos(INVENTORY_SLOT_BAG_0, slot))); + planned.item_flags_bits() + } + + /// Queue count and any binding transition in one transaction. Runtime + /// state is deliberately updated only after this transaction commits. + fn append_existing_loot_stack_persistence_like_cpp( + char_db: &CharacterDatabase, + transaction: &mut SqlTransaction, + db_guid: u64, + new_count: u32, + dynamic_flags: Option, + ) { + let mut update_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); + update_count.set_u32(0, new_count); + update_count.set_u64(1, db_guid); + transaction.append_expect_rows_affected(update_count, 1); + + if let Some(dynamic_flags) = dynamic_flags { + let mut update_flags = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_FLAGS); + update_flags.set_u32(0, dynamic_flags); + update_flags.set_u64(1, db_guid); + transaction.append_expect_rows_affected(update_flags, 1); + } + } + + /// Persist the complete result of one group-roll disenchant as a single + /// durable award. + /// + /// C++ `LootRoll::Finish` first materializes a temporary + /// `LOOT_DISENCHANTING` loot and then calls `Loot::AutoStore`. The C++ + /// loop stores each generated material independently, which is unsafe for + /// Rust's concurrently shared object authority: a later failure could + /// reopen the original roll slot after an earlier material was durable. + /// This bounded divergence keeps C++ generation/inventory rules but plans + /// every material before creating one SQL transaction. The detached + /// transaction worker owns the original roll claim through COMMIT. + async fn store_direct_disenchant_batch_like_cpp( + &mut self, + loot_entries: &[LootEntry], + dungeon_encounter_id: u32, + claim: Option<&LootClaimLease>, + claim_commit_context: Option, + ) -> bool { let Some(player_guid) = self.player_guid() else { return false; }; - let Some(char_db) = self.char_db().map(Arc::clone) else { - return false; - }; - let Some((store_result, mut store_dest, _)) = - self.plan_store_new_direct_inventory_item(item_id, count) - else { - self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); - return false; - }; - if store_result != InventoryResult::Ok { - self.send_equip_error(store_result, None, None, 0, 0); + if loot_entries.is_empty() + || loot_entries + .iter() + .any(|entry| entry.item_id == 0 || entry.quantity == 0) + { return false; } - - let store_random_properties = { - let mut rng = self.represented_runtime_subrng_like_cpp(); - self.generate_loot_store_random_properties_with_rng_like_cpp(item_id, &mut rng) + let durable_item_fanout = match (claim, claim_commit_context) { + (Some(claim), Some(context)) => { + let Some(fanout) = self.prepare_durable_loot_item_fanout_like_cpp(claim, context) + else { + return false; + }; + Some(fanout) + } + (None, None) => None, + _ => return false, }; - if store_dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - bag == u8::from(INVENTORY_SLOT_BAG_0) - && self - .inventory_items_like_cpp() - .get(&slot) - .is_some_and(|existing| { - self.inventory_item_objects_like_cpp() - .get(&existing.guid) - .is_some_and(|item| { - !loot_store_data_can_stack_with_item( - loot_entry, - store_random_properties, - item, - ) - }) - }) - }) { - let Some(compatible_dest) = self.plan_direct_loot_item_preserving_cpp_store_metadata( - loot_entry, - store_random_properties, + #[cfg(test)] + if let Some(grants) = self.loot_item_store_test_grants_like_cpp.clone() { + let success = self.loot_item_store_test_success_like_cpp; + let commit_gate = self.loot_item_store_test_commit_gate_like_cpp.clone(); + let grant_count = loot_entries.len(); + let runtime_inventory_applied = + claim_commit_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = claim_commit_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map(|(context, runtime_inventory_applied)| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid: context.owner_guid, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }); + let Ok(persistence) = spawn_loot_claim_persistence_worker_like_cpp( + async move { + // Model the asynchronous commit boundary so cancellation + // regressions exercise the same ownership shape as SQL. + tokio::task::yield_now().await; + if let Some(gate) = commit_gate { + gate.notified().await; + } + if !success { + return Err(()); + } + grants.fetch_add(grant_count, Ordering::SeqCst); + Ok(()) + }, + claim.cloned(), + durable_item_completion, ) else { - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); return false; }; - store_dest = compatible_dest; + if !matches!(persistence.await, Ok(Ok(()))) { + return false; + } + for (slot, entry) in loot_entries.iter().enumerate() { + self.send_loot_item_push_result( + player_guid, + ObjectGuid::EMPTY, + entry, + 0, + 0, + u8::try_from(slot).unwrap_or(0), + entry.quantity, + entry.quantity, + false, + dungeon_encounter_id, + ); + } + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } + return true; } - let mut planned_existing_counts = Vec::<(u8, ObjectGuid, u64, u32, u32)>::new(); - let mut planned_new_stacks = Vec::::new(); + let Some(char_db) = self.char_db().map(Arc::clone) else { + return false; + }; - for dest in store_dest { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - if bag != u8::from(INVENTORY_SLOT_BAG_0) { + // `CanStoreNewItem`'s max-count checks must see all generated stacks + // of the same material, not each temporary LootItem in isolation. + let mut quantity_by_item = HashMap::::new(); + for entry in loot_entries { + let Some(total) = quantity_by_item + .get(&entry.item_id) + .copied() + .unwrap_or(0) + .checked_add(entry.quantity) + else { self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); return false; + }; + quantity_by_item.insert(entry.item_id, total); + } + for (item_id, count) in quantity_by_item { + let Some((store_result, _, _)) = + self.plan_store_new_direct_inventory_item(item_id, count) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + if store_result != InventoryResult::Ok { + self.send_equip_error(store_result, None, None, 0, 0); + return false; } + } + + let backpack_end = INVENTORY_SLOT_ITEM_START + .saturating_add(INVENTORY_DEFAULT_SIZE) + .min(INVENTORY_SLOT_ITEM_END); + let mut planned_existing_stacks = Vec::::new(); + let mut planned_new_stacks = Vec::::new(); + let mut planned_grants = Vec::::new(); + for loot_entry in loot_entries { + let random_properties = { + let mut rng = self.represented_runtime_subrng_like_cpp(); + self.generate_loot_store_random_properties_with_rng_like_cpp( + loot_entry.item_id, + &mut rng, + ) + }; let max_stack = self - .item_storage_template(item_id) + .item_storage_template(loot_entry.item_id) .map(|template| template.max_stack_size) .unwrap_or(1) .max(1); - - if let Some(existing) = self.inventory_items_like_cpp().get(&slot) { + let mut remaining = loot_entry.quantity; + let mut existing_pushes = Vec::new(); + let mut new_pushes = Vec::new(); + + // Existing backpack stacks are consumed first, matching the + // direct StoreNewItem path represented in this server. + for slot in INVENTORY_SLOT_ITEM_START..backpack_end { + if remaining == 0 { + break; + } + let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { + continue; + }; + if existing.entry_id != loot_entry.item_id { + continue; + } let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) else { self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); return false; }; - let base_count = planned_existing_counts + if !loot_store_data_can_stack_with_item( + loot_entry, + random_properties, + existing_object, + ) { + continue; + } + + let current_count = planned_existing_stacks .iter() - .find(|(planned_slot, ..)| *planned_slot == slot) - .map(|(_, _, _, new_count, _)| *new_count) + .find(|planned| planned.slot == slot) + .map(|planned| planned.new_count) .unwrap_or_else(|| existing_object.count()); - let new_count = base_count.saturating_add(dest.count); - if existing.entry_id != item_id - || new_count > max_stack - || !loot_store_data_can_stack_with_item( - loot_entry, - store_random_properties, - existing_object, - ) - { - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; + let added_count = max_stack.saturating_sub(current_count).min(remaining); + if added_count == 0 { + continue; } - if let Some(existing_plan) = planned_existing_counts + let new_count = current_count.saturating_add(added_count); + if let Some(planned) = planned_existing_stacks .iter_mut() - .find(|(planned_slot, ..)| *planned_slot == slot) + .find(|planned| planned.slot == slot) { - existing_plan.3 = new_count; - existing_plan.4 = existing_plan.4.saturating_add(dest.count); + planned.new_count = new_count; } else { - planned_existing_counts.push(( + let dynamic_flags = self.stored_existing_item_dynamic_flags_like_cpp( + loot_entry.item_id, + slot, + existing_object, + ); + planned_existing_stacks.push(PlannedDisenchantExistingStack { slot, - existing.guid, - existing.db_guid, + item_guid: existing.guid, + db_guid: existing.db_guid, new_count, - dest.count, - )); + dynamic_flags, + flags_changed: dynamic_flags != existing_object.item_flags_bits(), + }); } - continue; + existing_pushes.push(PlannedDisenchantExistingPush { + slot, + item_guid: existing.guid, + added_count, + new_count, + }); + remaining = remaining.saturating_sub(added_count); } - if let Some(stack) = planned_new_stacks - .iter_mut() - .find(|stack| stack.slot == slot) - { - if stack.entry_id == item_id - && stack.random_properties_id == store_random_properties.id - && stack.random_properties_seed == store_random_properties.seed - && stack.item_context == loot_entry.item_context - && stack.count.saturating_add(dest.count) <= max_stack + // A second generated LootItem for the same material may continue + // a new stack already planned earlier in this same transaction. + for (stack_index, stack) in planned_new_stacks.iter_mut().enumerate() { + if remaining == 0 { + break; + } + if stack.entry_id != loot_entry.item_id + || stack.random_properties_id != random_properties.id + || stack.random_properties_seed != random_properties.seed + || stack.item_context != loot_entry.item_context { - stack.count = stack.count.saturating_add(dest.count); continue; } - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; + let added_count = max_stack.saturating_sub(stack.count).min(remaining); + if added_count == 0 { + continue; + } + stack.count = stack.count.saturating_add(added_count); + new_pushes.push(PlannedDisenchantNewPush { + stack_index, + added_count, + new_count: stack.count, + }); + remaining = remaining.saturating_sub(added_count); } - planned_new_stacks.push(PlannedLootNewStack { - slot, - entry_id: item_id, - count: dest.count, - max_durability: self.item_template_max_durability(item_id), - random_properties_id: store_random_properties.id, - random_properties_seed: store_random_properties.seed, - item_context: loot_entry.item_context, + while remaining > 0 { + let Some(slot) = (INVENTORY_SLOT_ITEM_START..backpack_end).find(|slot| { + !self.inventory_items_like_cpp().contains_key(slot) + && !planned_new_stacks.iter().any(|stack| stack.slot == *slot) + }) else { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; + let count = remaining.min(max_stack); + let stack_index = planned_new_stacks.len(); + planned_new_stacks.push(PlannedLootNewStack { + slot, + entry_id: loot_entry.item_id, + count, + max_durability: self.item_template_max_durability(loot_entry.item_id), + dynamic_flags: self + .stored_new_item_dynamic_flags_like_cpp(loot_entry.item_id, slot), + random_properties_id: random_properties.id, + random_properties_seed: random_properties.seed, + item_context: loot_entry.item_context, + }); + new_pushes.push(PlannedDisenchantNewPush { + stack_index, + added_count: count, + new_count: count, + }); + remaining = remaining.saturating_sub(count); + } + + planned_grants.push(PlannedDisenchantGrant { + entry: loot_entry.clone(), + random_properties, + existing_pushes, + new_pushes, }); } - let mut tx = SqlTransaction::new(); - for &(_, _, db_guid, new_count, _) in &planned_existing_counts { - let mut upd_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); - upd_count.set_u32(0, new_count); - upd_count.set_u64(1, db_guid); - tx.append(upd_count); + let mut transaction = SqlTransaction::new(); + for stack in &planned_existing_stacks { + Self::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut transaction, + stack.db_guid, + stack.new_count, + stack.flags_changed.then_some(stack.dynamic_flags), + ); } - let realm_id = self.realm_id(); - let mut created_new_stacks = Vec::new(); + let mut created_new_stacks = Vec::with_capacity(planned_new_stacks.len()); if !planned_new_stacks.is_empty() { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - let mut next_item_guid = match char_db.query(&max_guid_stmt).await { - Ok(r) => r.try_read::(0).unwrap_or(0) + 1, - Err(_) => 1, + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "disenchant item grant has no process-wide item GUID allocator" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; }; - for stack in &planned_new_stacks { - let db_guid = next_item_guid; - next_item_guid += 1; - let item_guid = ObjectGuid::create_item(realm_id, db_guid as i64); - - let mut ins_item = + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { + let mut insert_item = char_db.prepare(CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT); - ins_item.set_u64(0, db_guid); - ins_item.set_u32(1, stack.entry_id); - ins_item.set_u64(2, player_guid.counter() as u64); - ins_item.set_u32(3, stack.count); - ins_item.set_u32(4, stack.max_durability); - ins_item.set_i32(5, stack.random_properties_id); - ins_item.set_i32(6, stack.random_properties_seed); - ins_item.set_u8(7, stack.item_context); - tx.append(ins_item); - - let mut ins_inv = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); - ins_inv.set_u64(0, player_guid.counter() as u64); - ins_inv.set_u8(1, stack.slot); - ins_inv.set_u64(2, db_guid); - tx.append(ins_inv); + insert_item.set_u64(0, db_guid); + insert_item.set_u32(1, stack.entry_id); + insert_item.set_u64(2, player_guid.counter() as u64); + insert_item.set_u32(3, stack.count); + insert_item.set_u32(4, stack.max_durability); + insert_item.set_u32(5, stack.dynamic_flags); + insert_item.set_i32(6, stack.random_properties_id); + insert_item.set_i32(7, stack.random_properties_seed); + insert_item.set_u8(8, stack.item_context); + transaction.append_expect_rows_affected(insert_item, 1); + + let mut insert_inventory = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); + insert_inventory.set_u64(0, player_guid.counter() as u64); + insert_inventory.set_u8(1, stack.slot); + insert_inventory.set_u64(2, db_guid); + transaction.append_expect_rows_affected(insert_inventory, 1); created_new_stacks.push((stack.clone(), db_guid, item_guid)); } } - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("LootItem: store transaction failed: {e}"); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; + let runtime_inventory_applied = + claim_commit_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = claim_commit_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map(|(context, runtime_inventory_applied)| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid: context.owner_guid, + loot_list_id: context.loot_list_id, + player_guid: context.player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + transaction + .commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + claim.cloned(), + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!(?error, "disenchant claim closed before persistence started"); + return false; + } + }; + match persistence.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + warn!(?error, "disenchant material batch transaction failed"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + Err(error) => { + warn!(?error, "disenchant material batch worker terminated"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } } - for &(_, item_guid, _, new_count, _) in &planned_existing_counts { - self.update_inventory_item_object_like_cpp(item_guid, |item| { - item.set_count(new_count); + for stack in &planned_existing_stacks { + self.update_inventory_item_object_like_cpp(stack.item_guid, |item| { + item.set_count(stack.new_count); + if stack.flags_changed { + item.replace_all_item_flags(ItemFieldFlags::from_bits_retain( + stack.dynamic_flags, + )); + } }); } @@ -6810,6 +10664,7 @@ impl WorldSession { loot_item_context(stack.item_context), stack.slot, ); + self.apply_stored_new_item_flags_like_cpp(stack.entry_id, stack.slot, &mut item_object); if stack.random_properties_id != 0 { item_object.set_random_properties_id(stack.random_properties_id); } @@ -6820,22 +10675,27 @@ impl WorldSession { self.insert_inventory_item_object(item_object); } self.sync_object_accessor_player(); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } - let quest_log_item_id = self - .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) - .await - .quest_log_item_id - .try_into() - .unwrap_or(0); - let mut changed_quest_ids = self - .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( - item_id, - quest_log_item_id, - count, - ) - .await; - self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) - .await; + for grant in &planned_grants { + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(grant.entry.item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let mut changed_quest_ids = self + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + grant.entry.item_id, + quest_log_item_id, + grant.entry.quantity, + ) + .await; + self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) + .await; + } let map_id = self.player_map_id_like_cpp(); if !created_new_stacks.is_empty() { @@ -6847,7 +10707,7 @@ impl WorldSession { owner_guid: player_guid, contained_in: player_guid, stack_count: stack.count, - dynamic_flags: 0, + dynamic_flags: stack.dynamic_flags, durability: stack.max_durability, max_durability: stack.max_durability, random_properties_seed: stack.random_properties_seed, @@ -6859,2942 +10719,8508 @@ impl WorldSession { container_item_guids: [ObjectGuid::EMPTY; 36], }) .collect(); - self.send_packet(&UpdateObject::create_items(item_creates, map_id)); + self.send_packet(&UpdateObject::create_stored_items(item_creates, map_id)); + } + for stack in &planned_existing_stacks { + let update = if stack.flags_changed { + UpdateObject::item_stack_count_and_flags_update( + stack.item_guid, + map_id, + stack.new_count, + stack.dynamic_flags, + ) + } else { + UpdateObject::item_stack_count_update(stack.item_guid, map_id, stack.new_count) + }; + self.send_packet(&update); } - for &(slot, item_guid, _, new_count, added_count) in &planned_existing_counts { - self.send_packet(&UpdateObject::item_stack_count_update( - item_guid, map_id, new_count, - )); - self.send_loot_item_push_result( - player_guid, - item_guid, - loot_entry, - store_random_properties.id, - store_random_properties.seed, - slot, - added_count, - new_count, - false, - dungeon_encounter_id, + // C++ writes each material's item update on the instance connection + // before `SendNewItem` routes its push result to the realm connection. + if !self + .wait_for_instance_send_before_realm_send_like_cpp() + .await + { + let _ = self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), ); + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable disenchant claim"); + return true; } - for (stack, _, item_guid) in &created_new_stacks { - self.send_loot_item_push_result( - player_guid, - *item_guid, - loot_entry, - stack.random_properties_id, - stack.random_properties_seed, - stack.slot, - stack.count, - stack.count, - false, - dungeon_encounter_id, - ); + for grant in &planned_grants { + for push in &grant.existing_pushes { + self.send_loot_item_push_result( + player_guid, + push.item_guid, + &grant.entry, + grant.random_properties.id, + grant.random_properties.seed, + push.slot, + push.added_count, + push.new_count, + false, + dungeon_encounter_id, + ); + } + for push in &grant.new_pushes { + let (stack, _, item_guid) = &created_new_stacks[push.stack_index]; + self.send_loot_item_push_result( + player_guid, + *item_guid, + &grant.entry, + stack.random_properties_id, + stack.random_properties_seed, + stack.slot, + push.added_count, + push.new_count, + false, + dungeon_encounter_id, + ); + } + } + + // `Loot::AutoStore` completes every realm-routed `SendNewItem` before + // `LootRoll::Finish` emits the original slot removal on the instance + // connection. Do not publish the later instance packets without the + // writer acknowledgement; reconnect will reload the durable grant. + if !self + .wait_for_realm_send_before_instance_update_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable disenchant claim"); + return true; + } + + // C++ `Loot::AutoStore` performs `StoreNewItem` and `SendNewItem` for + // every generated material. Only after `AutoStore` returns does + // `LootRoll::Finish` call `NotifyItemRemoved` for the original loot. + // SQL and the claim were already committed by the detached worker. + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; } if !created_new_stacks.is_empty() { - let changed_slots: Vec<_> = created_new_stacks + let changed_slots = created_new_stacks .iter() .map(|(stack, _, item_guid)| (stack.slot, *item_guid)) - .collect(); + .collect::>(); self.send_player_values_update_from_entity_bridge(&changed_slots, &[], &[], &[], None); } for update in &collection_updates { self.send_player_values_update_like_cpp(update); } - self.sync_player_registry_state_like_cpp(); true } - fn plan_direct_loot_item_preserving_cpp_store_metadata( - &self, + async fn store_direct_loot_item_from_owner_like_cpp( + &mut self, loot_entry: &LootEntry, - random_properties: LootStoreRandomProperties, - ) -> Option> { - let max_stack = self - .item_storage_template(loot_entry.item_id) - .map(|template| template.max_stack_size) - .unwrap_or(1) - .max(1); - let mut remaining = loot_entry.quantity; - let mut dest = Vec::new(); + dungeon_encounter_id: u32, + owner_guid: ObjectGuid, + ) -> bool { + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + owner_guid.is_item().then_some(owner_guid), + None, + None, + ) + .await + } - let mut existing_slots: Vec = self.inventory_items_like_cpp().keys().copied().collect(); - existing_slots.sort_unstable(); - for slot in existing_slots { - if remaining == 0 { - break; + async fn store_claimed_direct_loot_item_from_owner_like_cpp( + &mut self, + loot_entry: &LootEntry, + dungeon_encounter_id: u32, + owner_guid: ObjectGuid, + loot_obj: ObjectGuid, + claim: &LootClaimLease, + ) -> bool { + let Some(player_guid) = self.player_guid() else { + return false; + }; + self.store_direct_loot_item_with_source_like_cpp( + loot_entry, + dungeon_encounter_id, + owner_guid.is_item().then_some(owner_guid), + Some(claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid, + loot_obj, + loot_list_id: loot_entry.loot_list_id, + player_guid, + free_for_all: loot_entry.flags.freeforall, + }), + ) + .await + } + + async fn store_direct_loot_item_with_source_like_cpp( + &mut self, + loot_entry: &LootEntry, + dungeon_encounter_id: u32, + stored_item_loot_source: Option, + claim: Option<&LootClaimLease>, + claim_commit_context: Option, + ) -> bool { + let item_id = loot_entry.item_id; + let count = loot_entry.quantity; + let Some(player_guid) = self.player_guid() else { + return false; + }; + let durable_item_fanout = match (claim, claim_commit_context) { + (Some(claim), Some(context)) => { + let Some(fanout) = self.prepare_durable_loot_item_fanout_like_cpp(claim, context) + else { + return false; + }; + Some(fanout) } - let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { - continue; - }; - let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) - else { - continue; + (None, None) => None, + _ => return false, + }; + // C++ Loot::AutoStore validates CanStoreNewItem before StoreNewItem. + // That ordering still applies when StoreNewItem later converts a + // quest-bound Item into objective credit and returns nullptr. + let Some((store_result, mut store_dest, _)) = + self.plan_store_new_direct_inventory_item(item_id, count) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + if store_result != InventoryResult::Ok { + self.send_equip_error(store_result, None, None, 0, 0); + return false; + } + let quest_log_item_id = self + .load_creature_item_template_addon_loot_metadata_like_cpp(item_id) + .await + .quest_log_item_id + .try_into() + .unwrap_or(0); + let bound_objective_plan = self + .plan_quest_source_item_bound_objective_persistence_like_cpp( + item_id, + quest_log_item_id, + count, + ); + #[cfg(test)] + if let Some(grants) = self.loot_item_store_test_grants_like_cpp.clone() { + let success = self.loot_item_store_test_success_like_cpp; + let commit_gate = self.loot_item_store_test_commit_gate_like_cpp.clone(); + let materializes_inventory_item = bound_objective_plan.is_none(); + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let Ok(worker) = spawn_loot_claim_persistence_worker_like_cpp( + async move { + tokio::task::yield_now().await; + if let Some(gate) = commit_gate { + gate.notified().await; + } + if !success { + return Err(()); + } + if materializes_inventory_item { + grants.fetch_add(1, Ordering::SeqCst); + } + Ok(()) + }, + claim.cloned(), + durable_item_completion, + ) else { + return false; }; - if existing.entry_id != loot_entry.item_id - || !loot_store_data_can_stack_with_item( + if !matches!(worker.await, Ok(Ok(()))) { + return false; + } + if let Some(plan) = bound_objective_plan.as_ref() { + let applied = self + .apply_quest_source_item_bound_objective_preflight_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + debug_assert!(applied.as_ref().is_some_and(|result| result.no_grant)); + debug_assert!(plan.statuses.iter().all(|planned| { + self.player_quests + .get(&planned.quest_id) + .is_some_and(|actual| { + actual.status == planned.status + && actual.objective_counts == planned.objective_counts + }) + })); + } + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } + if bound_objective_plan.is_none() { + self.send_loot_item_push_result( + player_guid, + ObjectGuid::EMPTY, loot_entry, - random_properties, - existing_object, - ) - || existing_object.count() >= max_stack - { - continue; + 0, + 0, + 0, + count, + count, + false, + dungeon_encounter_id, + ); } - let can_add = max_stack - .saturating_sub(existing_object.count()) - .min(remaining); - if can_add > 0 { - dest.push(ItemPosCount::new( - make_item_pos(INVENTORY_SLOT_BAG_0, slot), - can_add, - )); - remaining = remaining.saturating_sub(can_add); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); } + return true; } - - let backpack_end = INVENTORY_SLOT_ITEM_START - .saturating_add(INVENTORY_DEFAULT_SIZE) - .min(INVENTORY_SLOT_ITEM_END); - for slot in INVENTORY_SLOT_ITEM_START..backpack_end { - if remaining == 0 { - break; - } - if self.inventory_items_like_cpp().contains_key(&slot) { - continue; + let Some(char_db) = self.char_db().map(Arc::clone) else { + return false; + }; + if let Some(bound_objective_plan) = bound_objective_plan { + let mut tx = SqlTransaction::new(); + self.append_planned_quest_statuses_to_transaction_like_cpp( + &mut tx, + char_db.as_ref(), + player_guid.counter() as u64, + &bound_objective_plan.statuses, + ); + if let Some(item_guid) = stored_item_loot_source { + let mut delete_source = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); + delete_source.set_u64(0, item_guid.counter() as u64); + delete_source.set_u32(1, item_id); + delete_source.set_u32(2, count); + delete_source.set_u32(3, u32::from(loot_entry.loot_list_id)); + tx.append_expect_rows_affected(delete_source, 1); } - let quantity = max_stack.min(remaining); - dest.push(ItemPosCount::new( - make_item_pos(INVENTORY_SLOT_BAG_0, slot), - quantity, - )); - remaining = remaining.saturating_sub(quantity); - } - - (remaining == 0).then_some(dest) - } - - fn send_loot_item_push_result( - &self, - player_guid: ObjectGuid, - item_guid: ObjectGuid, - loot_entry: &LootEntry, - random_properties_id: i32, - random_properties_seed: i32, - slot: u8, - quantity: u32, - quantity_in_inventory: u32, - created: bool, - dungeon_encounter_id: u32, - ) { - let is_encounter_loot = dungeon_encounter_id != 0; - self.send_packet(&ItemPushResult { - player_guid, - slot: u8::from(INVENTORY_SLOT_BAG_0), - slot_in_bag: i32::from(slot), - item: ItemInstance { - item_id: loot_entry.item_id as i32, - random_properties_seed, - random_properties_id, - item_bonus: None, - modifications: ItemModList { values: Vec::new() }, - }, - quest_log_item_id: 0, - quantity: quantity as i32, - quantity_in_inventory: quantity_in_inventory as i32, - dungeon_encounter_id: dungeon_encounter_id as i32, - battle_pet_species_id: 0, - battle_pet_breed_id: 0, - battle_pet_breed_quality: 0, - battle_pet_level: 0, - item_guid, - pushed: false, - display_text: if is_encounter_loot { - ItemPushResultDisplayType::EncounterLoot - } else { - ItemPushResultDisplayType::Normal - }, - created, - is_bonus_roll: false, - is_encounter_loot, - }); - } - - async fn destroy_fully_looted_direct_item(&mut self, item_guid: ObjectGuid) { - let player_guid = match self.player_guid() { - Some(guid) => guid, - None => return, - }; - - let runtime_item = self - .inventory_item_objects_like_cpp() - .get(&item_guid) - .cloned(); - let (bag, slot) = match runtime_item.as_ref() { - Some(item) => (item.bag_slot(), item.slot()), - None => return, - }; - let Some(item) = self.get_inventory_item_by_pos(bag, slot) else { - return; - }; + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + tx.commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + claim.cloned(), + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!( + ?error, + "LootItem: quest-bound claim closed before persistence started" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + }; + match persistence.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + warn!(?error, "LootItem: quest-bound objective transaction failed"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + Err(error) => { + warn!( + ?error, + "LootItem: detached quest-bound transaction worker terminated" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + } - let char_db = match self.char_db() { - Some(db) => Arc::clone(db), - None => return, + let applied = self + .apply_quest_source_item_bound_objective_preflight_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + if !applied.as_ref().is_some_and(|result| result.no_grant) + || !bound_objective_plan.statuses.iter().all(|planned| { + self.player_quests + .get(&planned.quest_id) + .is_some_and(|actual| { + actual.status == planned.status + && actual.objective_counts == planned.objective_counts + }) + }) + { + self.kick("durable quest-bound loot state diverged; relog required"); + return true; + } + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } + self.sync_player_registry_state_like_cpp(); + return true; + } + let store_random_properties = { + let mut rng = self.represented_runtime_subrng_like_cpp(); + self.generate_loot_store_random_properties_with_rng_like_cpp(item_id, &mut rng) }; - let mut tx = SqlTransaction::new(); - let should_expire_refund = runtime_item - .as_ref() - .is_some_and(|item_object| item_object.is_refundable()); - if should_expire_refund { - let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); - del_refund.set_u64(0, item.db_guid); - tx.append(del_refund); + if store_dest.iter().any(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + bag == u8::from(INVENTORY_SLOT_BAG_0) + && self + .inventory_items_like_cpp() + .get(&slot) + .is_some_and(|existing| { + self.inventory_item_objects_like_cpp() + .get(&existing.guid) + .is_some_and(|item| { + !loot_store_data_can_stack_with_item( + loot_entry, + store_random_properties, + item, + ) + }) + }) + }) { + let Some(compatible_dest) = self.plan_direct_loot_item_preserving_cpp_store_metadata( + loot_entry, + store_random_properties, + ) else { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; + store_dest = compatible_dest; } - let mut del_inv = char_db.prepare(CharStatements::DEL_CHAR_INVENTORY_ITEM); - del_inv.set_u64(0, player_guid.counter() as u64); - del_inv.set_u64(1, item.db_guid); - tx.append(del_inv); + let mut planned_existing_counts = Vec::::new(); + let mut planned_new_stacks = Vec::::new(); - let mut del_item = char_db.prepare(CharStatements::DEL_ITEM_INSTANCE); - del_item.set_u64(0, item.db_guid); - tx.append(del_item); + for dest in store_dest { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + if bag != u8::from(INVENTORY_SLOT_BAG_0) { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } - if let Err(e) = char_db.commit_transaction(tx).await { - warn!("LootRelease: delete fully looted item failed: {e}"); - return; - } + let max_stack = self + .item_storage_template(item_id) + .map(|template| template.max_stack_size) + .unwrap_or(1) + .max(1); - self.remove_fully_looted_runtime_item(bag, slot, item.guid); + if let Some(existing) = self.inventory_items_like_cpp().get(&slot) { + let Some(existing_object) = + self.inventory_item_objects_like_cpp().get(&existing.guid) + else { + self.send_equip_error(InventoryResult::ItemNotFound, None, None, 0, 0); + return false; + }; + let base_count = planned_existing_counts + .iter() + .find(|planned| planned.slot == slot) + .map(|planned| planned.new_count) + .unwrap_or_else(|| existing_object.count()); + let new_count = base_count.saturating_add(dest.count); + if existing.entry_id != item_id + || new_count > max_stack + || !loot_store_data_can_stack_with_item( + loot_entry, + store_random_properties, + existing_object, + ) + { + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + if let Some(existing_plan) = planned_existing_counts + .iter_mut() + .find(|planned| planned.slot == slot) + { + existing_plan.new_count = new_count; + existing_plan.added_count = + existing_plan.added_count.saturating_add(dest.count); + } else { + let dynamic_flags = self.stored_existing_item_dynamic_flags_like_cpp( + item_id, + slot, + existing_object, + ); + planned_existing_counts.push(PlannedDirectLootExistingStack { + slot, + item_guid: existing.guid, + db_guid: existing.db_guid, + new_count, + added_count: dest.count, + dynamic_flags, + flags_changed: dynamic_flags != existing_object.item_flags_bits(), + }); + } + continue; + } - if should_expire_refund { - self.send_packet(&ItemExpirePurchaseRefund { - item_guid: item.guid, + if let Some(stack) = planned_new_stacks + .iter_mut() + .find(|stack| stack.slot == slot) + { + if stack.entry_id == item_id + && stack.random_properties_id == store_random_properties.id + && stack.random_properties_seed == store_random_properties.seed + && stack.item_context == loot_entry.item_context + && stack.count.saturating_add(dest.count) <= max_stack + { + stack.count = stack.count.saturating_add(dest.count); + continue; + } + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + + planned_new_stacks.push(PlannedLootNewStack { + slot, + entry_id: item_id, + count: dest.count, + max_durability: self.item_template_max_durability(item_id), + dynamic_flags: self.stored_new_item_dynamic_flags_like_cpp(item_id, slot), + random_properties_id: store_random_properties.id, + random_properties_seed: store_random_properties.seed, + item_context: loot_entry.item_context, }); } - // Player-values update and stat refresh only apply to top-level slots. - if bag == INVENTORY_SLOT_BAG_0 { - let mut visible_item_changes = Vec::new(); - let mut virtual_item_changes = Vec::new(); - if (slot as usize) < 19 { - visible_item_changes.push((slot, 0i32, 0u16, 0u16)); - } - if slot >= 15 && slot <= 17 { - virtual_item_changes.push((slot - 15, 0i32, 0u16, 0u16)); - } + let mut tx = SqlTransaction::new(); + for stack in &planned_existing_counts { + Self::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut tx, + stack.db_guid, + stack.new_count, + stack.flags_changed.then_some(stack.dynamic_flags), + ); + } - self.send_player_values_update_from_entity_bridge( - &[(slot, ObjectGuid::EMPTY)], - &visible_item_changes, - &virtual_item_changes, - &[], - None, - ); + let mut created_new_stacks = Vec::new(); + if !planned_new_stacks.is_empty() { + let Some(allocated_guids) = + self.allocate_item_instance_guids_like_cpp(planned_new_stacks.len()) + else { + warn!( + count = planned_new_stacks.len(), + "loot item grant has no process-wide item GUID allocator" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; - if slot < 19 { - self.send_stat_update(); + for (stack, (db_guid, item_guid)) in planned_new_stacks.iter().zip(allocated_guids) { + let mut ins_item = + char_db.prepare(CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT); + ins_item.set_u64(0, db_guid); + ins_item.set_u32(1, stack.entry_id); + ins_item.set_u64(2, player_guid.counter() as u64); + ins_item.set_u32(3, stack.count); + ins_item.set_u32(4, stack.max_durability); + ins_item.set_u32(5, stack.dynamic_flags); + ins_item.set_i32(6, stack.random_properties_id); + ins_item.set_i32(7, stack.random_properties_seed); + ins_item.set_u8(8, stack.item_context); + tx.append_expect_rows_affected(ins_item, 1); + + let mut ins_inv = char_db.prepare(CharStatements::INS_CHAR_INVENTORY); + ins_inv.set_u64(0, player_guid.counter() as u64); + ins_inv.set_u8(1, stack.slot); + ins_inv.set_u64(2, db_guid); + tx.append_expect_rows_affected(ins_inv, 1); + + created_new_stacks.push((stack.clone(), db_guid, item_guid)); } } - } -} -fn master_loot_error_for_inventory_result_like_cpp(result: InventoryResult) -> Option { - match result { - InventoryResult::Ok => None, - InventoryResult::ItemMaxCount => Some(LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP), - InventoryResult::InvFull => Some(LOOT_ERROR_MASTER_INV_FULL_LIKE_CPP), - _ => Some(LOOT_ERROR_MASTER_OTHER_LIKE_CPP), - } -} + // Item-container loot is a move between two durable stores. Delete + // the source row in the same transaction that grants the destination + // stack so a crash cannot duplicate (grant-only) or lose (delete-only) + // the item. + if let Some(item_guid) = stored_item_loot_source { + let mut delete_source = char_db.prepare(CharStatements::DEL_ITEMCONTAINER_ITEM); + delete_source.set_u64(0, item_guid.counter() as u64); + delete_source.set_u32(1, item_id); + delete_source.set_u32(2, count); + delete_source.set_u32(3, u32::from(loot_entry.loot_list_id)); + tx.append_expect_rows_affected(delete_source, 1); + } -// ── Loot generation ─────────────────────────────────────────────── + let durable_claim = claim.cloned(); + let durable_completion_context = stored_item_loot_source + .map(|owner_guid| (owner_guid, loot_entry.loot_list_id, player_guid, true)) + .or_else(|| { + claim_commit_context.map(|context| { + ( + context.owner_guid, + context.loot_list_id, + context.player_guid, + false, + ) + }) + }); + let runtime_inventory_applied = + durable_completion_context.map(|_| Arc::new(AtomicBool::new(false))); + let durable_item_completion = durable_completion_context + .zip(runtime_inventory_applied.as_ref().map(Arc::clone)) + .map( + |( + (owner_guid, loot_list_id, player_guid, item_owner_auto_release), + runtime_inventory_applied, + )| { + ( + self.begin_durable_item_loot_persistence_like_cpp(), + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id, + player_guid, + item_owner_auto_release, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: durable_item_fanout.clone(), + runtime_inventory_applied, + }, + ) + }, + ); + let persistence_char_db = Arc::clone(&char_db); + let persistence = match spawn_sql_loot_claim_persistence_worker_like_cpp( + async move { + tx.commit_with_outcome_like_cpp(persistence_char_db.pool()) + .await + }, + durable_claim, + durable_item_completion, + self.session_command_tx(), + ) { + Ok(persistence) => persistence, + Err(error) => { + warn!(?error, "LootItem: claim closed before persistence started"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + }; + let persistence_result = match persistence.await { + Ok(result) => result, + Err(error) => { + warn!( + ?error, + "LootItem: detached store transaction worker terminated" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } + }; + if let Err(e) = persistence_result { + warn!("LootItem: store transaction failed: {e:?}"); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct ItemTemplateAddonLootMetadataLikeCpp { - flags_cu: u32, - quest_log_item_id: i32, -} + for stack in &planned_existing_counts { + self.update_inventory_item_object_like_cpp(stack.item_guid, |item| { + item.set_count(stack.new_count); + if stack.flags_changed { + item.replace_all_item_flags(ItemFieldFlags::from_bits_retain( + stack.dynamic_flags, + )); + } + }); + } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RepresentedLootPlayerContext { - race: u8, - class: u8, - gender: u8, - level: u8, - known_spells: Vec, - active_quest_statuses: HashMap, - active_quest_objective_counts: HashMap>, - rewarded_quests: HashSet, - inventory_item_counts: HashMap, - is_current: bool, -} + let mut collection_updates = Vec::new(); + for (stack, db_guid, item_guid) in &created_new_stacks { + self.insert_inventory_item_like_cpp( + stack.slot, + InventoryItem { + guid: *item_guid, + entry_id: stack.entry_id, + db_guid: *db_guid, + inventory_type: self.item_template_inventory_type(stack.entry_id), + }, + ); + let mut item_object = self.make_inventory_item_object( + *item_guid, + stack.entry_id, + player_guid, + stack.count, + stack.max_durability, + loot_item_context(stack.item_context), + stack.slot, + ); + self.apply_stored_new_item_flags_like_cpp(stack.entry_id, stack.slot, &mut item_object); + if stack.random_properties_id != 0 { + item_object.set_random_properties_id(stack.random_properties_id); + } + if stack.random_properties_seed != 0 { + item_object.set_property_seed(stack.random_properties_seed); + } + collection_updates.extend(self.on_item_added_to_collection_like_cpp(&item_object)); + self.insert_inventory_item_object(item_object); + } + self.sync_object_accessor_player(); + if let Some(runtime_inventory_applied) = runtime_inventory_applied { + runtime_inventory_applied.store(true, Ordering::Release); + } -impl RepresentedLootPlayerContext { - fn quest_status(&self, quest_id: u32) -> u8 { - self.active_quest_statuses - .get(&quest_id) - .copied() - .or_else(|| { - self.rewarded_quests - .contains(&quest_id) - .then_some(QUEST_STATUS_REWARDED_LIKE_CPP) - }) - .unwrap_or(QUEST_STATUS_NONE_LIKE_CPP) - } + let mut changed_quest_ids = self + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp( + item_id, + quest_log_item_id, + count, + ) + .await; + self.save_changed_represented_quest_statuses_like_cpp(&mut changed_quest_ids) + .await; - fn inventory_item_count(&self, item_id: u32) -> u32 { - self.inventory_item_counts - .get(&item_id) - .copied() - .unwrap_or(0) - } -} + let map_id = self.player_map_id_like_cpp(); + if !created_new_stacks.is_empty() { + let item_creates = created_new_stacks + .iter() + .map(|(stack, _, item_guid)| ItemCreateData { + item_guid: *item_guid, + entry_id: stack.entry_id as i32, + owner_guid: player_guid, + contained_in: player_guid, + stack_count: stack.count, + dynamic_flags: stack.dynamic_flags, + durability: stack.max_durability, + max_durability: stack.max_durability, + random_properties_seed: stack.random_properties_seed, + random_properties_id: stack.random_properties_id, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], + gems: Vec::new(), + context: stack.item_context, + container_slots: 0, + container_item_guids: [ObjectGuid::EMPTY; 36], + }) + .collect(); + self.send_packet(&UpdateObject::create_stored_items(item_creates, map_id)); + } -impl ItemTemplateAddonLootMetadataLikeCpp { - fn ignores_quest_status(self) -> bool { - self.flags_cu & ITEM_FLAGS_CU_IGNORE_QUEST_STATUS_LIKE_CPP != 0 - } + for stack in &planned_existing_counts { + let update = if stack.flags_changed { + UpdateObject::item_stack_count_and_flags_update( + stack.item_guid, + map_id, + stack.new_count, + stack.dynamic_flags, + ) + } else { + UpdateObject::item_stack_count_update(stack.item_guid, map_id, stack.new_count) + }; + self.send_packet(&update); + } - fn follows_loot_rules(self) -> bool { - self.flags_cu & ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP != 0 - } -} + // The worker committed SQL and the authority claim before runtime + // publication. C++ `StoreNewItem` sends the stored item's update, + // `Player::StoreLootItem` then notifies removal, and only afterwards + // does `SendNewItem` emit `SMSG_ITEM_PUSH_RESULT`. + if !self.publish_persisted_loot_item_removal_like_cpp( + claim, + claim_commit_context, + durable_item_fanout.as_ref(), + ) { + return false; + } -fn player_class_mask_like_cpp(class_id: u8) -> Option { - if (1..=13).contains(&class_id) { - Some(1_u32 << (class_id - 1)) - } else { - None - } -} + if !self + .wait_for_instance_send_before_realm_send_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable item claim"); + return true; + } -fn player_race_mask_like_cpp(race_id: u8) -> Option { - let bit = match race_id { - 1..=11 => race_id - 1, - 22 => 21, - 24..=32 => race_id - 1, - 34 => 11, - 35 => 12, - 36 => 13, - 37 => 14, - 52 => 16, - 70 => 15, - _ => return None, - }; - Some(1_u32 << bit) -} + for stack in &planned_existing_counts { + self.send_loot_item_push_result( + player_guid, + stack.item_guid, + loot_entry, + store_random_properties.id, + store_random_properties.seed, + stack.slot, + stack.added_count, + stack.new_count, + false, + dungeon_encounter_id, + ); + } -fn player_team_for_race_cpp_representable(race: u8) -> u32 { - match race { - 2 | 5 | 6 | 8 | 9 | 10 | 26 | 27 | 28 | 31 | 35 | 36 | 70 => 67, - _ => 469, - } -} + for (stack, _, item_guid) in &created_new_stacks { + self.send_loot_item_push_result( + player_guid, + *item_guid, + loot_entry, + stack.random_properties_id, + stack.random_properties_seed, + stack.slot, + stack.count, + stack.count, + false, + dungeon_encounter_id, + ); + } -fn represented_item_faction_flags_block_player_like_cpp(flags2: Option, race: u8) -> bool { - let Some(flags2) = flags2 else { - return false; - }; + if (!created_new_stacks.is_empty() || !collection_updates.is_empty()) + && !self + .wait_for_realm_send_before_instance_update_like_cpp() + .await + { + self.sync_player_registry_state_like_cpp(); + self.kick("loot socket ordering fence failed after durable item claim"); + return true; + } - let team = player_team_for_race_cpp_representable(race); - ((flags2 & ItemFlags2::FactionHorde as u32) != 0 && team != 67) - || ((flags2 & ItemFlags2::FactionAlliance as u32) != 0 && team != 469) -} + if !created_new_stacks.is_empty() { + let changed_slots: Vec<_> = created_new_stacks + .iter() + .map(|(stack, _, item_guid)| (stack.slot, *item_guid)) + .collect(); + self.send_player_values_update_from_entity_bridge(&changed_slots, &[], &[], &[], None); + } + for update in &collection_updates { + self.send_player_values_update_like_cpp(update); + } -fn player_quest_status_mask_like_cpp(status: Option, rewarded: bool) -> u32 { - if rewarded { - return 0x40; + self.sync_player_registry_state_like_cpp(); + true } - match status { - None => 0x01, - Some(QUEST_STATUS_COMPLETE_LIKE_CPP) => 0x02, - Some(QUEST_STATUS_INCOMPLETE_LIKE_CPP) => 0x08, - Some(QUEST_STATUS_FAILED_LIKE_CPP) => 0x20, - _ => 0, - } -} + fn plan_direct_loot_item_preserving_cpp_store_metadata( + &self, + loot_entry: &LootEntry, + random_properties: LootStoreRandomProperties, + ) -> Option> { + let max_stack = self + .item_storage_template(loot_entry.item_id) + .map(|template| template.max_stack_size) + .unwrap_or(1) + .max(1); + let mut remaining = loot_entry.quantity; + let mut dest = Vec::new(); -fn generated_creature_loot_item_to_entry_like_cpp( - item: GeneratedLootItem, - addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, -) -> LootEntry { - LootEntry { - loot_list_id: item.loot_list_id as u8, - item_id: item.item_id, - quantity: item.count, - random_properties_id: item.random_properties_id, - random_properties_seed: item.random_properties_seed, - item_context: item.context, - flags: LootEntryFlags { - follow_loot_rules: !item.needs_quest || addon_metadata.follows_loot_rules(), - freeforall: item.free_for_all, - blocked: item.is_blocked, - counted: item.is_counted, - under_threshold: item.is_under_threshold, - needs_quest: item.needs_quest, - }, - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: item.is_looted, - } -} + let mut existing_slots: Vec = self.inventory_items_like_cpp().keys().copied().collect(); + existing_slots.sort_unstable(); + for slot in existing_slots { + if remaining == 0 { + break; + } + let Some(existing) = self.inventory_items_like_cpp().get(&slot) else { + continue; + }; + let Some(existing_object) = self.inventory_item_objects_like_cpp().get(&existing.guid) + else { + continue; + }; + if existing.entry_id != loot_entry.item_id + || !loot_store_data_can_stack_with_item( + loot_entry, + random_properties, + existing_object, + ) + || existing_object.count() >= max_stack + { + continue; + } + let can_add = max_stack + .saturating_sub(existing_object.count()) + .min(remaining); + if can_add > 0 { + dest.push(ItemPosCount::new( + make_item_pos(INVENTORY_SLOT_BAG_0, slot), + can_add, + )); + remaining = remaining.saturating_sub(can_add); + } + } -#[derive(Debug, Clone)] -struct RepresentedCreatureLootStateLikeCpp { - is_alive: bool, - position: wow_core::Position, - level: u8, - entry: u32, - loot_id: u32, - gold_min: u32, - gold_max: u32, - dungeon_encounter_id: u32, - tappers: Vec, -} + let backpack_end = INVENTORY_SLOT_ITEM_START + .saturating_add(INVENTORY_DEFAULT_SIZE) + .min(INVENTORY_SLOT_ITEM_END); + for slot in INVENTORY_SLOT_ITEM_START..backpack_end { + if remaining == 0 { + break; + } + if self.inventory_items_like_cpp().contains_key(&slot) { + continue; + } + let quantity = max_stack.min(remaining); + dest.push(ItemPosCount::new( + make_item_pos(INVENTORY_SLOT_BAG_0, slot), + quantity, + )); + remaining = remaining.saturating_sub(quantity); + } -#[derive(Debug, Clone, Copy)] -struct RepresentedGameObjectLootStateLikeCpp { - position: Option, - display_id: Option, - scale: f32, - rotation: [f32; 4], - go_type: Option, - interact_radius_override: Option, - lock_id: Option, - owner_guid: Option, -} + (remaining == 0).then_some(dest) + } -pub(crate) fn represented_gameobject_interaction_distance_like_cpp( - go_type: Option, - interact_radius_override: Option, -) -> f32 { - // C++ ref: GameObject.cpp GetInteractionDistance(). - // Spell-lock range remains with the typed GameObject/SpellInfo port. - if let Some(override_hundredths) = interact_radius_override.filter(|value| *value != 0) { - return override_hundredths as f32 / 100.0; + fn send_loot_item_push_result( + &self, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + loot_entry: &LootEntry, + random_properties_id: i32, + random_properties_seed: i32, + slot: u8, + quantity: u32, + quantity_in_inventory: u32, + created: bool, + dungeon_encounter_id: u32, + ) { + let is_encounter_loot = dungeon_encounter_id != 0; + self.send_packet_realm(&ItemPushResult { + player_guid, + slot: u8::from(INVENTORY_SLOT_BAG_0), + slot_in_bag: i32::from(slot), + item: ItemInstance { + item_id: loot_entry.item_id as i32, + random_properties_seed, + random_properties_id, + item_bonus: None, + modifications: ItemModList { values: Vec::new() }, + }, + quest_log_item_id: 0, + quantity: quantity as i32, + quantity_in_inventory: quantity_in_inventory as i32, + dungeon_encounter_id: dungeon_encounter_id as i32, + battle_pet_species_id: 0, + battle_pet_breed_id: 0, + battle_pet_breed_quality: 0, + battle_pet_level: 0, + item_guid, + pushed: false, + display_text: if is_encounter_loot { + ItemPushResultDisplayType::EncounterLoot + } else { + ItemPushResultDisplayType::Normal + }, + created, + is_bonus_roll: false, + is_encounter_loot, + }); } - match go_type.map(u32::from) { - Some(GAMEOBJECT_TYPE_AREADAMAGE) => 0.0, - Some(GAMEOBJECT_TYPE_QUESTGIVER) - | Some(GAMEOBJECT_TYPE_TEXT) - | Some(GAMEOBJECT_TYPE_FLAGSTAND) - | Some(GAMEOBJECT_TYPE_FLAGDROP) - | Some(GAMEOBJECT_TYPE_MINI_GAME) => 5.5555553, - Some(GAMEOBJECT_TYPE_BINDER) => 10.0, - Some(GAMEOBJECT_TYPE_CHAIR) | Some(GAMEOBJECT_TYPE_BARBER_CHAIR) => 3.0, - Some(GAMEOBJECT_TYPE_FISHING_NODE) => 100.0, - Some(GAMEOBJECT_TYPE_FISHING_HOLE) => 20.0 + wow_movement::CONTACT_DISTANCE_LIKE_CPP, - Some(GAMEOBJECT_TYPE_CAMERA) - | Some(GAMEOBJECT_TYPE_MAP_OBJECT) - | Some(GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY) - | Some(GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING) - | Some(GAMEOBJECT_TYPE_DOOR) => 5.0, - Some(GAMEOBJECT_TYPE_GUILD_BANK) | Some(GAMEOBJECT_TYPE_MAILBOX) => 10.0, - _ => 5.0, + async fn destroy_fully_looted_direct_item(&mut self, item_guid: ObjectGuid) { + self.destroy_direct_item_count_after_loot_release_like_cpp(item_guid, None) + .await; } -} -fn represented_gameobject_display_box_contains_like_cpp( - go_position: wow_core::Position, - player_position: wow_core::Position, - display_info: &wow_data::GameObjectDisplayInfoEntry, - scale: f32, - rotation: [f32; 4], - radius: f32, -) -> bool { - let min_x = display_info.geo_box_min.x * scale - radius; - let min_y = display_info.geo_box_min.y * scale - radius; - let min_z = display_info.geo_box_min.z * scale - radius; - let max_x = display_info.geo_box_max.x * scale + radius; - let max_y = display_info.geo_box_max.y * scale + radius; - let max_z = display_info.geo_box_max.z * scale + radius; + async fn destroy_direct_item_count_after_loot_release_like_cpp( + &mut self, + item_guid: ObjectGuid, + maximum_destroy_count: Option, + ) { + let player_guid = match self.player_guid() { + Some(guid) => guid, + None => return, + }; - let dx = player_position.x - go_position.x; - let dy = player_position.y - go_position.y; - let dz = player_position.z - go_position.z; - let [qx, qy, qz, qw] = rotation; - let iqx = -qx; - let iqy = -qy; - let iqz = -qz; + let runtime_item = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .cloned(); + let (bag, slot) = match runtime_item.as_ref() { + Some(item) => (item.bag_slot(), item.slot()), + None => return, + }; - let tx = 2.0 * (iqy * dz - iqz * dy); - let ty = 2.0 * (iqz * dx - iqx * dz); - let tz = 2.0 * (iqx * dy - iqy * dx); - let local_x = dx + qw * tx + (iqy * tz - iqz * ty); - let local_y = dy + qw * ty + (iqz * tx - iqx * tz); - let local_z = dz + qw * tz + (iqx * ty - iqy * tx); + let Some(item) = self.get_inventory_item_by_pos(bag, slot) else { + return; + }; - local_x >= min_x - && local_x <= max_x - && local_y >= min_y - && local_y <= max_y - && local_z >= min_z - && local_z <= max_z -} - -fn represented_loot_object_guid_like_cpp(owner: ObjectGuid) -> ObjectGuid { - if owner.is_empty() { - return ObjectGuid::EMPTY; - } - - ObjectGuid::create_world_object( - HighGuid::LootObject, - 0, - owner.realm_id(), - owner.map_id(), - owner.server_id(), - 0, - owner.counter(), - ) -} + let char_db = match self.char_db() { + Some(db) => Arc::clone(db), + None => return, + }; -fn loot_type_for_client_like_cpp(loot_type: u8) -> u8 { - match loot_type { - LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP => { - LOOT_TYPE_DISENCHANTING_LIKE_CPP + let current_count = runtime_item.as_ref().map_or(1, Item::count); + let new_count = + direct_item_count_after_loot_release_like_cpp(current_count, maximum_destroy_count); + if new_count != 0 { + let mut update_count = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_COUNT); + update_count.set_u32(0, new_count); + update_count.set_u64(1, item.db_guid); + if let Err(error) = char_db.execute(&update_count).await { + warn!(?error, "LootRelease: update partially consumed item failed"); + return; + } + self.update_inventory_item_object_like_cpp(item_guid, |item| { + item.set_count(new_count); + item.set_loot_generated(false); + }); + self.sync_object_accessor_player(); + self.send_packet(&UpdateObject::item_stack_count_update( + item_guid, + self.player_map_id_like_cpp(), + new_count, + )); + return; } - LOOT_TYPE_INSIGNIA_LIKE_CPP => LOOT_TYPE_SKINNING_LIKE_CPP, - LOOT_TYPE_FISHINGHOLE_LIKE_CPP | LOOT_TYPE_FISHING_JUNK_LIKE_CPP => { - LOOT_TYPE_FISHING_LIKE_CPP + + let mut tx = SqlTransaction::new(); + let should_expire_refund = runtime_item + .as_ref() + .is_some_and(|item_object| item_object.is_refundable()); + if should_expire_refund { + let mut del_refund = char_db.prepare(CharStatements::DEL_ITEM_REFUND_INSTANCE); + del_refund.set_u64(0, item.db_guid); + tx.append(del_refund); } - _ => loot_type, - } -} -fn loot_is_looted_like_cpp(loot: &CreatureLoot) -> bool { - loot.coins == 0 && loot.unlooted_count == 0 -} + let mut del_inv = char_db.prepare(CharStatements::DEL_CHAR_INVENTORY_ITEM); + del_inv.set_u64(0, player_guid.counter() as u64); + del_inv.set_u64(1, item.db_guid); + tx.append(del_inv); -fn mark_loot_allowed_for_player_like_cpp(loot: &mut CreatureLoot, player_guid: ObjectGuid) { - if !player_guid.is_empty() && !loot.allowed_looters.contains(&player_guid) { - loot.allowed_looters.push(player_guid); - } + let mut del_item = char_db.prepare(CharStatements::DEL_ITEM_INSTANCE); + del_item.set_u64(0, item.db_guid); + tx.append(del_item); - for entry in &mut loot.items { - if entry.allowed_looters.is_empty() || entry.flags.freeforall { - entry.add_allowed_looter_like_cpp(player_guid); + if let Err(e) = char_db.commit_transaction(tx).await { + warn!("LootRelease: delete fully looted item failed: {e}"); + return; } - } - let existing_ffa_item_ids: Vec = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .map(|(_, items)| items.iter().map(|item| item.loot_list_id).collect()) - .unwrap_or_default(); - let mut ffa_items = Vec::new(); - for entry in &mut loot.items { - if entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - && !existing_ffa_item_ids.contains(&entry.loot_list_id) - { - ffa_items.push(NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, + self.remove_fully_looted_runtime_item(bag, slot, item.guid); + + if should_expire_refund { + self.send_packet(&ItemExpirePurchaseRefund { + item_guid: item.guid, }); - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } else if !entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - && !entry.flags.counted - { - entry.flags.counted = true; - loot.unlooted_count = loot.unlooted_count.saturating_add(1); } - } - if !ffa_items.is_empty() { - match loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| *player == player_guid) - { - Some((_, existing)) => existing.extend(ffa_items), - None => loot.player_ffa_items.push((player_guid, ffa_items)), + // Player-values update and stat refresh only apply to top-level slots. + if bag == INVENTORY_SLOT_BAG_0 { + let mut visible_item_changes = Vec::new(); + let mut virtual_item_changes = Vec::new(); + if (slot as usize) < 19 { + visible_item_changes.push((slot, 0i32, 0u16, 0u16)); + } + if slot >= 15 && slot <= 17 { + virtual_item_changes.push((slot - 15, 0i32, 0u16, 0u16)); + } + + self.send_player_values_update_from_entity_bridge( + &[(slot, ObjectGuid::EMPTY)], + &visible_item_changes, + &virtual_item_changes, + &[], + None, + ); + + if slot < 19 { + self.send_stat_update(); + } } } } -#[cfg(test)] -fn assign_represented_personal_loot_items_like_cpp( - loot: &mut CreatureLoot, - tappers: &[ObjectGuid], - rng: &mut R, -) { - if tappers.is_empty() { - return; - } - - loot.unlooted_count = 0; - loot.player_ffa_items.clear(); - - for entry in &mut loot.items { - entry.allowed_looters.clear(); - entry.flags.counted = false; +fn durable_loot_item_fanout_viewers_like_cpp( + precommit_viewers: &[ObjectGuid], + committed_viewers: &[ObjectGuid], +) -> HashSet { + precommit_viewers + .iter() + .chain(committed_viewers) + .copied() + .collect() +} - let chosen_tapper = tappers[rng.gen_range(0..tappers.len())]; - entry.add_allowed_looter_like_cpp(chosen_tapper); +fn master_loot_error_for_inventory_result_like_cpp(result: InventoryResult) -> Option { + match result { + InventoryResult::Ok => None, + InventoryResult::ItemMaxCount => Some(LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP), + InventoryResult::InvFull => Some(LOOT_ERROR_MASTER_INV_FULL_LIKE_CPP), + _ => Some(LOOT_ERROR_MASTER_OTHER_LIKE_CPP), } +} - rebuild_represented_personal_loot_counts_like_cpp(loot); +// ── Loot generation ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ItemTemplateAddonLootMetadataLikeCpp { + flags_cu: u32, + quest_log_item_id: i32, } -fn rebuild_represented_personal_loot_counts_like_cpp(loot: &mut CreatureLoot) { - loot.unlooted_count = 0; - loot.player_ffa_items.clear(); +#[derive(Debug, Clone, PartialEq, Eq)] +struct RepresentedLootPlayerContext { + race: u8, + class: u8, + gender: u8, + level: u8, + known_spells: Vec, + active_quest_statuses: HashMap, + active_quest_objective_counts: HashMap>, + rewarded_quests: HashSet, + inventory_item_counts: HashMap, + is_current: bool, +} - for entry in &mut loot.items { - entry.ffa_looted_by.clear(); - entry.flags.counted = false; +impl RepresentedLootPlayerContext { + fn quest_status(&self, quest_id: u32) -> u8 { + self.active_quest_statuses + .get(&quest_id) + .copied() + .or_else(|| { + self.rewarded_quests + .contains(&quest_id) + .then_some(QUEST_STATUS_REWARDED_LIKE_CPP) + }) + .unwrap_or(QUEST_STATUS_NONE_LIKE_CPP) + } - if entry.flags.freeforall { - for looter in &entry.allowed_looters { - match loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| player == looter) - { - Some((_, existing)) => existing.push(NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, - }), - None => loot.player_ffa_items.push(( - *looter, - vec![NotNormalLootItem { - loot_list_id: entry.loot_list_id, - is_looted: false, - }], - )), - } - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } - } else if !entry.allowed_looters.is_empty() { - entry.flags.counted = true; - loot.unlooted_count = loot.unlooted_count.saturating_add(1); - } + fn inventory_item_count(&self, item_id: u32) -> u32 { + self.inventory_item_counts + .get(&item_id) + .copied() + .unwrap_or(0) } } -fn loot_player_has_unlooted_ffa_item_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, - loot_list_id: u8, -) -> bool { - loot.player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .is_some_and(|(_, items)| { - items - .iter() - .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) - }) -} +impl ItemTemplateAddonLootMetadataLikeCpp { + fn ignores_quest_status(self) -> bool { + self.flags_cu & ITEM_FLAGS_CU_IGNORE_QUEST_STATUS_LIKE_CPP != 0 + } -fn loot_item_is_looted_for_player_like_cpp( - loot: &CreatureLoot, - entry: &LootEntry, - player_guid: ObjectGuid, -) -> bool { - if entry.flags.freeforall { - !loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id) - } else { - entry.taken + fn follows_loot_rules(self) -> bool { + self.flags_cu & ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP != 0 } } -fn mark_loot_item_looted_for_player_like_cpp( - loot: &mut CreatureLoot, - loot_list_id: u8, - player_guid: ObjectGuid, -) { - let should_decrement = loot - .items - .iter() - .find(|entry| entry.loot_list_id == loot_list_id) - .is_some_and(|entry| !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid)); - - if let Some(entry) = loot - .items - .iter_mut() - .find(|entry| entry.loot_list_id == loot_list_id) - { - entry.mark_looted_for_player_like_cpp(player_guid); - if entry.flags.freeforall { - if let Some((_, items)) = loot - .player_ffa_items - .iter_mut() - .find(|(player, _)| *player == player_guid) - && let Some(item) = items - .iter_mut() - .find(|item| item.loot_list_id == loot_list_id) - { - item.is_looted = true; - } - } - if should_decrement { - loot.unlooted_count = loot.unlooted_count.saturating_sub(1); - } +fn player_class_mask_like_cpp(class_id: u8) -> Option { + if (1..=13).contains(&class_id) { + Some(1_u32 << (class_id - 1)) + } else { + None } } -fn represented_loot_response_items_like_cpp( - loot: &CreatureLoot, - player_guid: ObjectGuid, -) -> Vec { - loot.items - .iter() - .filter_map(|entry| { - let ui_type = loot_item_ui_type_for_player_like_cpp( - player_guid, - &entry.allowed_looters, - loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid), - entry.flags.freeforall, - loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id), - entry.flags.needs_quest, - entry.flags.follow_loot_rules, - loot.loot_method, - loot.round_robin_player, - loot.loot_master, - entry.flags.under_threshold, - entry.flags.blocked, - entry.roll_winner, - )?; - - Some(LootItemData { - item_type: 0, - ui_type, - can_trade_to_tap_list: false, - loot: ItemInstance { - item_id: entry.item_id as i32, - ..ItemInstance::default() - }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, - }) - }) - .collect() +fn player_race_mask_like_cpp(race_id: u8) -> Option { + let bit = match race_id { + 1..=11 => race_id - 1, + 22 => 21, + 24..=32 => race_id - 1, + 34 => 11, + 35 => 12, + 36 => 13, + 37 => 14, + 52 => 16, + 70 => 15, + _ => return None, + }; + Some(1_u32 << bit) } -fn looted_corpse_decay_secs_like_cpp( - is_fully_skinned: bool, - corpse_delay_secs: u32, - ignore_decay_ratio: bool, - corpse_decay_looted_rate: f32, -) -> u32 { - if is_fully_skinned { - return 0; +fn player_team_for_race_cpp_representable(race: u8) -> u32 { + match race { + 2 | 5 | 6 | 8 | 9 | 10 | 26 | 27 | 28 | 31 | 35 | 36 | 70 => 67, + _ => 469, } - - let rate = if ignore_decay_ratio { - 1.0 - } else { - corpse_decay_looted_rate.max(0.0) - }; - ((corpse_delay_secs as f32) * rate) as u32 } -fn loot_can_be_opened_by_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - if loot_is_looted_like_cpp(loot) { +fn represented_item_faction_flags_block_player_like_cpp(flags2: Option, race: u8) -> bool { + let Some(flags2) = flags2 else { return false; - } - - loot_has_item_for_all_like_cpp(loot, player_guid) - || loot_has_item_for_player_like_cpp(loot, player_guid) -} + }; -fn loot_has_over_threshold_item_like_cpp(loot: &CreatureLoot) -> bool { - loot.items - .iter() - .any(|entry| !entry.taken && entry.is_over_threshold_like_cpp()) + let team = player_team_for_race_cpp_representable(race); + ((flags2 & ItemFlags2::FactionHorde as u32) != 0 && team != 67) + || ((flags2 & ItemFlags2::FactionAlliance as u32) != 0 && team != 469) } -fn connected_roll_looters_like_cpp( - entry: &LootEntry, - player_guid: ObjectGuid, - current_map_id: u16, - player_registry: Option<&PlayerRegistry>, -) -> Vec { - let mut looters = Vec::new(); - - for looter in &entry.allowed_looters { - if *looter == player_guid { - looters.push(*looter); - continue; - } - - let Some(registry) = player_registry else { - continue; - }; - let Some(player) = registry.get(looter) else { - continue; - }; - if player.map_id == current_map_id { - looters.push(*looter); - } +fn player_quest_status_mask_like_cpp(status: Option, rewarded: bool) -> u32 { + if rewarded { + return 0x40; } - looters.sort_by_key(|guid| (guid.high_value(), guid.low_value())); - looters.dedup(); - looters -} - -fn represented_max_enchanting_skill_like_cpp( - looters: &[ObjectGuid], - current_player_guid: ObjectGuid, - current_player_enchanting_skill: u16, - player_registry: Option<&PlayerRegistry>, -) -> u16 { - looters.iter().fold(0, |max_skill, looter| { - if *looter == current_player_guid { - max_skill.max(current_player_enchanting_skill) - } else { - max_skill.max( - player_registry - .and_then(|registry| registry.get(looter)) - .map(|player| player.enchanting_skill) - .unwrap_or(0), - ) - } - }) -} - -fn start_loot_roll_packet_like_cpp( - loot_obj: ObjectGuid, - map_id: u16, - loot_method: u8, - entry: &LootEntry, - valid_rolls: u8, - dungeon_encounter_id: i32, -) -> StartLootRoll { - StartLootRoll { - loot_obj, - map_id: map_id as i32, - roll_time_ms: LOOT_ROLL_TIMEOUT_MS_LIKE_CPP, - method: loot_method, - valid_rolls, - loot_roll_ineligible_reason: [0; 4], - item: LootItemData { - item_type: 0, - ui_type: LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, - can_trade_to_tap_list: entry.allowed_looters.len() > 1, - loot: ItemInstance { - item_id: entry.item_id as i32, - random_properties_id: entry.random_properties_id, - random_properties_seed: entry.random_properties_seed, - ..ItemInstance::default() - }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, - }, - dungeon_encounter_id, + match status { + None => 0x01, + Some(QUEST_STATUS_COMPLETE_LIKE_CPP) => 0x02, + Some(QUEST_STATUS_INCOMPLETE_LIKE_CPP) => 0x08, + Some(QUEST_STATUS_FAILED_LIKE_CPP) => 0x20, + _ => 0, } } -fn loot_roll_broadcast_item_like_cpp(entry: &LootEntry, ui_type: u8) -> LootItemData { - LootItemData { - item_type: 0, - ui_type, - can_trade_to_tap_list: entry.allowed_looters.len() > 1, - loot: ItemInstance { - item_id: entry.item_id as i32, - random_properties_id: entry.random_properties_id, - random_properties_seed: entry.random_properties_seed, - ..ItemInstance::default() +fn generated_creature_loot_item_to_entry_like_cpp( + item: GeneratedLootItem, + addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, +) -> LootEntry { + LootEntry { + loot_list_id: item.loot_list_id as u8, + item_id: item.item_id, + quantity: item.count, + random_properties_id: item.random_properties_id, + random_properties_seed: item.random_properties_seed, + item_context: item.context, + flags: LootEntryFlags { + follow_loot_rules: !item.needs_quest || addon_metadata.follows_loot_rules(), + freeforall: item.free_for_all, + blocked: item.is_blocked, + counted: item.is_counted, + under_threshold: item.is_under_threshold, + needs_quest: item.needs_quest, }, - loot_list_id: entry.loot_list_id, - quantity: entry.quantity, - loot_item_type: 0, - } -} - -fn roll_chance_with_rate_like_cpp(chance: f32, rate: f32, rng: &mut R) -> bool { - if chance >= 100.0 { - return true; + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: item.is_looted, } - rng.gen_range(0.0f32..100.0f32) < chance * rate } -fn referenced_loot_max_count_like_cpp(max_count: u8, rate: f32) -> u32 { - ((max_count as f32) * rate) as u32 +fn generated_shared_gameobject_loot_item_to_entry_like_cpp( + item: GeneratedLootItem, + addon_metadata: ItemTemplateAddonLootMetadataLikeCpp, + allowed_looters: &[ObjectGuid], + mut item_allowed_for_player: FAllowed, +) -> LootEntry +where + FAllowed: FnMut(LootStoreItemContext, ObjectGuid) -> bool, +{ + let store_item_context = item.store_item_context; + let mut entry = generated_creature_loot_item_to_entry_like_cpp(item, addon_metadata); + for looter in allowed_looters { + if item_allowed_for_player(store_item_context, *looter) { + entry.add_allowed_looter_like_cpp(*looter); + } + } + entry } -fn represented_disenchant_loot_plain_row_can_roll_like_cpp( - row: &LootStoreItem, - item_exists: bool, -) -> bool { - row.can_roll_as_plain_entry_like_cpp(item_exists, LOOT_MODE_DEFAULT_LIKE_CPP) -} - -fn represented_disenchant_loot_reference_row_can_roll_like_cpp(row: &LootStoreItem) -> bool { - row.can_roll_as_reference_entry_like_cpp(LOOT_MODE_DEFAULT_LIKE_CPP) +#[derive(Debug, Clone)] +struct RepresentedCreatureLootStateLikeCpp { + is_alive: bool, + position: wow_core::Position, + level: u8, + entry: u32, + loot_id: u32, + gold_min: u32, + gold_max: u32, + dungeon_encounter_id: u32, + tappers: Vec, + loot_lifecycle_revision: u64, } -fn add_loot_item_stacks_like_cpp( - loot_items: &mut Vec, - item_id: u32, - mut count: u32, - max_stack_size: u32, - flags: LootEntryFlags, -) { - while count > 0 && loot_items.len() < MAX_NR_LOOT_ITEMS_LIKE_CPP { - let quantity = count.min(max_stack_size); - loot_items.push(LootEntry { - loot_list_id: loot_items.len() as u8, - item_id, - quantity, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags, - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }); - count = count.saturating_sub(max_stack_size); - } +#[derive(Debug, Clone)] +struct RepresentedGameObjectLootInstallObservationLikeCpp { + authority: OwnedLootAuthority, + object_generation: u64, + loot_lifecycle_revision: u64, } -#[derive(Debug, Clone)] -struct DisenchantLootTemplateFrame { - template: LootTemplate, - entry_index: usize, - group_index: usize, - requested_group_id: u8, +#[derive(Debug, Clone, Copy)] +struct RepresentedGameObjectLootStateLikeCpp { + position: Option, + display_id: Option, + scale: f32, + rotation: [f32; 4], + go_type: Option, + interact_radius_override: Option, + lock_id: Option, + owner_guid: Option, } -fn disenchant_loot_template_frame_like_cpp( - rows: Vec, - requested_group_id: u8, -) -> DisenchantLootTemplateFrame { - let mut template = LootTemplate::default(); - for row in rows { - template.add_entry_like_cpp(row); +pub(crate) fn represented_gameobject_interaction_distance_like_cpp( + go_type: Option, + interact_radius_override: Option, +) -> f32 { + // C++ ref: GameObject.cpp GetInteractionDistance(). + // Spell-lock range remains with the typed GameObject/SpellInfo port. + if let Some(override_hundredths) = interact_radius_override.filter(|value| *value != 0) { + return override_hundredths as f32 / 100.0; } - DisenchantLootTemplateFrame { - template, - entry_index: 0, - group_index: 0, - requested_group_id, + match go_type.map(u32::from) { + Some(GAMEOBJECT_TYPE_AREADAMAGE) => 0.0, + Some(GAMEOBJECT_TYPE_QUESTGIVER) + | Some(GAMEOBJECT_TYPE_TEXT) + | Some(GAMEOBJECT_TYPE_FLAGSTAND) + | Some(GAMEOBJECT_TYPE_FLAGDROP) + | Some(GAMEOBJECT_TYPE_MINI_GAME) => 5.5555553, + Some(GAMEOBJECT_TYPE_BINDER) => 10.0, + Some(GAMEOBJECT_TYPE_CHAIR) | Some(GAMEOBJECT_TYPE_BARBER_CHAIR) => 3.0, + Some(GAMEOBJECT_TYPE_FISHING_NODE) => 100.0, + Some(GAMEOBJECT_TYPE_FISHING_HOLE) => 20.0 + wow_movement::CONTACT_DISTANCE_LIKE_CPP, + Some(GAMEOBJECT_TYPE_CAMERA) + | Some(GAMEOBJECT_TYPE_MAP_OBJECT) + | Some(GAMEOBJECT_TYPE_DUNGEON_DIFFICULTY) + | Some(GAMEOBJECT_TYPE_DESTRUCTIBLE_BUILDING) + | Some(GAMEOBJECT_TYPE_DOOR) => 5.0, + Some(GAMEOBJECT_TYPE_GUILD_BANK) | Some(GAMEOBJECT_TYPE_MAILBOX) => 10.0, + _ => 5.0, } } -#[derive(Debug, Clone, Copy)] -enum DisenchantLootTemplateTable { - Disenchant, - Reference, -} +fn represented_gameobject_display_box_contains_like_cpp( + go_position: wow_core::Position, + player_position: wow_core::Position, + display_info: &wow_data::GameObjectDisplayInfoEntry, + scale: f32, + rotation: [f32; 4], + radius: f32, +) -> bool { + let min_x = display_info.geo_box_min.x * scale - radius; + let min_y = display_info.geo_box_min.y * scale - radius; + let min_z = display_info.geo_box_min.z * scale - radius; + let max_x = display_info.geo_box_max.x * scale + radius; + let max_y = display_info.geo_box_max.y * scale + radius; + let max_z = display_info.geo_box_max.z * scale + radius; -impl DisenchantLootTemplateTable { - fn name(self) -> &'static str { - match self { - Self::Disenchant => "disenchant_loot_template", - Self::Reference => "reference_loot_template", - } - } -} + let dx = player_position.x - go_position.x; + let dy = player_position.y - go_position.y; + let dz = player_position.z - go_position.z; + let [qx, qy, qz, qw] = rotation; + let iqx = -qx; + let iqy = -qy; + let iqz = -qz; -fn represented_loot_roll_finish_winner_like_cpp( - state: &RepresentedLootRollState, -) -> Option> { - let mut winner = None; - let mut has_need = false; + let tx = 2.0 * (iqy * dz - iqz * dy); + let ty = 2.0 * (iqz * dx - iqx * dz); + let tz = 2.0 * (iqx * dy - iqy * dx); + let local_x = dx + qw * tx + (iqy * tz - iqz * ty); + let local_y = dy + qw * ty + (iqz * tx - iqx * tz); + let local_z = dz + qw * tz + (iqx * ty - iqy * tx); - for (player_guid, vote) in &state.voters { - match vote.vote { - ROLL_VOTE_NEED_LIKE_CPP => { - if !has_need - || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - has_need = true; - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { - if !has_need - && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_PASS_LIKE_CPP | ROLL_VOTE_NOT_VALID_LIKE_CPP => {} - ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => return None, - _ => {} - } + local_x >= min_x + && local_x <= max_x + && local_y >= min_y + && local_y <= max_y + && local_z >= min_z + && local_z <= max_z +} + +#[cfg(test)] +fn represented_loot_object_guid_like_cpp(owner: ObjectGuid) -> ObjectGuid { + if owner.is_empty() { + return ObjectGuid::EMPTY; } - Some(winner) + ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner.realm_id(), + owner.map_id(), + 0, + 0, + owner.counter(), + ) } -fn represented_loot_roll_current_winner_like_cpp( - state: &RepresentedLootRollState, -) -> Option<(ObjectGuid, RepresentedLootRollVote)> { - let mut winner = None; - let mut has_need = false; +/// Unit fixtures that predate canonical map objects may exercise packet-cache +/// behavior locally. This is a compile-time false branch in production: live +/// Creature/GameObject claims fail closed without their map-owned authority. +const fn represented_local_loot_fixture_allowed_like_cpp() -> bool { + cfg!(test) +} - for (player_guid, vote) in &state.voters { - match vote.vote { - ROLL_VOTE_NEED_LIKE_CPP => { - if !has_need - || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - has_need = true; - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { - if !has_need - && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { - vote.roll_number > current.roll_number - }) - { - winner = Some((*player_guid, *vote)); - } - } - ROLL_VOTE_PASS_LIKE_CPP - | ROLL_VOTE_NOT_VALID_LIKE_CPP - | ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => {} - _ => {} +fn loot_type_for_client_like_cpp(loot_type: u8) -> u8 { + match loot_type { + LOOT_TYPE_PROSPECTING_LIKE_CPP | LOOT_TYPE_MILLING_LIKE_CPP => { + LOOT_TYPE_DISENCHANTING_LIKE_CPP + } + LOOT_TYPE_INSIGNIA_LIKE_CPP => LOOT_TYPE_SKINNING_LIKE_CPP, + LOOT_TYPE_FISHINGHOLE_LIKE_CPP | LOOT_TYPE_FISHING_JUNK_LIKE_CPP => { + LOOT_TYPE_FISHING_LIKE_CPP } + _ => loot_type, } - - winner } -fn loot_has_item_for_all_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - if loot.coins > 0 { - return true; - } - - loot.items.iter().any(|entry| { - !entry.taken - && entry.flags.follow_loot_rules - && !entry.flags.freeforall - && entry.has_allowed_looter_like_cpp(player_guid) - }) +fn loot_is_looted_like_cpp(loot: &CreatureLoot) -> bool { + loot.coins == 0 && loot.unlooted_count == 0 } -fn loot_has_item_for_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { - loot.items.iter().any(|entry| { - !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) - && entry.has_allowed_looter_like_cpp(player_guid) - && (!entry.flags.follow_loot_rules || entry.flags.freeforall) - }) +fn direct_item_count_after_loot_release_like_cpp( + current_count: u32, + maximum_destroy_count: Option, +) -> u32 { + let destroy_count = maximum_destroy_count + .unwrap_or(current_count) + .min(current_count); + current_count.saturating_sub(destroy_count) } -fn loot_item_context(context: u8) -> ItemContext { - ::from_u8(context).unwrap_or(ItemContext::None) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct LootStoreRandomProperties { - id: i32, - seed: i32, -} - -fn loot_store_data_can_stack_with_item( - loot_entry: &LootEntry, - random_properties: LootStoreRandomProperties, - item: &Item, -) -> bool { - let data = item.data(); - data.random_properties_id == random_properties.id - && data.property_seed == random_properties.seed - && u8::try_from(data.context).unwrap_or(0) == loot_entry.item_context -} +fn mark_loot_allowed_for_player_like_cpp(loot: &mut CreatureLoot, player_guid: ObjectGuid) { + if !player_guid.is_empty() && !loot.allowed_looters.contains(&player_guid) { + loot.allowed_looters.push(player_guid); + } -impl WorldSession { - fn generate_loot_store_random_properties_with_rng_like_cpp( - &self, - item_id: u32, - rng: &mut R, - ) -> LootStoreRandomProperties { - // C++ Player::StoreLootItem calls ItemEnchantmentMgr::GenerateRandomProperties(itemid). - let random_select = self.item_template_random_select(item_id); - let random_suffix = self.item_template_random_suffix_group_id(item_id); - if random_select == 0 && random_suffix == 0 { - return LootStoreRandomProperties { id: 0, seed: 0 }; + for entry in &mut loot.items { + if entry.allowed_looters.is_empty() || entry.flags.freeforall { + entry.add_allowed_looter_like_cpp(player_guid); } + } - if random_select != 0 { - let Some(random_properties_id) = - self.select_random_enchantment_from_group_like_cpp(u32::from(random_select), rng) - else { - return LootStoreRandomProperties { id: 0, seed: 0 }; - }; - - if self - .item_random_properties_store() - .and_then(|store| store.get(random_properties_id)) - .is_none() - { - return LootStoreRandomProperties { id: 0, seed: 0 }; - } - - return LootStoreRandomProperties { - id: i32::try_from(random_properties_id).unwrap_or(0), - seed: 0, - }; + let existing_ffa_item_ids: Vec = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .map(|(_, items)| items.iter().map(|item| item.loot_list_id).collect()) + .unwrap_or_default(); + let mut ffa_items = Vec::new(); + for entry in &mut loot.items { + if entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + && !existing_ffa_item_ids.contains(&entry.loot_list_id) + { + ffa_items.push(NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }); + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } else if !entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + && !entry.flags.counted + { + entry.flags.counted = true; + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } + } - let Some(random_suffix_id) = - self.select_random_enchantment_from_group_like_cpp(u32::from(random_suffix), rng) - else { - return LootStoreRandomProperties { id: 0, seed: 0 }; - }; - - if self - .item_random_suffix_store() - .and_then(|store| store.get(random_suffix_id)) - .is_none() + if !ffa_items.is_empty() { + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| *player == player_guid) { - return LootStoreRandomProperties { id: 0, seed: 0 }; + Some((_, existing)) => existing.extend(ffa_items), + None => loot.player_ffa_items.push((player_guid, ffa_items)), } + } +} - let seed = self - .item_random_property_template(item_id) - .map(|template| self.random_property_points_like_cpp(template)) - .unwrap_or(0); - - LootStoreRandomProperties { - id: -i32::try_from(random_suffix_id).unwrap_or(0), - seed, +/// Completes the shared C++ `Loot::FillLoot` visibility/count state before the +/// generation is published through the object-owned authority. Applying this +/// only to the session cache after publication is unsafe: reconciliation would +/// immediately restore the older authoritative snapshot and lose the tap list. +fn prepare_represented_shared_loot_generation_like_cpp( + loot: &mut CreatureLoot, + allowed_looters: &[ObjectGuid], +) { + for looter in allowed_looters { + if !looter.is_empty() && !loot.allowed_looters.contains(looter) { + loot.allowed_looters.push(*looter); } } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); +} - fn select_random_enchantment_from_group_like_cpp( - &self, - group_id: u32, - rng: &mut R, - ) -> Option { - let group = self - .item_random_enchantment_template_store() - .and_then(|store| store.group(group_id))?; - select_weighted_random_enchantment_like_cpp(group, rng) +fn prepare_represented_shared_creature_loot_generation_like_cpp( + loot: &mut CreatureLoot, + allowed_looters: &[ObjectGuid], +) { + for looter in allowed_looters { + mark_loot_allowed_for_player_like_cpp(loot, *looter); } + rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); +} - fn random_property_points_like_cpp(&self, template: ItemRandomPropertyTemplateEntry) -> i32 { - let prop_index = - match ::from_i8(template.inventory_type) { - Some(InventoryType::NonEquip) - | Some(InventoryType::Bag) - | Some(InventoryType::Tabard) - | Some(InventoryType::Ammo) - | Some(InventoryType::Quiver) - | Some(InventoryType::Relic) - | None => return 0, - Some(InventoryType::Head) - | Some(InventoryType::Body) - | Some(InventoryType::Chest) - | Some(InventoryType::Legs) - | Some(InventoryType::Weapon2Hand) - | Some(InventoryType::Robe) => 0, - Some(InventoryType::Shoulders) - | Some(InventoryType::Waist) - | Some(InventoryType::Feet) - | Some(InventoryType::Hands) - | Some(InventoryType::Trinket) => 1, - Some(InventoryType::Neck) - | Some(InventoryType::Wrists) - | Some(InventoryType::Finger) - | Some(InventoryType::Shield) - | Some(InventoryType::Cloak) - | Some(InventoryType::Holdable) => 2, - Some(InventoryType::Weapon) - | Some(InventoryType::WeaponMainhand) - | Some(InventoryType::WeaponOffhand) => 3, - Some(InventoryType::Ranged) - | Some(InventoryType::Thrown) - | Some(InventoryType::RangedRight) => 4, - _ => return 0, - }; +#[cfg(test)] +fn assign_represented_personal_loot_items_like_cpp( + loot: &mut CreatureLoot, + tappers: &[ObjectGuid], + rng: &mut R, +) { + if tappers.is_empty() { + return; + } - let Some(points) = self - .rand_prop_points_store() - .and_then(|store| store.get(u32::from(template.item_level))) - else { - return 0; - }; + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); - match ::from_i8(template.quality) { - Some(ItemQuality::Uncommon) => points.good[prop_index] as i32, - Some(ItemQuality::Rare) | Some(ItemQuality::Heirloom) => { - points.superior[prop_index] as i32 + for entry in &mut loot.items { + entry.allowed_looters.clear(); + entry.flags.counted = false; + + let chosen_tapper = tappers[rng.gen_range(0..tappers.len())]; + entry.add_allowed_looter_like_cpp(chosen_tapper); + } + + rebuild_represented_personal_loot_counts_like_cpp(loot); +} + +fn rebuild_represented_personal_loot_counts_like_cpp(loot: &mut CreatureLoot) { + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); + + for entry in &mut loot.items { + entry.ffa_looted_by.clear(); + entry.flags.counted = false; + + if entry.flags.freeforall { + for looter in &entry.allowed_looters { + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| player == looter) + { + Some((_, existing)) => existing.push(NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }), + None => loot.player_ffa_items.push(( + *looter, + vec![NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted: false, + }], + )), + } + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } - Some(ItemQuality::Epic) - | Some(ItemQuality::Legendary) - | Some(ItemQuality::Artifact) => points.epic[prop_index] as i32, - _ => 0, + } else if !entry.allowed_looters.is_empty() { + entry.flags.counted = true; + loot.unlooted_count = loot.unlooted_count.saturating_add(1); } } } -fn select_weighted_random_enchantment_like_cpp( - group: &[ItemRandomEnchantmentTemplateEntry], - rng: &mut R, -) -> Option { - let valid_rows = group - .iter() - .filter(|row| (0.000001..=100.0).contains(&row.chance)) - .collect::>(); - let weights = valid_rows.iter().map(|row| row.chance).collect::>(); - let distribution = WeightedIndex::new(weights).ok()?; - Some(valid_rows[distribution.sample(rng)].enchantment_id) +/// Rebuild a player-scoped authority pool without resurrecting entries already +/// consumed in the session view. The generation helper above intentionally +/// starts fresh; authority synchronization can also run during release, after +/// `taken`/`ffa_looted_by` have already changed. +fn rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot: &mut CreatureLoot) { + loot.unlooted_count = 0; + loot.player_ffa_items.clear(); + + for entry in &mut loot.items { + if entry.flags.freeforall { + entry.flags.counted = false; + for looter in &entry.allowed_looters { + let is_looted = entry.ffa_looted_by.contains(looter); + let item = NotNormalLootItem { + loot_list_id: entry.loot_list_id, + is_looted, + }; + match loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| player == looter) + { + Some((_, items)) => items.push(item), + None => loot.player_ffa_items.push((*looter, vec![item])), + } + if !is_looted { + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } + } + } else { + entry.flags.counted = !entry.allowed_looters.is_empty(); + if entry.flags.counted && !entry.taken { + loot.unlooted_count = loot.unlooted_count.saturating_add(1); + } + } + } } -#[derive(Debug, Clone)] -struct PlannedLootNewStack { - slot: u8, - entry_id: u32, - count: u32, - max_durability: u32, - random_properties_id: i32, - random_properties_seed: i32, - item_context: u8, +fn loot_player_has_unlooted_ffa_item_like_cpp( + loot: &CreatureLoot, + player_guid: ObjectGuid, + loot_list_id: u8, +) -> bool { + loot.player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .is_some_and(|(_, items)| { + items + .iter() + .any(|item| item.loot_list_id == loot_list_id && !item.is_looted) + }) } -#[cfg(test)] -mod tests { - use super::{ - GAMEOBJECT_TYPE_AREADAMAGE, GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, - GAMEOBJECT_TYPE_DOOR, GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, - INVENTORY_SLOT_BAG_0, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, - ItemTemplateAddonLootMetadataLikeCpp, LOCK_KEY_SKILL_LIKE_CPP, LOCK_KEY_SPELL_LIKE_CPP, - LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_MODE_DEFAULT_LIKE_CPP, - LOOT_MODE_JUNK_FISH_LIKE_CPP, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, - LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, LootStoreRandomProperties, - ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, ROLL_FLAG_TYPE_NEED_LIKE_CPP, - ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, - ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, RepresentedLootPlayerContext, - SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, SyncChestGameobjectStateAndRefreshLikeCppCommand, - SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, - SyncGooberGameobjectStateAndRefreshLikeCppCommand, - assign_represented_personal_loot_items_like_cpp, - generated_creature_loot_item_to_entry_like_cpp, loot_is_looted_like_cpp, loot_item_context, - loot_store_data_can_stack_with_item, loot_type_for_client_like_cpp, - looted_corpse_decay_secs_like_cpp, mark_loot_allowed_for_player_like_cpp, - mark_loot_item_looted_for_player_like_cpp, - represented_gameobject_display_box_contains_like_cpp, - represented_gameobject_interaction_distance_like_cpp, - represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, - select_weighted_random_enchantment_like_cpp, start_loot_roll_packet_like_cpp, - }; - use crate::conditions::QUEST_STATUS_REWARDED_LIKE_CPP; - use crate::session::{ - RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, - RepresentedLootRollCriteriaEvent, SessionState, - }; - use rand::{Rng, SeedableRng, rngs::StdRng}; - use std::time::{Duration, Instant}; - use std::{ - collections::{HashMap, HashSet}, - sync::{Arc, Mutex, RwLock}, - }; - use wow_ai::CreatureAI; - use wow_constants::{ - InventoryResult, InventoryType, ItemBondingType, ItemClass, ItemContext, ItemFlags2, - ItemQuality, TypeId, TypeMask, - }; - use wow_core::{ObjectGuid, Position, guid::HighGuid}; - use wow_data::quest::{ - QUEST_REWARD_REPUTATIONS_COUNT, QuestObjective, QuestStore, QuestTemplate, - }; - use wow_data::{ - AreaTableEntry, AreaTableStore, ChrSpecializationEntry, ChrSpecializationStore, - ItemDisenchantLootEntry, ItemDisenchantLootStore, ItemRandomEnchantmentTemplateEntry, - ItemRandomEnchantmentTemplateStore, ItemRandomPropertiesEntry, ItemRandomPropertiesStore, - ItemRandomPropertyTemplateEntry, ItemRandomSuffixEntry, ItemRandomSuffixStore, ItemRecord, - ItemSparseTemplateEntry, ItemStatsStore, ItemStore, RandPropPointsEntry, - RandPropPointsStore, SpellEffectInfo, SpellInfo, SpellMiscEntry, SpellMiscStore, - SpellRangeEntry, SpellRangeStore, SpellStore, - }; - use wow_database::{CharStatements, StatementDef}; - use wow_entities::{ - AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, Corpse, CorpseType, Creature, - CreatureOwnedLoot, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_FISHING_HOLE, - GAMEOBJECT_TYPE_FISHING_NODE, GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, - GO_DYNFLAG_LO_NO_INTERACT, GameObject, GameObjectLootSource, GameObjectOwnedLoot, - GatheringNodeUseSource, GoState, Item, ItemCreateInfo, LootState, MAX_ITEM_SPELLS, - WorldObject, - }; - use wow_loot::{ - GeneratedLootItem, LOOT_SLOT_TYPE_OWNER_LIKE_CPP, LootConditionRowLikeCpp, LootStore, - LootStoreItem, LootStoreKind, LootStores, LootTemplateRow, - }; - use wow_network::{ - GroupInfo, GroupRegistry, LootDropRatesLikeCpp, LootRollVoteCommand, PendingInvites, - PlayerBroadcastInfo, PlayerRegistry, SessionCommand, - }; - use wow_packet::packets::loot::{ - CreatureLoot, LOOT_ERROR_MASTER_OTHER_LIKE_CPP, LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP, - LOOT_ERROR_NO_LOOT_LIKE_CPP, LOOT_ERROR_PLAYER_NOT_FOUND_LIKE_CPP, - LOOT_ERROR_TOO_FAR_LIKE_CPP, LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, - LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, LOOT_TYPE_CHEST_LIKE_CPP, - LOOT_TYPE_CORPSE_LIKE_CPP, LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP, - LOOT_TYPE_DISENCHANTING_LIKE_CPP, LOOT_TYPE_FISHING_JUNK_LIKE_CPP, - LOOT_TYPE_FISHING_LIKE_CPP, LOOT_TYPE_FISHINGHOLE_LIKE_CPP, - LOOT_TYPE_GATHERING_NODE_LIKE_CPP, LOOT_TYPE_INSIGNIA_LIKE_CPP, LOOT_TYPE_ITEM_LIKE_CPP, - LOOT_TYPE_MILLING_LIKE_CPP, LOOT_TYPE_NONE_LIKE_CPP, LOOT_TYPE_PROSPECTING_LIKE_CPP, - LOOT_TYPE_SKINNING_LIKE_CPP, LootEntry, LootEntryFlags, LootRoll, MasterLootItem, - SetLootSpecialization, - }; - use wow_packet::packets::update::CreatureCreateData; - use wow_packet::{ServerPacket, WorldPacket}; +fn loot_item_is_looted_for_player_like_cpp( + loot: &CreatureLoot, + entry: &LootEntry, + player_guid: ObjectGuid, +) -> bool { + if entry.flags.freeforall { + !loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id) + } else { + entry.taken + } +} - use crate::session::{ - AuraApplication, InventoryItem, SPELL_AURA_INTERRUPT_FLAG_LOOTING_LIKE_CPP, SpellCastState, - WorldSession, - }; +fn mark_loot_item_looted_for_player_like_cpp( + loot: &mut CreatureLoot, + loot_list_id: u8, + player_guid: ObjectGuid, +) { + let should_decrement = loot + .items + .iter() + .find(|entry| entry.loot_list_id == loot_list_id) + .is_some_and(|entry| !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid)); - fn make_session_with_send_capacity( - capacity: usize, - ) -> (WorldSession, flume::Receiver>) { - let (_pkt_tx, pkt_rx) = flume::bounded::(1); - let (send_tx, send_rx) = flume::bounded::>(capacity); - ( - WorldSession::new( - 1, - "TestAccount".into(), - 0, - 2, - 9, - 54261, - vec![0u8; 40], - "esES".into(), - pkt_rx, - send_tx, - ), - send_rx, - ) + if let Some(entry) = loot + .items + .iter_mut() + .find(|entry| entry.loot_list_id == loot_list_id) + { + entry.mark_looted_for_player_like_cpp(player_guid); + if entry.flags.freeforall { + if let Some((_, items)) = loot + .player_ffa_items + .iter_mut() + .find(|(player, _)| *player == player_guid) + && let Some(item) = items + .iter_mut() + .find(|item| item.loot_list_id == loot_list_id) + { + item.is_looted = true; + } + } + if should_decrement { + loot.unlooted_count = loot.unlooted_count.saturating_sub(1); + } } +} - fn make_session_with_send() -> (WorldSession, flume::Receiver>) { - make_session_with_send_capacity(1) +fn represented_loot_response_items_like_cpp( + loot: &CreatureLoot, + player_guid: ObjectGuid, +) -> Vec { + loot.items + .iter() + .filter_map(|entry| { + let ui_type = loot_item_ui_type_for_player_like_cpp( + player_guid, + &entry.allowed_looters, + loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid), + entry.flags.freeforall, + loot_player_has_unlooted_ffa_item_like_cpp(loot, player_guid, entry.loot_list_id), + entry.flags.needs_quest, + entry.flags.follow_loot_rules, + loot.loot_method, + loot.round_robin_player, + loot.loot_master, + entry.flags.under_threshold, + entry.flags.blocked, + entry.roll_winner, + )?; + + Some(LootItemData { + item_type: 0, + ui_type, + can_trade_to_tap_list: false, + loot: ItemInstance { + item_id: entry.item_id as i32, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, + }) + }) + .collect() +} + +fn looted_corpse_decay_secs_like_cpp( + is_fully_skinned: bool, + corpse_delay_secs: u32, + ignore_decay_ratio: bool, + corpse_decay_looted_rate: f32, +) -> u32 { + if is_fully_skinned { + return 0; } - fn make_session() -> WorldSession { - make_session_with_send().0 + let rate = if ignore_decay_ratio { + 1.0 + } else { + corpse_decay_looted_rate.max(0.0) + }; + ((corpse_delay_secs as f32) * rate) as u32 +} + +fn loot_can_be_opened_by_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + if loot_is_looted_like_cpp(loot) { + return false; } - fn canonical_world_object(guid: ObjectGuid, map_id: u32, position: Position) -> WorldObject { - let (type_id, type_mask) = if guid.is_game_object() { - (TypeId::GameObject, TypeMask::GAME_OBJECT) - } else { - (TypeId::Unit, TypeMask::UNIT) - }; - let mut object = WorldObject::new(false, type_id, type_mask); - object.object_mut().create(guid); - object.set_map(map_id, 0).unwrap(); - object.relocate(position); - object.object_mut().add_to_world(); - object + loot_has_item_for_all_like_cpp(loot, player_guid) + || loot_has_item_for_player_like_cpp(loot, player_guid) +} + +/// Exact represented branch order of C++ `Player::isAllowedToLoot` for a +/// creature and the pool selected by `Creature::GetLootForPlayer`. +fn creature_loot_is_allowed_to_player_like_cpp( + creature_is_dead: bool, + player_has_pending_bind: bool, + loot: &CreatureLoot, + player_guid: ObjectGuid, +) -> bool { + if !creature_is_dead || player_has_pending_bind || loot_is_looted_like_cpp(loot) { + return false; + } + if !loot.allowed_looters.contains(&player_guid) + || (!loot_has_item_for_all_like_cpp(loot, player_guid) + && !loot_has_item_for_player_like_cpp(loot, player_guid)) + { + return false; } - fn attach_canonical_map_object( - session: &mut WorldSession, - kind: AccessorObjectKind, - object: WorldObject, - ) { - let map_id = object.map_id(); - let instance_id = object.instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .add_to_map_like_cpp(kind, object) - .unwrap(); + match loot.loot_method { + LOOT_METHOD_FREE_FOR_ALL_LIKE_CPP | LOOT_METHOD_PERSONAL_LIKE_CPP => true, + LOOT_METHOD_ROUND_ROBIN_LIKE_CPP => { + loot.round_robin_player.is_empty() + || loot.round_robin_player == player_guid + || loot_has_item_for_player_like_cpp(loot, player_guid) } - session.set_canonical_map_manager(manager); + LOOT_METHOD_MASTER_LIKE_CPP + | LOOT_METHOD_GROUP_LIKE_CPP + | LOOT_METHOD_NEED_BEFORE_GREED_LIKE_CPP => { + loot.round_robin_player.is_empty() + || loot.round_robin_player == player_guid + || loot_has_over_threshold_item_like_cpp(loot) + || loot_has_item_for_player_like_cpp(loot, player_guid) + } + _ => false, } +} - fn attach_canonical_gameobject(session: &mut WorldSession, game_object: GameObject) { - let map_id = game_object.world().map_id(); - let instance_id = game_object.world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_game_object(game_object).unwrap(), - ) - .unwrap(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreatureLootReleaseCommandQueueOutcomeLikeCpp { + Queued, + Retrying, + Disconnected, +} + +fn queue_creature_loot_release_command_reliably_like_cpp( + command_tx: &flume::Sender, + command: SessionCommand, +) -> CreatureLootReleaseCommandQueueOutcomeLikeCpp { + match command_tx.try_send(command) { + Ok(()) => CreatureLootReleaseCommandQueueOutcomeLikeCpp::Queued, + Err(flume::TrySendError::Disconnected(_)) => { + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Disconnected + } + Err(flume::TrySendError::Full(command)) => { + let command_tx = command_tx.clone(); + // Never await another session from the source session loop: two + // full queues could otherwise wait on each other forever. The + // detached retry retains the exact command until capacity opens; + // receiver-side authority/lifecycle gates coalesce its meaning to + // the current corpse generation and reject stale respawn reuse. + tokio::spawn(async move { + if command_tx.send_async(command).await.is_err() { + tracing::debug!( + "loot-release DynamicFlags retry ended after target session disconnected" + ); + } + }); + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Retrying } - session.set_canonical_map_manager(manager); } +} - fn attach_canonical_creature(session: &mut WorldSession, creature: Creature) { - let map_id = creature.unit().world().map_id(); - let instance_id = creature.unit().world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_creature(creature).unwrap(), - ) - .unwrap(); +fn loot_has_over_threshold_item_like_cpp(loot: &CreatureLoot) -> bool { + loot.items + .iter() + .any(|entry| !entry.taken && entry.is_over_threshold_like_cpp()) +} + +fn connected_roll_looters_like_cpp( + entry: &LootEntry, + player_guid: ObjectGuid, + current_map_id: u16, + current_instance_id: u32, + player_registry: Option<&PlayerRegistry>, +) -> Vec { + let mut looters = Vec::new(); + + for looter in &entry.allowed_looters { + if *looter == player_guid { + looters.push(*looter); + continue; } - session.set_canonical_map_manager(manager); - } - fn attach_canonical_corpse(session: &mut WorldSession, corpse: Corpse) { - let map_id = corpse.world().map_id(); - let instance_id = corpse.world().instance_id(); - let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); - { - let mut manager = manager.lock().unwrap(); - manager - .create_world_map(map_id, instance_id) - .map_mut() - .insert_map_object_record( - wow_entities::MapObjectRecord::new_corpse(corpse).unwrap(), - ) - .unwrap(); + let Some(registry) = player_registry else { + continue; + }; + let Some(player) = registry.get(looter) else { + continue; + }; + if player.map_id == current_map_id && player.instance_id == current_instance_id { + looters.push(*looter); } - session.set_canonical_map_manager(manager); } - fn make_canonical_corpse_for_session(session: &WorldSession, guid: ObjectGuid) -> Corpse { - let mut corpse = Corpse::new_at(CorpseType::ResurrectablePvp, 1_000); - corpse.world_mut().object_mut().create(guid); - corpse - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) - .unwrap(); - corpse.world_mut().relocate(Position::ZERO); - corpse.world_mut().object_mut().add_to_world(); - corpse.set_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE); - corpse.clear_corpse_data_changes(); - corpse - } + looters.sort_by_key(|guid| (guid.high_value(), guid.low_value())); + looters.dedup(); + looters +} - fn canonical_corpse_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_corpse(guid).cloned() - } +fn represented_max_enchanting_skill_like_cpp( + looters: &[ObjectGuid], + current_player_guid: ObjectGuid, + current_player_enchanting_skill: u16, + player_registry: Option<&PlayerRegistry>, +) -> u16 { + looters.iter().fold(0, |max_skill, looter| { + if *looter == current_player_guid { + max_skill.max(current_player_enchanting_skill) + } else { + max_skill.max( + player_registry + .and_then(|registry| registry.get(looter)) + .map(|player| player.enchanting_skill) + .unwrap_or(0), + ) + } + }) +} - fn make_canonical_creature_for_session(session: &WorldSession, guid: ObjectGuid) -> Creature { - let mut creature = Creature::new(false); - creature.unit_mut().world_mut().object_mut().create(guid); - creature - .unit_mut() - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) - .unwrap(); - creature.unit_mut().world_mut().relocate(Position::ZERO); - creature.unit_mut().world_mut().object_mut().add_to_world(); - creature +fn start_loot_roll_packet_like_cpp( + loot_obj: ObjectGuid, + map_id: u16, + loot_method: u8, + entry: &LootEntry, + valid_rolls: u8, + dungeon_encounter_id: i32, +) -> StartLootRoll { + StartLootRoll { + loot_obj, + map_id: map_id as i32, + roll_time_ms: LOOT_ROLL_TIMEOUT_MS_LIKE_CPP, + method: loot_method, + valid_rolls, + loot_roll_ineligible_reason: [0; 4], + item: LootItemData { + item_type: 0, + ui_type: LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, + can_trade_to_tap_list: entry.allowed_looters.len() > 1, + loot: ItemInstance { + item_id: entry.item_id as i32, + random_properties_id: entry.random_properties_id, + random_properties_seed: entry.random_properties_seed, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, + }, + dungeon_encounter_id, } +} - fn canonical_creature_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_creature(guid).cloned() +fn loot_roll_broadcast_item_like_cpp(entry: &LootEntry, ui_type: u8) -> LootItemData { + LootItemData { + item_type: 0, + ui_type, + can_trade_to_tap_list: entry.allowed_looters.len() > 1, + loot: ItemInstance { + item_id: entry.item_id as i32, + random_properties_id: entry.random_properties_id, + random_properties_seed: entry.random_properties_seed, + ..ItemInstance::default() + }, + loot_list_id: entry.loot_list_id, + quantity: entry.quantity, + loot_item_type: 0, } +} - fn make_canonical_gameobject_for_session( - session: &WorldSession, - guid: ObjectGuid, - go_type: u8, - ) -> GameObject { - let mut game_object = GameObject::new(); - game_object.world_mut().object_mut().create(guid); - game_object - .world_mut() - .set_map(u32::from(session.player_map_id_like_cpp()), 0) - .unwrap(); - game_object.world_mut().relocate(Position::ZERO); - game_object.world_mut().object_mut().add_to_world(); - game_object.set_go_type(go_type); - game_object +fn roll_chance_with_rate_like_cpp(chance: f32, rate: f32, rng: &mut R) -> bool { + if chance >= 100.0 { + return true; } + rng.gen_range(0.0f32..100.0f32) < chance * rate +} - fn canonical_gameobject_snapshot( - session: &WorldSession, - guid: ObjectGuid, - ) -> Option { - let manager = session.canonical_map_manager.as_ref()?; - let manager = manager.lock().ok()?; - let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; - map.map().get_typed_game_object(guid).cloned() - } +fn referenced_loot_max_count_like_cpp(max_count: u8, rate: f32) -> u32 { + ((max_count as f32) * rate) as u32 +} - fn loot_item_packet(object: ObjectGuid, loot_list_id: u8) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_uint32(1); - pkt.write_packed_guid(&object); - pkt.write_uint8(loot_list_id); - pkt.write_bit(false); - pkt.flush_bits(); - pkt.reset_read(); - pkt - } +fn represented_disenchant_loot_plain_row_can_roll_like_cpp( + row: &LootStoreItem, + item_exists: bool, +) -> bool { + row.can_roll_as_plain_entry_like_cpp(item_exists, LOOT_MODE_DEFAULT_LIKE_CPP) +} - fn loot_unit_packet(object: ObjectGuid) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_packed_guid(&object); - pkt.reset_read(); - pkt - } +fn represented_disenchant_loot_reference_row_can_roll_like_cpp(row: &LootStoreItem) -> bool { + row.can_roll_as_reference_entry_like_cpp(LOOT_MODE_DEFAULT_LIKE_CPP) +} - fn represented_loot_entry( - loot_list_id: u8, - item_id: u32, - player_guid: ObjectGuid, - ) -> LootEntry { - LootEntry { - loot_list_id, +fn add_loot_item_stacks_like_cpp( + loot_items: &mut Vec, + item_id: u32, + mut count: u32, + max_stack_size: u32, + flags: LootEntryFlags, +) { + while count > 0 && loot_items.len() < MAX_NR_LOOT_ITEMS_LIKE_CPP { + let quantity = count.min(max_stack_size); + loot_items.push(LootEntry { + loot_list_id: loot_items.len() as u8, item_id, - quantity: 1, + quantity, random_properties_id: 0, random_properties_seed: 0, item_context: 0, - flags: LootEntryFlags { - follow_loot_rules: true, - freeforall: false, - blocked: false, - counted: false, - under_threshold: false, - needs_quest: false, - }, - allowed_looters: vec![player_guid], - roll_winner: ObjectGuid::EMPTY, + flags, + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, ffa_looted_by: Vec::new(), taken: false, - } + }); + count = count.saturating_sub(max_stack_size); } +} - #[test] - fn represented_loot_type_for_client_matches_cpp_aliases() { - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_NONE_LIKE_CPP), - LOOT_TYPE_NONE_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_LIKE_CPP), - LOOT_TYPE_CORPSE_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_ITEM_LIKE_CPP), - LOOT_TYPE_ITEM_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_GATHERING_NODE_LIKE_CPP), - LOOT_TYPE_GATHERING_NODE_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CHEST_LIKE_CPP), - LOOT_TYPE_CHEST_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP), - LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_PROSPECTING_LIKE_CPP), - LOOT_TYPE_DISENCHANTING_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_MILLING_LIKE_CPP), - LOOT_TYPE_DISENCHANTING_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_INSIGNIA_LIKE_CPP), - LOOT_TYPE_SKINNING_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_FISHINGHOLE_LIKE_CPP), - LOOT_TYPE_FISHING_LIKE_CPP - ); - assert_eq!( - loot_type_for_client_like_cpp(LOOT_TYPE_FISHING_JUNK_LIKE_CPP), - LOOT_TYPE_FISHING_LIKE_CPP - ); +#[derive(Debug, Clone)] +struct DisenchantLootTemplateFrame { + template: LootTemplate, + entry_index: usize, + group_index: usize, + requested_group_id: u8, +} + +fn disenchant_loot_template_frame_like_cpp( + rows: Vec, + requested_group_id: u8, +) -> DisenchantLootTemplateFrame { + let mut template = LootTemplate::default(); + for row in rows { + template.add_entry_like_cpp(row); } - #[tokio::test] - async fn represented_loot_response_acquire_reason_uses_cpp_loot_type_mapping() { - let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let owner_guid = test_creature_guid(19_096); - let loot_guid = represented_loot_object_guid_like_cpp(owner_guid); - let entry = represented_loot_entry(0, 25, player_guid); - register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 1, - loot_type: LOOT_TYPE_PROSPECTING_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: vec![entry], - looted_by_player: false, - }, - ); + DisenchantLootTemplateFrame { + template, + entry_index: 0, + group_index: 0, + requested_group_id, + } +} - let response = session - .represented_loot_response_for_owner_like_cpp(owner_guid, player_guid, false) - .await - .unwrap(); +#[derive(Debug, Clone, Copy)] +enum DisenchantLootTemplateTable { + Disenchant, + Reference, +} - assert_eq!(response.acquire_reason, LOOT_TYPE_DISENCHANTING_LIKE_CPP); +impl DisenchantLootTemplateTable { + fn name(self) -> &'static str { + match self { + Self::Disenchant => "disenchant_loot_template", + Self::Reference => "reference_loot_template", + } } +} - #[test] - fn represented_start_loot_roll_carries_cpp_dungeon_encounter_id() { - let player_guid = ObjectGuid::create_player(1, 42); - let loot_obj = ObjectGuid::create_world_object(HighGuid::LootObject, 0, 1, 0, 0, 1, 900); - let entry = represented_loot_entry(0, 25, player_guid); - - let packet = start_loot_roll_packet_like_cpp( - loot_obj, - 571, - LOOT_METHOD_GROUP_LIKE_CPP, - &entry, - ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, - 615, - ); +fn represented_loot_roll_finish_winner_like_cpp( + state: &RepresentedLootRollState, +) -> Option> { + let mut winner = None; + let mut has_need = false; - assert_eq!(packet.dungeon_encounter_id, 615); + for (player_guid, vote) in &state.voters { + match vote.vote { + ROLL_VOTE_NEED_LIKE_CPP => { + if !has_need + || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + has_need = true; + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { + if !has_need + && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_PASS_LIKE_CPP | ROLL_VOTE_NOT_VALID_LIKE_CPP => {} + ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => return None, + _ => {} + } } - #[test] - fn represented_loot_item_push_result_carries_cpp_encounter_loot_fields() { - let (session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let item_guid = ObjectGuid::create_item(1, 700); - let entry = represented_loot_entry(0, 25, player_guid); + Some(winner) +} - session.send_loot_item_push_result( - player_guid, - item_guid, - &entry, - 0, - 0, - 0, - 1, - 1, - false, - 615, - ); +fn represented_loot_roll_current_winner_like_cpp( + state: &RepresentedLootRollState, +) -> Option<(ObjectGuid, RepresentedLootRollVote)> { + let mut winner = None; + let mut has_need = false; - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::ItemPushResult as u16 - ); - assert_eq!(sent.read_packed_guid().unwrap(), player_guid); - assert_eq!(sent.read_uint8().unwrap(), u8::from(INVENTORY_SLOT_BAG_0)); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 1); - assert_eq!(sent.read_int32().unwrap(), 1); - assert_eq!(sent.read_int32().unwrap(), 615); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_uint32().unwrap(), 0); - assert_eq!(sent.read_int32().unwrap(), 0); - assert_eq!(sent.read_packed_guid().unwrap(), item_guid); - assert!(!sent.read_bit().unwrap()); - assert!(!sent.read_bit().unwrap()); - assert_eq!(sent.read_bits(3).unwrap(), 2); - assert!(!sent.read_bit().unwrap()); - assert!(sent.read_bit().unwrap()); - assert_eq!(sent.read_int32().unwrap(), 25); + for (player_guid, vote) in &state.voters { + match vote.vote { + ROLL_VOTE_NEED_LIKE_CPP => { + if !has_need + || winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + has_need = true; + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_GREED_LIKE_CPP | ROLL_VOTE_DISENCHANT_LIKE_CPP => { + if !has_need + && winner.is_none_or(|(_, current): (ObjectGuid, RepresentedLootRollVote)| { + vote.roll_number > current.roll_number + }) + { + winner = Some((*player_guid, *vote)); + } + } + ROLL_VOTE_PASS_LIKE_CPP + | ROLL_VOTE_NOT_VALID_LIKE_CPP + | ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP => {} + _ => {} + } } - #[test] - fn creature_generated_loot_entry_uses_item_template_addon_follow_loot_rules_like_cpp() { - let generated = GeneratedLootItem { - item_id: 25, - count: 1, - loot_list_id: 7, - random_properties_id: -77, - random_properties_seed: 456, - context: ItemContext::DungeonNormal as u8, - free_for_all: false, - follow_loot_rules: false, - needs_quest: true, - is_looted: false, - is_blocked: false, - is_under_threshold: false, - is_counted: false, - }; + winner +} - let default_entry = generated_creature_loot_item_to_entry_like_cpp( - generated, - ItemTemplateAddonLootMetadataLikeCpp::default(), - ); - assert!(!default_entry.flags.follow_loot_rules); - assert_eq!(default_entry.loot_list_id, 7); - assert_eq!(default_entry.random_properties_id, -77); - assert_eq!(default_entry.random_properties_seed, 456); - assert_eq!(default_entry.item_context, ItemContext::DungeonNormal as u8); - - let follow_entry = generated_creature_loot_item_to_entry_like_cpp( - generated, - ItemTemplateAddonLootMetadataLikeCpp { - flags_cu: ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, - quest_log_item_id: 0, - }, - ); - assert!(follow_entry.flags.follow_loot_rules); - assert!(follow_entry.flags.needs_quest); +fn loot_has_item_for_all_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + if loot.coins > 0 { + return true; } - #[test] - fn represented_loot_response_items_use_cpp_ui_type_decision_tree() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 100); - - let mut rolling_entry = represented_loot_entry(0, 25, player_guid); - rolling_entry.flags.blocked = true; + loot.items.iter().any(|entry| { + !entry.taken + && entry.flags.follow_loot_rules + && !entry.flags.freeforall + && entry.has_allowed_looter_like_cpp(player_guid) + }) +} - let mut won_entry = represented_loot_entry(1, 26, player_guid); - won_entry.roll_winner = player_guid; +fn loot_has_item_for_player_like_cpp(loot: &CreatureLoot, player_guid: ObjectGuid) -> bool { + loot.items.iter().any(|entry| { + !loot_item_is_looted_for_player_like_cpp(loot, entry, player_guid) + && entry.has_allowed_looter_like_cpp(player_guid) + && (!entry.flags.follow_loot_rules || entry.flags.freeforall) + }) +} - let mut hidden_entry = represented_loot_entry(2, 27, player_guid); - hidden_entry.roll_winner = other_guid; +fn loot_item_context(context: u8) -> ItemContext { + ::from_u8(context).unwrap_or(ItemContext::None) +} - let mut allowed_entry = represented_loot_entry(3, 28, player_guid); - allowed_entry.flags.under_threshold = true; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LootStoreRandomProperties { + id: i32, + seed: i32, +} - let loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: vec![rolling_entry, won_entry, hidden_entry, allowed_entry], - looted_by_player: false, - }; +fn loot_store_data_can_stack_with_item( + loot_entry: &LootEntry, + random_properties: LootStoreRandomProperties, + item: &Item, +) -> bool { + let data = item.data(); + data.random_properties_id == random_properties.id + && data.property_seed == random_properties.seed + && u8::try_from(data.context).unwrap_or(0) == loot_entry.item_context +} - let items = represented_loot_response_items_like_cpp(&loot, player_guid); +impl WorldSession { + fn generate_loot_store_random_properties_with_rng_like_cpp( + &self, + item_id: u32, + rng: &mut R, + ) -> LootStoreRandomProperties { + // C++ Player::StoreLootItem calls ItemEnchantmentMgr::GenerateRandomProperties(itemid). + let random_select = self.item_template_random_select(item_id); + let random_suffix = self.item_template_random_suffix_group_id(item_id); + if random_select == 0 && random_suffix == 0 { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } - assert_eq!(items.len(), 3); - assert_eq!(items[0].loot_list_id, 0); - assert_eq!(items[0].ui_type, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP); - assert_eq!(items[1].loot_list_id, 1); - assert_eq!(items[1].ui_type, LOOT_SLOT_TYPE_OWNER_LIKE_CPP); - assert_eq!(items[2].loot_list_id, 3); - assert_eq!(items[2].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); - } + if random_select != 0 { + let Some(random_properties_id) = + self.select_random_enchantment_from_group_like_cpp(u32::from(random_select), rng) + else { + return LootStoreRandomProperties { id: 0, seed: 0 }; + }; - #[test] - fn represented_ffa_loot_uses_player_ffa_items_like_cpp() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 101); + if self + .item_random_properties_store() + .and_then(|store| store.get(random_properties_id)) + .is_none() + { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } - let mut ffa_entry = represented_loot_entry(0, 25, player_guid); - ffa_entry.flags.freeforall = true; - ffa_entry.allowed_looters.clear(); + return LootStoreRandomProperties { + id: i32::try_from(random_properties_id).unwrap_or(0), + seed: 0, + }; + } - let mut loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: Vec::new(), - items: vec![ffa_entry], - looted_by_player: false, + let Some(random_suffix_id) = + self.select_random_enchantment_from_group_like_cpp(u32::from(random_suffix), rng) + else { + return LootStoreRandomProperties { id: 0, seed: 0 }; }; - mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); - mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); - assert_eq!(loot.unlooted_count, 2); - - let player_items = represented_loot_response_items_like_cpp(&loot, player_guid); - let other_items = represented_loot_response_items_like_cpp(&loot, other_guid); - assert_eq!(player_items.len(), 1); - assert_eq!(other_items.len(), 1); - assert_eq!(player_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); - assert_eq!(other_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + if self + .item_random_suffix_store() + .and_then(|store| store.get(random_suffix_id)) + .is_none() + { + return LootStoreRandomProperties { id: 0, seed: 0 }; + } - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 1); + let seed = self + .item_random_property_template(item_id) + .map(|template| self.random_property_points_like_cpp(template)) + .unwrap_or(0); - let player_ffa = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == player_guid) - .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) - .unwrap(); - let other_ffa = loot - .player_ffa_items - .iter() - .find(|(player, _)| *player == other_guid) - .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) - .unwrap(); + LootStoreRandomProperties { + id: -i32::try_from(random_suffix_id).unwrap_or(0), + seed, + } + } - assert!(player_ffa.is_looted); - assert!(!other_ffa.is_looted); - assert!(represented_loot_response_items_like_cpp(&loot, player_guid).is_empty()); - assert_eq!( - represented_loot_response_items_like_cpp(&loot, other_guid).len(), - 1 - ); + fn select_random_enchantment_from_group_like_cpp( + &self, + group_id: u32, + rng: &mut R, + ) -> Option { + let group = self + .item_random_enchantment_template_store() + .and_then(|store| store.group(group_id))?; + select_weighted_random_enchantment_like_cpp(group, rng) } - #[test] - fn represented_unlooted_count_counts_shared_items_once_like_cpp() { - let player_guid = ObjectGuid::create_player(1, 42); - let other_guid = ObjectGuid::create_player(1, 77); - let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 102); + fn random_property_points_like_cpp(&self, template: ItemRandomPropertyTemplateEntry) -> i32 { + let prop_index = + match ::from_i8(template.inventory_type) { + Some(InventoryType::NonEquip) + | Some(InventoryType::Bag) + | Some(InventoryType::Tabard) + | Some(InventoryType::Ammo) + | Some(InventoryType::Quiver) + | Some(InventoryType::Relic) + | None => return 0, + Some(InventoryType::Head) + | Some(InventoryType::Body) + | Some(InventoryType::Chest) + | Some(InventoryType::Legs) + | Some(InventoryType::Weapon2Hand) + | Some(InventoryType::Robe) => 0, + Some(InventoryType::Shoulders) + | Some(InventoryType::Waist) + | Some(InventoryType::Feet) + | Some(InventoryType::Hands) + | Some(InventoryType::Trinket) => 1, + Some(InventoryType::Neck) + | Some(InventoryType::Wrists) + | Some(InventoryType::Finger) + | Some(InventoryType::Shield) + | Some(InventoryType::Cloak) + | Some(InventoryType::Holdable) => 2, + Some(InventoryType::Weapon) + | Some(InventoryType::WeaponMainhand) + | Some(InventoryType::WeaponOffhand) => 3, + Some(InventoryType::Ranged) + | Some(InventoryType::Thrown) + | Some(InventoryType::RangedRight) => 4, + _ => return 0, + }; - let mut entry = represented_loot_entry(0, 25, player_guid); - entry.allowed_looters.clear(); + let Some(points) = self + .rand_prop_points_store() + .and_then(|store| store.get(u32::from(template.item_level))) + else { + return 0; + }; - let mut loot = CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: Vec::new(), - items: vec![entry], - looted_by_player: false, - }; + match ::from_i8(template.quality) { + Some(ItemQuality::Uncommon) => points.good[prop_index] as i32, + Some(ItemQuality::Rare) | Some(ItemQuality::Heirloom) => { + points.superior[prop_index] as i32 + } + Some(ItemQuality::Epic) + | Some(ItemQuality::Legendary) + | Some(ItemQuality::Artifact) => points.epic[prop_index] as i32, + _ => 0, + } + } +} - mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); - assert_eq!(loot.unlooted_count, 1); - assert!(loot.items[0].flags.counted); +fn select_weighted_random_enchantment_like_cpp( + group: &[ItemRandomEnchantmentTemplateEntry], + rng: &mut R, +) -> Option { + let valid_rows = group + .iter() + .filter(|row| (0.000001..=100.0).contains(&row.chance)) + .collect::>(); + let weights = valid_rows.iter().map(|row| row.chance).collect::>(); + let distribution = WeightedIndex::new(weights).ok()?; + Some(valid_rows[distribution.sample(rng)].enchantment_id) +} - mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); - assert_eq!(loot.unlooted_count, 1); +#[derive(Debug, Clone)] +struct PlannedLootNewStack { + slot: u8, + entry_id: u32, + count: u32, + max_durability: u32, + dynamic_flags: u32, + random_properties_id: i32, + random_properties_seed: i32, + item_context: u8, +} - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 0); - mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); - assert_eq!(loot.unlooted_count, 0); - } +/// Everything needed to mirror C++ `Player::StoreLootItem`'s post-store wire +/// boundary. SQL and the object-owned claim are settled by the detached +/// worker; the session publishes the stored-item update before choosing the +/// direct-loot or disenchant-specific removal/`ItemPushResult` order. +#[derive(Debug, Clone, Copy)] +struct LootItemClaimCommitContextLikeCpp { + owner_guid: ObjectGuid, + loot_obj: ObjectGuid, + loot_list_id: u8, + player_guid: ObjectGuid, + free_for_all: bool, +} - #[test] - fn represented_loot_removed_uses_players_looting_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let open_guid = ObjectGuid::create_player(1, 77); - let closed_guid = ObjectGuid::create_player(1, 88); - let stale_guid = ObjectGuid::create_player(1, 99); - let owner_guid = test_creature_guid(19_095); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); - let (open_tx, open_rx) = flume::bounded::>(1); - let (closed_tx, closed_rx) = flume::bounded::>(1); - let player_registry = Arc::new(PlayerRegistry::default()); - player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); - player_registry.insert(closed_guid, broadcast_info(closed_guid, closed_tx)); - session.set_player_registry(player_registry); - session.set_player_guid(Some(player_guid)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid, open_guid, stale_guid], - allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], - items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags::default(), - allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }], - looted_by_player: false, - }, - ); +#[derive(Debug, Clone)] +struct PlannedDisenchantExistingStack { + slot: u8, + item_guid: ObjectGuid, + db_guid: u64, + new_count: u32, + dynamic_flags: u32, + flags_changed: bool, +} - session.represented_notify_loot_item_removed_like_cpp(owner_guid, 0); +#[derive(Debug, Clone)] +struct PlannedDirectLootExistingStack { + slot: u8, + item_guid: ObjectGuid, + db_guid: u64, + new_count: u32, + added_count: u32, + dynamic_flags: u32, + flags_changed: bool, +} - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRemoved as u16 - ); - assert_eq!(sent.read_packed_guid().unwrap(), owner_guid); - assert_eq!(sent.read_packed_guid().unwrap(), loot_object); - assert_eq!(sent.read_uint8().unwrap(), 0); +#[derive(Debug, Clone)] +struct PlannedDisenchantExistingPush { + slot: u8, + item_guid: ObjectGuid, + added_count: u32, + new_count: u32, +} - let sent = open_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRemoved as u16 - ); - assert!(closed_rx.try_recv().is_err()); - assert_eq!( - session.loot_table.get(&owner_guid).unwrap().players_looting, - vec![player_guid, open_guid] - ); +#[derive(Debug, Clone)] +struct PlannedDisenchantNewPush { + stack_index: usize, + added_count: u32, + new_count: u32, +} + +#[derive(Debug, Clone)] +struct PlannedDisenchantGrant { + entry: LootEntry, + random_properties: LootStoreRandomProperties, + existing_pushes: Vec, + new_pushes: Vec, +} + +/// Own a loot lease in the same detached task that crosses the durable +/// persistence boundary. Tokio does not cancel a spawned task when the +/// caller drops its `JoinHandle`, so packet/session cancellation cannot turn a +/// successful SQL commit back into an available object-owned claim. +enum LootClaimPersistenceWorkerError { + Persistence(E), + Claim(LootClaimCommitError), +} + +impl std::fmt::Debug for LootClaimPersistenceWorkerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Persistence(error) => formatter.debug_tuple("Persistence").field(error).finish(), + Self::Claim(error) => formatter.debug_tuple("Claim").field(error).finish(), + } } +} - #[test] - fn represented_money_removed_erases_missing_players_looting_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let open_guid = ObjectGuid::create_player(1, 77); - let stale_guid = ObjectGuid::create_player(1, 99); - let owner_guid = test_creature_guid(19_096); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); - let (open_tx, open_rx) = flume::bounded::>(1); - let player_registry = Arc::new(PlayerRegistry::default()); - player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); - session.set_player_registry(player_registry); - session.set_player_guid(Some(player_guid)); - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 7, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid, open_guid, stale_guid], - allowed_looters: vec![player_guid, open_guid, stale_guid], - items: Vec::new(), - looted_by_player: false, - }, - ); +/// The stored-money row is the durable single-winner token. Keeping its +/// deletion in the same transaction as the gold update and requiring exactly +/// one affected row turns retries/concurrent handlers into a database CAS. +const STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP: u64 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct StoredItemMoneyDbOutcomeLikeCpp { + before: u64, + after: u64, + applied_delta: u64, + notified_amount: u64, +} - session.represented_notify_money_removed_like_cpp(owner_guid); +#[derive(Debug)] +enum StoredItemMoneyAttemptErrorLikeCpp { + DefinitelyRolledBack(LootMoneyPersistenceErrorLikeCpp), + CommitOutcomeUnknown { + error: DatabaseError, + outcome: StoredItemMoneyDbOutcomeLikeCpp, + }, +} - let sent = send_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::CoinRemoved as u16 - ); - assert_eq!(sent.read_packed_guid().unwrap(), loot_object); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StoredItemMoneyCommitReconciliationLikeCpp { + Committed, + RolledBack, + Indeterminate, +} - let sent = open_rx.try_recv().unwrap(); - let mut sent = WorldPacket::from_bytes(&sent); - assert_eq!( - sent.read_uint16().unwrap(), - wow_constants::ServerOpcodes::CoinRemoved as u16 - ); - assert_eq!( - session.loot_table.get(&owner_guid).unwrap().players_looting, - vec![player_guid, open_guid] - ); +fn classify_stored_item_money_commit_reconciliation_like_cpp( + outcome: StoredItemMoneyDbOutcomeLikeCpp, + observed_money: u64, + observed_source_money: Option, +) -> StoredItemMoneyCommitReconciliationLikeCpp { + let all_before = + observed_money == outcome.before && observed_source_money == Some(outcome.notified_amount); + let all_after = observed_money == outcome.after && observed_source_money.is_none(); + match (all_before, all_after) { + (true, false) => StoredItemMoneyCommitReconciliationLikeCpp::RolledBack, + (false, true) => StoredItemMoneyCommitReconciliationLikeCpp::Committed, + _ => StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate, } +} - fn loot_release_packet(object: ObjectGuid) -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_packed_guid(&object); - pkt.reset_read(); - pkt - } +fn stored_item_money_zero_without_source_outcome_like_cpp( + before: u64, + cached_notified_amount: u64, +) -> Option { + (cached_notified_amount == 0).then_some(StoredItemMoneyDbOutcomeLikeCpp { + before, + after: before, + applied_delta: 0, + notified_amount: 0, + }) +} - fn loot_money_packet() -> WorldPacket { - let mut pkt = WorldPacket::new_empty(); - pkt.write_bit(false); - pkt.flush_bits(); - pkt.reset_read(); - pkt +fn stored_item_money_attempt_is_deadlock_like_cpp( + error: &StoredItemMoneyAttemptErrorLikeCpp, +) -> bool { + matches!( + error, + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error) + ) if is_database_deadlock_like_cpp(error) + ) +} + +async fn attempt_stored_item_money_transaction_like_cpp( + char_db: &CharacterDatabase, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + cached_notified_amount: u64, +) -> Result { + let definitely = |error| { + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::from(error)), + ) + }; + let mut transaction = char_db.pool().begin().await.map_err(definitely)?; + // Global order shared with group payouts: character mutation mutex, then + // character row, then the stored Item source row. + let before = sqlx::query_scalar::<_, u64>(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(player_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)? + .ok_or_else(|| { + StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::MissingPlayer, + ) + })?; + let source_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE.sql()) + .bind(item_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)?; + let Some(notified_amount) = source_money else { + if let Some(outcome) = + stored_item_money_zero_without_source_outcome_like_cpp(before, cached_notified_amount) + { + transaction.rollback().await.map_err(definitely)?; + return Ok(outcome); + } + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction( + "stored Item money source was already consumed".to_string(), + )), + )); + }; + let (after, applied_delta) = loot_money_durable_outcome_like_cpp(before, notified_amount); + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before, + after, + applied_delta, + notified_amount, + }; + + if applied_delta != 0 { + let result = sqlx::query(CharStatements::UPD_CHAR_MONEY.sql()) + .bind(after) + .bind(player_guid.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if result.rows_affected() != 1 { + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction(format!( + "stored Item money update affected {} rows; expected exactly 1", + result.rows_affected() + ))), + )); + } + } + let delete = sqlx::query(CharStatements::DEL_ITEMCONTAINER_MONEY.sql()) + .bind(item_guid.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if delete.rows_affected() != STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP { + return Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction(format!( + "stored Item money source delete affected {} rows; expected exactly 1", + delete.rows_affected() + ))), + )); } - fn recv_packet_with_opcode( - rx: &flume::Receiver>, - opcode: wow_constants::ServerOpcodes, - ) -> WorldPacket { - for _ in 0..8 { - let sent = rx.try_recv().unwrap(); - let mut packet = WorldPacket::from_bytes(&sent); - if packet.read_uint16().unwrap() == opcode as u16 { - return packet; + match transaction.commit().await { + Ok(()) => Ok(outcome), + Err(error) => { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + Err(StoredItemMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error), + )) + } else { + Err(StoredItemMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { error, outcome }) } } - panic!("expected packet opcode {:?}", opcode); } +} - fn test_creature(guid: ObjectGuid, is_alive: bool) -> CreatureAI { - let mut creature = CreatureAI::new( - guid, - 1, - Position::ZERO, - 100, - 1, - 1, - 2, - 0.0, - 1, - 35, - 0, - 0, - 0, - 0, - 0, - None, - 0, - ); - creature.is_alive = is_alive; - creature +async fn reconcile_stored_item_money_commit_like_cpp( + char_db: &CharacterDatabase, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + outcome: StoredItemMoneyDbOutcomeLikeCpp, +) -> Result { + let mut transaction = char_db.pool().begin().await.map_err(DatabaseError::from)?; + // Read and lock both facts in the same order as the original mutation. + // The per-character mutation mutex is still held, so a later local payout + // cannot manufacture a mixed observation while COMMIT is reconciled. + let observed_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(player_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(DatabaseError::from)? + .ok_or_else(|| { + DatabaseError::Transaction("stored-money character vanished".to_string()) + })?; + let observed_source_money = + sqlx::query_scalar::<_, u64>(CharStatements::SEL_ITEMCONTAINER_MONEY_FOR_UPDATE.sql()) + .bind(item_guid.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(DatabaseError::from)?; + let classification = classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + observed_money, + observed_source_money, + ); + transaction.rollback().await.map_err(DatabaseError::from)?; + Ok(classification) +} + +fn queue_stored_item_money_indeterminate_kick_like_cpp(command_tx: &flume::Sender) { + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "stored Item money COMMIT outcome is unknown; relog required".to_string(), + }); + if let Err(error) = command_tx.try_send(kick) { + let kick = error.into_inner(); + let command_tx = command_tx.clone(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); } +} - fn register_test_creature_like_cpp(session: &mut WorldSession, creature: CreatureAI) { - if session.map_manager.is_none() { - session.set_map_manager(Arc::new(RwLock::new(crate::map_manager::MapManager::new()))); +fn spawn_loot_claim_persistence_worker_like_cpp( + persistence: F, + claim: Option, + durable_item_completion: Option<( + DurableItemLootPersistenceGuardLikeCpp, + DurableItemLootCompletionLikeCpp, + )>, +) -> Result< + tokio::task::JoinHandle>>, + LootClaimCommitError, +> +where + F: std::future::Future> + Send + 'static, + E: Send + 'static, +{ + let persistence_guard = claim + .as_ref() + .map(LootClaimLease::begin_persistence_guard_like_cpp) + .transpose()?; + drop(claim); + Ok(tokio::spawn(async move { + let mut durable_item_completion = durable_item_completion; + persistence + .await + .map_err(LootClaimPersistenceWorkerError::Persistence)?; + if let Some(mut guard) = persistence_guard { + let (_, committed_snapshot) = guard + .commit_with_snapshot_like_cpp() + .map_err(LootClaimPersistenceWorkerError::Claim)?; + if let (Some(snapshot), Some((_, completion))) = + (committed_snapshot, durable_item_completion.as_ref()) + && let Some(fanout) = completion.item_fanout.as_ref() + { + // Publish the serialization cut before exposing the durable + // completion to the session. Sampling the authority later can + // include an opener that already saw the consumed slot. + let _ = fanout.committed_snapshot.set(snapshot); + } + } + if let Some((guard, completion)) = durable_item_completion.as_mut() { + guard.mark_committed_like_cpp(completion.clone()); } + Ok(()) + })) +} - let create_data = CreatureCreateData { - guid: creature.guid, - entry: creature.entry, - display_id: creature.display_id, - native_display_id: creature.display_id, - display_scale: 1.0, - native_x_display_scale: 1.0, - bounding_radius: 0.389, - combat_reach: 1.5, - health: i64::from(creature.hp.max(1)), - max_health: i64::from(creature.max_hp.max(1)), - level: creature.level, - faction_template: creature.faction as i32, - npc_flags: u64::from(creature.npc_flags), - unit_flags: creature.unit_flags, - unit_flags2: 0, - unit_flags3: 0, - aura_state: crate::map_manager::WorldCreature::health_aura_state_like_cpp( - u64::from(creature.hp.max(1)), - u64::from(creature.max_hp.max(1)), - true, - ), - damage_school: wow_constants::spell::SpellSchools::Normal as u8, - scale: 1.0, - unit_class: 1, - display_power: 1, - power: [0; 10], - max_power: [0; 10], - base_mana: 0, - virtual_items: [(0, 0, 0); 3], - base_attack_time: 2000, - ranged_attack_time: 0, - movement_flags: 0, - vehicle_id: 0, - play_hover_anim: false, - hover_height: 1.0, - mount_display_id: 0, - stand_state: 0, - vis_flags: 0, - anim_tier: 0, - emote_state: 0, - sheathe_state: wow_constants::unit::SheathState::Melee as u8, - pvp_flags: 0, - current_area_id: 0, - speed_walk_rate: 1.0, - speed_run_rate: 1.14286, - ai_anim_kit_id: 0, - movement_anim_kit_id: 0, - melee_anim_kit_id: 0, - }; - let guid = creature.guid; - let is_alive = creature.is_alive; - session.register_world_creature( - session.player_map_id_like_cpp(), - creature.current_pos, - create_data, - creature.min_dmg, - creature.max_dmg, - creature.aggro_radius, - creature.loot_id, - 0, - creature.gold_min, - creature.gold_max, - creature.boss_id, - creature.dungeon_encounter_id, - 0, - 0, +/// Outcome-aware SQL variant for consume-and-grant item transactions. A +/// transport failure returned by COMMIT cannot be treated as rollback: the +/// old object allocation is quarantined permanently and the player is kicked +/// to reload whichever durable state MySQL ultimately kept. +fn spawn_sql_loot_claim_persistence_worker_like_cpp( + persistence: F, + claim: Option, + durable_item_completion: Option<( + DurableItemLootPersistenceGuardLikeCpp, + DurableItemLootCompletionLikeCpp, + )>, + command_tx: flume::Sender, +) -> Result< + tokio::task::JoinHandle>>, + LootClaimCommitError, +> +where + F: std::future::Future> + Send + 'static, +{ + let mut persistence_guard = claim + .as_ref() + .map(LootClaimLease::begin_persistence_guard_like_cpp) + .transpose()?; + drop(claim); + Ok(tokio::spawn(async move { + let mut durable_item_completion = durable_item_completion; + match persistence.await { + Ok(()) => {} + Err(error @ SqlTransactionCommitError::DefinitelyRolledBack(_)) => { + return Err(LootClaimPersistenceWorkerError::Persistence(error)); + } + Err(error @ SqlTransactionCommitError::CommitOutcomeUnknown(_)) => { + if let Some(guard) = persistence_guard.as_mut() { + let _ = guard.quarantine_commit_unknown_like_cpp(); + } + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "loot item COMMIT outcome is unknown; relog required".to_string(), + }); + if let Err(send_error) = command_tx.try_send(kick) { + let kick = send_error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); + } + return Err(LootClaimPersistenceWorkerError::Persistence(error)); + } + } + if let Some(mut guard) = persistence_guard { + let (_, committed_snapshot) = guard + .commit_with_snapshot_like_cpp() + .map_err(LootClaimPersistenceWorkerError::Claim)?; + if let (Some(snapshot), Some((_, completion))) = + (committed_snapshot, durable_item_completion.as_ref()) + && let Some(fanout) = completion.item_fanout.as_ref() + { + let _ = fanout.committed_snapshot.set(snapshot); + } + } + if let Some((guard, completion)) = durable_item_completion.as_mut() { + guard.mark_committed_like_cpp(completion.clone()); + } + Ok(()) + })) +} + +#[cfg(test)] +mod tests { + use super::{ + CreatureLootReleaseCommandQueueOutcomeLikeCpp, GAMEOBJECT_TYPE_AREADAMAGE, + GAMEOBJECT_TYPE_BINDER, GAMEOBJECT_TYPE_CHAIR, GAMEOBJECT_TYPE_DOOR, + GAMEOBJECT_TYPE_GUILD_BANK, GAMEOBJECT_TYPE_QUESTGIVER, INVENTORY_SLOT_BAG_0, + INVENTORY_SLOT_ITEM_START, ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, + ItemTemplateAddonLootMetadataLikeCpp, LOCK_KEY_SKILL_LIKE_CPP, LOCK_KEY_SPELL_LIKE_CPP, + LOOT_METHOD_GROUP_LIKE_CPP, LOOT_METHOD_MASTER_LIKE_CPP, LOOT_METHOD_ROUND_ROBIN_LIKE_CPP, + LOOT_MODE_DEFAULT_LIKE_CPP, LOOT_MODE_JUNK_FISH_LIKE_CPP, + LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP, + LootItemClaimCommitContextLikeCpp, LootStoreRandomProperties, + ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, ROLL_FLAG_TYPE_NEED_LIKE_CPP, + ROLL_VOTE_GREED_LIKE_CPP, ROLL_VOTE_NEED_LIKE_CPP, ROLL_VOTE_NOT_EMITTED_YET_LIKE_CPP, + ROLL_VOTE_NOT_VALID_LIKE_CPP, ROLL_VOTE_PASS_LIKE_CPP, RepresentedLootPlayerContext, + SPELL_EFFECT_OPEN_LOCK_LIKE_CPP, StoredItemMoneyCommitReconciliationLikeCpp, + StoredItemMoneyDbOutcomeLikeCpp, SyncChestGameobjectStateAndRefreshLikeCppCommand, + SyncGatheringNodeGameobjectStateAndRefreshLikeCppCommand, + SyncGooberGameobjectStateAndRefreshLikeCppCommand, + assign_represented_personal_loot_items_like_cpp, + classify_stored_item_money_commit_reconciliation_like_cpp, + creature_loot_is_allowed_to_player_like_cpp, direct_item_count_after_loot_release_like_cpp, + generated_creature_loot_item_to_entry_like_cpp, + generated_shared_gameobject_loot_item_to_entry_like_cpp, loot_is_looted_like_cpp, + loot_item_context, loot_store_data_can_stack_with_item, loot_type_for_client_like_cpp, + looted_corpse_decay_secs_like_cpp, mark_loot_allowed_for_player_like_cpp, + mark_loot_item_looted_for_player_like_cpp, + prepare_represented_shared_loot_generation_like_cpp, + queue_creature_loot_release_command_reliably_like_cpp, + represented_gameobject_display_box_contains_like_cpp, + represented_gameobject_interaction_distance_like_cpp, + represented_loot_object_guid_like_cpp, represented_loot_response_items_like_cpp, + select_weighted_random_enchantment_like_cpp, start_loot_roll_packet_like_cpp, + stored_item_money_zero_without_source_outcome_like_cpp, + }; + use crate::conditions::QUEST_STATUS_REWARDED_LIKE_CPP; + use crate::session::{ + DurableItemLootCompletionLikeCpp, LootMoneyViewerFanoutLikeCpp, + RepresentedGameObjectSpellCaster, RepresentedGameObjectUseEffect, + RepresentedLootRollCriteriaEvent, SessionState, loot_money_durable_outcome_like_cpp, + }; + use rand::{Rng, SeedableRng, rngs::StdRng}; + use std::time::{Duration, Instant}; + use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, Barrier, Mutex, RwLock, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + }, + }; + use wow_ai::CreatureAI; + use wow_constants::{ + InventoryResult, InventoryType, ItemBondingType, ItemClass, ItemContext, ItemFieldFlags, + ItemFlags2, ItemQuality, TypeId, TypeMask, UnitDynFlags, + }; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; + use wow_data::quest::{ + QUEST_REWARD_REPUTATIONS_COUNT, QuestObjective, QuestStore, QuestTemplate, + }; + use wow_data::{ + AreaTableEntry, AreaTableStore, ChrSpecializationEntry, ChrSpecializationStore, + ItemDisenchantLootEntry, ItemDisenchantLootStore, ItemRandomEnchantmentTemplateEntry, + ItemRandomEnchantmentTemplateStore, ItemRandomPropertiesEntry, ItemRandomPropertiesStore, + ItemRandomPropertyTemplateEntry, ItemRandomSuffixEntry, ItemRandomSuffixStore, ItemRecord, + ItemSparseTemplateEntry, ItemStatsStore, ItemStore, RandPropPointsEntry, + RandPropPointsStore, SpellEffectInfo, SpellInfo, SpellMiscEntry, SpellMiscStore, + SpellRangeEntry, SpellRangeStore, SpellStore, + }; + use wow_database::{CharStatements, DatabaseError, SqlTransactionCommitError, StatementDef}; + use wow_entities::{ + AccessorObjectKind, CORPSE_DYNFLAG_LOOTABLE, Corpse, CorpseType, Creature, + CreatureOwnedLoot, GAMEOBJECT_TYPE_CHEST, GAMEOBJECT_TYPE_FISHING_HOLE, + GAMEOBJECT_TYPE_FISHING_NODE, GAMEOBJECT_TYPE_GATHERING_NODE, GAMEOBJECT_TYPE_GOOBER, + GO_DYNFLAG_LO_NO_INTERACT, GameObject, GameObjectLootSource, GameObjectOwnedLoot, + GatheringNodeUseSource, GoState, Item, ItemCreateInfo, LootState, MAX_ITEM_SPELLS, + MAX_MONEY_AMOUNT, ObjectChangedFields, Player, WorldObject, + }; + use wow_loot::{ + GeneratedLootItem, LOOT_SLOT_TYPE_OWNER_LIKE_CPP, LootClaimPayload, + LootConditionRowLikeCpp, LootStore, LootStoreItem, LootStoreItemContext, LootStoreKind, + LootStores, LootTemplateRow, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + }; + use wow_network::{ + ApplyLootMoneyLikeCppCommand, GroupInfo, GroupRegistry, KickLikeCppCommand, + LootDropRatesLikeCpp, LootRollCommandIdentityLikeCpp, LootRollVoteCommand, + MasterLootGiveResult, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, SessionCommand, + }; + use wow_packet::packets::loot::{ + CreatureLoot, LOOT_ERROR_MASTER_OTHER_LIKE_CPP, LOOT_ERROR_MASTER_UNIQUE_ITEM_LIKE_CPP, + LOOT_ERROR_NO_LOOT_LIKE_CPP, LOOT_ERROR_PLAYER_NOT_FOUND_LIKE_CPP, + LOOT_ERROR_TOO_FAR_LIKE_CPP, LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, + LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, LOOT_TYPE_CHEST_LIKE_CPP, + LOOT_TYPE_CORPSE_LIKE_CPP, LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP, + LOOT_TYPE_DISENCHANTING_LIKE_CPP, LOOT_TYPE_FISHING_JUNK_LIKE_CPP, + LOOT_TYPE_FISHING_LIKE_CPP, LOOT_TYPE_FISHINGHOLE_LIKE_CPP, + LOOT_TYPE_GATHERING_NODE_LIKE_CPP, LOOT_TYPE_INSIGNIA_LIKE_CPP, LOOT_TYPE_ITEM_LIKE_CPP, + LOOT_TYPE_MILLING_LIKE_CPP, LOOT_TYPE_NONE_LIKE_CPP, LOOT_TYPE_PICKPOCKETING_LIKE_CPP, + LOOT_TYPE_PROSPECTING_LIKE_CPP, LOOT_TYPE_SKINNING_LIKE_CPP, LootEntry, LootEntryFlags, + LootResponse, LootRoll, MasterLootItem, SetLootSpecialization, + }; + use wow_packet::packets::update::{ + CreatureCreateData, ObjectDataValuesUpdate, UnitDataValuesDeltaUpdate, + }; + use wow_packet::{ServerPacket, WorldPacket}; + + use crate::session::{ + AuraApplication, InventoryItem, SPELL_AURA_INTERRUPT_FLAG_LOOTING_LIKE_CPP, SpellCastState, + WorldSession, + }; + + fn make_session_with_send_capacity( + capacity: usize, + ) -> (WorldSession, flume::Receiver>) { + let (_pkt_tx, pkt_rx) = flume::bounded::(1); + let (send_tx, send_rx) = flume::bounded::>(capacity); + let mut session = WorldSession::new( + 1, + "TestAccount".into(), 0, - -1, + 2, + 9, + 54261, + vec![0u8; 40], + "esES".into(), + pkt_rx, + send_tx, ); - if !is_alive { - let _ = session.mutate_world_creature(guid, |world_creature| { - world_creature.creature.mark_ai_dead(0); - }); - } + session.set_loot_money_persistence_test_result_like_cpp(true); + (session, send_rx) } - fn insert_allowed_coin_loot_like_cpp( - session: &mut WorldSession, - owner_guid: ObjectGuid, - player_guid: ObjectGuid, - coins: u32, - ) { - session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(owner_guid), - coins, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, - }, - ); + fn make_session_with_send() -> (WorldSession, flume::Receiver>) { + make_session_with_send_capacity(1) } - fn tap_test_creature_like_cpp( - session: &mut WorldSession, - creature_guid: ObjectGuid, - player_guid: ObjectGuid, - ) { - let _ = session.mutate_world_creature(creature_guid, |world_creature| { - world_creature - .creature - .set_tapped_by_player(player_guid, &[]); - }); + fn make_session() -> WorldSession { + make_session_with_send().0 } - fn loot_response_failure_reason(sent: &[u8]) -> u8 { - loot_response_failure_reason_and_threshold(sent).0 + #[test] + fn stored_item_money_commit_unknown_requires_joint_balance_and_source_evidence_like_cpp() { + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before: 100, + after: 107, + applied_delta: 7, + notified_amount: 7, + }; + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 100, Some(7)), + StoredItemMoneyCommitReconciliationLikeCpp::RolledBack + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 107, None), + StoredItemMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 100, None), + StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate, + "a missing source alone cannot attribute a later consumer's commit to this attempt" + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp(outcome, 107, Some(7)), + StoredItemMoneyCommitReconciliationLikeCpp::Indeterminate + ); } - fn loot_response_threshold(sent: &[u8]) -> u8 { - loot_response_failure_reason_and_threshold(sent).1 + #[test] + fn stored_item_money_cap_noop_still_reconciles_source_consumption_like_cpp() { + let outcome = StoredItemMoneyDbOutcomeLikeCpp { + before: MAX_MONEY_AMOUNT - 1, + after: MAX_MONEY_AMOUNT - 1, + applied_delta: 0, + notified_amount: 2, + }; + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + MAX_MONEY_AMOUNT - 1, + None, + ), + StoredItemMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + classify_stored_item_money_commit_reconciliation_like_cpp( + outcome, + MAX_MONEY_AMOUNT - 1, + Some(2), + ), + StoredItemMoneyCommitReconciliationLikeCpp::RolledBack + ); } - fn loot_response_failure_reason_and_threshold(sent: &[u8]) -> (u8, u8) { - let mut pkt = WorldPacket::from_bytes(&sent[2..]); - let _owner = pkt.read_packed_guid().unwrap(); - let _loot_obj = pkt.read_packed_guid().unwrap(); - let failure_reason = pkt.read_uint8().unwrap(); - let _acquire_reason = pkt.read_uint8().unwrap(); - let _loot_method = pkt.read_uint8().unwrap(); - let threshold = pkt.read_uint8().unwrap(); - (failure_reason, threshold) + #[test] + fn stored_item_money_zero_without_db_source_is_success_but_positive_is_consumed() { + let zero = stored_item_money_zero_without_source_outcome_like_cpp(41, 0).unwrap(); + assert_eq!(zero.before, 41); + assert_eq!(zero.after, 41); + assert_eq!(zero.applied_delta, 0); + assert_eq!(zero.notified_amount, 0); + assert!(stored_item_money_zero_without_source_outcome_like_cpp(41, 1).is_none()); } - fn test_creature_guid(counter: i64) -> ObjectGuid { - ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, counter) - } + #[test] + fn stored_item_money_completion_applies_db_delta_to_divergent_runtime_base_like_cpp() { + let (db_after, durable_delta) = loot_money_durable_outcome_like_cpp(100, 7); + let runtime_before = 500; + let runtime_after = runtime_before + durable_delta; - fn test_corpse_guid(counter: i64) -> ObjectGuid { - ObjectGuid::create_world_object(HighGuid::Corpse, 0, 1, 0, 0, 1, counter) + assert_eq!(db_after, 107); + assert_eq!(runtime_after, 507); + assert_ne!(runtime_after, db_after); } - fn broadcast_info(guid: ObjectGuid, send_tx: flume::Sender>) -> PlayerBroadcastInfo { - let (command_tx, _command_rx) = flume::bounded(1); - PlayerBroadcastInfo { - map_id: 0, - instance_id: 0, - position: Position::ZERO, - combat_reach: 0.0, - liquid_status: 0, - is_in_world: true, - send_tx, - command_tx, - active_loot_rolls: Vec::new(), - pass_on_group_loot: false, - enchanting_skill: 0, - is_alive: true, - current_health: 100, - max_health: 100, - power_type: 0, - current_power: 0, - max_power: 0, - is_pvp: false, - is_ffa_pvp: false, - is_ghost: false, - is_afk: false, - is_dnd: false, - auto_reply_msg_like_cpp: String::new(), - in_vehicle: false, - has_vehicle_kit_like_cpp: false, - party_member_vehicle_seat: 0, - zone_id: 0, - spec_id: 0, - unit_flags: 0, - unit_flags2: 0, - unit_state: 0, - is_game_master: false, - dungeon_difficulty_id: 1, - is_contested_pvp: false, - active_expansion: 2, - pending_quest_sharing: None, - known_spells: Vec::new(), - active_quest_statuses: Default::default(), - active_quest_objective_counts: Default::default(), - rewarded_quests: Default::default(), - completed_achievements: Default::default(), - daily_quests_completed: Default::default(), - df_quests: Default::default(), - faction_template_id: 0, - reputation_standings: Vec::new(), - reputation_state_flags: Vec::new(), - forced_reputation_ranks: Vec::new(), - forced_reputation_faction_ids: Vec::new(), - inventory_item_counts: Default::default(), - party_member_party_type: [0; 2], - party_member_phase_states: Default::default(), - party_member_auras: Vec::new(), - party_member_pet_stats: None, - player_name: format!("Player{}", guid.counter()), - account_id: guid.counter() as u32, - recruiter_id: 0, - race: 1, - class: 1, - sex: 0, - level: 1, - gray_level: 0, - display_id: 49, - visible_items: [(0, 0, 0); 19], - lifetime_honorable_kills: 0, - this_week_contribution: 0, - yesterday_contribution: 0, - today_honorable_kills: 0, - yesterday_honorable_kills: 0, - lifetime_max_rank: 0, - honor_level: 0, + #[test] + fn item_instance_guid_allocator_is_shared_across_concurrent_loot_sessions_like_cpp() { + const WORKERS: usize = 8; + const GUIDS_PER_WORKER: usize = 128; + const FIRST_GUID: i64 = 40_000; + + let generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, FIRST_GUID)); + let start = Arc::new(Barrier::new(WORKERS)); + let handles = (0..WORKERS) + .map(|_| { + let generator = Arc::clone(&generator); + let start = Arc::clone(&start); + std::thread::spawn(move || { + let mut session = make_session(); + session.set_realm_id(7); + session.set_item_guid_generator_like_cpp(generator); + start.wait(); + session + .allocate_item_instance_guids_like_cpp(GUIDS_PER_WORKER) + .expect("shared item allocator must be installed") + }) + }) + .collect::>(); + + let mut allocated = handles + .into_iter() + .flat_map(|handle| handle.join().expect("allocation worker must finish")) + .collect::>(); + allocated.sort_unstable_by_key(|(db_guid, _)| *db_guid); + + assert_eq!(allocated.len(), WORKERS * GUIDS_PER_WORKER); + for (offset, (db_guid, object_guid)) in allocated.iter().enumerate() { + let expected = FIRST_GUID as u64 + offset as u64; + assert_eq!(*db_guid, expected); + assert!(object_guid.is_item()); + assert_eq!(object_guid.counter() as u64, expected); } + assert_eq!( + generator.next_after_max_used(), + FIRST_GUID + (WORKERS * GUIDS_PER_WORKER) as i64 + ); } - fn loot_condition( - condition_type_or_reference: i32, - value1: u32, - value2: u32, - value3: u32, - ) -> LootConditionRowLikeCpp { - LootConditionRowLikeCpp { - else_group: 0, - condition_type_or_reference, - condition_target: 0, - value1, - value2, - value3, - string_value1: String::new(), - negative: false, - script_name: String::new(), - } + #[test] + fn item_instance_guid_allocator_fails_closed_and_never_reuses_failed_grant_like_cpp() { + let mut session = make_session(); + assert_eq!(session.allocate_item_instance_guids_like_cpp(1), None); + + let generator = Arc::new(ObjectGuidGenerator::new(HighGuid::Item, 91_000)); + session.set_item_guid_generator_like_cpp(Arc::clone(&generator)); + + // C++ consumes a GUID when Item::CreateItem runs. A later storage or + // transaction failure may leave a gap, but must never make that GUID + // available to a competing durable grant. + let abandoned_after_persistence_failure = session + .allocate_item_instance_guids_like_cpp(1) + .expect("allocator must be installed"); + assert_eq!(abandoned_after_persistence_failure[0].0, 91_000); + drop(abandoned_after_persistence_failure); + + let next_grant = session + .allocate_item_instance_guids_like_cpp(1) + .expect("allocator must remain installed"); + assert_eq!(next_grant[0].0, 91_001); + assert_eq!(generator.next_after_max_used(), 91_002); } - fn test_quest_template(id: u32) -> QuestTemplate { - QuestTemplate { - id, - quest_type: 0, - quest_level: 1, - quest_max_scaling_level: 0, - quest_package_id: 0, - min_level: 1, - quest_sort_id: 0, - quest_info_id: 0, - suggested_group_num: 0, - reward_next_quest: 0, - reward_xp_difficulty: 0, - reward_xp_multiplier: 1.0, - reward_money_difficulty: 0, - reward_money_multiplier: 1.0, - reward_bonus_money: 0, - reward_display_spell: [0; 3], - reward_spell: 0, - reward_honor: 0, - reward_title_id: 0, - reward_skill_line_id: 0, - reward_skill_points: 0, - reward_mail_template_id: 0, - reward_mail_delay_secs: 0, - reward_mail_sender_entry: 0, - reward_faction_ids: [0; QUEST_REWARD_REPUTATIONS_COUNT], - reward_faction_values: [0; QUEST_REWARD_REPUTATIONS_COUNT], - reward_faction_overrides: [0; QUEST_REWARD_REPUTATIONS_COUNT], - reward_faction_cap_in: [0; QUEST_REWARD_REPUTATIONS_COUNT], - reward_faction_flags: 0, - source_item_id: 0, - source_item_count: 0, - source_spell_id: 0, - limit_time_secs: 0, - expansion: 0, - flags: 0, - flags_ex: 0, - flags_ex2: 0, - special_flags: 0, - event_id_for_quest: 0, - reward_items: [0; 4], - reward_amounts: [0; 4], - reward_currencies: [0; 4], - reward_currency_amounts: [0; 4], - item_drop: [0; 4], - item_drop_quantity: [0; 4], - log_title: String::new(), - log_description: String::new(), - quest_description: String::new(), - area_description: String::new(), - quest_completion_log: String::new(), - objectives: Vec::new(), - allowable_races: 0, - allowable_classes: 0, - max_level: 0, - prev_quest_id: 0, - next_quest_id: 0, - exclusive_group: 0, - breadcrumb_for_quest_id: 0, - dependent_previous_quests: Vec::new(), - dependent_breadcrumb_quests: Vec::new(), - required_min_rep_faction: 0, - required_min_rep_value: 0, - required_max_rep_faction: 0, - required_max_rep_value: 0, - required_skill_id: 0, - required_skill_points: 0, - reward_choice_items: [(0, 0); 6], - reward_choice_item_types: [0; 6], + fn canonical_world_object(guid: ObjectGuid, map_id: u32, position: Position) -> WorldObject { + let (type_id, type_mask) = if guid.is_game_object() { + (TypeId::GameObject, TypeMask::GAME_OBJECT) + } else { + (TypeId::Unit, TypeMask::UNIT) + }; + let mut object = WorldObject::new(false, type_id, type_mask); + object.object_mut().create(guid); + object.set_map(map_id, 0).unwrap(); + object.relocate(position); + object.object_mut().add_to_world(); + object + } + + fn attach_canonical_map_object( + session: &mut WorldSession, + kind: AccessorObjectKind, + object: WorldObject, + ) { + let map_id = object.map_id(); + let instance_id = object.instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .add_to_map_like_cpp(kind, object) + .unwrap(); } + session.set_canonical_map_manager(manager); } - #[test] - fn represented_personal_loot_remote_context_uses_registry_fields_like_cpp() { - let (session, _) = make_session_with_send_capacity(1); - let remote_context = RepresentedLootPlayerContext { - race: 1, - class: 1, - gender: 0, - level: 80, - known_spells: Vec::new(), - active_quest_statuses: HashMap::new(), - active_quest_objective_counts: HashMap::new(), - rewarded_quests: HashSet::new(), - inventory_item_counts: HashMap::new(), - is_current: false, + fn attach_loot_guid_allocator_for_owner(session: &mut WorldSession, owner_guid: ObjectGuid) { + let kind = if owner_guid.is_game_object() { + AccessorObjectKind::GameObject + } else { + AccessorObjectKind::Creature }; + attach_canonical_map_object( + session, + kind, + canonical_world_object(owner_guid, u32::from(owner_guid.map_id()), Position::ZERO), + ); + } + #[test] + fn map_owned_loot_guid_sequence_is_shared_across_owner_kinds_like_cpp() { + let mut session = make_session(); + let map_id = 571; + let realm_id = 7; + session.set_realm_id(realm_id); + let shared_owner_counter = 19_700; + let creature_owner = ObjectGuid::create_world_object( + HighGuid::Creature, + 0, + 11, + map_id, + 17, + 101, + shared_owner_counter, + ); + let gameobject_owner = ObjectGuid::create_world_object( + HighGuid::GameObject, + 0, + 12, + map_id, + 18, + 202, + shared_owner_counter, + ); + attach_loot_guid_allocator_for_owner(&mut session, creature_owner); + + let creature_loot = session + .next_canonical_loot_object_guid_like_cpp(creature_owner) + .expect("the creature owner map should allocate a LootObject"); + let gameobject_loot = session + .next_canonical_loot_object_guid_like_cpp(gameobject_owner) + .expect("the gameobject owner map should share the LootObject sequence"); + + assert_ne!(creature_loot, gameobject_loot); + assert_eq!(creature_loot.counter(), 1); + assert_eq!(gameobject_loot.counter(), 2); + for loot_guid in [creature_loot, gameobject_loot] { + assert_eq!(loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(loot_guid.sub_type(), 0); + assert_eq!(loot_guid.realm_id(), realm_id); + assert_eq!(loot_guid.map_id(), map_id); + assert_eq!(loot_guid.server_id(), 0); + assert_eq!(loot_guid.entry(), 0); + } + + let manager = session.canonical_map_manager.as_ref().unwrap(); + let mut manager = manager.lock().unwrap(); assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(6, 469, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(15, 1, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(16, 1, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(20, 0, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(27, 70, 3, 0), - &remote_context, - ), - Some(true) + manager + .find_map_mut(u32::from(map_id), 0) + .unwrap() + .map_mut() + .get_max_low_guid_like_cpp(HighGuid::LootObject) + .unwrap(), + 3 ); } #[test] - fn represented_personal_loot_remote_quest_and_spell_conditions_use_registry_like_cpp() { - let (session, _) = make_session_with_send_capacity(1); - let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); - active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP); - let mut rewarded_quests = HashSet::new(); - rewarded_quests.insert(300); - let remote_context = RepresentedLootPlayerContext { - race: 1, - class: 1, - gender: 0, - level: 80, - known_spells: vec![12_345], - active_quest_statuses, - active_quest_objective_counts: HashMap::new(), - rewarded_quests, - inventory_item_counts: HashMap::new(), - is_current: false, - }; + fn loot_guid_allocator_without_canonical_map_fails_without_advancing_like_cpp() { + let mut session = make_session(); + let owner_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 9, 571, 7, 303, 19_701); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(9, 100, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(28, 200, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(8, 300, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(14, 400, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - remote_context.quest_status(300), - QUEST_STATUS_REWARDED_LIKE_CPP - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(14, 300, 0, 0), - &remote_context, - ), - Some(false), - "C++ Player::GetQuestStatus returns REWARDED before QUEST_STATUS_NONE" - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(25, 12_345, 0, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(47, 100, 0x08, 0), - &remote_context, - ), - Some(true) + assert!( + session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .is_none() ); + + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + let first_allocated = session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .expect("the first allocation after attaching the map should succeed"); + assert_eq!(first_allocated.counter(), 1); } #[test] - fn represented_personal_loot_remote_inventory_and_objective_conditions_use_registry_like_cpp() { - let (mut session, _) = make_session_with_send_capacity(1); - let mut quest_store = QuestStore::new(); - let mut quest = test_quest_template(100); - quest.objectives.push(QuestObjective { - id: 11, - quest_id: 100, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: 7001, - amount: 7, - flags: 0, - flags2: 0, - progress_bar_weight: 0.0, - description: String::new(), - }); - quest_store.quests.insert(100, quest); - session.set_quest_store(Arc::new(quest_store)); - let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); - let mut active_quest_objective_counts = HashMap::new(); - active_quest_objective_counts.insert(100, vec![5]); - let mut inventory_item_counts = HashMap::new(); - inventory_item_counts.insert(9001, 2); - let remote_context = RepresentedLootPlayerContext { - race: 1, - class: 1, - gender: 0, - level: 80, - known_spells: Vec::new(), - active_quest_statuses, - active_quest_objective_counts, - rewarded_quests: HashSet::new(), - inventory_item_counts, - is_current: false, - }; + fn loot_guid_allocator_refuses_different_owner_map_without_advancing_like_cpp() { + let mut session = make_session(); + let canonical_owner = + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 9, 571, 7, 404, 19_702); + let other_map_owner = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 9, 0, 7, 505, 19_703); + attach_loot_guid_allocator_for_owner(&mut session, canonical_owner); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(2, 9001, 2, 0), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(2, 9001, 3, 0), - &remote_context, - ), - Some(false) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(2, 9001, 2, 1), - &remote_context, - ), - None - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(48, 11, 0, 5), - &remote_context, - ), - Some(true) - ); - assert_eq!( - session.evaluate_creature_loot_condition_for_player_like_cpp_representable( - &loot_condition(48, 11, 0, 4), - &remote_context, - ), - Some(false) + assert!( + session + .next_canonical_loot_object_guid_like_cpp(other_map_owner) + .is_none() ); + + let first_allocated = session + .next_canonical_loot_object_guid_like_cpp(canonical_owner) + .expect("the rejected owner must not consume the canonical map sequence"); + assert_eq!(first_allocated.counter(), 1); } #[test] - fn represented_personal_loot_remote_has_quest_for_item_objective_like_cpp() { - let (mut session, _) = make_session_with_send_capacity(1); - install_limited_test_item_template(&mut session, 7001, 0); - let mut quest_store = QuestStore::new(); - let mut quest = test_quest_template(100); - quest.objectives.push(QuestObjective { - id: 1, - quest_id: 100, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: 7001, - amount: 3, - flags: 0, - flags2: 0, - progress_bar_weight: 0.0, - description: String::new(), - }); - quest_store.quests.insert(100, quest); - session.set_quest_store(Arc::new(quest_store)); - - let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); - let mut active_quest_objective_counts = HashMap::new(); - active_quest_objective_counts.insert(100, vec![2]); - let mut remote_context = RepresentedLootPlayerContext { - race: 1, - class: 1, - gender: 0, - level: 80, - known_spells: Vec::new(), - active_quest_statuses, - active_quest_objective_counts, - rewarded_quests: HashSet::new(), - inventory_item_counts: HashMap::new(), - is_current: false, - }; - - assert!(session.item_loot_quest_status_allows_for_player_like_cpp( - 7001, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - &remote_context, - )); + fn personal_loot_pools_receive_distinct_map_owned_guids_like_cpp() { + let mut session = make_session(); + session.set_realm_id(7); + let owner_guid = + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 9, 571, 7, 606, 19_704); + let first_player = ObjectGuid::create_player(1, 42); + let second_player = ObjectGuid::create_player(1, 77); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = session + .next_canonical_loot_object_guid_like_cpp(owner_guid) + .expect("the base personal pool should receive a map-owned LootObject"); + loot.allowed_looters = vec![second_player, first_player]; + + let (shared, personal) = session + .represented_loot_authority_pools_like_cpp(owner_guid, first_player, loot, true) + .expect("both personal pools should materialize"); + + assert!(shared.is_none()); + assert_eq!(personal.len(), 2); + let first_pool = personal.get(&first_player).unwrap(); + let second_pool = personal.get(&second_player).unwrap(); + assert_ne!(first_pool.loot_guid, second_pool.loot_guid); + assert_eq!(first_pool.loot_guid.counter(), 1); + assert_eq!(second_pool.loot_guid.counter(), 2); + for (player_guid, pool) in [(first_player, first_pool), (second_player, second_pool)] { + assert_eq!(pool.loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(pool.loot_guid.realm_id(), 7); + assert_eq!(pool.loot_guid.map_id(), 571); + assert_eq!(pool.loot_guid.server_id(), 0); + assert_eq!(pool.loot_guid.entry(), 0); + assert_eq!(pool.allowed_looters, vec![player_guid]); + assert_eq!(pool.items[0].allowed_looters, vec![player_guid]); + } + } - remote_context - .active_quest_objective_counts - .insert(100, vec![3]); - assert!(!session.item_loot_quest_status_allows_for_player_like_cpp( - 7001, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - &remote_context, - )); + fn attach_canonical_gameobject(session: &mut WorldSession, game_object: GameObject) { + let map_id = game_object.world().map_id(); + let instance_id = game_object.world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_game_object(game_object).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); } - #[test] - fn represented_personal_loot_remote_has_quest_for_item_drop_like_cpp() { - let (mut session, _) = make_session_with_send_capacity(1); - install_limited_test_item_template(&mut session, 7002, 0); - let mut quest_store = QuestStore::new(); - let mut quest = test_quest_template(200); - quest.item_drop[0] = 7002; - quest.item_drop_quantity[0] = 4; - quest_store.quests.insert(200, quest); - session.set_quest_store(Arc::new(quest_store)); + fn attach_canonical_creature(session: &mut WorldSession, creature: Creature) { + let map_id = creature.unit().world().map_id(); + let instance_id = creature.unit().world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_creature(creature).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } - let mut active_quest_statuses = HashMap::new(); - active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); - let mut inventory_item_counts = HashMap::new(); - inventory_item_counts.insert(7002, 3); - let mut remote_context = RepresentedLootPlayerContext { - race: 1, - class: 1, - gender: 0, - level: 80, - known_spells: Vec::new(), - active_quest_statuses, - active_quest_objective_counts: HashMap::new(), - rewarded_quests: HashSet::new(), - inventory_item_counts, - is_current: false, - }; + fn attach_canonical_corpse(session: &mut WorldSession, corpse: Corpse) { + let map_id = corpse.world().map_id(); + let instance_id = corpse.world().instance_id(); + let manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = manager.lock().unwrap(); + manager + .create_world_map(map_id, instance_id) + .map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_corpse(corpse).unwrap(), + ) + .unwrap(); + } + session.set_canonical_map_manager(manager); + } - assert!(session.item_loot_quest_status_allows_for_player_like_cpp( - 7002, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - &remote_context, - )); + fn make_canonical_corpse_for_session(session: &WorldSession, guid: ObjectGuid) -> Corpse { + let mut corpse = Corpse::new_at(CorpseType::ResurrectablePvp, 1_000); + corpse.world_mut().object_mut().create(guid); + corpse + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + corpse.world_mut().relocate(Position::ZERO); + corpse.world_mut().object_mut().add_to_world(); + corpse.set_corpse_dynamic_flag(CORPSE_DYNFLAG_LOOTABLE); + corpse.clear_corpse_data_changes(); + corpse + } - remote_context.inventory_item_counts.insert(7002, 4); - assert!(!session.item_loot_quest_status_allows_for_player_like_cpp( - 7002, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - &remote_context, - )); + fn canonical_corpse_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_corpse(guid).cloned() } - #[tokio::test] - async fn loot_item_added_progresses_incomplete_quest_item_objective_like_cpp() { - let (mut session, send_rx) = make_session_with_send_capacity(1); - let quest_id = 8_336; - let item_id = 20_482; - let mut quest = test_quest_template(quest_id); - quest.objectives.push(QuestObjective { - id: quest_id * 10, - quest_id, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: item_id as i32, - amount: 6, - flags: 0, - flags2: 0, - progress_bar_weight: 0.0, - description: String::new(), - }); - session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); - session.player_quests.insert( - quest_id, - crate::handlers::quest::PlayerQuestStatus { - quest_id, - status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, - explored: false, - accept_time_secs: 0, - end_time_secs: 0, - objective_counts: vec![0], - slot: 0, - }, - ); + fn make_canonical_creature_for_session(session: &WorldSession, guid: ObjectGuid) -> Creature { + let mut creature = Creature::new(false); + creature.unit_mut().world_mut().object_mut().create(guid); + creature + .unit_mut() + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + creature.unit_mut().world_mut().relocate(Position::ZERO); + creature.unit_mut().world_mut().object_mut().add_to_world(); + creature + } - assert!(session.item_loot_quest_status_allows_like_cpp( - item_id, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - )); + fn canonical_creature_snapshot(session: &WorldSession, guid: ObjectGuid) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_creature(guid).cloned() + } - let changed_quest_ids = session - .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 3) - .await; + fn make_canonical_gameobject_for_session( + session: &WorldSession, + guid: ObjectGuid, + go_type: u8, + ) -> GameObject { + let mut game_object = GameObject::new(); + game_object.world_mut().object_mut().create(guid); + game_object + .world_mut() + .set_map(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap(); + game_object.world_mut().relocate(Position::ZERO); + game_object.world_mut().object_mut().add_to_world(); + game_object.set_go_type(go_type); + game_object + } - assert_eq!(changed_quest_ids, vec![quest_id]); - assert_eq!( - session - .player_quests - .get(&quest_id) - .expect("quest progress should remain active") - .objective_counts, - vec![3] - ); - assert!( - send_rx.try_recv().is_err(), - "C++ UpdateQuestObjectiveProgress suppresses generic credit packets for ITEM objectives" - ); + fn canonical_gameobject_snapshot( + session: &WorldSession, + guid: ObjectGuid, + ) -> Option { + let manager = session.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + let map = manager.find_map(u32::from(session.player_map_id_like_cpp()), 0)?; + map.map().get_typed_game_object(guid).cloned() } - #[test] - fn banked_quest_item_recomputes_objective_and_reopens_quest_like_cpp() { - let (mut session, send_rx) = make_session_with_send_capacity(1); - session.set_player_guid(Some(ObjectGuid::create_player(1, 42))); - session.set_loaded_player_identity_like_cpp(571, 1, 1, 1, 0); - let quest_id = 8_338; - let item_id = 20_484; - let mut quest = test_quest_template(quest_id); - quest.objectives.push(QuestObjective { - id: quest_id * 10, - quest_id, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: item_id as i32, - amount: 3, - flags: 0, - flags2: 0, - progress_bar_weight: 0.0, - description: String::new(), - }); - session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); - session.player_quests.insert( - quest_id, - crate::handlers::quest::PlayerQuestStatus { - quest_id, - status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, - explored: false, - accept_time_secs: 0, - end_time_secs: 0, - objective_counts: vec![3], - slot: 0, - }, - ); + fn loot_item_packet(object: ObjectGuid, loot_list_id: u8) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_uint32(1); + pkt.write_packed_guid(&object); + pkt.write_uint8(loot_list_id); + pkt.write_bit(false); + pkt.flush_bits(); + pkt.reset_read(); + pkt + } - let planned = session.plan_bank_item_quest_persistence_like_cpp(item_id, 0, true, 0, 0); - assert_eq!(planned.len(), 1); - assert_eq!(planned[0].objective_counts, vec![0]); - assert_eq!( - planned[0].status, - crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP - ); - assert_eq!( - session.apply_quest_item_removed_like_cpp(item_id), - vec![quest_id] - ); - let status = session.player_quests.get(&quest_id).expect("active quest"); - assert_eq!( - status.status, - crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP - ); - assert_eq!(status.objective_counts, vec![0]); - let update = send_rx - .try_recv() - .expect("banking a quest item should update the quest-log slot"); - assert_eq!( - WorldPacket::from_bytes(&update).server_opcode(), - Some(wow_constants::ServerOpcodes::UpdateObject) - ); + fn loot_unit_packet(object: ObjectGuid) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_packed_guid(&object); + pkt.reset_read(); + pkt } - #[tokio::test] - async fn withdrawn_banked_item_restores_bound_objective_like_cpp() { - let (mut session, _send_rx) = make_session_with_send_capacity(4); - let quest_id = 8_339; - let item_id = 20_485; - let mut quest = test_quest_template(quest_id); - quest.objectives.push(QuestObjective { - id: quest_id * 10, - quest_id, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: item_id as i32, - amount: 1, - flags: 0, - flags2: 1, - progress_bar_weight: 0.0, - description: String::new(), - }); - session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); - session.player_quests.insert( - quest_id, - crate::handlers::quest::PlayerQuestStatus { - quest_id, - status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, - explored: false, - accept_time_secs: 0, - end_time_secs: 0, - objective_counts: vec![0], - slot: 0, + fn represented_loot_entry( + loot_list_id: u8, + item_id: u32, + player_guid: ObjectGuid, + ) -> LootEntry { + LootEntry { + loot_list_id, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + freeforall: false, + blocked: false, + counted: false, + under_threshold: false, + needs_quest: false, + }, + allowed_looters: vec![player_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + } + } + + fn install_active_item_loot_completion_fixture_like_cpp( + session: &mut WorldSession, + player_guid: ObjectGuid, + owner_guid: ObjectGuid, + coins: u32, + ) { + assert!(owner_guid.is_item()); + session.set_player_guid(Some(player_guid)); + session.set_active_loot_guid(owner_guid); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: owner_guid, + coins, + unlooted_count: 1, + loot_type: LOOT_TYPE_ITEM_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, }, ); + } - let planned = session.plan_bank_item_quest_persistence_like_cpp(item_id, 0, false, 0, 1); - assert_eq!(planned.len(), 1); - assert_eq!(planned[0].objective_counts, vec![1]); + #[test] + fn represented_loot_type_for_client_matches_cpp_aliases() { assert_eq!( - planned[0].status, - crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + loot_type_for_client_like_cpp(LOOT_TYPE_NONE_LIKE_CPP), + LOOT_TYPE_NONE_LIKE_CPP ); - let changed_quest_ids = session - .apply_quest_item_added_objective_progress_like_cpp(item_id, 0, 1) - .await; - - assert_eq!(changed_quest_ids, vec![quest_id]); - let status = session.player_quests.get(&quest_id).expect("active quest"); - assert_eq!(status.objective_counts, vec![1]); assert_eq!( - status.status, - crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_LIKE_CPP), + LOOT_TYPE_CORPSE_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_ITEM_LIKE_CPP), + LOOT_TYPE_ITEM_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_GATHERING_NODE_LIKE_CPP), + LOOT_TYPE_GATHERING_NODE_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_CHEST_LIKE_CPP), + LOOT_TYPE_CHEST_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP), + LOOT_TYPE_CORPSE_PERSONAL_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_PROSPECTING_LIKE_CPP), + LOOT_TYPE_DISENCHANTING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_MILLING_LIKE_CPP), + LOOT_TYPE_DISENCHANTING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_INSIGNIA_LIKE_CPP), + LOOT_TYPE_SKINNING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_FISHINGHOLE_LIKE_CPP), + LOOT_TYPE_FISHING_LIKE_CPP + ); + assert_eq!( + loot_type_for_client_like_cpp(LOOT_TYPE_FISHING_JUNK_LIKE_CPP), + LOOT_TYPE_FISHING_LIKE_CPP ); } #[tokio::test] - async fn loot_item_eligibility_does_not_treat_complete_quest_as_incomplete_like_cpp() { - let (mut session, _) = make_session_with_send_capacity(1); - let quest_id = 8_337; - let item_id = 20_483; - let mut quest = test_quest_template(quest_id); - quest.objectives.push(QuestObjective { - id: quest_id * 10, - quest_id, - obj_type: 1, - order: 0, - storage_index: 0, - object_id: item_id as i32, - amount: 1, - flags: 0, - flags2: 0, - progress_bar_weight: 0.0, - description: String::new(), - }); - session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); - session.player_quests.insert( - quest_id, - crate::handlers::quest::PlayerQuestStatus { - quest_id, - status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, - explored: false, - accept_time_secs: 0, - end_time_secs: 0, - objective_counts: vec![0], - slot: 0, + async fn represented_loot_response_acquire_reason_uses_cpp_loot_type_mapping() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + let owner_guid = test_creature_guid(19_096); + let loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + let entry = represented_loot_entry(0, 25, player_guid); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_PROSPECTING_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: vec![entry], + looted_by_player: false, }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); - assert!(!session.has_incomplete_quest_objective_for_item_like_cpp(item_id)); - assert!(!session.item_loot_quest_status_allows_like_cpp( - item_id, - true, - ItemTemplateAddonLootMetadataLikeCpp::default(), - )); - - let changed_quest_ids = session - .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 1) - .await; + let response = session + .represented_loot_response_for_owner_like_cpp(owner_guid, player_guid, false) + .await + .unwrap(); - assert!(changed_quest_ids.is_empty()); - assert_eq!( - session - .player_quests - .get(&quest_id) - .expect("complete quest should not progress as incomplete") - .objective_counts, - vec![0] - ); + assert_eq!(response.acquire_reason, LOOT_TYPE_DISENCHANTING_LIKE_CPP); } - fn install_master_loot_group( - session: &mut WorldSession, - master_guid: ObjectGuid, - candidate_guid: ObjectGuid, - ) { - let group_registry = Arc::new(GroupRegistry::default()); - let mut group = GroupInfo::new(master_guid); - group.add_member(candidate_guid); - group.loot_method = LOOT_METHOD_MASTER_LIKE_CPP; - group.master_looter_guid = master_guid; - let group_guid = group.group_guid; - group_registry.insert(group_guid, group); - session.group_guid = Some(group_guid); - session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); - } + #[test] + fn represented_start_loot_roll_carries_cpp_dungeon_encounter_id() { + let player_guid = ObjectGuid::create_player(1, 42); + let loot_obj = ObjectGuid::create_world_object(HighGuid::LootObject, 0, 1, 0, 0, 1, 900); + let entry = represented_loot_entry(0, 25, player_guid); - fn install_group_loot_group( - session: &mut WorldSession, - leader_guid: ObjectGuid, - candidate_guid: ObjectGuid, - ) { - let group_registry = Arc::new(GroupRegistry::default()); - let mut group = GroupInfo::new(leader_guid); - group.add_member(candidate_guid); - group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; - let group_guid = group.group_guid; - group_registry.insert(group_guid, group); - session.group_guid = Some(group_guid); - session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); - } + let packet = start_loot_roll_packet_like_cpp( + loot_obj, + 571, + LOOT_METHOD_GROUP_LIKE_CPP, + &entry, + ROLL_ALL_TYPE_NO_DISENCHANT_LIKE_CPP, + 615, + ); - fn install_limited_test_item_template(session: &mut WorldSession, entry: u32, max_count: i32) { - install_limited_test_item_template_with_flags2(session, entry, max_count, 0); + assert_eq!(packet.dungeon_encounter_id, 615); } - fn install_limited_test_item_template_with_flags2( - session: &mut WorldSession, - entry: u32, - max_count: i32, - flags2: u32, - ) { - session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { + #[test] + fn represented_loot_item_push_result_uses_realm_route_and_cpp_encounter_fields() { + let (mut session, instance_rx) = make_session_with_send(); + let (realm_tx, realm_rx) = flume::bounded(1); + session.install_realm_send_channel_for_test(realm_tx); + let player_guid = ObjectGuid::create_player(1, 42); + let item_guid = ObjectGuid::create_item(1, 700); + let entry = represented_loot_entry(0, 25, player_guid); + + session.send_loot_item_push_result( + player_guid, + item_guid, + &entry, + 0, + 0, + 0, + 1, + 1, + false, + 615, + ); + + assert!(instance_rx.try_recv().is_err()); + let sent = realm_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::ItemPushResult as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), player_guid); + assert_eq!(sent.read_uint8().unwrap(), u8::from(INVENTORY_SLOT_BAG_0)); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 1); + assert_eq!(sent.read_int32().unwrap(), 1); + assert_eq!(sent.read_int32().unwrap(), 615); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_uint32().unwrap(), 0); + assert_eq!(sent.read_int32().unwrap(), 0); + assert_eq!(sent.read_packed_guid().unwrap(), item_guid); + assert!(!sent.read_bit().unwrap()); + assert!(!sent.read_bit().unwrap()); + assert_eq!(sent.read_bits(3).unwrap(), 2); + assert!(!sent.read_bit().unwrap()); + assert!(sent.read_bit().unwrap()); + assert_eq!(sent.read_int32().unwrap(), 25); + } + + #[test] + fn creature_generated_loot_entry_uses_item_template_addon_follow_loot_rules_like_cpp() { + let generated = GeneratedLootItem { + item_id: 25, + count: 1, + loot_list_id: 7, + random_properties_id: -77, + random_properties_seed: 456, + context: ItemContext::DungeonNormal as u8, + store_item_context: LootStoreItemContext { + store_kind: LootStoreKind::Creature, + entry: 100, + item: LootStoreItem { + item_id: 25, + reference: 0, + chance: 100.0, + needs_quest: true, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }, + free_for_all: false, + follow_loot_rules: false, + needs_quest: true, + is_looted: false, + is_blocked: false, + is_under_threshold: false, + is_counted: false, + }; + + let default_entry = generated_creature_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp::default(), + ); + assert!(!default_entry.flags.follow_loot_rules); + assert_eq!(default_entry.loot_list_id, 7); + assert_eq!(default_entry.random_properties_id, -77); + assert_eq!(default_entry.random_properties_seed, 456); + assert_eq!(default_entry.item_context, ItemContext::DungeonNormal as u8); + + let follow_entry = generated_creature_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp { + flags_cu: ITEM_FLAGS_CU_FOLLOW_LOOT_RULES_LIKE_CPP, + quest_log_item_id: 0, + }, + ); + assert!(follow_entry.flags.follow_loot_rules); + assert!(follow_entry.flags.needs_quest); + } + + #[test] + fn represented_loot_response_items_use_cpp_ui_type_decision_tree() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 100); + + let mut rolling_entry = represented_loot_entry(0, 25, player_guid); + rolling_entry.flags.blocked = true; + + let mut won_entry = represented_loot_entry(1, 26, player_guid); + won_entry.roll_winner = player_guid; + + let mut hidden_entry = represented_loot_entry(2, 27, player_guid); + hidden_entry.roll_winner = other_guid; + + let mut allowed_entry = represented_loot_entry(3, 28, player_guid); + allowed_entry.flags.under_threshold = true; + + let loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: vec![rolling_entry, won_entry, hidden_entry, allowed_entry], + looted_by_player: false, + }; + + let items = represented_loot_response_items_like_cpp(&loot, player_guid); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].loot_list_id, 0); + assert_eq!(items[0].ui_type, LOOT_SLOT_TYPE_ROLL_ONGOING_LIKE_CPP); + assert_eq!(items[1].loot_list_id, 1); + assert_eq!(items[1].ui_type, LOOT_SLOT_TYPE_OWNER_LIKE_CPP); + assert_eq!(items[2].loot_list_id, 3); + assert_eq!(items[2].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + } + + #[test] + fn represented_ffa_loot_uses_player_ffa_items_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 101); + + let mut ffa_entry = represented_loot_entry(0, 25, player_guid); + ffa_entry.flags.freeforall = true; + ffa_entry.allowed_looters.clear(); + + let mut loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![ffa_entry], + looted_by_player: false, + }; + + mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); + mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); + assert_eq!(loot.unlooted_count, 2); + + let player_items = represented_loot_response_items_like_cpp(&loot, player_guid); + let other_items = represented_loot_response_items_like_cpp(&loot, other_guid); + assert_eq!(player_items.len(), 1); + assert_eq!(other_items.len(), 1); + assert_eq!(player_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + assert_eq!(other_items[0].ui_type, LOOT_SLOT_TYPE_ALLOW_LOOT_LIKE_CPP); + + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 1); + + let player_ffa = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == player_guid) + .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + .unwrap(); + let other_ffa = loot + .player_ffa_items + .iter() + .find(|(player, _)| *player == other_guid) + .and_then(|(_, items)| items.iter().find(|item| item.loot_list_id == 0)) + .unwrap(); + + assert!(player_ffa.is_looted); + assert!(!other_ffa.is_looted); + assert!(represented_loot_response_items_like_cpp(&loot, player_guid).is_empty()); + assert_eq!( + represented_loot_response_items_like_cpp(&loot, other_guid).len(), + 1 + ); + } + + #[test] + fn prospecting_and_milling_release_consume_at_most_five_source_items_like_cpp() { + assert_eq!( + direct_item_count_after_loot_release_like_cpp(20, Some(5)), + 15 + ); + assert_eq!(direct_item_count_after_loot_release_like_cpp(5, Some(5)), 0); + assert_eq!(direct_item_count_after_loot_release_like_cpp(3, Some(5)), 0); + assert_eq!(direct_item_count_after_loot_release_like_cpp(20, None), 0); + } + + #[test] + fn represented_unlooted_count_counts_shared_items_once_like_cpp() { + let player_guid = ObjectGuid::create_player(1, 42); + let other_guid = ObjectGuid::create_player(1, 77); + let loot_guid = ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, 102); + + let mut entry = represented_loot_entry(0, 25, player_guid); + entry.allowed_looters.clear(); + + let mut loot = CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![entry], + looted_by_player: false, + }; + + mark_loot_allowed_for_player_like_cpp(&mut loot, player_guid); + assert_eq!(loot.unlooted_count, 1); + assert!(loot.items[0].flags.counted); + + mark_loot_allowed_for_player_like_cpp(&mut loot, other_guid); + assert_eq!(loot.unlooted_count, 1); + + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 0); + mark_loot_item_looted_for_player_like_cpp(&mut loot, 0, player_guid); + assert_eq!(loot.unlooted_count, 0); + } + + #[test] + fn shared_gameobject_normal_item_with_two_looters_counts_once_like_cpp() { + let first = ObjectGuid::create_player(1, 42); + let second = ObjectGuid::create_player(1, 77); + let mut entry = represented_loot_entry(0, 25, first); + entry.allowed_looters = vec![first, second]; + let mut loot = CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(test_gameobject_guid(91_101)), + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: vec![entry], + looted_by_player: false, + }; + + prepare_represented_shared_loot_generation_like_cpp(&mut loot, &[first, second]); + + assert_eq!(loot.allowed_looters, vec![first, second]); + assert_eq!(loot.items[0].allowed_looters, vec![first, second]); + assert_eq!(loot.unlooted_count, 1); + assert!(loot.items[0].flags.counted); + } + + #[test] + fn shared_gameobject_item_evaluates_each_looter_after_roll_like_cpp() { + let first = ObjectGuid::create_player(1, 42); + let second = ObjectGuid::create_player(1, 77); + let store_item_context = LootStoreItemContext { + store_kind: LootStoreKind::Reference, + entry: 900, + item: LootStoreItem { + item_id: 25, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }; + let generated = GeneratedLootItem { + item_id: 25, + count: 1, + loot_list_id: 0, + random_properties_id: 0, + random_properties_seed: 0, + context: ItemContext::None as u8, + store_item_context, + free_for_all: false, + follow_loot_rules: true, + needs_quest: false, + is_looted: false, + is_blocked: false, + is_under_threshold: false, + is_counted: false, + }; + let mut evaluated = Vec::new(); + + let entry = generated_shared_gameobject_loot_item_to_entry_like_cpp( + generated, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &[first, second], + |context, looter| { + evaluated.push((context, looter)); + looter == second + }, + ); + + assert_eq!( + evaluated, + vec![(store_item_context, first), (store_item_context, second)] + ); + assert_eq!(entry.item_id, 25, "the rolled candidate remains present"); + assert_eq!(entry.allowed_looters, vec![second]); + } + + #[test] + fn represented_loot_removed_uses_players_looting_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let open_guid = ObjectGuid::create_player(1, 77); + let closed_guid = ObjectGuid::create_player(1, 88); + let stale_guid = ObjectGuid::create_player(1, 99); + let owner_guid = test_creature_guid(19_095); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (open_tx, open_rx) = flume::bounded::>(1); + let (closed_tx, closed_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); + player_registry.insert(closed_guid, broadcast_info(closed_guid, closed_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: loot_object, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid, open_guid, stale_guid], + allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: vec![player_guid, open_guid, closed_guid, stale_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + }, + ); + + session.represented_notify_loot_item_removed_like_cpp(owner_guid, 0); + + let sent = send_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRemoved as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), owner_guid); + assert_eq!(sent.read_packed_guid().unwrap(), loot_object); + assert_eq!(sent.read_uint8().unwrap(), 0); + + let sent = open_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRemoved as u16 + ); + assert!(closed_rx.try_recv().is_err()); + assert_eq!( + session.loot_table.get(&owner_guid).unwrap().players_looting, + vec![player_guid, open_guid] + ); + } + + #[test] + fn durable_item_fanout_uses_precommit_union_exact_commit_cut_like_cpp() { + let before = ObjectGuid::create_player(1, 41); + let during = ObjectGuid::create_player(1, 42); + let after = ObjectGuid::create_player(1, 43); + + let viewers = + super::durable_loot_item_fanout_viewers_like_cpp(&[before], &[before, during]); + + assert_eq!(viewers, HashSet::from([before, during])); + assert!( + !viewers.contains(&after), + "a later authority sample must never expand the exact commit fanout" + ); + } + + #[test] + fn represented_money_removed_erases_missing_players_looting_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let open_guid = ObjectGuid::create_player(1, 77); + let stale_guid = ObjectGuid::create_player(1, 99); + let owner_guid = test_creature_guid(19_096); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (open_tx, open_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(open_guid, broadcast_info(open_guid, open_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: loot_object, + coins: 7, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid, open_guid, stale_guid], + allowed_looters: vec![player_guid, open_guid, stale_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + + session.represented_notify_money_removed_like_cpp(owner_guid); + + let sent = send_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::CoinRemoved as u16 + ); + assert_eq!(sent.read_packed_guid().unwrap(), loot_object); + + let sent = open_rx.try_recv().unwrap(); + let mut sent = WorldPacket::from_bytes(&sent); + assert_eq!( + sent.read_uint16().unwrap(), + wow_constants::ServerOpcodes::CoinRemoved as u16 + ); + assert_eq!( + session.loot_table.get(&owner_guid).unwrap().players_looting, + vec![player_guid, open_guid] + ); + } + + fn loot_release_packet(object: ObjectGuid) -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_packed_guid(&object); + pkt.reset_read(); + pkt + } + + fn loot_money_packet() -> WorldPacket { + let mut pkt = WorldPacket::new_empty(); + pkt.write_bit(false); + pkt.flush_bits(); + pkt.reset_read(); + pkt + } + + fn recv_packet_with_opcode( + rx: &flume::Receiver>, + opcode: wow_constants::ServerOpcodes, + ) -> WorldPacket { + for _ in 0..8 { + let sent = rx.try_recv().unwrap(); + let mut packet = WorldPacket::from_bytes(&sent); + if packet.read_uint16().unwrap() == opcode as u16 { + return packet; + } + } + panic!("expected packet opcode {:?}", opcode); + } + + fn drain_server_opcodes_like_cpp(rx: &flume::Receiver>) -> Vec { + let mut opcodes = Vec::new(); + while let Ok(bytes) = rx.try_recv() { + let mut packet = WorldPacket::from_bytes(&bytes); + opcodes.push(packet.read_uint16().unwrap()); + } + opcodes + } + + fn test_creature(guid: ObjectGuid, is_alive: bool) -> CreatureAI { + let mut creature = CreatureAI::new( + guid, + 1, + Position::ZERO, + 100, + 1, + 1, + 2, + 0.0, + 1, + 35, + 0, + 0, + 0, + 0, + 0, + None, + 0, + ); + creature.is_alive = is_alive; + creature + } + + fn register_test_creature_like_cpp(session: &mut WorldSession, creature: CreatureAI) { + if session.map_manager.is_none() { + session.set_map_manager(Arc::new(RwLock::new(crate::map_manager::MapManager::new()))); + } + + let create_data = CreatureCreateData { + guid: creature.guid, + entry: creature.entry, + display_id: creature.display_id, + native_display_id: creature.display_id, + display_scale: 1.0, + native_x_display_scale: 1.0, + bounding_radius: 0.389, + combat_reach: 1.5, + health: i64::from(creature.hp.max(1)), + max_health: i64::from(creature.max_hp.max(1)), + level: creature.level, + faction_template: creature.faction as i32, + npc_flags: u64::from(creature.npc_flags), + unit_flags: creature.unit_flags, + unit_flags2: 0, + unit_flags3: 0, + aura_state: crate::map_manager::WorldCreature::health_aura_state_like_cpp( + u64::from(creature.hp.max(1)), + u64::from(creature.max_hp.max(1)), + true, + ), + damage_school: wow_constants::spell::SpellSchools::Normal as u8, + scale: 1.0, + unit_class: 1, + display_power: 1, + power: [0; 10], + max_power: [0; 10], + base_mana: 0, + virtual_items: [(0, 0, 0); 3], + base_attack_time: 2000, + ranged_attack_time: 0, + movement_flags: 0, + vehicle_id: 0, + play_hover_anim: false, + hover_height: 1.0, + mount_display_id: 0, + stand_state: 0, + vis_flags: 0, + anim_tier: 0, + emote_state: 0, + sheathe_state: wow_constants::unit::SheathState::Melee as u8, + pvp_flags: 0, + current_area_id: 0, + speed_walk_rate: 1.0, + speed_run_rate: 1.14286, + ai_anim_kit_id: 0, + movement_anim_kit_id: 0, + melee_anim_kit_id: 0, + }; + let guid = creature.guid; + let is_alive = creature.is_alive; + session.register_world_creature( + session.player_map_id_like_cpp(), + creature.current_pos, + create_data, + creature.min_dmg, + creature.max_dmg, + creature.aggro_radius, + creature.loot_id, + 0, + creature.gold_min, + creature.gold_max, + creature.boss_id, + creature.dungeon_encounter_id, + 0, + 0, + 0, + -1, + ); + if !is_alive { + let _ = session.mutate_world_creature(guid, |world_creature| { + world_creature.creature.mark_ai_dead(0); + }); + } + } + + fn insert_allowed_coin_loot_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + coins: u32, + ) { + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(owner_guid), + coins, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + if session + .represented_owned_loot_authority_like_cpp(owner_guid) + .is_some() + { + install_cached_test_creature_loot_authority_like_cpp(session, owner_guid, player_guid); + } + } + + /// Legacy packet tests construct the result of `Unit::Kill` directly. + /// Install that fixture into the creature before exercising CMSG_LOOT_UNIT + /// so the request remains a pure read/reconciliation path, like C++. + fn install_cached_test_creature_loot_authority_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + scope_player: ObjectGuid, + ) { + if let Some(loot) = session.loot_table.get_mut(&owner_guid) { + // The fixtures describe the already-filtered post-FillLoot item + // set. Rebuild only its derived counters; adding looters here + // would erase negative eligibility cases the fixture represents. + super::rebuild_represented_personal_loot_counts_preserving_consumed_like_cpp(loot); + } + session + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, scope_player) + .expect("the kill-time loot fixture must install into its creature authority"); + } + + fn two_sessions_with_authoritative_creature_loot_like_cpp( + mut loot: CreatureLoot, + ) -> ( + WorldSession, + flume::Receiver>, + WorldSession, + flume::Receiver>, + ObjectGuid, + ObjectGuid, + ObjectGuid, + ) { + let (mut first, first_rx) = make_session_with_send_capacity(32); + let (mut second, second_rx) = make_session_with_send_capacity(32); + let first_guid = ObjectGuid::create_player(1, 42); + let second_guid = ObjectGuid::create_player(1, 43); + let owner_guid = test_creature_guid(19_500); + let shared_map = Arc::new(RwLock::new(crate::map_manager::MapManager::new())); + + first.set_player_guid(Some(first_guid)); + second.set_player_guid(Some(second_guid)); + install_limited_test_item_template(&mut first, 25, 0); + install_limited_test_item_template(&mut second, 25, 0); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + first.set_map_manager(Arc::clone(&shared_map)); + second.set_map_manager(shared_map); + register_test_creature_like_cpp(&mut first, test_creature(owner_guid, false)); + + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![first_guid, second_guid]; + for entry in &mut loot.items { + entry.allowed_looters = vec![first_guid, second_guid]; + } + first.loot_table.insert(owner_guid, loot); + first + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, first_guid) + .unwrap(); + + first.set_active_loot_guid(owner_guid); + let first_response = authoritative_test_loot_response_like_cpp( + owner_guid, + &first.loot_table[&owner_guid], + first_guid, + ); + first.represented_on_loot_opened_like_cpp(owner_guid, first_guid, first_response); + assert!(second.reconcile_represented_loot_cache_like_cpp(owner_guid, second_guid)); + second.set_active_loot_guid(owner_guid); + let second_response = authoritative_test_loot_response_like_cpp( + owner_guid, + &second.loot_table[&owner_guid], + second_guid, + ); + second.represented_on_loot_opened_like_cpp(owner_guid, second_guid, second_response); + + ( + first, + first_rx, + second, + second_rx, + owner_guid, + first_guid, + second_guid, + ) + } + + fn authoritative_test_loot_like_cpp(coins: u32, with_item: bool) -> CreatureLoot { + CreatureLoot { + loot_guid: ObjectGuid::EMPTY, + coins, + unlooted_count: u8::from(with_item), + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: Vec::new(), + items: with_item + .then(|| LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }) + .into_iter() + .collect(), + looted_by_player: false, + } + } + + fn authoritative_test_loot_response_like_cpp( + owner_guid: ObjectGuid, + loot: &CreatureLoot, + player_guid: ObjectGuid, + ) -> LootResponse { + LootResponse { + owner: owner_guid, + loot_obj: loot.loot_guid, + failure_reason: LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP, + acquire_reason: loot_type_for_client_like_cpp(loot.loot_type), + loot_method: loot.loot_method, + threshold: LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP, + coins: loot.coins, + items: represented_loot_response_items_like_cpp(loot, player_guid), + currencies: Vec::new(), + acquired: true, + ae_looting: false, + } + } + + #[tokio::test] + async fn full_loot_response_queue_rolls_back_open_without_blocking_authority_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(1); + let player_guid = ObjectGuid::create_player(1, 61_900); + let owner_guid = test_creature_guid(61_901); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![player_guid]; + loot.items[0].allowed_looters = vec![player_guid]; + session.loot_table.insert(owner_guid, loot); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + + let sentinel = vec![0xAA, 0x55]; + session.send_tx().send(sentinel.clone()).unwrap(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let open_thread = std::thread::spawn(move || { + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + done_tx + .send(( + session.loot_table.contains_key(&owner_guid), + session.active_loot_view_owners.contains(&owner_guid), + )) + .unwrap(); + }); + + let (cached, active) = done_rx + .recv_timeout(Duration::from_millis(500)) + .expect("a full socket queue must not block while loot authority is locked"); + open_thread.join().unwrap(); + assert!(!cached); + assert!(!active); + assert_eq!(send_rx.try_recv().unwrap(), sentinel); + assert!(send_rx.try_recv().is_err(), "no LootResponse was enqueued"); + + let rejected = authority.snapshot_for_player_like_cpp(player_guid).unwrap(); + assert!(rejected.loot.players_looting.is_empty()); + assert!(!rejected.loot.looted_by_player); + + let claim = tokio::time::timeout( + Duration::from_millis(500), + authority.reserve_item_like_cpp(player_guid, 0), + ) + .await + .expect("a failed response enqueue must release the authority mutex") + .unwrap(); + assert!(claim.rollback_like_cpp()); + authority.retire_like_cpp(); + assert!(authority.is_retired_like_cpp()); + } + + #[tokio::test] + async fn successful_loot_open_queues_response_before_claim_removal_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 61_910); + let owner_guid = test_creature_guid(61_911); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + loot.allowed_looters = vec![player_guid]; + loot.items[0].allowed_looters = vec![player_guid]; + session.loot_table.insert(owner_guid, loot); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + let claim = authority + .reserve_item_like_cpp(player_guid, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let committed = authority.snapshot_for_player_like_cpp(player_guid).unwrap(); + session.represented_notify_loot_item_removed_from_snapshot_like_cpp( + owner_guid, + Some(&authority), + &committed, + 0, + ); + + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + let response_index = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootResponse as u16) + .expect("the accepted opening response was queued"); + let removal_index = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .expect("the committed claim removal was queued"); + assert!( + response_index < removal_index, + "the authority lock must order LootResponse before LootRemoved: {opcodes:?}" + ); + } + + #[test] + fn stale_player_map_key_does_not_rebind_creature_loot_authorities_like_cpp() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 61_709); + let owner_guid = test_creature_guid(61_710); + session.set_player_guid(Some(player_guid)); + session.set_state(SessionState::LoggedIn); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + let canonical_creature = make_canonical_creature_for_session(&session, owner_guid); + attach_canonical_creature(&mut session, canonical_creature); + + let (map_id, instance_id) = session.current_legacy_runtime_map_key_like_cpp(); + let map_key = wow_map::MapKey::new(u32::from(map_id), instance_id); + let legacy_before = session + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .expect("the legacy creature owns its pristine authority"); + let canonical_before = session + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .expect("the canonical creature owns a separate pristine authority"); + assert!(!legacy_before.shares_storage_like_cpp(&canonical_before)); + assert_eq!(session.current_canonical_player_map_key_like_cpp(), None); + + assert!( + session + .represented_owned_loot_authority_like_cpp(owner_guid) + .is_none(), + "a logged-in player between maps must fail closed" + ); + + let legacy_after = session + .read_legacy_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .unwrap(); + let canonical_after = session + .read_canonical_creature_loot_authority_on_map_like_cpp(owner_guid, map_key) + .unwrap(); + assert!(legacy_after.shares_storage_like_cpp(&legacy_before)); + assert!(canonical_after.shares_storage_like_cpp(&canonical_before)); + assert!( + !legacy_after.shares_storage_like_cpp(&canonical_after), + "reconciliation must not mutate either stale-map mirror" + ); + } + + fn represented_disenchant_test_outputs_like_cpp( + winner_guid: ObjectGuid, + item_id: u32, + ) -> Vec { + (0..2) + .map(|loot_list_id| LootEntry { + loot_list_id, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + ..Default::default() + }, + allowed_looters: vec![winner_guid], + roll_winner: winner_guid, + ffa_looted_by: Vec::new(), + taken: false, + }) + .collect() + } + + #[tokio::test] + async fn two_sessions_claim_one_authoritative_item_exactly_once_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + let first_barrier = Arc::clone(&barrier); + let first_task = tokio::spawn(async move { + first_barrier.wait().await; + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + first + }); + let second_barrier = Arc::clone(&barrier); + let second_task = tokio::spawn(async move { + second_barrier.wait().await; + second.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + second + }); + barrier.wait().await; + + let mut first = first_task.await.unwrap(); + let _second = second_task.await.unwrap(); + assert_eq!(grants.load(Ordering::SeqCst), 1); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); + } + + #[tokio::test] + async fn durable_direct_item_claim_notifies_removed_before_item_push_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, _first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + // Use one observer channel for both physical routes so this test can + // assert their relative C++ wire order. Route separation is covered + // independently by the ItemPushResult test above. + let shared_send = first.send_tx().clone(); + first.install_realm_send_channel_for_test(shared_send); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert_eq!( + drain_server_opcodes_like_cpp(&first_rx), + vec![ + wow_constants::ServerOpcodes::LootRemoved as u16, + wow_constants::ServerOpcodes::ItemPushResult as u16, + ], + "C++ Player::StoreLootItem notifies removal before SendNewItem" + ); + } + + fn install_quest_bound_loot_objective_like_cpp( + session: &mut WorldSession, + quest_id: u32, + item_id: u32, + current_count: i32, + required_count: i32, + ) { + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: required_count, + flags: 0, + flags2: 1, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![current_count], + slot: 0, + }, + ); + } + + #[tokio::test] + async fn quest_bound_loot_credits_objective_without_physical_item_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + let item_id = 25; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, item_id, 5, 6); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!( + grants.load(Ordering::SeqCst), + 0, + "C++ StoreNewItem returns nullptr for quest-bound objective credit" + ); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![6]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + ); + assert!(!first.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); + + let opcodes = drain_server_opcodes_like_cpp(&first_rx); + let bound_credit = wow_constants::ServerOpcodes::ItemPushResult as u16; + let loot_removed = wow_constants::ServerOpcodes::LootRemoved as u16; + assert!(opcodes.contains(&bound_credit), "{opcodes:?}"); + assert!(opcodes.contains(&loot_removed), "{opcodes:?}"); + assert!( + opcodes.iter().position(|opcode| *opcode == bound_credit) + < opcodes.iter().position(|opcode| *opcode == loot_removed), + "C++ bound objective notification precedes the committed loot removal: {opcodes:?}" + ); + } + + #[tokio::test] + async fn failed_quest_bound_loot_persistence_rolls_back_credit_and_claim_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, 25, 5, 6); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![5]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(!snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 1); + } + + #[tokio::test] + async fn quest_bound_loot_still_requires_can_store_new_item_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let quest_id = 8_336; + let item_id = 25; + install_quest_bound_loot_objective_like_cpp(&mut first, quest_id, item_id, 5, 6); + install_limited_test_item_template(&mut first, item_id, 1); + let existing_guid = ObjectGuid::create_item(1, 83_360); + first.insert_inventory_item_like_cpp( + INVENTORY_SLOT_ITEM_START, + InventoryItem { + guid: existing_guid, + entry_id: item_id, + db_guid: existing_guid.counter() as u64, + inventory_type: None, + }, + ); + let existing_item = first.make_inventory_item_object( + existing_guid, + item_id, + first_guid, + 1, + 0, + ItemContext::None, + INVENTORY_SLOT_ITEM_START, + ); + first.insert_inventory_item_object(existing_item); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let status = first.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![5]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(!snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 1); + } + + #[tokio::test] + async fn item_grant_commit_unknown_quarantines_claim_and_kicks_even_when_queue_full() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .unwrap(); + let (command_tx, command_rx) = flume::bounded(1); + command_tx + .send(SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "preexisting".to_string(), + })) + .unwrap(); + + let worker = super::spawn_sql_loot_claim_persistence_worker_like_cpp( + async { + Err(SqlTransactionCommitError::CommitOutcomeUnknown( + DatabaseError::Transaction("lost COMMIT reply".to_string()), + )) + }, + Some(claim), + None, + command_tx, + ) + .unwrap(); + let result = worker.await.unwrap(); + + assert!(matches!( + result, + Err(super::LootClaimPersistenceWorkerError::Persistence( + SqlTransactionCommitError::CommitOutcomeUnknown(_) + )) + )); + assert_eq!( + authority.lifecycle_like_cpp(), + OwnedLootAuthorityLifecycle::Quarantined + ); + assert!( + authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .is_err(), + "unknown COMMIT must never reopen the object-owned item claim" + ); + + let _preexisting = command_rx.recv().unwrap(); + let queued = tokio::time::timeout(Duration::from_secs(1), command_rx.recv_async()) + .await + .expect("full command queue fallback must eventually enqueue the kick") + .unwrap(); + assert!(matches!(queued, SessionCommand::KickLikeCpp(_))); + } + + #[tokio::test] + async fn cancelled_after_runtime_apply_retains_multiviewer_fanout_and_corpse_lifecycle_like_cpp() + { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let corpse_before = first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + creature.corpse_despawn_at() + }) + .unwrap(); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .unwrap(); + let context = LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid: first_guid, + free_for_all: false, + }; + let fanout = first + .prepare_durable_loot_item_fanout_like_cpp(&claim, context) + .expect("pre-COMMIT fanout route"); + assert_eq!(fanout.precommit_snapshot.loot.players_looting.len(), 2); + + let runtime_inventory_applied = Arc::new(AtomicBool::new(true)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = first.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid: owner, + loot_list_id: 0, + player_guid: first_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: Some(fanout), + runtime_inventory_applied: Arc::clone(&runtime_inventory_applied), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + + first.handle_loot_release(loot_release_packet(owner)).await; + second.handle_loot_release(loot_release_packet(owner)).await; + commit_tx.send(()).unwrap(); + first.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!first.is_disconnecting()); + assert!(runtime_inventory_applied.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&first_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)) + ); + assert!( + drain_server_opcodes_like_cpp(&second_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)) + ); + let corpse_after = first + .mutate_world_creature(owner, |creature| { + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), + ) + }) + .unwrap(); + assert!(!corpse_after.1); + assert!(corpse_after.0 <= corpse_before); + } + + #[test] + fn retired_object_authority_releases_every_session_window_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + authority.retire_like_cpp(); + first.close_retired_active_loot_windows_like_cpp(first_guid); + second.close_retired_active_loot_windows_like_cpp(second_guid); + + for (session, owner_guid) in [(&first, owner), (&second, owner)] { + assert!(!session.active_loot_view_owners.contains(&owner_guid)); + assert!(!session.loot_table.contains_key(&owner_guid)); + } + for rx in [&first_rx, &second_rx] { + assert_eq!( + drain_server_opcodes_like_cpp(rx), + vec![wow_constants::ServerOpcodes::LootRelease as u16] + ); + } + } + + #[tokio::test] + async fn failed_authoritative_item_store_rolls_back_for_retry_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert!( + !authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + } + + #[tokio::test] + async fn two_sessions_claim_one_authoritative_money_pool_exactly_once_like_cpp() { + let (first, _first_rx, second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let first_barrier = Arc::clone(&barrier); + let first_task = tokio::spawn(async move { + let mut first = first; + first_barrier.wait().await; + first.handle_loot_money(loot_money_packet()).await; + first + }); + let second_barrier = Arc::clone(&barrier); + let second_task = tokio::spawn(async move { + let mut second = second; + second_barrier.wait().await; + second.handle_loot_money(loot_money_packet()).await; + second + }); + barrier.wait().await; + + let mut first = first_task.await.unwrap(); + let second = second_task.await.unwrap(); + assert_eq!( + first.player_gold_like_cpp() + second.player_gold_like_cpp(), + 9 + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + } + + #[tokio::test] + async fn failed_authoritative_money_persistence_rolls_back_for_retry_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(false); + + first.handle_loot_money(loot_money_packet()).await; + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert_eq!(first.player_gold_like_cpp(), 0); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 7 + ); + + first.set_loot_money_persistence_test_result_like_cpp(true); + first.handle_loot_money(loot_money_packet()).await; + assert_eq!(first.player_gold_like_cpp(), 7); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + } + + #[tokio::test] + async fn stale_active_money_view_cannot_claim_replacement_generation_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let opened_generation = first.active_loot_view_generations_like_cpp[&owner]; + let mut replacement = authoritative_test_loot_like_cpp(11, false); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + assert_ne!(opened_generation, replacement_generation); + + first.handle_loot_money(loot_money_packet()).await; + + assert_eq!(first.player_gold_like_cpp(), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert_eq!(snapshot.loot.coins, 11); + } + + #[tokio::test] + async fn stale_active_item_view_cannot_claim_replacement_generation_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let mut replacement = authoritative_test_loot_like_cpp(0, true); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + first + .handle_loot_item(loot_item_packet( + represented_loot_object_guid_like_cpp(owner), + 0, + )) + .await; + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert!(!snapshot.loot.items[0].taken); + } + + #[tokio::test] + async fn item_waiter_waking_on_replacement_rolls_back_new_generation_claim_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let blocker = authority + .reserve_item_like_cpp(first_guid, 0) + .await + .unwrap(); + let grants = Arc::new(AtomicUsize::new(0)); + first.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + let waiter = tokio::spawn(async move { + first.handle_loot_item(loot_item_packet(loot_obj, 0)).await; + first + }); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + let mut replacement = authoritative_test_loot_like_cpp(0, true); + replacement.loot_guid = loot_obj; + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + drop(blocker); + let _first = waiter.await.unwrap(); + + assert_eq!(grants.load(Ordering::SeqCst), 0); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert!(!snapshot.loot.items[0].taken); + } + + #[tokio::test] + async fn stale_release_keeps_replacement_viewer_and_pool_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let mut replacement = authoritative_test_loot_like_cpp(13, true); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + replacement.items[0].allowed_looters = vec![first_guid, second_guid]; + let replacement_generation = authority.replace_like_cpp(Some(replacement), HashMap::new()); + authority.add_viewer_like_cpp(first_guid).unwrap(); + + assert!( + first + .do_loot_release_owner_like_cpp(owner, first_guid) + .await + ); + + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.generation, replacement_generation); + assert_eq!(snapshot.loot.coins, 13); + assert!(!snapshot.loot.items[0].taken); + assert!(snapshot.loot.players_looting.contains(&first_guid)); + assert!(!first.active_loot_view_owners.contains(&owner)); + } + + #[tokio::test] + async fn durable_money_delta_is_order_independent_near_gold_cap_like_cpp() { + let start = MAX_MONEY_AMOUNT - 7; + let (after_first, first_delta) = loot_money_durable_outcome_like_cpp(start, 5); + let (after_second, second_delta) = loot_money_durable_outcome_like_cpp(after_first, 5); + assert_eq!((after_second, first_delta, second_delta), (start + 5, 5, 0)); + + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + session.set_player_guid(Some(player_guid)); + session.set_player_gold_like_cpp(start); + let committed = Arc::new(AtomicBool::new(false)); + let command_authority = OwnedLootAuthority::new(); + let command = |durable_delta| ApplyLootMoneyLikeCppCommand { + recipient: player_guid, + loot_owner: test_creature_guid(19_510), + loot_obj: represented_loot_object_guid_like_cpp(test_creature_guid(19_510)), + amount: 5, + durable_applied_amount: Arc::new(AtomicU64::new(durable_delta)), + durable_persistence_tracker: Default::default(), + sole_looter: false, + authority: command_authority.clone(), + authority_generation: 1, + authority_committed: Arc::clone(&committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + + // Runtime delivery is deliberately opposite to the locked DB order. + session + .handle_apply_loot_money_like_cpp_command(command(second_delta)) + .await; + session + .handle_apply_loot_money_like_cpp_command(command(first_delta)) + .await; + + assert_eq!(session.player_gold_like_cpp(), start + 5); + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + assert_eq!( + opcodes, + vec![ + wow_constants::ServerOpcodes::LootMoneyNotify as u16, + wow_constants::ServerOpcodes::LootMoneyNotify as u16, + ] + ); + } + + #[tokio::test] + async fn durable_old_generation_payout_does_not_touch_replacement_loot_like_cpp() { + let (mut first, first_rx, _second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(7, false), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let old_generation = first.active_loot_view_generations_like_cpp[&owner]; + let mut replacement = authoritative_test_loot_like_cpp(17, false); + replacement.loot_guid = represented_loot_object_guid_like_cpp(owner); + replacement.allowed_looters = vec![first_guid, second_guid]; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + authority.add_viewer_like_cpp(first_guid).unwrap(); + + first + .handle_apply_loot_money_like_cpp_command(ApplyLootMoneyLikeCppCommand { + recipient: first_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 3, + durable_applied_amount: Arc::new(AtomicU64::new(3)), + durable_persistence_tracker: Default::default(), + sole_looter: true, + authority: authority.clone(), + authority_generation: old_generation, + authority_committed: Arc::new(AtomicBool::new(true)), + send_coin_removed: Arc::new(AtomicBool::new(true)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }) + .await; + + assert_eq!(first.player_gold_like_cpp(), 3); + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert_eq!(snapshot.loot.coins, 17); + assert!(snapshot.loot.players_looting.contains(&first_guid)); + let opcodes = drain_server_opcodes_like_cpp(&first_rx); + assert!(!opcodes.contains(&(wow_constants::ServerOpcodes::CoinRemoved as u16))); + assert_eq!( + opcodes + .iter() + .filter(|opcode| { + **opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1 + ); + } + + #[tokio::test] + async fn remote_group_money_is_one_atomic_durable_fanout_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + install_group_loot_group(&mut first, first_guid, second_guid); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + + // C++ divides integral copper and discards the remainder. + assert_eq!(first.player_gold_like_cpp(), 4); + assert_eq!(second.player_gold_like_cpp(), 4); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + for opcodes in [ + drain_server_opcodes_like_cpp(&first_rx), + drain_server_opcodes_like_cpp(&second_rx), + ] { + let coin = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::CoinRemoved as u16) + .expect("active viewer must receive CoinRemoved"); + let money = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16) + .expect("payout recipient must receive LootMoneyNotify"); + assert!(coin < money, "C++ removes coins before notifying payout"); + } + } + + #[test] + fn pickpocket_money_is_not_shared_with_the_group_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = test_creature_guid(19_501); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + registry.insert(member_guid, broadcast_info(member_guid, member_tx)); + session.set_player_registry(registry); + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.loot_type = LOOT_TYPE_PICKPOCKETING_LIKE_CPP; + loot.allowed_looters = vec![player_guid, member_guid]; + session.loot_table.insert(owner, loot); + + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] + ); + } + + #[test] + fn vehicle_corpse_money_shares_and_pool_allowed_looters_control_membership_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = ObjectGuid::create_vehicle_like_cpp(1, 0, 1, 19_502); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + registry.insert(member_guid, broadcast_info(member_guid, member_tx)); + session.set_player_registry(registry); + + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.loot_type = LOOT_TYPE_CORPSE_LIKE_CPP; + loot.allowed_looters = vec![player_guid]; + session.loot_table.insert(owner, loot); + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] + ); + + session + .loot_table + .get_mut(&owner) + .unwrap() + .allowed_looters + .push(member_guid); + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid, member_guid] + ); + } + + #[test] + fn corpse_money_reward_distance_ignores_range_only_in_same_dungeon_instance_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + let owner = test_creature_guid(19_503); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + let mut member = broadcast_info(member_guid, member_tx.clone()); + member.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + registry.insert(member_guid, member); + session.set_player_registry(Arc::clone(®istry)); + let mut loot = authoritative_test_loot_like_cpp(8, false); + loot.allowed_looters = vec![player_guid, member_guid]; + session.loot_table.insert(owner, loot); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] + ); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid, member_guid] + ); + + let mut wrong_instance = broadcast_info(member_guid, member_tx); + wrong_instance.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + wrong_instance.instance_id = 1; + registry.insert(member_guid, wrong_instance); + assert_eq!( + session.represented_loot_money_recipients_like_cpp(owner), + vec![player_guid] + ); + } + + #[test] + fn chest_allowed_looters_ignore_range_only_in_same_dungeon_instance_like_cpp() { + let (mut session, _send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let member_guid = ObjectGuid::create_player(1, 43); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + install_group_loot_group(&mut session, player_guid, member_guid); + let registry = Arc::new(PlayerRegistry::default()); + let (member_tx, _member_rx) = flume::bounded(1); + let mut member = broadcast_info(member_guid, member_tx.clone()); + member.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + registry.insert(member_guid, member); + session.set_player_registry(Arc::clone(®istry)); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + assert_eq!( + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid] + ); + + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + assert_eq!( + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid, member_guid] + ); + + let mut wrong_instance = broadcast_info(member_guid, member_tx); + wrong_instance.position = Position::new(10_000.0, 0.0, 0.0, 0.0); + wrong_instance.instance_id = 1; + registry.insert(member_guid, wrong_instance); + assert_eq!( + session.represented_group_looters_at_reward_distance_like_cpp(player_guid), + vec![player_guid] + ); + } + + #[tokio::test] + async fn failed_remote_group_money_transaction_credits_nobody_and_retries_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + install_group_loot_group(&mut first, first_guid, second_guid); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + first.set_loot_money_persistence_test_result_like_cpp(false); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + assert_eq!(first.player_gold_like_cpp(), 0); + assert_eq!(second.player_gold_like_cpp(), 0); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 9 + ); + + first.set_loot_money_persistence_test_result_like_cpp(true); + first.handle_loot_money(loot_money_packet()).await; + second.process_represented_session_commands_like_cpp().await; + assert_eq!(first.player_gold_like_cpp(), 4); + assert_eq!(second.player_gold_like_cpp(), 4); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + } + + #[tokio::test] + async fn cancelled_money_waiter_cannot_reopen_a_durable_claim_like_cpp() { + let (mut first, _first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(true); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority.reserve_money_like_cpp(first_guid).await.unwrap(); + let authority_generation = claim.generation_like_cpp(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let application = ApplyLootMoneyLikeCppCommand { + recipient: second_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 9, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: second.durable_loot_money_persistence_tracker_like_cpp(), + sole_looter: true, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(true)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + let delivery = ( + second.session_command_tx(), + SessionCommand::ApplyLootMoneyLikeCpp(application), + ); + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: first_guid, + source_player: first_guid, + source_command_tx: first.session_command_tx(), + player_registry: first.player_registry().cloned(), + map_id: first.player_map_id_like_cpp(), + instance_id: first + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0), + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + authority: authority.clone(), + authority_generation, + payout_recipients: [second_guid].into_iter().collect(), + }; + let _ = drain_server_opcodes_like_cpp(&second_rx); + let persistence = first + .spawn_group_loot_money_persistence_like_cpp( + vec![(second_guid, 9)], + claim, + vec![delivery], + Arc::clone(&authority_committed), + viewer_fanout, + ) + .unwrap(); + + // The outer packet task owns only the JoinHandle. Aborting it must not + // cancel the detached SQL+authority worker that owns the lease. + let waiter = tokio::spawn(async move { persistence.await }); + waiter.abort(); + let _ = waiter.await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + second.process_represented_session_commands_like_cpp().await; + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .coins, + 0 + ); + let zero = authority + .reserve_money_like_cpp(first_guid) + .await + .expect("C++ keeps the view and serializes a later zero-money observation"); + assert_eq!(zero.payload_like_cpp(), &LootClaimPayload::Money(0)); + assert!(zero.commit_like_cpp().unwrap()); + assert!(authority_committed.load(Ordering::Acquire)); + assert_eq!(second.player_gold_like_cpp(), 9); + let opcodes = drain_server_opcodes_like_cpp(&second_rx); + let coin = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::CoinRemoved as u16) + .unwrap(); + let money = opcodes + .iter() + .position(|opcode| *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16) + .unwrap(); + assert!(coin < money); + } + + #[tokio::test] + async fn money_viewer_opened_during_persistence_receives_coin_removed_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(9, false), + ); + first.set_loot_money_persistence_test_result_like_cpp(true); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + assert!(authority.remove_viewer_like_cpp(second_guid)); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + + let claim = authority.reserve_money_like_cpp(first_guid).await.unwrap(); + let authority_generation = claim.generation_like_cpp(); + let authority_committed = Arc::new(AtomicBool::new(false)); + let application = ApplyLootMoneyLikeCppCommand { + recipient: first_guid, + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + amount: 9, + durable_applied_amount: Arc::new(AtomicU64::new(0)), + durable_persistence_tracker: first.durable_loot_money_persistence_tracker_like_cpp(), + sole_looter: true, + authority: authority.clone(), + authority_generation, + authority_committed: Arc::clone(&authority_committed), + send_coin_removed: Arc::new(AtomicBool::new(false)), + applied: Arc::new(AtomicBool::new(false)), + published: Arc::new(AtomicBool::new(false)), + }; + let viewer_fanout = LootMoneyViewerFanoutLikeCpp { + scope_player: first_guid, + source_player: first_guid, + source_command_tx: first.session_command_tx(), + player_registry: Some(registry), + map_id: first.player_map_id_like_cpp(), + instance_id: first + .current_canonical_player_map_key_like_cpp() + .map(|key| key.instance_id) + .unwrap_or(0), + loot_owner: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + authority: authority.clone(), + authority_generation, + payout_recipients: [first_guid].into_iter().collect(), + }; + let persistence = first + .spawn_group_loot_money_persistence_like_cpp( + vec![(first_guid, 9)], + claim, + vec![( + first.session_command_tx(), + SessionCommand::ApplyLootMoneyLikeCpp(application), + )], + Arc::clone(&authority_committed), + viewer_fanout, + ) + .unwrap(); + + authority + .open_view_with_snapshot_like_cpp(second_guid, |_, _| ()) + .expect("late viewer opens before the detached worker commits"); + persistence.await.unwrap().unwrap(); + second.process_represented_session_commands_like_cpp().await; + + assert!(authority_committed.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&second_rx) + .contains(&(wow_constants::ServerOpcodes::CoinRemoved as u16)), + "a viewer that saw non-zero money during SQL must receive C++ NotifyMoneyRemoved" + ); + } + + #[tokio::test] + async fn cancelled_item_waiter_cannot_reopen_a_durable_claim_like_cpp() { + let (mut first, _first_rx, _second, _second_rx, owner, first_guid, _second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = release_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + None, + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + release_tx.send(()).unwrap(); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + let snapshot = authority.snapshot_for_player_like_cpp(first_guid).unwrap(); + assert!(snapshot.loot.items[0].taken); + assert_eq!(snapshot.loot.unlooted_count, 0); + assert!( + authority + .reserve_item_for_award_like_cpp(first_guid, 0) + .await + .is_err() + ); + } + + #[tokio::test] + async fn durable_item_completion_auto_releases_only_after_items_and_coins_are_empty_like_cpp() { + for (coins, should_release) in [(0, true), (7, false)] { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_801 + i64::from(coins)); + let owner_guid = ObjectGuid::create_item(1, 61_811 + i64::from(coins)); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + coins, + ); + let runtime_inventory_applied = Arc::new(AtomicBool::new(true)); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async { Ok::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied, + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!session.is_disconnecting()); + assert_eq!( + session.loot_table.contains_key(&owner_guid), + !should_release + ); + assert_eq!(session.is_active_loot_guid(owner_guid), !should_release); + if !should_release { + let loot = session.loot_table.get(&owner_guid).unwrap(); + assert!(loot.items[0].taken); + assert_eq!(loot.coins, coins); + } + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| { + *opcode == wow_constants::ServerOpcodes::LootRelease as u16 + }) + .count(), + usize::from(should_release), + "C++ StoreLootItem checks Loot::isLooted(), including money" + ); + } + } + + #[tokio::test] + async fn durable_item_completion_never_auto_releases_creature_or_gameobject_owner_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_820); + session.set_player_guid(Some(player_guid)); + for owner_guid in [ + test_creature_guid(61_821), + ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 0, 0, 1, 61_822), + ] { + session.set_active_loot_guid(owner_guid); + session.loot_table.insert( + owner_guid, + CreatureLoot { + loot_guid: owner_guid, + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_ITEM_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, + }, + ); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async { Ok::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(true)), + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); + } + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!session.is_disconnecting()); + assert!(session.loot_table.values().all(|loot| !loot.items[0].taken)); + assert!( + !drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootRelease as u16)) + ); + } + + #[tokio::test] + async fn cancelled_item_handler_after_commit_releases_and_forces_inventory_reload_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_830); + let owner_guid = ObjectGuid::create_item(1, 61_831); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(session.is_disconnecting()); + assert!(!session.loot_table.contains_key(&owner_guid)); + assert!(!session.is_active_loot_guid(owner_guid)); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 1 + ); + } + + #[tokio::test] + async fn cancelled_world_owner_claim_after_commit_reconciles_cache_and_forces_reload_like_cpp() + { + let (mut session, _send_rx, _second, _second_rx, owner_guid, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + Some(claim), + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(session.is_disconnecting()); + assert!( + authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + session.loot_table.get(&owner_guid).unwrap().items[0].taken, + "master/roll/direct world-owner grants share this claimed-store recovery path" + ); + } + + #[tokio::test] + async fn failed_item_persistence_publishes_no_removal_or_release_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_840); + let owner_guid = ObjectGuid::create_item(1, 61_841); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let result = super::spawn_loot_claim_persistence_worker_like_cpp( + async { Err::<(), ()>(()) }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap() + .await + .unwrap(); + assert!(result.is_err()); + + session.wait_for_active_loot_persistence_like_cpp().await; + + assert!(!session.is_disconnecting()); + assert!(session.is_active_loot_guid(owner_guid)); + assert!(!session.loot_table.get(&owner_guid).unwrap().items[0].taken); + assert!( + !drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootRelease as u16)) + ); + } + + #[tokio::test] + async fn cancelled_stored_item_money_before_commit_retries_without_local_consumption_like_cpp() + { + let (mut session, _send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_845); + let owner_guid = ObjectGuid::create_item(1, 61_846); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let durable_source_row = Arc::new(AtomicBool::new(true)); + let first_source = Arc::clone(&durable_source_row); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (_commit_tx, commit_rx) = tokio::sync::oneshot::channel::<()>(); + let first_balance_applied = Arc::new(AtomicBool::new(false)); + let first_runtime_applied = Arc::new(AtomicBool::new(false)); + let first_guard = session.begin_durable_item_loot_persistence_like_cpp(); + let first_worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + first_source + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| ()) + }, + None, + Some(( + first_guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&first_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&first_runtime_applied), + }, + )), + ) + .unwrap(); + started_rx.await.unwrap(); + first_worker.abort(); + assert!(first_worker.await.unwrap_err().is_cancelled()); + + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(durable_source_row.load(Ordering::Acquire)); + assert_eq!(session.player_gold_like_cpp(), 100); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 7); + assert!(!first_runtime_applied.load(Ordering::Acquire)); + + let retry_source = Arc::clone(&durable_source_row); + let retry_balance_applied = Arc::new(AtomicBool::new(false)); + let retry_runtime_applied = Arc::new(AtomicBool::new(false)); + let retry_guard = session.begin_durable_item_loot_persistence_like_cpp(); + super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + retry_source + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map(|_| ()) + .map_err(|_| ()) + }, + None, + Some(( + retry_guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&retry_balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&retry_runtime_applied), + }, + )), + ) + .unwrap() + .await + .unwrap() + .unwrap(); + + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(!durable_source_row.load(Ordering::Acquire)); + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert!(retry_runtime_applied.load(Ordering::Acquire)); + assert!(session.is_active_loot_guid(owner_guid)); + } + + #[tokio::test] + async fn cancelled_stored_item_money_after_commit_is_replayed_before_disconnect_save_like_cpp() + { + let (mut session, _send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_847); + let owner_guid = ObjectGuid::create_item(1, 61_848); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let balance_applied = Arc::new(AtomicBool::new(false)); + let runtime_money_applied = Arc::new(AtomicBool::new(false)); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&runtime_money_applied), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + + let mut save = Box::pin(session.save_disconnect_player_to_db_like_cpp()); + assert!( + tokio::time::timeout(Duration::from_millis(5), &mut save) + .await + .is_err(), + "disconnect save must remain pending until durable loot publication is idle" + ); + commit_tx.send(()).unwrap(); + save.await; + + assert_eq!(session.player_gold_like_cpp(), 107); + assert!(runtime_money_applied.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn stored_item_money_save_reconciled_balance_still_publishes_source_once_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_848); + let owner_guid = ObjectGuid::create_item(1, 61_849); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(107); + let balance_applied = Arc::new(AtomicBool::new(true)); + let publication_applied = Arc::new(AtomicBool::new(false)); + let mut guard = session.begin_durable_item_loot_persistence_like_cpp(); + guard.mark_committed_like_cpp(DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::clone(&balance_applied)), + item_fanout: None, + runtime_inventory_applied: Arc::clone(&publication_applied), + }); + drop(guard); + + session + .apply_pending_durable_item_loot_completions_like_cpp() + .await; + + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert!(balance_applied.load(Ordering::Acquire)); + assert!(publication_applied.load(Ordering::Acquire)); + assert!( + drain_server_opcodes_like_cpp(&send_rx) + .contains(&(wow_constants::ServerOpcodes::LootMoneyNotify as u16)) + ); + } + + #[tokio::test] + async fn stored_item_money_delete_cas_allows_exactly_one_durable_grant_like_cpp() { + assert_eq!( + super::STORED_ITEM_MONEY_SOURCE_ROWS_EXPECTED_LIKE_CPP, + 1, + "the source-row delete must fail the whole transaction after another winner" + ); + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_849); + let owner_guid = ObjectGuid::create_item(1, 61_850); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 7, + ); + session.set_player_gold_like_cpp(100); + + let source_row = Arc::new(AtomicBool::new(true)); + let durable_grants = Arc::new(AtomicUsize::new(0)); + let mut workers = Vec::new(); + for _ in 0..2 { + let source_row = Arc::clone(&source_row); + let durable_grants = Arc::clone(&durable_grants); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + workers.push( + super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + tokio::task::yield_now().await; + source_row + .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| ())?; + durable_grants.fetch_add(1, Ordering::SeqCst); + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(7), + durable_item_money_notified_amount: Some(7), + durable_item_money_balance_applied: Some(Arc::new(AtomicBool::new( + false, + ))), + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(), + ); + } + + let mut successes = 0; + for worker in workers { + if matches!(worker.await, Ok(Ok(()))) { + successes += 1; + } + } + session.wait_for_active_loot_persistence_like_cpp().await; + + assert_eq!(successes, 1); + assert_eq!(durable_grants.load(Ordering::SeqCst), 1); + assert_eq!(session.player_gold_like_cpp(), 107); + assert_eq!(session.loot_table.get(&owner_guid).unwrap().coins, 0); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| { + *opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1 + ); + } + + #[tokio::test] + async fn cancelled_zero_stored_item_money_notifies_once_and_normal_completion_does_not_replay_like_cpp() + { + for cancelled_waiter in [true, false] { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = + ObjectGuid::create_player(1, if cancelled_waiter { 61_851 } else { 61_852 }); + let owner_guid = + ObjectGuid::create_item(1, if cancelled_waiter { 61_853 } else { 61_854 }); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + + if cancelled_waiter { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: false, + durable_item_money_applied_amount: Some(0), + durable_item_money_notified_amount: Some(0), + durable_item_money_balance_applied: Some(Arc::new(AtomicBool::new( + false, + ))), + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(false)), + }, + )), + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + commit_tx.send(()).unwrap(); + session.wait_for_active_loot_persistence_like_cpp().await; + } else { + session.set_loot_money_persistence_test_result_like_cpp(true); + session.handle_loot_money(loot_money_packet()).await; + session.wait_for_active_loot_persistence_like_cpp().await; + } + + let opcodes = drain_server_opcodes_like_cpp(&send_rx); + assert_eq!( + opcodes + .iter() + .filter(|opcode| { + **opcode == wow_constants::ServerOpcodes::LootMoneyNotify as u16 + }) + .count(), + 1, + "zero money still notifies, while a normally published completion is not replayed" + ); + session.wait_for_active_loot_persistence_like_cpp().await; + assert!(drain_server_opcodes_like_cpp(&send_rx).is_empty()); + } + } + + #[tokio::test] + async fn disconnect_waits_for_item_publication_and_releases_once_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 61_850); + let owner_guid = ObjectGuid::create_item(1, 61_851); + install_active_item_loot_completion_fixture_like_cpp( + &mut session, + player_guid, + owner_guid, + 0, + ); + let (commit_tx, commit_rx) = tokio::sync::oneshot::channel(); + let guard = session.begin_durable_item_loot_persistence_like_cpp(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = commit_rx.await; + Ok::<(), ()>(()) + }, + None, + Some(( + guard, + DurableItemLootCompletionLikeCpp { + owner_guid, + loot_list_id: 0, + player_guid, + item_owner_auto_release: true, + durable_item_money_applied_amount: None, + durable_item_money_notified_amount: None, + durable_item_money_balance_applied: None, + item_fanout: None, + runtime_inventory_applied: Arc::new(AtomicBool::new(true)), + }, + )), + ) + .unwrap(); + let release_commit = tokio::spawn(async move { + tokio::task::yield_now().await; + commit_tx.send(()).unwrap(); + worker.await.unwrap().unwrap(); + }); + + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + release_commit.await.unwrap(); + + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 1 + ); + } + + #[tokio::test] + async fn disconnect_runs_full_creature_release_lifecycle_after_persistence_like_cpp() { + let (mut session, _send_rx, _second, _second_rx, owner_guid, _player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, false), + ); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let before = session + .mutate_world_creature(owner_guid, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), + ) + }) + .unwrap(); + assert!(before.1); + + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + + let after = session + .mutate_world_creature(owner_guid, |creature| { + ( + creature.corpse_despawn_at(), + creature.has_lootable_dynamic_flag_like_cpp(), + ) + }) + .unwrap(); + assert!(!after.1); + assert!(after.0 <= before.0); + } + + #[tokio::test] + async fn remote_master_loot_command_transports_and_commits_claim_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_master_loot_give_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + entry, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + } + + #[tokio::test] + async fn remote_master_timeout_then_release_still_fans_out_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + + // The target already closed its own view. The source will close while + // the detached target worker owns the claim as `Persisting`. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin(first.request_represented_remote_master_loot_give_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + entry, + Some(claim), + )); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), + }; + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); + + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; + + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + drain_server_opcodes_like_cpp(&first_rx) + .contains(&(wow_constants::ServerOpcodes::LootRemoved as u16)), + "the pre-COMMIT route survives request timeout and CMSG_LOOT_RELEASE" + ); + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "completion must run AllLootRemovedFromCorpse without an active view" + ); + } + + #[tokio::test] + async fn remote_roll_winner_command_transports_and_commits_claim_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken + ); + } + + #[tokio::test] + async fn detached_remote_claim_waits_for_every_authority_viewer_before_corpse_lifecycle_like_cpp() + { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first + .mutate_world_creature(owner, |creature| { + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + + // The remote winner closes, while the original looter deliberately + // keeps the same authoritative Loot window open. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let player_registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + player_registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(&player_registry)); + second.set_player_registry(player_registry); + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "the detached winner cannot finish lifecycle while the original viewer remains open" + ); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .players_looting, + vec![first_guid] + ); + + first.handle_loot_release(loot_release_packet(owner)).await; + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "the final real viewer release performs the ordinary C++ corpse transition" + ); + } + + #[tokio::test] + async fn remote_roll_timeout_then_release_fans_out_once_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let entry = match claim.payload_like_cpp() { + LootClaimPayload::Item(entry) => entry.clone(), + LootClaimPayload::Money(_) => panic!("expected item claim"), + }; + + // The target no longer has a live loot window. The source closes its + // window only after the remote request times out with the target's + // detached persistence worker still owning the claim. + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, entry.item_id, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin( + first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + vec![entry], + false, + Some(claim), + ), + ); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), + }; + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); + + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; + + assert_eq!(grants.load(Ordering::SeqCst), 1); + assert!( + authority + .snapshot_for_player_like_cpp(first_guid) + .unwrap() + .loot + .items[0] + .taken, + "the roll claim is terminal after the durable commit" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&first_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .count(), + 1, + "the pre-COMMIT route publishes the roll removal exactly once" + ); + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "post-COMMIT completion must finish the corpse lifecycle without an active view" + ); + } + + #[tokio::test] + async fn local_disenchant_batch_commits_all_materials_and_original_claim_like_cpp() { + let (mut session, rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&rx); + let shared_send = session.send_tx().clone(); + session.install_realm_send_channel_for_test(shared_send); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(player_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + session.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + + assert!( + session + .store_direct_disenchant_batch_like_cpp( + &materials, + 0, + Some(&claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid, + free_for_all: false, + }), + ) + .await + ); + assert_eq!(grants.load(Ordering::SeqCst), 2); + assert_eq!( + drain_server_opcodes_like_cpp(&rx), + vec![ + wow_constants::ServerOpcodes::ItemPushResult as u16, + wow_constants::ServerOpcodes::ItemPushResult as u16, + wow_constants::ServerOpcodes::LootRemoved as u16, + ], + "C++ Loot::AutoStore sends every material before the original roll slot is removed" + ); + assert!(claim.is_committed_like_cpp()); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_err() + ); + } + + #[tokio::test] + async fn failed_disenchant_batch_grants_zero_and_original_slot_retries_like_cpp() { + let (mut session, _rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(player_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + // Both material grants are already planned when this seam rejects the + // one transaction, modelling a failure while persisting its second + // output. Runtime observes neither output. + session.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), false); + + assert!( + !session + .store_direct_disenchant_batch_like_cpp( + &materials, + 0, + Some(&claim), + Some(LootItemClaimCommitContextLikeCpp { + owner_guid: owner, + loot_obj: represented_loot_object_guid_like_cpp(owner), + loot_list_id: 0, + player_guid, + free_for_all: false, + }), + ) + .await + ); + assert_eq!(grants.load(Ordering::SeqCst), 0); + assert!(!claim.is_committed_like_cpp()); + drop(claim); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn remote_disenchant_batch_uses_one_command_and_commits_all_materials_like_cpp() { + let (mut first, _first_rx, mut second, _second_rx, owner, _first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(second_guid, 700); + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, 700, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let player_registry = Arc::new(PlayerRegistry::default()); + let (registry_send_tx, _registry_send_rx) = flume::bounded(8); + let mut second_info = broadcast_info(second_guid, registry_send_tx); + second_info.command_tx = second.session_command_tx(); + player_registry.insert(second_guid, second_info); + first.set_player_registry(player_registry); + + let request = first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + materials, + true, + Some(claim), + ); + let target = async { + tokio::task::yield_now().await; + // One drain handles the complete two-material result. A former + // implementation required one command/ack round-trip per item. + second.process_represented_session_commands_like_cpp().await; + }; + let (result, ()) = tokio::join!(request, target); + + assert_eq!(result, MasterLootGiveResult::Stored); + assert_eq!(grants.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .is_err() + ); + } + + #[tokio::test] + async fn remote_disenchant_timeout_then_release_fans_out_once_and_finalizes_corpse_like_cpp() { + let (mut first, first_rx, mut second, second_rx, owner, first_guid, second_guid) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let _ = drain_server_opcodes_like_cpp(&first_rx); + let _ = drain_server_opcodes_like_cpp(&second_rx); + first.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + first + .mutate_world_creature(owner, |creature| { + creature.creature.set_corpse_delay(120, false); + creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); + }) + .unwrap(); + + let authority = first + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(second_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(second_guid, generation, 0, false, Some(second_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .unwrap(); + let materials = represented_disenchant_test_outputs_like_cpp(second_guid, 700); + + second.handle_loot_release(loot_release_packet(owner)).await; + let _ = drain_server_opcodes_like_cpp(&second_rx); + + let registry = Arc::new(PlayerRegistry::default()); + let mut first_info = broadcast_info(first_guid, first.send_tx().clone()); + first_info.command_tx = first.session_command_tx(); + registry.insert(first_guid, first_info); + let mut second_info = broadcast_info(second_guid, second.send_tx().clone()); + second_info.command_tx = second.session_command_tx(); + registry.insert(second_guid, second_info); + first.set_player_registry(Arc::clone(®istry)); + second.set_player_registry(registry); + + let grants = Arc::new(AtomicUsize::new(0)); + install_limited_test_item_template(&mut second, 700, 0); + second.set_loot_item_store_test_seam_like_cpp(Arc::clone(&grants), true); + let commit_gate = Arc::new(tokio::sync::Notify::new()); + second.set_loot_item_store_test_commit_gate_like_cpp(Arc::clone(&commit_gate)); + + let mut request = Box::pin( + first.request_represented_remote_loot_roll_winner_store_like_cpp( + second_guid, + owner, + represented_loot_object_guid_like_cpp(owner), + 0, + 0, + materials, + true, + Some(claim), + ), + ); + let mut target = Box::pin(async { + tokio::task::yield_now().await; + second.process_represented_session_commands_like_cpp().await; + }); + let result = tokio::select! { + result = &mut request => result, + _ = &mut target => panic!("target must remain behind the COMMIT gate"), + }; + drop(request); + assert_eq!(result, MasterLootGiveResult::TargetMismatch); + + first.handle_loot_release(loot_release_packet(owner)).await; + commit_gate.notify_one(); + target.await; + + assert_eq!(grants.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(second_guid, 0) + .await + .is_err(), + "the original roll claim remains terminal after every material commits" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&first_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRemoved as u16) + .count(), + 1, + "the material batch publishes the original roll removal exactly once" + ); + assert!( + !first + .mutate_world_creature(owner, |creature| { + creature.has_lootable_dynamic_flag_like_cpp() + }) + .unwrap(), + "post-COMMIT completion must finish the corpse lifecycle without an active view" + ); + } + + #[tokio::test] + async fn cancelled_disenchant_waiter_cannot_reopen_durable_batch_like_cpp() { + let (mut session, _rx, _second, _second_rx, owner, player_guid, _) = + two_sessions_with_authoritative_creature_loot_like_cpp( + authoritative_test_loot_like_cpp(0, true), + ); + let authority = session + .represented_owned_loot_authority_like_cpp(owner) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .generation; + authority + .finish_item_roll_like_cpp(player_guid, generation, 0, false, Some(player_guid)) + .unwrap(); + let claim = authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .unwrap(); + let durable_materials = Arc::new(AtomicUsize::new(0)); + let durable_materials_worker = Arc::clone(&durable_materials); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let worker = super::spawn_loot_claim_persistence_worker_like_cpp( + async move { + let _ = started_tx.send(()); + let _ = release_rx.await; + durable_materials_worker.fetch_add(2, Ordering::SeqCst); + Ok::<(), ()>(()) + }, + Some(claim), + None, + ) + .unwrap(); + let waiter = tokio::spawn(async move { worker.await }); + + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + release_tx.send(()).unwrap(); + for _ in 0..4 { + tokio::task::yield_now().await; + } + + assert_eq!(durable_materials.load(Ordering::SeqCst), 2); + assert!( + authority + .reserve_item_for_award_like_cpp(player_guid, 0) + .await + .is_err() + ); + } + + fn tap_test_creature_like_cpp( + session: &mut WorldSession, + creature_guid: ObjectGuid, + player_guid: ObjectGuid, + ) { + let _ = session.mutate_world_creature(creature_guid, |world_creature| { + world_creature + .creature + .set_tapped_by_player(player_guid, &[]); + }); + } + + fn loot_response_failure_reason(sent: &[u8]) -> u8 { + loot_response_failure_reason_and_threshold(sent).0 + } + + fn loot_response_threshold(sent: &[u8]) -> u8 { + loot_response_failure_reason_and_threshold(sent).1 + } + + fn loot_response_failure_reason_and_threshold(sent: &[u8]) -> (u8, u8) { + let mut pkt = WorldPacket::from_bytes(&sent[2..]); + let _owner = pkt.read_packed_guid().unwrap(); + let _loot_obj = pkt.read_packed_guid().unwrap(); + let failure_reason = pkt.read_uint8().unwrap(); + let _acquire_reason = pkt.read_uint8().unwrap(); + let _loot_method = pkt.read_uint8().unwrap(); + let threshold = pkt.read_uint8().unwrap(); + (failure_reason, threshold) + } + + fn test_creature_guid(counter: i64) -> ObjectGuid { + ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 0, 0, 1, counter) + } + + fn test_corpse_guid(counter: i64) -> ObjectGuid { + ObjectGuid::create_world_object(HighGuid::Corpse, 0, 1, 0, 0, 1, counter) + } + + fn broadcast_info(guid: ObjectGuid, send_tx: flume::Sender>) -> PlayerBroadcastInfo { + let (command_tx, _command_rx) = flume::bounded(1); + PlayerBroadcastInfo { + map_id: 0, + instance_id: 0, + position: Position::ZERO, + combat_reach: 0.0, + liquid_status: 0, + is_in_world: true, + send_tx, + command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), + active_loot_rolls: Vec::new(), + pass_on_group_loot: false, + enchanting_skill: 0, + is_alive: true, + current_health: 100, + max_health: 100, + power_type: 0, + current_power: 0, + max_power: 0, + is_pvp: false, + is_ffa_pvp: false, + is_ghost: false, + is_afk: false, + is_dnd: false, + auto_reply_msg_like_cpp: String::new(), + in_vehicle: false, + has_vehicle_kit_like_cpp: false, + party_member_vehicle_seat: 0, + zone_id: 0, + spec_id: 0, + unit_flags: 0, + unit_flags2: 0, + unit_state: 0, + is_game_master: false, + dungeon_difficulty_id: 1, + is_contested_pvp: false, + active_expansion: 2, + pending_quest_sharing: None, + known_spells: Vec::new(), + active_quest_statuses: Default::default(), + active_quest_objective_counts: Default::default(), + rewarded_quests: Default::default(), + completed_achievements: Default::default(), + daily_quests_completed: Default::default(), + df_quests: Default::default(), + faction_template_id: 0, + reputation_standings: Vec::new(), + reputation_state_flags: Vec::new(), + forced_reputation_ranks: Vec::new(), + forced_reputation_faction_ids: Vec::new(), + inventory_item_counts: Default::default(), + party_member_party_type: [0; 2], + party_member_phase_states: Default::default(), + party_member_auras: Vec::new(), + party_member_pet_stats: None, + player_name: format!("Player{}", guid.counter()), + account_id: guid.counter() as u32, + recruiter_id: 0, + race: 1, + class: 1, + sex: 0, + level: 1, + gray_level: 0, + display_id: 49, + visible_items: [(0, 0, 0); 19], + lifetime_honorable_kills: 0, + this_week_contribution: 0, + yesterday_contribution: 0, + today_honorable_kills: 0, + yesterday_honorable_kills: 0, + lifetime_max_rank: 0, + honor_level: 0, + } + } + + fn loot_condition( + condition_type_or_reference: i32, + value1: u32, + value2: u32, + value3: u32, + ) -> LootConditionRowLikeCpp { + LootConditionRowLikeCpp { + else_group: 0, + condition_type_or_reference, + condition_target: 0, + value1, + value2, + value3, + string_value1: String::new(), + negative: false, + script_name: String::new(), + } + } + + fn test_quest_template(id: u32) -> QuestTemplate { + QuestTemplate { + id, + quest_type: 0, + quest_level: 1, + quest_max_scaling_level: 0, + quest_package_id: 0, + min_level: 1, + quest_sort_id: 0, + quest_info_id: 0, + suggested_group_num: 0, + reward_next_quest: 0, + reward_xp_difficulty: 0, + reward_xp_multiplier: 1.0, + reward_money_difficulty: 0, + reward_money_multiplier: 1.0, + reward_bonus_money: 0, + reward_display_spell: [0; 3], + reward_spell: 0, + reward_honor: 0, + reward_title_id: 0, + reward_skill_line_id: 0, + reward_skill_points: 0, + reward_mail_template_id: 0, + reward_mail_delay_secs: 0, + reward_mail_sender_entry: 0, + reward_faction_ids: [0; QUEST_REWARD_REPUTATIONS_COUNT], + reward_faction_values: [0; QUEST_REWARD_REPUTATIONS_COUNT], + reward_faction_overrides: [0; QUEST_REWARD_REPUTATIONS_COUNT], + reward_faction_cap_in: [0; QUEST_REWARD_REPUTATIONS_COUNT], + reward_faction_flags: 0, + source_item_id: 0, + source_item_count: 0, + source_spell_id: 0, + limit_time_secs: 0, + expansion: 0, + flags: 0, + flags_ex: 0, + flags_ex2: 0, + special_flags: 0, + event_id_for_quest: 0, + reward_items: [0; 4], + reward_amounts: [0; 4], + reward_currencies: [0; 4], + reward_currency_amounts: [0; 4], + item_drop: [0; 4], + item_drop_quantity: [0; 4], + log_title: String::new(), + log_description: String::new(), + quest_description: String::new(), + area_description: String::new(), + quest_completion_log: String::new(), + objectives: Vec::new(), + allowable_races: 0, + allowable_classes: 0, + max_level: 0, + prev_quest_id: 0, + next_quest_id: 0, + exclusive_group: 0, + breadcrumb_for_quest_id: 0, + dependent_previous_quests: Vec::new(), + dependent_breadcrumb_quests: Vec::new(), + required_min_rep_faction: 0, + required_min_rep_value: 0, + required_max_rep_faction: 0, + required_max_rep_value: 0, + required_skill_id: 0, + required_skill_points: 0, + reward_choice_items: [(0, 0); 6], + reward_choice_item_types: [0; 6], + } + } + + #[test] + fn represented_personal_loot_remote_context_uses_registry_fields_like_cpp() { + let (session, _) = make_session_with_send_capacity(1); + let remote_context = RepresentedLootPlayerContext { + race: 1, + class: 1, + gender: 0, + level: 80, + known_spells: Vec::new(), + active_quest_statuses: HashMap::new(), + active_quest_objective_counts: HashMap::new(), + rewarded_quests: HashSet::new(), + inventory_item_counts: HashMap::new(), + is_current: false, + }; + + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(6, 469, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(15, 1, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(16, 1, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(20, 0, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(27, 70, 3, 0), + &remote_context, + ), + Some(true) + ); + } + + #[test] + fn represented_personal_loot_remote_quest_and_spell_conditions_use_registry_like_cpp() { + let (session, _) = make_session_with_send_capacity(1); + let mut active_quest_statuses = HashMap::new(); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); + active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP); + let mut rewarded_quests = HashSet::new(); + rewarded_quests.insert(300); + let remote_context = RepresentedLootPlayerContext { + race: 1, + class: 1, + gender: 0, + level: 80, + known_spells: vec![12_345], + active_quest_statuses, + active_quest_objective_counts: HashMap::new(), + rewarded_quests, + inventory_item_counts: HashMap::new(), + is_current: false, + }; + + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(9, 100, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(28, 200, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(8, 300, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(14, 400, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + remote_context.quest_status(300), + QUEST_STATUS_REWARDED_LIKE_CPP + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(14, 300, 0, 0), + &remote_context, + ), + Some(false), + "C++ Player::GetQuestStatus returns REWARDED before QUEST_STATUS_NONE" + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(25, 12_345, 0, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(47, 100, 0x08, 0), + &remote_context, + ), + Some(true) + ); + } + + #[test] + fn represented_personal_loot_remote_inventory_and_objective_conditions_use_registry_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + let mut quest_store = QuestStore::new(); + let mut quest = test_quest_template(100); + quest.objectives.push(QuestObjective { + id: 11, + quest_id: 100, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: 7001, + amount: 7, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + quest_store.quests.insert(100, quest); + session.set_quest_store(Arc::new(quest_store)); + let mut active_quest_statuses = HashMap::new(); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); + let mut active_quest_objective_counts = HashMap::new(); + active_quest_objective_counts.insert(100, vec![5]); + let mut inventory_item_counts = HashMap::new(); + inventory_item_counts.insert(9001, 2); + let remote_context = RepresentedLootPlayerContext { + race: 1, + class: 1, + gender: 0, + level: 80, + known_spells: Vec::new(), + active_quest_statuses, + active_quest_objective_counts, + rewarded_quests: HashSet::new(), + inventory_item_counts, + is_current: false, + }; + + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(2, 9001, 2, 0), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(2, 9001, 3, 0), + &remote_context, + ), + Some(false) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(2, 9001, 2, 1), + &remote_context, + ), + None + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(48, 11, 0, 5), + &remote_context, + ), + Some(true) + ); + assert_eq!( + session.evaluate_creature_loot_condition_for_player_like_cpp_representable( + &loot_condition(48, 11, 0, 4), + &remote_context, + ), + Some(false) + ); + } + + #[test] + fn represented_personal_loot_remote_has_quest_for_item_objective_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + install_limited_test_item_template(&mut session, 7001, 0); + let mut quest_store = QuestStore::new(); + let mut quest = test_quest_template(100); + quest.objectives.push(QuestObjective { + id: 1, + quest_id: 100, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: 7001, + amount: 3, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + quest_store.quests.insert(100, quest); + session.set_quest_store(Arc::new(quest_store)); + + let mut active_quest_statuses = HashMap::new(); + active_quest_statuses.insert(100, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); + let mut active_quest_objective_counts = HashMap::new(); + active_quest_objective_counts.insert(100, vec![2]); + let mut remote_context = RepresentedLootPlayerContext { + race: 1, + class: 1, + gender: 0, + level: 80, + known_spells: Vec::new(), + active_quest_statuses, + active_quest_objective_counts, + rewarded_quests: HashSet::new(), + inventory_item_counts: HashMap::new(), + is_current: false, + }; + + assert!(session.item_loot_quest_status_allows_for_player_like_cpp( + 7001, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &remote_context, + )); + + remote_context + .active_quest_objective_counts + .insert(100, vec![3]); + assert!(!session.item_loot_quest_status_allows_for_player_like_cpp( + 7001, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &remote_context, + )); + } + + #[test] + fn represented_personal_loot_remote_has_quest_for_item_drop_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + install_limited_test_item_template(&mut session, 7002, 0); + let mut quest_store = QuestStore::new(); + let mut quest = test_quest_template(200); + quest.item_drop[0] = 7002; + quest.item_drop_quantity[0] = 4; + quest_store.quests.insert(200, quest); + session.set_quest_store(Arc::new(quest_store)); + + let mut active_quest_statuses = HashMap::new(); + active_quest_statuses.insert(200, crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP); + let mut inventory_item_counts = HashMap::new(); + inventory_item_counts.insert(7002, 3); + let mut remote_context = RepresentedLootPlayerContext { + race: 1, + class: 1, + gender: 0, + level: 80, + known_spells: Vec::new(), + active_quest_statuses, + active_quest_objective_counts: HashMap::new(), + rewarded_quests: HashSet::new(), + inventory_item_counts, + is_current: false, + }; + + assert!(session.item_loot_quest_status_allows_for_player_like_cpp( + 7002, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &remote_context, + )); + + remote_context.inventory_item_counts.insert(7002, 4); + assert!(!session.item_loot_quest_status_allows_for_player_like_cpp( + 7002, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + &remote_context, + )); + } + + #[tokio::test] + async fn loot_item_added_progresses_incomplete_quest_item_objective_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(1); + let quest_id = 8_336; + let item_id = 20_482; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 6, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![0], + slot: 0, + }, + ); + + assert!(session.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let changed_quest_ids = session + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 3) + .await; + + assert_eq!(changed_quest_ids, vec![quest_id]); + assert_eq!( + session + .player_quests + .get(&quest_id) + .expect("quest progress should remain active") + .objective_counts, + vec![3] + ); + assert!( + send_rx.try_recv().is_err(), + "C++ UpdateQuestObjectiveProgress suppresses generic credit packets for ITEM objectives" + ); + } + + #[test] + fn banked_quest_item_recomputes_objective_and_reopens_quest_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(1); + session.set_player_guid(Some(ObjectGuid::create_player(1, 42))); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 1, 0); + let quest_id = 8_338; + let item_id = 20_484; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 3, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![3], + slot: 0, + }, + ); + + let planned = session.plan_bank_item_quest_persistence_like_cpp(item_id, 0, true, 0, 0); + assert_eq!(planned.len(), 1); + assert_eq!(planned[0].objective_counts, vec![0]); + assert_eq!( + planned[0].status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + assert_eq!( + session.apply_quest_item_removed_like_cpp(item_id), + vec![quest_id] + ); + let status = session.player_quests.get(&quest_id).expect("active quest"); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP + ); + assert_eq!(status.objective_counts, vec![0]); + let update = send_rx + .try_recv() + .expect("banking a quest item should update the quest-log slot"); + assert_eq!( + WorldPacket::from_bytes(&update).server_opcode(), + Some(wow_constants::ServerOpcodes::UpdateObject) + ); + } + + #[tokio::test] + async fn withdrawn_banked_item_restores_bound_objective_like_cpp() { + let (mut session, _send_rx) = make_session_with_send_capacity(4); + let quest_id = 8_339; + let item_id = 20_485; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 1, + flags: 0, + flags2: 1, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![0], + slot: 0, + }, + ); + + let planned = session.plan_bank_item_quest_persistence_like_cpp(item_id, 0, false, 0, 1); + assert_eq!(planned.len(), 1); + assert_eq!(planned[0].objective_counts, vec![1]); + assert_eq!( + planned[0].status, + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + ); + let changed_quest_ids = session + .apply_quest_item_added_objective_progress_like_cpp(item_id, 0, 1) + .await; + + assert_eq!(changed_quest_ids, vec![quest_id]); + let status = session.player_quests.get(&quest_id).expect("active quest"); + assert_eq!(status.objective_counts, vec![1]); + assert_eq!( + status.status, + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP + ); + } + + #[tokio::test] + async fn loot_item_eligibility_does_not_treat_complete_quest_as_incomplete_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(1); + let quest_id = 8_337; + let item_id = 20_483; + let mut quest = test_quest_template(quest_id); + quest.objectives.push(QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: 1, + order: 0, + storage_index: 0, + object_id: item_id as i32, + amount: 1, + flags: 0, + flags2: 0, + progress_bar_weight: 0.0, + description: String::new(), + }); + session.set_quest_store(Arc::new(QuestStore::from_quests_like_cpp([quest]))); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: vec![0], + slot: 0, + }, + ); + + assert!(!session.has_incomplete_quest_objective_for_item_like_cpp(item_id)); + assert!(!session.item_loot_quest_status_allows_like_cpp( + item_id, + true, + ItemTemplateAddonLootMetadataLikeCpp::default(), + )); + + let changed_quest_ids = session + .apply_quest_source_item_added_non_bound_objective_progress_like_cpp(item_id, 0, 1) + .await; + + assert!(changed_quest_ids.is_empty()); + assert_eq!( + session + .player_quests + .get(&quest_id) + .expect("complete quest should not progress as incomplete") + .objective_counts, + vec![0] + ); + } + + #[tokio::test] + async fn quest_required_creature_loot_is_not_generated_after_completion_like_cpp() { + let (mut session, _) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 42); + let quest_id = 8_336; + let item_id = 20_482; + let loot_id = 15_274; + session.set_player_guid(Some(player_guid)); + install_limited_test_item_template(&mut session, item_id, 0); + install_quest_bound_loot_objective_like_cpp(&mut session, quest_id, item_id, 6, 6); + session.player_quests.get_mut(&quest_id).unwrap().status = + crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP; + + let mut creature_store = LootStore::for_kind_like_cpp(LootStoreKind::Creature); + creature_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: true, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Creature, creature_store); + session.set_loot_stores(Arc::new(stores)); + + let complete_loot = session + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + .unwrap(); + assert!( + complete_loot.is_empty(), + "C++ LootItem::AllowedForPlayer rejects QuestRequired items after HasQuestForItem becomes false" + ); + + let status = session.player_quests.get_mut(&quest_id).unwrap(); + status.status = crate::conditions::QUEST_STATUS_INCOMPLETE_LIKE_CPP; + status.objective_counts[0] = 5; + let incomplete_loot = session + .generate_represented_creature_loot_items_for_player_like_cpp(loot_id, player_guid) + .await + .unwrap(); + assert_eq!(incomplete_loot.len(), 1); + assert!(incomplete_loot[0].flags.needs_quest); + } + + fn install_master_loot_group( + session: &mut WorldSession, + master_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) { + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(master_guid); + group.add_member(candidate_guid); + group.loot_method = LOOT_METHOD_MASTER_LIKE_CPP; + group.master_looter_guid = master_guid; + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + session.group_guid = Some(group_guid); + session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + } + + fn install_group_loot_group( + session: &mut WorldSession, + leader_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) { + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(leader_guid); + group.add_member(candidate_guid); + group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + session.group_guid = Some(group_guid); + session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + } + + fn generation_guarded_group_loot_like_cpp( + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) -> CreatureLoot { + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(owner_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid, candidate_guid], + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags { + follow_loot_rules: true, + blocked: true, + ..Default::default() + }, + allowed_looters: vec![player_guid, candidate_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + } + } + + async fn open_generation_guarded_group_roll_like_cpp( + spawn_id: i64, + ) -> ( + WorldSession, + flume::Receiver>, + flume::Receiver>, + ObjectGuid, + ObjectGuid, + ObjectGuid, + ) { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let player_guid = ObjectGuid::create_player(1, 42); + let candidate_guid = ObjectGuid::create_player(1, 77); + let owner_guid = test_creature_guid(spawn_id); + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let (candidate_tx, candidate_rx) = flume::bounded::>(16); + let player_registry = Arc::new(PlayerRegistry::default()); + player_registry.insert(candidate_guid, broadcast_info(candidate_guid, candidate_tx)); + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + install_group_loot_group(&mut session, player_guid, candidate_guid); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.loot_table.insert( + owner_guid, + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid), + ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); + + session.handle_loot_unit(loot_unit_packet(owner_guid)).await; + while send_rx.try_recv().is_ok() {} + while candidate_rx.try_recv().is_ok() {} + + let state = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .expect("first loot generation should start the group roll"); + assert_eq!(state.owner_guid, owner_guid); + assert_eq!( + state.authority_generation, + session + .represented_loot_cache_generations_like_cpp + .get(&owner_guid) + .copied() + .expect("opened loot cache should be generation-tagged") + ); + + ( + session, + send_rx, + candidate_rx, + player_guid, + candidate_guid, + owner_guid, + ) + } + + fn replace_generation_guarded_group_loot_like_cpp( + session: &mut WorldSession, + owner_guid: ObjectGuid, + player_guid: ObjectGuid, + candidate_guid: ObjectGuid, + ) -> u64 { + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .expect("test creature should expose its object-owned loot authority"); + let previous_generation = authority.generation_like_cpp(); + let retired_generation = authority.retire_like_cpp(); + let replacement = + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid); + let replacement_generation = authority + .replace_retired_generation_like_cpp( + retired_generation, + Some(replacement), + HashMap::new(), + ) + .expect("explicit test generation replaces the observed retired lifetime"); + assert!(replacement_generation > previous_generation); + replacement_generation + } + + fn install_limited_test_item_template(session: &mut WorldSession, entry: u32, max_count: i32) { + install_limited_test_item_template_with_flags2(session, entry, max_count, 0); + } + + fn install_limited_test_item_template_with_flags2( + session: &mut WorldSession, + entry: u32, + max_count: i32, + flags2: u32, + ) { + install_limited_test_item_template_with_flags2_and_bonding( + session, + entry, + max_count, + flags2, + ItemBondingType::None, + ); + } + + fn install_limited_test_item_template_with_flags2_and_bonding( + session: &mut WorldSession, + entry: u32, + max_count: i32, + flags2: u32, + bonding: ItemBondingType, + ) { + session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { + id: entry, + class_id: ItemClass::Consumable as u8, + subclass_id: 0, + material: 0, + inventory_type: InventoryType::NonEquip as i8, + sheathe_type: 0, + random_select: 0, + random_suffix_group_id: 0, + scaling_stat_distribution_id: 0, + scaling_stat_value: 0, + }]))); + session.set_item_stats_store(Arc::new(ItemStatsStore::from_sparse_templates([( + entry, + ItemSparseTemplateEntry { + flags: [0, flags2, 0, 0], + bag_family: 0, + start_quest_id: 0, + stackable: 20, + max_count, + lock_id: 0, + required_reputation_rank: 0, + sell_price: 0, + buy_price: 0, + vendor_stack_count: 1, + price_variance: 1.0, + price_random_value: 1.0, + max_durability: 0, + other_faction_item_id: 0, + content_tuning_id: 0, + player_level_to_item_level_curve_id: 0, + limit_category: 0, + instance_bound: 0, + zone_bound: [0, 0], + required_reputation_faction: 0, + allowable_class: -1, + required_expansion: 0, + bonding: bonding as u8, + container_slots: 0, + inventory_type: InventoryType::NonEquip as i8, + }, + )]))); + } + + fn install_disenchantable_test_item_template(session: &mut WorldSession, entry: u32) { + session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { id: entry, + class_id: ItemClass::Armor as u8, + subclass_id: 0, + material: 0, + inventory_type: InventoryType::Chest as i8, + sheathe_type: 0, + random_select: 0, + random_suffix_group_id: 0, + scaling_stat_distribution_id: 0, + scaling_stat_value: 0, + }]))); + session.set_item_stats_store(Arc::new( + ItemStatsStore::from_sparse_and_random_property_templates( + [( + entry, + ItemSparseTemplateEntry { + flags: [0, 0, 0, 0], + bag_family: 0, + start_quest_id: 0, + stackable: 1, + max_count: 0, + lock_id: 0, + required_reputation_rank: 0, + sell_price: 1, + buy_price: 0, + vendor_stack_count: 1, + price_variance: 1.0, + price_random_value: 1.0, + max_durability: 0, + other_faction_item_id: 0, + content_tuning_id: 0, + player_level_to_item_level_curve_id: 0, + limit_category: 0, + instance_bound: 0, + zone_bound: [0, 0], + required_reputation_faction: 0, + allowable_class: -1, + required_expansion: 0, + bonding: ItemBondingType::None as u8, + container_slots: 0, + inventory_type: InventoryType::Chest as i8, + }, + )], + [( + entry, + ItemRandomPropertyTemplateEntry { + item_level: 10, + quality: ItemQuality::Rare as i8, + inventory_type: InventoryType::Chest as i8, + }, + )], + ), + )); + session.set_item_disenchant_loot_store(Arc::new(ItemDisenchantLootStore::from_entries([ + ItemDisenchantLootEntry { + id: 901, + subclass: 0, + quality: ItemQuality::Rare as u8, + min_level: 1, + max_level: 20, + skill_required: 175, + expansion_id: -2, + class_id: ItemClass::Armor as u32, + }, + ]))); + } + + fn install_active_spell_cast(session: &mut WorldSession, player_guid: ObjectGuid) { + session.active_spell_cast = Some(SpellCastState { + spell_id: 133, + target_guid: player_guid, + target_data: wow_packet::packets::spell::SpellTargetData { + flags: 0x2, + unit: player_guid, + ..Default::default() + }, + cast_id: ObjectGuid::create_world_object(HighGuid::Cast, 0, 1, 0, 0, 1, 7), + cast_start_time: std::time::Instant::now(), + cast_time_ms: 30_000, + spell_visual: wow_packet::packets::spell::SpellCastVisual { + spell_visual_id: 1, + script_visual_id: 0, + }, + metadata: crate::session::SpellCastMetadata::default(), + }); + } + + fn install_visible_aura_with_interrupt_flags( + session: &mut WorldSession, + slot: u8, + spell_id: i32, + caster_guid: ObjectGuid, + aura_interrupt_flags: u32, + ) { + session.visible_auras.insert( + slot, + AuraApplication { + spell_id, + caster_guid, + slot, + duration_total: 30_000, + duration_remaining: 30_000, + stack_count: 1, + aura_flags: 0x0000_0001, + effect_mask: 0x0000_0001, + aura_interrupt_flags, + aura_interrupt_flags2: 0, + represented_effect: None, + represented_amount: 0, + represented_effect_amounts: Vec::new(), + represented_misc_value: None, + represented_multiplier: 1.0, + applied_at: std::time::Instant::now(), + }, + ); + } + + #[tokio::test] + async fn represented_creature_money_uses_cpp_money_drop_rate() { + let mut session = make_session(); + let owner_guid = test_creature_guid(1); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + money: 2.5, + ..LootDropRatesLikeCpp::default() + }); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 100, + 100, + 0, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.coins, 250); + assert!(loot.items.is_empty()); + } + + #[tokio::test] + async fn represented_creature_money_zero_gold_max_stays_zero_like_cpp() { + let mut session = make_session(); + let owner_guid = test_creature_guid(1); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 0, + 0, + 0, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.coins, 0); + assert!(loot.items.is_empty()); + } + + #[tokio::test] + async fn loot_response_success_keeps_cpp_failure_and_threshold_defaults() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 42); + let creature_guid = test_creature_guid(19_118); + session.set_player_guid(Some(player_guid)); + register_test_creature_like_cpp(&mut session, test_creature(creature_guid, false)); + + let group_registry = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(player_guid); + group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; + group.loot_threshold = 4; + let group_guid = group.group_guid; + group_registry.insert(group_guid, group); + session.group_guid = Some(group_guid); + session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + + session.loot_table.insert( + creature_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(creature_guid), + coins: 1, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: LOOT_METHOD_GROUP_LIKE_CPP, + loot_master: ObjectGuid::EMPTY, + round_robin_player: player_guid, + player_ffa_items: Vec::new(), + players_looting: Vec::new(), + allowed_looters: vec![player_guid], + items: Vec::new(), + looted_by_player: false, + }, + ); + install_cached_test_creature_loot_authority_like_cpp( + &mut session, + creature_guid, + player_guid, + ); + + let response = session + .represented_loot_response_for_owner_like_cpp(creature_guid, player_guid, false) + .await + .unwrap(); + + assert_eq!(response.loot_method, LOOT_METHOD_GROUP_LIKE_CPP); + assert_eq!( + response.failure_reason, + LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP + ); + assert_eq!(response.threshold, LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP); + } + + #[tokio::test] + async fn loot_error_response_keeps_cpp_threshold_default_like_cpp() { + let (session, send_rx) = make_session_with_send(); + let owner = test_creature_guid(19_119); + let loot_obj = represented_loot_object_guid_like_cpp(owner); + + session.send_loot_error_like_cpp(loot_obj, owner, LOOT_ERROR_TOO_FAR_LIKE_CPP); + + let sent = send_rx.try_recv().unwrap(); + assert_eq!( + loot_response_failure_reason(&sent), + LOOT_ERROR_TOO_FAR_LIKE_CPP + ); + assert_eq!( + loot_response_threshold(&sent), + LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP + ); + } + + #[tokio::test] + async fn represented_creature_loot_generation_carries_cpp_dungeon_encounter_id() { + let mut session = make_session(); + let owner_guid = test_creature_guid(19_097); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + let loot = session + .generate_represented_creature_loot_like_cpp( + owner_guid, + ObjectGuid::create_player(1, 42), + 10, + 25, + 0, + 0, + 0, + 615, + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.dungeon_encounter_id, 615); + } + + struct OverworldPersonalLootTestFixtureLikeCpp { + session: WorldSession, + owner_guid: ObjectGuid, + first_tapper: ObjectGuid, + second_tapper: ObjectGuid, + disconnected_tapper: ObjectGuid, + normal_item_id: u32, + alliance_item_id: u32, + } + + fn overworld_personal_loot_test_fixture_like_cpp() -> OverworldPersonalLootTestFixtureLikeCpp { + let mut session = make_session(); + let first_tapper = ObjectGuid::create_player(1, 42); + let second_tapper = ObjectGuid::create_player(1, 43); + let disconnected_tapper = ObjectGuid::create_player(1, 44); + let owner_guid = test_creature_guid(19_098); + let loot_id = 90_001; + let normal_item_id = 80_101; + let alliance_item_id = 80_102; + + session.set_player_guid(Some(first_tapper)); + session.set_loaded_player_identity_like_cpp(0, 1, 1, 10, 0); + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + + let registry = Arc::new(PlayerRegistry::default()); + let (second_tx, _second_rx) = flume::bounded(1); + let mut second = broadcast_info(second_tapper, second_tx); + second.race = 2; + registry.insert(second_tapper, second); + let (disconnected_tx, _disconnected_rx) = flume::bounded(1); + let mut disconnected = broadcast_info(disconnected_tapper, disconnected_tx); + disconnected.is_in_world = false; + registry.insert(disconnected_tapper, disconnected); + session.set_player_registry(registry); + + let item_record = |id| ItemRecord { + id, class_id: ItemClass::Consumable as u8, subclass_id: 0, material: 0, @@ -9804,317 +19230,994 @@ mod tests { random_suffix_group_id: 0, scaling_stat_distribution_id: 0, scaling_stat_value: 0, - }]))); - session.set_item_stats_store(Arc::new(ItemStatsStore::from_sparse_templates([( - entry, - ItemSparseTemplateEntry { - flags: [0, flags2, 0, 0], - bag_family: 0, - start_quest_id: 0, - stackable: 20, - max_count, - lock_id: 0, - required_reputation_rank: 0, - sell_price: 0, - buy_price: 0, - vendor_stack_count: 1, - price_variance: 1.0, - price_random_value: 1.0, - max_durability: 0, - other_faction_item_id: 0, - content_tuning_id: 0, - player_level_to_item_level_curve_id: 0, - limit_category: 0, - instance_bound: 0, - zone_bound: [0, 0], - required_reputation_faction: 0, - allowable_class: -1, - required_expansion: 0, - bonding: ItemBondingType::None as u8, - container_slots: 0, - inventory_type: InventoryType::NonEquip as i8, - }, - )]))); + }; + let sparse_template = |flags2| ItemSparseTemplateEntry { + flags: [0, flags2, 0, 0], + bag_family: 0, + start_quest_id: 0, + stackable: 20, + max_count: 0, + lock_id: 0, + required_reputation_rank: 0, + sell_price: 0, + buy_price: 0, + vendor_stack_count: 1, + price_variance: 1.0, + price_random_value: 1.0, + max_durability: 0, + other_faction_item_id: 0, + content_tuning_id: 0, + player_level_to_item_level_curve_id: 0, + limit_category: 0, + instance_bound: 0, + zone_bound: [0, 0], + required_reputation_faction: 0, + allowable_class: -1, + required_expansion: 0, + bonding: ItemBondingType::None as u8, + container_slots: 0, + inventory_type: InventoryType::NonEquip as i8, + }; + session.set_item_store(Arc::new(ItemStore::from_records([ + item_record(normal_item_id), + item_record(alliance_item_id), + ]))); + session.set_item_stats_store(Arc::new(ItemStatsStore::from_sparse_templates([ + (normal_item_id, sparse_template(0)), + ( + alliance_item_id, + sparse_template(ItemFlags2::FactionAlliance as u32), + ), + ]))); + + let mut creature_store = LootStore::for_kind_like_cpp(LootStoreKind::Creature); + creature_store + .load_rows_like_cpp( + [normal_item_id, alliance_item_id].map(|item_id| LootTemplateRow { + entry: loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }), + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Creature, creature_store); + session.set_loot_stores(Arc::new(stores)); + + let mut creature = test_creature(owner_guid, false); + creature.entry = 9_001; + creature.level = 10; + creature.loot_id = loot_id; + creature.gold_min = 7; + creature.gold_max = 7; + register_test_creature_like_cpp(&mut session, creature); + session.mutate_world_creature(owner_guid, |world_creature| { + world_creature + .creature + .set_tapped_by_player(disconnected_tapper, &[first_tapper, second_tapper]); + }); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); + + OverworldPersonalLootTestFixtureLikeCpp { + session, + owner_guid, + first_tapper, + second_tapper, + disconnected_tapper, + normal_item_id, + alliance_item_id, + } + } + + fn assert_overworld_personal_loot_generation_like_cpp( + authority: &OwnedLootAuthority, + fixture: &OverworldPersonalLootTestFixtureLikeCpp, + ) -> (u8, u8) { + assert!(authority.shared_snapshot_like_cpp().is_none()); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 2); + assert!(!personal.contains_key(&fixture.disconnected_tapper)); + + let first = &personal[&fixture.first_tapper].loot; + let second = &personal[&fixture.second_tapper].loot; + assert_ne!(first.loot_guid, second.loot_guid); + for (tapper, loot) in [ + (fixture.first_tapper, first), + (fixture.second_tapper, second), + ] { + assert_eq!(loot.loot_guid.high_type(), HighGuid::LootObject); + assert_eq!(loot.coins, 7); + assert_eq!(loot.loot_method, 0); + assert_eq!(loot.allowed_looters, vec![tapper]); + assert!( + loot.items + .iter() + .all(|item| item.allowed_looters == vec![tapper]) + ); + } + assert!( + first + .items + .iter() + .any(|item| item.item_id == fixture.normal_item_id) + ); + assert!( + first + .items + .iter() + .any(|item| item.item_id == fixture.alliance_item_id) + ); + assert_eq!( + second + .items + .iter() + .map(|item| item.item_id) + .collect::>(), + vec![fixture.normal_item_id], + "FillLoot eligibility must run for the Horde tapper instead of cloning the first pool" + ); + + let first_normal_slot = first + .items + .iter() + .find(|item| item.item_id == fixture.normal_item_id) + .unwrap() + .loot_list_id; + (first_normal_slot, second.items[0].loot_list_id) + } + + async fn assert_overworld_personal_loot_claims_are_independent_like_cpp( + authority: &OwnedLootAuthority, + first_tapper: ObjectGuid, + first_normal_slot: u8, + second_tapper: ObjectGuid, + second_normal_slot: u8, + ) { + assert!( + authority + .reserve_item_like_cpp(first_tapper, first_normal_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + !authority + .personal_snapshot_like_cpp(second_tapper) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + authority + .reserve_money_like_cpp(first_tapper) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert_eq!( + authority + .personal_snapshot_like_cpp(second_tapper) + .unwrap() + .loot + .coins, + 7 + ); + assert!( + authority + .reserve_item_like_cpp(second_tapper, second_normal_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); + assert!( + authority + .reserve_money_like_cpp(second_tapper) + .await + .unwrap() + .commit_like_cpp() + .unwrap() + ); } - fn install_disenchantable_test_item_template(session: &mut WorldSession, entry: u32) { - session.set_item_store(Arc::new(ItemStore::from_records([ItemRecord { - id: entry, - class_id: ItemClass::Armor as u8, - subclass_id: 0, - material: 0, - inventory_type: InventoryType::Chest as i8, - sheathe_type: 0, - random_select: 0, - random_suffix_group_id: 0, - scaling_stat_distribution_id: 0, - scaling_stat_value: 0, - }]))); - session.set_item_stats_store(Arc::new( - ItemStatsStore::from_sparse_and_random_property_templates( - [( - entry, - ItemSparseTemplateEntry { - flags: [0, 0, 0, 0], - bag_family: 0, - start_quest_id: 0, - stackable: 1, - max_count: 0, - lock_id: 0, - required_reputation_rank: 0, - sell_price: 1, - buy_price: 0, - vendor_stack_count: 1, - price_variance: 1.0, - price_random_value: 1.0, - max_durability: 0, - other_faction_item_id: 0, - content_tuning_id: 0, - player_level_to_item_level_curve_id: 0, - limit_category: 0, - instance_bound: 0, - zone_bound: [0, 0], - required_reputation_faction: 0, - allowable_class: -1, - required_expansion: 0, - bonding: ItemBondingType::None as u8, - container_slots: 0, - inventory_type: InventoryType::Chest as i8, - }, - )], - [( - entry, - ItemRandomPropertyTemplateEntry { - item_level: 10, - quality: ItemQuality::Rare as i8, - inventory_type: InventoryType::Chest as i8, - }, - )], - ), - )); - session.set_item_disenchant_loot_store(Arc::new(ItemDisenchantLootStore::from_entries([ - ItemDisenchantLootEntry { - id: 901, - subclass: 0, - quality: ItemQuality::Rare as u8, - min_level: 1, - max_level: 20, - skill_required: 175, - expansion_id: -2, - class_id: ItemClass::Armor as u32, - }, - ]))); + #[tokio::test] + async fn overworld_creature_builds_independent_personal_loot_per_connected_tapper_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .expect("the dead creature keeps its object-owned loot authority"); + let (first_normal_slot, second_normal_slot) = + assert_overworld_personal_loot_generation_like_cpp(&authority, &fixture); + assert_overworld_personal_loot_claims_are_independent_like_cpp( + &authority, + fixture.first_tapper, + first_normal_slot, + fixture.second_tapper, + second_normal_slot, + ) + .await; } - fn install_active_spell_cast(session: &mut WorldSession, player_guid: ObjectGuid) { - session.active_spell_cast = Some(SpellCastState { - spell_id: 133, - target_guid: player_guid, - target_data: wow_packet::packets::spell::SpellTargetData { - flags: 0x2, - unit: player_guid, - ..Default::default() - }, - cast_id: ObjectGuid::create_world_object(HighGuid::Cast, 0, 1, 0, 0, 1, 7), - cast_start_time: std::time::Instant::now(), - cast_time_ms: 30_000, - spell_visual: wow_packet::packets::spell::SpellCastVisual { - spell_visual_id: 1, - script_visual_id: 0, - }, - metadata: crate::session::SpellCastMetadata::default(), - }); + #[tokio::test] + async fn dungeon_encounter_builds_independent_unlocked_personal_pools_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + let encounter_id = 733; + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.ai_ownership_mut().dungeon_encounter_id = encounter_id; + }); + fixture + .session + .represented_locked_dungeon_encounters + .insert((fixture.second_tapper, encounter_id)); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + assert!(personal.contains_key(&fixture.first_tapper)); + assert!(!personal.contains_key(&fixture.second_tapper)); + assert!(!personal.contains_key(&fixture.disconnected_tapper)); + let first = &personal[&fixture.first_tapper].loot; + assert_eq!(first.dungeon_encounter_id, encounter_id); + assert_eq!(first.allowed_looters, vec![fixture.first_tapper]); } - fn install_visible_aura_with_interrupt_flags( - session: &mut WorldSession, - slot: u8, - spell_id: i32, - caster_guid: ObjectGuid, - aura_interrupt_flags: u32, - ) { - session.visible_auras.insert( - slot, - AuraApplication { - spell_id, - caster_guid, - slot, - duration_total: 30_000, - duration_remaining: 30_000, - stack_count: 1, - aura_flags: 0x0000_0001, - effect_mask: 0x0000_0001, - aura_interrupt_flags, - aura_interrupt_flags2: 0, - represented_effect: None, - represented_amount: 0, - represented_effect_amounts: Vec::new(), - represented_misc_value: None, - represented_multiplier: 1.0, - applied_at: std::time::Instant::now(), - }, + #[tokio::test] + async fn dungeon_trash_builds_one_personal_pool_for_selected_group_looter_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 0, + instance_type: wow_data::map::MAP_INSTANCE, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + let groups = Arc::new(GroupRegistry::default()); + let mut group = GroupInfo::new(fixture.first_tapper); + group.add_member(fixture.second_tapper); + group.looter_guid = fixture.second_tapper; + let group_guid = group.group_guid; + groups.insert(group_guid, group); + fixture.session.group_guid = Some(group_guid); + fixture + .session + .set_group_registry(Arc::clone(&groups), Arc::new(PendingInvites::default())); + + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + assert!(!personal.contains_key(&fixture.first_tapper)); + let selected = &personal[&fixture.second_tapper].loot; + assert_eq!(selected.dungeon_encounter_id, 0); + assert_eq!(selected.allowed_looters, vec![fixture.second_tapper]); + assert!( + selected + .items + .iter() + .all(|entry| { entry.allowed_looters == vec![fixture.second_tapper] }) + ); + assert_eq!( + groups.get(&group_guid).unwrap().looter_guid_like_cpp(), + fixture.first_tapper, + "non-empty dungeon trash advances the round-robin group looter" + ); + } + + #[tokio::test] + async fn cmsg_loot_unit_never_regenerates_after_creature_clear_loot_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.clear_loot_like_cpp(); + }); + let retired_generation = authority.generation_like_cpp(); + + assert!( + fixture + .session + .represented_loot_response_for_owner_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + false, + ) + .await + .is_none() + ); + assert!(authority.is_retired_like_cpp()); + assert_eq!(authority.generation_like_cpp(), retired_generation); + assert!( + authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .is_none() + ); + } + + #[test] + fn stale_kill_generator_cannot_install_after_creature_lifecycle_aba_like_cpp() { + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let expected_generation = authority.generation_like_cpp(); + let expected_revision = fixture + .session + .represented_creature_loot_state_like_cpp(fixture.owner_guid) + .unwrap() + .loot_lifecycle_revision; + let mut stale_pool = authoritative_test_loot_like_cpp(0, true); + stale_pool.loot_guid = represented_loot_object_guid_like_cpp(fixture.owner_guid); + stale_pool.allowed_looters = vec![fixture.first_tapper]; + stale_pool.items[0].allowed_looters = vec![fixture.first_tapper]; + + fixture + .session + .mutate_world_creature(fixture.owner_guid, |creature| { + creature.creature.clear_loot_like_cpp(); + creature + .creature + .set_death_state_runtime(wow_constants::DeathState::JustRespawned, 0); + creature + .creature + .set_death_state_runtime(wow_constants::DeathState::JustDied, 0); + }); + + assert!( + !fixture + .session + .install_represented_creature_kill_loot_if_current_like_cpp( + fixture.owner_guid, + &authority, + expected_generation, + expected_revision, + None, + HashMap::from([(fixture.first_tapper, stale_pool)]), + ) + ); + assert!(authority.is_retired_like_cpp()); + assert!( + authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .is_none() + ); + } + + #[tokio::test] + async fn represented_gameobject_chest_loot_carries_cpp_source_metadata() { + let mut session = make_session(); + let gameobject_guid = test_gameobject_guid(91_001); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); + let source = GameObjectLootSource { + loot_id: 0, + use_group_loot_rules: false, + dungeon_encounter_id: 733, + personal_loot_id: 10_001, + push_loot_id: 0, + triggered_event_id: 0, + linked_trap_entry: 0, + ..Default::default() + }; + + let loot = session + .generate_represented_gameobject_chest_loot_like_cpp( + gameobject_guid, + ObjectGuid::create_player(1, 42), + source, + &[], + ) + .await + .expect("canonical owner map allocates a LootObject"); + + assert_eq!(loot.loot_type, LOOT_TYPE_CHEST_LIKE_CPP); + assert_eq!(loot.dungeon_encounter_id, 733); + assert_eq!(loot.loot_method, 0); + } + + #[tokio::test] + async fn represented_non_encounter_personal_chest_keeps_two_session_pools_independent_like_cpp() + { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut second, _second_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 42); + let second_player = ObjectGuid::create_player(1, 77); + let gameobject_guid = test_gameobject_guid(91_015); + let personal_loot_id = 10_015; + let item_id = 80_015; + + first.set_player_guid(Some(first_player)); + second.set_player_guid(Some(second_player)); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone( + first + .canonical_map_manager + .as_ref() + .expect("both sessions share the canonical map owner"), + )); + + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut second, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, + ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + second.set_loot_stores(stores); + + let source = GameObjectLootSource { + loot_id: 0, + use_group_loot_rules: true, + dungeon_encounter_id: 0, + personal_loot_id, + ..Default::default() + }; + + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + second + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .expect("canonical chest remains map-owned") + .loot_authority_like_cpp() + .clone(); + assert!(authority.shared_snapshot_like_cpp().is_none()); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 2); + + let first_pool = personal.get(&first_player).unwrap(); + let second_pool = personal.get(&second_player).unwrap(); + assert_ne!(first_pool.loot.loot_guid, second_pool.loot.loot_guid); + assert_eq!(first_pool.loot.loot_method, 0); + assert_eq!(second_pool.loot.loot_method, 0); + for (player, pool) in [(first_player, first_pool), (second_player, second_pool)] { + assert_eq!(pool.loot.allowed_looters, vec![player]); + assert_eq!(pool.loot.items.len(), 1); + assert_eq!(pool.loot.items[0].item_id, item_id); + assert_eq!(pool.loot.items[0].allowed_looters, vec![player]); + } + + let first_slot = first_pool.loot.items[0].loot_list_id; + authority + .reserve_item_like_cpp(first_player, first_slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .snapshot_for_player_like_cpp(first_player) + .unwrap() + .loot + .items[0] + .taken + ); + assert!( + !authority + .snapshot_for_player_like_cpp(second_player) + .unwrap() + .loot + .items[0] + .taken + ); + } + + #[tokio::test] + async fn represented_empty_non_encounter_personal_chest_still_opens_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 141); + let gameobject_guid = test_gameobject_guid(91_021); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, ); + attach_canonical_gameobject(&mut session, gameobject); + + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 0, + personal_loot_id: 10_021, + ..Default::default() + }, + ) + .await; + + let authority = canonical_gameobject_snapshot(&session, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let pool = authority + .snapshot_for_player_like_cpp(player_guid) + .expect("C++ retains the empty non-encounter personal pool"); + assert!(loot_is_looted_like_cpp(&pool.loot)); + assert_eq!(pool.loot.allowed_looters, vec![player_guid]); + assert!(session.is_active_loot_guid(gameobject_guid)); + let _response = + recv_packet_with_opcode(&send_rx, wow_constants::ServerOpcodes::LootResponse); } #[tokio::test] - async fn represented_creature_money_uses_cpp_money_drop_rate() { - let mut session = make_session(); - session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { - money: 2.5, - ..LootDropRatesLikeCpp::default() - }); + async fn represented_personal_encounter_late_session_without_canonical_tap_list_fails_closed() { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut second, second_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 142); + let second_player = ObjectGuid::create_player(1, 177); + let gameobject_guid = test_gameobject_guid(91_018); + let personal_loot_id = 10_018; + let item_id = 80_018; + + first.set_player_guid(Some(first_player)); + second.set_player_guid(Some(second_player)); + first.set_player_position_like_cpp(Position::ZERO); + second.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone( + first + .canonical_map_manager + .as_ref() + .expect("both sessions share the canonical map owner"), + )); - let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(1), - ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 100, - 100, - 0, + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut second, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + second.set_loot_stores(stores); + + let source = GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id, + ..Default::default() + }; + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let first_before = authority + .snapshot_for_player_like_cpp(first_player) + .expect("the first opener owns the initial encounter pool"); + + second + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) .await; - assert_eq!(loot.coins, 250); - assert!(loot.items.is_empty()); + let personal = authority.personal_snapshots_like_cpp(); + assert_eq!(personal.len(), 1); + let first_after = personal.get(&first_player).unwrap(); + assert_eq!(first_after, &first_before); + assert!( + authority + .snapshot_for_player_like_cpp(second_player) + .is_none(), + "without canonical GameObject::GetTapList state Rust must not fabricate outsider loot" + ); + assert!(!second.is_active_loot_guid(gameobject_guid)); + assert!(second_rx.try_recv().is_err()); } #[tokio::test] - async fn represented_creature_money_zero_gold_max_stays_zero_like_cpp() { - let mut session = make_session(); + async fn represented_personal_encounter_locked_or_empty_late_player_does_not_install_like_cpp() + { + let (mut first, _first_rx) = make_session_with_send_capacity(8); + let (mut locked, locked_rx) = make_session_with_send_capacity(8); + let (mut empty, empty_rx) = make_session_with_send_capacity(8); + let first_player = ObjectGuid::create_player(1, 242); + let locked_player = ObjectGuid::create_player(1, 277); + let empty_player = ObjectGuid::create_player(1, 288); + let gameobject_guid = test_gameobject_guid(91_019); + let personal_loot_id = 10_019; + let item_id = 80_019; + let encounter_id = 734; + + for (session, player) in [ + (&mut first, first_player), + (&mut locked, locked_player), + (&mut empty, empty_player), + ] { + session.set_player_guid(Some(player)); + session.set_player_position_like_cpp(Position::ZERO); + } + let gameobject = make_canonical_gameobject_for_session( + &first, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + let manager = Arc::clone(first.canonical_map_manager.as_ref().unwrap()); + locked.set_canonical_map_manager(Arc::clone(&manager)); + empty.set_canonical_map_manager(manager); - let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(1), - ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 0, - 0, - 0, + install_limited_test_item_template(&mut first, item_id, 0); + install_limited_test_item_template(&mut locked, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, ) + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + let stores = Arc::new(stores); + first.set_loot_stores(Arc::clone(&stores)); + locked.set_loot_stores(stores); + locked + .represented_locked_dungeon_encounters + .insert((locked_player, encounter_id)); + + let source = GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: encounter_id, + personal_loot_id, + ..Default::default() + }; + first + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + let authority = canonical_gameobject_snapshot(&first, gameobject_guid) + .unwrap() + .loot_authority_like_cpp() + .clone(); + let first_before = authority + .snapshot_for_player_like_cpp(first_player) + .unwrap(); + + locked + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) + .await; + empty + .open_represented_gameobject_chest_like_cpp(gameobject_guid, source) .await; - assert_eq!(loot.coins, 0); - assert!(loot.items.is_empty()); + assert_eq!(authority.personal_snapshots_like_cpp().len(), 1); + assert_eq!( + authority + .snapshot_for_player_like_cpp(first_player) + .unwrap(), + first_before + ); + assert!( + authority + .snapshot_for_player_like_cpp(locked_player) + .is_none() + ); + assert!( + authority + .snapshot_for_player_like_cpp(empty_player) + .is_none() + ); + assert!(!locked.is_active_loot_guid(gameobject_guid)); + assert!(!empty.is_active_loot_guid(gameobject_guid)); + assert!(locked_rx.try_recv().is_err()); + assert!(empty_rx.try_recv().is_err()); } - #[tokio::test] - async fn loot_response_success_keeps_cpp_failure_and_threshold_defaults() { + #[test] + fn personal_encounter_late_upsert_cannot_cross_clear_loot_like_cpp() { let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let creature_guid = test_creature_guid(19_118); - session.set_player_guid(Some(player_guid)); - register_test_creature_like_cpp(&mut session, test_creature(creature_guid, false)); - - let group_registry = Arc::new(GroupRegistry::default()); - let mut group = GroupInfo::new(player_guid); - group.loot_method = LOOT_METHOD_GROUP_LIKE_CPP; - group.loot_threshold = 4; - let group_guid = group.group_guid; - group_registry.insert(group_guid, group); - session.group_guid = Some(group_guid); - session.set_group_registry(group_registry, Arc::new(PendingInvites::default())); + let first_player = ObjectGuid::create_player(1, 342); + let late_player = ObjectGuid::create_player(1, 377); + let gameobject_guid = test_gameobject_guid(91_020); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + let mut first_pool = authoritative_test_loot_like_cpp(0, true); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(gameobject_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first_player]; + first_pool.items[0].allowed_looters = vec![first_player]; + assert!( + gameobject + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool)]), + ) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + let observation = session + .represented_gameobject_loot_install_observation_like_cpp(gameobject_guid) + .expect("late generation observes the active chest lifetime"); - session.loot_table.insert( - creature_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(creature_guid), - coins: 1, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: player_guid, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, - }, + let mut stale_late_pool = authoritative_test_loot_like_cpp(0, true); + stale_late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + gameobject_guid.realm_id(), + gameobject_guid.map_id(), + 0, + 0, + gameobject_guid.counter() + 1, ); + stale_late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + stale_late_pool.allowed_looters = vec![late_player]; + stale_late_pool.items[0].allowed_looters = vec![late_player]; + session + .represented_personal_loot_owners + .insert(gameobject_guid); + session + .represented_personal_loot_money + .insert((gameobject_guid, late_player), 0); - let response = session - .represented_loot_response_for_owner_like_cpp(creature_guid, player_guid, false) - .await - .unwrap(); + session.mutate_canonical_gameobject_by_guid_like_cpp(gameobject_guid, |gameobject| { + gameobject.clear_loot_like_cpp(); + }); - assert_eq!(response.loot_method, LOOT_METHOD_GROUP_LIKE_CPP); - assert_eq!( - response.failure_reason, - LOOT_RESPONSE_DEFAULT_FAILURE_REASON_LIKE_CPP + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_if_observed_with_empty_policy_like_cpp( + gameobject_guid, + late_player, + stale_late_pool, + false, + true, + &observation, + ) + .is_none() ); - assert_eq!(response.threshold, LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP); + assert!(authority.is_retired_like_cpp()); + assert!(authority.personal_snapshots_like_cpp().is_empty()); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(gameobject_guid, late_player)) + ); + assert!(!session.loot_table.contains_key(&gameobject_guid)); } #[tokio::test] - async fn loot_error_response_keeps_cpp_threshold_default_like_cpp() { - let (session, send_rx) = make_session_with_send(); - let owner = test_creature_guid(19_119); - let loot_obj = represented_loot_object_guid_like_cpp(owner); + async fn represented_empty_personal_encounter_chest_does_not_install_or_open_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + let gameobject_guid = test_gameobject_guid(91_016); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut session, gameobject); - session.send_loot_error_like_cpp(loot_obj, owner, LOOT_ERROR_TOO_FAR_LIKE_CPP); + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id: 10_016, + ..Default::default() + }, + ) + .await; - let sent = send_rx.try_recv().unwrap(); - assert_eq!( - loot_response_failure_reason(&sent), - LOOT_ERROR_TOO_FAR_LIKE_CPP + let gameobject = canonical_gameobject_snapshot(&session, gameobject_guid).unwrap(); + assert!(gameobject.loot_authority_like_cpp().is_pristine_like_cpp()); + assert!( + gameobject + .loot_authority_like_cpp() + .personal_snapshots_like_cpp() + .is_empty() ); - assert_eq!( - loot_response_threshold(&sent), - LOOT_RESPONSE_DEFAULT_THRESHOLD_LIKE_CPP + assert!(!session.loot_table.contains_key(&gameobject_guid)); + assert!( + !session + .represented_personal_loot_owners + .contains(&gameobject_guid) + ); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(gameobject_guid, player_guid)) ); + assert!(!session.is_active_loot_guid(gameobject_guid)); + assert!(send_rx.try_recv().is_err()); } #[tokio::test] - async fn represented_creature_loot_generation_carries_cpp_dungeon_encounter_id() { - let mut session = make_session(); + async fn represented_nonempty_personal_encounter_chest_keeps_live_canonical_pool_like_cpp() { + let (mut session, _send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 42); + let gameobject_guid = test_gameobject_guid(91_017); + let personal_loot_id = 10_017; + let item_id = 80_017; + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + let gameobject = make_canonical_gameobject_for_session( + &session, + gameobject_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + attach_canonical_gameobject(&mut session, gameobject); - let loot = session - .generate_represented_creature_loot_like_cpp( - test_creature_guid(19_097), - ObjectGuid::create_player(1, 42), - 10, - 25, - 0, - 0, - 0, - 615, + install_limited_test_item_template(&mut session, item_id, 0); + let mut gameobject_store = LootStore::for_kind_like_cpp(LootStoreKind::Gameobject); + gameobject_store + .load_rows_like_cpp( + [LootTemplateRow { + entry: personal_loot_id, + item: LootStoreItem { + item_id, + reference: 0, + chance: 100.0, + needs_quest: false, + loot_mode: LOOT_MODE_DEFAULT_LIKE_CPP, + group_id: 0, + min_count: 1, + max_count: 1, + }, + }], + |_| true, ) - .await; - - assert_eq!(loot.dungeon_encounter_id, 615); - } - - #[tokio::test] - async fn represented_gameobject_chest_loot_carries_cpp_source_metadata() { - let mut session = make_session(); - let source = GameObjectLootSource { - loot_id: 0, - use_group_loot_rules: false, - dungeon_encounter_id: 733, - personal_loot_id: 10_001, - push_loot_id: 0, - triggered_event_id: 0, - linked_trap_entry: 0, - ..Default::default() - }; + .unwrap(); + let mut stores = LootStores::new(); + stores.insert(LootStoreKind::Gameobject, gameobject_store); + session.set_loot_stores(Arc::new(stores)); - let loot = session - .generate_represented_gameobject_chest_loot_like_cpp( - test_gameobject_guid(91_001), - ObjectGuid::create_player(1, 42), - source, + session + .open_represented_gameobject_chest_like_cpp( + gameobject_guid, + GameObjectLootSource { + loot_id: 0, + dungeon_encounter_id: 733, + personal_loot_id, + ..Default::default() + }, ) .await; - assert_eq!(loot.loot_type, LOOT_TYPE_CHEST_LIKE_CPP); - assert_eq!(loot.dungeon_encounter_id, 733); - assert_eq!(loot.loot_method, 0); + let gameobject = canonical_gameobject_snapshot(&session, gameobject_guid).unwrap(); + let pool = gameobject + .loot_authority_like_cpp() + .snapshot_for_player_like_cpp(player_guid) + .expect("nonempty encounter pool remains map-owned"); + assert_eq!(pool.loot.allowed_looters, vec![player_guid]); + assert_eq!(pool.loot.items.len(), 1); + assert_eq!(pool.loot.items[0].item_id, item_id); + assert!(!loot_is_looted_like_cpp(&pool.loot)); + assert!(session.is_active_loot_guid(gameobject_guid)); } #[tokio::test] @@ -10123,6 +20226,7 @@ mod tests { let mut session = make_session(); let player_guid = ObjectGuid::create_player(1, 42); let gameobject_guid = test_gameobject_guid(91_008); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); let source = GameObjectLootSource { loot_id: 0, use_group_loot_rules: false, @@ -10139,8 +20243,10 @@ mod tests { gameobject_guid, player_guid, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![player_guid]); assert!( @@ -10168,6 +20274,7 @@ mod tests { let second_tapper = ObjectGuid::create_player(1, 77); let non_player_tapper = ObjectGuid::create_item(1, 900); let gameobject_guid = test_gameobject_guid(91_009); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); session.represented_gameobject_tap_lists.insert( gameobject_guid, vec![ @@ -10193,8 +20300,10 @@ mod tests { gameobject_guid, first_tapper, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![first_tapper, second_tapper]); assert!( @@ -10226,6 +20335,7 @@ mod tests { let locked_tapper = ObjectGuid::create_player(1, 42); let open_tapper = ObjectGuid::create_player(1, 77); let gameobject_guid = test_gameobject_guid(91_010); + attach_loot_guid_allocator_for_owner(&mut session, gameobject_guid); session .represented_gameobject_tap_lists .insert(gameobject_guid, vec![locked_tapper, open_tapper]); @@ -10248,8 +20358,10 @@ mod tests { gameobject_guid, locked_tapper, source, + &[], ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.allowed_looters, vec![open_tapper]); assert!( @@ -11255,6 +21367,7 @@ mod tests { let master_guid = ObjectGuid::create_player(1, 42); let candidate_guid = ObjectGuid::create_player(1, 77); let owner_guid = test_creature_guid(19_049); + attach_loot_guid_allocator_for_owner(&mut session, owner_guid); install_master_loot_group(&mut session, master_guid, candidate_guid); let loot = session @@ -11268,13 +21381,29 @@ mod tests { 0, 0, ) - .await; + .await + .expect("canonical owner map allocates a LootObject"); assert_eq!(loot.loot_method, LOOT_METHOD_MASTER_LIKE_CPP); assert_eq!(loot.loot_master, master_guid); assert_eq!(loot.round_robin_player, master_guid); } + #[test] + fn represented_gameobject_group_loot_keeps_round_robin_empty_like_cpp() { + let mut session = make_session(); + let opener = ObjectGuid::create_player(1, 42); + let candidate = ObjectGuid::create_player(1, 77); + install_master_loot_group(&mut session, opener, candidate); + + let (loot_method, loot_master, round_robin_player) = + session.represented_gameobject_chest_group_state_like_cpp(true, opener); + + assert_eq!(loot_method, LOOT_METHOD_MASTER_LIKE_CPP); + assert_eq!(loot_master, opener); + assert_eq!(round_robin_player, ObjectGuid::EMPTY); + } + fn test_gameobject_guid(counter: i64) -> ObjectGuid { ObjectGuid::create_world_object(HighGuid::GameObject, 0, 1, 0, 0, 1, counter) } @@ -11304,6 +21433,10 @@ mod tests { assert!(sql.contains("randomPropertiesId")); assert!(sql.contains("randomPropertiesSeed")); assert!(sql.contains("context")); + assert!( + sql.contains("'', '', ?, ?, ?, ?"), + "stored-new-item flags must be a bound parameter, not the old hard-coded zero" + ); let item_guid = ObjectGuid::create_item(1, 902); let owner_guid = ObjectGuid::create_player(1, 42); @@ -11326,6 +21459,202 @@ mod tests { assert_eq!(u8::try_from(data.context).unwrap_or(0), 2); } + #[test] + fn stored_new_item_flags_follow_cpp_new_and_binding_rules() { + let mut session = make_session(); + let item_id = 25; + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + (ItemFieldFlags::NEW_ITEM | ItemFieldFlags::SOULBOUND).bits() + ); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::None, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + ItemFieldFlags::NEW_ITEM.bits(), + "an unbound backpack item must not acquire SOULBOUND" + ); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnEquip, + ); + assert_eq!( + session.stored_new_item_dynamic_flags_like_cpp(item_id, INVENTORY_SLOT_ITEM_START), + ItemFieldFlags::NEW_ITEM.bits(), + "C++ bind-if-stored does not bind OnEquip items in backpack slots" + ); + } + + #[test] + fn historical_stack_binding_adds_only_soulbound_like_cpp() { + let mut session = make_session(); + let item_id = 25; + let historical = Item::new(0); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + let flags = session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + INVENTORY_SLOT_ITEM_START, + &historical, + ); + assert_eq!(flags, ItemFieldFlags::SOULBOUND.bits()); + assert_eq!(flags & ItemFieldFlags::NEW_ITEM.bits(), 0); + + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnEquip, + ); + assert_eq!( + session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + wow_entities::INVENTORY_SLOT_BAG_START, + &historical, + ), + ItemFieldFlags::SOULBOUND.bits(), + "C++ binds an OnEquip item when that item is stored in a bag-equipment position" + ); + assert_eq!( + session.stored_existing_item_dynamic_flags_like_cpp( + item_id, + INVENTORY_SLOT_ITEM_START, + &historical, + ), + 0, + "C++ does not bind an OnEquip stack in an ordinary backpack slot" + ); + } + + #[tokio::test] + async fn existing_stack_count_and_binding_share_one_sql_transaction() { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .connect_lazy("mysql://rustycore:rustycore@127.0.0.1:1/characters") + .expect("syntactically valid lazy MySQL pool"); + let char_db = wow_database::CharacterDatabase::from_pool(pool); + + let mut bound_update = wow_database::SqlTransaction::new(); + WorldSession::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut bound_update, + 77, + 19, + Some(ItemFieldFlags::SOULBOUND.bits()), + ); + assert_eq!( + bound_update.len(), + 2, + "count and DynamicFlags must be committed or rolled back together" + ); + + let mut count_only_update = wow_database::SqlTransaction::new(); + WorldSession::append_existing_loot_stack_persistence_like_cpp( + &char_db, + &mut count_only_update, + 77, + 19, + None, + ); + assert_eq!(count_only_update.len(), 1); + } + + #[tokio::test] + async fn failed_existing_stack_store_publishes_neither_count_nor_binding() { + let (mut session, _send_rx) = make_session_with_send_capacity(4); + let player_guid = ObjectGuid::create_player(1, 42); + let item_guid = ObjectGuid::create_item(1, 77); + let item_id = 25; + session.set_player_guid(Some(player_guid)); + install_limited_test_item_template_with_flags2_and_bonding( + &mut session, + item_id, + 0, + 0, + ItemBondingType::OnAcquire, + ); + session.insert_inventory_item_like_cpp( + INVENTORY_SLOT_ITEM_START, + InventoryItem { + guid: item_guid, + entry_id: item_id, + db_guid: 77, + inventory_type: None, + }, + ); + let item = session.make_inventory_item_object( + item_guid, + item_id, + player_guid, + 4, + 0, + ItemContext::None, + INVENTORY_SLOT_ITEM_START, + ); + session.insert_inventory_item_object(item); + + let failing_pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(100)) + .connect_lazy("mysql://rustycore:rustycore@127.0.0.1:1/characters") + .expect("syntactically valid lazy MySQL pool"); + session.set_char_db(Arc::new(wow_database::CharacterDatabase::from_pool( + failing_pool, + ))); + + let stored = session + .store_direct_loot_item_like_cpp( + &LootEntry { + loot_list_id: 0, + item_id, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: vec![player_guid], + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }, + 0, + ) + .await; + + assert!(!stored); + let historical = session + .inventory_item_objects_like_cpp() + .get(&item_guid) + .expect("failed transaction keeps the historical stack"); + assert_eq!(historical.count(), 4); + assert_eq!(historical.item_flags_bits(), 0); + } + #[test] fn loot_item_random_context_stack_compatibility_uses_cpp_store_metadata() { let item_guid = ObjectGuid::create_item(1, 901); @@ -11992,6 +22321,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let response = send_rx.try_recv().unwrap(); @@ -12059,6 +22389,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; session.handle_loot_unit(loot_unit_packet(owner_guid)).await; @@ -12123,6 +22454,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, master_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12203,6 +22535,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let response = send_rx.try_recv().unwrap(); @@ -12336,6 +22669,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12407,6 +22741,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12581,6 +22916,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12642,6 +22978,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); @@ -12736,13 +23073,13 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); let _start_roll = send_rx.try_recv().unwrap(); let _remote_loot_list = candidate_rx.try_recv().unwrap(); let _remote_start_roll = candidate_rx.try_recv().unwrap(); - session .handle_loot_roll(LootRoll { loot_obj: loot_object, @@ -12826,6 +23163,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -12992,6 +23330,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -13075,6 +23414,101 @@ mod tests { } } + #[tokio::test] + async fn stale_loot_roll_vote_does_not_mutate_replacement_generation_like_cpp() { + let (mut session, send_rx, candidate_rx, player_guid, candidate_guid, owner_guid) = + open_generation_guarded_group_roll_like_cpp(19_060).await; + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let old_generation = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .authority_generation; + let replacement_generation = replace_generation_guarded_group_loot_like_cpp( + &mut session, + owner_guid, + player_guid, + candidate_guid, + ); + assert_ne!(old_generation, replacement_generation); + + session + .handle_loot_roll(LootRoll { + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_NEED_LIKE_CPP, + }) + .await; + + assert!( + !session + .represented_loot_rolls + .contains_key(&(loot_object, 0)), + "the stale roll must be cancelled instead of routed or voted" + ); + assert!(send_rx.try_recv().is_err()); + assert!(candidate_rx.try_recv().is_err()); + assert!(session.represented_loot_roll_criteria_events.is_empty()); + + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + let entry = &replacement.loot.items[0]; + assert!(entry.flags.blocked); + assert!(entry.roll_winner.is_empty()); + assert!(!entry.taken); + assert_eq!(replacement.loot.unlooted_count, 1); + } + + #[tokio::test] + async fn stale_loot_roll_expiry_does_not_mutate_replacement_generation_like_cpp() { + let (mut session, send_rx, candidate_rx, player_guid, candidate_guid, owner_guid) = + open_generation_guarded_group_roll_like_cpp(19_061).await; + let loot_object = represented_loot_object_guid_like_cpp(owner_guid); + let old_generation = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .authority_generation; + let replacement_generation = replace_generation_guarded_group_loot_like_cpp( + &mut session, + owner_guid, + player_guid, + candidate_guid, + ); + assert_ne!(old_generation, replacement_generation); + session + .represented_loot_rolls + .get_mut(&(loot_object, 0)) + .unwrap() + .end_time = Instant::now() - Duration::from_millis(1); + + session.tick_represented_loot_rolls_like_cpp().await; + + assert!( + !session + .represented_loot_rolls + .contains_key(&(loot_object, 0)), + "the stale timer must be cancelled without finishing against replacement loot" + ); + assert!(send_rx.try_recv().is_err()); + assert!(candidate_rx.try_recv().is_err()); + assert!(session.represented_loot_roll_criteria_events.is_empty()); + + let authority = session + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let replacement = authority.shared_snapshot_like_cpp().unwrap(); + assert_eq!(replacement.generation, replacement_generation); + let entry = &replacement.loot.items[0]; + assert!(entry.flags.blocked); + assert!(entry.roll_winner.is_empty()); + assert!(!entry.taken); + assert_eq!(replacement.loot.unlooted_count, 1); + } + #[tokio::test] async fn loot_roll_all_passed_unblocks_without_all_passed_to_valid_voters_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(8); @@ -13126,6 +23560,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); @@ -13247,12 +23682,19 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, owner_guid, player_guid); session.handle_loot_unit(loot_unit_packet(owner_guid)).await; let _response = send_rx.try_recv().unwrap(); let _loot_list = send_rx.try_recv().unwrap(); let _start_roll = send_rx.try_recv().unwrap(); let _remote_loot_list = candidate_rx.try_recv().unwrap(); let _remote_start_roll = candidate_rx.try_recv().unwrap(); + let roll_identity = session + .represented_loot_rolls + .get(&(loot_object, 0)) + .unwrap() + .command_identity + .clone(); session .session_command_tx() @@ -13262,6 +23704,7 @@ mod tests { loot_list_id: 0, roll_type: ROLL_VOTE_GREED_LIKE_CPP, pass_on_group_loot: false, + roll_identity, })) .unwrap(); session @@ -13298,6 +23741,55 @@ mod tests { ); } + #[test] + fn loot_roll_vote_command_accepts_exact_enqueued_roll_identity_like_cpp() { + let loot_object = represented_loot_object_guid_like_cpp(test_creature_guid(19_062)); + let authority = OwnedLootAuthority::new(); + let roll_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority, 7); + let command = LootRollVoteCommand { + voter_guid: ObjectGuid::create_player(1, 77), + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_GREED_LIKE_CPP, + pass_on_group_loot: false, + roll_identity: roll_identity.clone(), + }; + + assert!( + WorldSession::represented_loot_roll_vote_command_targets_identity_like_cpp( + &command, + &roll_identity, + ) + ); + } + + #[test] + fn queued_loot_roll_vote_rejects_replacement_with_same_key_and_generation_like_cpp() { + let loot_object = represented_loot_object_guid_like_cpp(test_creature_guid(19_063)); + let authority = OwnedLootAuthority::new(); + let stale_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority.clone(), 7); + let replacement_identity = + LootRollCommandIdentityLikeCpp::new_like_cpp(loot_object, 0, authority, 7); + let stale_command = LootRollVoteCommand { + voter_guid: ObjectGuid::create_player(1, 77), + loot_obj: loot_object, + loot_list_id: 0, + roll_type: ROLL_VOTE_GREED_LIKE_CPP, + pass_on_group_loot: false, + roll_identity: stale_identity, + }; + + assert!( + !WorldSession::represented_loot_roll_vote_command_targets_identity_like_cpp( + &stale_command, + &replacement_identity, + ), + "a command queued for the destroyed C++ LootRoll* must not vote on its replacement" + ); + } + #[tokio::test] async fn loot_roll_remote_session_routes_vote_to_owner_session_like_cpp() { let (mut owner_session, owner_rx) = make_session_with_send_capacity(8); @@ -13305,7 +23797,6 @@ mod tests { let player_guid = ObjectGuid::create_player(1, 42); let candidate_guid = ObjectGuid::create_player(1, 77); let owner_guid = test_creature_guid(19_056); - let loot_object = represented_loot_object_guid_like_cpp(owner_guid); let (candidate_tx, candidate_rx) = flume::bounded::>(8); let (owner_registry_tx, _owner_registry_rx) = flume::bounded::>(8); let player_registry = Arc::new(PlayerRegistry::default()); @@ -13320,41 +23811,61 @@ mod tests { remote_session.set_player_registry(Arc::clone(&player_registry)); remote_session.set_player_guid(Some(candidate_guid)); install_group_loot_group(&mut owner_session, player_guid, candidate_guid); + + let mut canonical_player = Player::new(Some(1), false); + canonical_player + .unit_mut() + .world_mut() + .object_mut() + .create(player_guid); + canonical_player + .unit_mut() + .world_mut() + .set_map(u32::from(owner_guid.map_id()), 0) + .unwrap(); + canonical_player + .unit_mut() + .world_mut() + .relocate(Position::ZERO); + canonical_player + .unit_mut() + .world_mut() + .object_mut() + .add_to_world(); + let canonical_creature = make_canonical_creature_for_session(&owner_session, owner_guid); + let canonical_manager = Arc::new(Mutex::new(wow_map::MapManager::default())); + { + let mut manager = canonical_manager.lock().unwrap(); + let map = manager.create_world_map(u32::from(owner_guid.map_id()), 0); + map.map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_player(canonical_player).unwrap(), + ) + .unwrap(); + map.map_mut() + .insert_map_object_record( + wow_entities::MapObjectRecord::new_creature(canonical_creature).unwrap(), + ) + .unwrap(); + } + owner_session.set_canonical_map_manager(canonical_manager); + let loot_object = owner_session + .next_represented_loot_object_guid_like_cpp(owner_guid) + .expect("the canonical owner map must allocate the C++ LootObject identity"); + register_test_creature_like_cpp(&mut owner_session, test_creature(owner_guid, false)); - owner_session.loot_table.insert( - owner_guid, - CreatureLoot { - loot_guid: loot_object, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: LOOT_METHOD_GROUP_LIKE_CPP, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: Vec::new(), - allowed_looters: vec![player_guid, candidate_guid], - items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags { - follow_loot_rules: true, - blocked: true, - ..Default::default() - }, - allowed_looters: vec![player_guid, candidate_guid], - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, - }], - looted_by_player: false, - }, - ); + let mut loot = + generation_guarded_group_loot_like_cpp(owner_guid, player_guid, candidate_guid); + loot.loot_guid = loot_object; + owner_session.loot_table.insert(owner_guid, loot); + owner_session + .sync_represented_creature_loot_to_canonical_like_cpp(owner_guid, player_guid) + .expect("the fixture loot must be installed into the object-owned authority"); + let installed = owner_session + .represented_owned_loot_authority_like_cpp(owner_guid) + .and_then(|authority| authority.shared_snapshot_like_cpp()) + .expect("the canonical creature must expose the installed shared loot"); + assert_eq!(installed.loot.loot_guid, loot_object); owner_session .handle_loot_unit(loot_unit_packet(owner_guid)) @@ -13370,7 +23881,8 @@ mod tests { .get(&player_guid) .unwrap() .active_loot_rolls - .contains(&(loot_object, 0)) + .iter() + .any(|identity| identity.matches_key_like_cpp(loot_object, 0)) ); remote_session @@ -13582,6 +24094,171 @@ mod tests { assert!(session.active_loot_view_owners.contains(&secondary_guid)); } + async fn open_test_ae_pair_like_cpp( + session: &mut WorldSession, + player_guid: ObjectGuid, + primary_guid: ObjectGuid, + secondary_guid: ObjectGuid, + ) -> OwnedLootAuthority { + session.set_player_guid(Some(player_guid)); + session.set_enable_ae_loot_like_cpp(true); + session.set_player_position_like_cpp(Position::ZERO); + register_test_creature_like_cpp(session, test_creature(primary_guid, false)); + register_test_creature_like_cpp(session, test_creature(secondary_guid, false)); + insert_allowed_coin_loot_like_cpp(session, primary_guid, player_guid, 7); + insert_allowed_coin_loot_like_cpp(session, secondary_guid, player_guid, 7); + + session + .handle_loot_unit(loot_unit_packet(primary_guid)) + .await; + + assert!(session.is_active_loot_guid(primary_guid)); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + let authority = session + .represented_owned_loot_authority_like_cpp(secondary_guid) + .expect("the secondary AE owner must expose its object-owned authority"); + assert!( + authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid) + ); + authority + } + + #[tokio::test] + async fn disconnect_after_primary_ae_release_closes_secondary_view_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_711); + let primary_guid = test_creature_guid(61_712); + let secondary_guid = test_creature_guid(61_713); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session + .cleanup_shared_runtime_state_on_disconnect_like_cpp() + .await; + + assert!(session.active_loot_view_owners.is_empty()); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "logout must remove the secondary AE viewer even after the primary was released" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 + ); + } + + #[tokio::test] + async fn new_loot_after_primary_ae_release_closes_secondary_before_replacing_tracking_like_cpp() + { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_714); + let primary_guid = test_creature_guid(61_715); + let secondary_guid = test_creature_guid(61_716); + let new_guid = test_creature_guid(61_717); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session.set_enable_ae_loot_like_cpp(false); + register_test_creature_like_cpp(&mut session, test_creature(new_guid, false)); + insert_allowed_coin_loot_like_cpp(&mut session, new_guid, player_guid, 7); + session.handle_loot_unit(loot_unit_packet(new_guid)).await; + + assert!(session.is_active_loot_guid(new_guid)); + assert_eq!(session.active_loot_view_owners.len(), 1); + assert!(!session.active_loot_view_owners.contains(&secondary_guid)); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "the secondary AE viewer must be released before set_active_loot_guid clears tracking" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 + ); + } + + #[tokio::test] + async fn item_loot_releases_ae_view_and_tracks_multiple_items_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(32); + let player_guid = ObjectGuid::create_player(1, 61_718); + let primary_guid = test_creature_guid(61_719); + let secondary_guid = test_creature_guid(61_720); + let first_item = ObjectGuid::create_item(1, 61_721); + let second_item = ObjectGuid::create_item(1, 61_722); + let secondary_authority = + open_test_ae_pair_like_cpp(&mut session, player_guid, primary_guid, secondary_guid) + .await; + + session + .handle_loot_release(loot_release_packet(primary_guid)) + .await; + assert!(session.active_loot_guid.is_empty()); + assert!(session.active_loot_view_owners.contains(&secondary_guid)); + + session + .open_active_item_loot_view_like_cpp(player_guid, first_item) + .await; + session + .open_active_item_loot_view_like_cpp(player_guid, second_item) + .await; + + assert!(session.is_active_loot_guid(first_item)); + assert_eq!(session.active_loot_view_owners.len(), 2); + assert!(session.active_loot_view_owners.contains(&first_item)); + assert!(session.active_loot_view_owners.contains(&second_item)); + assert!(!session.active_loot_view_owners.contains(&secondary_guid)); + assert!( + !secondary_authority + .snapshot_for_player_like_cpp(player_guid) + .unwrap() + .loot + .players_looting + .contains(&player_guid), + "item loot must release the surviving secondary AE viewer first" + ); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx) + .into_iter() + .filter(|opcode| *opcode == wow_constants::ServerOpcodes::LootRelease as u16) + .count(), + 2 + ); + } + #[tokio::test] async fn loot_unit_empty_visible_loot_returns_silently_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -13608,6 +24285,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, loot_guid, player_guid); session.handle_loot_unit(loot_unit_packet(loot_guid)).await; assert!(send_rx.try_recv().is_err()); @@ -13697,6 +24375,7 @@ mod tests { }, ); + install_cached_test_creature_loot_authority_like_cpp(&mut session, loot_guid, player_guid); session.handle_loot_unit(loot_unit_packet(loot_guid)).await; assert!(send_rx.try_recv().is_err()); @@ -13933,6 +24612,7 @@ mod tests { #[tokio::test] async fn loot_money_consumes_all_active_loot_views_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let owner_one = test_creature_guid(19_025); let owner_two = test_creature_guid(19_026); @@ -14021,6 +24701,7 @@ mod tests { #[tokio::test] async fn loot_money_gain_completes_money_tracking_event_objective_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(5); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let loot_guid = test_creature_guid(19_029); let quest_id = 12_530; @@ -14099,6 +24780,7 @@ mod tests { #[tokio::test] async fn loot_money_splits_corpse_gold_to_near_group_members_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(2); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let other_guid = ObjectGuid::create_player(1, 43); let loot_guid = test_creature_guid(19_027); @@ -15973,6 +26655,12 @@ mod tests { assert_eq!(sent.read_packed_guid().unwrap(), loot_guid); assert_eq!(sent.read_packed_guid().unwrap(), player_guid); assert!(!session.is_active_loot_guid(loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "the closed session view is a discardable cache; the creature authority keeps loot" + ); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert_eq!(session.loot_table[&loot_guid].coins, 7); assert!(session.loot_table.contains_key(&loot_guid)); assert_eq!( session.loot_table.get(&loot_guid).unwrap().players_looting, @@ -16027,7 +26715,7 @@ mod tests { assert!(send_rx.try_recv().is_ok()); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!(!session.loot_table.contains_key(&loot_guid)); let canonical = canonical_creature_snapshot(&session, loot_guid).unwrap(); assert_eq!( canonical.shared_loot_like_cpp(), @@ -16039,6 +26727,9 @@ mod tests { Some(&CreatureOwnedLoot::new(7, 0)) ); assert!(!canonical.is_fully_looted_like_cpp()); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert_eq!(session.loot_table[&loot_guid].coins, 7); + assert!(session.loot_table.contains_key(&loot_guid)); assert_eq!( session .mutate_world_creature(loot_guid, |creature| creature.corpse_despawn_at()) @@ -16048,6 +26739,93 @@ mod tests { ); } + #[tokio::test] + async fn authoritative_partial_release_clears_round_robin_for_all_sessions_and_forces_dynflags_like_cpp() + { + let first_guid = ObjectGuid::create_player(1, 61_890); + let second_guid = ObjectGuid::create_player(1, 61_891); + let mut loot = authoritative_test_loot_like_cpp(7, false); + loot.round_robin_player = first_guid; + loot.allowed_looters = vec![first_guid, second_guid]; + let (mut first, _first_rx, mut second, _second_rx, owner_guid, _, _) = + two_sessions_with_authoritative_creature_loot_like_cpp(loot); + // The helper uses its own deterministic player ids; install the exact + // current round-robin holder from the opened first session. + let opened_first = first.player_guid().unwrap(); + let opened_second = second.player_guid().unwrap(); + let authority = first + .represented_owned_loot_authority_like_cpp(owner_guid) + .unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .generation; + let mut replacement = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .loot; + replacement.round_robin_player = opened_first; + authority.replace_like_cpp(Some(replacement), HashMap::new()); + let replacement_generation = authority + .snapshot_for_player_like_cpp(opened_first) + .unwrap() + .generation; + assert_ne!(generation, replacement_generation); + authority.add_viewer_like_cpp(opened_first).unwrap(); + first + .active_loot_view_generations_like_cpp + .insert(owner_guid, replacement_generation); + first + .active_loot_view_authorities_like_cpp + .insert(owner_guid, authority.clone()); + assert!(first.reconcile_represented_loot_cache_like_cpp(owner_guid, opened_first)); + let _ = first.mutate_world_creature(owner_guid, |creature| { + creature + .creature + .unit_mut() + .world_mut() + .object_mut() + .clear_update_mask(false); + }); + + assert!( + first + .do_loot_release_owner_like_cpp(owner_guid, opened_first) + .await + ); + + assert!( + authority + .snapshot_for_player_like_cpp(opened_second) + .unwrap() + .loot + .round_robin_player + .is_empty() + ); + assert!(second.reconcile_represented_loot_cache_like_cpp(owner_guid, opened_second)); + assert!( + second + .loot_table + .get(&owner_guid) + .unwrap() + .round_robin_player + .is_empty() + ); + assert!( + first + .mutate_world_creature(owner_guid, |creature| { + creature + .creature + .unit() + .world() + .object() + .changed_fields() + .contains(ObjectChangedFields::DYNAMIC_FLAGS) + }) + .unwrap() + ); + } + #[tokio::test] async fn creature_owned_loot_release_fully_consumed_uses_canonical_is_fully_looted_like_cpp() { let (mut session, send_rx) = make_session_with_send(); @@ -16071,47 +26849,297 @@ mod tests { .unwrap(), 120 ); - session.loot_table.insert( - loot_guid, - CreatureLoot { - loot_guid, - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid], - allowed_looters: Vec::new(), - items: Vec::new(), - looted_by_player: false, - }, + session.loot_table.insert( + loot_guid, + CreatureLoot { + loot_guid, + coins: 0, + unlooted_count: 0, + loot_type: LOOT_TYPE_CORPSE_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: Vec::new(), + items: Vec::new(), + looted_by_player: false, + }, + ); + + session + .handle_loot_release(loot_release_packet(loot_guid)) + .await; + + assert!(send_rx.try_recv().is_ok()); + assert!(!session.is_active_loot_guid(loot_guid)); + assert!(!session.loot_table.contains_key(&loot_guid)); + let canonical = canonical_creature_snapshot(&session, loot_guid).unwrap(); + assert_eq!( + canonical.shared_loot_like_cpp(), + Some(&CreatureOwnedLoot::default()) + ); + assert!(canonical.is_fully_looted_like_cpp()); + let corpse_despawn_at = session + .mutate_world_creature(loot_guid, |creature| creature.corpse_despawn_at()) + .unwrap() + .expect("fully looted corpse should start decay timer"); + let remaining = corpse_despawn_at.saturating_duration_since(Instant::now()); + assert!( + (55..=60).contains(&remaining.as_secs()), + "C++ uses corpse_delay * Rate.Corpse.Decay.Looted; got {remaining:?}" + ); + } + + #[tokio::test] + async fn personal_creature_release_starts_decay_only_after_every_pool_is_looted_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let first_player = ObjectGuid::create_player(1, 53); + let second_player = ObjectGuid::create_player(1, 54); + let owner_guid = test_creature_guid(19_119); + let mut creature = make_canonical_creature_for_session(&session, owner_guid); + + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.allowed_looters = vec![first_player]; + let mut second_pool = authoritative_test_loot_like_cpp(0, true); + second_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + second_pool.allowed_looters = vec![second_player]; + second_pool.items[0].allowed_looters = vec![second_player]; + assert!( + creature + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool), (second_player, second_pool),]), + ) + .installed() + ); + let authority = creature.loot_authority_like_cpp().clone(); + attach_canonical_creature(&mut session, creature); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_loot_drop_rates_like_cpp(LootDropRatesLikeCpp { + corpse_decay_looted: 0.5, + ..LootDropRatesLikeCpp::default() + }); + let corpse_deadline_before = session + .mutate_world_creature(owner_guid, |creature| { + creature.creature.set_corpse_delay(120, false); + let deadline = Instant::now() + Duration::from_secs(120); + creature.set_corpse_despawn_at(Some(deadline)); + creature.corpse_despawn_at() + }) + .flatten() + .expect("dead creature should already own its normal corpse deadline"); + + session.set_player_guid(Some(first_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, first_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + first_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, first_player, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, first_player) + .await + ); + assert_eq!( + session + .mutate_world_creature(owner_guid, |creature| creature.corpse_despawn_at()) + .flatten(), + Some(corpse_deadline_before), + "one empty personal pool must not start global corpse decay while a peer has loot" + ); + assert!(!authority.is_fully_looted_like_cpp()); + + session.set_player_guid(Some(second_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, second_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + second_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, second_player, response); + let claim = authority + .reserve_item_like_cpp(second_player, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, second_player) + .await + ); + assert!(authority.is_fully_looted_like_cpp()); + let corpse_deadline_after = session + .mutate_world_creature(owner_guid, |creature| creature.corpse_despawn_at()) + .flatten() + .expect("last personal pool should start looted-corpse decay"); + assert!(corpse_deadline_after < corpse_deadline_before); + let remaining = corpse_deadline_after.saturating_duration_since(Instant::now()); + assert!((55..=60).contains(&remaining.as_secs())); + } + + #[test] + fn creature_loot_release_dynamic_flags_are_viewer_dependent_like_cpp() { + let mut session = make_session(); + let first_player = ObjectGuid::create_player(1, 61); + let second_player = ObjectGuid::create_player(1, 62); + let unrelated_player = ObjectGuid::create_player(1, 63); + let owner_guid = test_creature_guid(19_120); + let mut creature = make_canonical_creature_for_session(&session, owner_guid); + + let mut consumed_pool = authoritative_test_loot_like_cpp(0, false); + consumed_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + consumed_pool.allowed_looters = vec![first_player]; + let mut live_pool = authoritative_test_loot_like_cpp(0, true); + live_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + live_pool.allowed_looters = vec![second_player]; + live_pool.items[0].allowed_looters = vec![second_player]; + assert!( + creature + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, consumed_pool), (second_player, live_pool),]), + ) + .installed() ); + let authority = creature.loot_authority_like_cpp().clone(); + attach_canonical_creature(&mut session, creature); + register_test_creature_like_cpp(&mut session, test_creature(owner_guid, false)); + session.set_player_guid(Some(first_player)); - session - .handle_loot_release(loot_release_packet(loot_guid)) - .await; + let update = UnitDataValuesDeltaUpdate { + object_data: Some(ObjectDataValuesUpdate { + changed_object_type_mask: 1, + object_data_mask: 1 << 2, + entry_id: 0, + dynamic_flags: UnitDynFlags::Lootable as u32, + scale: 1.0, + }), + ..UnitDataValuesDeltaUpdate::default() + }; + let dynamic_flags_for = |viewer_guid| { + session + .creature_loot_release_values_for_viewer_like_cpp( + owner_guid, + viewer_guid, + false, + Some(&authority), + update.clone(), + ) + .object_data + .unwrap() + .dynamic_flags + }; - assert!(send_rx.try_recv().is_ok()); - assert!(!session.is_active_loot_guid(loot_guid)); - assert!(!session.loot_table.contains_key(&loot_guid)); - let canonical = canonical_creature_snapshot(&session, loot_guid).unwrap(); + assert_eq!(dynamic_flags_for(first_player), 0); assert_eq!( - canonical.shared_loot_like_cpp(), - Some(&CreatureOwnedLoot::default()) + dynamic_flags_for(second_player), + UnitDynFlags::Lootable as u32, + "one exhausted personal pool must not hide another player's live loot" ); - assert!(canonical.is_fully_looted_like_cpp()); - let corpse_despawn_at = session - .mutate_world_creature(loot_guid, |creature| creature.corpse_despawn_at()) - .unwrap() - .expect("fully looted corpse should start decay timer"); - let remaining = corpse_despawn_at.saturating_duration_since(Instant::now()); + assert_eq!(dynamic_flags_for(unrelated_player), 0); + } + + #[test] + fn creature_loot_visibility_applies_full_cpp_allowed_to_loot_gate() { + let round_robin_owner = ObjectGuid::create_player(1, 64); + let other_player = ObjectGuid::create_player(1, 65); + let mut loot = authoritative_test_loot_like_cpp(0, true); + loot.loot_method = LOOT_METHOD_ROUND_ROBIN_LIKE_CPP; + loot.round_robin_player = round_robin_owner; + loot.allowed_looters = vec![round_robin_owner, other_player]; + loot.items[0].allowed_looters = vec![round_robin_owner, other_player]; + loot.items[0].flags.follow_loot_rules = true; + + assert!(creature_loot_is_allowed_to_player_like_cpp( + true, + false, + &loot, + round_robin_owner, + )); assert!( - (55..=60).contains(&remaining.as_secs()), - "C++ uses corpse_delay * Rate.Corpse.Decay.Looted; got {remaining:?}" + !creature_loot_is_allowed_to_player_like_cpp(true, false, &loot, other_player), + "ordinary shared round-robin loot belongs only to the selected player" + ); + assert!( + !creature_loot_is_allowed_to_player_like_cpp(false, false, &loot, round_robin_owner,), + "C++ rejects loot visibility for a living creature" + ); + assert!( + !creature_loot_is_allowed_to_player_like_cpp(true, true, &loot, round_robin_owner,), + "C++ HasPendingBind suppresses loot visibility" + ); + + loot.items[0].flags.follow_loot_rules = false; + assert!( + creature_loot_is_allowed_to_player_like_cpp(true, false, &loot, other_player), + "quest/conditional/free-for-player loot remains visible outside round robin" + ); + } + + #[tokio::test] + async fn creature_loot_release_command_retries_without_blocking_source_like_cpp() { + let (command_tx, command_rx) = flume::bounded(1); + command_tx + .send(SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp) + .unwrap(); + let creature_guid = test_creature_guid(19_121); + assert_eq!( + queue_creature_loot_release_command_reliably_like_cpp( + &command_tx, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp( + wow_network::SendCreatureLootReleaseValuesUpdateLikeCppCommand { + creature_guid, + map_id: 0, + instance_id: 0, + unit_values_update: UnitDataValuesDeltaUpdate::default(), + authority: None, + }, + ), + ), + CreatureLootReleaseCommandQueueOutcomeLikeCpp::Retrying, + "a full peer queue must schedule retry without blocking this session" ); + assert!(matches!( + command_rx.recv().unwrap(), + SessionCommand::RefreshVisibleGameobjectsOrSpellClicksLikeCpp + )); + let queued = tokio::time::timeout(Duration::from_secs(1), command_rx.recv_async()) + .await + .expect("detached retry should enqueue after capacity opens") + .unwrap(); + assert!(matches!( + queued, + SessionCommand::SendCreatureLootReleaseValuesUpdateLikeCpp(command) + if command.creature_guid == creature_guid + )); } #[tokio::test] @@ -16177,13 +27205,14 @@ mod tests { #[tokio::test] async fn creature_owned_loot_release_fully_consumed_removes_lootable_dynflag_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); + let (mut session, send_rx) = make_session_with_send_capacity(4); let player_guid = ObjectGuid::create_player(1, 42); let loot_guid = test_creature_guid(19_116); let creature = make_canonical_creature_for_session(&session, loot_guid); attach_canonical_creature(&mut session, creature); session.set_player_guid(Some(player_guid)); session.set_active_loot_guid(loot_guid); + session.client_visible_guids_like_cpp.insert(loot_guid); register_test_creature_like_cpp(&mut session, test_creature(loot_guid, false)); let _ = session.mutate_world_creature(loot_guid, |creature| { creature.apply_corpse_loot_flags_after_death_state_like_cpp(true, false); @@ -16217,7 +27246,14 @@ mod tests { .handle_loot_release(loot_release_packet(loot_guid)) .await; - assert!(send_rx.try_recv().is_ok()); + assert_eq!( + drain_server_opcodes_like_cpp(&send_rx), + vec![ + wow_constants::ServerOpcodes::LootRelease as u16, + wow_constants::ServerOpcodes::UpdateObject as u16, + ], + "C++ ForceUpdateFieldChange must become a visible VALUES update so the client removes the loot cursor" + ); assert!( !session .mutate_world_creature(loot_guid, |creature| creature @@ -16597,7 +27633,177 @@ mod tests { let loot_guid = test_gameobject_guid(19_132); let mut game_object = make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - game_object.set_personal_loot_like_cpp(player_guid, GameObjectOwnedLoot::new(0, 1)); + game_object.set_personal_loot_like_cpp(player_guid, GameObjectOwnedLoot::new(0, 1)); + attach_canonical_gameobject(&mut session, game_object); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_active_loot_guid(loot_guid); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() + }, + ); + session.loot_table.insert( + loot_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(loot_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: vec![player_guid], + items: vec![represented_loot_entry(0, 25, player_guid)], + looted_by_player: false, + }, + ); + + session + .handle_loot_release(loot_release_packet(loot_guid)) + .await; + + assert!(send_rx.try_recv().is_ok()); + let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert_eq!( + canonical.shared_loot_like_cpp(), + Some(&GameObjectOwnedLoot::new(0, 1)) + ); + assert_eq!(canonical.personal_loot_count_like_cpp(), 0); + assert_eq!( + canonical.loot_for_player_like_cpp(player_guid), + Some(&GameObjectOwnedLoot::new(0, 1)) + ); + assert!(!canonical.is_fully_looted_like_cpp()); + assert_eq!(canonical.loot_state(), LootState::Activated); + assert_eq!(canonical.loot_state_unit_guid(), player_guid); + assert!(canonical.restock_time() > 0); + assert!(!session.loot_table.contains_key(&loot_guid)); + assert!(session.reconcile_represented_loot_cache_like_cpp(loot_guid, player_guid)); + assert!(session.loot_table.contains_key(&loot_guid)); + assert_eq!( + session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap() + .loot_state, + Some(LootState::Activated) + ); + } + + #[tokio::test] + async fn loot_release_partial_chest_syncs_state_to_same_map_viewers_like_cpp() { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let same_map_guid = ObjectGuid::create_player(1, 77); + let loot_guid = test_gameobject_guid(19_138); + let (same_command_tx, same_command_rx) = flume::bounded(2); + let (same_send_tx, _same_send_rx) = flume::bounded::>(1); + let player_registry = Arc::new(PlayerRegistry::default()); + let mut same_info = broadcast_info(same_map_guid, same_send_tx); + same_info.map_id = 571; + same_info.command_tx = same_command_tx; + player_registry.insert(same_map_guid, same_info); + + session.set_player_registry(player_registry); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.set_player_map_position_like_cpp(571, Position::ZERO); + session.set_active_loot_guid(loot_guid); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + loot_id: 7_001, + chest_restock_time_secs: 45, + chest_consumable: false, + ..Default::default() + }, + ); + session.loot_table.insert( + loot_guid, + CreatureLoot { + loot_guid: represented_loot_object_guid_like_cpp(loot_guid), + coins: 0, + unlooted_count: 1, + loot_type: LOOT_TYPE_CHEST_LIKE_CPP, + dungeon_encounter_id: 0, + loot_method: 0, + loot_master: ObjectGuid::EMPTY, + round_robin_player: ObjectGuid::EMPTY, + player_ffa_items: Vec::new(), + players_looting: vec![player_guid], + allowed_looters: Vec::new(), + items: vec![LootEntry { + loot_list_id: 0, + item_id: 25, + quantity: 1, + random_properties_id: 0, + random_properties_seed: 0, + item_context: 0, + flags: LootEntryFlags::default(), + allowed_looters: Vec::new(), + roll_winner: ObjectGuid::EMPTY, + ffa_looted_by: Vec::new(), + taken: false, + }], + looted_by_player: false, + }, + ); + + session + .handle_loot_release(loot_release_packet(loot_guid)) + .await; + + let release_bytes = send_rx.try_recv().unwrap(); + let mut release = WorldPacket::from_bytes(&release_bytes); + assert_eq!( + release.read_uint16().unwrap(), + wow_constants::ServerOpcodes::LootRelease as u16 + ); + let command = match same_command_rx.try_recv() { + Ok(SessionCommand::SyncChestGameobjectStateAndRefreshLikeCpp(command)) => command, + other => panic!("expected chest release sync command, got {other:?}"), + }; + assert_eq!(command.gameobject_guid, loot_guid); + assert_eq!(command.map_id, 571); + assert_eq!( + command.loot_state, + Some(wow_entities::LootState::Activated as u8) + ); + assert_eq!(command.loot_state_unit_guid, player_guid); + assert_eq!(command.chest_loot_id, 7_001); + assert_eq!(command.chest_restock_time_secs, 45); + } + + #[tokio::test] + async fn gameobject_owned_loot_release_fully_consumed_chest_uses_canonical_is_fully_looted_like_cpp() + { + let (mut session, send_rx) = make_session_with_send(); + let player_guid = ObjectGuid::create_player(1, 42); + let loot_guid = test_gameobject_guid(19_133); + let mut game_object = + make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); + game_object.set_shared_loot_like_cpp(GameObjectOwnedLoot::new(0, 1)); attach_canonical_gameobject(&mut session, game_object); session.set_player_guid(Some(player_guid)); session.set_player_position_like_cpp(Position::ZERO); @@ -16622,7 +27828,7 @@ mod tests { CreatureLoot { loot_guid: represented_loot_object_guid_like_cpp(loot_guid), coins: 0, - unlooted_count: 1, + unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, dungeon_encounter_id: 0, loot_method: 0, @@ -16631,7 +27837,7 @@ mod tests { player_ffa_items: Vec::new(), players_looting: vec![player_guid], allowed_looters: vec![player_guid], - items: vec![represented_loot_entry(0, 25, player_guid)], + items: Vec::new(), looted_by_player: false, }, ); @@ -16644,46 +27850,73 @@ mod tests { let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); assert_eq!( canonical.shared_loot_like_cpp(), - Some(&GameObjectOwnedLoot::new(0, 1)) - ); - assert_eq!(canonical.personal_loot_count_like_cpp(), 0); - assert_eq!( - canonical.loot_for_player_like_cpp(player_guid), - Some(&GameObjectOwnedLoot::new(0, 1)) + Some(&GameObjectOwnedLoot::default()) ); - assert!(!canonical.is_fully_looted_like_cpp()); - assert_eq!(canonical.loot_state(), LootState::Activated); - assert_eq!(canonical.loot_state_unit_guid(), player_guid); - assert!(canonical.restock_time() > 0); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!(canonical.is_fully_looted_like_cpp()); + assert_eq!(canonical.loot_state(), LootState::JustDeactivated); + assert_eq!(canonical.loot_state_unit_guid(), ObjectGuid::EMPTY); + assert_eq!(canonical.restock_time(), 0); + assert!(!session.loot_table.contains_key(&loot_guid)); assert_eq!( session .represented_gameobject_use_states .get(&loot_guid) .unwrap() .loot_state, - Some(LootState::Activated) + Some(LootState::JustDeactivated) ); } - #[tokio::test] - async fn loot_release_partial_chest_syncs_state_to_same_map_viewers_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); + #[test] + fn gameobject_loot_release_without_canonical_manager_keeps_represented_restock_fallback_like_cpp() + { + let mut session = make_session(); let player_guid = ObjectGuid::create_player(1, 42); - let same_map_guid = ObjectGuid::create_player(1, 77); - let loot_guid = test_gameobject_guid(19_138); - let (same_command_tx, same_command_rx) = flume::bounded(2); - let (same_send_tx, _same_send_rx) = flume::bounded::>(1); - let player_registry = Arc::new(PlayerRegistry::default()); - let mut same_info = broadcast_info(same_map_guid, same_send_tx); - same_info.map_id = 571; - same_info.command_tx = same_command_tx; - player_registry.insert(same_map_guid, same_info); + let loot_guid = test_gameobject_guid(19_135); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + loot_guid, + loot_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + session.record_represented_gameobject_chest_release_metadata_like_cpp( + loot_guid, + GameObjectLootSource { + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() + }, + ); - session.set_player_registry(player_registry); + session.apply_represented_gameobject_loot_release_like_cpp( + loot_guid, + player_guid, + true, + true, + None, + ); + + let state = session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap(); + assert_eq!(state.loot_state, Some(LootState::NotReady)); + assert_eq!(state.loot_state_unit_guid, ObjectGuid::EMPTY); + assert!(state.chest_restock_until.is_some()); + } + + #[tokio::test] + async fn gameobject_owned_loot_release_personal_chest_syncs_current_player_and_despawns_like_cpp() + { + let (mut session, send_rx) = make_session_with_send_capacity(2); + let player_guid = ObjectGuid::create_player(1, 42); + let loot_guid = test_gameobject_guid(19_134); + let game_object = + make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); + attach_canonical_gameobject(&mut session, game_object); session.set_player_guid(Some(player_guid)); session.set_player_position_like_cpp(Position::ZERO); - session.set_player_map_position_like_cpp(571, Position::ZERO); session.set_active_loot_guid(loot_guid); session.record_represented_gameobject_runtime_state_like_cpp( 0, @@ -16695,38 +27928,33 @@ mod tests { session.record_represented_gameobject_chest_release_metadata_like_cpp( loot_guid, GameObjectLootSource { - loot_id: 7_001, - chest_restock_time_secs: 45, + personal_loot_id: 55, chest_consumable: false, + chest_restock_time_secs: 7, ..Default::default() }, ); + session.represented_personal_loot_owners.insert(loot_guid); + session + .represented_personal_loot_money + .insert((loot_guid, player_guid), 0); session.loot_table.insert( loot_guid, CreatureLoot { loot_guid: represented_loot_object_guid_like_cpp(loot_guid), coins: 0, - unlooted_count: 1, + unlooted_count: 0, loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 0, + dungeon_encounter_id: 1, loot_method: 0, loot_master: ObjectGuid::EMPTY, round_robin_player: ObjectGuid::EMPTY, player_ffa_items: Vec::new(), players_looting: vec![player_guid], - allowed_looters: Vec::new(), + allowed_looters: vec![player_guid], items: vec![LootEntry { - loot_list_id: 0, - item_id: 25, - quantity: 1, - random_properties_id: 0, - random_properties_seed: 0, - item_context: 0, - flags: LootEntryFlags::default(), - allowed_looters: Vec::new(), - roll_winner: ObjectGuid::EMPTY, - ffa_looted_by: Vec::new(), - taken: false, + taken: true, + ..represented_loot_entry(0, 25, player_guid) }], looted_by_player: false, }, @@ -16736,204 +27964,602 @@ mod tests { .handle_loot_release(loot_release_packet(loot_guid)) .await; - let release_bytes = send_rx.try_recv().unwrap(); - let mut release = WorldPacket::from_bytes(&release_bytes); + assert!(send_rx.try_recv().is_ok()); + let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert_eq!(canonical.shared_loot_like_cpp(), None); assert_eq!( - release.read_uint16().unwrap(), - wow_constants::ServerOpcodes::LootRelease as u16 + canonical.personal_loot_like_cpp(player_guid), + Some(&GameObjectOwnedLoot::default()) ); - let command = match same_command_rx.try_recv() { - Ok(SessionCommand::SyncChestGameobjectStateAndRefreshLikeCpp(command)) => command, - other => panic!("expected chest release sync command, got {other:?}"), + let state = session + .represented_gameobject_use_states + .get(&loot_guid) + .unwrap(); + assert_eq!(state.per_player_state_player_guid, Some(player_guid)); + assert_eq!(state.per_player_despawn_secs, Some(7)); + assert!(state.per_player_despawn_until.is_some()); + } + + #[tokio::test] + async fn authoritative_partial_gameobject_release_drops_cache_and_reopen_rehydrates_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(8); + let player_guid = ObjectGuid::create_player(1, 451); + let owner_guid = test_gameobject_guid(19_451); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(player_guid)); + let mut pool = authoritative_test_loot_like_cpp(11, true); + pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + pool.allowed_looters = vec![player_guid]; + pool.items[0].allowed_looters = vec![player_guid]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(player_guid, pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(player_guid)); + session.set_player_position_like_cpp(Position::ZERO); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + owner_guid, + owner_guid.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_CHEST as u8, + ); + let source = GameObjectLootSource { + personal_loot_id: 55, + chest_consumable: false, + chest_restock_time_secs: 7, + ..Default::default() }; - assert_eq!(command.gameobject_guid, loot_guid); - assert_eq!(command.map_id, 571); + session.record_represented_gameobject_chest_release_metadata_like_cpp(owner_guid, source); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, player_guid)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + player_guid, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, player_guid, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, player_guid) + .await + ); + assert!(!session.loot_table.contains_key(&owner_guid)); + assert!( + !session + .represented_loot_cache_generations_like_cpp + .contains_key(&owner_guid) + ); + assert!( + !session + .represented_personal_loot_money + .contains_key(&(owner_guid, player_guid)) + ); + let before_reopen = authority + .snapshot_for_player_like_cpp(player_guid) + .expect("release preserves the canonical personal pool"); + assert_eq!(before_reopen.loot.coins, 11); + assert!(!before_reopen.loot.items[0].taken); + + session + .open_represented_gameobject_chest_like_cpp(owner_guid, source) + .await; + assert!(session.loot_table.contains_key(&owner_guid)); + assert!( + session + .represented_personal_loot_owners + .contains(&owner_guid) + ); assert_eq!( - command.loot_state, - Some(wow_entities::LootState::Activated as u8) + session + .represented_personal_loot_money + .get(&(owner_guid, player_guid)), + Some(&11) + ); + assert!(session.is_active_loot_guid(owner_guid)); + + let slot = before_reopen.loot.items[0].loot_list_id; + authority + .reserve_item_like_cpp(player_guid, slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .reserve_item_like_cpp(player_guid, slot) + .await + .is_err(), + "rehydration must not manufacture a second claim" ); - assert_eq!(command.loot_state_unit_guid, player_guid); - assert_eq!(command.chest_loot_id, 7_001); - assert_eq!(command.chest_restock_time_secs, 45); } #[tokio::test] - async fn gameobject_owned_loot_release_fully_consumed_chest_uses_canonical_is_fully_looted_like_cpp() + async fn authoritative_partial_personal_creature_release_drops_cache_and_reopen_rehydrates_like_cpp() { - let (mut session, send_rx) = make_session_with_send(); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_133); - let mut game_object = - make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - game_object.set_shared_loot_like_cpp(GameObjectOwnedLoot::new(0, 1)); - attach_canonical_gameobject(&mut session, game_object); - session.set_player_guid(Some(player_guid)); + let mut fixture = overworld_personal_loot_test_fixture_like_cpp(); + fixture + .session + .ensure_represented_creature_kill_loot_like_cpp(fixture.owner_guid) + .await; + let authority = fixture + .session + .represented_owned_loot_authority_like_cpp(fixture.owner_guid) + .unwrap(); + let before_release = authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .unwrap(); + let slot = before_release + .loot + .items + .iter() + .find(|item| item.item_id == fixture.normal_item_id) + .unwrap() + .loot_list_id; + assert!( + fixture.session.reconcile_represented_loot_cache_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + ) + ); + let opened = authority + .add_viewer_like_cpp(fixture.first_tapper) + .expect("the authoritative personal pool opens"); + fixture.session.set_active_loot_guid(fixture.owner_guid); + fixture + .session + .active_loot_view_generations_like_cpp + .insert(fixture.owner_guid, opened.generation); + fixture + .session + .active_loot_view_authorities_like_cpp + .insert(fixture.owner_guid, authority.clone()); + + assert!( + fixture + .session + .do_loot_release_owner_like_cpp(fixture.owner_guid, fixture.first_tapper) + .await + ); + assert!(!fixture.session.loot_table.contains_key(&fixture.owner_guid)); + assert!( + !fixture + .session + .represented_loot_cache_generations_like_cpp + .contains_key(&fixture.owner_guid) + ); + assert!( + !fixture + .session + .represented_personal_loot_money + .contains_key(&(fixture.owner_guid, fixture.first_tapper)) + ); + let after_release = authority + .snapshot_for_player_like_cpp(fixture.first_tapper) + .unwrap(); + assert_eq!(after_release.generation, before_release.generation); + assert_eq!(after_release.scope, before_release.scope); + assert_eq!(after_release.loot.coins, before_release.loot.coins); + assert_eq!(after_release.loot.items, before_release.loot.items); + assert!(after_release.loot.players_looting.is_empty()); + assert!( + after_release.loot.looted_by_player, + "C++ keeps the per-Loot was-opened state after closing the viewer" + ); + + let response = fixture + .session + .represented_loot_response_for_owner_like_cpp( + fixture.owner_guid, + fixture.first_tapper, + false, + ) + .await + .expect("the creature authority rehydrates a personal view"); + assert_eq!(response.coins, 7); + assert!( + fixture + .session + .represented_personal_loot_owners + .contains(&fixture.owner_guid) + ); + assert_eq!( + fixture + .session + .represented_personal_loot_money + .get(&(fixture.owner_guid, fixture.first_tapper)), + Some(&7) + ); + authority + .reserve_item_like_cpp(fixture.first_tapper, slot) + .await + .unwrap() + .commit_like_cpp() + .unwrap(); + assert!( + authority + .reserve_item_like_cpp(fixture.first_tapper, slot) + .await + .is_err() + ); + } + + #[tokio::test] + async fn personal_gameobject_release_deactivates_only_after_every_pool_is_looted_like_cpp() { + let (mut session, send_rx) = make_session_with_send_capacity(16); + let first_player = ObjectGuid::create_player(1, 51); + let second_player = ObjectGuid::create_player(1, 52); + let owner_guid = test_gameobject_guid(19_138); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first_player)); + + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first_player]; + let mut second_pool = authoritative_test_loot_like_cpp(0, true); + second_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + second_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + second_pool.allowed_looters = vec![second_player]; + second_pool.items[0].allowed_looters = vec![second_player]; + assert!( + gameobject + .initialize_loot_authority_like_cpp( + None, + HashMap::from([(first_player, first_pool), (second_player, second_pool),]), + ) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); session.set_player_position_like_cpp(Position::ZERO); - session.set_active_loot_guid(loot_guid); session.record_represented_gameobject_runtime_state_like_cpp( 0, - loot_guid, - loot_guid.entry(), + owner_guid, + owner_guid.entry(), Position::ZERO, GAMEOBJECT_TYPE_CHEST as u8, ); session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, + owner_guid, GameObjectLootSource { + personal_loot_id: 55, chest_consumable: false, chest_restock_time_secs: 7, ..Default::default() }, ); - session.loot_table.insert( - loot_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(loot_guid), - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 0, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid], - allowed_looters: vec![player_guid], - items: Vec::new(), - looted_by_player: false, - }, - ); - session - .handle_loot_release(loot_release_packet(loot_guid)) - .await; + session.set_player_guid(Some(first_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, first_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + first_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, first_player, response); + let _ = drain_server_opcodes_like_cpp(&send_rx); - assert!(send_rx.try_recv().is_ok()); - let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, first_player) + .await + ); assert_eq!( - canonical.shared_loot_like_cpp(), - Some(&GameObjectOwnedLoot::default()) + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Activated, + "one empty personal pool must not globally deactivate a chest while a peer has loot" ); - assert!(canonical.is_fully_looted_like_cpp()); - assert_eq!(canonical.loot_state(), LootState::JustDeactivated); - assert_eq!(canonical.loot_state_unit_guid(), ObjectGuid::EMPTY); - assert_eq!(canonical.restock_time(), 0); - assert!(!session.loot_table.contains_key(&loot_guid)); + assert!(!authority.is_retired_like_cpp()); + assert!(!authority.is_fully_looted_like_cpp()); assert_eq!( session .represented_gameobject_use_states - .get(&loot_guid) + .get(&owner_guid) .unwrap() - .loot_state, - Some(LootState::JustDeactivated) + .per_player_state_player_guid, + Some(first_player), + "C++ still runs OnLootRelease for the selected empty personal pool" + ); + + session.set_player_guid(Some(second_player)); + assert!(session.reconcile_represented_loot_cache_like_cpp(owner_guid, second_player)); + session.set_active_loot_guid(owner_guid); + let response = authoritative_test_loot_response_like_cpp( + owner_guid, + &session.loot_table[&owner_guid], + second_player, + ); + session.represented_on_loot_opened_like_cpp(owner_guid, second_player, response); + let claim = authority + .reserve_item_like_cpp(second_player, 0) + .await + .unwrap(); + assert_eq!(claim.commit_like_cpp(), Ok(true)); + let _ = drain_server_opcodes_like_cpp(&send_rx); + + assert!( + session + .do_loot_release_owner_like_cpp(owner_guid, second_player) + .await + ); + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::JustDeactivated, + "the last empty personal pool must globally deactivate the chest" + ); + assert!(authority.is_fully_looted_like_cpp()); + assert!(!authority.is_retired_like_cpp()); + + let manager = Arc::clone(session.canonical_map_manager.as_ref().unwrap()); + let mut manager = manager.lock().unwrap(); + manager + .find_map_mut(u32::from(session.player_map_id_like_cpp()), 0) + .unwrap() + .map_mut() + .update_game_object_like_cpp(owner_guid, 1, 0); + drop(manager); + assert!( + authority.is_retired_like_cpp(), + "the canonical JustDeactivated update must clear and retire the completed authority" ); } #[test] - fn gameobject_loot_release_without_canonical_manager_keeps_represented_restock_fallback_like_cpp() - { + fn personal_gameobject_upsert_before_release_invalidates_global_deactivation_like_cpp() { let mut session = make_session(); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_135); - session.record_represented_gameobject_runtime_state_like_cpp( - 0, - loot_guid, - loot_guid.entry(), - Position::ZERO, + let first = ObjectGuid::create_player(1, 61_860); + let late = ObjectGuid::create_player(1, 61_861); + let owner_guid = test_gameobject_guid(61_862); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, GAMEOBJECT_TYPE_CHEST as u8, ); - session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, - GameObjectLootSource { - chest_consumable: false, - chest_restock_time_secs: 7, - ..Default::default() - }, + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(first)); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) + .unwrap(); + assert!(close.whole_object_fully_looted); + + let mut late_pool = authoritative_test_loot_like_cpp(0, true); + late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, + 0, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, + ); + late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + late_pool.allowed_looters = vec![late]; + late_pool.items[0].allowed_looters = vec![late]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, late, late_pool, false, + ) + .is_some() ); - session.apply_represented_gameobject_loot_release_like_cpp(loot_guid, player_guid, true); + assert!( + session + .set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + owner_guid, + &authority, + close.object_generation, + close.lifecycle_revision, + LootState::JustDeactivated, + None, + 0, + false, + ) + .is_none(), + "the late pool revision must invalidate the earlier fully-looted observation" + ); + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Activated + ); + assert!(authority.snapshot_for_player_like_cpp(late).is_some()); + } - let state = session - .represented_gameobject_use_states - .get(&loot_guid) + #[test] + fn personal_gameobject_release_before_upsert_rejects_resurrection_like_cpp() { + let mut session = make_session(); + let first = ObjectGuid::create_player(1, 61_870); + let late = ObjectGuid::create_player(1, 61_871); + let owner_guid = test_gameobject_guid(61_872); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_CHEST as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() + ); + let authority = gameobject.loot_authority_like_cpp().clone(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(first)); + authority.add_viewer_like_cpp(first).unwrap(); + let generation = authority + .snapshot_for_player_like_cpp(first) + .unwrap() + .generation; + let close = authority + .close_viewer_if_generation_like_cpp(generation, first) .unwrap(); - assert_eq!(state.loot_state, Some(LootState::NotReady)); - assert_eq!(state.loot_state_unit_guid, ObjectGuid::EMPTY); - assert!(state.chest_restock_until.is_some()); - } + assert!( + session + .set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + owner_guid, + &authority, + close.object_generation, + close.lifecycle_revision, + LootState::JustDeactivated, + None, + 0, + false, + ) + .is_some() + ); - #[tokio::test] - async fn gameobject_owned_loot_release_personal_chest_syncs_current_player_and_despawns_like_cpp() - { - let (mut session, send_rx) = make_session_with_send_capacity(2); - let player_guid = ObjectGuid::create_player(1, 42); - let loot_guid = test_gameobject_guid(19_134); - let game_object = - make_canonical_gameobject_for_session(&session, loot_guid, GAMEOBJECT_TYPE_CHEST as u8); - attach_canonical_gameobject(&mut session, game_object); - session.set_player_guid(Some(player_guid)); - session.set_player_position_like_cpp(Position::ZERO); - session.set_active_loot_guid(loot_guid); - session.record_represented_gameobject_runtime_state_like_cpp( + let mut late_pool = authoritative_test_loot_like_cpp(0, true); + late_pool.loot_guid = ObjectGuid::create_world_object( + HighGuid::LootObject, 0, - loot_guid, - loot_guid.entry(), - Position::ZERO, - GAMEOBJECT_TYPE_CHEST as u8, + owner_guid.realm_id(), + owner_guid.map_id(), + 0, + 0, + owner_guid.counter() + 1, ); - session.record_represented_gameobject_chest_release_metadata_like_cpp( - loot_guid, - GameObjectLootSource { - personal_loot_id: 55, - chest_consumable: false, - chest_restock_time_secs: 7, - ..Default::default() - }, + late_pool.loot_type = LOOT_TYPE_CHEST_LIKE_CPP; + late_pool.allowed_looters = vec![late]; + late_pool.items[0].allowed_looters = vec![late]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, late, late_pool, false, + ) + .is_none(), + "a generator finishing after JustDeactivated must not resurrect the object" ); - session.represented_personal_loot_owners.insert(loot_guid); - session - .represented_personal_loot_money - .insert((loot_guid, player_guid), 0); - session.loot_table.insert( - loot_guid, - CreatureLoot { - loot_guid: represented_loot_object_guid_like_cpp(loot_guid), - coins: 0, - unlooted_count: 0, - loot_type: LOOT_TYPE_CHEST_LIKE_CPP, - dungeon_encounter_id: 1, - loot_method: 0, - loot_master: ObjectGuid::EMPTY, - round_robin_player: ObjectGuid::EMPTY, - player_ffa_items: Vec::new(), - players_looting: vec![player_guid], - allowed_looters: vec![player_guid], - items: vec![LootEntry { - taken: true, - ..represented_loot_entry(0, 25, player_guid) - }], - looted_by_player: false, - }, + assert_eq!( + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::JustDeactivated + ); + assert!(authority.snapshot_for_player_like_cpp(late).is_none()); + } + + #[test] + fn personal_fishing_hole_restock_accepts_second_lifecycle_and_rejects_old_observation_like_cpp() + { + let mut session = make_session(); + let first = ObjectGuid::create_player(1, 61_880); + let second = ObjectGuid::create_player(1, 61_881); + let owner_guid = test_gameobject_guid(61_882); + let mut gameobject = make_canonical_gameobject_for_session( + &session, + owner_guid, + GAMEOBJECT_TYPE_FISHING_HOLE as u8, + ); + gameobject.set_loot_state(LootState::Activated, Some(first)); + let mut first_pool = authoritative_test_loot_like_cpp(0, false); + first_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + first_pool.loot_type = LOOT_TYPE_FISHINGHOLE_LIKE_CPP; + first_pool.allowed_looters = vec![first]; + assert!( + gameobject + .initialize_loot_authority_like_cpp(None, HashMap::from([(first, first_pool)]),) + .installed() ); + let authority = gameobject.loot_authority_like_cpp().clone(); + let first_generation = authority.generation_like_cpp(); + attach_canonical_gameobject(&mut session, gameobject); + session.set_player_guid(Some(second)); + let stale_observation = session + .represented_gameobject_loot_install_observation_like_cpp(owner_guid) + .unwrap(); session - .handle_loot_release(loot_release_packet(loot_guid)) - .await; + .mutate_canonical_gameobject_by_guid_like_cpp(owner_guid, |gameobject| { + gameobject.clear_loot_like_cpp(); + gameobject.set_loot_state(LootState::Ready, None); + }) + .unwrap(); - assert!(send_rx.try_recv().is_ok()); - let canonical = canonical_gameobject_snapshot(&session, loot_guid).unwrap(); - assert_eq!(canonical.shared_loot_like_cpp(), None); + let mut stale_pool = authoritative_test_loot_like_cpp(0, true); + stale_pool.loot_guid = represented_loot_object_guid_like_cpp(owner_guid); + stale_pool.loot_type = LOOT_TYPE_FISHINGHOLE_LIKE_CPP; + stale_pool.allowed_looters = vec![second]; + stale_pool.items[0].allowed_looters = vec![second]; + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_if_observed_like_cpp( + owner_guid, + second, + stale_pool.clone(), + false, + &stale_observation, + ) + .is_none(), + "an async generator from before ClearLoot must lose the lifecycle CAS" + ); + assert!(authority.is_retired_like_cpp()); + + assert!( + session + .upsert_represented_personal_gameobject_loot_authority_like_cpp( + owner_guid, second, stale_pool, false, + ) + .is_some(), + "a generator started after Ready must install the new fishing-hole lifetime" + ); + assert!(authority.generation_like_cpp() > first_generation); + assert!(authority.snapshot_for_player_like_cpp(second).is_some()); assert_eq!( - canonical.personal_loot_like_cpp(player_guid), - Some(&GameObjectOwnedLoot::default()) + canonical_gameobject_snapshot(&session, owner_guid) + .unwrap() + .loot_state(), + LootState::Ready ); - let state = session - .represented_gameobject_use_states - .get(&loot_guid) - .unwrap(); - assert_eq!(state.per_player_state_player_guid, Some(player_guid)); - assert_eq!(state.per_player_despawn_secs, Some(7)); - assert!(state.per_player_despawn_until.is_some()); } #[tokio::test] @@ -17082,6 +28708,51 @@ mod tests { assert_eq!(hole_state.loot_state, Some(LootState::JustDeactivated)); } + #[test] + fn concurrent_fishing_hole_releases_cannot_finish_ready_after_max_like_cpp() { + let mut first = make_session(); + let mut second = make_session(); + let fishing_hole = test_gameobject_guid(61_910); + let gameobject = make_canonical_gameobject_for_session( + &first, + fishing_hole, + GAMEOBJECT_TYPE_FISHING_HOLE as u8, + ); + attach_canonical_gameobject(&mut first, gameobject); + second.set_canonical_map_manager(Arc::clone(first.canonical_map_manager.as_ref().unwrap())); + let start = Arc::new(Barrier::new(2)); + + std::thread::scope(|scope| { + let first_start = Arc::clone(&start); + let first_session = &mut first; + let first_handle = scope.spawn(move || { + first_start.wait(); + first_session + .release_canonical_fishing_hole_like_cpp(fishing_hole, Some(2)) + .unwrap() + }); + let second_start = Arc::clone(&start); + let second_session = &mut second; + let second_handle = scope.spawn(move || { + second_start.wait(); + second_session + .release_canonical_fishing_hole_like_cpp(fishing_hole, Some(2)) + .unwrap() + }); + let first_outcome = first_handle.join().unwrap(); + let second_outcome = second_handle.join().unwrap(); + assert_eq!( + [first_outcome.0, second_outcome.0].into_iter().max(), + Some(2) + ); + assert!([first_outcome.1, second_outcome.1].contains(&LootState::JustDeactivated)); + }); + + let canonical = canonical_gameobject_snapshot(&first, fishing_hole).unwrap(); + assert_eq!(canonical.use_times(), 2); + assert_eq!(canonical.loot_state(), LootState::JustDeactivated); + } + #[tokio::test] async fn gameobject_loot_release_fishing_hole_uses_canonical_use_count_when_represented_stale_like_cpp() { @@ -17251,6 +28922,35 @@ mod tests { assert_eq!(send_rx.try_recv().unwrap(), expected); } + #[test] + fn partial_gathering_node_release_does_not_run_on_loot_release_state_like_cpp() { + let mut session = make_session(); + let player_guid = ObjectGuid::create_player(1, 61_900); + let gathering_node = test_gameobject_guid(61_901); + session.record_represented_gameobject_runtime_state_like_cpp( + 0, + gathering_node, + gathering_node.entry(), + Position::ZERO, + GAMEOBJECT_TYPE_GATHERING_NODE as u8, + ); + + session.apply_represented_gameobject_loot_release_like_cpp( + gathering_node, + player_guid, + false, + false, + None, + ); + + let state = session + .represented_gameobject_use_states + .get(&gathering_node) + .unwrap(); + assert_ne!(state.go_state, Some(GoState::Active)); + assert_eq!(state.loot_state, Some(LootState::Activated)); + } + #[tokio::test] async fn loot_release_personal_chest_records_per_player_despawn_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 3f5c171a0..fced7401a 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -8328,6 +8328,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/movement.rs b/crates/wow-world/src/handlers/movement.rs index a1be822ac..83c5db8ac 100644 --- a/crates/wow-world/src/handlers/movement.rs +++ b/crates/wow-world/src/handlers/movement.rs @@ -2668,6 +2668,7 @@ mod tests { is_in_world: true, send_tx, command_tx, + durable_loot_money_tracker_like_cpp: Default::default(), active_loot_rolls: Vec::new(), pass_on_group_loot: false, enchanting_skill: 0, diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index ae580d104..e4bb3c8da 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -159,9 +159,17 @@ enum QuestSourceItemStoreOutcomeLikeCpp { } #[derive(Debug, Clone, PartialEq, Eq)] -struct QuestSourceItemBoundPreflightLikeCpp { - no_grant: bool, - changed_quest_ids: Vec, +pub(crate) struct QuestSourceItemBoundPreflightLikeCpp { + pub(crate) no_grant: bool, + pub(crate) changed_quest_ids: Vec, +} + +/// Durable snapshot of C++ `StoreNewItem`'s first, quest-bound +/// `ItemAddedQuestCheck` pass. A single matching bound objective consumes the +/// loot award as quest credit without materialising an inventory Item. +#[derive(Debug, Clone)] +pub(crate) struct QuestSourceItemBoundPersistencePlanLikeCpp { + pub(crate) statuses: Vec, } fn reputation_rank_from_standing_like_cpp(standing: i32) -> u8 { @@ -1571,6 +1579,112 @@ impl WorldSession { } } + /// C++ walks one objective-status index and stops at the first quest-bound + /// item objective. Rust stores statuses in a `HashMap`, so two independent + /// scans could select different quests. Use the explicit quest-log slot + /// (then quest id as a deterministic duplicate-slot fallback) for both the + /// durable plan and its post-commit application. + fn quest_bound_item_objective_quest_order_like_cpp(&self) -> Vec { + let mut quests = self + .player_quests + .values() + .map(|status| (status.slot, status.quest_id)) + .collect::>(); + quests.sort_unstable(); + quests.into_iter().map(|(_, quest_id)| quest_id).collect() + } + + /// Pure form of the first C++ `Player::StoreNewItem` quest pass: + /// `ItemAddedQuestCheck(itemId, count, true, &hadBoundItemObjective)`. + /// + /// `UpdateQuestObjectiveProgress` stops after the first matching + /// `QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM` objective. When it changes + /// one objective, `StoreNewItem` returns `nullptr` and no physical Item is + /// created. Keeping this as a snapshot lets loot persist the objective and + /// consume its object-owned claim in one SQL/authority transaction. + pub(crate) fn plan_quest_source_item_bound_objective_persistence_like_cpp( + &self, + entry_id: u32, + quest_log_item_id: u32, + count: u32, + ) -> Option { + let quest_store = self.quest_store.as_ref()?; + let count_i32 = i32::try_from(count).unwrap_or(i32::MAX); + let entry_object_id = i32::try_from(entry_id).unwrap_or(i32::MAX); + let quest_log_object_id = i32::try_from(quest_log_item_id).unwrap_or(i32::MAX); + let ordered_quest_ids = self.quest_bound_item_objective_quest_order_like_cpp(); + + for object_id in [entry_object_id, quest_log_object_id] { + if object_id == quest_log_object_id && quest_log_item_id == 0 { + continue; + } + + for quest_id in &ordered_quest_ids { + let Some(current_status) = self.player_quests.get(quest_id) else { + continue; + }; + if current_status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { + continue; + } + let Some(quest) = quest_store.get(current_status.quest_id) else { + continue; + }; + + for (objective_index, objective) in quest.objectives.iter().enumerate() { + if objective.obj_type != QUEST_OBJECTIVE_ITEM_LIKE_CPP_LOCAL + || (objective.flags2 + & QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM_LIKE_CPP_LOCAL) + == 0 + || objective.object_id != object_id + || !Self::represented_quest_objective_completable_like_cpp( + current_status, + quest, + objective_index, + ) + { + continue; + } + + let Ok(storage_index) = usize::try_from(objective.storage_index) else { + continue; + }; + let current = current_status + .objective_counts + .get(storage_index) + .copied() + .unwrap_or(0); + if current >= objective.amount { + continue; + } + + let mut planned_status = current_status.clone(); + if planned_status.objective_counts.len() <= storage_index { + planned_status.objective_counts.resize(storage_index + 1, 0); + } + let new_count = current.saturating_add(count_i32).clamp(0, objective.amount); + planned_status.objective_counts[storage_index] = new_count; + let quest_already_rewarded = self.rewarded_quests.contains(&quest.id); + if new_count >= objective.amount + && Self::represented_can_complete_quest_after_objective_like_cpp( + &planned_status, + quest, + objective.id, + quest_already_rewarded, + ) + { + planned_status.status = QUEST_STATUS_COMPLETE_LIKE_CPP; + } + + return Some(QuestSourceItemBoundPersistencePlanLikeCpp { + statuses: vec![planned_status], + }); + } + } + } + + None + } + async fn apply_quest_source_item_bound_objective_progress_for_object_like_cpp( &mut self, quest_store: &QuestStore, @@ -1579,8 +1693,12 @@ impl WorldSession { ) -> Vec<(u32, i32)> { let mut updated_counts = Vec::new(); let mut quests_to_complete = Vec::new(); + let ordered_quest_ids = self.quest_bound_item_objective_quest_order_like_cpp(); - for status in self.player_quests.values_mut() { + 'quests: for quest_id in ordered_quest_ids { + let Some(status) = self.player_quests.get_mut(&quest_id) else { + continue; + }; if status.status != QUEST_STATUS_INCOMPLETE_LIKE_CPP { continue; } @@ -1632,6 +1750,9 @@ impl WorldSession { { quests_to_complete.push(status.quest_id); } + // C++ `UpdateQuestObjectiveProgress` stops after the first + // credited quest-bound Item objective. + break 'quests; } } @@ -1645,7 +1766,7 @@ impl WorldSession { updated_counts } - async fn apply_quest_source_item_bound_objective_preflight_like_cpp( + pub(crate) async fn apply_quest_source_item_bound_objective_preflight_like_cpp( &mut self, entry_id: u32, quest_log_item_id: u32, @@ -2161,42 +2282,27 @@ impl WorldSession { let mut last_bag = u8::from(wow_entities::INVENTORY_SLOT_BAG_0); let mut last_slot = 0; let mut last_count_in_stack = 0; - let mut next_item_guid = if dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - self.get_inventory_item_by_pos(bag, slot).is_none() - }) { - if let Some(char_db) = self.char_db().map(Arc::clone) { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(row) => row.try_read::(0).unwrap_or(0).saturating_add(1), - Err(error) => { - warn!( - account = self.account_id, - entry_id, - ?error, - "QuestConfirmAccept: failed to allocate DB item guid for source item" - ); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return None; - } - } - } else { - self.inventory_items_like_cpp() - .values() - .map(|item| item.db_guid) - .chain( - self.inventory_item_objects_like_cpp() - .keys() - .map(|guid| guid.counter() as u64), - ) - .max() - .unwrap_or(0) - .saturating_add(1) - } - } else { - 0 + let new_item_count = dest + .iter() + .filter(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + self.get_inventory_item_by_pos(bag, slot).is_none() + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + account = self.account_id, + entry_id, + count = new_item_count, + "QuestConfirmAccept: process-wide item GUID allocator is unavailable" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return None; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); for dest in dest { let bag = (dest.pos >> 8) as u8; @@ -2263,9 +2369,15 @@ impl WorldSession { return None; }; - let db_guid = next_item_guid; - next_item_guid = next_item_guid.saturating_add(1); - let item_guid = ObjectGuid::create_item(self.realm_id(), db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!( + account = self.account_id, + entry_id, + "QuestConfirmAccept: preallocated item GUID count did not match store plan" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return None; + }; let max_durability = self.item_template_max_durability(entry_id); let should_bind = source_item_bonding.is_some_and(|bonding| { matches!(bonding, ItemBondingType::OnAcquire | ItemBondingType::Quest) @@ -2522,42 +2634,27 @@ impl WorldSession { let mut last_bag = u8::from(wow_entities::INVENTORY_SLOT_BAG_0); let mut last_slot = 0; let mut last_count_in_stack = 0; - let mut next_item_guid = if dest.iter().any(|dest| { - let bag = (dest.pos >> 8) as u8; - let slot = (dest.pos & 0x00FF) as u8; - self.get_inventory_item_by_pos(bag, slot).is_none() - }) { - if let Some(char_db) = self.char_db().map(Arc::clone) { - let max_guid_stmt = char_db.prepare(CharStatements::SEL_MAX_ITEM_GUID); - match char_db.query(&max_guid_stmt).await { - Ok(row) => row.try_read::(0).unwrap_or(0).saturating_add(1), - Err(error) => { - warn!( - account = self.account_id, - entry_id, - ?error, - "RewardQuest: failed to allocate DB item guid for reward item" - ); - self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); - return false; - } - } - } else { - self.inventory_items_like_cpp() - .values() - .map(|item| item.db_guid) - .chain( - self.inventory_item_objects_like_cpp() - .keys() - .map(|guid| guid.counter() as u64), - ) - .max() - .unwrap_or(0) - .saturating_add(1) - } - } else { - 0 + let new_item_count = dest + .iter() + .filter(|dest| { + let bag = (dest.pos >> 8) as u8; + let slot = (dest.pos & 0x00FF) as u8; + self.get_inventory_item_by_pos(bag, slot).is_none() + }) + .count(); + let Some(allocated_new_item_guids) = + self.allocate_item_instance_guids_like_cpp(new_item_count) + else { + warn!( + account = self.account_id, + entry_id, + count = new_item_count, + "RewardQuest: process-wide item GUID allocator is unavailable" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; }; + let mut allocated_new_item_guids = allocated_new_item_guids.into_iter(); for dest in dest { let bag = (dest.pos >> 8) as u8; @@ -2624,9 +2721,15 @@ impl WorldSession { return false; }; - let db_guid = next_item_guid; - next_item_guid = next_item_guid.saturating_add(1); - let item_guid = ObjectGuid::create_item(self.realm_id(), db_guid as i64); + let Some((db_guid, item_guid)) = allocated_new_item_guids.next() else { + warn!( + account = self.account_id, + entry_id, + "RewardQuest: preallocated item GUID count did not match store plan" + ); + self.send_equip_error(InventoryResult::InvFull, None, None, 0, 0); + return false; + }; let max_durability = self.item_template_max_durability(entry_id); let should_bind = item_bonding.is_some_and(|bonding| { matches!(bonding, ItemBondingType::OnAcquire | ItemBondingType::Quest) @@ -6121,16 +6224,39 @@ impl WorldSession { let money = quest.reward_money_difficulty; if money > 0 { - let old_money = self.player_gold_like_cpp(); - let new_money = old_money.saturating_add(money as u64); - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - self.set_player_gold_like_cpp(new_money); - self.save_player_gold().await; + match self + .mutate_and_persist_player_gold_exclusive_like_cpp(|old_money| { + crate::session::loot_money_durable_outcome_like_cpp(old_money, money as u64).0 + }) + .await + { + Some((old_money, new_money)) => { + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + None => { + // Boundary: the represented reward path persists item and + // currency grants before reaching money and does not yet + // own C++ `Player::RewardQuest` as one durable transaction. + // Aborting here would leave the quest retryable after those + // grants and permit duplicates. Preserve the existing + // completion behavior; an ambiguous money COMMIT has + // already quarantined/kicked the session in the shared + // helper. Atomic quest reward persistence is separate debt. + warn!( + account = self.account_id, + quest_id, + money, + "Quest reward money was not durably established; preserving non-atomic represented reward completion to avoid duplicate retry" + ); + } + } } self.apply_represented_quest_title_and_talent_rewards_like_cpp(quest); @@ -7724,7 +7850,7 @@ mod tests { ItemClass, ItemContext, }; use wow_core::guid::HighGuid; - use wow_core::{ObjectGuid, Position}; + use wow_core::{ObjectGuid, ObjectGuidGenerator, Position}; use wow_data::quest::{ QUEST_FLAGS_DAILY_LIKE_CPP, QUEST_FLAGS_WEEKLY_LIKE_CPP, QUEST_ITEM_DROP_COUNT, QUEST_REWARD_CHOICES_COUNT, QUEST_REWARD_CURRENCY_COUNT, QUEST_REWARD_DISPLAY_SPELL_COUNT, @@ -7769,6 +7895,14 @@ mod tests { session.set_player_guid(Some(ObjectGuid::create_player(1, 42))); session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); session.set_player_position_like_cpp(Position::new(10.0, 0.0, 0.0, 0.0)); + session.set_item_guid_generator_like_cpp(Arc::new(ObjectGuidGenerator::new( + HighGuid::Item, + 1, + ))); + // Reward tests model a successful CharacterDatabase commit. The + // production path is fail-closed when no database is available; unit + // fixtures have no pool and must opt into the explicit success seam. + session.set_loot_money_persistence_test_result_like_cpp(true); (session, send_rx) } @@ -8671,6 +8805,65 @@ mod tests { assert!(send_rx.try_recv().is_err()); } + #[tokio::test] + async fn bound_item_durable_plan_and_apply_use_the_same_quest_log_order_like_cpp() { + let (mut session, _send_rx) = make_session(); + let item_id = 19_950; + let early_slot_quest_id = 7_452; + let late_slot_quest_id = 7_451; + let bound_quest = |quest_id: u32| { + let mut quest = quest_template(quest_id); + quest.objectives = vec![QuestObjective { + id: quest_id * 10, + quest_id, + obj_type: QUEST_OBJECTIVE_ITEM_LIKE_CPP_LOCAL, + order: 0, + storage_index: 0, + object_id: item_id, + amount: 2, + flags: 0, + flags2: QUEST_OBJECTIVE_FLAG_2_QUEST_BOUND_ITEM_LIKE_CPP_LOCAL, + progress_bar_weight: 0.0, + description: String::new(), + }]; + quest + }; + let quest_store = Arc::new(QuestStore::from_quests_like_cpp([ + bound_quest(late_slot_quest_id), + bound_quest(early_slot_quest_id), + ])); + session.set_quest_store(Arc::clone(&quest_store)); + // Insert the numerically smaller quest first, but give it the later + // quest-log slot. HashMap bucket/insertion order must affect neither + // the pre-SQL plan nor the post-COMMIT mutation. + add_active_quest_in_slot(&mut session, late_slot_quest_id, 9); + add_active_quest_in_slot(&mut session, early_slot_quest_id, 2); + + let planned = session + .plan_quest_source_item_bound_objective_persistence_like_cpp(item_id as u32, 0, 1) + .expect("one bound objective should be planned"); + assert_eq!(planned.statuses.len(), 1); + assert_eq!(planned.statuses[0].quest_id, early_slot_quest_id); + + let applied = session + .apply_quest_source_item_bound_objective_progress_for_object_like_cpp( + quest_store.as_ref(), + item_id, + 1, + ) + .await; + assert_eq!(applied, vec![(early_slot_quest_id, 1)]); + assert_eq!( + session.player_quests[&early_slot_quest_id].objective_counts, + vec![1] + ); + assert!( + session.player_quests[&late_slot_quest_id] + .objective_counts + .is_empty() + ); + } + #[tokio::test] async fn bank_withdrawal_item_objective_never_sends_generic_credit_like_cpp() { let (mut session, send_rx) = make_session(); @@ -13012,8 +13205,8 @@ mod tests { } #[tokio::test] - async fn quest_confirm_accept_source_item_multiple_bound_objectives_still_creates_item_like_cpp() - { + async fn quest_confirm_accept_source_item_multiple_bound_objectives_stops_after_first_like_cpp() + { let (mut session, send_rx) = make_session(); let receiver_guid = session.player_guid().unwrap(); let sender_guid = ObjectGuid::create_player(1, 192); @@ -13063,7 +13256,7 @@ mod tests { .player_quests .get(&quest_id) .expect("source-item quest should add local quest state"); - assert_eq!(status.objective_counts, vec![2, 2]); + assert_eq!(status.objective_counts, vec![2, 0]); let stored_source_item_count: u32 = session .inventory_items_like_cpp() .values() @@ -13071,7 +13264,7 @@ mod tests { .filter_map(|item| session.inventory_item_objects_like_cpp().get(&item.guid)) .map(|item| item.count()) .sum(); - assert_eq!(stored_source_item_count, 2); + assert_eq!(stored_source_item_count, 0); assert_eq!( session.represented_quest_confirm_accepts_like_cpp(), &[RepresentedQuestConfirmAcceptLikeCpp { @@ -13079,7 +13272,7 @@ mod tests { sender_guid_before_clear: sender_guid, quest_id, raw_quest_id: quest_id as i32, - reason: RepresentedQuestConfirmAcceptOutcomeReasonLikeCpp::ReceiverGiveQuestSourceItemStoredNewItem, + reason: RepresentedQuestConfirmAcceptOutcomeReasonLikeCpp::ReceiverGiveQuestSourceItemBoundObjectiveNoGrant, object_accessor_unrepresented: true, party_runtime_unrepresented: true, can_add_source_item_unrepresented: false, @@ -13097,7 +13290,7 @@ mod tests { packet.read_uint16().ok() == Some(wow_constants::ServerOpcodes::ItemPushResult as u16) }), - "multiple bound objectives do not trigger C++ no-grant path, so SendNewItem still runs" + "C++ sends the bound-objective ItemPushResult without materializing an inventory Item" ); assert!(sender_rx.try_recv().is_err()); } diff --git a/crates/wow-world/src/handlers/spell.rs b/crates/wow-world/src/handlers/spell.rs index c347ef3e9..3ff2d89da 100644 --- a/crates/wow-world/src/handlers/spell.rs +++ b/crates/wow-world/src/handlers/spell.rs @@ -84,6 +84,14 @@ fn spell_power_trace_enabled_like_cpp() -> bool { }) } +fn normalize_item_money_loot_bounds_like_cpp(min_money: u32, max_money: u32) -> (u32, u32) { + if min_money > max_money { + (max_money, min_money) + } else { + (min_money, max_money) + } +} + // ── Handler registrations ───────────────────────────────────────── inventory::submit! { @@ -922,7 +930,8 @@ impl WorldSession { let loot_guid = loot.loot_guid; let coins = loot.coins; - self.set_active_loot_guid(item.guid); + self.open_active_item_loot_view_like_cpp(player_guid, item.guid) + .await; self.send_packet(&LootResponse { owner: item.guid, loot_obj: loot_guid, @@ -938,6 +947,17 @@ impl WorldSession { }); } + pub(crate) async fn open_active_item_loot_view_like_cpp( + &mut self, + player_guid: ObjectGuid, + item_guid: ObjectGuid, + ) { + if self.has_active_non_item_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } + self.add_active_loot_view_owner_like_cpp(item_guid); + } + async fn open_wrapped_gift_like_cpp(&mut self, bag: u8, slot: u8, item_guid: ObjectGuid) { let gift = match self.load_wrapped_gift_row_like_cpp(item_guid).await { WrappedGiftLoad::Found(gift) => gift, @@ -1140,9 +1160,29 @@ impl WorldSession { match world_db.query(&stmt).await { Ok(result) if !result.is_empty() => { - let min_money = result.try_read::(0).unwrap_or(0); - let max_money = result.try_read::(1).unwrap_or(0); - (min_money, max_money) + match (result.try_read::(0), result.try_read::(1)) { + (Some(min_money), Some(max_money)) => { + if min_money > max_money { + // ObjectMgr::LoadItemTemplateAddon swaps invalid item + // bounds before storing the template. GameObject addon + // money deliberately does not share this normalization. + warn!( + item_entry, + min_money, + max_money, + "minimum item money loot exceeded maximum; swapping like C++" + ); + } + normalize_item_money_loot_bounds_like_cpp(min_money, max_money) + } + _ => { + warn!( + item_entry, + "failed to decode item_template_addon money loot as C++ uint32 columns" + ); + (0, 0) + } + } } Ok(_) => (0, 0), Err(err) => { @@ -2745,7 +2785,8 @@ mod tests { apply_wrapped_gift_transform_like_cpp, item_loot_quest_status_allows_like_cpp, loot_entry_flags_for_row_metadata_like_cpp, loot_template_group_row_can_roll_like_cpp, loot_template_plain_row_can_roll_like_cpp, loot_template_reference_row_can_roll_like_cpp, - player_class_mask_like_cpp, player_quest_status_mask_like_cpp, player_race_mask_like_cpp, + normalize_item_money_loot_bounds_like_cpp, player_class_mask_like_cpp, + player_quest_status_mask_like_cpp, player_race_mask_like_cpp, referenced_loot_max_count_like_cpp, roll_chance_with_rate_like_cpp, roll_group_loot_row_like_cpp, stored_item_row_can_load_like_cpp_representable, stored_loot_item_should_persist_like_cpp, @@ -5922,18 +5963,18 @@ mod tests { wow_loot::generate_money_loot_with_rate_like_cpp(0, 0, 1.0, &mut rng), 0 ); - assert_eq!( - wow_loot::generate_money_loot_with_rate_like_cpp(120, 100, 1.0, &mut rng), - 100 - ); + let swapped = normalize_item_money_loot_bounds_like_cpp(120, 100); + assert_eq!(swapped, (100, 120)); + let swapped_roll = + wow_loot::generate_money_loot_with_rate_like_cpp(swapped.0, swapped.1, 1.0, &mut rng); + assert!((100..=120).contains(&swapped_roll)); assert_eq!( wow_loot::generate_money_loot_with_rate_like_cpp(100, 100, 1.0, &mut rng), 100 ); - assert_eq!( - wow_loot::generate_money_loot_with_rate_like_cpp(120, 100, 2.5, &mut rng), - 250 - ); + let swapped_rate_roll = + wow_loot::generate_money_loot_with_rate_like_cpp(swapped.0, swapped.1, 2.5, &mut rng); + assert!((250..=300).contains(&swapped_rate_roll)); let small_range = wow_loot::generate_money_loot_with_rate_like_cpp(100, 200, 1.0, &mut rng); assert!((100..=200).contains(&small_range)); diff --git a/crates/wow-world/src/handlers/talent.rs b/crates/wow-world/src/handlers/talent.rs index 8b604312d..0df1fa329 100644 --- a/crates/wow-world/src/handlers/talent.rs +++ b/crates/wow-world/src/handlers/talent.rs @@ -15,10 +15,9 @@ use wow_packet::packets::talent::{ }; use wow_packet::{ClientPacket, WorldPacket}; -use crate::session::{ - RepresentedConfirmRespecWipeLikeCpp, RepresentedTalentRespecVisualSpellCastLikeCpp, - WorldSession, -}; +#[cfg(test)] +use crate::session::RepresentedTalentRespecVisualSpellCastLikeCpp; +use crate::session::{RepresentedConfirmRespecWipeLikeCpp, WorldSession}; const CONFIRM_RESPEC_WIPE_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits(); const MIN_TALENT_RESET_LEVEL_LIKE_CPP: u8 = 15; @@ -126,33 +125,27 @@ impl WorldSession { return; } + // Preserve C++ ResetTalents ordering for these precondition-side + // effects. The durable transaction covers money, reset metadata, and + // talent topology. Pet/spell runtime removal, criteria, and packets are + // published synchronously after COMMIT; exact `_SaveSpells` persistence + // remains bounded until Rust retains the complete PlayerSpellMap state. self.record_represented_talent_reset_script_hook_like_cpp(false); self.remove_represented_at_login_flag_like_cpp(AT_LOGIN_RESET_TALENTS_LIKE_CPP, true); - if !self.apply_represented_talent_reset_cost_like_cpp().await { + let Some(committed) = self.commit_represented_talent_reset_like_cpp().await else { return; - } - - self.remove_represented_pet_not_in_slot_like_cpp(); + }; - self.record_represented_confirm_respec_wipe_like_cpp(RepresentedConfirmRespecWipeLikeCpp { - respec_master: request.respec_master, - respec_type: request.respec_type, - }); - if self.reset_represented_active_talents_like_cpp() { - self.send_packet(&self.represented_update_talent_data_packet_like_cpp()); - if let Some(player_guid) = self.player_guid() { - self.record_represented_talent_respec_visual_spell_cast_like_cpp( - RepresentedTalentRespecVisualSpellCastLikeCpp { - caster_guid: request.respec_master, - target_guid: player_guid, - spell_id: UNTALENT_VISUAL_EFFECT_SPELL_ID_LIKE_CPP, - triggered: true, - spell_runtime_unrepresented: true, - }, - ); - } - } + self.publish_committed_represented_talent_reset_like_cpp( + committed, + RepresentedConfirmRespecWipeLikeCpp { + respec_master: request.respec_master, + respec_type: request.respec_type, + }, + UNTALENT_VISUAL_EFFECT_SPELL_ID_LIKE_CPP, + ) + .await; } fn represented_can_confirm_respec_wipe_like_cpp( @@ -1882,6 +1875,92 @@ mod tests { ); } + #[tokio::test] + async fn confirm_respec_wipe_definite_rollback_publishes_no_covered_runtime_or_packets() { + let (mut session, send_rx) = make_session_with_send_capacity(4); + let trainer = test_creature_guid(189); + let pet_guid = ObjectGuid::create_world_object(HighGuid::Pet, 0, 1, 0, 0, 500, 143); + let mut talent = test_talent_entry_like_cpp(101, 0, 50_101); + talent.spell_id = 70_101; + talent.overrides_spell_id = 60_101; + register_test_trainer(&mut session, trainer, NPCFlags1::TRAINER.bits()); + install_test_talent_entries_with_tab_class_mask(&mut session, vec![talent], 1); + session.mark_represented_talents_loaded_like_cpp(); + + session + .handle_learn_talent(learn_talent_packet(101, 0)) + .await; + let _ = drain_sent_packets(&send_rx); + session.set_represented_pet_mode_state_like_cpp( + Some(pet_guid), + wow_packet::packets::pet::REACT_DEFENSIVE_LIKE_CPP, + wow_packet::packets::pet::COMMAND_FOLLOW_LIKE_CPP, + ); + session.set_represented_pet_stable_like_cpp(represented_current_pet_stable_like_cpp(143)); + session.set_player_gold_like_cpp(20_000); + session.set_represented_at_login_flags_like_cpp( + AT_LOGIN_RENAME_LIKE_CPP | AT_LOGIN_RESET_TALENTS_LIKE_CPP, + ); + session.set_loot_money_persistence_test_result_like_cpp(false); + + session + .handle_confirm_respec_wipe(confirm_respec_wipe_packet( + trainer, + SPEC_RESET_TALENTS_LIKE_CPP, + )) + .await; + + assert!( + send_rx.try_recv().is_err(), + "a definite SQL rollback must not publish talent, spell, money, or visual packets" + ); + assert_eq!(session.player_gold_like_cpp(), 20_000); + assert_eq!(session.represented_talent_reset_cost_like_cpp(), 0); + assert_eq!(session.represented_talent_reset_time_secs_like_cpp(), 0); + assert!(session.known_spells_like_cpp().contains(&50_101)); + assert_eq!( + session + .represented_update_talent_data_packet_like_cpp() + .groups[0] + .talents, + vec![wow_packet::packets::misc::TalentInfoLikeCpp { + talent_id: 101, + rank: 0, + }] + ); + assert_eq!(session.represented_pet_guid_like_cpp(), Some(pet_guid)); + assert_eq!( + session.represented_pet_stable_current_index_like_cpp(), + Some(0) + ); + assert!( + session + .represented_talent_respec_criteria_events_like_cpp() + .is_empty() + ); + assert!( + session + .represented_confirm_respec_wipe_requests_like_cpp() + .is_empty() + ); + assert!( + session + .represented_talent_respec_visual_spell_casts_like_cpp() + .is_empty() + ); + + assert_eq!( + session.represented_talent_reset_script_hooks_like_cpp(), + &[RepresentedTalentResetScriptHookLikeCpp { no_cost: false }], + "C++ fires the script hook before the reset's money/transaction gate" + ); + assert_eq!( + session.represented_at_login_flags_like_cpp(), + AT_LOGIN_RENAME_LIKE_CPP, + "C++ removes AT_LOGIN_RESET_TALENTS before entering the fallible save" + ); + } + #[tokio::test] async fn confirm_respec_wipe_removes_feign_death_before_reset_talents_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(4); diff --git a/crates/wow-world/src/handlers/trainer.rs b/crates/wow-world/src/handlers/trainer.rs index 1f93c7698..fc0c6b594 100644 --- a/crates/wow-world/src/handlers/trainer.rs +++ b/crates/wow-world/src/handlers/trainer.rs @@ -29,6 +29,7 @@ use tracing::{info, warn}; use wow_constants::ClientOpcodes; use wow_constants::unit::NPCFlags1; +use wow_database::SqlTransaction; use wow_database::statements::character::CharStatements; use wow_database::statements::world::WorldStatements; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus}; @@ -38,7 +39,7 @@ use wow_packet::packets::trainer::{ }; use crate::conditions; -use crate::session::{RepresentedQuestObjectiveProgressEventLikeCpp, WorldSession}; +use crate::session::WorldSession; const TRAINER_LIST_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits(); const TRAINER_BUY_NPC_FLAGS_LIKE_CPP: u32 = NPCFlags1::TRAINER.bits() @@ -550,41 +551,60 @@ impl WorldSession { None => return, }; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; + // ── Deduct gold ──────────────────────────────────────────────────── let old_money = self.player_gold_like_cpp(); let new_money = old_money.saturating_sub(money_cost as u64); - self.enqueue_represented_quest_objective_progress_like_cpp( - RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { - old_money, - new_money, - }, - ); - self.set_player_gold_like_cpp(new_money); + let mut tx = SqlTransaction::new(); let mut upd_money = char_db.prepare(CharStatements::UPD_CHAR_MONEY); - upd_money.set_u64(0, self.player_gold_like_cpp()); + upd_money.set_u64(0, new_money); upd_money.set_u64(1, player_guid.counter() as u64); - if let Err(e) = char_db.execute(&upd_money).await { - warn!( - account = self.account_id, - "TrainerBuySpell: update money failed: {e}" - ); - } + tx.append(upd_money); // ── Persist spell to character_spell ─────────────────────────────── let mut ins_spell = char_db.prepare(CharStatements::INS_CHARACTER_SPELL); ins_spell.set_u64(0, player_guid.counter() as u64); ins_spell.set_i32(1, spell_id); - if let Err(e) = char_db.execute(&ins_spell).await { + tx.append(ins_spell); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + tx, + old_money, + new_money, + "trainer spell purchase", + ) + .await + else { warn!( account = self.account_id, spell_id = spell_id, - "TrainerBuySpell: insert character_spell failed: {e}" + "TrainerBuySpell: atomic money/spell transaction did not commit" ); - } - - // ── Update in-memory state ───────────────────────────────────────── + self.send_packet(&TrainerBuyFailed { + trainer_guid, + spell_id, + reason: 0, + }); + return; + }; + // Publish every runtime field represented by the committed money/spell + // transaction before reopening money-payout admission. There must be + // no cancellation point between COMMIT and this publication. + self.stage_player_money_change_like_cpp(old_money, new_money); self.learn_known_spell_like_cpp(spell_id); + self.sync_object_accessor_player(); self.sync_player_registry_state_like_cpp(); + drop(money_persistence); + + // ── Update in-memory state ───────────────────────────────────────── self.drain_represented_quest_objective_progress_like_cpp() .await; diff --git a/crates/wow-world/src/map_manager.rs b/crates/wow-world/src/map_manager.rs index ac21faec1..8dd3b7faa 100644 --- a/crates/wow-world/src/map_manager.rs +++ b/crates/wow-world/src/map_manager.rs @@ -1685,6 +1685,14 @@ impl WorldCreature { object.force_dynamic_flags_update_like_cpp(); } + pub fn force_dynamic_flags_update_like_cpp(&mut self) { + self.creature + .unit_mut() + .world_mut() + .object_mut() + .force_dynamic_flags_update_like_cpp(); + } + pub fn has_lootable_dynamic_flag_like_cpp(&self) -> bool { self.creature .unit() @@ -7913,6 +7921,10 @@ mod tests { static_flags: [0; 8], creature_type: 7, type_flags: 0, + loot_id: 21_779, + skin_loot_id: 21_780, + gold_min: 13, + gold_max: 31, movement_type: wow_entities::MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, @@ -8000,6 +8012,10 @@ mod tests { ); assert_eq!(bridged.create_data.speed_walk_rate, 1.0); assert_eq!(bridged.create_data.speed_run_rate, 1.14286); + assert_eq!(bridged.creature.ai_ownership().loot_id, 21_779); + assert_eq!(bridged.creature.ai_ownership().skin_loot_id, 21_780); + assert_eq!(bridged.creature.ai_ownership().gold_min, 13); + assert_eq!(bridged.creature.ai_ownership().gold_max, 31); } #[test] @@ -8036,6 +8052,10 @@ mod tests { static_flags: [0; 8], creature_type: 7, type_flags: 0, + loot_id: 0, + skin_loot_id: 0, + gold_min: 0, + gold_max: 0, movement_type: wow_entities::MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 82fb241ee..ee35b4be8 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -7,14 +7,17 @@ //! [`WorldSocket`](wow_network::WorldSocket) and dispatches them to handlers. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::sync::{ Arc, Mutex, OnceLock, - atomic::{AtomicI64, Ordering}, + atomic::{AtomicBool, AtomicI64, Ordering}, }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parking_lot::RwLock; use rand::{Rng, RngCore, SeedableRng, rngs::StdRng, seq::SliceRandom}; +use sqlx::Row; use tracing::{debug, info, trace, warn}; use crate::entity_update_bridge::{ @@ -126,8 +129,9 @@ use wow_data::{ spell_duration_ms_like_cpp, spell_effect_radius_like_cpp, }; use wow_database::{ - CharStatements, CharacterDatabase, LoginDatabase, LoginStatements, PreparedStatement, - SqlTransaction, StatementDef, WorldDatabase, + CharStatements, CharacterDatabase, DatabaseError, LoginDatabase, LoginStatements, + PreparedStatement, SqlTransaction, SqlTransactionCommitError, StatementDef, WorldDatabase, + is_database_deadlock_like_cpp, retry_deadlocked_operation_like_cpp, }; use wow_entities::{ AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentDurationAction, @@ -171,17 +175,23 @@ use wow_entities::{ CONTAINER_DATA_SLOTS_PARENT_BIT, ContainerDataUpdate, ContainerDataValues, }; use wow_handler::{PacketHandlerEntry, PacketProcessing, SessionStatus, build_dispatch_table}; -use wow_loot::{LootStoreKind, LootStores}; +use wow_loot::{ + LootClaimLease, LootStoreKind, LootStores, OwnedLootAuthority, OwnedLootAuthorityLifecycle, + OwnedLootAuthorityStamp, OwnedLootScope, OwnedLootSnapshot, +}; use wow_map::coords::SIZE_OF_GRID_CELL; use wow_network::player_registry::SendIfVisibleLikeCppCommand; use wow_network::session_mgr::{InstanceLink, SessionManager}; use wow_network::{ ChatFloodConfigLikeCpp, ChatLevelRequirementsLikeCpp, ChatListenRangesLikeCpp, + DurableLootMoneyCompletionLikeCpp, DurableLootMoneyPersistenceGuardLikeCpp, + DurableLootMoneyPersistenceTrackerLikeCpp, DurableLootMoneySaveFenceLikeCpp, GameEventQuestCompleteClientOutcomeLikeCpp, GameEventQuestCompleteCommandLikeCpp, GroupInfo, GroupInstanceResetMethodLikeCpp, GroupInstanceResetResultLikeCpp, GroupRegistry, - KickLikeCppCommand, LootDropRatesLikeCpp, PacketSpoofConfigLikeCpp, PendingInvites, + KickLikeCppCommand, LootDropRatesLikeCpp, LootRollCommandIdentityLikeCpp, + NotifyLootMoneyRemovedLikeCppCommand, PacketSpoofConfigLikeCpp, PendingInvites, PlayerBroadcastInfo, PlayerRegistry, ReputationRatesLikeCpp, SessionCommand, - SocketTimeoutsLikeCpp, group_guid_by_db_store_id_like_cpp, + SocketTimeoutsLikeCpp, SocketWriteFenceLikeCpp, group_guid_by_db_store_id_like_cpp, }; use wow_packet::packets::chat::{ChatMsg, ChatPkt, PrintNotification}; use wow_packet::packets::gossip::ClientGossipText; @@ -236,6 +246,487 @@ pub(crate) const REST_STATE_RESTED_LIKE_CPP: u8 = 1; pub(crate) const REST_STATE_NORMAL_LIKE_CPP: u8 = 2; pub(crate) const REST_STATE_RAF_LINKED_LIKE_CPP: u8 = 6; +#[derive(Debug)] +pub(crate) enum LootMoneyPersistenceErrorLikeCpp { + MissingPlayer, + MissingCharacterDatabase, + WorkerTerminated, + Claim(wow_loot::LootClaimCommitError), + Database(DatabaseError), + CommitOutcomeUnknown(DatabaseError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DurableLootMoneyDbOutcomeLikeCpp { + before: u64, + after: u64, + applied_delta: u64, +} + +/// Owned exclusion held while an absolute character-money mutation derives +/// and persists its new value. Admission stays closed and the same +/// per-character serial lock used by group/stored loot remains held through +/// the caller's COMMIT. +#[must_use] +pub(crate) struct ExclusivePlayerMoneyPersistenceLikeCpp { + _save_fence: DurableLootMoneySaveFenceLikeCpp, + _mutation_lock: tokio::sync::OwnedMutexGuard<()>, +} + +/// Pure post-reset snapshot used to make the durable talent reset and its +/// runtime publication describe the same state. +/// +/// C++ `Player::ResetTalents` calls `RemoveTalent` for the active group, then +/// `_SaveTalents` rewrites every group. `RemoveTalent` calls +/// `RemoveSpell(..., disabled=true)`, and the complete C++ `_SaveSpells` path +/// rewrites those rows with their exact active/disabled/favorite state. Rust +/// does not retain the full `PlayerSpellMap` active/disabled/temporary state, +/// so this deliberately leaves `character_spell` and +/// `character_spell_favorite` untouched. A known non-dependent spell proves a +/// normal persisted row exists; it does not prove that the talent is its only +/// source. Deleting that row would lose normal ownership where C++ instead +/// preserves a disabled row. Recursive `RemoveSpell` persistence and exact +/// disabled/favorite preservation therefore remain a represented boundary, +/// while active `character_talent` rows can no longer survive a committed fee. +#[derive(Debug, Clone, PartialEq, Eq)] +struct RepresentedTalentResetStatePlanLikeCpp { + active_group: u8, + active_talents: BTreeMap, + post_talents: [BTreeMap; MAX_SPECIALIZATIONS_LIKE_CPP], +} + +/// A successfully committed reset whose covered runtime state still has to be +/// published synchronously before the money exclusion is released. +#[must_use] +pub(crate) struct CommittedRepresentedTalentResetLikeCpp { + money_persistence: ExclusivePlayerMoneyPersistenceLikeCpp, + old_money: u64, + new_money: u64, + cost: u32, + reset_time_secs: u64, + state_plan: RepresentedTalentResetStatePlanLikeCpp, +} + +/// Once a SQL transaction containing an absolute money write starts awaiting +/// COMMIT, cancellation is itself an unknown outcome. This synchronous drop +/// fence prevents a cancelled packet/shutdown future from reopening payout +/// admission and then letting disconnect-save overwrite a transaction whose +/// COMMIT reply was never observed. +struct PlayerMoneyCommitCancellationFenceLikeCpp { + tracker: Arc, + armed: bool, +} + +impl PlayerMoneyCommitCancellationFenceLikeCpp { + fn new(tracker: Arc) -> Self { + Self { + tracker, + armed: true, + } + } + + fn disarm_like_cpp(&mut self) { + self.armed = false; + } +} + +impl Drop for PlayerMoneyCommitCancellationFenceLikeCpp { + fn drop(&mut self) { + if self.armed { + self.tracker.mark_indeterminate_like_cpp(); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AbsolutePlayerMoneyCommitReconciliationLikeCpp { + Committed, + RolledBack, + Indeterminate, +} + +/// Reconcile an ambiguous COMMIT using a money row whose value changed in the +/// transaction. Equal before/after values are deliberately not evidence: a +/// caller may have bundled other durable mutations whose outcome cannot be +/// inferred from an unchanged money column. +fn reconcile_absolute_player_money_commit_like_cpp( + money_before: u64, + money_after: u64, + observed_money: Option, +) -> AbsolutePlayerMoneyCommitReconciliationLikeCpp { + if money_before == money_after { + return AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate; + } + + match observed_money { + Some(observed) if observed == money_after => { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed + } + Some(observed) if observed == money_before => { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack + } + Some(_) | None => AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate, + } +} + +/// Routing data retained by the detached durable worker so viewers that open +/// the same C++ `Loot` while SQL is in flight are not missed. The worker +/// samples the authority only after the money claim commits; an opener before +/// that point saw the non-zero pool and receives `CoinRemoved`, while a later +/// opener observes zero directly in its `LootResponse`. +pub(crate) struct LootMoneyViewerFanoutLikeCpp { + pub scope_player: ObjectGuid, + pub source_player: ObjectGuid, + pub source_command_tx: flume::Sender, + pub player_registry: Option>, + pub map_id: u16, + pub instance_id: u32, + pub loot_owner: ObjectGuid, + pub loot_obj: ObjectGuid, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub payout_recipients: HashSet, +} + +/// Routing state for one durable item claim. The completion owns this +/// independently of the packet waiter, so a timeout, cancellation, or later +/// `CMSG_LOOT_RELEASE` cannot suppress the result of an already ordered claim. +/// C++ serializes these handlers and notifies synchronously; Rust's SQL wait is +/// an implementation detail, so the pre-COMMIT cohort is retained and the +/// exact COMMIT snapshot adds viewers that opened during that wait. `published` +/// is shared with the normal handler path and provides the single publication +/// CAS. +#[derive(Clone)] +pub(crate) struct DurableLootItemFanoutLikeCpp { + pub owner_guid: ObjectGuid, + pub loot_obj: ObjectGuid, + pub loot_list_id: u8, + pub player_guid: ObjectGuid, + pub free_for_all: bool, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub precommit_snapshot: OwnedLootSnapshot, + /// Exact post-mutation pool captured by the claim commit while the + /// authority mutex is still held. A viewer opening after that point has + /// already observed the removed item and must not receive a stale + /// `LootRemoved` fanout. + pub committed_snapshot: Arc>, + pub source_send_tx: flume::Sender>, + pub player_registry: Option>, + pub map_id: u16, + pub instance_id: u32, + pub published: Arc, +} + +impl std::fmt::Debug for DurableLootItemFanoutLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DurableLootItemFanoutLikeCpp") + .field("owner_guid", &self.owner_guid) + .field("loot_obj", &self.loot_obj) + .field("loot_list_id", &self.loot_list_id) + .field("player_guid", &self.player_guid) + .field("free_for_all", &self.free_for_all) + .field("authority_generation", &self.authority_generation) + .field("map_id", &self.map_id) + .field("instance_id", &self.instance_id) + .field("published", &self.published.load(Ordering::Acquire)) + .finish_non_exhaustive() + } +} + +/// Runtime publication retained by a detached durable-loot worker after its +/// SQL transaction commits. Every detached item grant is tracked until the +/// live Player inventory is synchronized; Item owners additionally use the +/// completion to publish stored-container removal/release state. The same +/// tracker also prevents a committed stored-container money payout from being +/// overwritten by a stale disconnect save. +#[derive(Debug, Clone)] +pub(crate) struct DurableItemLootCompletionLikeCpp { + pub owner_guid: ObjectGuid, + pub loot_list_id: u8, + pub player_guid: ObjectGuid, + pub item_owner_auto_release: bool, + /// Delta accepted from the character row locked in the same transaction + /// that deletes an Item owner's stored-money row. Keeping a delta preserves + /// intervening local/group changes; `None` identifies an item grant. + pub durable_item_money_applied_amount: Option, + /// Original C++ loot-money notification amount. This is retained instead + /// of reconstructing it from runtime balances, and is emitted even when it + /// is zero. + pub durable_item_money_notified_amount: Option, + /// Exact-once gate shared with the per-character durable money tracker. + /// A save fence may apply the balance delta before this completion gets a + /// chance to publish source removal and client notification. + pub durable_item_money_balance_applied: Option>, + /// Retained only for object-owned item claims. Stored-container money has + /// its own durable fanout route. + pub item_fanout: Option, + /// Exact-once gate for item/source publication and lifecycle. This is + /// intentionally separate from `durable_item_money_balance_applied`. + /// Item-grant completions observed false require a relog; stored-money + /// completions can publish source removal after a save applied the balance. + pub runtime_inventory_applied: Arc, +} + +#[derive(Debug, Default)] +struct DurableItemLootPersistenceStateLikeCpp { + in_flight: usize, + completions: Vec, +} + +/// Session-local counterpart to an authority persistence guard for durable +/// loot grants. It lets logout/disconnect wait for detached item/money +/// transactions, publish their committed runtime state, and only then run C++ +/// `DoLootReleaseAll` and save the Player. +#[derive(Debug, Clone)] +pub(crate) struct DurableItemLootPersistenceTrackerLikeCpp { + state: Arc>, + changed: tokio::sync::watch::Sender, +} + +impl Default for DurableItemLootPersistenceTrackerLikeCpp { + fn default() -> Self { + let (changed, _) = tokio::sync::watch::channel(0); + Self { + state: Arc::new(Mutex::new(DurableItemLootPersistenceStateLikeCpp::default())), + changed, + } + } +} + +impl DurableItemLootPersistenceTrackerLikeCpp { + pub(crate) fn begin_like_cpp(&self) -> DurableItemLootPersistenceGuardLikeCpp { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight += 1; + DurableItemLootPersistenceGuardLikeCpp { + tracker: self.clone(), + completion: None, + } + } + + pub(crate) async fn wait_until_idle_like_cpp(&self) { + let mut changed = self.changed.subscribe(); + loop { + if self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight + == 0 + { + return; + } + if changed.changed().await.is_err() { + return; + } + } + } + + pub(crate) fn take_completions_like_cpp(&self) -> Vec { + std::mem::take( + &mut self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .completions, + ) + } +} + +#[derive(Debug)] +pub(crate) struct DurableItemLootPersistenceGuardLikeCpp { + tracker: DurableItemLootPersistenceTrackerLikeCpp, + completion: Option, +} + +impl DurableItemLootPersistenceGuardLikeCpp { + pub(crate) fn mark_committed_like_cpp(&mut self, completion: DurableItemLootCompletionLikeCpp) { + self.completion = Some(completion); + } +} + +impl Drop for DurableItemLootPersistenceGuardLikeCpp { + fn drop(&mut self) { + let mut state = self + .tracker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(completion) = self.completion.take() { + state.completions.push(completion); + } + state.in_flight = state.in_flight.saturating_sub(1); + drop(state); + self.tracker + .changed + .send_modify(|version| *version = version.wrapping_add(1)); + } +} + +#[cfg(test)] +mod durable_item_loot_persistence_tracker_tests { + use std::time::Duration; + + use super::DurableItemLootPersistenceTrackerLikeCpp; + + #[tokio::test] + async fn completion_between_idle_check_and_wait_poll_is_not_lost_like_cpp() { + let tracker = DurableItemLootPersistenceTrackerLikeCpp::default(); + let guard = tracker.begin_like_cpp(); + let mut changed = tracker.changed.subscribe(); + assert_eq!( + tracker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .in_flight, + 1 + ); + + // Deliberately publish after the locked busy observation but before + // `changed()` is first polled. watch retains the version transition. + drop(guard); + tokio::time::timeout(Duration::from_secs(1), changed.changed()) + .await + .expect("durable item persistence wake must not be lost") + .unwrap(); + tracker.wait_until_idle_like_cpp().await; + } +} + +impl std::fmt::Display for LootMoneyPersistenceErrorLikeCpp { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingPlayer => formatter.write_str("loot-money player is missing"), + Self::MissingCharacterDatabase => { + formatter.write_str("loot-money character database is missing") + } + Self::WorkerTerminated => formatter.write_str("loot-money persistence worker stopped"), + Self::Claim(error) => write!(formatter, "loot-money claim failure: {error:?}"), + Self::Database(error) => write!(formatter, "loot-money database failure: {error}"), + Self::CommitOutcomeUnknown(error) => { + write!(formatter, "loot-money COMMIT outcome is unknown: {error}") + } + } + } +} + +impl std::error::Error for LootMoneyPersistenceErrorLikeCpp { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(error) | Self::CommitOutcomeUnknown(error) => Some(error), + Self::MissingPlayer + | Self::MissingCharacterDatabase + | Self::WorkerTerminated + | Self::Claim(_) => None, + } + } +} + +/// C++ `Player::ModifyMoney` accepts the whole positive delta or leaves the +/// balance unchanged when it would cross `MAX_MONEY_AMOUNT`. +pub(crate) fn loot_money_durable_outcome_like_cpp( + current_money: u64, + requested_delta: u64, +) -> (u64, u64) { + current_money + .checked_add(requested_delta) + .filter(|new_money| *new_money <= MAX_MONEY_AMOUNT) + .map_or((current_money, 0), |new_money| (new_money, requested_delta)) +} + +#[derive(Debug)] +enum GroupLootMoneyAttemptErrorLikeCpp { + DefinitelyRolledBack(LootMoneyPersistenceErrorLikeCpp), + CommitOutcomeUnknown { + error: DatabaseError, + outcomes: HashMap, + }, +} + +fn group_loot_money_attempt_is_deadlock_like_cpp( + error: &GroupLootMoneyAttemptErrorLikeCpp, +) -> bool { + matches!( + error, + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error) + ) if is_database_deadlock_like_cpp(error) + ) +} + +async fn attempt_group_loot_money_transaction_like_cpp( + char_db: &CharacterDatabase, + payouts: &[(ObjectGuid, u64)], +) -> Result, GroupLootMoneyAttemptErrorLikeCpp> +{ + let definitely = |error| { + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::from(error)), + ) + }; + let mut transaction = char_db.pool().begin().await.map_err(definitely)?; + let mut outcomes = HashMap::with_capacity(payouts.len()); + for (recipient, amount) in payouts { + let row = sqlx::query(CharStatements::SEL_CHAR_MONEY_FOR_UPDATE.sql()) + .bind(recipient.counter() as u64) + .fetch_optional(&mut *transaction) + .await + .map_err(definitely)? + .ok_or_else(|| { + GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::MissingPlayer, + ) + })?; + let current_money = row.try_get::("money").map_err(definitely)?; + let (new_money, applied_delta) = + loot_money_durable_outcome_like_cpp(current_money, *amount); + if applied_delta != 0 { + let update_result = sqlx::query("UPDATE characters SET money = ? WHERE guid = ?") + .bind(new_money) + .bind(recipient.counter() as u64) + .execute(&mut *transaction) + .await + .map_err(definitely)?; + if update_result.rows_affected() != 1 { + return Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(DatabaseError::Transaction( + format!( + "loot-money update for character {} affected {} rows; expected exactly 1", + recipient.counter(), + update_result.rows_affected() + ), + )), + )); + } + } + outcomes.insert( + *recipient, + DurableLootMoneyDbOutcomeLikeCpp { + before: current_money, + after: new_money, + applied_delta, + }, + ); + } + match transaction.commit().await { + Ok(()) => Ok(outcomes), + Err(error) => { + let error = DatabaseError::from(error); + if is_database_deadlock_like_cpp(&error) { + Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack( + LootMoneyPersistenceErrorLikeCpp::Database(error), + )) + } else { + Err(GroupLootMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { error, outcomes }) + } + } + } +} + /// Live seam for C++ `ScriptMgr::OnAreaTrigger`. /// /// A ported content script receives the mutable session and returns the same @@ -1174,8 +1665,16 @@ pub(crate) struct RepresentedLootRollVote { #[derive(Debug, Clone)] pub(crate) struct RepresentedLootRollState { + pub owner_guid: ObjectGuid, pub loot_obj: ObjectGuid, pub loot_list_id: u8, + pub authority: OwnedLootAuthority, + pub authority_generation: u64, + pub authority_scope: OwnedLootScope, + /// Exact C++ `LootRoll*` lifetime surrogate published to remote sessions. + /// This must change even if a replacement reuses the same packet key, + /// authority allocation, and authority generation. + pub command_identity: LootRollCommandIdentityLikeCpp, pub end_time: Instant, pub voters: HashMap, } @@ -3013,22 +3512,96 @@ pub(crate) fn sync_canonical_creature_entity_on_map_like_cpp( map_id: u32, instance_id: u32, mut creature: wow_entities::Creature, -) { +) -> Option { let guid = creature.unit().world().object().guid(); let Ok(mut manager) = manager.lock() else { - return; + return None; }; let Some(map) = manager.find_map_mut(map_id, instance_id) else { - return; + return None; }; if map.map().get_creature(guid).is_none() { - return; + return None; + } + + if let Some(current_authority) = map + .map() + .get_typed_creature(guid) + .map(|current| current.loot_authority_like_cpp().clone()) + { + let incoming_authority = creature.loot_authority_like_cpp().clone(); + let current_stamp = current_authority.stamp_like_cpp(); + let incoming_stamp = incoming_authority.stamp_like_cpp(); + let authority = reconcile_creature_loot_authority_mirrors_like_cpp( + ¤t_authority, + current_stamp, + &incoming_authority, + incoming_stamp, + ); + map.map_mut() + .get_typed_creature_mut(guid)? + .rebind_loot_authority_if_current_like_cpp( + ¤t_authority, + current_stamp, + authority.clone(), + )?; + // `creature` is a cloned transport snapshot whose old authority is + // still owned by the live legacy entity. Do not detach it here; the + // caller performs the expected-stamp CAS on that actual entity. + creature.adopt_loot_authority_for_snapshot_like_cpp(authority); } creature.unit_mut().world_mut().object_mut().add_to_world(); let Ok(record) = wow_entities::MapObjectRecord::new_creature(creature) else { - return; + return None; }; - let _ = map.map_mut().insert_map_object_record(record); + let authority = record + .creature() + .map(|creature| creature.loot_authority_like_cpp().clone())?; + map.map_mut().insert_map_object_record(record).ok()?; + Some(authority) +} + +/// Selects one backing authority for two mirrors without ever merging two +/// independently claimable active states. Distinct non-pristine authorities +/// are quarantined as one retired canonical tombstone. +pub(crate) fn reconcile_creature_loot_authority_mirrors_like_cpp( + canonical: &OwnedLootAuthority, + canonical_stamp: OwnedLootAuthorityStamp, + incoming: &OwnedLootAuthority, + incoming_stamp: OwnedLootAuthorityStamp, +) -> OwnedLootAuthority { + if canonical.shares_storage_like_cpp(incoming) { + return canonical.clone(); + } + + use OwnedLootAuthorityLifecycle::{Active, Detached, Pristine, Quarantined, Retired}; + + match (canonical_stamp.lifecycle, incoming_stamp.lifecycle) { + // Once divergent live pools were observed, keep the attached terminal + // tombstone until object destruction. It must not be reopened merely + // because another stale mirror still looks active. + (Quarantined, _) => canonical.clone(), + (_, Quarantined) => incoming.clone(), + // Two independently claimable live pools, or a live pool conflicting + // with an attached destruction tombstone, are ambiguous without a + // shared incarnation id. Converge on a terminal fail-closed authority. + (Active, Active) | (Active, Retired) | (Retired, Active) => { + return OwnedLootAuthority::new_retired_tombstone_like_cpp(); + } + // A live authority can safely fill a never-used placeholder. A + // detached allocation has already lost entity ownership. + (Active, Pristine | Detached) => canonical.clone(), + (Pristine | Detached, Active) => incoming.clone(), + // A still-attached retired authority is the lifetime tombstone shared + // across respawn/restock. A displaced authority is classified as + // `Detached`, so it cannot win this branch or be resurrected. + (Retired, Pristine) => canonical.clone(), + (Pristine, Retired) => incoming.clone(), + (Pristine, Pristine) | (Retired, Retired) => canonical.clone(), + (Detached, Detached) => OwnedLootAuthority::new_retired_tombstone_like_cpp(), + (Detached, _) => incoming.clone(), + (_, Detached) => canonical.clone(), + } } pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( @@ -3036,16 +3609,39 @@ pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( map_id: u32, instance_id: u32, mut creature: wow_entities::Creature, -) { +) -> Option { let guid = creature.unit().world().object().guid(); let Ok(mut manager) = manager.lock() else { - return; + return None; }; let Some(map) = manager.find_map_mut(map_id, instance_id) else { - return; + return None; }; if map.map().get_creature(guid).is_some() { - return; + let current = map.map_mut().get_typed_creature_mut(guid)?; + let current_authority = current.loot_authority_like_cpp().clone(); + let incoming_authority = creature.loot_authority_like_cpp().clone(); + let current_stamp = current_authority.stamp_like_cpp(); + let incoming_stamp = incoming_authority.stamp_like_cpp(); + let authority = reconcile_creature_loot_authority_mirrors_like_cpp( + ¤t_authority, + current_stamp, + &incoming_authority, + incoming_stamp, + ); + current.rebind_loot_authority_if_current_like_cpp( + ¤t_authority, + current_stamp, + authority.clone(), + )?; + creature.adopt_loot_authority_for_snapshot_like_cpp(authority.clone()); + return Some(authority); + } + + if creature.loot_authority_like_cpp().lifecycle_like_cpp() + == OwnedLootAuthorityLifecycle::Detached + { + return None; } let object = creature.unit().world().clone(); @@ -3053,10 +3649,12 @@ pub(crate) fn insert_canonical_creature_map_object_on_map_like_cpp( .map_mut() .add_to_map_like_cpp(AccessorObjectKind::Creature, object); creature.unit_mut().world_mut().object_mut().add_to_world(); + let authority = creature.loot_authority_like_cpp().clone(); let Ok(record) = wow_entities::MapObjectRecord::new_creature(creature) else { - return; + return None; }; - let _ = map.map_mut().insert_map_object_record(record); + map.map_mut().insert_map_object_record(record).ok()?; + Some(authority) } pub(crate) fn remove_canonical_creature_map_object_on_map_like_cpp( @@ -3922,6 +4520,8 @@ pub struct WorldSession { // Outbound channel (serialized bytes back to WorldSocket) send_tx: flume::Sender>, + // FIFO completion fence paired with the current physical send channel. + send_write_fence_like_cpp: Option, // Cross-session commands executed by this session's own update loop. session_command_tx: flume::Sender, @@ -4214,6 +4814,8 @@ pub struct WorldSession { // GUID generator for new characters guid_generator: Option>, + // Process-wide C++ ObjectMgr generator for new item instances. + item_guid_generator_like_cpp: Option>, // Characters confirmed for this account legit_characters: Vec, @@ -4482,6 +5084,8 @@ pub struct WorldSession { realm_packet_rx: Option>, /// Realm send channel — kept alive so the realm writer task persists. realm_send_tx: Option>>, + /// FIFO completion fence paired with `realm_send_tx` after ConnectTo. + realm_send_write_fence_like_cpp: Option, // ── Movement & World position ───────────────────────────────── /// Server-side position of the player (updated from CMSG_MOVE_*). @@ -5052,13 +5656,47 @@ pub struct WorldSession { /// Active loot windows keyed by creature GUID. pub(crate) loot_table: std::collections::HashMap, + /// Object-owned loot generation represented by each session-local packet cache. + pub(crate) represented_loot_cache_generations_like_cpp: + std::collections::HashMap, /// Mirrors C++ PlayerData::LootTargetGUID for guards that compare active loot by GUID. pub(crate) active_loot_guid: wow_core::ObjectGuid, /// Represented owner GUIDs currently visible through C++ `Player::m_AELootView`. pub(crate) active_loot_view_owners: std::collections::HashSet, + /// Object-owned generation that was actually opened for each active loot view. + /// + /// GUIDs are reused across creature respawns and gameobject restocks. A delayed + /// packet from an older window must therefore not be authorized merely because + /// the replacement lifetime has the same owner/loot GUID and player eligibility. + pub(crate) active_loot_view_generations_like_cpp: + std::collections::HashMap, + /// Exact backing allocation opened for each view. Scope epochs restart at + /// one in a newly allocated authority, so the generation map alone cannot + /// prevent ABA when a creature GUID is recreated. + pub(crate) active_loot_view_authorities_like_cpp: + std::collections::HashMap, + /// Detached durable loot grants and their post-commit runtime + /// publications. This covers claimed world-owner items plus Item-owner + /// items/money; Item owners have no map-owned loot authority. + durable_item_loot_persistence_like_cpp: DurableItemLootPersistenceTrackerLikeCpp, + /// Per-character fence published to remote loot sources before they begin + /// mutating this character's durable balance. + durable_loot_money_persistence_like_cpp: Arc, /// Represented pending group/NBG loot rolls keyed by `(LootObj, LootListID)`. pub(crate) represented_loot_rolls: std::collections::HashMap<(wow_core::ObjectGuid, u8), RepresentedLootRollState>, + /// Explicit test seam for persistence-sensitive loot-money paths. Production + /// never bypasses the character database. + #[cfg(test)] + loot_money_persistence_test_result_like_cpp: Option, + #[cfg(test)] + pub(crate) loot_item_store_test_grants_like_cpp: Option>, + #[cfg(test)] + pub(crate) loot_item_store_test_success_like_cpp: bool, + /// Optional test-only COMMIT gate for exercising remote command timeout + /// and release while the authority claim is already persistence-owned. + #[cfg(test)] + pub(crate) loot_item_store_test_commit_gate_like_cpp: Option>, #[cfg(test)] pub(crate) represented_loot_roll_criteria_events: Vec, #[cfg(test)] @@ -6020,6 +6658,7 @@ impl WorldSession { mute_time_like_cpp: 0, packet_rx, send_tx, + send_write_fence_like_cpp: None, session_command_tx, session_command_rx, state: SessionState::Authed, @@ -6180,6 +6819,7 @@ impl WorldSession { ("RustyCore".to_string(), "RustyCore".to_string()), )]), guid_generator: None, + item_guid_generator_like_cpp: None, legit_characters: Vec::new(), pending_packets: Vec::new(), player_loading: None, @@ -6331,6 +6971,7 @@ impl WorldSession { cuf_profiles_loaded_like_cpp: false, realm_packet_rx: None, realm_send_tx: None, + realm_send_write_fence_like_cpp: None, player_position: None, player_movement_flags_like_cpp: MovementFlag::NONE, represented_can_swim_to_fly_transition_like_cpp: false, @@ -6619,10 +7260,26 @@ impl WorldSession { MAX_SPECIALIZATIONS_LIKE_CPP], represented_glyphs_loaded_like_cpp: false, loot_table: std::collections::HashMap::new(), + represented_loot_cache_generations_like_cpp: std::collections::HashMap::new(), active_loot_guid: ObjectGuid::EMPTY, active_loot_view_owners: std::collections::HashSet::new(), + active_loot_view_generations_like_cpp: std::collections::HashMap::new(), + active_loot_view_authorities_like_cpp: std::collections::HashMap::new(), + durable_item_loot_persistence_like_cpp: + DurableItemLootPersistenceTrackerLikeCpp::default(), + durable_loot_money_persistence_like_cpp: Arc::new( + DurableLootMoneyPersistenceTrackerLikeCpp::default(), + ), represented_loot_rolls: std::collections::HashMap::new(), #[cfg(test)] + loot_money_persistence_test_result_like_cpp: None, + #[cfg(test)] + loot_item_store_test_grants_like_cpp: None, + #[cfg(test)] + loot_item_store_test_success_like_cpp: true, + #[cfg(test)] + loot_item_store_test_commit_gate_like_cpp: None, + #[cfg(test)] represented_loot_roll_criteria_events: Vec::new(), #[cfg(test)] represented_gameobject_criteria_events: Vec::new(), @@ -8624,10 +9281,11 @@ impl WorldSession { guid: ObjectGuid, f: impl FnOnce(&mut wow_entities::Creature) -> R, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; let creature = managed.map_mut().get_typed_creature_mut(guid)?; Some(f(creature)) } @@ -8637,10 +9295,11 @@ impl WorldSession { guid: ObjectGuid, f: impl FnOnce(&mut wow_entities::GameObject) -> R, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; let gameobject = managed.map_mut().get_typed_game_object_mut(guid)?; Some(f(gameobject)) } @@ -8652,11 +9311,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_game_object() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let gameobject = map.map().get_typed_game_object(guid)?; let linked_trap_guid = gameobject.linked_trap_guid_like_cpp(); (!linked_trap_guid.is_empty()).then_some(linked_trap_guid) @@ -8679,11 +9340,12 @@ impl WorldSession { chest_restock_time_secs: u32, shared_loot_is_changed_like_cpp: bool, ) -> Option { - let map_id = u32::from(self.player_map_id_like_cpp()); + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); let manager = Arc::clone(self.canonical_map_manager.as_ref()?); let mut manager = manager.lock().ok()?; - let managed = manager.find_map_mut(map_id, 0)?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; Some(managed.map_mut().set_gameobject_loot_state_like_cpp( guid, state, @@ -8694,6 +9356,138 @@ impl WorldSession { )) } + /// Applies the global fully-looted transition only if the exact authority + /// generation and pool topology observed by `DoLootRelease` are still + /// current. The canonical map lock is acquired before the authority lock, + /// matching personal-loot upsert order and making check+state mutation one + /// C++-serialized operation. + pub(crate) fn set_canonical_gameobject_loot_state_if_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + state: wow_entities::LootState, + unit_guid: Option, + chest_restock_time_secs: u32, + shared_loot_is_changed_like_cpp: bool, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let object_authority = managed + .map() + .get_typed_game_object(guid)? + .loot_authority_like_cpp() + .clone(); + if !object_authority.shares_storage_like_cpp(authority) { + return None; + } + + authority.with_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + managed.map_mut().set_gameobject_loot_state_like_cpp( + guid, + state, + unit_guid, + game_time_secs, + chest_restock_time_secs, + shared_loot_is_changed_like_cpp, + ) + }, + ) + } + + /// Detached durable-claim completion may transition the object only when + /// no client still has any shared or personal loot pool open. The final + /// viewer check and map mutation are serialized under the authority lock. + pub(crate) fn set_canonical_gameobject_loot_state_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + state: wow_entities::LootState, + unit_guid: Option, + chest_restock_time_secs: u32, + shared_loot_is_changed_like_cpp: bool, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let object_authority = managed + .map() + .get_typed_game_object(guid)? + .loot_authority_like_cpp() + .clone(); + if !object_authority.shares_storage_like_cpp(authority) { + return None; + } + + authority.with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + managed.map_mut().set_gameobject_loot_state_like_cpp( + guid, + state, + unit_guid, + game_time_secs, + chest_restock_time_secs, + shared_loot_is_changed_like_cpp, + ) + }, + ) + } + + /// C++ fishing-hole release performs AddUse, MaxOpens comparison, and + /// SetLootState on one world thread. Keep all three under one map lock so + /// two concurrent personal releases cannot finish in `Ready` after max. + pub(crate) fn release_canonical_fishing_hole_like_cpp( + &mut self, + guid: ObjectGuid, + max_opens: Option, + ) -> Option<( + u32, + wow_entities::LootState, + wow_map::map::GameObjectSetLootStateOutcomeLikeCpp, + )> { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let game_time_secs = i64::try_from(wow_core::GameTime::now().as_secs()).unwrap_or(i64::MAX); + let manager = Arc::clone(self.canonical_map_manager.as_ref()?); + let mut manager = manager.lock().ok()?; + let managed = manager.find_map_mut(map_key.map_id, map_key.instance_id)?; + let map = managed.map_mut(); + let use_count = { + let gameobject = map.get_typed_game_object_mut(guid)?; + gameobject.add_use_like_cpp(); + gameobject.use_times() + }; + let loot_state = if max_opens.is_some_and(|max_opens| use_count >= max_opens) { + wow_entities::LootState::JustDeactivated + } else { + wow_entities::LootState::Ready + }; + let outcome = map.set_gameobject_loot_state_like_cpp( + guid, + loot_state, + None, + game_time_secs, + 0, + false, + ); + Some((use_count, loot_state, outcome)) + } + pub(crate) fn add_use_and_get_canonical_gameobject_use_count_like_cpp( &mut self, guid: ObjectGuid, @@ -10844,7 +11638,11 @@ impl WorldSession { creature }; canonical_creature.clear_data_changes(); - self.insert_canonical_creature_map_object_like_cpp(map_id, canonical_creature.clone()); + if let Some(authority) = + self.insert_canonical_creature_map_object_like_cpp(map_id, canonical_creature.clone()) + { + canonical_creature.rebind_loot_authority_like_cpp(authority); + } if let Some(manager) = &self.map_manager { let (grid_x, grid_y) = crate::map_manager::world_to_grid_coords(position.x, position.y); @@ -10877,28 +11675,32 @@ impl WorldSession { &mut self, map_id: u16, creature: wow_entities::Creature, - ) { + ) -> Option { let Some(manager) = self.canonical_map_manager.as_ref() else { - return; + return None; }; insert_canonical_creature_map_object_on_map_like_cpp( manager, u32::from(map_id), 0, creature, - ); + ) } pub(crate) fn remove_world_creature( &mut self, guid: ObjectGuid, ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let manager = self.map_manager.as_ref().cloned()?; let removed = { let mut manager = manager .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); - manager.remove_creature_any(self.player_map_id_like_cpp(), 0, guid) + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { + creature.creature.clear_loot_like_cpp(); + } + manager.remove_creature_any(map_id, instance_id, guid) }; if removed.is_some() { self.remove_canonical_creature_map_object_like_cpp(guid); @@ -10907,13 +11709,14 @@ impl WorldSession { } fn remove_canonical_creature_map_object_like_cpp(&mut self, guid: ObjectGuid) { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; remove_canonical_creature_map_object_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, guid, ); } @@ -10923,28 +11726,41 @@ impl WorldSession { guid: ObjectGuid, position: wow_core::Position, ) { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; relocate_canonical_creature_map_object_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, guid, position, ); } fn sync_canonical_creature_entity_like_cpp(&mut self, creature: wow_entities::Creature) { + let guid = creature.guid(); + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; - sync_canonical_creature_entity_on_map_like_cpp( + let authority = sync_canonical_creature_entity_on_map_like_cpp( manager, - u32::from(self.player_map_id_like_cpp()), - 0, + u32::from(map_id), + instance_id, creature, ); + if let Some(authority) = authority { + let _ = self.rebind_legacy_creature_loot_authority_like_cpp( + guid, + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } } pub(crate) fn record_represented_gameobject_runtime_state_like_cpp( @@ -11080,11 +11896,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_game_object() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let game_object = map.map().get_typed_game_object(guid)?; Some(RepresentedGameObjectAccessLikeCpp { entry: game_object.world().object().entry(), @@ -11200,11 +12018,13 @@ impl WorldSession { if guid.is_empty() || !guid.is_any_type_creature() { return None; } + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; let manager = self.canonical_map_manager.as_ref()?; let Ok(manager) = manager.lock() else { return None; }; - let map = manager.find_map(u32::from(self.player_map_id_like_cpp()), 0)?; + let map = manager.find_map(map_key.map_id, map_key.instance_id)?; let record = map.map().map_object_record(guid)?; let creature = if guid.is_pet() { record.pet()?.creature() @@ -12735,13 +13555,18 @@ impl WorldSession { let Some(owner_guid) = owner_guid else { return; }; + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return; + }; let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(self.player_map_id_like_cpp()), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if let Some(game_object) = map.map_mut().get_typed_game_object_mut(guid) { @@ -12750,13 +13575,18 @@ impl WorldSession { } fn set_canonical_gameobject_spell_id_like_cpp(&mut self, guid: ObjectGuid, spell_id: u32) { + let Some(map_key) = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())) + else { + return; + }; let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(self.player_map_id_like_cpp()), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if let Some(game_object) = map.map_mut().get_typed_game_object_mut(guid) { @@ -12768,15 +13598,19 @@ impl WorldSession { &self, guid: ObjectGuid, ) -> Option { - let canonical_owner = self - .canonical_map_manager - .as_ref() - .and_then(|manager| manager.lock().ok()) - .and_then(|manager| { - manager - .find_map(u32::from(self.player_map_id_like_cpp()), 0) - .and_then(|map| map.map().get_typed_game_object(guid)) - .map(|game_object| game_object.owner_guid()) + let map_key = + self.canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp())); + let canonical_owner = map_key + .and_then(|map_key| { + self.canonical_map_manager + .as_ref() + .and_then(|manager| manager.lock().ok()) + .and_then(|manager| { + manager + .find_map(map_key.map_id, map_key.instance_id) + .and_then(|map| map.map().get_typed_game_object(guid)) + .map(|game_object| game_object.owner_guid()) + }) }) .filter(|owner_guid| !owner_guid.is_empty()); canonical_owner.or_else(|| { @@ -12828,13 +13662,21 @@ impl WorldSession { .represented_gameobject_use_states .get(&guid) .and_then(|state| state.owner_guid); + let Some(map_key) = self.canonical_object_lookup_map_key_like_cpp(u32::from(map_id)) else { + return; + }; + // A represented object from a stale client/map context must never be + // materialized beside the player in a different map. + if map_key.map_id != u32::from(map_id) { + return; + } let Some(manager) = self.canonical_map_manager.as_ref() else { return; }; let Ok(mut manager) = manager.lock() else { return; }; - let Some(map) = manager.find_map_mut(u32::from(map_id), 0) else { + let Some(map) = manager.find_map_mut(map_key.map_id, map_key.instance_id) else { return; }; if map.map().get_game_object(guid).is_some() { @@ -12855,7 +13697,7 @@ impl WorldSession { } if game_object .world_mut() - .set_map(u32::from(map_id), 0) + .set_map(map_key.map_id, map_key.instance_id) .is_err() { return; @@ -12871,19 +13713,192 @@ impl WorldSession { let _ = map.map_mut().insert_map_object_record(record); } + pub(crate) fn read_legacy_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + self.read_legacy_creature_loot_authority_on_map_like_cpp( + guid, + wow_map::MapKey::new(u32::from(map_id), instance_id), + ) + } + + pub(crate) fn read_legacy_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let map_id = u16::try_from(map_key.map_id).ok()?; + let manager = self.map_manager.as_ref()?; + manager + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature(map_id, map_key.instance_id, guid) + .map(|world_creature| world_creature.creature.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_legacy_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + self.rebind_legacy_creature_loot_authority_on_map_like_cpp( + guid, + wow_map::MapKey::new(u32::from(map_id), instance_id), + expected, + expected_stamp, + authority, + ) + } + + pub(crate) fn rebind_legacy_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_id = u16::try_from(map_key.map_id).ok()?; + let manager = self.map_manager.as_ref()?; + manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .find_creature_mut(map_id, map_key.instance_id, guid) + .and_then(|world_creature| { + world_creature + .creature + .rebind_loot_authority_if_current_like_cpp(expected, expected_stamp, authority) + }) + } + + pub(crate) fn read_canonical_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.read_canonical_creature_loot_authority_on_map_like_cpp(guid, map_key) + } + + pub(crate) fn read_canonical_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + manager + .find_map(map_key.map_id, map_key.instance_id)? + .map() + .get_typed_creature(guid) + .map(|creature| creature.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_canonical_creature_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.rebind_canonical_creature_loot_authority_on_map_like_cpp( + guid, + map_key, + expected, + expected_stamp, + authority, + ) + } + + pub(crate) fn rebind_canonical_creature_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + manager + .find_map_mut(map_key.map_id, map_key.instance_id)? + .map_mut() + .get_typed_creature_mut(guid) + .and_then(|creature| { + creature.rebind_loot_authority_if_current_like_cpp( + expected, + expected_stamp, + authority, + ) + }) + } + + pub(crate) fn read_canonical_gameobject_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + self.read_canonical_gameobject_loot_authority_on_map_like_cpp(guid, map_key) + } + + pub(crate) fn read_canonical_gameobject_loot_authority_on_map_like_cpp( + &self, + guid: ObjectGuid, + map_key: wow_map::MapKey, + ) -> Option { + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + manager + .find_map(map_key.map_id, map_key.instance_id)? + .map() + .get_typed_game_object(guid) + .map(|gameobject| gameobject.loot_authority_like_cpp().clone()) + } + + pub(crate) fn rebind_canonical_gameobject_loot_authority_like_cpp( + &self, + guid: ObjectGuid, + expected: &OwnedLootAuthority, + expected_stamp: OwnedLootAuthorityStamp, + authority: OwnedLootAuthority, + ) -> Option { + let map_key = self + .canonical_object_lookup_map_key_like_cpp(u32::from(self.player_map_id_like_cpp()))?; + let manager = self.canonical_map_manager.as_ref()?; + let mut manager = manager.lock().ok()?; + manager + .find_map_mut(map_key.map_id, map_key.instance_id)? + .map_mut() + .get_typed_game_object_mut(guid) + .and_then(|gameobject| { + gameobject.rebind_loot_authority_if_current_like_cpp( + expected, + expected_stamp, + authority, + ) + }) + } + pub(crate) fn mutate_world_creature(&mut self, guid: ObjectGuid, f: F) -> Option where F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let mut f = Some(f); if let Some(manager) = self.map_manager.as_ref().cloned() { let result = { let mut manager = manager .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(creature) = - manager.find_creature_mut(self.player_map_id_like_cpp(), 0, guid) - { + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { let result = f.take().expect("creature mutator is called once")(creature); Some((result, creature.position(), creature.creature.clone())) } else { @@ -12900,6 +13915,89 @@ impl WorldSession { None } + pub(crate) fn mutate_world_creature_if_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + f: F, + ) -> Option + where + F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, + { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + let manager = self.map_manager.as_ref().cloned()?; + let guarded_result = { + let mut manager = manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let creature = manager.find_creature_mut(map_id, instance_id, guid)?; + if !creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(authority) + { + return None; + } + authority.with_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + let result = f(creature); + (result, creature.position(), creature.creature.clone()) + }, + ) + }?; + let (result, position, creature) = guarded_result; + self.relocate_canonical_creature_map_object_like_cpp(guid, position); + self.sync_canonical_creature_entity_like_cpp(creature); + Some(result) + } + + /// Detached durable-claim completion variant of the guarded creature + /// mutation. It additionally requires every authoritative loot viewer set + /// to remain empty through the map mutation. + pub(crate) fn mutate_world_creature_if_unviewed_fully_looted_observation_like_cpp( + &mut self, + guid: ObjectGuid, + authority: &OwnedLootAuthority, + object_generation: u64, + lifecycle_revision: u64, + f: F, + ) -> Option + where + F: FnOnce(&mut crate::map_manager::WorldCreature) -> R, + { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + let manager = self.map_manager.as_ref().cloned()?; + let guarded_result = { + let mut manager = manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let creature = manager.find_creature_mut(map_id, instance_id, guid)?; + if !creature + .creature + .loot_authority_like_cpp() + .shares_storage_like_cpp(authority) + { + return None; + } + authority.with_unviewed_fully_looted_lifecycle_observation_like_cpp( + object_generation, + lifecycle_revision, + || { + let result = f(creature); + (result, creature.position(), creature.creature.clone()) + }, + ) + }?; + let (result, position, creature) = guarded_result; + self.relocate_canonical_creature_map_object_like_cpp(guid, position); + self.sync_canonical_creature_entity_like_cpp(creature); + Some(result) + } + pub(crate) fn pause_interacted_creature_movement_like_cpp(&mut self, guid: ObjectGuid) -> bool { self.mutate_world_creature(guid, |creature| { creature.pause_interaction_movement_like_cpp() @@ -12908,11 +14006,12 @@ impl WorldSession { } pub(crate) fn world_creature_guids(&self) -> Vec { + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); if let Some(manager) = &self.map_manager { return manager .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .creature_guids(self.player_map_id_like_cpp(), 0); + .creature_guids(map_id, instance_id); } Vec::new() @@ -12925,12 +14024,13 @@ impl WorldSession { let Some(manager) = &self.map_manager else { return Vec::new(); }; + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); manager .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .active_creature_guids_for_player_update_like_cpp( - self.player_map_id_like_cpp(), - 0, + map_id, + instance_id, player_position, self.represented_player_phase_shift_like_cpp(), ) @@ -12942,9 +14042,9 @@ impl WorldSession { /// Push a `PendingRespawn` into the shared map's respawn queue. /// - /// instance_id=0: legacy path limitation — consistent with `register_world_creature`, - /// `remove_world_creature`, and `mutate_world_creature` which all hardcode 0. - /// The canonical respawn store (wow_map `RespawnStoreLikeCpp`) is a separate step. + /// The registration bootstrap still defaults to instance `0`; live removal/mutation + /// follows the canonical Player instance. The canonical respawn store + /// (`wow_map::RespawnStoreLikeCpp`) is a separate step. /// Lock is acquired, respawn is pushed, then lock is released before returning. /// No `.await` is performed under lock. pub(crate) fn push_map_respawn_like_cpp( @@ -12997,6 +14097,7 @@ impl WorldSession { let mut seen = std::collections::HashSet::new(); if let Some(manager) = &self.map_manager { + let (_, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); let visibility_range = self.player_map_visibility_range_like_cpp(map_id); creatures.extend( manager @@ -13004,7 +14105,7 @@ impl WorldSession { .unwrap_or_else(|poisoned| poisoned.into_inner()) .get_visible_creatures_in_phase( map_id, - 0, + instance_id, position.x, position.y, position.z, @@ -13717,6 +14818,17 @@ impl WorldSession { self.guid_generator = Some(generator); } + /// Install the process-wide C++ + /// `sObjectMgr->GetGenerator()` mirror. + pub fn set_item_guid_generator_like_cpp(&mut self, generator: Arc) { + assert_eq!( + generator.high_guid(), + HighGuid::Item, + "item GUID allocator must use HighGuid::Item" + ); + self.item_guid_generator_like_cpp = Some(generator); + } + /// Set the login database for this session. pub fn set_login_db(&mut self, db: Arc) { self.login_db = Some(db); @@ -18677,7 +19789,6 @@ impl WorldSession { return false; }; - let top_level_slot = top_level_inventory_item.as_ref().map(|(slot, _)| *slot); let top_level_inventory_item = top_level_inventory_item.map(|(_, item)| item); let item_entry_id = top_level_inventory_item .as_ref() @@ -18689,9 +19800,6 @@ impl WorldSession { .unwrap_or_else(|| item_guid.counter() as u64); let current_durability = item_object.data().durability; let max_durability = item_object.data().max_durability; - let was_broken = item_object.is_broken(); - let equipped_slot = top_level_slot - .filter(|slot| is_equipment_packed_pos(make_item_pos(INVENTORY_SLOT_BAG_0, *slot))); let cost = self.item_durability_repair_cost_like_cpp( item_entry_id, current_durability, @@ -18700,18 +19808,64 @@ impl WorldSession { repair_cost_rate, ); - if take_cost { + if take_cost && cost != 0 { + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return false; + }; let old_money = self.player_gold_like_cpp(); if old_money < cost { return false; } - if cost != 0 { - self.apply_player_money_change_like_cpp(old_money, old_money - cost) - .await; - } - } + let new_money = old_money - cost; - if let Some(char_db) = self.char_db.as_ref() { + let money_persistence = if let Some(char_db) = self.char_db.as_ref().map(Arc::clone) { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, + player_guid.counter() as u64, + )); + let mut durability = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); + durability.set_u32(0, max_durability); + durability.set_u64(1, item_db_guid); + transaction.append(durability); + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "single item durability repair", + ) + .await + else { + return false; + }; + money_persistence + } else { + // Unit fixtures do not install a CharacterDatabase. A live + // session always has one; without it no detached SQL payout + // can originate from this session either. + money_persistence + }; + + // The committed transaction repaired the item and charged money. + // Publish both runtime fields before the guard opens; otherwise a + // cancelled criteria drain can leave durable durability repaired + // while the live item remains broken. + self.stage_player_money_change_like_cpp(old_money, new_money); + let repaired = self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid); + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return repaired; + } else if let Some(char_db) = self.char_db.as_ref() { let mut stmt = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); stmt.set_u32(0, max_durability); stmt.set_u64(1, item_db_guid); @@ -18725,6 +19879,24 @@ impl WorldSession { } } + self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid) + } + + fn apply_inventory_item_durability_repair_runtime_like_cpp( + &mut self, + item_guid: ObjectGuid, + ) -> bool { + let top_level_slot = self + .inventory_items_like_cpp() + .iter() + .find_map(|(&slot, item)| (item.guid == item_guid).then_some(slot)); + let Some(item_object) = self.inventory_item_objects_like_cpp().get(&item_guid) else { + return false; + }; + let max_durability = item_object.data().max_durability; + let was_broken = item_object.is_broken(); + let equipped_slot = top_level_slot + .filter(|slot| is_equipment_packed_pos(make_item_pos(INVENTORY_SLOT_BAG_0, *slot))); let updated = self.update_inventory_item_object_like_cpp(item_guid, |item| { item.set_durability(max_durability); }); @@ -18767,27 +19939,86 @@ impl WorldSession { ) -> bool { let repair_items = self.repairable_inventory_item_costs_like_cpp(discount, repair_cost_rate); + if repair_items.is_empty() { + return true; + } let total_cost = repair_items .iter() .fold(0u64, |total, (_, cost)| total.saturating_add(*cost)); - - if self.player_gold_like_cpp() < total_cost { + let planned_repairs = repair_items + .iter() + .filter_map(|(item_guid, _)| { + let item = self.inventory_item_objects_like_cpp().get(item_guid)?; + let db_guid = self + .inventory_items_like_cpp() + .values() + .find(|inventory_item| inventory_item.guid == *item_guid) + .map(|inventory_item| inventory_item.db_guid) + .unwrap_or_else(|| item_guid.counter() as u64); + Some((*item_guid, db_guid, item.data().max_durability)) + }) + .collect::>(); + if planned_repairs.len() != repair_items.len() { return false; } - if total_cost != 0 { - let old_money = self.player_gold_like_cpp(); - self.apply_player_money_change_like_cpp(old_money, old_money - total_cost) - .await; + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return false; + }; + let old_money = self.player_gold_like_cpp(); + if old_money < total_cost { + return false; } + let new_money = old_money - total_cost; + + let money_persistence = if let Some(char_db) = self.char_db.as_ref().map(Arc::clone) { + let Some(player_guid) = self.player_guid() else { + return false; + }; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, + player_guid.counter() as u64, + )); + for &(_, db_guid, max_durability) in &planned_repairs { + let mut durability = char_db.prepare(CharStatements::UPD_ITEM_INSTANCE_DURABILITY); + durability.set_u32(0, max_durability); + durability.set_u64(1, db_guid); + transaction.append(durability); + } + let Some(money_persistence) = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "all-items durability repair", + ) + .await + else { + return false; + }; + money_persistence + } else { + money_persistence + }; + // Money and every durability row share one COMMIT. Mirror all of those + // rows in runtime while admission is still fenced and before the first + // cancellation point. + self.stage_player_money_change_like_cpp(old_money, new_money); let mut repaired_any = false; - for (item_guid, _) in repair_items { - repaired_any |= self - .repair_inventory_item_durability_like_cpp(item_guid, false, 0.0, repair_cost_rate) - .await; + for (item_guid, _, _) in planned_repairs { + repaired_any |= self.apply_inventory_item_durability_repair_runtime_like_cpp(item_guid); } - repaired_any || total_cost == 0 + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + repaired_any } /// C++ `Player::DurabilityRepairAll(takeCost=true, guildBank=true)` for represented items. @@ -20078,9 +21309,23 @@ impl WorldSession { pub(crate) fn set_active_loot_guid(&mut self, guid: ObjectGuid) { self.active_loot_guid = ObjectGuid::EMPTY; self.active_loot_view_owners.clear(); + self.active_loot_view_generations_like_cpp.clear(); + self.active_loot_view_authorities_like_cpp.clear(); self.add_active_loot_view_owner_like_cpp(guid); } + pub(crate) fn has_active_loot_views_like_cpp(&self) -> bool { + !self.active_loot_guid.is_empty() || !self.active_loot_view_owners.is_empty() + } + + pub(crate) fn has_active_non_item_loot_views_like_cpp(&self) -> bool { + (!self.active_loot_guid.is_empty() && !self.active_loot_guid.is_item()) + || self + .active_loot_view_owners + .iter() + .any(|guid| !guid.is_item()) + } + pub(crate) fn add_active_loot_view_owner_like_cpp(&mut self, guid: ObjectGuid) { if guid.is_empty() { return; @@ -20091,10 +21336,20 @@ impl WorldSession { } self.active_loot_view_owners.insert(guid); + if let Some(generation) = self + .represented_loot_cache_generations_like_cpp + .get(&guid) + .copied() + { + self.active_loot_view_generations_like_cpp + .insert(guid, generation); + } } pub(crate) fn clear_active_loot_guid_if(&mut self, guid: ObjectGuid) { self.active_loot_view_owners.remove(&guid); + self.active_loot_view_generations_like_cpp.remove(&guid); + self.active_loot_view_authorities_like_cpp.remove(&guid); if self.active_loot_guid == guid { self.active_loot_guid = ObjectGuid::EMPTY; } @@ -23330,8 +24585,228 @@ impl WorldSession { self.quest_faction_reward_store = Some(store); } + async fn reconcile_durable_loot_money_before_save_like_cpp(&mut self) -> bool { + let tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + tracker.wait_until_idle_like_cpp().await; + let completions = tracker.pending_completions_like_cpp(); + for completion in completions { + if completion + .applied + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + let old_money = self.player_gold_like_cpp(); + let new_money = old_money + .checked_add(completion.durable_applied_amount) + .filter(|money| *money <= MAX_MONEY_AMOUNT) + .unwrap_or(old_money); + self.set_player_gold_like_cpp(new_money); + if old_money != new_money { + self.enqueue_represented_quest_objective_progress_like_cpp( + RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { + old_money, + new_money, + }, + ); + } + } + + // Do not drain money criteria while the save fence is held. That path + // can reward a quest and re-enter `save_player_gold`, which would wait + // on this same fence. Queue the exact transition here; normal command + // publication or the save caller drains it only after releasing the + // fence. This keeps a save-first completion from losing MoneyChanged. + + if tracker.is_indeterminate_like_cpp() { + self.kick("loot-money COMMIT outcome is unknown; skipping absolute money save"); + return false; + } + true + } + + /// Close detached-payout admission, wait for every previously admitted + /// worker, apply its exact-once durable deltas, then acquire the character + /// money mutation lock. Callers must derive old/new runtime values only + /// after this returns and retain the guard through their DB COMMIT. + pub(crate) async fn begin_exclusive_player_money_persistence_like_cpp( + &mut self, + ) -> Option { + let tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + let save_fence = tracker.close_admission_for_save_like_cpp(); + tracker.wait_until_idle_like_cpp().await; + if !self + .reconcile_durable_loot_money_before_save_like_cpp() + .await + { + return None; + } + let mutation_lock = tracker.lock_money_mutation_like_cpp().await; + Some(ExclusivePlayerMoneyPersistenceLikeCpp { + _save_fence: save_fence, + _mutation_lock: mutation_lock, + }) + } + + /// Derive one runtime money change only after the shared payout barrier, + /// persist it while admission and the mutation mutex remain held, then + /// publish the runtime value. Criteria must be queued/drained by the caller + /// after this returns so reward callbacks cannot re-enter under the fence. + pub(crate) async fn mutate_and_persist_player_gold_exclusive_like_cpp( + &mut self, + mutation: F, + ) -> Option<(u64, u64)> + where + F: FnOnce(u64) -> u64, + { + let money_persistence = self + .begin_exclusive_player_money_persistence_like_cpp() + .await?; + let guid = self.player_guid()?.counter() as u64; + let old_money = self.player_gold_like_cpp(); + let new_money = mutation(old_money); + + #[cfg(test)] + if let Some(success) = self.loot_money_persistence_test_result_like_cpp { + if !success { + return None; + } + self.set_player_gold_like_cpp(new_money); + drop(money_persistence); + return Some((old_money, new_money)); + } + + if old_money == new_money { + drop(money_persistence); + return Some((old_money, new_money)); + } + + let char_db = self.char_db().map(Arc::clone)?; + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + new_money, guid, + )); + let money_persistence = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "exclusive player-money mutation", + ) + .await?; + self.set_player_gold_like_cpp(new_money); + drop(money_persistence); + Some((old_money, new_money)) + } + + /// Commit a transaction whose money change can act as its reconciliation + /// marker. On a definite rollback, dropping the returned `None` reopens + /// normal payout admission. On an ambiguous COMMIT, the current durable + /// money row is compared with the exact pre/post values while the shared + /// per-character mutation lock remains held. + /// + /// Callers must publish every runtime mutation covered by `transaction` + /// before dropping the returned guard. If `money_before == money_after`, + /// an ambiguous COMMIT is necessarily quarantined because an unchanged + /// money row cannot prove whether other statements committed. + pub(crate) async fn commit_exclusive_player_money_transaction_like_cpp( + &mut self, + money_persistence: ExclusivePlayerMoneyPersistenceLikeCpp, + char_db: &CharacterDatabase, + transaction: SqlTransaction, + money_before: u64, + money_after: u64, + operation: &'static str, + ) -> Option { + let mut cancellation_fence = PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone( + &self.durable_loot_money_persistence_like_cpp, + )); + let commit_result = transaction + .commit_with_outcome_like_cpp(char_db.pool()) + .await; + match commit_result { + Ok(()) => { + cancellation_fence.disarm_like_cpp(); + Some(money_persistence) + } + Err(SqlTransactionCommitError::DefinitelyRolledBack(error)) => { + cancellation_fence.disarm_like_cpp(); + warn!(%error, operation, "player-money transaction definitely rolled back"); + None + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(error)) => { + let observed_money = match self.player_guid() { + Some(player_guid) => { + sqlx::query_scalar::<_, u64>("SELECT money FROM characters WHERE guid = ?") + .bind(player_guid.counter() as u64) + .fetch_optional(char_db.pool()) + .await + .ok() + .flatten() + } + None => None, + }; + + match reconcile_absolute_player_money_commit_like_cpp( + money_before, + money_after, + observed_money, + ) { + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed => { + cancellation_fence.disarm_like_cpp(); + warn!( + %error, + operation, + money_before, + money_after, + "player-money COMMIT reply was lost but durable money proves the transaction committed" + ); + Some(money_persistence) + } + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack => { + cancellation_fence.disarm_like_cpp(); + warn!( + %error, + operation, + money_before, + money_after, + "player-money COMMIT reply was lost but durable money proves the transaction rolled back" + ); + None + } + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate => { + self.durable_loot_money_persistence_like_cpp + .mark_indeterminate_like_cpp(); + cancellation_fence.disarm_like_cpp(); + self.kick( + "player-money COMMIT outcome is unknown; relog required before another money mutation", + ); + warn!( + %error, + operation, + money_before, + money_after, + ?observed_money, + "player-money COMMIT outcome remains indeterminate; quarantined the session" + ); + None + } + } + } + } + } + /// Save current player gold to the characters DB. - pub(crate) async fn save_player_gold(&self) { + pub(crate) async fn save_player_gold(&mut self) { + let Some(money_persistence) = self + .begin_exclusive_player_money_persistence_like_cpp() + .await + else { + return; + }; let guid = match self.player_guid() { Some(g) => g.counter() as u64, None => return, @@ -23340,9 +24815,446 @@ impl WorldSession { Some(db) => Arc::clone(db), None => return, }; - let stmt = - Self::build_character_gold_save_statement_like_cpp(self.player_gold_like_cpp(), guid); - let _ = char_db.execute(&stmt).await; + let money = self.player_gold_like_cpp(); + let mut transaction = SqlTransaction::new(); + transaction.append(Self::build_character_gold_save_statement_like_cpp( + money, guid, + )); + // For an absolute one-column save the pre-transaction durable value is + // unknown. Pass equal sentinels so any lost COMMIT reply is quarantined + // instead of guessing from a coincidentally equal row. + let money_persistence = self + .commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + money, + money, + "absolute player-money save", + ) + .await; + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + /// Persist an explicit player-money value and surface database failures to + /// callers that must not expose a loot payout before it is durable. + /// + /// Unlike [`Self::save_player_gold`], this helper fails closed when there is + /// no selected player or character database. Focused tests must opt into an + /// explicit persistence result through the test seam below. + pub(crate) async fn persist_player_gold_checked_like_cpp( + &self, + money: u64, + ) -> Result<(), LootMoneyPersistenceErrorLikeCpp> { + #[cfg(test)] + if let Some(success) = self.loot_money_persistence_test_result_like_cpp { + return success + .then_some(()) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + + let guid = self + .player_guid() + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingPlayer)?; + let char_db = self + .char_db() + .map(Arc::clone) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase)?; + let stmt = Self::build_character_gold_save_statement_like_cpp(money, guid.counter() as u64); + char_db + .execute(&stmt) + .await + .map(|_| ()) + .map_err(LootMoneyPersistenceErrorLikeCpp::Database) + } + + /// Start the complete durable half of one shared money claim in a detached + /// task. The task owns the lease across `COMMIT`, commits the authority in + /// the same task immediately after SQL success, then schedules the + /// already-durable session-local applications. + /// + /// Dropping or aborting the packet-handler future only drops its + /// `JoinHandle`; Tokio keeps this worker alive. This closes the duplicate + /// window where SQL could commit after the handler was cancelled while the + /// lease's `Drop` reopened the object pool. + /// + /// Boundary: this is an in-process guarantee, not a durable claim journal. + /// Aborting this detached task (including runtime/process shutdown) at the + /// database commit await boundary can still lose the continuation between + /// durable SQL and the synchronous authority commit. Recovery across that + /// boundary requires persisting claim identity in the same transaction and + /// replaying it at startup; #106 does not yet provide such a journal. + /// Delivery tasks may likewise outlive a target session, but they carry + /// only runtime publication: durable player money reloads from SQL and the + /// already-committed authority prevents a second in-process payout. + pub(crate) fn spawn_group_loot_money_persistence_like_cpp( + &self, + mut payouts: Vec<(ObjectGuid, u64)>, + claim: LootClaimLease, + mut deliveries: Vec<(flume::Sender, SessionCommand)>, + authority_committed: Arc, + viewer_fanout: LootMoneyViewerFanoutLikeCpp, + ) -> Result< + tokio::task::JoinHandle>, + LootMoneyPersistenceErrorLikeCpp, + > { + if payouts.is_empty() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + if self.player_guid().is_none() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + + #[cfg(test)] + let test_result = self.loot_money_persistence_test_result_like_cpp; + #[cfg(not(test))] + let test_result: Option = None; + + payouts + .sort_unstable_by_key(|(recipient, _)| (recipient.high_value(), recipient.low_value())); + payouts.dedup_by_key(|(recipient, _)| *recipient); + + // Register every recipient directly before any SQL begins. Commands + // are publication-only; waiting for target acknowledgements here would + // create self/A↔B deadlocks between concurrent group looters. + let payout_recipients = payouts + .iter() + .map(|(recipient, _)| *recipient) + .collect::>(); + let mut money_persistence_guards = HashMap::< + ObjectGuid, + DurableLootMoneyPersistenceGuardLikeCpp, + >::with_capacity(payouts.len()); + let mut money_mutation_trackers = HashMap::< + ObjectGuid, + Arc, + >::with_capacity(payouts.len()); + for (_, command) in &deliveries { + let SessionCommand::ApplyLootMoneyLikeCpp(command) = command else { + continue; + }; + if payout_recipients.contains(&command.recipient) + && !money_persistence_guards.contains_key(&command.recipient) + { + let guard = command + .durable_persistence_tracker + .begin_like_cpp() + .map_err(|_| LootMoneyPersistenceErrorLikeCpp::MissingPlayer)?; + money_persistence_guards.insert(command.recipient, guard); + money_mutation_trackers.insert( + command.recipient, + Arc::clone(&command.durable_persistence_tracker), + ); + } + } + if money_persistence_guards.len() != payouts.len() { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingPlayer); + } + + let char_db = if test_result.is_some() { + None + } else { + Some( + self.char_db() + .map(Arc::clone) + .ok_or(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase)?, + ) + }; + + let mut persistence_guard = claim + .begin_persistence_guard_like_cpp() + .map_err(LootMoneyPersistenceErrorLikeCpp::Claim)?; + drop(claim); + + Ok(tokio::spawn(async move { + // Match the sorted character-row lock order below. Stored-item + // money uses the same per-character lock before it takes the row, + // so COMMIT reconciliation cannot be confused by a later local + // payout interleaving between the failed reply and our reads. + let mut _money_mutation_locks = Vec::with_capacity(payouts.len()); + for (recipient, _) in &payouts { + _money_mutation_locks.push( + money_mutation_trackers + .get(recipient) + .expect("every payout retained its target mutation lock") + .lock_money_mutation_like_cpp() + .await, + ); + } + + let durable_outcomes = if let Some(success) = test_result { + // Give cancellation regressions a deterministic opportunity to + // drop the outer waiter while this detached task owns `claim`. + tokio::task::yield_now().await; + if !success { + return Err(LootMoneyPersistenceErrorLikeCpp::MissingCharacterDatabase); + } + payouts + .iter() + .map(|(recipient, amount)| { + ( + *recipient, + DurableLootMoneyDbOutcomeLikeCpp { + before: 0, + after: *amount, + applied_delta: *amount, + }, + ) + }) + .collect::>() + } else { + let char_db = + char_db.expect("production loot-money worker must own a character database"); + let attempt = retry_deadlocked_operation_like_cpp( + || attempt_group_loot_money_transaction_like_cpp(char_db.as_ref(), &payouts), + group_loot_money_attempt_is_deadlock_like_cpp, + ) + .await; + let durable_outcomes = match attempt { + Ok(outcomes) => outcomes, + Err(GroupLootMoneyAttemptErrorLikeCpp::DefinitelyRolledBack(error)) => { + return Err(error); + } + Err(GroupLootMoneyAttemptErrorLikeCpp::CommitOutcomeUnknown { + error: commit_error, + outcomes: durable_outcomes, + }) => { + // A transport failure at COMMIT is ambiguous. While every + // recipient fence remains registered, compare only rows + // whose value changed. A cap-only no-op has no durable + // mutation to distinguish: either COMMIT outcome permits + // the same safe authority consumption with zero delta. + let changed = durable_outcomes + .iter() + .filter(|(_, outcome)| outcome.before != outcome.after) + .collect::>(); + let cap_only_noop = changed.is_empty(); + let mut all_before = !cap_only_noop; + let mut all_after = !cap_only_noop; + let mut reconciliation_failed = false; + for (recipient, outcome) in changed { + match sqlx::query_scalar::<_, u64>( + "SELECT money FROM characters WHERE guid = ?", + ) + .bind(recipient.counter() as u64) + .fetch_optional(char_db.pool()) + .await + { + Ok(Some(current)) => { + all_before &= current == outcome.before; + all_after &= current == outcome.after; + } + Ok(None) | Err(_) => { + reconciliation_failed = true; + break; + } + } + } + + if all_before && !all_after && !reconciliation_failed { + return Err(LootMoneyPersistenceErrorLikeCpp::Database( + DatabaseError::Transaction( + "loot-money COMMIT was reconciled as rolled back".to_string(), + ), + )); + } + if !cap_only_noop && (!all_after || all_before || reconciliation_failed) { + for guard in money_persistence_guards.values_mut() { + guard.mark_indeterminate_like_cpp(); + } + let _ = persistence_guard.quarantine_commit_unknown_like_cpp(); + for (command_tx, _) in &deliveries { + let kick = SessionCommand::KickLikeCpp(KickLikeCppCommand { + reason: "loot-money COMMIT outcome is unknown; relog required" + .to_string(), + }); + if let Err(error) = command_tx.try_send(kick) { + let command_tx = command_tx.clone(); + let kick = error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(kick).await; + }); + } + } + return Err(LootMoneyPersistenceErrorLikeCpp::CommitOutcomeUnknown( + commit_error, + )); + } + durable_outcomes + } + }; + durable_outcomes + }; + + for (_, command) in &mut deliveries { + if let SessionCommand::ApplyLootMoneyLikeCpp(command) = command { + let outcome = durable_outcomes + .get(&command.recipient) + .copied() + .expect("every admitted payout retains its locked DB outcome"); + command + .durable_applied_amount + .store(outcome.applied_delta, Ordering::Release); + money_persistence_guards + .get_mut(&command.recipient) + .expect("every payout registered its target money fence") + .commit_like_cpp(DurableLootMoneyCompletionLikeCpp { + durable_money_before: outcome.before, + durable_money_after: outcome.after, + durable_applied_amount: outcome.applied_delta, + applied: Arc::clone(&command.applied), + }); + } + } + + let committed_snapshot = match persistence_guard.commit_with_snapshot_like_cpp() { + Ok((_, committed_snapshot)) => { + authority_committed.store(true, Ordering::Release); + committed_snapshot + } + Err(error) => { + // SQL is already durable. Never let a lifecycle race reopen + // this old allocation; payout publication still proceeds, + // but replacement loot is not touched. + warn!(?error, "durable loot-money authority commit failed closed"); + let _ = persistence_guard.quarantine_commit_unknown_like_cpp(); + None + } + }; + + // `Loot::PlayersLooting` can grow while the detached SQL task is + // running. The snapshot above was captured by the commit while + // holding the same authority mutex: every included opener saw + // non-zero money, and every later opener sees zero directly. + let mut viewers = committed_snapshot + .filter(|snapshot| { + snapshot.generation == viewer_fanout.authority_generation + && snapshot.loot.loot_guid == viewer_fanout.loot_obj + }) + .map(|snapshot| { + snapshot + .loot + .players_looting + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + + for (_, command) in &mut deliveries { + if let SessionCommand::ApplyLootMoneyLikeCpp(command) = command { + command + .send_coin_removed + .store(viewers.remove(&command.recipient), Ordering::Release); + } + } + + for viewer in viewers { + if viewer_fanout.payout_recipients.contains(&viewer) { + continue; + } + let command_tx = if viewer == viewer_fanout.source_player { + Some(viewer_fanout.source_command_tx.clone()) + } else { + viewer_fanout.player_registry.as_ref().and_then(|registry| { + let target = registry.get(&viewer)?; + (target.is_in_world + && target.map_id == viewer_fanout.map_id + && target.instance_id == viewer_fanout.instance_id) + .then(|| target.command_tx.clone()) + }) + }; + let Some(command_tx) = command_tx else { + continue; + }; + deliveries.push(( + command_tx, + SessionCommand::NotifyLootMoneyRemovedLikeCpp( + NotifyLootMoneyRemovedLikeCppCommand { + recipient: viewer, + loot_owner: viewer_fanout.loot_owner, + loot_obj: viewer_fanout.loot_obj, + authority: viewer_fanout.authority.clone(), + authority_generation: viewer_fanout.authority_generation, + authority_committed: Arc::clone(&authority_committed), + }, + ), + )); + } + + // Do not make persistence wait for another session's bounded + // command queue. Each delivery task owns its command until the + // target drains capacity or disconnects. + for (command_tx, command) in deliveries { + if let Err(error) = command_tx.try_send(command) { + let command = error.into_inner(); + tokio::spawn(async move { + let _ = command_tx.send_async(command).await; + }); + } + } + Ok(()) + })) + } + + #[cfg(test)] + pub(crate) fn set_loot_money_persistence_test_result_like_cpp(&mut self, success: bool) { + self.loot_money_persistence_test_result_like_cpp = Some(success); + } + + pub(crate) fn loot_money_persistence_test_result_for_worker_like_cpp(&self) -> Option { + #[cfg(test)] + { + self.loot_money_persistence_test_result_like_cpp + } + #[cfg(not(test))] + { + None + } + } + + pub(crate) fn begin_durable_item_loot_persistence_like_cpp( + &self, + ) -> DurableItemLootPersistenceGuardLikeCpp { + self.durable_item_loot_persistence_like_cpp.begin_like_cpp() + } + + pub(crate) async fn wait_for_durable_item_loot_persistence_like_cpp(&self) { + self.durable_item_loot_persistence_like_cpp + .wait_until_idle_like_cpp() + .await; + } + + pub(crate) fn durable_loot_money_persistence_tracker_like_cpp( + &self, + ) -> Arc { + Arc::clone(&self.durable_loot_money_persistence_like_cpp) + } + + pub(crate) fn take_durable_item_loot_completions_like_cpp( + &self, + ) -> Vec { + self.durable_item_loot_persistence_like_cpp + .take_completions_like_cpp() + } + + #[cfg(test)] + pub(crate) fn set_loot_item_store_test_seam_like_cpp( + &mut self, + grants: Arc, + success: bool, + ) { + self.loot_item_store_test_grants_like_cpp = Some(grants); + self.loot_item_store_test_success_like_cpp = success; + } + + #[cfg(test)] + pub(crate) fn set_loot_item_store_test_commit_gate_like_cpp( + &mut self, + gate: Arc, + ) { + self.loot_item_store_test_commit_gate_like_cpp = Some(gate); } fn build_character_gold_save_statement_like_cpp( @@ -24390,7 +26302,18 @@ impl WorldSession { &mut self, snapshot: &PlayerSaveToDbSnapshotLikeCpp, now_unix_secs: i64, - ) -> PlayerSaveToDbStatementPlanLikeCpp { + ) -> Option { + // A cancelled/ambiguous transaction can contain money plus other + // absolute replacements (for example a talent reset). Building a + // partial full-save plan from the pre-COMMIT runtime snapshot would + // overwrite those non-money rows even if the earlier COMMIT succeeded. + if self + .durable_loot_money_persistence_like_cpp + .is_indeterminate_like_cpp() + { + return None; + } + let guid_counter = snapshot.guid.counter() as u64; let mut plan = PlayerSaveToDbStatementPlanLikeCpp::default(); @@ -24578,7 +26501,7 @@ impl WorldSession { ); } - plan + Some(plan) } fn mark_current_player_save_to_db_committed_like_cpp( @@ -24605,6 +26528,35 @@ impl WorldSession { // autosave callers before it appends statements. self.reset_player_save_timer_like_cpp(); + let money_tracker = Arc::clone(&self.durable_loot_money_persistence_like_cpp); + let money_save_fence = money_tracker.close_admission_for_save_like_cpp(); + self.wait_for_durable_item_loot_persistence_like_cpp().await; + self.apply_pending_durable_item_loot_completions_with_objective_drain_like_cpp(false) + .await; + let money_state_is_determinate = self + .reconcile_durable_loot_money_before_save_like_cpp() + .await; + if !money_state_is_determinate { + // The same unknown transaction may also have committed talents, + // reset metadata, inventory, or other absolute state. Do not let a + // disconnect/autosave restore any pre-COMMIT runtime snapshot. + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + } + let money_mutation_lock = money_tracker.lock_money_mutation_like_cpp().await; + if money_tracker.is_indeterminate_like_cpp() { + self.kick( + "player persistence became indeterminate while waiting for the full-save money lock; aborting the entire save", + ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + } + let Some(snapshot) = self.sync_session_from_save_to_db_snapshot_like_cpp() else { warn!( account = self.account_id, @@ -24613,6 +26565,10 @@ impl WorldSession { has_canonical_map_manager = self.canonical_map_manager.is_some(), "Skipping Player::SaveToDB represented save because no coherent player snapshot is available" ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; return; }; let Some(char_db) = self.char_db().map(Arc::clone) else { @@ -24621,19 +26577,37 @@ impl WorldSession { player_guid = ?self.player_guid(), "Skipping Player::SaveToDB represented save because character database is unavailable" ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; return; }; - let mut plan = - self.current_player_save_to_db_statement_plan_like_cpp(&snapshot, unix_now()); + let Some(mut plan) = + self.current_player_save_to_db_statement_plan_like_cpp(&snapshot, unix_now()) + else { + self.kick( + "player persistence became indeterminate before the full-save statement snapshot; aborting the entire save", + ); + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + return; + }; let statement_count = plan.statements.len(); let mut tx = SqlTransaction::new(); for statement in std::mem::take(&mut plan.statements) { tx.append(statement); } - match char_db.commit_transaction(tx).await { + let mut cancellation_fence = + PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&money_tracker)); + let commit_result = tx.commit_with_outcome_like_cpp(char_db.pool()).await; + match commit_result { Ok(()) => { + cancellation_fence.disarm_like_cpp(); self.mark_current_player_save_to_db_committed_like_cpp(&plan); info!( guid = snapshot.guid.counter(), @@ -24641,11 +26615,35 @@ impl WorldSession { "Player::SaveToDB represented save committed in one CharacterDatabase transaction" ); } - Err(err) => warn!( - guid = snapshot.guid.counter(), - statement_count, "Failed to commit Player::SaveToDB represented transaction: {err}" - ), + Err(SqlTransactionCommitError::DefinitelyRolledBack(err)) => { + cancellation_fence.disarm_like_cpp(); + warn!( + guid = snapshot.guid.counter(), + statement_count, + "Failed to commit Player::SaveToDB represented transaction: {err}" + ); + } + Err(SqlTransactionCommitError::CommitOutcomeUnknown(err)) => { + // The full save includes many absolute replacements. A money + // row alone cannot establish whether that whole transaction + // committed, so preserve dirty flags and force a reload before + // any further money mutation can race an unknown durable base. + money_tracker.mark_indeterminate_like_cpp(); + cancellation_fence.disarm_like_cpp(); + self.kick( + "Player::SaveToDB COMMIT outcome is unknown; relog required before another money mutation", + ); + warn!( + guid = snapshot.guid.counter(), + statement_count, + "Player::SaveToDB represented transaction COMMIT outcome is unknown: {err}" + ); + } } + drop(money_mutation_lock); + drop(money_save_fence); + self.drain_represented_quest_objective_progress_like_cpp() + .await; } async fn process_pending_periodic_player_save_like_cpp(&mut self) { @@ -25761,6 +27759,77 @@ impl WorldSession { .unwrap_or_default() } + fn represented_talent_reset_state_plan_like_cpp( + &self, + ) -> Option { + if !self.represented_talents_loaded_like_cpp { + return None; + } + + let active_group = self.represented_active_talent_group_like_cpp; + let active_group_index = usize::from(active_group); + let active_talents = self + .represented_talents_like_cpp + .get(active_group_index)? + .clone(); + let mut post_talents = self.represented_talents_like_cpp.clone(); + post_talents[active_group_index].clear(); + + Some(RepresentedTalentResetStatePlanLikeCpp { + active_group, + active_talents, + post_talents, + }) + } + + /// Build the represented durable money/reset-metadata/talent transaction + /// without mutating the session. The absolute money write is retained even + /// for a zero-cost reset so a lost COMMIT reply cannot be mistaken for proof + /// that the talent statements rolled back. `character_spell` is deliberately + /// untouched until Rust retains the complete C++ `PlayerSpellMap` row state. + fn represented_talent_reset_transaction_statement_plan_like_cpp( + &self, + guid_counter: u64, + old_money: u64, + new_money: u64, + cost: u32, + reset_time_secs: u64, + ) -> Option<( + RepresentedTalentResetStatePlanLikeCpp, + Vec, + )> { + let state_plan = self.represented_talent_reset_state_plan_like_cpp()?; + let mut statements = vec![ + Self::build_character_gold_save_statement_like_cpp(new_money, guid_counter), + Self::build_character_talent_reset_state_save_statement_like_cpp( + cost, + reset_time_secs, + guid_counter, + ), + Self::build_character_talent_delete_statement_like_cpp(guid_counter), + ]; + + for (talent_group, talents) in state_plan.post_talents.iter().enumerate() { + for (talent_id, rank) in talents { + if self + .represented_talent_info_like_cpp(*talent_id, *rank) + .is_none() + { + continue; + } + statements.push(Self::build_character_talent_insert_statement_like_cpp( + guid_counter, + *talent_id, + *rank, + talent_group as u8, + )); + } + } + + debug_assert_eq!(old_money.saturating_sub(new_money), u64::from(cost)); + Some((state_plan, statements)) + } + pub(crate) fn build_character_talent_delete_statement_like_cpp( guid_counter: u64, ) -> PreparedStatement { @@ -28243,6 +30312,16 @@ impl WorldSession { old_money: u64, new_money: u64, ) { + self.stage_player_money_change_like_cpp(old_money, new_money); + self.drain_represented_quest_objective_progress_like_cpp() + .await; + } + + /// Publish an already-durable absolute money mutation without awaiting. + /// Transactional callers use this while their exclusive money guard is + /// still held, then drop the guard before draining criteria (which can + /// re-enter money persistence through a quest reward). + pub(crate) fn stage_player_money_change_like_cpp(&mut self, old_money: u64, new_money: u64) { if old_money != new_money { self.enqueue_represented_quest_objective_progress_like_cpp( RepresentedQuestObjectiveProgressEventLikeCpp::MoneyChanged { @@ -28252,8 +30331,6 @@ impl WorldSession { ); } self.set_player_gold_like_cpp(new_money); - self.drain_represented_quest_objective_progress_like_cpp() - .await; } pub(crate) fn represented_next_reset_talents_cost_like_cpp(&self, now_secs: u64) -> u32 { @@ -28264,36 +30341,142 @@ impl WorldSession { ) } - pub(crate) async fn apply_represented_talent_reset_cost_like_cpp(&mut self) -> bool { + pub(crate) async fn commit_represented_talent_reset_like_cpp( + &mut self, + ) -> Option { let now_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - self.apply_represented_talent_reset_cost_at_like_cpp(now_secs) + self.commit_represented_talent_reset_at_like_cpp(now_secs) .await } - pub(crate) async fn apply_represented_talent_reset_cost_at_like_cpp( + async fn commit_represented_talent_reset_at_like_cpp( &mut self, now_secs: u64, - ) -> bool { + ) -> Option { let cost = if self.no_reset_talent_cost_like_cpp { 0 } else { u64::from(self.represented_next_reset_talents_cost_like_cpp(now_secs)) }; + + let money_persistence = self + .begin_exclusive_player_money_persistence_like_cpp() + .await?; let old_money = self.player_gold_like_cpp(); - if !self.no_reset_talent_cost_like_cpp && old_money < cost { + if old_money < cost { self.send_buy_error(BuyResult::NotEnoughtMoney, None, 0); - return false; + return None; + } + let new_money = old_money - cost; + let player_guid = self.player_guid()?; + let (state_plan, statements) = self + .represented_talent_reset_transaction_statement_plan_like_cpp( + player_guid.counter() as u64, + old_money, + new_money, + cost as u32, + now_secs, + )?; + let mut transaction = SqlTransaction::new(); + for statement in statements { + transaction.append(statement); } - self.apply_player_money_change_like_cpp(old_money, old_money - cost) + // Unit fixtures without a CharacterDatabase explicitly model a + // successful COMMIT. The failure seam proves that no covered runtime + // state is published on a definite rollback. + #[cfg(test)] + if self.loot_money_persistence_test_result_like_cpp == Some(false) { + return None; + } + #[cfg(test)] + let bypass_database_like_cpp = self.loot_money_persistence_test_result_like_cpp + == Some(true) + || self.char_db.is_none(); + #[cfg(not(test))] + let bypass_database_like_cpp = false; + + let money_persistence = if bypass_database_like_cpp { + money_persistence + } else { + let char_db = self.char_db.as_ref().map(Arc::clone)?; + self.commit_exclusive_player_money_transaction_like_cpp( + money_persistence, + char_db.as_ref(), + transaction, + old_money, + new_money, + "talent reset", + ) + .await? + }; + + Some(CommittedRepresentedTalentResetLikeCpp { + money_persistence, + old_money, + new_money, + cost: cost as u32, + reset_time_secs: now_secs, + state_plan, + }) + } + + /// Publish every runtime effect covered by the committed reset before the + /// shared money guard is released. There is deliberately no `.await` + /// between entry and `drop(money_persistence)`: cancellation cannot expose + /// a durable fee/talent reset with the old session state still live. + pub(crate) async fn publish_committed_represented_talent_reset_like_cpp( + &mut self, + committed: CommittedRepresentedTalentResetLikeCpp, + request: RepresentedConfirmRespecWipeLikeCpp, + visual_spell_id: u32, + ) { + let CommittedRepresentedTalentResetLikeCpp { + money_persistence, + old_money, + new_money, + cost, + reset_time_secs, + state_plan, + } = committed; + + self.remove_represented_pet_not_in_slot_like_cpp(); + self.record_represented_confirm_respec_wipe_like_cpp(request); + + debug_assert_eq!( + self.represented_active_talent_group_like_cpp, + state_plan.active_group + ); + for (talent_id, rank) in &state_plan.active_talents { + self.remove_represented_active_talent_side_effects_like_cpp(*talent_id, *rank); + } + self.represented_talents_like_cpp = state_plan.post_talents; + self.refresh_represented_talent_points_like_cpp(); + + self.stage_player_money_change_like_cpp(old_money, new_money); + self.represented_talent_reset_cost_like_cpp = cost; + self.represented_talent_reset_time_secs_like_cpp = reset_time_secs; + self.record_represented_talent_respec_criteria_like_cpp(cost); + + self.send_packet(&self.represented_update_talent_data_packet_like_cpp()); + if let Some(player_guid) = self.player_guid() { + self.record_represented_talent_respec_visual_spell_cast_like_cpp( + RepresentedTalentRespecVisualSpellCastLikeCpp { + caster_guid: request.respec_master, + target_guid: player_guid, + spell_id: visual_spell_id, + triggered: true, + spell_runtime_unrepresented: true, + }, + ); + } + + drop(money_persistence); + self.drain_represented_quest_objective_progress_like_cpp() .await; - self.record_represented_talent_respec_criteria_like_cpp(cost as u32); - self.represented_talent_reset_cost_like_cpp = cost as u32; - self.represented_talent_reset_time_secs_like_cpp = now_secs; - true } fn record_represented_talent_respec_criteria_like_cpp(&mut self, cost: u32) { @@ -29083,10 +31266,13 @@ impl WorldSession { is_in_world: self.player_is_in_world_for_registry_like_cpp(), send_tx: self.send_tx.clone(), command_tx: self.session_command_tx.clone(), + durable_loot_money_tracker_like_cpp: Arc::clone( + &self.durable_loot_money_persistence_like_cpp, + ), active_loot_rolls: self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(), pass_on_group_loot: self.pass_on_group_loot, enchanting_skill: self.represented_enchanting_skill, @@ -29190,8 +31376,8 @@ impl WorldSession { info.liquid_status = self.player_liquid_status_like_cpp(); info.active_loot_rolls = self .represented_loot_rolls - .keys() - .map(|key| (key.0, key.1)) + .values() + .map(|state| state.command_identity.clone()) .collect(); info.pass_on_group_loot = self.pass_on_group_loot; info.enchanting_skill = self.represented_enchanting_skill; @@ -29391,10 +31577,11 @@ impl WorldSession { } pub async fn cleanup_shared_runtime_state_on_disconnect_like_cpp(&mut self) { + self.wait_for_active_loot_persistence_like_cpp().await; if let Some(player_guid) = self.player_guid() - && !self.active_loot_guid.is_empty() + && self.has_active_loot_views_like_cpp() { - self.close_active_loot_windows_like_cpp(player_guid); + self.do_loot_release_all_like_cpp(player_guid).await; } self.cleanup_shared_runtime_state(); } @@ -29412,6 +31599,10 @@ impl WorldSession { "Saving player on disconnect" ); self.set_player_logout_like_cpp(true); + self.wait_for_active_loot_persistence_like_cpp().await; + if self.has_active_loot_views_like_cpp() { + self.do_loot_release_all_like_cpp(player_guid).await; + } self.clear_buyback_on_logout().await; self.save_current_player_to_db_like_cpp().await; self.save_account_mounts_like_cpp().await; @@ -29480,6 +31671,35 @@ impl WorldSession { self.guid_generator.as_ref() } + /// Allocate item database/object GUIDs from the process-wide generator. + /// + /// C++ initializes this generator once from `MAX(item_instance.guid) + 1` + /// in `ObjectMgr::SetHighestGuids`, and every `Item::CreateItem` consumes + /// the next value. Rust sessions execute concurrently, so the shared + /// `ObjectGuidGenerator` uses an atomic fetch-add. Allocations are never + /// returned after a later persistence failure, matching C++ Item creation. + pub(crate) fn allocate_item_instance_guids_like_cpp( + &self, + count: usize, + ) -> Option> { + if count == 0 { + return Some(Vec::new()); + } + let generator = self.item_guid_generator_like_cpp.as_ref()?; + if generator.high_guid() != HighGuid::Item { + return None; + } + + let realm_id = self.realm_id(); + (0..count) + .map(|_| { + let counter = generator.generate(); + let db_guid = u64::try_from(counter).ok()?; + Some((db_guid, ObjectGuid::create_item(realm_id, counter))) + }) + .collect() + } + /// Set the session manager for ConnectTo flow. pub fn set_session_mgr(&mut self, mgr: Arc) { self.session_mgr = Some(mgr); @@ -29519,6 +31739,10 @@ impl WorldSession { pub(crate) fn set_player_logout_like_cpp(&mut self, player_logout: bool) { self.player_logout_like_cpp = player_logout; + if player_logout { + self.durable_loot_money_persistence_like_cpp + .close_admission_permanently_like_cpp(); + } self.sync_current_player_session_visibility_detection_like_cpp(); } @@ -29552,6 +31776,13 @@ impl WorldSession { &self.send_tx } + /// Install the FIFO completion fence paired with the session's initial + /// realm socket. Runtime does this immediately after constructing the + /// session; unit sessions that never own a physical writer leave it empty. + pub fn set_send_write_fence_like_cpp(&mut self, fence: SocketWriteFenceLikeCpp) { + self.send_write_fence_like_cpp = Some(fence); + } + /// Clone the C++-style cross-session command channel for this active /// session. /// @@ -31599,6 +33830,9 @@ impl WorldSession { // ── Spell casting tick ───────────────────────────────────────── // Check if an active spell cast has completed and execute it. if self.state == SessionState::LoggedIn { + if let Some(player_guid) = self.player_guid() { + self.close_retired_active_loot_windows_like_cpp(player_guid); + } self.tick_represented_loot_rolls_like_cpp().await; self.tick_represented_gameobject_update_like_cpp(); self.send_represented_gameobject_visibility_on_destroy_from_last_update_like_cpp(); @@ -31728,6 +33962,12 @@ impl WorldSession { // realm socket from closing. let old_send_tx = std::mem::replace(&mut self.send_tx, link.send_tx); self.realm_send_tx = Some(old_send_tx); + let next_write_fence = link + .send_write_fence_like_cpp + .or_else(|| self.send_write_fence_like_cpp.clone()); + let old_write_fence = + std::mem::replace(&mut self.send_write_fence_like_cpp, next_write_fence); + self.realm_send_write_fence_like_cpp = old_write_fence; if let Some(pkt_rx) = link.pkt_rx { let old_packet_rx = std::mem::replace(&mut self.packet_rx, pkt_rx); @@ -34293,6 +36533,38 @@ impl WorldSession { } } + /// Attempts to enqueue one instance-channel packet without waiting for + /// socket-writer capacity. + /// + /// This is deliberately narrow rather than a replacement for the normal + /// packet API. Object-owned loot uses it while holding its short authority + /// mutex so a stalled client cannot block every concurrent claim/release. + pub(crate) fn try_send_packet(&self, pkt: &P) -> bool { + let data = pkt.to_bytes(); + if std::env::var_os("RUSTYCORE_LOGIN_TRACE").is_some() { + info!( + account = self.account_id, + opcode = ?P::OPCODE, + bytes = data.len(), + "RUST_LOGIN_TRACE try_send_packet" + ); + } + match self.send_tx.try_send(data) { + Ok(()) => true, + Err(flume::TrySendError::Full(_)) => { + warn!( + "Send channel full for account {}; packet rejected without blocking", + self.account_id + ); + false + } + Err(flume::TrySendError::Disconnected(_)) => { + warn!("Send channel closed for account {}", self.account_id); + false + } + } + } + fn send_system_message_like_cpp(&self, text: &str) { for line in text.split('\n') { self.send_packet(&ChatPkt { @@ -34376,11 +36648,78 @@ impl WorldSession { } } + /// Wait for current-instance packets to reach their physical socket before + /// emitting a later realm packet. C++ performs both `SendDirectMessage` + /// calls synchronously; Rust's two independent writer tasks need this FIFO + /// completion fence to retain the same cross-connection order. + pub(crate) async fn wait_for_instance_send_before_realm_send_like_cpp(&self) -> bool { + let Some(realm_send_tx) = self.realm_send_tx.as_ref() else { + return true; + }; + if realm_send_tx.same_channel(&self.send_tx) { + return true; + } + let Some(write_fence) = self.send_write_fence_like_cpp.as_ref() else { + warn!( + account = self.account_id, + "instance/realm ordering fence unavailable for separate sockets" + ); + return false; + }; + let acknowledged = write_fence + .wait_for_prior_packets_written_like_cpp(&self.send_tx, Duration::from_millis(250)) + .await; + if !acknowledged { + warn!( + account = self.account_id, + "instance writer did not acknowledge the bounded ordering fence" + ); + } + acknowledged + } + + /// Wait for realm packets to reach their physical socket before emitting a + /// later instance update. This mirrors `Player::SendNewItem` preceding the + /// deferred `Map::SendObjectUpdates` player-field flush in C++. + pub(crate) async fn wait_for_realm_send_before_instance_update_like_cpp(&self) -> bool { + let Some(realm_send_tx) = self.realm_send_tx.as_ref() else { + return true; + }; + if realm_send_tx.same_channel(&self.send_tx) { + return true; + } + let Some(write_fence) = self.realm_send_write_fence_like_cpp.as_ref() else { + warn!( + account = self.account_id, + "realm/instance ordering fence unavailable for separate sockets" + ); + return false; + }; + let acknowledged = write_fence + .wait_for_prior_packets_written_like_cpp(realm_send_tx, Duration::from_millis(250)) + .await; + if !acknowledged { + warn!( + account = self.account_id, + "realm writer did not acknowledge the bounded ordering fence" + ); + } + acknowledged + } + #[cfg(test)] pub(crate) fn install_realm_send_channel_for_test(&mut self, tx: flume::Sender>) { self.realm_send_tx = Some(tx); } + #[cfg(test)] + pub(crate) fn install_realm_send_write_fence_for_test( + &mut self, + fence: SocketWriteFenceLikeCpp, + ) { + self.realm_send_write_fence_like_cpp = Some(fence); + } + /// Send pre-serialized packet bytes to the client. /// /// Used for packets with dynamic opcodes (e.g. `SetSpellModifier` @@ -34403,6 +36742,19 @@ impl WorldSession { } } + /// Send pre-serialized packet bytes on the realm connection. + /// + /// This is the cross-session counterpart of [`Self::send_packet_realm`]: + /// registry commands already carry serialized bytes, but C++ opcode + /// routing still requires packets such as `SMSG_PARTY_INVITE` and + /// `SMSG_PARTY_MEMBER_FULL_STATE` to use `CONNECTION_TYPE_REALM`. + pub(crate) fn send_raw_packet_realm(&self, data: &[u8]) { + let tx = self.realm_send_tx.as_ref().unwrap_or(&self.send_tx); + if tx.send(data.to_vec()).is_err() { + warn!("Realm send channel closed for account {}", self.account_id); + } + } + pub fn send_equip_error( &self, result: InventoryResult, @@ -34694,6 +37046,10 @@ impl WorldSession { if guid.is_none() { self.player_controller = None; self.represented_seer_guid_like_cpp = None; + // Old registry clones remain permanently closed; a later character + // selected on this authenticated session receives a fresh fence. + self.durable_loot_money_persistence_like_cpp = + Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); } } @@ -49352,15 +51708,102 @@ impl WorldSession { let manager = self.canonical_map_manager.as_ref()?; let manager = manager.lock().ok()?; let mut key = None; + let mut ambiguous = false; manager.do_for_all_maps(|managed| { - if key.is_none() && managed.map().get_typed_player(guid).is_some() { + if managed.map().get_typed_player(guid).is_none() { + return; + } + if key.is_some() { + ambiguous = true; + } else { key = Some(wow_map::MapKey::new( managed.map_id(), managed.instance_id(), )); } }); - key + (!ambiguous).then_some(key).flatten() + } + + /// Resolve object access through the player's exact canonical `Map`, as + /// C++ `ObjectAccessor::GetCreature/GetGameObject(WorldObject const&, ...)` + /// does through `world_object.GetMap()`. During pre-player bootstrap and + /// focused represented tests, accept a sole map for the expected map id; + /// multiple instances without a canonical Player fail closed. + pub(crate) fn canonical_object_lookup_map_key_like_cpp( + &self, + fallback_map_id: u32, + ) -> Option { + if let Some(map_key) = self.current_canonical_player_map_key_like_cpp() { + return Some(map_key); + } + + let manager = self.canonical_map_manager.as_ref()?; + let manager = manager.lock().ok()?; + if let Some(player_guid) = self.player_guid() { + let mut player_map_count = 0usize; + manager.do_for_all_maps(|managed| { + if managed.map().get_typed_player(player_guid).is_some() { + player_map_count = player_map_count.saturating_add(1); + } + }); + // A player temporarily visible in two canonical maps is a + // transfer boundary, not permission to choose one by iteration + // order. Object-owned mutations fail closed until ownership is + // unambiguous. + if player_map_count != 0 || self.state == SessionState::LoggedIn { + return None; + } + } + let mut fallback_key = None; + let mut ambiguous = false; + manager.do_for_all_maps(|managed| { + if managed.map_id() != fallback_map_id { + return; + } + if fallback_key.is_some() { + ambiguous = true; + } else { + fallback_key = Some(wow_map::MapKey::new( + managed.map_id(), + managed.instance_id(), + )); + } + }); + (!ambiguous).then_some(fallback_key).flatten() + } + + /// Revalidates the exact map ownership captured before a multi-lock loot + /// authority reconciliation. If a canonical Player existed at capture + /// time, fallback lookup is forbidden: disappearing or moving during the + /// attempt must fail closed rather than mutate the old map. + pub(crate) fn loot_reconciliation_map_key_still_valid_like_cpp( + &self, + map_key: wow_map::MapKey, + canonical_player_was_present: bool, + ) -> bool { + if canonical_player_was_present { + return self.current_canonical_player_map_key_like_cpp() == Some(map_key); + } + if self.canonical_map_manager.is_some() { + return self.canonical_object_lookup_map_key_like_cpp(map_key.map_id) == Some(map_key); + } + let (map_id, instance_id) = self.current_legacy_runtime_map_key_like_cpp(); + u32::from(map_id) == map_key.map_id && instance_id == map_key.instance_id + } + + /// The legacy map facade must follow the same map instance that owns the + /// canonical Player. Instance `0` remains only the bootstrap fallback for + /// tests/runtime phases where no canonical Player has been materialized. + pub(crate) fn current_legacy_runtime_map_key_like_cpp(&self) -> (u16, u32) { + let fallback_map_id = self.player_map_id_like_cpp(); + let Some(map_key) = self.current_canonical_player_map_key_like_cpp() else { + return (fallback_map_id, 0); + }; + let Ok(map_id) = u16::try_from(map_key.map_id) else { + return (fallback_map_id, 0); + }; + (map_id, map_key.instance_id) } fn represented_dynamic_object_values_update_delivery_fingerprint_like_cpp( @@ -50222,6 +52665,9 @@ impl WorldSession { ); self.send_tx = realm_tx; } + if let Some(realm_write_fence) = self.realm_send_write_fence_like_cpp.take() { + self.send_write_fence_like_cpp = Some(realm_write_fence); + } if let Some(realm_rx) = self.realm_packet_rx.take() { info!( "Restoring realm packet channel as primary for account {}", @@ -50573,12 +53019,30 @@ pub fn run_legacy_creature_movement_tick_once_like_cpp( guid, position, ); - sync_canonical_creature_entity_on_map_like_cpp( + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let authority = sync_canonical_creature_entity_on_map_like_cpp( canonical_map_manager, map_id, instance_id, creature, ); + if let Some(authority) = authority { + let mut legacy = legacy_map_manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(world_creature) = + legacy.find_creature_mut(map_id as u16, instance_id, guid) + { + let _ = world_creature + .creature + .rebind_loot_authority_if_current_like_cpp( + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } + } outcome.canonical_syncs += 1; } } @@ -50714,6 +53178,9 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( .collect(); for guid in despawn_guids { + if let Some(creature) = manager.find_creature_mut(map_id, instance_id, guid) { + creature.creature.clear_loot_like_cpp(); + } let Some(creature) = manager.remove_creature_any(map_id, instance_id, guid) else { continue; }; @@ -50890,12 +53357,31 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( } } for (map_id, instance_id, creature) in canonical_inserts { - insert_canonical_creature_map_object_on_map_like_cpp( + let guid = creature.guid(); + let expected_legacy_authority = creature.loot_authority_like_cpp().clone(); + let expected_legacy_stamp = expected_legacy_authority.stamp_like_cpp(); + let authority = insert_canonical_creature_map_object_on_map_like_cpp( canonical_map_manager, map_id, instance_id, creature, ); + if let Some(authority) = authority { + let mut legacy = legacy_map_manager + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(world_creature) = + legacy.find_creature_mut(map_id as u16, instance_id, guid) + { + let _ = world_creature + .creature + .rebind_loot_authority_if_current_like_cpp( + &expected_legacy_authority, + expected_legacy_stamp, + authority, + ); + } + } outcome.canonical_inserts += 1; } } @@ -57228,11 +59714,17 @@ impl WorldSession { restored.max(represented_restored) } - fn current_map_is_dungeon_like_cpp(&self) -> bool { + pub(crate) fn current_map_is_dungeon_like_cpp(&self) -> bool { + self.current_map_dungeon_state_like_cpp().unwrap_or(false) + } + + /// Resolve the current map's C++ `MapEntry::IsDungeon` classification + /// without conflating missing Map.db2 metadata with an overworld map. + pub(crate) fn current_map_dungeon_state_like_cpp(&self) -> Option { self.map_store .as_ref() .and_then(|store| store.get(u32::from(self.player_map_id_like_cpp()))) - .is_some_and(|entry| entry.is_dungeon()) + .map(|entry| entry.is_dungeon()) } fn canonical_threatened_by_me_owner_guids_like_cpp( @@ -58137,7 +60629,7 @@ fn default_available_classes() -> Vec = plan.statements.iter().map(PreparedStatement::sql).collect(); assert_eq!( @@ -106785,6 +110053,107 @@ mod tests { ); } + #[test] + fn ambiguous_absolute_money_commit_requires_exact_changed_row_evidence_like_cpp() { + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(75)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Committed + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(100)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::RolledBack + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, Some(90)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 75, None), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate + ); + assert_eq!( + reconcile_absolute_player_money_commit_like_cpp(100, 100, Some(100)), + AbsolutePlayerMoneyCommitReconciliationLikeCpp::Indeterminate, + "an unchanged money row cannot prove whether bundled non-money statements committed" + ); + } + + #[tokio::test] + async fn failed_exclusive_money_persistence_never_publishes_runtime_and_reopens_admission() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 5_012); + session.set_player_guid(Some(player_guid)); + session.set_player_gold_like_cpp(100); + session.set_loot_money_persistence_test_result_like_cpp(false); + let tracker = session.durable_loot_money_persistence_tracker_like_cpp(); + + assert_eq!( + session + .mutate_and_persist_player_gold_exclusive_like_cpp(|money| money + 25) + .await, + None + ); + assert_eq!(session.player_gold_like_cpp(), 100); + assert!( + tracker.begin_like_cpp().is_ok(), + "a definite persistence failure must drop the save fence and reopen payout admission" + ); + } + + #[tokio::test] + async fn indeterminate_money_state_blocks_full_save_reconciliation_like_cpp() { + let (mut session, _, _) = make_session(); + let tracker = session.durable_loot_money_persistence_tracker_like_cpp(); + tracker.mark_indeterminate_like_cpp(); + + assert!( + !session + .reconcile_durable_loot_money_before_save_like_cpp() + .await + ); + assert!(tracker.is_indeterminate_like_cpp()); + assert!( + session + .current_player_save_to_db_statement_plan_like_cpp( + &PlayerSaveToDbSnapshotLikeCpp { + guid: ObjectGuid::create_player(1, 50_013), + map_id: 0, + instance_id: 0, + position: Position::default(), + level: 1, + xp: 0, + money: 0, + health: 1, + max_health: 1, + powers: empty_character_power_snapshot_like_cpp(), + }, + 0, + ) + .is_none(), + "an indeterminate money transaction must prevent every partial full-save plan" + ); + } + + #[test] + fn cancelled_money_commit_fence_permanently_closes_admission_before_disconnect_save() { + let tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + { + let _armed = PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&tracker)); + } + + assert!(tracker.is_indeterminate_like_cpp()); + assert!(tracker.begin_like_cpp().is_err()); + + let safe_tracker = Arc::new(DurableLootMoneyPersistenceTrackerLikeCpp::default()); + { + let mut known_outcome = + PlayerMoneyCommitCancellationFenceLikeCpp::new(Arc::clone(&safe_tracker)); + known_outcome.disarm_like_cpp(); + } + assert!(!safe_tracker.is_indeterminate_like_cpp()); + assert!(safe_tracker.begin_like_cpp().is_ok()); + } + #[test] fn offline_rested_xp_tavern_accrues_more_than_wilderness_like_cpp() { let (mut wilderness, _, _) = make_session(); @@ -106856,7 +110225,9 @@ mod tests { ) }); - let plan = session.current_player_save_to_db_statement_plan_like_cpp(&snapshot, 1_000); + let plan = session + .current_player_save_to_db_statement_plan_like_cpp(&snapshot, 1_000) + .expect("determinate tracker should allow a full-save plan"); assert!(plan.tutorials_changed_committed_like_cpp); assert!(plan.equipment_sets_committed_like_cpp); @@ -110929,6 +114300,138 @@ mod tests { ); } + #[test] + fn talent_reset_transaction_plan_clears_active_preserves_inactive_and_persists_zero_cost() { + let (mut session, _, _) = make_session(); + session.set_talent_store(Arc::new(wow_data::TalentStore::from_entries([ + test_talent_entry_like_cpp(101, 0, 50_101), + test_talent_entry_like_cpp(202, 1, 50_202), + ]))); + let mut spell_store = wow_data::SpellStore::new(); + spell_store.insert( + 50_101, + wow_data::SpellInfo { + effects: vec![wow_data::SpellEffectInfo { + effect: wow_data::spell::spell_effect_types::SPELL_EFFECT_LEARN_SPELL, + effect_trigger_spell: 60_101, + ..Default::default() + }], + ..test_spell_info_like_cpp(50_101) + }, + ); + spell_store.insert(60_101, test_spell_info_like_cpp(60_101)); + spell_store.insert(50_202, test_spell_info_like_cpp(50_202)); + session.set_spell_store(Arc::new(spell_store)); + install_test_talent_tab_store_like_cpp(&mut session); + + session.set_represented_active_talent_group_like_cpp(0); + assert!(session.load_represented_talent_row_like_cpp(101, 0, 0)); + assert!(session.load_represented_talent_row_like_cpp(202, 1, 1)); + session.mark_represented_talents_loaded_like_cpp(); + session.set_known_spells_like_cpp(vec![50_101, 60_101, 50_202]); + + let (state_plan, statements) = session + .represented_talent_reset_transaction_statement_plan_like_cpp(42, 777, 777, 0, 123) + .expect("coherent talent state should produce an atomic reset plan"); + + assert_eq!(state_plan.active_group, 0); + assert_eq!(state_plan.active_talents, BTreeMap::from([(101, 0)])); + assert!(state_plan.post_talents[0].is_empty()); + assert_eq!(state_plan.post_talents[1], BTreeMap::from([(202, 1)])); + assert_eq!(statements.len(), 4); + assert_eq!(statements[0].sql(), CharStatements::UPD_CHAR_MONEY.sql()); + assert_eq!( + statements[0].params(), + &[ + wow_database::SqlParam::U64(777), + wow_database::SqlParam::U64(42), + ], + "zero-cost reset still carries an absolute money marker in the same transaction" + ); + assert_eq!( + statements[1].sql(), + CharStatements::UPD_CHAR_TALENT_RESET_STATE.sql() + ); + assert_eq!( + statements[1].params(), + &[ + wow_database::SqlParam::U32(0), + wow_database::SqlParam::U64(123), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!(statements[2].sql(), CharStatements::DEL_CHAR_TALENT.sql()); + assert_eq!(statements[3].sql(), CharStatements::INS_CHAR_TALENT.sql()); + assert_eq!( + statements[3].params(), + &[ + wow_database::SqlParam::U64(42), + wow_database::SqlParam::U32(202), + wow_database::SqlParam::U8(1), + wow_database::SqlParam::U8(1), + ], + "the inactive talent group is reinserted after the delete-all" + ); + assert!( + statements.iter().all(|statement| { + !matches!( + statement.sql(), + sql if sql == CharStatements::DEL_CHAR_SPELL_BY_SPELL.sql() + || sql == CharStatements::INS_CHAR_SPELL.sql() + || sql == CharStatements::DEL_CHAR_SPELL_FAVORITE.sql() + || sql == CharStatements::INS_CHAR_SPELL_FAVORITE.sql() + ) + }), + "talent reset leaves character_spell ownership untouched until PlayerSpellMap is represented" + ); + + assert_eq!(session.player_gold_like_cpp(), 0); + assert_eq!( + session + .represented_update_talent_data_packet_like_cpp() + .groups[0] + .talents + .len(), + 1, + "building the transaction plan is pure and does not publish the reset" + ); + } + + #[test] + fn talent_reset_transaction_plan_persists_capped_fee_with_talent_delete() { + let (mut session, _, _) = make_session(); + session.mark_represented_talents_loaded_like_cpp(); + let month = TALENT_RESET_MONTH_SECS_LIKE_CPP; + let now = 10 * month; + session.set_represented_talent_reset_state_like_cpp(500_000, now); + let cost = session.represented_next_reset_talents_cost_like_cpp(now); + assert_eq!(cost, 500_000); + + let (_, statements) = session + .represented_talent_reset_transaction_statement_plan_like_cpp( + 42, 1_000_000, 500_000, cost, now, + ) + .expect("loaded empty active group should still persist its reset"); + + assert_eq!(statements.len(), 3); + assert_eq!( + statements[0].params(), + &[ + wow_database::SqlParam::U64(500_000), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!( + statements[1].params(), + &[ + wow_database::SqlParam::U32(500_000), + wow_database::SqlParam::U64(now), + wow_database::SqlParam::U64(42), + ] + ); + assert_eq!(statements[2].sql(), CharStatements::DEL_CHAR_TALENT.sql()); + } + #[test] fn character_glyph_save_requires_coherent_load_like_cpp() { let (session, _, _) = make_session(); @@ -124236,6 +127739,7 @@ mod tests { #[tokio::test] async fn loot_money_consumes_only_current_active_loot_like_cpp() { let (mut session, _, send_rx) = make_session(); + session.set_loot_money_persistence_test_result_like_cpp(true); let player_guid = ObjectGuid::create_player(1, 42); let active_guid = test_creature_guid(19_001); let inactive_guid = test_creature_guid(19_002); @@ -125603,7 +129107,10 @@ mod tests { assert_eq!(sent.read_packed_guid().unwrap(), loot_guid); assert_eq!(sent.read_packed_guid().unwrap(), player_guid); assert!(!session.is_active_loot_guid(loot_guid)); - assert!(session.loot_table.contains_key(&loot_guid)); + assert!( + !session.loot_table.contains_key(&loot_guid), + "full disconnect release retires the session packet-cache copy like C++" + ); } #[test] @@ -131869,6 +135376,7 @@ mod tests { ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 792, 40); let position = Position::new(1.0, 2.0, 3.0, 0.0); session.set_player_guid(Some(player_guid)); + session.set_loot_money_persistence_test_result_like_cpp(true); session.set_player_map_position_like_cpp(571, position); session.set_canonical_map_manager(Arc::clone(&canonical)); add_canonical_test_player_on_map(&canonical, player_guid, position, 571, 0); @@ -131920,6 +135428,59 @@ mod tests { ); } + #[tokio::test] + async fn quest_reward_money_crossing_cap_leaves_balance_unchanged_like_cpp() { + let (mut session, _pkt_tx, _send_rx) = make_session(); + let player_guid = ObjectGuid::create_player(1, 99); + let source_guid = + ObjectGuid::create_world_object(HighGuid::Creature, 0, 1, 571, 0, 792, 40); + let position = Position::new(1.0, 2.0, 3.0, 0.0); + session.set_player_guid(Some(player_guid)); + session.set_player_map_position_like_cpp(571, position); + let canonical = shared_canonical_map_manager(); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, position, 571, 0); + add_canonical_test_creature( + &canonical, + source_guid, + 792, + position, + wow_constants::unit::NPCFlags1::QUEST_GIVER.bits(), + ); + + let quest_id = 9_233; + let mut quest = test_quest_template(quest_id); + quest.reward_money_difficulty = 2; + let mut quest_store = wow_data::quest::QuestStore::from_quests_like_cpp([quest]); + quest_store.ender_quests.insert(792, vec![quest_id]); + session.quest_store = Some(Arc::new(quest_store)); + session.set_player_gold_like_cpp(MAX_MONEY_AMOUNT - 1); + session.player_quests.insert( + quest_id, + crate::handlers::quest::PlayerQuestStatus { + quest_id, + status: crate::conditions::QUEST_STATUS_COMPLETE_LIKE_CPP, + explored: false, + accept_time_secs: 0, + end_time_secs: 0, + objective_counts: Vec::new(), + slot: 0, + }, + ); + + session + .handle_quest_giver_choose_reward(quest_giver_choose_reward_packet_like_cpp( + source_guid, + quest_id, + 0, + 0, + )) + .await; + + assert_eq!(session.player_gold_like_cpp(), MAX_MONEY_AMOUNT - 1); + assert!(session.rewarded_quests.contains(&quest_id)); + } + #[tokio::test] async fn quest_giver_choose_reward_missing_source_rejects_before_mutation_like_cpp() { let (mut session, _pkt_tx, send_rx) = make_session(); @@ -132023,6 +135584,7 @@ mod tests { let (mut session, _pkt_tx, send_rx) = make_session(); let player_guid = ObjectGuid::create_player(1, 99); session.set_player_guid(Some(player_guid)); + session.set_loot_money_persistence_test_result_like_cpp(true); session.set_player_gold_like_cpp(5); let mut quest = test_quest_template(9_226); quest.flags = crate::handlers::quest::QUEST_FLAGS_AUTO_COMPLETE_LIKE_CPP; @@ -134896,6 +138458,14 @@ mod tests { wow_entities::CreatureAiState::WalkingRandom ); } + let legacy_authority = manager + .read() + .unwrap() + .find_creature(0, 0, guid) + .unwrap() + .creature + .loot_authority_like_cpp() + .clone(); { let guard = canonical.lock().unwrap(); let typed = guard @@ -134908,6 +138478,7 @@ mod tests { typed.ai_state(), wow_entities::CreatureAiState::WalkingRandom ); + assert!(legacy_authority.shares_storage_like_cpp(typed.loot_authority_like_cpp())); } } @@ -135500,6 +139071,10 @@ mod tests { static_flags: [0; 8], creature_type: 0, type_flags: 0, + loot_id: 0, + skin_loot_id: 0, + gold_min: 0, + gold_max: 0, movement_type: MovementGeneratorType::Idle, ground_movement_type: wow_constants::CreatureGroundMovementType::Run as u8, swim_allowed: true, diff --git a/docs/migration/EXISTING-CODE-DEFECTS.md b/docs/migration/EXISTING-CODE-DEFECTS.md index 12133ef01..c205a4311 100644 --- a/docs/migration/EXISTING-CODE-DEFECTS.md +++ b/docs/migration/EXISTING-CODE-DEFECTS.md @@ -68,10 +68,27 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." rejected after a stack overflow and replaced with the verified release artifact before these passes. Kept open only until the PR current-HEAD gates pass. - [ ] **D-C5 Loot item TOCTOU → duplication.** Slot marked looted *after* the async inventory - store; two concurrent looters both store it. `handlers/loot.rs:1143-1219`. C++ blocks the - slot *before* store. + store; two concurrent looters both store it. `handlers/loot.rs`. C++ instead gets safety from + object-owned `Loot` plus globally serialized `PROCESS_THREADUNSAFE` session work; it validates + storage before mutating the shared slot. + - 2026-07-18 issue #106 local slice: creatures/gameobjects now own a generation-tagged shared + loot authority; item/master/roll/disenchant paths use cancellation-safe leases whose detached + persistence worker owns the claim across SQL `COMMIT`. Session loot tables are packet caches, + and stale corpse/GO generations and stale group rolls fail closed. Kept open until the live + two-bot race, single-session capture, manual original-client QA, CI, and current-HEAD review + gates pass. - [ ] **D-C6 Loot money TOCTOU → duplication.** `loot.coins` zeroed *after* distribute; two - concurrent `handle_loot_money` both pay out. `handlers/loot.rs:1293-1356`. + concurrent `handle_loot_money` both pay out. `handlers/loot.rs`. + - 2026-07-18 issue #106 local slice: one detached worker atomically persists every connected, + allowed, in-range group share, commits the object-owned money claim, and then schedules one + exact-once runtime application per session without cross-session acknowledgement waits. A + cancelled packet future cannot reopen a successful DB transaction. Kept open for the same + runtime/review gates as D-C5. + - **Open crash boundary (not acceptance credit for #106):** these detached workers and their + completion trackers are in-process only. A runtime/process abort exactly after SQL `COMMIT` + but before the synchronous authority/completion continuation can still lose that continuation. + Closing `kill -9` recovery requires a durable claim journal written in the same transaction + and replayed at startup; neither the current authority nor the session tracker is that journal. - [ ] **D-C7 Player save has incomplete transaction coverage.** Issue #17 wraps the Rust-covered represented `Player::SaveToDB` character statements in one `SqlTransaction`, but full C++ save parity, login/account transaction coupling, capture diff, and manual live-client @@ -128,7 +145,9 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." ## MED — wrong values / loose checks / minor loss - [ ] **D-M1 Silent gold-save error.** `let _ = char_db.execute(stmt).await` swallows failures. `session.rs:21495`. -- [ ] **D-M2 Money split rounding loss.** Integer division discards remainder copper (C++ gives it to first recipient). `handlers/loot.rs:1323`. +- [x] **D-M2 was not a defect: C++ also discards group-money division remainder.** + `LootHandler.cpp::HandleLootMoneyOpcode` computes `loot->gold / playersNear.size()` and credits + that same truncated amount to every recipient; there is no first-recipient remainder branch. - [ ] **D-M3 Off-hand dual-wield damage has no penalty** (~25% too high). `session.rs:7923`. - [ ] **D-M4 Haste has no attack-speed cap** → scales unbounded. `session.rs:1358`. - [ ] **D-M5 Threat = raw damage**, no ability/role threat modifiers. `session.rs:47875`. diff --git a/docs/operations/pr-preflight.md b/docs/operations/pr-preflight.md index 86d11fbc0..a4d6dc89f 100644 --- a/docs/operations/pr-preflight.md +++ b/docs/operations/pr-preflight.md @@ -26,29 +26,135 @@ Printing `review` or `full` with `--dry-run` does not require Codex or its execu | `diff [BASE]` | Whitespace-check committed, staged, and unstaged changes | No | | `format` | Run harness self-tests and both formatting checks from CI | No | | `check` | Run locked core checks, bot check, and server builds from CI | No | -| `test` | Run the four focused library suites from CI | No | +| `test` | Run focused suites, loot-race tests, and the required capture gate from CI | No | | `ci` | Run `format`, `check`, and `test` | No | | `quick [BASE]` | Run `diff`, `format`, and `check` during iteration | No | -| `capture` | Test the committed C++↔Rust capture-diff fixtures without `protoc` | No | +| `capture` | Test committed captures and enforce required capture contracts without `protoc` | No | | `review [BASE]` | Review a clean committed diff with Codex in read-only mode | No | | `review-uncommitted` | Review staged, unstaged, and untracked work during iteration | No | -| `full [BASE]` | Run `diff`, `ci`, `capture`, and `review` | No | +| `full [BASE]` | Run `diff`, CI (including the capture gate), and `review` | No | | `stable` | Check/build server binaries with latest stable Rust | No | | `qa-login` | Run the integrated live login bot | **Yes** | +| `qa-loot-race` | Run the destructive two-session atomic-loot bot | **Yes** | `BASE` defaults to `origin/3.4.3`. Use `--dry-run` before a command to print its underlying commands without executing them or provisioning optional review tools. -`qa-login` is intentionally outside `full`. It requires running services and may create/update -local QA accounts and session data, so it refuses to run without explicit acknowledgement: +`qa-login` is intentionally outside `full`. It requires running services, may create missing +local QA auth rows, and writes normal login/session data, so it refuses to run without explicit +acknowledgement. Existing BNet/game identities and character ownership are validation-only: a +credential, numeric-ID, ownership, ban, online-state, or realm-count mismatch fails closed rather +than being rewritten. ```bash ./tools/pr-preflight.sh --allow-runtime-qa qa-login ``` +`qa-loot-race` additionally refuses to trust whichever process happens to be +listening or whichever bot an ignored `.env.local` happens to select. Pin both +the feature-branch `world-server` and `wow-test-bot` files plus their SHA-256 +digests. The +gate snapshots and accredits the current normal PM2 executable, drives the +repository's guarded `capture-rust.sh loot-two-session-atomic-race --yes` +through a FIFO, then accredits the replacement capture PID/path/hash and both +distinct listeners against that same PID before running the bot. The capture +identity must remain unchanged; +after the bot, the gate signals completion and waits for fixture cleanup plus +exact restoration of the original PM2 executable/profile. +The bot writes its report to a fresh private path; exit status zero is accepted +only when that report proves the exact two accounts, two successful logins, +party/target/loot observations, exactly one item winner, both removal fanouts, +money notifications of `10` and `0`, one persisted item, an exact persisted +money delta of `10`, and relog verification. The preflight passes that same +fresh path plus the pinned bot executable/hash to the guarded capture wrapper. +After stopping the capture world and restoring the fixture and normal PM2 +profile, the wrapper independently revalidates the complete report contract; +missing, symlinked, malformed, failed, wrong-identity, split-runtime-target, or +wrong-bot evidence prevents atomic dump/manifest publication even though +cleanup and normal-runtime restoration still complete. A successful race +artifact retains a mode-0600 copy as `rust/race.bot-report.json`; the manifest +records its SHA-256 and final absolute path together with non-null race fixture +and bot-evidence contracts. + +The preflight creates a private `WOW_BOT_FIXTURE_JOURNAL` path and forces +`WOW_BOT_ENSURE_TEST_ACCOUNTS=0`; loot QA therefore requires pre-provisioned +disposable accounts and never runs the generic account/character bootstrap. +The bot must durably journal before its first mutation, remove the pending +journal only after bounded restoration, and atomically create the mode-0600 +JSON marker `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The marker pins the +journal SHA-256 and cleanup PID. A pending journal, unsafe/malformed marker, or +digest mismatch prevents the normal PM2 world from restarting. Cleanup/restoration +failure takes precedence over the original bot/signal exit status, and the +recovery directory is retained for inspection. The preflight tracks the bot PID, +terminates and waits for it before capture cleanup on signals, and the wrapper +executes the bot with Linux parent-death protection so no detached child can +continue mutating the fixture. + +The bot is forced to loopback ports `8085`/`8086` (override the accredited +ports with `RUST_WORLD_PORT`/`RUST_INSTANCE_PORT`) and rejects a different +instance port advertised by `SMSG_CONNECT_TO`. Run this only on an isolated +host/network namespace whose firewall restricts the BNet/world/instance ports +to loopback, because the server process itself may use wildcard listeners: + +```bash +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +TARGET_EXEC="$(realpath /absolute/path/to/feature-branch/world-server)" +TARGET_SHA="$(sha256sum "$TARGET_EXEC" | awk '{print $1}')" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +BOT_SHA="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" +RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf \ +WOW_BOT_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +WOW_BOT_WORLD_EXEC="$TARGET_EXEC" \ +WOW_BOT_WORLD_EXEC_SHA256="$TARGET_SHA" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$BOT_SHA" \ +./tools/pr-preflight.sh --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race +``` + +`RUST_CAPTURE_DB_CONF` is the credential source used by the bounded fixture +guard; `RUST_CAPTURE_EFFECTIVE_CONFIG` is independently pinned to the exact +config selected by the PM2 cwd/argv profile. They are intentionally different +on the current host, and the capture wrapper fails closed if the latter does +not match PM2. + +`RUST_WORLD_PORT` and `RUST_INSTANCE_PORT` must be different. The fixture guard +also rejects orphan `gameobject_addon`, `gameobject_overrides`, `spawn_group`, +pool, event, or linked-respawn rows before claiming spawn `9106001`. Cleanup +checks the complete pinned spawn (including persisted `state`) and addon row +before its first write; it never resets state merely to make deletion pass. + +For a no-build QA run, also provide the independently pinned +`WOW_BOT_EXEC`/`WOW_BOT_EXEC_SHA256`. Caller-supplied runtime and bot variables +take precedence over ignored `.env.local` defaults. + Fresh C++ or Rust packet recording is also intentionally excluded: the capture scripts can -restart services and require an interactive client flow. The safe `capture` profile only tests -the fixtures already committed to the repository and does not invoke protobuf tooling. +restart services and require an interactive client flow. The `capture` profile does not invoke +protobuf tooling. It tests committed fixtures and runs `verify-required` for milestone contracts. +A required contract whose matched real C++/Rust artifacts have not been installed fails closed. +Issue #106's pre-lineage one-client loot claim still compares CLEAN at the strict six-packet +semantic layer, but it is intentionally `awaiting-real-captures`: it lacks the mandatory completed +RAW manifests and process/config lineage. A fresh import must validate those manifests, retain exact +copies, hash the exact reviewed selection and all installed outputs, and publish the complete flow +generation atomically before `verify-required` can pass. The separate two-client race remains +runtime evidence rather than a golden, because global packet logs merge concurrent sessions. +RAW capture-manifest schema v3 distinguishes PM2's configured entry +PID/start-time/path/hash/profile from the unique PID/start-time owning both +listeners: they may be identical for a direct Rust binary, while the legacy +non-`exec` shell wrapper requires a verified descendant listener. It also +attests that the capture runtime/process tree is absent, fixture cleanup was +verified, and the normal runtime is healthy before no-overwrite publication. +For `loot-single-item-claim`, guard, canonical TESTBOT2/TESTBOT3 fixture +identity, pinned bot executable, and a semantically validated successful bot +report are mandatory on both sides. C++ derives its effective config from the +PM2 argv/wrapper and requires an exact canonical `CPP_CONF` match. Required +recording also needs a clean committed RustyCore harness/source. The known-dirty +legacy C++ checkout is identified by HEAD plus an explicit dirty flag and +deterministic worktree-state digest; its pinned live listener-binary SHA-256 +remains primary. The shared private mode-0700 orchestration lock, output paths, +and raw packet inventory reject symlinks; DB password loading and both mysql +calls suppress `bash -x` so credentials and `MYSQL_PWD` cannot enter logs. ## Recommended maintainer flow diff --git a/tools/pr-preflight.sh b/tools/pr-preflight.sh index 987456480..f2799ddbe 100755 --- a/tools/pr-preflight.sh +++ b/tools/pr-preflight.sh @@ -6,9 +6,22 @@ POLICY_FILE="$REPO_ROOT/tools/codex-review-policy.md" SCHEMA_FILE="$REPO_ROOT/tools/codex-review-schema.json" PROTOC_VERSION_FILE="$REPO_ROOT/.protoc-version" DEFAULT_BASE="origin/3.4.3" +DEFAULT_RUST_MIN_STACK=268435456 CODEX_REVIEW_TIMEOUT_SECONDS="${CODEX_REVIEW_TIMEOUT_SECONDS:-1800}" DRY_RUN=0 ALLOW_RUNTIME_QA=0 +ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 +RUST_MIN_STACK="${RUST_MIN_STACK:-$DEFAULT_RUST_MIN_STACK}" +export RUST_MIN_STACK +QA_LOOT_RACE_CAPTURE_SCRIPT="$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" +QA_LOOT_RACE_CAPTURE_PID="" +QA_LOOT_RACE_CAPTURE_FD="" +QA_LOOT_RACE_CAPTURE_DIR="" +QA_LOOT_RACE_CAPTURE_LOG="" +QA_LOOT_RACE_FIXTURE_JOURNAL="" +QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 +QA_LOOT_RACE_BOT_PID="" +QA_LOOT_RACE_PRE_READY_MARGIN_SECONDS=120 usage() { cat <<'EOF' @@ -18,24 +31,26 @@ Usage: ./tools/pr-preflight.sh [OPTIONS] [BASE] Options: - --dry-run Print commands without running them. - --allow-runtime-qa Allow qa-login to touch local QA account/session data. - -h, --help Show this help. + --dry-run Print commands without running them. + --allow-runtime-qa Allow live QA commands to modify local QA data. + --ack-disposable-overworld-loot-race Acknowledge qa-loot-race mutates its exact shared-chest fixture. + -h, --help Show this help. Commands: self-test Test harness parsing and pinned-version invariants. format Run the two formatting checks used by GitHub Actions. check Run the locked core checks and server builds used by CI. - test Run the four focused library suites used by CI. + test Run focused suites, loot-race tests, and required capture gate used by CI. ci Run format, check, and test (the required Rust CI jobs). diff [BASE] Check committed, staged, and unstaged diffs for whitespace errors. quick [BASE] Run diff, format, and check. capture Run capture-diff regression tests (protoc not required). review [BASE] Review the clean committed diff with local Codex. review-uncommitted Review staged, unstaged, and untracked changes with local Codex. - full [BASE] Run diff, ci, capture, and review on a clean committed HEAD. + full [BASE] Run diff, CI (including capture), and review on a clean committed HEAD. stable Check/build the server binaries with latest stable Rust. qa-login Run the existing live login bot; requires --allow-runtime-qa. + qa-loot-race Run destructive live two-session loot QA; requires both QA flags. BASE defaults to origin/3.4.3. The GitHub Codex reviewer verdict remains required. EOF @@ -54,6 +69,13 @@ die() { exit 64 } +validate_rust_min_stack() { + [[ "$RUST_MIN_STACK" =~ ^[1-9][0-9]*$ ]] || die \ + "RUST_MIN_STACK must be a positive integer" + ((RUST_MIN_STACK >= DEFAULT_RUST_MIN_STACK)) || die \ + "RUST_MIN_STACK must be at least $DEFAULT_RUST_MIN_STACK bytes for Rust 1.88" +} + print_command() { printf '+' printf ' %q' "$@" @@ -71,6 +93,249 @@ require_command() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } +sha256_of_file() { + local output digest + output="$(sha256sum <"$1")" || return 1 + digest="${output%% *}" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s' "$digest" +} + +qa_world_identity() { + local process_name="$1" + local expected_exec="$2" + pm2 jlist | jq -er --arg name "$process_name" --arg expected_exec "$expected_exec" ' + [.[] | select(.name == $name)] as $matches + | if ($matches | length) != 1 then + error("PM2 world process must exist exactly once") + else $matches[0] end + | if .pm2_env.status != "online" + or (.pid // 0) <= 0 + or .pm2_env.pm_exec_path != $expected_exec + then error("PM2 world process is not the pinned online executable") + else [.pid, (.pm2_env.restart_time // 0)] | @tsv end + ' +} + +qa_world_snapshot() { + local process_name="$1" + pm2 jlist | jq -er --arg name "$process_name" ' + [.[] | select(.name == $name)] as $matches + | if ($matches | length) != 1 then + error("PM2 world process must exist exactly once") + else $matches[0] end + | if .pm2_env.status != "online" + or (.pid // 0) <= 0 + or (.pm2_env.pm_exec_path | type) != "string" + or .pm2_env.pm_exec_path == "" + then error("PM2 world process is not an online executable") + else [.pid, (.pm2_env.restart_time // 0), .pm2_env.pm_exec_path] | @tsv end + ' +} + +qa_world_process_matches() { + local identity="$1" + local expected_exec="$2" + local expected_sha="$3" + local pid proc_exe live_exec source_sha live_sha + + [[ "$identity" == *$'\t'* ]] || return 1 + pid="${identity%%$'\t'*}" + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + proc_exe="/proc/${pid}/exe" + [[ -L "$proc_exe" ]] || return 1 + live_exec="$(realpath -e -- "$proc_exe" 2>/dev/null)" || return 1 + [[ "$live_exec" == "$expected_exec" ]] || return 1 + source_sha="$(sha256_of_file "$expected_exec")" || return 1 + live_sha="$(sha256_of_file "$proc_exe")" || return 1 + [[ "$source_sha" == "$expected_sha" && "$live_sha" == "$expected_sha" ]] +} + +qa_world_ports_ready() { + local identity="$1" + local world_port="$2" + local instance_port="$3" + local pid + + [[ "$world_port" != "$instance_port" ]] || return 1 + pid="${identity%%$'\t'*}" + qa_port_owned_exclusively_by_pid "$world_port" "$pid" \ + && qa_port_owned_exclusively_by_pid "$instance_port" "$pid" +} + +qa_port_owned_exclusively_by_pid() { + local port="$1" + local pid="$2" + local sockets socket remaining seen_pid matched_pid + + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + sockets="$(ss -H -ltnp "sport = :${port}" 2>/dev/null)" || return 1 + [[ -n "$sockets" ]] || return 1 + while IFS= read -r socket; do + remaining="$socket" + seen_pid=0 + while [[ "$remaining" =~ pid=([0-9]+), ]]; do + matched_pid="${BASH_REMATCH[1]}" + [[ "$matched_pid" == "$pid" ]] || return 1 + seen_pid=1 + remaining="${remaining#*"${BASH_REMATCH[0]}"}" + done + ((seen_pid == 1)) || return 1 + done <<<"$sockets" +} + +qa_world_packet_dump_absent() { + local process_name="$1" + pm2 jlist | jq -e --arg name "$process_name" ' + [.[] | select(.name == $name)] as $matches + | ($matches | length) == 1 + and (($matches[0].pm2_env | has("RUSTYCORE_PACKET_DUMP_DIR")) | not) + and (((($matches[0].pm2_env.env // {}) + | has("RUSTYCORE_PACKET_DUMP_DIR"))) | not) + ' >/dev/null +} + +qa_loot_race_capture_cleanup() { + local status=$? + local bot_wait_status=0 + local wrapper_status="$QA_LOOT_RACE_CAPTURE_WAIT_STATUS" + local recovery_pending=0 + + trap - EXIT + trap '' HUP INT TERM + if [[ "$QA_LOOT_RACE_BOT_PID" =~ ^[1-9][0-9]*$ ]]; then + if kill -0 "$QA_LOOT_RACE_BOT_PID" 2>/dev/null; then + kill -TERM "$QA_LOOT_RACE_BOT_PID" 2>/dev/null || true + fi + wait "$QA_LOOT_RACE_BOT_PID" || bot_wait_status=$? + fi + QA_LOOT_RACE_BOT_PID="" + if [[ "$QA_LOOT_RACE_CAPTURE_PID" =~ ^[1-9][0-9]*$ ]]; then + if kill -0 "$QA_LOOT_RACE_CAPTURE_PID" 2>/dev/null; then + kill -TERM "$QA_LOOT_RACE_CAPTURE_PID" 2>/dev/null || true + fi + wait "$QA_LOOT_RACE_CAPTURE_PID" || wrapper_status=$? + fi + QA_LOOT_RACE_CAPTURE_PID="" + if [[ "$QA_LOOT_RACE_CAPTURE_FD" =~ ^[0-9]+$ ]]; then + exec {QA_LOOT_RACE_CAPTURE_FD}>&- || true + fi + QA_LOOT_RACE_CAPTURE_FD="" + # A wrapper cleanup/restoration failure is more important than the original + # bot/signal status because it means the normal world may be unsafe to start. + if ((wrapper_status != 0)); then + status=$wrapper_status + elif ((status == 0 && bot_wait_status != 0)); then + status=$bot_wait_status + fi + if [[ -n "$QA_LOOT_RACE_FIXTURE_JOURNAL" ]] \ + && { [[ -e "$QA_LOOT_RACE_FIXTURE_JOURNAL" \ + || -L "$QA_LOOT_RACE_FIXTURE_JOURNAL" ]] \ + || [[ -e "${QA_LOOT_RACE_FIXTURE_JOURNAL}.cleanup-complete" \ + || -L "${QA_LOOT_RACE_FIXTURE_JOURNAL}.cleanup-complete" ]]; }; then + recovery_pending=1 + fi + if ((status != 0)) && [[ -s "$QA_LOOT_RACE_CAPTURE_LOG" ]]; then + echo "qa-loot-race guarded capture log:" >&2 + sed -n '1,240p' "$QA_LOOT_RACE_CAPTURE_LOG" >&2 || true + fi + if [[ -n "$QA_LOOT_RACE_CAPTURE_DIR" && -d "$QA_LOOT_RACE_CAPTURE_DIR" ]]; then + rm -f -- "$QA_LOOT_RACE_CAPTURE_DIR/control.fifo" + if ((status == 0 && recovery_pending == 0)); then + rm -f -- "$QA_LOOT_RACE_CAPTURE_DIR/capture.log" + rmdir -- "$QA_LOOT_RACE_CAPTURE_DIR" 2>/dev/null || true + else + echo "qa-loot-race recovery artifacts retained at ${QA_LOOT_RACE_CAPTURE_DIR}" >&2 + fi + fi + QA_LOOT_RACE_CAPTURE_DIR="" + QA_LOOT_RACE_CAPTURE_LOG="" + QA_LOOT_RACE_FIXTURE_JOURNAL="" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + exit "$status" +} + +qa_loot_capture_ready_timeout_seconds() { + local world_ready_timeout="${1:-${CAPTURE_WORLD_READY_TIMEOUT_SECONDS:-180}}" + local world_stop_timeout="${2:-${CAPTURE_WORLD_STOP_TIMEOUT_SECONDS:-30}}" + + [[ "$world_ready_timeout" =~ ^[1-9][0-9]*$ ]] \ + && ((${#world_ready_timeout} <= 4)) \ + && ((10#$world_ready_timeout >= 3)) \ + && ((10#$world_ready_timeout <= 3600)) || { + echo "CAPTURE_WORLD_READY_TIMEOUT_SECONDS must be an integer from 3 through 3600" >&2 + return 1 + } + [[ "$world_stop_timeout" =~ ^[1-9][0-9]*$ ]] \ + && ((${#world_stop_timeout} <= 4)) \ + && ((10#$world_stop_timeout <= 3600)) || { + echo "CAPTURE_WORLD_STOP_TIMEOUT_SECONDS must be an integer from 1 through 3600" >&2 + return 1 + } + printf '%s\n' "$((world_ready_timeout + world_stop_timeout + QA_LOOT_RACE_PRE_READY_MARGIN_SECONDS))" +} + +qa_wait_for_loot_capture_ready() { + local capture_pid="$1" + local capture_log="$2" + local marker=">>> Perform the 'loot-two-session-atomic-race' flow with the client now." + local wrapper_status=0 + local timeout_seconds deadline + + timeout_seconds="$(qa_loot_capture_ready_timeout_seconds)" || return 1 + deadline=$((SECONDS + timeout_seconds)) + while ((SECONDS < deadline)); do + if [[ -f "$capture_log" ]] && rg -Fq -- "$marker" "$capture_log"; then + return 0 + fi + if ! kill -0 "$capture_pid" 2>/dev/null; then + wait "$capture_pid" || wrapper_status=$? + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=$wrapper_status + QA_LOOT_RACE_CAPTURE_PID="" + echo "guarded capture wrapper exited before its ready marker (status $wrapper_status)" >&2 + return 1 + fi + sleep 0.25 + done + echo "timed out waiting ${timeout_seconds} seconds for the guarded capture ready marker" >&2 + return 1 +} + +require_exact_occurrences() { + local text="$1" + local needle="$2" + local expected="$3" + local label="$4" + local count=0 + + [[ -n "$needle" ]] || die "cannot count an empty self-test pattern for $label" + while [[ "$text" == *"$needle"* ]]; do + text="${text#*"$needle"}" + ((count += 1)) + done + ((count == expected)) || die \ + "$label appeared $count time(s); expected exactly $expected" +} + +self_test_cleanup() { + local world_pid="${1:-}" + local artifacts="${2:-}" + + if [[ "$world_pid" =~ ^[1-9][0-9]*$ ]] && kill -0 "$world_pid" 2>/dev/null; then + kill "$world_pid" 2>/dev/null || true + wait "$world_pid" 2>/dev/null || true + fi + if [[ -n "$artifacts" && -d "$artifacts" ]]; then + rm -rf -- "$artifacts" + fi +} + +valid_tcp_port() { + local value="$1" + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + ((10#$value >= 1 && 10#$value <= 65535)) +} + toolchain_channel() { local channel channel="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' "$REPO_ROOT/rust-toolchain.toml" | head -n 1)" @@ -82,7 +347,9 @@ cargo_cmd() { local channel ((DRY_RUN)) || require_command cargo channel="$(toolchain_channel)" - run_cmd cargo "+$channel" "$@" + # Rust 1.88 can ICE while reloading a stale incremental dep graph. Keep the + # compile/test gate deterministic without leaking this setting into live QA. + run_cmd env CARGO_INCREMENTAL=0 cargo "+$channel" "$@" } project_protoc_version() { @@ -200,6 +467,17 @@ run_check() { -p world-server cargo_cmd check --locked --manifest-path tools/wow-test-bot/Cargo.toml cargo_cmd build --locked -p bnet-server -p world-server + cargo_cmd clippy --locked --no-deps --message-format short \ + -p wow-loot \ + -p wow-entities \ + -p wow-map \ + -p wow-network \ + --lib + cargo_cmd clippy --locked --no-deps --message-format short \ + -p wow-world \ + --lib \ + -- \ + --cap-lints warn } run_test() { @@ -207,8 +485,13 @@ run_test() { resolve_protoc cargo_cmd test --locked -p wow-data --lib cargo_cmd test --locked -p wow-packet --lib + cargo_cmd test --locked -p wow-loot --lib + cargo_cmd test --locked -p wow-entities --lib cargo_cmd test --locked -p wow-map --lib + cargo_cmd test --locked -p wow-network --lib cargo_cmd test --locked -p wow-world --lib + cargo_cmd test --locked --manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests + run_capture } run_ci() { @@ -238,6 +521,8 @@ run_quick() { run_capture() { log "Committed capture-diff regression gate" cargo_cmd test --locked -p capture-diff + cargo_cmd run --locked -p capture-diff -- \ + verify-required loot-single-item-claim } review_result() { @@ -468,6 +753,9 @@ run_self_test() { local artifacts local capture_output local clean_result + local cpp_capture_text + local cpp_missing_pin_output + local ci_dry_run_output local combined_inspection_result local committed_inspection_result local dependency @@ -475,15 +763,68 @@ run_self_test() { local echo_inspection_result local findings_result local full_dry_run_output + local github_workflow_text local incomplete_inspection_result local inspection_result local invalid_result + local loot_race_fake_bot + local loot_race_fake_bot_sha + local loot_race_malicious_env + local loot_race_wrapper_args + local loot_race_wrapper_args_file + local loot_race_wrapper_missing_ack_output + local loot_race_wrapper_xtrace_output + local loot_race_wrapper_output + local loot_race_secret_sentinel="rc106-secret-must-not-appear" local no_inspection_result local path_limited_inspection_result local protoc_output + local qa_fake_bin + local qa_fake_bot + local qa_fake_bot_marker + local qa_fake_bot_sha + local qa_fake_capture + local qa_fake_capture_publish_marker + local qa_fake_world + local qa_fake_world_other + local qa_identity + local qa_instance_port=45124 + local qa_loot_race_dry_run_output + local qa_loot_race_missing_ack_output + local qa_loot_race_missing_bot_pin_output + local qa_loot_race_missing_world_pin_output + local qa_pin_fixture + local qa_pin_fixture_sha + local qa_pm2_after_file + local qa_pm2_before_file + local qa_pm2_duplicate + local qa_pm2_json_file + local qa_pm2_offline + local qa_pm2_online + local qa_pm2_pid_changed + local qa_pm2_restart_changed + local qa_pm2_state_file + local qa_pm2_wrong_path + local qa_positive_output + local qa_cleanup_output + local qa_drift_output + local qa_early_output + local qa_early_status=0 + local qa_kill_output + local qa_restart_output + local qa_pid_output + local qa_term_output + local qa_world_pid="" + local qa_world_port=45123 + local qa_world_sha + local qa_target_sha local range_summary_inspection_result + local rust_capture_text + local rust_missing_pin_output + local rust_race_missing_bot_output local review_dry_run_output local staged_alias_inspection_result + local -a qa_common_env=() local rc=0 if ((DRY_RUN)); then @@ -494,10 +835,17 @@ run_self_test() { fi require_command python3 + [[ "$(qa_loot_capture_ready_timeout_seconds 180 30)" == 330 ]] || die \ + "loot-race pre-ready timeout does not dominate explicit wrapper budgets" + if qa_loot_capture_ready_timeout_seconds 2 30 >/dev/null 2>&1 \ + || qa_loot_capture_ready_timeout_seconds 180 3601 >/dev/null 2>&1; then + die "loot-race pre-ready timeout accepted an invalid inner timeout" + fi project_protoc_version >/dev/null python3 -m json.tool "$SCHEMA_FILE" >/dev/null || die "invalid Codex review JSON schema" artifacts="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-preflight-self-test.XXXXXX")" + trap 'self_test_cleanup "${qa_world_pid:-}" "${artifacts:-}"' EXIT clean_result="$artifacts/clean.json" findings_result="$artifacts/findings.json" invalid_result="$artifacts/invalid.json" @@ -584,10 +932,11 @@ run_self_test() { [[ "$rc" == "65" ]] || die "staged alias inspection self-test returned $rc instead of 65" mkdir -p "$artifacts/bin" - for dependency in awk dirname git head sed tr; do + for dependency in awk dirname env git head realpath sed sha256sum tr; do ln -s "$(command -v "$dependency")" "$artifacts/bin/$dependency" done - printf '#!/bin/sh\nexit 0\n' >"$artifacts/bin/cargo" + printf '#!/bin/sh\n[ "${RUST_MIN_STACK:-0}" -ge %s ] || exit 70\n[ "${CARGO_INCREMENTAL:-}" = 0 ] || exit 71\nexit 0\n' \ + "$DEFAULT_RUST_MIN_STACK" >"$artifacts/bin/cargo" expected_protoc_version="$(project_protoc_version)" printf '#!/bin/sh\nprintf "libprotoc %s\\n"\n' \ "$expected_protoc_version" >"$artifacts/bin/protoc" @@ -602,8 +951,1734 @@ run_self_test() { capture_output="$(PATH="$artifacts/bin" PROTOC="$artifacts/missing-protoc" \ "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" capture 2>&1)" || die \ "capture profile unexpectedly requires protoc" - [[ "$capture_output" == *"test --locked -p capture-diff"* ]] || die \ - "capture profile did not run the capture-diff tests" + require_exact_occurrences "$capture_output" \ + "test --locked -p capture-diff" 1 \ + "capture profile capture-diff test command" + require_exact_occurrences "$capture_output" \ + "verify-required loot-single-item-claim" 1 \ + "capture profile required-flow command" + + ci_dry_run_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run ci 2>&1)" || die \ + "CI dry-run unexpectedly requires optional execution tools" + [[ "$ci_dry_run_output" == *"clippy --locked --no-deps --message-format short -p wow-loot"* ]] || die \ + "CI profile did not print the loot-authority clippy command" + [[ "$ci_dry_run_output" == *"clippy --locked --no-deps --message-format short -p wow-world --lib -- --cap-lints warn"* ]] || die \ + "CI profile did not print the capped wow-world clippy command" + [[ "$ci_dry_run_output" == *"test --locked -p wow-loot --lib"* ]] || die \ + "CI profile did not print the wow-loot tests" + [[ "$ci_dry_run_output" == *"test --locked -p wow-entities --lib"* ]] || die \ + "CI profile did not print the wow-entities tests" + [[ "$ci_dry_run_output" == *"test --locked -p wow-network --lib"* ]] || die \ + "CI profile did not print the wow-network tests" + [[ "$ci_dry_run_output" == *"--manifest-path tools/wow-test-bot/Cargo.toml loot_race::tests"* ]] || die \ + "CI profile did not print the focused loot-race harness tests" + [[ "$ci_dry_run_output" == *"+ env CARGO_INCREMENTAL=0 cargo +1.88.0 test --locked -p wow-world --lib"* ]] || die \ + "local CI profile did not disable Rust 1.88 incremental compilation" + require_exact_occurrences "$ci_dry_run_output" \ + "test --locked -p capture-diff" 1 \ + "local CI capture-diff test command" + require_exact_occurrences "$ci_dry_run_output" \ + "verify-required loot-single-item-claim" 1 \ + "local CI required-flow command" + [[ "$ci_dry_run_output" != *"WOW_BOT_LOOT_RACE_SMOKE=1"* ]] || die \ + "normal CI profile must never activate destructive live loot-race QA" + + github_workflow_text="$(<"$REPO_ROOT/.github/workflows/rust-ci.yml")" + require_exact_occurrences "$github_workflow_text" \ + 'CARGO_INCREMENTAL: "0"' 2 \ + "GitHub workflow non-incremental Rust 1.88 contract" + require_exact_occurrences "$github_workflow_text" \ + "cargo +1.88.0 test --locked -p capture-diff" 1 \ + "GitHub workflow capture-diff test command" + require_exact_occurrences "$github_workflow_text" \ + "cargo +1.88.0 run --locked -p capture-diff -- verify-required loot-single-item-claim" 1 \ + "GitHub workflow required-flow command" + + if qa_loot_race_missing_ack_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run --allow-runtime-qa \ + qa-loot-race 2>&1)"; then + die "qa-loot-race accepted live mutation without its destructive acknowledgement" + fi + [[ "$qa_loot_race_missing_ack_output" == *"--ack-disposable-overworld-loot-race"* ]] || die \ + "qa-loot-race missing-ack error did not name the required acknowledgement" + + qa_loot_race_dry_run_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)" || die \ + "acknowledged qa-loot-race dry-run failed" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_SMOKE=1"* ]] || die \ + "qa-loot-race did not select the loot-race bot mode" + [[ "$qa_loot_race_dry_run_output" != *"CARGO_INCREMENTAL=0"* ]] || die \ + "qa-loot-race must not inherit compile-only incremental settings" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC="* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_EXEC_SHA256="* ]] || die \ + "qa-loot-race dry-run did not disclose the pinned bot provenance" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_ENSURE_TEST_ACCOUNTS=0"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_FIXTURE_JOURNAL="* ]] || die \ + "qa-loot-race did not disable identity bootstrap and disclose its recovery journal" + [[ "$qa_loot_race_dry_run_output" == *"WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1"* ]] || die \ + "qa-loot-race did not forward the destructive acknowledgement" + [[ "$qa_loot_race_dry_run_output" == *"run_rustycore_login_smoke.sh"* ]] || die \ + "qa-loot-race did not invoke the live QA wrapper" + [[ "$qa_loot_race_dry_run_output" == *"capture-rust.sh loot-two-session-atomic-race --yes"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_LOOT_FIXTURE_GUARD=1"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_REPORT=/tmp/rustycore-loot-race-qa/bot-report.json"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf"* \ + && "$qa_loot_race_dry_run_output" == *"RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf"* \ + && "$qa_loot_race_dry_run_output" == *"wait for guarded capture READY marker"* \ + && "$qa_loot_race_dry_run_output" == *"wait for exact PM2/fixture restoration"* ]] || die \ + "qa-loot-race dry-run did not disclose its guarded capture lifecycle" + [[ "$qa_loot_race_dry_run_output" == *"WORLD_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"WORLD_PORT=8085"* \ + && "$qa_loot_race_dry_run_output" == *"INSTANCE_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"INSTANCE_PORT=8086"* ]] || die \ + "qa-loot-race dry-run did not pin the bot to the accredited local listeners" + [[ "$qa_loot_race_dry_run_output" == *"BNET_HOST=127.0.0.1"* \ + && "$qa_loot_race_dry_run_output" == *"BNET_PORT=8081"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001"* \ + && "$qa_loot_race_dry_run_output" == *"WOW_BOT_LOOT_RACE_ITEM_ENTRY=38"* ]] || die \ + "qa-loot-race dry-run did not pin BNet and the exact disposable fixture" + + if qa_loot_race_missing_world_pin_output="$(PATH="$artifacts/bin" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)"; then + die "qa-loot-race accepted an unpinned running world server" + fi + [[ "$qa_loot_race_missing_world_pin_output" == *"WOW_BOT_WORLD_EXEC"* ]] || die \ + "qa-loot-race missing-world-pin error did not name WOW_BOT_WORLD_EXEC" + + qa_pin_fixture="$artifacts/bin/cargo" + qa_pin_fixture_sha="$(sha256sum "$qa_pin_fixture" | awk '{print $1}')" + if qa_loot_race_missing_bot_pin_output="$(PATH="$artifacts/bin" \ + WOW_BOT_WORLD_EXEC="$qa_pin_fixture" \ + WOW_BOT_WORLD_EXEC_SHA256="$qa_pin_fixture_sha" \ + "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race 2>&1)"; then + die "qa-loot-race accepted an unpinned bot executable" + fi + [[ "$qa_loot_race_missing_bot_pin_output" == *"WOW_BOT_EXEC"* ]] || die \ + "qa-loot-race missing-bot-pin error did not name WOW_BOT_EXEC" + + if loot_race_wrapper_missing_ack_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 \ + WOW_BOT_ENV_FILE=/dev/null \ + "$BASH" "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1)"; then + die "loot-race wrapper accepted destructive mode without acknowledgement" + fi + [[ "$loot_race_wrapper_missing_ack_output" == *"WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1"* ]] || die \ + "loot-race wrapper missing-ack error did not name the required acknowledgement" + [[ "$loot_race_wrapper_missing_ack_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret while loading defaults" + loot_race_wrapper_xtrace_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0 \ + WOW_BOT_ENV_FILE=/dev/null \ + "$BASH" -x "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1 || true)" + [[ "$loot_race_wrapper_xtrace_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret when invoked with bash -x" + + loot_race_fake_bot="$artifacts/fake-loot-race-bot" + loot_race_malicious_env="$artifacts/malicious.env.local" + loot_race_wrapper_args_file="$artifacts/loot-race-wrapper-args" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" >"$WOW_BOT_SELF_TEST_ARGS"\n' >"$loot_race_fake_bot" + printf '%s\n' \ + 'set -x' \ + 'WOW_BOT_LOOT_RACE_SMOKE=0' \ + 'WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=0' \ + 'WOW_BOT_EXEC=/tmp/not-the-pinned-bot' \ + 'WOW_BOT_EXEC_SHA256=0000000000000000000000000000000000000000000000000000000000000000' \ + >"$loot_race_malicious_env" + printf 'WOW_BOT_SELF_TEST_ARGS=%q\nWOW_BOT_REPORT=%q\nWOW_BOT_LOG=%q\n' \ + "$artifacts/not-the-pinned-args" \ + "$artifacts/not-the-pinned-report" \ + "$artifacts/not-the-pinned-log" >>"$loot_race_malicious_env" + chmod +x "$loot_race_fake_bot" + loot_race_fake_bot_sha="$(sha256sum "$loot_race_fake_bot" | awk '{print $1}')" + loot_race_wrapper_output="$(PATH="$artifacts/bin" \ + WOW_BOT_PASSWORD="$loot_race_secret_sentinel" WOW_BOT_GENERATE_LOCAL_PASSWORD=0 \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=1 WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=WRONG1@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=WRONG2@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=9999 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9998 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=77 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=9997 \ + WOW_BOT_FIXTURE_JOURNAL="$artifacts/standalone-loot-race.journal" \ + WOW_BOT_ENV_FILE="$loot_race_malicious_env" \ + WOW_BOT_EXEC="$loot_race_fake_bot" WOW_BOT_EXEC_SHA256="$loot_race_fake_bot_sha" \ + WOW_BOT_REPORT="$artifacts/loot-race-report.json" \ + WOW_BOT_LOG="$artifacts/loot-race.log" \ + WOW_BOT_SELF_TEST_ARGS="$loot_race_wrapper_args_file" \ + "$BASH" "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" 2>&1)" || die \ + "acknowledged loot-race wrapper self-test failed" + [[ "$loot_race_wrapper_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret while execing the bot" + loot_race_wrapper_args="$(<"$loot_race_wrapper_args_file")" + [[ "$loot_race_wrapper_args" == *"--loot-race-smoke"* ]] || die \ + "loot-race wrapper did not pass its bot mode" + [[ "$loot_race_wrapper_args" == *"--ack-disposable-overworld-loot-race"* ]] || die \ + "loot-race wrapper did not translate its acknowledgement to the CLI guard" + [[ "$loot_race_wrapper_args" == *$'--loot-race-gameobject-entry\n2846\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-gameobject-spawn-guid\n9106001\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-item-entry\n38\n'* ]] || die \ + "loot-race wrapper did not pin the Tattered Chest 2846/9106001/item-38 defaults" + [[ "$loot_race_wrapper_args" == *$'--loot-race-account-a\nTESTBOT2@bot.local\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-account-b\nTESTBOT3@bot.local\n'* \ + && "$loot_race_wrapper_args" == *$'--loot-race-runtime-counter\n0\n'* ]] || die \ + "loot-race wrapper allowed hostile environment overrides of its disposable identities/runtime counter" + [[ "$loot_race_wrapper_args" != *"--single"* ]] || die \ + "loot-race wrapper incorrectly reduced the two-session flow to one account" + [[ "$loot_race_wrapper_args" != *"--ensure-test-accounts"* ]] || die \ + "loot-race wrapper did not force the destructive identity bootstrap off" + [[ -f "$artifacts/loot-race.log" && ! -e "$artifacts/not-the-pinned-args" ]] || die \ + "loot-race wrapper allowed .env.local to replace caller-pinned QA inputs" + [[ "$loot_race_wrapper_output" != *"$loot_race_secret_sentinel"* ]] || die \ + "loot-race wrapper exposed a caller secret through env-file xtrace" + + require_command cp + require_command jq + require_command rg + require_command sleep + ( + # Exercise the SQL/journal guard shared by capture-cpp.sh and + # capture-rust.sh without touching a real database or service. + # shellcheck source=crates/capture-diff/scripts/loot-fixture-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/loot-fixture-common.sh" + fixture_guard_dir="$artifacts/common-fixture-guard" + fixture_health_state="$fixture_guard_dir/health" + mkdir -m 700 "$fixture_guard_dir" + WOW_BOT_FIXTURE_JOURNAL="$fixture_guard_dir/fixture.journal" + LOOT_FIXTURE_CLEANUP_MARKER="" + LOOT_FIXTURE_GUARD_ENABLED=1 + LOOT_FIXTURE_ENTRY=21779 + LOOT_FIXTURE_EXPECTED_HEALTH_MODIFIER=1 + LOOT_FIXTURE_TEMP_HEALTH_MODIFIER=0.0001 + LOOT_FIXTURE_SNAPSHOT_READY=0 + + validate_fresh_loot_fixture_journal + : >"$WOW_BOT_FIXTURE_JOURNAL" + if validate_fresh_loot_fixture_journal >/dev/null 2>&1; then + exit 80 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + chmod 755 "$fixture_guard_dir" + if validate_fresh_loot_fixture_journal >/dev/null 2>&1; then + exit 146 + fi + chmod 700 "$fixture_guard_dir" + validate_fresh_loot_fixture_journal + loot_fixture_bot_cleanup_safe_for_capture_state 0 + if loot_fixture_bot_cleanup_safe_for_capture_state 2 >/dev/null 2>&1; then + exit 147 + fi + ln -s "$fixture_guard_dir/missing-journal" "$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 148 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + : >"$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 152 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" + ln -s "$fixture_guard_dir/missing-marker" "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 149 + fi + rm -f "$LOOT_FIXTURE_CLEANUP_MARKER" + + printf '%s\n' \ + '{"version":1,"journal_sha256":"0000000000000000000000000000000000000000000000000000000000000000","cleanup_pid":123}' \ + >"$LOOT_FIXTURE_CLEANUP_MARKER" + chmod 600 "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 0 >/dev/null 2>&1; then + exit 150 + fi + loot_fixture_bot_cleanup_safe_for_capture_state 1 + loot_fixture_bot_cleanup_complete + chmod 644 "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_complete >/dev/null 2>&1; then + exit 81 + fi + chmod 600 "$LOOT_FIXTURE_CLEANUP_MARKER" + : >"$WOW_BOT_FIXTURE_JOURNAL" + if loot_fixture_bot_cleanup_complete >/dev/null 2>&1; then + exit 82 + fi + rm -f "$WOW_BOT_FIXTURE_JOURNAL" "$LOOT_FIXTURE_CLEANUP_MARKER" + if loot_fixture_bot_cleanup_safe_for_capture_state 1 >/dev/null 2>&1; then + exit 151 + fi + LOOT_FIXTURE_GUARD_ENABLED=0 + loot_fixture_bot_cleanup_safe_for_capture_state invalid + LOOT_FIXTURE_GUARD_ENABLED=1 + + printf '%s\n' 1 >"$fixture_health_state" + loot_fixture_character_mysql() { + printf '%s\n' 0 + } + loot_fixture_world_mysql() { + local query="${2:-}" + local state + state="$(<"$fixture_health_state")" + if [[ "$query" == *"UPDATE creature_template_difficulty"* \ + && "$query" == *"SET HealthModifier = 0.0001"* ]]; then + if [[ "$state" == 1 ]]; then + printf '%s\n' 0.0001 >"$fixture_health_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"UPDATE creature_template_difficulty"* \ + && "$query" == *"SET HealthModifier = 1"* ]]; then + if [[ "$state" == 0.0001 ]]; then + printf '%s\n' 1 >"$fixture_health_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT COUNT(*)"* \ + && "$query" == *"HealthModifier - 0.0001"* ]]; then + [[ "$state" == 0.0001 ]] && printf '%s\n' 1 || printf '%s\n' 0 + elif [[ "$query" == *"SELECT COUNT(*)"* \ + && "$query" == *"HealthModifier - 1"* ]]; then + [[ "$state" == 1 ]] && printf '%s\n' 1 || printf '%s\n' 0 + else + return 83 + fi + } + loot_fixture_wait_until_all_characters_offline + apply_creature_health_fixture_guard >/dev/null + [[ "$(<"$fixture_health_state")" == 0.0001 \ + && "$LOOT_FIXTURE_SNAPSHOT_READY" == 1 ]] || exit 84 + restore_creature_health_fixture_guard >/dev/null + [[ "$(<"$fixture_health_state")" == 1 \ + && "$LOOT_FIXTURE_SNAPSHOT_READY" == 0 ]] || exit 85 + + printf '%s\n' 2 >"$fixture_health_state" + LOOT_FIXTURE_SNAPSHOT_READY=1 + if restore_creature_health_fixture_guard >/dev/null 2>&1; then + exit 86 + fi + [[ "$(<"$fixture_health_state")" == 2 ]] || exit 87 + ) || die "shared C++/Rust loot-fixture guard self-test failed" + + ( + # Exercise the exact shared-chest cleanup implementation without sourcing + # capture-rust.sh's executable entrypoint or touching a real database. + capture_rust_script="$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" + shared_chest_functions="$artifacts/shared-chest-restore-functions.sh" + shared_chest_state_dir="$artifacts/shared-chest-restore-state" + mkdir -m 700 "$shared_chest_state_dir" + + extract_capture_function() { + local function_name="$1" + awk -v signature="${function_name}() {" ' + $0 == signature { copying = 1 } + copying { print } + copying && $0 == "}" { found = 1; exit } + END { if (!found) exit 1 } + ' "$capture_rust_script" + } + + : >"$shared_chest_functions" + for function_name in \ + restore_loot_fixture_guard \ + shared_chest_spawn_exact_count \ + shared_chest_spawn_cleanup_counts \ + shared_chest_addon_exact_count \ + shared_chest_spawn_metadata_counts \ + restore_shared_chest_fixture_guard; do + extract_capture_function "$function_name" \ + >>"$shared_chest_functions" || exit 157 + done + # shellcheck disable=SC1090 + source "$shared_chest_functions" + + LOOT_FIXTURE_KIND=shared-chest + LOOT_FIXTURE_CHEST_GUID=9106001 + LOOT_FIXTURE_CHEST_TEMPLATE_ENTRY=2846 + LOOT_FIXTURE_CHEST_ADDON_FACTION=101 + shared_chest_spawn_state="$shared_chest_state_dir/spawn" + shared_chest_addon_state="$shared_chest_state_dir/addon" + shared_chest_respawn_state="$shared_chest_state_dir/respawn" + shared_chest_metadata_state="$shared_chest_state_dir/metadata" + shared_chest_write_log="$shared_chest_state_dir/writes" + shared_chest_failure_file="$shared_chest_state_dir/failure" + + reset_shared_chest_restore_state() { + local spawn="$1" + local addon="$2" + local respawn="$3" + local metadata="$4" + printf '%s\n' "$spawn" >"$shared_chest_spawn_state" + printf '%s\n' "$addon" >"$shared_chest_addon_state" + printf '%s\n' "$respawn" >"$shared_chest_respawn_state" + printf '%s\n' "$metadata" >"$shared_chest_metadata_state" + : >"$shared_chest_write_log" + : >"$shared_chest_failure_file" + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=1 + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=1 + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=1 + } + + loot_fixture_world_mysql() { + local query="${2:-}" + local expected state + if [[ "$query" == *"DELETE FROM gameobject"* ]]; then + printf '%s\n' delete-spawn >>"$shared_chest_write_log" + for expected in \ + "WHERE guid = 9106001" \ + "AND id = 2846" \ + "AND map = 0" \ + "AND zoneId = 0" \ + "AND areaId = 0" \ + "AND spawnDifficulties = '0'" \ + "AND phaseUseFlags = 0" \ + "AND PhaseId = 0" \ + "AND PhaseGroup = 0" \ + "AND terrainSwapMap = -1" \ + "AND position_x = CAST(-8946.95 AS FLOAT)" \ + "AND position_y = CAST(-132.493 AS FLOAT)" \ + "AND position_z = CAST(83.5312 AS FLOAT)" \ + "AND orientation = 0" \ + "AND rotation0 = 0" \ + "AND rotation1 = 0" \ + "AND rotation2 = 0" \ + "AND rotation3 = 0" \ + "AND spawntimesecs = 300" \ + "AND animprogress = 255" \ + "AND state = 1" \ + "AND ScriptName = ''" \ + "AND StringId IS NULL" \ + "AND VerifiedBuild = 0"; do + [[ "$query" == *"$expected"* ]] || return 169 + done + state="$(<"$shared_chest_spawn_state")" + if [[ "$state" == exact || "$state" == exact-verify-fails ]]; then + if [[ "$state" == exact-verify-fails ]]; then + printf '%s\n' absent-fail-once >"$shared_chest_spawn_state" + else + printf '%s\n' absent >"$shared_chest_spawn_state" + fi + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"UPDATE gameobject_template_addon"* ]]; then + printf '%s\n' restore-addon >>"$shared_chest_write_log" + for expected in \ + "SET mingold = 0, maxgold = 0" \ + "WHERE entry = 2846" \ + "AND faction = 101" \ + "AND flags = 0" \ + "AND mingold = 10" \ + "AND maxgold = 10" \ + "AND artkit0 = 0" \ + "AND artkit1 = 0" \ + "AND artkit2 = 0" \ + "AND artkit3 = 0" \ + "AND artkit4 = 0" \ + "AND WorldEffectID = 0" \ + "AND AIAnimKitID = 0"; do + [[ "$query" == *"$expected"* ]] || return 170 + done + state="$(<"$shared_chest_addon_state")" + if [[ "$state" == 10 ]]; then + printf '%s\n' 0 >"$shared_chest_addon_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"COALESCE(SUM("* \ + && "$query" == *"FROM gameobject"* ]]; then + for expected in \ + "id = 2846" \ + "AND map = 0" \ + "AND zoneId = 0" \ + "AND areaId = 0" \ + "AND spawnDifficulties = '0'" \ + "AND phaseUseFlags = 0" \ + "AND PhaseId = 0" \ + "AND PhaseGroup = 0" \ + "AND terrainSwapMap = -1" \ + "AND position_x = CAST(-8946.95 AS FLOAT)" \ + "AND position_y = CAST(-132.493 AS FLOAT)" \ + "AND position_z = CAST(83.5312 AS FLOAT)" \ + "AND orientation = 0" \ + "AND rotation0 = 0" \ + "AND rotation1 = 0" \ + "AND rotation2 = 0" \ + "AND rotation3 = 0" \ + "AND spawntimesecs = 300" \ + "AND animprogress = 255" \ + "AND state = 1" \ + "AND ScriptName = ''" \ + "AND StringId IS NULL" \ + "AND VerifiedBuild = 0" \ + "FROM pool_members + WHERE type = 1 AND spawnId = 9106001" \ + "FROM game_event_gameobject + WHERE guid = 9106001" \ + "FROM linked_respawn + WHERE guid = 9106001 + OR linkedGuid = 9106001" \ + "FROM gameobject_addon + WHERE guid = 9106001" \ + "FROM gameobject_overrides + WHERE spawnId = 9106001" \ + "FROM spawn_group + WHERE spawnType = 1 AND spawnId = 9106001" \ + "FROM gameobject + WHERE guid = 9106001"; do + [[ "$query" == *"$expected"* ]] || return 179 + done + state="$(<"$shared_chest_spawn_state")" + case "$state" in + exact|exact-verify-fails) + printf '1\t1\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + absent) + printf '0\t0\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + absent-fail-once) + printf '%s\n' absent >"$shared_chest_spawn_state" + return 171 + ;; + *) + printf '1\t0\t%s\n' "$(<"$shared_chest_metadata_state")" + ;; + esac + elif [[ "$query" == *"FROM pool_members"* ]]; then + for expected in \ + "FROM pool_members + WHERE type = 1 AND spawnId = 9106001" \ + "FROM game_event_gameobject + WHERE guid = 9106001" \ + "FROM linked_respawn + WHERE guid = 9106001 + OR linkedGuid = 9106001" \ + "FROM gameobject_addon + WHERE guid = 9106001" \ + "FROM gameobject_overrides + WHERE spawnId = 9106001" \ + "FROM spawn_group + WHERE spawnType = 1 AND spawnId = 9106001"; do + [[ "$query" == *"$expected"* ]] || return 179 + done + cat "$shared_chest_metadata_state" + elif [[ "$query" == *"FROM gameobject_template_addon"* ]]; then + for expected in \ + "WHERE entry = 2846" \ + "AND faction = 101" \ + "AND flags = 0" \ + "AND artkit0 = 0" \ + "AND artkit1 = 0" \ + "AND artkit2 = 0" \ + "AND artkit3 = 0" \ + "AND artkit4 = 0" \ + "AND WorldEffectID = 0" \ + "AND AIAnimKitID = 0"; do + [[ "$query" == *"$expected"* ]] || return 180 + done + state="$(<"$shared_chest_addon_state")" + if [[ "$state" == 0 \ + && "$query" == *"mingold = 0"* \ + && "$query" == *"maxgold = 0"* ]] \ + || [[ "$state" == 10 \ + && "$query" == *"mingold = 10"* \ + && "$query" == *"maxgold = 10"* ]]; then + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT COUNT(*) FROM gameobject"* \ + && "$query" == *"AND id ="* ]]; then + state="$(<"$shared_chest_spawn_state")" + [[ "$state" == exact || "$state" == exact-verify-fails ]] \ + && printf '%s\n' 1 || printf '%s\n' 0 + elif [[ "$query" == *"SELECT COUNT(*) FROM gameobject"* ]]; then + state="$(<"$shared_chest_spawn_state")" + case "$state" in + absent) printf '%s\n' 0 ;; + absent-fail-once) + printf '%s\n' absent >"$shared_chest_spawn_state" + return 171 + ;; + *) printf '%s\n' 1 ;; + esac + else + return 158 + fi + } + + loot_fixture_character_mysql() { + local query="${2:-}" + local state + state="$(<"$shared_chest_respawn_state")" + if [[ "$query" == *"DELETE FROM respawn"* ]]; then + printf '%s\n' delete-respawn >>"$shared_chest_write_log" + if [[ "$state" == generated \ + && "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* \ + && "$query" == *"respawnTime = 123456"* \ + && "$query" == *"mapId = 0"* \ + && "$query" == *"instanceId = 0"* ]]; then + printf '%s\n' none >"$shared_chest_respawn_state" + printf '%s\n' 1 + else + printf '%s\n' 0 + fi + elif [[ "$query" == *"SELECT respawnTime FROM respawn"* ]]; then + [[ "$state" == generated \ + && "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* \ + && "$query" == *"mapId = 0"* \ + && "$query" == *"instanceId = 0"* ]] \ + && printf '%s\n' 123456 + elif [[ "$query" == *"SELECT COUNT(*) FROM respawn"* ]]; then + [[ "$query" == *"WHERE type = 1"* \ + && "$query" == *"spawnId = 9106001"* ]] || return 172 + case "$state" in + none) printf '%s\n' 0 ;; + generated) printf '%s\n' 1 ;; + drift) printf '%s\n' 2 ;; + *) return 159 ;; + esac + else + return 160 + fi + } + + reset_shared_chest_restore_state exact 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_respawn_state")" == none \ + && "$(<"$shared_chest_write_log")" == $'delete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 161 + + reset_shared_chest_restore_state exact 10 generated $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_respawn_state")" == none \ + && "$(<"$shared_chest_write_log")" == $'delete-respawn\ndelete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 162 + + # The spawn flag is armed before INSERT. Absence is already the exact + # postcondition and must not block restoration of the addon mutation. + reset_shared_chest_restore_state absent 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_write_log")" == restore-addon \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 173 + + # Simulate DELETE committing while its immediate COUNT verification fails. + # A fresh final reconciliation must observe absence and disarm cleanup. + reset_shared_chest_restore_state exact-verify-fails 10 none $'0\t0\t0\t0\t0\t0' + restore_loot_fixture_guard >/dev/null 2>&1 + [[ "$(<"$shared_chest_spawn_state")" == absent \ + && "$(<"$shared_chest_addon_state")" == 0 \ + && "$(<"$shared_chest_write_log")" == $'delete-spawn\nrestore-addon' \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY" == 0 \ + && "$LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY" == 0 ]] || exit 174 + + reset_shared_chest_restore_state exact 10 none $'1\t0\t0\t0\t0\t0' + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 163 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_spawn_state")" == exact \ + && "$shared_chest_failure" == *"spawn/state/metadata drifted"* ]] || exit 164 + + reset_shared_chest_restore_state exact drift none $'0\t0\t0\t0\t0\t0' + LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY=0 + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 165 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_addon_state")" == drift \ + && "$shared_chest_failure" == *"template addon drifted"* ]] || exit 166 + + reset_shared_chest_restore_state absent 0 drift $'0\t0\t0\t0\t0\t0' + LOOT_FIXTURE_CHEST_SPAWN_DELETE_READY=0 + LOOT_FIXTURE_CHEST_ADDON_RESTORE_READY=0 + if restore_loot_fixture_guard > /dev/null 2>"$shared_chest_failure_file"; then + exit 167 + fi + shared_chest_failure="$(<"$shared_chest_failure_file")" + [[ ! -s "$shared_chest_write_log" \ + && "$(<"$shared_chest_respawn_state")" == drift \ + && "$LOOT_FIXTURE_CHEST_RESPAWN_DELETE_READY" == 1 \ + && "$shared_chest_failure" == *"unexpected respawn ownership"* ]] || exit 168 + ) || die "shared-chest exact restore self-test failed" + + ( + # Prove both supported topologies: a direct Rust PM2 entry/listener and a + # non-exec C++ shell wrapper with one descendant owning both listeners. + # Reject changed ancestry, changed/foreign listeners, duplicate PM2 + # identity, and incomplete stopped state before DB restoration. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + unset CAPTURE_WORLD_STOP_TIMEOUT_SECONDS CAPTURE_WORLD_READY_TIMEOUT_SECONDS + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + [[ "$CAPTURE_WORLD_STOP_TIMEOUT_SECONDS" == 30 \ + && "$CAPTURE_WORLD_READY_TIMEOUT_SECONDS" == 180 ]] || exit 153 + capture_validate_world_timeouts + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS=0 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 154 + fi + CAPTURE_WORLD_STOP_TIMEOUT_SECONDS=30 + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=2 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 156 + fi + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=3601 + if capture_validate_world_timeouts >/dev/null 2>&1; then + exit 155 + fi + CAPTURE_WORLD_READY_TIMEOUT_SECONDS=180 + capture_validate_world_timeouts + capture_fixture_cleanup_verified_for_publication 0 0 || exit 175 + capture_fixture_cleanup_verified_for_publication 0 1 || exit 176 + capture_fixture_cleanup_verified_for_publication 1 1 || exit 177 + if capture_fixture_cleanup_verified_for_publication 1 0 \ + || capture_fixture_cleanup_verified_for_publication invalid 1 \ + || capture_fixture_cleanup_verified_for_publication 1 invalid; then + exit 178 + fi + CAPTURE_WORLD_PORT=45123 + CAPTURE_INSTANCE_PORT=45124 + CAPTURE_PROC_ROOT="$artifacts/fake-capture-proc" + mkdir -p "$CAPTURE_PROC_ROOT/42" "$CAPTURE_PROC_ROOT/43" "$CAPTURE_PROC_ROOT/44" + printf 'PPid:\t1\n' >"$CAPTURE_PROC_ROOT/42/status" + printf 'PPid:\t42\n' >"$CAPTURE_PROC_ROOT/43/status" + printf 'PPid:\t43\n' >"$CAPTURE_PROC_ROOT/44/status" + for pid_and_start in 42:42000 43:43000 44:44000; do + fake_pid="${pid_and_start%%:*}" + fake_start="${pid_and_start#*:}" + { + printf '%s (fake world process) S' "$fake_pid" + for _ in $(seq 4 21); do printf ' 0'; done + printf ' %s 0\n' "$fake_start" + } >"$CAPTURE_PROC_ROOT/$fake_pid/stat" + done + [ "$(capture_pid_starttime 42)" = 42000 ] \ + && [ "$(capture_process_tree_identity 42)" \ + = $'42:42000\n43:43000\n44:44000' ] || exit 143 + service_state="$artifacts/cpp-service-state.json" + service_world_listener=1 + service_instance_listener=1 + service_foreign_listener=0 + service_foreign_same_row=0 + service_listener_pid=44 + service_parent_alive=1 + service_listener_alive=1 + pm2() { + [ "${1:-}" = jlist ] || return 90 + cat "$service_state" + } + ss() { + case "$*" in + *"sport = :$CAPTURE_WORLD_PORT"*) + if ((service_world_listener && service_foreign_same_row)); then + printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=3),(other,pid=99,fd=5))\n' "$CAPTURE_WORLD_PORT" "$service_listener_pid" + elif ((service_world_listener)); then + printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=3))\n' "$CAPTURE_WORLD_PORT" "$service_listener_pid" + fi + ((service_foreign_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((other,pid=99,fd=5))\n' "$CAPTURE_WORLD_PORT" + ;; + *"sport = :$CAPTURE_INSTANCE_PORT"*) + ((service_instance_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:* users:((world,pid=%s,fd=4))\n' "$CAPTURE_INSTANCE_PORT" "$service_listener_pid" + ;; + *) + ((service_world_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:*\n' "$CAPTURE_WORLD_PORT" + ((service_instance_listener)) \ + && printf 'LISTEN 0 128 127.0.0.1:%s 0.0.0.0:*\n' "$CAPTURE_INSTANCE_PORT" + ;; + esac + return 0 + } + kill() { + [ "${1:-}" = -0 ] || return 1 + case "${3:-${2:-}}" in + 42) ((service_parent_alive)) ;; + 44) ((service_listener_alive)) ;; + *) return 1 ;; + esac + } + + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + [[ "$(capture_world_ready_once cpp-world)" == $'42\t44' ]] || exit 91 + # A direct binary is also valid: PM2 entry PID and listener PID coincide. + service_listener_pid=42 + [[ "$(capture_world_ready_once cpp-world)" == $'42\t42' ]] || exit 110 + service_listener_pid=44 + # Breaking the wrapper -> listener ancestry must invalidate the identity. + printf 'PPid:\t99\n' >"$CAPTURE_PROC_ROOT/43/status" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 111 + fi + printf 'PPid:\t42\n' >"$CAPTURE_PROC_ROOT/43/status" + printf '%s\n' \ + '[{"name":"cpp-world","pid":45,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 112 + fi + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + service_foreign_listener=1 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 97 + fi + service_foreign_listener=0 + service_foreign_same_row=1 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 98 + fi + service_foreign_same_row=0 + service_instance_listener=0 + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 92 + fi + service_instance_listener=1 + printf '%s\n' \ + '[{"name":"cpp-world","pid":42,"pm2_env":{"status":"online"}},{"name":"cpp-world","pid":43,"pm2_env":{"status":"online"}}]' \ + >"$service_state" + if capture_world_ready_once cpp-world >/dev/null 2>&1; then + exit 93 + fi + + printf '%s\n' \ + '[{"name":"cpp-world","pid":0,"pm2_env":{"status":"launching"}}]' \ + >"$service_state" + service_world_listener=0 + service_instance_listener=0 + service_parent_alive=0 + service_listener_alive=0 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 99 + fi + + printf '%s\n' \ + '[{"name":"cpp-world","pid":0,"pm2_env":{"status":"stopped"}}]' \ + >"$service_state" + service_world_listener=0 + service_instance_listener=0 + service_parent_alive=0 + service_listener_alive=0 + capture_world_stopped_once cpp-world $'42\t44' || exit 94 + service_parent_alive=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 95 + fi + service_parent_alive=0 + service_listener_alive=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 113 + fi + service_listener_alive=0 + service_world_listener=1 + if capture_world_stopped_once cpp-world $'42\t44'; then + exit 96 + fi + ) || die "C++ PM2/PID/listener fail-closed self-test failed" + + ( + # PM2 entrypoint bytes and Git worktree state are provenance, not comments. + # A changed wrapper, dirty harness, or newly dirty source must change/fail + # the recorded identity before any service mutation. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + entrypoint="$artifacts/non-exec-world-wrapper.sh" + entry_state="$artifacts/entrypoint-pm2.json" + printf '#!/bin/sh\n./world-server\n' >"$entrypoint" + chmod 700 "$entrypoint" + pm2() { + [ "${1:-}" = jlist ] || return 114 + cat "$entry_state" + } + jq -n --arg path "$entrypoint" \ + '[{name:"world",pid:42,pm2_env:{status:"online",restart_time:7,pm_exec_path:$path}}]' \ + >"$entry_state" + entry_before="$(capture_pm2_entrypoint_identity world 42)" || exit 115 + printf '#!/bin/sh\n./different-world-server\n' >"$entrypoint" + entry_after="$(capture_pm2_entrypoint_identity world 42)" || exit 116 + [ "$entry_before" != "$entry_after" ] || exit 117 + + harness_repo="$artifacts/clean-harness-repo" + source_repo="$artifacts/dirty-source-repo" + for repository in "$harness_repo" "$source_repo"; do + git init -q "$repository" + git -C "$repository" config user.name preflight + git -C "$repository" config user.email preflight@example.invalid + printf 'committed\n' >"$repository/tracked.txt" + git -C "$repository" add tracked.txt + git -C "$repository" commit -qm initial + done + harness_head="$(git -C "$harness_repo" rev-parse HEAD)" + source_head="$(git -C "$source_repo" rev-parse HEAD)" + capture_git_repo_clean_at_head "$harness_repo" "$harness_head" || exit 118 + capture_git_repo_clean_at_head "$source_repo" "$source_head" || exit 124 + harness_clean_digest="$(capture_git_worktree_state_sha256 "$harness_repo")" \ + || exit 119 + source_clean_digest="$(capture_git_worktree_state_sha256 "$source_repo")" \ + || exit 125 + printf 'dirty\n' >>"$harness_repo/tracked.txt" + if capture_git_repo_clean_at_head "$harness_repo" "$harness_head"; then + exit 120 + fi + [ "$(capture_git_worktree_state_sha256 "$harness_repo")" \ + != "$harness_clean_digest" ] || exit 121 + printf 'untracked source state\n' >"$source_repo/local.patch" + capture_git_repo_is_dirty "$source_repo" || exit 122 + [ "$(capture_git_worktree_state_sha256 "$source_repo")" \ + != "$source_clean_digest" ] || exit 123 + ) || die "capture entrypoint/worktree provenance self-test failed" + + ( + # Credential changes must not become dictionary-testable config hashes, + # while a capture-relevant non-secret change must still alter the digest. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + conf_a="$artifacts/redacted-config-a.conf" + conf_b="$artifacts/redacted-config-b.conf" + conf_c="$artifacts/redacted-config-c.conf" + printf '%s\n' \ + 'WorldServerPort = 8085' \ + 'WorldDatabaseInfo = "mysql://user:first-secret@localhost/world"' >"$conf_a" + printf '%s\n' \ + 'WorldServerPort = 8085' \ + 'WorldDatabaseInfo = "mysql://user:second-secret@localhost/world"' >"$conf_b" + printf '%s\n' \ + 'WorldServerPort = 9085' \ + 'WorldDatabaseInfo = "mysql://user:first-secret@localhost/world"' >"$conf_c" + hash_a="$(capture_effective_config_redacted_sha256 \ + "$conf_a" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 105 + hash_b="$(capture_effective_config_redacted_sha256 \ + "$conf_b" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 106 + hash_c="$(capture_effective_config_redacted_sha256 \ + "$conf_c" 'capture.packet_dump=enabled' WorldServerPort WorldDatabaseInfo)" || exit 107 + [ "$hash_a" = "$hash_b" ] || exit 108 + [ "$hash_a" != "$hash_c" ] || exit 109 + ) || die "redacted effective-config hash self-test failed" + + ( + # A race dump may publish only when a regular report from the exact pinned + # bot proves the complete two-session result. This is intentionally + # independent of the outer preflight's own report check. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + race_bot="$artifacts/race-evidence-bot" + race_report="$artifacts/race-evidence-valid.json" + race_invalid="$artifacts/race-evidence-invalid.json" + race_split_counter="$artifacts/race-evidence-split-counter.json" + race_report_link="$artifacts/race-evidence-link.json" + printf '#!/bin/sh\nexit 0\n' >"$race_bot" + chmod 700 "$race_bot" + jq -n ' + def result($account; $account_id; $character_guid; $item_push; $money): { + account: $account, + account_id: $account_id, + character_guid: $character_guid, + world_auth: true, + enum_characters: true, + player_login_verified: true, + loot_race_smoke: true, + loot_race_smoke_passed: true, + loot_race_failure: null, + loot_race_target_entry: 2846, + loot_race_target_spawn_guid: 9106001, + loot_race_target_runtime_counter: 40, + loot_race_party_confirmed: true, + loot_race_target_discovered: true, + loot_race_loot_opened: true, + loot_race_loot_list_id: 0, + loot_race_loot_coins: 10, + loot_race_item_push_seen: $item_push, + loot_race_loot_removed_seen: true, + loot_race_money_notify_amount: $money, + loot_race_coin_removed_seen: true, + loot_race_db_item_total: 1, + loot_race_db_money_delta: 10, + loot_race_relog_verified: true + }; + { + loot_race_smoke: true, + loot_item_capture: false, + results: [ + result("TESTBOT2@bot.local"; 9; 15; true; 10), + result("TESTBOT3@bot.local"; 10; 16; false; 0) + ] + } + ' >"$race_report" + race_bot_sha="$(capture_sha256_of_file "$race_bot")" || exit 179 + race_report_sha="$(capture_sha256_of_file "$race_report")" || exit 180 + race_evidence="$(capture_loot_race_bot_evidence \ + "$race_report" "$race_bot" "$race_bot_sha")" || exit 181 + [ "$race_evidence" \ + = "$race_bot"$'\t'"$race_bot_sha"$'\t'"$race_report"$'\t'"$race_report_sha" ] \ + || exit 182 + race_manifest_evidence="$(capture_bot_manifest_evidence \ + loot-two-session-atomic-race "$race_bot" "$race_bot_sha" \ + /target/captures/loot-two-session-atomic-race/rust/race.bot-report.json \ + "$race_report_sha")" || exit 186 + jq -e \ + --arg bot "$race_bot" \ + --arg bot_sha "$race_bot_sha" \ + --arg report_sha "$race_report_sha" ' + .fixture_guard != null + and .fixture_guard.enabled == true + and .fixture_guard.contract == "loot-two-session-atomic-race-fixture-v1" + and .fixture_guard.account == "TESTBOT2@bot.local" + and .fixture_guard.peer_account == "TESTBOT3@bot.local" + and .fixture_guard.gameobject_entry == 2846 + and .fixture_guard.gameobject_spawn_guid == 9106001 + and .fixture_guard.item_entry == 38 + and .fixture_guard.cleanup_verified == true + and .bot_report != null + and .bot_report.contract + == "wow-test-bot-loot-two-session-atomic-race-report-v1" + and .bot_report.exec_path == $bot + and .bot_report.exec_sha256 == $bot_sha + and .bot_report.report_path + == "/target/captures/loot-two-session-atomic-race/rust/race.bot-report.json" + and .bot_report.report_sha256 == $report_sha + and .bot_report.report_validated == true + ' <<<"$race_manifest_evidence" >/dev/null || exit 187 + jq -e '.fixture_guard == null and .bot_report == null' \ + <<<"$(capture_bot_manifest_evidence login '' '' '' '')" >/dev/null \ + || exit 188 + + jq '.results[0].loot_race_smoke_passed = false + | .results[0].loot_race_failure = "failure=17"' \ + "$race_report" >"$race_invalid" + if capture_loot_race_bot_evidence \ + "$race_invalid" "$race_bot" "$race_bot_sha" >/dev/null; then + exit 183 + fi + jq '.results[1].loot_race_target_runtime_counter = 41' \ + "$race_report" >"$race_split_counter" + if capture_loot_race_bot_evidence \ + "$race_split_counter" "$race_bot" "$race_bot_sha" >/dev/null; then + exit 184 + fi + ln -s "$race_report" "$race_report_link" + if capture_loot_race_bot_evidence \ + "$race_report_link" "$race_bot" "$race_bot_sha" >/dev/null \ + || capture_loot_race_bot_evidence \ + "$race_report" "$race_bot" "$(printf '0%.0s' {1..64})" >/dev/null; then + exit 185 + fi + ) || die "two-session race publication evidence self-test failed" + + ( + # C++ provenance must be derived from the PM2 profile/entrypoint, and a + # profile selecting config B must never accredit caller-declared config A. + # Environment values are secret and intentionally do not affect the + # stable profile hash; argv/config selection does. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + pm2_state="$artifacts/effective-config-pm2.json" + pm2_wrapper="$artifacts/effective-config-wrapper.sh" + config_a="$artifacts/effective-config-a.conf" + config_b="$artifacts/effective-config-b.conf" + printf 'WorldServerPort = 45123\n' >"$config_a" + printf 'WorldServerPort = 45125\n' >"$config_b" + printf '#!/bin/sh\nexec /opt/fake/worldserver -c %q\n' "$config_a" \ + >"$pm2_wrapper" + chmod 700 "$pm2_wrapper" + pm2() { + [ "${1:-}" = jlist ] || return 126 + cat "$pm2_state" + } + jq -n --arg wrapper "$pm2_wrapper" --arg cwd "$artifacts" \ + '[{name:"cpp-world",pid:0,pm2_env:{status:"stopped",pm_exec_path:$wrapper,pm_cwd:$cwd,args:[],env:{DB_PASSWORD:"first-secret",VISIBLE:"one"}}}]' \ + >"$pm2_state" + [ "$(capture_pm2_effective_config_path cpp-world)" = "$config_a" ] \ + || exit 127 + profile_a="$(capture_pm2_profile_redacted_sha256 cpp-world)" || exit 128 + jq '.[0].pm2_env.env.DB_PASSWORD = "second-secret"' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + profile_secret_changed="$(capture_pm2_profile_redacted_sha256 cpp-world)" \ + || exit 129 + [ "$profile_a" = "$profile_secret_changed" ] || exit 130 + jq --arg config "$config_b" \ + '.[0].pm2_env.args = ["-c", $config]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + [ "$(capture_pm2_effective_config_path cpp-world)" = "$config_b" ] \ + || exit 131 + [ "$(capture_pm2_effective_config_path cpp-world)" != "$config_a" ] \ + || exit 132 + profile_b="$(capture_pm2_profile_redacted_sha256 cpp-world)" || exit 133 + [ "$profile_a" != "$profile_b" ] || exit 134 + jq '.[0].pm2_env.args = ["-c"]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + if capture_pm2_effective_config_path cpp-world >/dev/null 2>&1; then + exit 144 + fi + jq --arg a "$config_a" --arg b "$config_b" \ + '.[0].pm2_env.args = ["-c", $a, "--config", $b]' \ + "$pm2_state" >"$pm2_state.next" + mv -- "$pm2_state.next" "$pm2_state" + if capture_pm2_effective_config_path cpp-world >/dev/null 2>&1; then + exit 145 + fi + ) || die "PM2 effective-config/profile A/B self-test failed" + + ( + # `bash -x` must never reveal DatabaseInfo credentials or MYSQL_PWD. + # Use a shell-local fake mysql command, so this test cannot reach a DB. + # shellcheck source=crates/capture-diff/scripts/loot-fixture-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/loot-fixture-common.sh" + xtrace_conf="$artifacts/xtrace-worldserver.conf" + xtrace_log="$artifacts/xtrace-credentials.log" + world_secret="rc106-world-secret-must-not-appear" + character_secret="rc106-character-secret-must-not-appear" + printf '%s\n' \ + "WorldDatabaseInfo = \"127.0.0.1;3306;world-user;${world_secret};world\"" \ + "CharacterDatabaseInfo = \"127.0.0.1;3306;character-user;${character_secret};characters\"" \ + >"$xtrace_conf" + LOOT_FIXTURE_DB_CONF="$xtrace_conf" + mysql() { + case " $* " in + *" world "*) [ "${MYSQL_PWD:-}" = "$world_secret" ] || return 135 ;; + *" characters "*) [ "${MYSQL_PWD:-}" = "$character_secret" ] || return 136 ;; + *) return 137 ;; + esac + printf '0\n' + } + { + set -x + load_loot_fixture_database_credentials + loot_fixture_world_mysql -e 'SELECT 0' >/dev/null + loot_fixture_character_mysql -e 'SELECT 0' >/dev/null + set +x + } 2>"$xtrace_log" || exit 138 + if rg -q "${world_secret}|${character_secret}|MYSQL_PWD=.*secret" \ + "$xtrace_log"; then + exit 139 + fi + ) || die "credential/MYSQL_PWD xtrace redaction self-test failed" + + ( + # Two wrappers that target the same host ports must never pass their + # service/SQL preconditions concurrently. + # shellcheck source=crates/capture-diff/scripts/capture-service-common.sh + source "$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + lock_file="$artifacts/capture-orchestration.lock" + ready_file="$artifacts/capture-orchestration.ready" + ( + capture_acquire_orchestration_lock "$lock_file" || exit 100 + : >"$ready_file" + sleep 1 + capture_release_orchestration_lock + ) & + holder_pid=$! + for _ in $(seq 1 40); do + [ -e "$ready_file" ] && break + sleep 0.025 + done + [ -e "$ready_file" ] || exit 101 + if capture_acquire_orchestration_lock "$lock_file"; then + capture_release_orchestration_lock + exit 102 + fi + wait "$holder_pid" || exit 103 + capture_acquire_orchestration_lock "$lock_file" || exit 104 + capture_release_orchestration_lock + [ -d "$lock_file" ] && [ ! -L "$lock_file" ] \ + && [ "$(stat -c '%a' -- "$lock_file")" = 700 ] \ + && [ "$(stat -c '%u' -- "$lock_file")" = "$(id -u)" ] || exit 140 + chmod 755 "$lock_file" + if capture_acquire_orchestration_lock "$lock_file"; then + capture_release_orchestration_lock + exit 141 + fi + chmod 700 "$lock_file" + lock_symlink="$artifacts/capture-orchestration-symlink" + ln -s "$lock_file" "$lock_symlink" + if capture_acquire_orchestration_lock "$lock_symlink"; then + capture_release_orchestration_lock + exit 142 + fi + ) || die "capture orchestration lock self-test failed" + + cpp_capture_text="$(<"$REPO_ROOT/crates/capture-diff/scripts/capture-cpp.sh")" + [[ "$cpp_capture_text" == *"loot-fixture-common.sh"* \ + && "$cpp_capture_text" == *"capture-service-common.sh"* \ + && "$cpp_capture_text" == *"capture_wait_for_world_stopped"* \ + && "$cpp_capture_text" == *"apply_creature_health_fixture_guard"* \ + && "$cpp_capture_text" == *"capture_validate_world_timeouts"* \ + && "$cpp_capture_text" == *"CPP_CAPTURE_BOT_READY=1"* \ + && "$cpp_capture_text" == *"loot_fixture_bot_cleanup_safe_for_capture_state"* ]] || die \ + "C++ capture wrapper is not wired to the shared fail-closed loot guard" + [[ "$cpp_capture_text" == *"CPP_CAPTURE_EXEC_SHA256"* \ + && "$cpp_capture_text" == *"cpp.capture-manifest.json"* \ + && "$cpp_capture_text" == *"OUT_PKT_STAGE"* \ + && "$cpp_capture_text" == *"capture_acquire_orchestration_lock"* \ + && "$cpp_capture_text" == *"cpp_capture_executable_unchanged"* \ + && "$cpp_capture_text" == *"source_repo_head"* \ + && "$cpp_capture_text" == *"effective_config_redacted_sha256"* \ + && "$cpp_capture_text" == *"pm2_entry_pid"* \ + && "$cpp_capture_text" == *"pm2_exec_sha256"* \ + && "$cpp_capture_text" == *"source_worktree_state_sha256"* ]] || die \ + "C++ capture wrapper lacks executable provenance, lock, or atomic manifest publication" + if cpp_missing_pin_output="$( + CPP_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + CPP_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-cpp.sh" \ + loot-single-item-claim --yes 2>&1 + )"; then + die "required C++ loot capture accepted missing executable path/SHA pin" + fi + [[ "$cpp_missing_pin_output" == *"requires CPP_CAPTURE_EXEC and CPP_CAPTURE_EXEC_SHA256"* ]] || die \ + "required C++ loot capture missing-pin error was not explicit" + + rust_capture_text="$(<"$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh")" + [[ "$rust_capture_text" == *"guarded Rust evidence requires RUST_CAPTURE_EXEC"* \ + && "$rust_capture_text" == *"rust.capture-manifest.json"* \ + && "$rust_capture_text" == *"DUMP_STAGE_DIR"* \ + && "$rust_capture_text" == *"capture_process_tree_identity"* \ + && "$rust_capture_text" == *"capture_terminate_process_tree"* \ + && "$rust_capture_text" == *"capture_process_tree_absent"* \ + && "$rust_capture_text" == *"capture_publish_noreplace"* \ + && "$rust_capture_text" == *"pm2_entry_starttime"* \ + && "$rust_capture_text" == *"pm2_profile_redacted_sha256"* \ + && "$rust_capture_text" == *"capture_pm2_process_stopped"* \ + && "$rust_capture_text" == *"capture_validate_world_timeouts"* \ + && "$rust_capture_text" == *'CAPTURE_STABLE_SAMPLES" -ge 4'* \ + && "$rust_capture_text" == *"CAPTURE_BOT_READY=1"* \ + && "$rust_capture_text" == *"capture_loot_race_bot_evidence"* \ + && "$rust_capture_text" == *"loot_fixture_bot_cleanup_safe_for_capture_state"* \ + && "$rust_capture_text" == *"source_repo_head"* \ + && "$rust_capture_text" == *"effective_config_redacted_sha256"* \ + && "$rust_capture_text" == *"pm2_entry_pid"* \ + && "$rust_capture_text" == *"pm2_exec_sha256"* \ + && "$rust_capture_text" == *"harness_worktree_state_sha256"* \ + && "$rust_capture_text" != *'rm -rf -- "$DUMP_DIR"'* ]] || die \ + "Rust capture wrapper lacks guarded provenance, PID death, C++ stop, or atomic manifest checks" + if rust_missing_pin_output="$( + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" \ + loot-single-item-claim --yes 2>&1 + )"; then + die "required Rust loot capture accepted missing executable path/SHA pin" + fi + [[ "$rust_missing_pin_output" == *"requires RUST_CAPTURE_EXEC and RUST_CAPTURE_EXEC_SHA256"* ]] || die \ + "required Rust loot capture missing-pin error was not explicit" + if rust_race_missing_bot_output="$( + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_EXEC=/not-used-before-bot-validation \ + RUST_CAPTURE_EXEC_SHA256="$(printf '0%.0s' {1..64})" \ + RUST_CAPTURE_EFFECTIVE_CONFIG=/dev/null \ + "$BASH" "$REPO_ROOT/crates/capture-diff/scripts/capture-rust.sh" \ + loot-two-session-atomic-race --yes 2>&1 + )"; then + die "guarded Rust race capture accepted missing pinned bot report evidence" + fi + [[ "$rust_race_missing_bot_output" \ + == *"requires WOW_BOT_EXEC, WOW_BOT_EXEC_SHA256, and WOW_BOT_REPORT"* ]] || die \ + "guarded Rust race capture missing-bot-report error was not explicit" + qa_fake_bin="$artifacts/qa-bin" + qa_fake_world="$artifacts/fake-world-server" + qa_fake_world_other="$artifacts/not-the-live-world-server" + qa_fake_bot="$artifacts/fake-loot-race-bot-only" + qa_fake_capture="$artifacts/fake-capture-rust.sh" + qa_fake_bot_marker="$artifacts/fake-loot-race-bot-ran" + qa_pm2_json_file="$artifacts/fake-pm2.json" + qa_pm2_before_file="$artifacts/fake-pm2-before.json" + qa_pm2_after_file="$artifacts/fake-pm2-after.json" + qa_pm2_state_file="$artifacts/fake-pm2-state" + mkdir -p "$qa_fake_bin" + for dependency in awk bash chmod dirname env git jq mkfifo mktemp mv realpath rg rm rmdir sed seq sha256sum sleep stat; do + ln -s "$(command -v "$dependency")" "$qa_fake_bin/$dependency" + done + printf '#!/bin/sh\nexit 0\n' >"$qa_fake_bin/mysql" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + '[ "${1:-}" = jlist ] || exit 64' \ + 'json_file="${QA_FAKE_PM2_JSON_FILE:?}"' \ + 'if [ -n "${QA_FAKE_PM2_STATE_FILE:-}" ]; then' \ + ' if [ -e "$QA_FAKE_PM2_STATE_FILE" ]; then' \ + ' json_file="${QA_FAKE_PM2_AFTER_FILE:?}"' \ + ' else' \ + ' : >"$QA_FAKE_PM2_STATE_FILE"' \ + ' json_file="${QA_FAKE_PM2_BEFORE_FILE:?}"' \ + ' fi' \ + 'fi' \ + 'IFS= read -r payload <"$json_file"' \ + 'printf "%s\n" "$payload"' \ + >"$qa_fake_bin/pm2" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + 'listener_pid=""' \ + 'if [ "${QA_FAKE_SS_DYNAMIC:-0}" = 1 ]; then' \ + ' listener_pid="$(jq -r ".[0].pid // empty" "${QA_FAKE_PM2_JSON_FILE:?}")"' \ + 'else' \ + ' case "$*" in' \ + ' *":${QA_FAKE_WORLD_PORT:?}"*) listener_pid="${QA_FAKE_SS_WORLD_PID:-}" ;;' \ + ' *":${QA_FAKE_INSTANCE_PORT:?}"*) listener_pid="${QA_FAKE_SS_INSTANCE_PID:-}" ;;' \ + ' esac' \ + 'fi' \ + 'if [ -n "$listener_pid" ] && [ -n "${QA_FAKE_SS_EXTRA_PID:-}" ]; then' \ + ' printf "LISTEN 0 128 127.0.0.1:* 0.0.0.0:* users:((fake-world,pid=%s,fd=3),(foreign,pid=%s,fd=4))\n" "$listener_pid" "$QA_FAKE_SS_EXTRA_PID"' \ + 'elif [ -n "$listener_pid" ]; then' \ + ' printf "LISTEN 0 128 127.0.0.1:* 0.0.0.0:* users:((fake-world,pid=%s,fd=3))\n" "$listener_pid"' \ + 'fi' \ + >"$qa_fake_bin/ss" + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + '[ "${WOW_BOT_ENSURE_TEST_ACCOUNTS:-}" = 0 ]' \ + 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ + 'umask 077' \ + 'write_marker() {' \ + ' printf "%s\n" "{\"version\":1,\"journal_sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"cleanup_pid\":123}" >"${journal}.cleanup-complete"' \ + ' chmod 600 "${journal}.cleanup-complete"' \ + '}' \ + ': >"$journal"' \ + 'report=""' \ + 'while [ "$#" -gt 0 ]; do' \ + ' if [ "$1" = --report ]; then' \ + ' shift' \ + ' report="${1:?missing report path}"' \ + ' fi' \ + ' shift' \ + 'done' \ + '[ -n "$report" ] || exit 65' \ + 'case "${QA_FAKE_BOT_MUTATION:-}" in' \ + ' restart) jq -c ".[0].pm2_env.restart_time += 1" "${QA_FAKE_PM2_JSON_FILE:?}" >"${QA_FAKE_PM2_JSON_FILE}.tmp" ;;' \ + ' pid) jq -c --argjson pid "${QA_FAKE_ORIGINAL_PID:?}" ".[0].pid = \$pid" "${QA_FAKE_PM2_JSON_FILE:?}" >"${QA_FAKE_PM2_JSON_FILE}.tmp" ;;' \ + ' term) kill -TERM "$$" ;;' \ + ' kill) kill -KILL "$$" ;;' \ + ' pending) exit 72 ;;' \ + ' drift) write_marker ;;' \ + 'esac' \ + 'if [ -e "${QA_FAKE_PM2_JSON_FILE:-}.tmp" ]; then mv "${QA_FAKE_PM2_JSON_FILE}.tmp" "$QA_FAKE_PM2_JSON_FILE"; fi' \ + 'if [ "${QA_FAKE_BOT_REPORT_MODE:-full}" = summary ]; then' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","account_id":9,"character_guid":15,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","account_id":10,"character_guid":16,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + 'else' \ + ' printf "%s\n" '\''{"loot_race_smoke":true,"loot_item_capture":false,"results":[{"account":"TESTBOT2@bot.local","account_id":9,"character_guid":15,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":true,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":10,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true},{"account":"TESTBOT3@bot.local","account_id":10,"character_guid":16,"world_auth":true,"enum_characters":true,"player_login_verified":true,"loot_race_smoke":true,"loot_race_smoke_passed":true,"loot_race_failure":null,"loot_race_target_entry":2846,"loot_race_target_spawn_guid":9106001,"loot_race_target_runtime_counter":12345,"loot_race_party_confirmed":true,"loot_race_target_discovered":true,"loot_race_loot_opened":true,"loot_race_loot_list_id":0,"loot_race_loot_coins":10,"loot_race_item_push_seen":false,"loot_race_loot_removed_seen":true,"loot_race_money_notify_amount":0,"loot_race_coin_removed_seen":true,"loot_race_db_item_total":1,"loot_race_db_money_delta":10,"loot_race_relog_verified":true}]}'\'' >"$report"' \ + 'fi' \ + 'printf "%s\n" fake-bot-only >"${QA_FAKE_BOT_MARKER:?}"' \ + 'if [ "${QA_FAKE_BOT_MUTATION:-}" != drift ]; then' \ + ' rm -f -- "$journal"' \ + ' write_marker' \ + 'fi' \ + >"$qa_fake_bot" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + '[ "${1:-}" = loot-two-session-atomic-race ] && [ "${2:-}" = --yes ]' \ + '[ "${RUST_CAPTURE_LOOT_FIXTURE_GUARD:-}" = 1 ]' \ + '[ "${RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION:-}" = 1 ]' \ + '[ "${RUST_CAPTURE_EXEC:?}" = "${QA_FAKE_TARGET_EXEC:?}" ]' \ + '[ "${RUST_CAPTURE_EXEC_SHA256:?}" = "${QA_FAKE_TARGET_SHA:?}" ]' \ + '[ -n "${RUST_CAPTURE_DB_CONF:?}" ]' \ + '[ "${PM2_RUST_WORLD:?}" = "${QA_FAKE_PROCESS_NAME:?}" ]' \ + '[ "${RUST_WORLD_PORT:?}" = "${QA_FAKE_WORLD_PORT:?}" ]' \ + '[ "${RUST_INSTANCE_PORT:?}" = "${QA_FAKE_INSTANCE_PORT:?}" ]' \ + 'report="${WOW_BOT_REPORT:?}"' \ + '[[ "$report" = /* && "$report" != *$'\''\n'\''* ]]' \ + '[ -d "$(dirname -- "$report")" ] && [ ! -L "$(dirname -- "$report")" ]' \ + '[ ! -e "$report" ] && [ ! -L "$report" ]' \ + 'source "${QA_FAKE_CAPTURE_COMMON:?}"' \ + 'journal="${WOW_BOT_FIXTURE_JOURNAL:?}"' \ + 'capture_pid=""' \ + 'capture_bot_ready=0' \ + 'restore() {' \ + ' status=$?' \ + ' trap - EXIT HUP INT TERM' \ + ' set +e' \ + ' if [ -n "$capture_pid" ]; then kill "$capture_pid" 2>/dev/null; wait "$capture_pid" 2>/dev/null; fi' \ + ' marker="${journal}.cleanup-complete"' \ + ' marker_mode="$(stat -c %a -- "$marker" 2>/dev/null || true)"' \ + ' if [ "$capture_bot_ready" = 0 ]; then' \ + ' cleanup_safe=$([ ! -e "$journal" ] && [ ! -L "$journal" ] && [ ! -e "$marker" ] && [ ! -L "$marker" ]; echo $?)' \ + ' else' \ + ' cleanup_safe=$([ ! -e "$journal" ] && [ ! -L "$journal" ] && [ -f "$marker" ] && [ ! -L "$marker" ] && [ "$marker_mode" = 600 ] && jq -e ".version == 1 and (.journal_sha256 | type == \"string\" and length == 64) and (.cleanup_pid | type == \"number\" and . > 0)" "$marker" >/dev/null 2>&1; echo $?)' \ + ' fi' \ + ' if [ "$cleanup_safe" != 0 ]; then' \ + ' printf "%s\n" "fake guarded capture refused normal PM2 restore: pending journal or missing cleanup-complete" >&2' \ + ' exit 74' \ + ' fi' \ + ' jq -cn --arg name "$QA_FAKE_PROCESS_NAME" --arg exec "$QA_FAKE_ORIGINAL_EXEC" --argjson pid "$QA_FAKE_ORIGINAL_PID" '\''[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:9,env:{}}}]'\'' >"$QA_FAKE_PM2_JSON_FILE"' \ + ' rm -f -- "${journal}.cleanup-complete"' \ + ' printf "%s\n" "fake guarded capture restored normal PM2 profile"' \ + ' exit "$status"' \ + '}' \ + 'trap restore EXIT' \ + 'trap '\''exit 129'\'' HUP' \ + 'trap '\''exit 130'\'' INT' \ + 'trap '\''exit 143'\'' TERM' \ + 'if [ -n "${QA_FAKE_CAPTURE_BEFORE_READY_STATUS:-}" ]; then exit "$QA_FAKE_CAPTURE_BEFORE_READY_STATUS"; fi' \ + '"$QA_FAKE_TARGET_EXEC" 300 &' \ + 'capture_pid=$!' \ + 'jq -cn --arg name "$QA_FAKE_PROCESS_NAME" --arg exec "$QA_FAKE_TARGET_EXEC" --argjson pid "$capture_pid" '\''[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:8,RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump",env:{RUSTYCORE_PACKET_DUMP_DIR:"/tmp/fake-dump"}}}]'\'' >"$QA_FAKE_PM2_JSON_FILE"' \ + 'capture_bot_ready=1' \ + 'printf "%s\n" ">>> Perform the '\''loot-two-session-atomic-race'\'' flow with the client now."' \ + 'IFS= read -r _' \ + 'if ! capture_loot_race_bot_evidence "$report" "$WOW_BOT_EXEC" "$WOW_BOT_EXEC_SHA256" >/dev/null; then' \ + ' printf "%s\n" "fake guarded capture refused publication without exact pinned race report" >&2' \ + ' exit 75' \ + 'fi' \ + 'printf "%s\n" published >"${QA_FAKE_CAPTURE_PUBLISH_MARKER:?}"' \ + 'exit "${QA_FAKE_CAPTURE_EXIT_STATUS:-0}"' \ + >"$qa_fake_capture" + chmod +x "$qa_fake_bin/mysql" "$qa_fake_bin/pm2" "$qa_fake_bin/ss" \ + "$qa_fake_bot" "$qa_fake_capture" + + cp -- "$(command -v sleep)" "$qa_fake_world" + cp -- "$qa_fake_world" "$qa_fake_world_other" + chmod +x "$qa_fake_world" "$qa_fake_world_other" + "$qa_fake_world" 300 & + qa_world_pid=$! + kill -0 "$qa_world_pid" 2>/dev/null || die \ + "temporary world-process self-test fixture did not start" + qa_world_sha="$(sha256_of_file "$qa_fake_world")" || die \ + "cannot hash temporary world-process self-test fixture" + qa_target_sha="$(sha256_of_file "$qa_fake_world_other")" || die \ + "cannot hash temporary target-world self-test fixture" + qa_fake_bot_sha="$(sha256_of_file "$qa_fake_bot")" || die \ + "cannot hash fake loot-race bot" + qa_fake_capture_publish_marker="$artifacts/fake-loot-race-capture-published" + + qa_pm2_online="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_duplicate="$(jq -cn --argjson rows "$qa_pm2_online" '$rows + $rows')" + qa_pm2_offline="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"stopped",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_wrong_path="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world_other" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + qa_pm2_restart_changed="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$qa_world_pid" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:8}}]')" + qa_pm2_pid_changed="$(jq -cn \ + --arg name self-test-world \ + --arg exec "$qa_fake_world" \ + --argjson pid "$((qa_world_pid + 1))" \ + '[{name:$name,pid:$pid,pm2_env:{status:"online",pm_exec_path:$exec,restart_time:7}}]')" + + printf '%s\n' "$qa_pm2_duplicate" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted duplicate PM2 entries" + fi + printf '%s\n' "$qa_pm2_offline" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted an offline PM2 entry" + fi + printf '%s\n' "$qa_pm2_wrong_path" >"$qa_pm2_json_file" + if ( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" >/dev/null 2>&1 + ); then + die "world identity accepted the wrong PM2 executable path" + fi + + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_identity="$( + export PATH="$qa_fake_bin" QA_FAKE_PM2_JSON_FILE="$qa_pm2_json_file" + qa_world_identity self-test-world "$qa_fake_world" + )" || die "world identity rejected the valid fake PM2 entry" + [[ "$qa_identity" == "$qa_world_pid"$'\t''7' ]] || die \ + "world identity returned an unexpected PID/restart tuple" + qa_world_process_matches "$qa_identity" "$qa_fake_world" "$qa_world_sha" || die \ + "world process pin rejected the valid live fake executable" + if qa_world_process_matches \ + "$qa_identity" "$qa_fake_world_other" "$qa_world_sha"; then + die "world process pin accepted the wrong executable path" + fi + if qa_world_process_matches \ + "$qa_identity" "$qa_fake_world" \ + 0000000000000000000000000000000000000000000000000000000000000000; then + die "world process pin accepted the wrong SHA-256" + fi + if ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID= + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ); then + die "world listener gate accepted a missing instance listener" + fi + ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID="$qa_world_pid" + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ) || die "world listener gate rejected the valid fake listeners" + if ( + export PATH="$qa_fake_bin" \ + QA_FAKE_WORLD_PORT="$qa_world_port" \ + QA_FAKE_INSTANCE_PORT="$qa_instance_port" \ + QA_FAKE_SS_WORLD_PID="$qa_world_pid" \ + QA_FAKE_SS_INSTANCE_PID="$qa_world_pid" \ + QA_FAKE_SS_EXTRA_PID="$((qa_world_pid + 1))" + qa_world_ports_ready "$qa_identity" "$qa_world_port" "$qa_instance_port" + ); then + die "world listener gate accepted a foreign PID on the same SO_REUSEPORT row" + fi + + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_before_file" + printf '%s\n' "$qa_pm2_restart_changed" >"$qa_pm2_after_file" + qa_common_env=( + "PATH=$qa_fake_bin" + "TMPDIR=$artifacts" + "RUST_MIN_STACK=$DEFAULT_RUST_MIN_STACK" + "PM2_RUST_WORLD=self-test-world" + "WOW_BOT_WORLD_EXEC=$qa_fake_world_other" + "WOW_BOT_WORLD_EXEC_SHA256=$qa_target_sha" + "RUST_WORLD_PORT=$qa_world_port" + "RUST_INSTANCE_PORT=$qa_instance_port" + "QA_FAKE_PM2_JSON_FILE=$qa_pm2_json_file" + "QA_FAKE_PM2_BEFORE_FILE=$qa_pm2_before_file" + "QA_FAKE_PM2_AFTER_FILE=$qa_pm2_after_file" + "QA_FAKE_PM2_STATE_FILE=" + "QA_FAKE_WORLD_PORT=$qa_world_port" + "QA_FAKE_INSTANCE_PORT=$qa_instance_port" + "QA_FAKE_SS_WORLD_PID=$qa_world_pid" + "QA_FAKE_SS_INSTANCE_PID=$qa_world_pid" + "QA_FAKE_SS_DYNAMIC=1" + "QA_FAKE_ORIGINAL_EXEC=$qa_fake_world" + "QA_FAKE_TARGET_EXEC=$qa_fake_world_other" + "QA_FAKE_TARGET_SHA=$qa_target_sha" + "QA_FAKE_PROCESS_NAME=self-test-world" + "QA_FAKE_ORIGINAL_PID=$qa_world_pid" + "RUST_CAPTURE_DB_CONF=/dev/null" + "WOW_BOT_ENV_FILE=/dev/null" + "WOW_BOT_PASSWORD=self-test" + "WOW_BOT_GENERATE_LOCAL_PASSWORD=0" + "WOW_BOT_ENSURE_TEST_ACCOUNTS=1" + "WOW_BOT_EXEC=$qa_fake_bot" + "WOW_BOT_EXEC_SHA256=$qa_fake_bot_sha" + "WOW_BOT_REPORT=$artifacts/fake-loot-race-report.json" + "WOW_BOT_LOG=$artifacts/fake-loot-race.log" + "QA_FAKE_BOT_MARKER=$qa_fake_bot_marker" + "QA_FAKE_CAPTURE_COMMON=$REPO_ROOT/crates/capture-diff/scripts/capture-service-common.sh" + "QA_FAKE_CAPTURE_PUBLISH_MARKER=$qa_fake_capture_publish_marker" + ) + + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_early_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_CAPTURE_BEFORE_READY_STATUS=73 + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )" || qa_early_status=$? + [[ "$qa_early_status" == 73 \ + && "$qa_early_output" == *"exited before its ready marker (status 73)"* \ + && "$qa_early_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "pre-ready wrapper failure did not restore safely and preserve its status" + + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + qa_positive_output="$( + ( + export "${qa_common_env[@]}" + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )" || { + printf '%s\n' "$qa_positive_output" >&2 + die "guarded capture QA self-test rejected valid fake infrastructure" + } + [[ "$(<"$qa_fake_bot_marker")" == fake-bot-only ]] || die \ + "guarded capture QA self-test did not execute only the fake bot" + [[ "$(<"$qa_fake_capture_publish_marker")" == published \ + && "$qa_positive_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "guarded capture QA self-test did not gate publication on the exact report and restore" + + rm -f "$qa_fake_bot_marker" "$qa_fake_capture_publish_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_summary_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_REPORT_MODE=summary + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a summary-only bot report" + fi + [[ -f "$qa_fake_bot_marker" && ! -e "$qa_fake_capture_publish_marker" \ + && "$qa_summary_output" == *"report did not prove the exact successful two-session contract"* \ + && "$qa_summary_output" == *"refused publication without exact pinned race report"* \ + && "$qa_summary_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "summary-only bot report self-test did not block publication and restore PM2" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_restart_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=restart + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a changed PM2 restart count" + fi + if [[ ! -f "$qa_fake_bot_marker" \ + || "$qa_restart_output" != *"guarded capture world restarted during loot-race QA"* \ + || "$qa_restart_output" != *"fake guarded capture restored normal PM2 profile"* ]]; then + printf '%s\n' "$qa_restart_output" >&2 + die "restart-count self-test did not fail closed and restore after the fake bot" + fi + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_pid_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=pid + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted a changed PM2 PID" + fi + [[ -f "$qa_fake_bot_marker" \ + && "$qa_pid_output" == *"guarded capture world restarted during loot-race QA"* \ + && "$qa_pid_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "PID-change self-test did not fail closed and restore after the fake bot" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_cleanup_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_CAPTURE_EXIT_STATUS=73 + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA ignored fixture-cleanup wrapper failure" + fi + [[ "$qa_cleanup_output" == *"guarded capture/fixture restoration failed with status 73"* \ + && "$qa_cleanup_output" == *"fake guarded capture restored normal PM2 profile"* ]] || die \ + "fixture-cleanup failure self-test did not propagate the wrapper status" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_term_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=term + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA restored normal PM2 after a TERM-interrupted fixture" + fi + [[ "$qa_term_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_term_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_term_output" != *"restored normal PM2 profile"* ]] || die \ + "TERM self-test did not retain the pending journal and prioritize cleanup failure" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_kill_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=kill + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA restored normal PM2 after a KILL-interrupted fixture" + fi + [[ "$qa_kill_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_kill_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_kill_output" != *"restored normal PM2 profile"* ]] || die \ + "KILL self-test did not retain the pending journal and prioritize cleanup failure" + + rm -f "$qa_fake_bot_marker" "$qa_pm2_state_file" + printf '%s\n' "$qa_pm2_online" >"$qa_pm2_json_file" + if qa_drift_output="$( + ( + export "${qa_common_env[@]}" QA_FAKE_BOT_MUTATION=drift + ALLOW_RUNTIME_QA=1 + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + QA_LOOT_RACE_CAPTURE_SCRIPT="$qa_fake_capture" + run_qa_loot_race + ) 2>&1 + )"; then + die "guarded capture QA accepted cleanup-complete while its recovery journal remained pending" + fi + [[ "$qa_drift_output" == *"guarded capture/fixture restoration failed with status 74"* \ + && "$qa_drift_output" == *"pending journal or missing cleanup-complete"* \ + && "$qa_drift_output" != *"restored normal PM2 profile"* ]] || die \ + "journal-drift self-test did not fail closed" review_dry_run_output="$(PATH="$artifacts/bin" \ "$BASH" "$REPO_ROOT/tools/pr-preflight.sh" --dry-run review HEAD 2>&1)" || die \ @@ -616,10 +2691,17 @@ run_self_test() { "full dry-run unexpectedly requires optional execution tools" [[ "$full_dry_run_output" == *"+ codex"* ]] || die \ "full dry-run did not print the Codex command" - [[ "$full_dry_run_output" == *"test --locked -p capture-diff"* ]] || die \ - "full dry-run did not print the capture-diff command" - - rm -rf "$artifacts" + require_exact_occurrences "$full_dry_run_output" \ + "test --locked -p capture-diff" 1 \ + "local full capture-diff test command" + require_exact_occurrences "$full_dry_run_output" \ + "verify-required loot-single-item-claim" 1 \ + "local full required-flow command" + + self_test_cleanup "$qa_world_pid" "$artifacts" + qa_world_pid="" + artifacts="" + trap - EXIT log "Preflight self-test passed" } @@ -715,7 +2797,6 @@ run_full() { require_clean_worktree run_diff "$base" run_ci - run_capture run_review "$base" } @@ -733,6 +2814,342 @@ run_qa_login() { run_cmd "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" } +run_qa_loot_race() { + local capture_script="$QA_LOOT_RACE_CAPTURE_SCRIPT" + local capture_status=0 + local bot_exec="${WOW_BOT_EXEC:-}" + local bot_expected_sha="${WOW_BOT_EXEC_SHA256:-}" + local bot_log + local bot_report + local control_fifo + local cpp_process_name="${PM2_CPP_WORLD:-cpp-world}" + local db_conf="${RUST_CAPTURE_DB_CONF:-${WOW_BOT_DB_CONF:-/home/server/trinity-legacy-install/bin/worldserver.conf}}" + local effective_config="${RUST_CAPTURE_EFFECTIVE_CONFIG:-/home/server/trinity-legacy-install/etc/worldserver.conf}" + local expected_sha + local fixture_cleanup_marker + local fixture_journal + local identity_before + local identity_capture + local identity_after + local identity_restored + local original_canonical_exec + local original_exec + local original_pid + local original_restart + local original_sha + local original_snapshot + local process_name="${PM2_RUST_WORLD:-rustycore-world}" + local world_exec="${WOW_BOT_WORLD_EXEC:-}" + local world_port="${RUST_WORLD_PORT:-8085}" + local instance_port="${RUST_INSTANCE_PORT:-8086}" + local bot_status=0 + local bnet_port="${BNET_PORT:-8081}" + local qa_status=0 + + ((ALLOW_RUNTIME_QA == 1)) || die \ + "qa-loot-race modifies disposable characters and world state; rerun with --allow-runtime-qa" + ((ACK_DISPOSABLE_OVERWORLD_LOOT_RACE == 1)) || die \ + "qa-loot-race mutates an exact shared-chest fixture; rerun with --ack-disposable-overworld-loot-race" + log "Live two-session atomic loot-claim QA bot" + + valid_tcp_port "$world_port" || die "RUST_WORLD_PORT must be an integer from 1 through 65535" + valid_tcp_port "$instance_port" || die \ + "RUST_INSTANCE_PORT must be an integer from 1 through 65535" + [[ "$world_port" != "$instance_port" ]] || die \ + "RUST_WORLD_PORT and RUST_INSTANCE_PORT must be distinct" + valid_tcp_port "$bnet_port" || die "BNET_PORT must be an integer from 1 through 65535" + + if ((DRY_RUN)); then + fixture_journal="/tmp/rustycore-loot-race-qa/fixture.journal" + bot_report="/tmp/rustycore-loot-race-qa/bot-report.json" + printf '+ snapshot the pinned PM2 world, then start the guarded capture world via FIFO/background\n' + run_cmd env \ + PM2_RUST_WORLD="$process_name" \ + PM2_CPP_WORLD="$cpp_process_name" \ + RUST_WORLD_PORT="$world_port" \ + RUST_INSTANCE_PORT="$instance_port" \ + RUST_CAPTURE_EXEC="$world_exec" \ + RUST_CAPTURE_EXEC_SHA256="${WOW_BOT_WORLD_EXEC_SHA256:-}" \ + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_DB_CONF="$db_conf" \ + RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + "$capture_script" loot-two-session-atomic-race --yes + printf '+ wait for guarded capture READY marker and accredit its exact PID/executable/listeners\n' + run_cmd env \ + BNET_HOST=127.0.0.1 \ + BNET_PORT="$bnet_port" \ + WORLD_HOST=127.0.0.1 \ + WORLD_PORT="$world_port" \ + INSTANCE_HOST=127.0.0.1 \ + INSTANCE_PORT="$instance_port" \ + WOW_BOT_DB_CONF="$db_conf" \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=0 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=38 \ + "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" + printf '+ verify capture PID/restart/executable/listeners unchanged, send ENTER, and wait for exact PM2/fixture restoration\n' + return + fi + + [[ -n "$world_exec" ]] || die \ + "qa-loot-race requires WOW_BOT_WORLD_EXEC pinned to the target capture world-server binary" + [[ "$world_exec" == /* ]] || die "WOW_BOT_WORLD_EXEC must be an absolute canonical path" + [[ ! -L "$world_exec" && -f "$world_exec" && -x "$world_exec" ]] || die \ + "WOW_BOT_WORLD_EXEC must be a regular executable file (not a symlink)" + [[ "$(realpath -e -- "$world_exec" 2>/dev/null)" == "$world_exec" ]] || die \ + "WOW_BOT_WORLD_EXEC must already be canonical" + expected_sha="${WOW_BOT_WORLD_EXEC_SHA256:-}" + [[ "$expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || die \ + "WOW_BOT_WORLD_EXEC_SHA256 must contain the pinned 64-digit SHA-256" + expected_sha="${expected_sha,,}" + [[ -n "$bot_exec" ]] || die \ + "qa-loot-race requires WOW_BOT_EXEC pinned to the exact wow-test-bot binary" + [[ "$bot_exec" == /* ]] || die "WOW_BOT_EXEC must be an absolute canonical path" + [[ ! -L "$bot_exec" && -f "$bot_exec" && -x "$bot_exec" ]] || die \ + "WOW_BOT_EXEC must be a regular executable file (not a symlink)" + [[ "$(realpath -e -- "$bot_exec" 2>/dev/null)" == "$bot_exec" ]] || die \ + "WOW_BOT_EXEC must already be canonical" + [[ "$bot_expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || die \ + "WOW_BOT_EXEC_SHA256 must contain the pinned 64-digit SHA-256" + bot_expected_sha="${bot_expected_sha,,}" + require_command jq + require_command mkfifo + require_command mysql + require_command pm2 + require_command realpath + require_command rg + require_command seq + require_command sha256sum + require_command ss + [[ -x "$capture_script" && ! -L "$capture_script" ]] || die \ + "qa-loot-race capture wrapper is missing, non-executable, or a symlink: $capture_script" + [[ "$(sha256_of_file "$world_exec")" == "$expected_sha" ]] || die \ + "WOW_BOT_WORLD_EXEC does not match WOW_BOT_WORLD_EXEC_SHA256" + [[ "$(sha256_of_file "$bot_exec")" == "$bot_expected_sha" ]] || die \ + "WOW_BOT_EXEC does not match WOW_BOT_EXEC_SHA256" + + original_snapshot="$(qa_world_snapshot "$process_name")" || die \ + "PM2 process $process_name is not one exact online world executable" + IFS=$'\t' read -r original_pid original_restart original_exec <<<"$original_snapshot" + [[ "$original_pid" =~ ^[1-9][0-9]*$ && "$original_restart" =~ ^[0-9]+$ \ + && -n "$original_exec" ]] || die "PM2 normal-world snapshot was malformed" + original_canonical_exec="$(realpath -e -- "$original_exec" 2>/dev/null)" || die \ + "normal PM2 world executable does not resolve: $original_exec" + [[ -f "$original_canonical_exec" && -x "$original_canonical_exec" ]] || die \ + "normal PM2 world executable is not a regular executable file" + original_sha="$(sha256_of_file "$original_canonical_exec")" || die \ + "cannot hash the normal PM2 world executable" + identity_before="${original_pid}"$'\t'"${original_restart}" + qa_world_process_matches \ + "$identity_before" "$original_canonical_exec" "$original_sha" || die \ + "live normal PM2 world bytes do not match its snapshotted executable" + qa_world_ports_ready "$identity_before" "$world_port" "$instance_port" || die \ + "pinned PM2 world process does not own listeners $world_port and $instance_port" + qa_world_packet_dump_absent "$process_name" || die \ + "normal PM2 world already carries RUSTYCORE_PACKET_DUMP_DIR; refusing nested capture QA" + + QA_LOOT_RACE_CAPTURE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-loot-race-qa.XXXXXX")" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + QA_LOOT_RACE_CAPTURE_LOG="$QA_LOOT_RACE_CAPTURE_DIR/capture.log" + QA_LOOT_RACE_FIXTURE_JOURNAL="$QA_LOOT_RACE_CAPTURE_DIR/fixture.journal" + bot_report="$QA_LOOT_RACE_CAPTURE_DIR/bot-report.json" + bot_log="$QA_LOOT_RACE_CAPTURE_DIR/bot.log" + fixture_journal="$QA_LOOT_RACE_FIXTURE_JOURNAL" + fixture_cleanup_marker="${fixture_journal}.cleanup-complete" + [[ ! -e "$fixture_journal" && ! -L "$fixture_journal" \ + && ! -e "$fixture_cleanup_marker" && ! -L "$fixture_cleanup_marker" \ + && ! -e "$bot_report" && ! -L "$bot_report" \ + && ! -e "$bot_log" && ! -L "$bot_log" ]] || die \ + "fresh QA fixture/report path is unexpectedly occupied" + control_fifo="$QA_LOOT_RACE_CAPTURE_DIR/control.fifo" + mkfifo -- "$control_fifo" + exec {QA_LOOT_RACE_CAPTURE_FD}<>"$control_fifo" + trap qa_loot_race_capture_cleanup EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + env \ + PM2_RUST_WORLD="$process_name" \ + PM2_CPP_WORLD="$cpp_process_name" \ + RUST_WORLD_PORT="$world_port" \ + RUST_INSTANCE_PORT="$instance_port" \ + RUST_CAPTURE_EXEC="$world_exec" \ + RUST_CAPTURE_EXEC_SHA256="$expected_sha" \ + RUST_CAPTURE_LOOT_FIXTURE_GUARD=1 \ + RUST_CAPTURE_ACK_LOOT_FIXTURE_MUTATION=1 \ + RUST_CAPTURE_DB_CONF="$db_conf" \ + RUST_CAPTURE_EFFECTIVE_CONFIG="$effective_config" \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + "$capture_script" loot-two-session-atomic-race --yes \ + <&"$QA_LOOT_RACE_CAPTURE_FD" >"$QA_LOOT_RACE_CAPTURE_LOG" 2>&1 & + QA_LOOT_RACE_CAPTURE_PID=$! + + qa_wait_for_loot_capture_ready \ + "$QA_LOOT_RACE_CAPTURE_PID" "$QA_LOOT_RACE_CAPTURE_LOG" || die \ + "guarded capture wrapper did not become ready" + identity_capture="$(qa_world_identity "$process_name" "$world_exec")" || die \ + "guarded capture world is not the pinned online executable" + [[ "$identity_capture" != "$identity_before" ]] || die \ + "guarded capture wrapper did not replace the normal PM2 world process" + qa_world_process_matches "$identity_capture" "$world_exec" "$expected_sha" || die \ + "guarded capture world bytes do not match the pinned executable" + qa_world_ports_ready "$identity_capture" "$world_port" "$instance_port" || die \ + "guarded capture world does not own listeners $world_port and $instance_port" + + env \ + BNET_HOST=127.0.0.1 \ + BNET_PORT="$bnet_port" \ + WORLD_HOST=127.0.0.1 \ + WORLD_PORT="$world_port" \ + INSTANCE_HOST=127.0.0.1 \ + INSTANCE_PORT="$instance_port" \ + WOW_BOT_DB_CONF="$db_conf" \ + WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_EXEC="$bot_exec" \ + WOW_BOT_EXEC_SHA256="$bot_expected_sha" \ + WOW_BOT_REPORT="$bot_report" \ + WOW_BOT_LOG="$bot_log" \ + WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" \ + WOW_BOT_LOOT_RACE_SMOKE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + WOW_BOT_LOOT_RACE_ACCOUNT_A=TESTBOT2@bot.local \ + WOW_BOT_LOOT_RACE_ACCOUNT_B=TESTBOT3@bot.local \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY=2846 \ + WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID=9106001 \ + WOW_BOT_LOOT_RACE_RUNTIME_COUNTER=0 \ + WOW_BOT_LOOT_RACE_ITEM_ENTRY=38 \ + "$REPO_ROOT/tools/wow-test-bot/run_rustycore_login_smoke.sh" & + QA_LOOT_RACE_BOT_PID=$! + wait "$QA_LOOT_RACE_BOT_PID" || bot_status=$? + QA_LOOT_RACE_BOT_PID="" + + if ((bot_status == 0)); then + if [[ ! -f "$bot_report" || -L "$bot_report" ]]; then + echo "loot-race bot exited successfully without a fresh regular report" >&2 + bot_status=1 + elif ! jq -e ' + .loot_race_smoke == true + and .loot_item_capture == false + and (.results | type == "array" and length == 2) + and ([.results[] | [.account, .account_id, .character_guid]] | sort + == [["TESTBOT2@bot.local", 9, 15], ["TESTBOT3@bot.local", 10, 16]]) + and all(.results[]; + .world_auth == true + and .enum_characters == true + and .player_login_verified == true + and .loot_race_smoke == true + and .loot_race_smoke_passed == true + and .loot_race_failure == null + and .loot_race_target_entry == 2846 + and .loot_race_target_spawn_guid == 9106001 + and (.loot_race_target_runtime_counter | type == "number" and . > 0) + and .loot_race_party_confirmed == true + and .loot_race_target_discovered == true + and .loot_race_loot_opened == true + and (.loot_race_loot_list_id | type == "number" and . >= 0 and . <= 255) + and .loot_race_loot_coins == 10 + and .loot_race_loot_removed_seen == true + and .loot_race_coin_removed_seen == true + and .loot_race_db_item_total == 1 + and .loot_race_db_money_delta == 10 + and .loot_race_relog_verified == true) + and ([.results[].loot_race_target_runtime_counter] | unique | length == 1) + and ([.results[].loot_race_loot_list_id] | unique | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == true)] | length == 1) + and ([.results[] | select(.loot_race_item_push_seen == false)] | length == 1) + and ([.results[].loot_race_money_notify_amount] | sort == [0, 10]) + ' "$bot_report" >/dev/null; then + echo "loot-race bot report did not prove the exact successful two-session contract" >&2 + bot_status=1 + fi + fi + + if ! identity_after="$(qa_world_identity "$process_name" "$world_exec")"; then + echo "guarded capture world changed or stopped during loot-race QA" >&2 + qa_status=1 + elif [[ "$identity_after" != "$identity_capture" ]]; then + echo "guarded capture world restarted during loot-race QA" >&2 + qa_status=1 + elif ! qa_world_process_matches "$identity_after" "$world_exec" "$expected_sha"; then + echo "guarded capture world bytes changed during loot-race QA" >&2 + qa_status=1 + elif ! qa_world_ports_ready "$identity_after" "$world_port" "$instance_port"; then + echo "guarded capture world listeners disappeared during loot-race QA" >&2 + qa_status=1 + fi + if ((bot_status != 0)); then + qa_status=$bot_status + fi + + if ! printf '\n' >&"$QA_LOOT_RACE_CAPTURE_FD"; then + echo "failed to signal guarded capture completion" >&2 + ((qa_status != 0)) || qa_status=1 + fi + exec {QA_LOOT_RACE_CAPTURE_FD}>&- + QA_LOOT_RACE_CAPTURE_FD="" + wait "$QA_LOOT_RACE_CAPTURE_PID" || capture_status=$? + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=$capture_status + QA_LOOT_RACE_CAPTURE_PID="" + sed -n '1,240p' "$QA_LOOT_RACE_CAPTURE_LOG" + if ((capture_status != 0)); then + echo "guarded capture/fixture restoration failed with status $capture_status" >&2 + qa_status=$capture_status + else + if ! identity_restored="$(qa_world_identity "$process_name" "$original_exec")"; then + echo "normal PM2 world was not restored after loot-race QA" >&2 + ((qa_status != 0)) || qa_status=1 + elif [[ "$identity_restored" == "$identity_capture" ]]; then + echo "capture PM2 identity remained live instead of restoring the normal world" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_process_matches \ + "$identity_restored" "$original_canonical_exec" "$original_sha"; then + echo "restored PM2 world does not match the snapshotted normal executable" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_ports_ready "$identity_restored" "$world_port" "$instance_port"; then + echo "restored PM2 world does not own the accredited listeners" >&2 + ((qa_status != 0)) || qa_status=1 + elif ! qa_world_packet_dump_absent "$process_name"; then + echo "restored PM2 world still carries RUSTYCORE_PACKET_DUMP_DIR" >&2 + ((qa_status != 0)) || qa_status=1 + fi + fi + + trap - EXIT + trap '' HUP INT TERM + rm -f -- "$control_fifo" + if ((qa_status == 0)) \ + && [[ ! -e "$fixture_journal" && ! -L "$fixture_journal" \ + && ! -e "$fixture_cleanup_marker" && ! -L "$fixture_cleanup_marker" ]]; then + rm -f -- "$QA_LOOT_RACE_CAPTURE_LOG" "$bot_report" "$bot_log" + rmdir -- "$QA_LOOT_RACE_CAPTURE_DIR" 2>/dev/null || true + else + echo "qa-loot-race recovery artifacts retained at ${QA_LOOT_RACE_CAPTURE_DIR}" >&2 + fi + QA_LOOT_RACE_CAPTURE_DIR="" + QA_LOOT_RACE_CAPTURE_LOG="" + QA_LOOT_RACE_FIXTURE_JOURNAL="" + QA_LOOT_RACE_CAPTURE_WAIT_STATUS=0 + return "$qa_status" +} + while (($# > 0)); do case "$1" in --dry-run) @@ -743,6 +3160,10 @@ while (($# > 0)); do ALLOW_RUNTIME_QA=1 shift ;; + --ack-disposable-overworld-loot-race) + ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 + shift + ;; -h | --help) usage exit 0 @@ -764,6 +3185,7 @@ fi cd "$REPO_ROOT" require_command git +validate_rust_min_stack case "$COMMAND" in self-test) @@ -818,6 +3240,10 @@ case "$COMMAND" in (($# == 0)) || die "qa-login does not accept arguments" run_qa_login ;; + qa-loot-race) + (($# == 0)) || die "qa-loot-race does not accept arguments" + run_qa_loot_race + ;; help) usage ;; diff --git a/tools/wow-test-bot/Cargo.lock b/tools/wow-test-bot/Cargo.lock index 9b60daf30..3d34b6c5c 100644 --- a/tools/wow-test-bot/Cargo.lock +++ b/tools/wow-test-bot/Cargo.lock @@ -3067,6 +3067,7 @@ dependencies = [ "flate2", "hex", "hmac", + "libc", "mysql", "num-bigint", "num-traits", @@ -3077,6 +3078,7 @@ dependencies = [ "serde_json", "sha2", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] diff --git a/tools/wow-test-bot/Cargo.toml b/tools/wow-test-bot/Cargo.toml index 8c739f07c..812627bef 100644 --- a/tools/wow-test-bot/Cargo.toml +++ b/tools/wow-test-bot/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.88" [dependencies] tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" anyhow = "1" tracing = "0.1" tracing-subscriber = "0.3" @@ -23,6 +24,7 @@ aes-gcm = "0.10" openssl = "0.10" mysql = "25.0" flate2 = "1" +libc = "0.2" [[bin]] name = "wow-test-bot" diff --git a/tools/wow-test-bot/README.md b/tools/wow-test-bot/README.md index 4cc9f3d0a..2b1ee06e5 100644 --- a/tools/wow-test-bot/README.md +++ b/tools/wow-test-bot/README.md @@ -63,15 +63,211 @@ baseline-zero login rows. Any other rows created by the server remain visible, which is why the acknowledged disposable identity and clean preflight are mandatory. +For the two-session atomic loot-claim smoke, use the guarded local preflight +command with the feature-branch world executable pinned. The currently running +normal PM2 world may be a different build; its executable/profile is snapshotted +and restored after QA: + +```bash +test -z "$(git status --porcelain=v1 --untracked-files=normal)" +TARGET_EXEC="$(realpath /absolute/path/to/issue-106/world-server)" +BOT_EXEC="$(realpath tools/wow-test-bot/target/debug/wow-test-bot)" +RUST_CAPTURE_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +RUST_CAPTURE_EFFECTIVE_CONFIG=/home/server/trinity-legacy-install/etc/worldserver.conf \ +WOW_BOT_DB_CONF=/home/server/trinity-legacy-install/bin/worldserver.conf \ +WOW_BOT_WORLD_EXEC="$TARGET_EXEC" \ +WOW_BOT_WORLD_EXEC_SHA256="$(sha256sum "$TARGET_EXEC" | awk '{print $1}')" \ +WOW_BOT_EXEC="$BOT_EXEC" \ +WOW_BOT_EXEC_SHA256="$(sha256sum "$BOT_EXEC" | awk '{print $1}')" \ +./tools/pr-preflight.sh --allow-runtime-qa \ + --ack-disposable-overworld-loot-race qa-loot-race +``` + +The DB fixture guard and the PM2 runtime config are separate pins on this host: +the former reads the full legacy `bin/worldserver.conf`, while the latter must +match the Rust PM2 profile's effective `etc/worldserver.conf` exactly. + +This live mode is never part of normal CI. Both flags are mandatory because the +guard temporarily mutates a world GameObject fixture and two disposable +characters. The wrapper translates that explicit acknowledgement to the +binary's CLI-only, legacy-named +`--ack-disposable-overworld-loot-race` guard; direct binary runs must pass +`--loot-race-smoke --ack-disposable-overworld-loot-race` themselves. The +default disposable characters are `TESTBOT2@bot.local` and +`TESTBOT3@bot.local` (the versioned config maps them to character GUIDs `15` +and `16`). Setup rechecks each configured GUID's account ownership and requires +one character per account; every destructive cleanup query remains scoped to +those two verified GUIDs. + +Both loot modes force `WOW_BOT_ENSURE_TEST_ACCOUNTS=0`: provision the two +disposable identities first. They also require an absolute +`WOW_BOT_FIXTURE_JOURNAL`. The bot creates that mode-0600 journal before its +first mutation, removes it after verified restoration, then atomically writes +the mode-0600 JSON marker `${WOW_BOT_FIXTURE_JOURNAL}.cleanup-complete`. The +marker records schema version `1`, the journal SHA-256, and the cleanup PID. A +matching journal+marker pair is recoverable/idempotent; a mismatched marker is +rejected. The guarded preflight supplies a private path automatically and will +not restart the normal PM2 world while the journal is pending or the marker is +missing, unsafe, or malformed. Loot workflows also have a hard end-to-end +deadline of 900 seconds; override it with +`WOW_BOT_LOOT_WORKFLOW_DEADLINE_SECS` or `--loot-workflow-deadline`. + +The wrapper `exec`s loot-mode bots and enables Linux parent-death signalling so +TERM/INT reaches the handler registered before fixture mutation and a dead +wrapper cannot leave a detached mutator. If a run leaves a journal, keep the +normal world stopped and run explicit recovery with the same DB configuration: + +```bash +WOW_BOT_DB_CONF=/absolute/path/to/worldserver.conf \ +WOW_BOT_FIXTURE_JOURNAL=/absolute/path/to/fixture.journal \ +tools/wow-test-bot/target/debug/wow-test-bot --recover-loot-fixture +``` + +Recovery performs no login or service action. Only after it verifies bounded +database restoration does it remove the journal and produce the cleanup marker; +the operator may then let the outer capture/preflight wrapper restore PM2. + +The race fixture is the existing Tattered Chest template (entry `2846`, loot +template `2278`) plus wrapper-owned spawn `9106001`. The guard temporarily +installs that exact spawn and changes only the chest addon's original `0/0` +money bounds to deterministic `10/10`; the loot template must contain exactly +one unconditional item `38`. It fails closed on template/data drift, a +pre-existing spawn or GameObject respawn, loot conditions, or pool/event/linked, +spawn-group, addon, or override ownership. Its full template-addon fields are +pinned as well. The group must report C++ `PERSONAL_LOOT` (`5`). The default runtime +counter is `0` (auto): each client discovers and preserves the complete live +GameObject ObjectGuid from `SMSG_UPDATE_OBJECT`. The SQL spawn id remains +`9106001`, but, as in C++, the live low counter is generated independently by +the map and is not assumed to equal that spawn id. A nonzero +`WOW_BOT_LOOT_RACE_RUNTIME_COUNTER` is only a strict override; it never replaces +live discovery. + +The smoke snapshots and relocates both characters, forms the real two-player +party, positions both clients at the chest, and synchronizes two +`CMSG_GAME_OBJ_USE` requests for the same packed GUID. A separate barrier proves +that both `SMSG_LOOT_RESPONSE` packets arrived before the competing item and +money claims begin. It verifies one global item award, exact database +persistence after logout/relogin, and fail-closed loser evidence. + +Chest money intentionally does not use the corpse group split. In C++ +`HandleLootMoneyOpcode`, `LOOT_CHEST` keeps `shareMoney=false`: the first +serialized requester receives all `10` copper with `SoleLooter=true`, the +second receives `0` with `SoleLooter=true`, and both active viewers observe two +matching `CoinRemoved` notifications because both requests notify viewers. The +database total must therefore increase by exactly `10`, and the persisted +winner must match the positive wire notification. + +After both clients log out, the bot restores its bounded character, item, +group, and respawn snapshots. The outer fixture guard stops the QA world before +removing unchanged spawn `9106001` and restoring the addon's `0/0` money bounds; +external drift is reported instead of overwritten. Cleanup validates the whole +spawn, including persisted `state`, before any delete; the bot must not reset +that field to conceal runtime drift. + +For the item proof, every observed `ItemPushResult` is retained before checking +its entry. The harness requires one winner payload, at most one identical copy +per socket, one exact `LootGone` on the loser, and one exact `LootRemoved` per +viewer; it repeats that proof after the later money settle window. Rust's +complete `StoreLootItem` side-effect cascade is deliberately tracked by #55, +so this #106 atomicity smoke accepts either winner-only delivery or one +identical C++ group broadcast per socket, but never a divergent or duplicate +logical grant. The winner's complete wire Item GUID, +player owner, entry, count, and top-level backpack slot are then keyed back to +one `item_instance` plus `character_inventory` row and checked again after +relogin. The database stores only the Item GUID counter (not its wire high/realm +half) and does not persist packet-only fields such as `QuestLogItemID`, display, +or delivery flags; those fields are validated from the wire where the fixed +fixture makes them deterministic, not claimed as database-verifiable evidence. + +Overrides are available through `WOW_BOT_LOOT_RACE_ACCOUNT_A/B`, +`WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY`, +`WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID`, +`WOW_BOT_LOOT_RACE_RUNTIME_COUNTER`, `WOW_BOT_LOOT_RACE_ITEM_ENTRY`, and +`WOW_BOT_LOOT_RACE_TIMEOUT_SECS`. The older +`WOW_BOT_LOOT_RACE_CREATURE_ENTRY` and +`WOW_BOT_LOOT_RACE_CREATURE_SPAWN_GUID` names remain fallback aliases so +existing callers do not break; they describe the GameObject only in race mode. + +The preflight first snapshots and accredits the exact normal PM2 executable, +then drives `capture-rust.sh loot-two-session-atomic-race --yes` through a FIFO. +After its ready marker, it accredits the new capture PID against the pinned +target path/hash and world/instance listeners. That identity must not restart or +change during the bot run. The preflight then signals completion, waits for the +capture wrapper to stop the QA world and restore the fixture plus original PM2 +profile, and accredits the restored original executable again. The wrapper gets +the same fresh `WOW_BOT_REPORT` path and pinned bot path/hash and independently +requires the exact successful two-session report before it can publish a +completed capture. A failed report therefore still drives fixture/runtime +cleanup but leaves no completed race artifact. Bot failure, capture drift, or +wrapper cleanup failure all remain failures. + +The bot endpoints are forced to `WORLD_HOST=127.0.0.1`, the accredited world +port, `INSTANCE_HOST=127.0.0.1`, and an expected `INSTANCE_PORT`; the bot rejects +a different port in `SMSG_CONNECT_TO`. Because the server may still bind a +wildcard listener, run this destructive QA only on an isolated host or network +namespace whose firewall restricts the BNet/world/instance ports to loopback +traffic. This prevents another client from entering the temporary fixture world +or a green result coming from a service other than the accredited process. + +For a capture-diff golden, use the separate single-session item-only mode: + +```bash +FIXTURE_RECOVERY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rustycore-loot-item.XXXXXX")" +FIXTURE_JOURNAL="$FIXTURE_RECOVERY_DIR/fixture.journal" +if WOW_BOT_ENSURE_TEST_ACCOUNTS=0 \ + WOW_BOT_FIXTURE_JOURNAL="$FIXTURE_JOURNAL" \ + WOW_BOT_LOOT_ITEM_CAPTURE=1 \ + WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1 \ + ./run_rustycore_login_smoke.sh \ + && test ! -e "$FIXTURE_JOURNAL" \ + && test -f "${FIXTURE_JOURNAL}.cleanup-complete"; then + rm -f -- "${FIXTURE_JOURNAL}.cleanup-complete" + rmdir -- "$FIXTURE_RECOVERY_DIR" +else + echo "fixture recovery evidence retained at $FIXTURE_RECOVERY_DIR" >&2 + false +fi +``` + +It uses the separate deterministic Doctor Maleficus fixture (entry `21779`, +unique world spawn `1117`, guaranteed item `30712`, and no coin pool) +with the same fail-closed character, inventory, respawn, and cleanup checks. +The item is The Doctor's Key: account A's exact top-level keyring slot +`bag=0, slot=106` must start empty, the wire push and persisted inventory row +must both use that slot, and cleanup must restore it to empty. A matching key +placed in an ordinary backpack slot does not pass. +Its full runtime ObjectGuid is auto-discovered by default as above; the SQL +spawn id is not assumed to be the live ObjectGuid counter. +Only account A connects; account B stays offline and is used solely by the +existing bounded fixture snapshot. The bot kills and opens the creature, sends +one `CMSG_LOOT_ITEM`, and requires exactly one matching +`SMSG_LOOT_REMOVED` plus one owner-correct `SMSG_ITEM_PUSH_RESULT`, refuses any +money or inventory-failure packets, then emits a fixed-serial zero-latency +`CMSG_PING` fence immediately after those two responses and verifies its Pong +before logout. It does not retain the two-client smoke's two-second evidence +settle window. It also verifies the one item row persisted +and that neither fixture character's money changed before cleanup. This mode +must not be confused with the two-session race; it exists so the global C++ and +Rust packet logs contain one attributable session for +`loot-single-item-claim`. + `config.example.json` is versioned with blank passwords. Use `WOW_BOT_PASSWORD`, the per-account `WOW_BOT_PASSWORD_` override, or an ignored local `config.json` for credentials. Do not commit real local bot passwords. -The RustyCore wrapper defaults to `WOW_BOT_ENSURE_TEST_ACCOUNTS=1`: it -generates an ignored `.env.local` password when none exists, then upserts only -local `@bot.local` BNet/game account rows with matching SRP credentials before -running. Set `WOW_BOT_GENERATE_LOCAL_PASSWORD=0` or +Outside loot modes, the RustyCore wrapper defaults to +`WOW_BOT_ENSURE_TEST_ACCOUNTS=1`: it +generates an ignored `.env.local` password when none exists, then either creates +both missing local `@bot.local` BNet/game auth rows or validates an already +complete identity exactly before running. It never updates credentials, +reassigns a character, repairs a partial BNet/game identity, clears a ban, or +changes an online/realm-count mismatch. Set `WOW_BOT_GENERATE_LOCAL_PASSWORD=0` or `WOW_BOT_ENSURE_TEST_ACCOUNTS=0` to disable that local QA bootstrap. +Loot modes override this variable to `0` even when the caller or `.env.local` +sets it to `1`. +Explicit caller environment values always win over `.env.local`, including +mode/acknowledgement, endpoint, executable/hash, and credential variables; the +wrapper suppresses xtrace while it loads and restores those values. Build and test this bot with Rust 1.88.0 (`cargo +1.88.0 ...`), matching the RustyCore toolchain. diff --git a/tools/wow-test-bot/run_rustycore_login_smoke.sh b/tools/wow-test-bot/run_rustycore_login_smoke.sh index 9b35cec26..f2009639d 100755 --- a/tools/wow-test-bot/run_rustycore_login_smoke.sh +++ b/tools/wow-test-bot/run_rustycore_login_smoke.sh @@ -1,13 +1,55 @@ #!/usr/bin/env bash set -euo pipefail +# The ignored defaults file is data owned by the local operator. Keep xtrace +# off while snapshotting/restoring exported values so neither caller secrets +# nor values from that file can be echoed accidentally. +RC_CALLER_XTRACE=0 +if [[ "$-" == *x* ]]; then + RC_CALLER_XTRACE=1 + set +x +fi +readonly RC_CALLER_XTRACE + cd "$(dirname "$0")" -if [[ -f .env.local ]]; then +# Local defaults must never replace values explicitly pinned by the caller. +# This is especially important for runtime QA provenance (`WOW_BOT_EXEC*`) and +# for the selected destructive mode. Preserve every exported bot/runtime input, +# source the ignored defaults, then restore the caller-owned values verbatim. +declare -A caller_runtime_env=() +while IFS='=' read -r -d '' env_name env_value; do + case "$env_name" in + WOW_BOT_* | BNET_HOST | BNET_PORT | WORLD_HOST | WORLD_PORT | INSTANCE_HOST | INSTANCE_PORT | REALM_ID) + caller_runtime_env["$env_name"]="$env_value" + ;; + esac +done < <(env -0) +readonly -A caller_runtime_env + +RC_ENV_FILE="${WOW_BOT_ENV_FILE:-.env.local}" +readonly RC_ENV_FILE +RC_ENV_STATUS=0 +if [[ -f "$RC_ENV_FILE" ]]; then + set +e set -a - . ./.env.local + . "$RC_ENV_FILE" 2>/dev/null + RC_ENV_STATUS=$? set +a + set -e + set +x +fi +if ((RC_ENV_STATUS != 0)); then + echo "Failed to load WOW_BOT_ENV_FILE" >&2 + exit 2 fi +for env_name in "${!caller_runtime_env[@]}"; do + export "$env_name=${caller_runtime_env[$env_name]}" +done +unset env_name env_value +# Keep xtrace disabled while credentials and caller-owned runtime values are +# inspected. It is restored only after the complete argv has been assembled; +# the bot password is inherited through the environment, never argv. password_env_name() { local account="$1" @@ -62,6 +104,7 @@ homebind_timeout_secs="${WOW_BOT_HOMEBIND_TIMEOUT_SECS:-8}" inventory_swap_timeout_secs="${WOW_BOT_INVENTORY_SWAP_TIMEOUT_SECS:-8}" rested_xp_timeout_secs="${WOW_BOT_RESTED_XP_TIMEOUT_SECS:-120}" rested_xp_offline_secs="${WOW_BOT_RESTED_XP_OFFLINE_SECS:-86400}" +loot_race_timeout_secs="${WOW_BOT_LOOT_RACE_TIMEOUT_SECS:-30}" ensure_accounts="${WOW_BOT_ENSURE_TEST_ACCOUNTS:-1}" export BNET_HOST="${BNET_HOST:-127.0.0.1}" @@ -102,16 +145,99 @@ rested_xp_acknowledged=0 if [[ "${WOW_BOT_ACK_DISPOSABLE_RESTED_XP:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then rested_xp_acknowledged=1 fi +loot_race_requested=0 +if [[ "${WOW_BOT_LOOT_RACE_SMOKE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_race_requested=1 +fi +loot_item_capture_requested=0 +if [[ "${WOW_BOT_LOOT_ITEM_CAPTURE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_item_capture_requested=1 +fi +loot_race_acknowledged=0 +if [[ "${WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE:-0}" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then + loot_race_acknowledged=1 +fi if ((rested_xp_acknowledged && !rested_xp_requested)); then echo "WOW_BOT_ACK_DISPOSABLE_RESTED_XP is only valid with WOW_BOT_RESTED_XP_SMOKE" >&2 exit 2 fi -if ((stand_state_requested + quest_requested + bank_requested + homebind_requested + inventory_swap_requested + rested_xp_requested > 1)); then - echo "Stand-state, quest, bank, homebind, inventory-swap, and rested-XP smoke are separate modes" >&2 +if ((loot_race_acknowledged && !loot_race_requested && !loot_item_capture_requested)); then + echo "WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE is only valid with WOW_BOT_LOOT_RACE_SMOKE or WOW_BOT_LOOT_ITEM_CAPTURE" >&2 + exit 2 +fi +if ((stand_state_requested + quest_requested + bank_requested + homebind_requested + inventory_swap_requested + rested_xp_requested + loot_race_requested + loot_item_capture_requested > 1)); then + echo "Stand-state, quest, bank, homebind, inventory-swap, rested-XP, loot-race, and loot-item-capture are separate modes" >&2 + exit 2 +fi +if ((loot_race_requested || loot_item_capture_requested)) \ + && ((!loot_race_acknowledged)); then + echo "Loot fixture modes require WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 exit 2 fi -if ((rested_xp_requested)); then +if ((loot_race_requested || loot_item_capture_requested)); then + # Loot fixtures require pre-provisioned, already-owned disposable identities. + # Never let the generic local bootstrap overwrite an existing numeric account + # id or reassign a configured character before the fixture contract validates. + ensure_accounts=0 + export WOW_BOT_ENSURE_TEST_ACCOUNTS=0 + fixture_journal="${WOW_BOT_FIXTURE_JOURNAL:-}" + if [[ -z "$fixture_journal" || "$fixture_journal" != /* \ + || "$fixture_journal" == *$'\n'* ]]; then + echo "Loot fixture modes require an absolute WOW_BOT_FIXTURE_JOURNAL path" >&2 + exit 2 + fi + if [[ ! -d "$(dirname -- "$fixture_journal")" ]]; then + echo "WOW_BOT_FIXTURE_JOURNAL parent directory does not exist" >&2 + exit 2 + fi + fixture_cleanup_marker="${fixture_journal}.cleanup-complete" + if [[ -e "$fixture_journal" || -L "$fixture_journal" \ + || -e "$fixture_cleanup_marker" || -L "$fixture_cleanup_marker" ]]; then + echo "Fixture journal/cleanup marker already exists; recover or remove it explicitly before running" >&2 + exit 2 + fi + export WOW_BOT_FIXTURE_JOURNAL="$fixture_journal" + export WOW_BOT_REQUIRE_PARENT_DEATH_GUARD=1 +fi + +if ((loot_item_capture_requested)); then + if ((!loot_race_acknowledged)); then + echo "Loot-item capture kills an exact overworld fixture and requires WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 + exit 2 + fi + report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-loot-item-capture-report.json}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-loot-item-capture.log}" + mode_args=( + --loot-item-capture + --ack-disposable-overworld-loot-race + --loot-race-account-a "TESTBOT2@bot.local" + --loot-race-account-b "TESTBOT3@bot.local" + --loot-race-creature-entry "21779" + --loot-race-creature-spawn-guid "1117" + --loot-race-runtime-counter "0" + --loot-race-item-entry "30712" + --loot-race-timeout "$loot_race_timeout_secs" + ) +elif ((loot_race_requested)); then + if ((!loot_race_acknowledged)); then + echo "Loot-race smoke mutates an exact shared-chest fixture and requires WOW_BOT_ACK_DISPOSABLE_OVERWORLD_LOOT_RACE=1" >&2 + exit 2 + fi + report_path="${WOW_BOT_REPORT:-/tmp/rustycore-bot-loot-race-smoke-report.json}" + log_path="${WOW_BOT_LOG:-/tmp/rustycore-bot-loot-race-smoke.log}" + mode_args=( + --loot-race-smoke + --ack-disposable-overworld-loot-race + --loot-race-account-a "TESTBOT2@bot.local" + --loot-race-account-b "TESTBOT3@bot.local" + --loot-race-gameobject-entry "2846" + --loot-race-gameobject-spawn-guid "9106001" + --loot-race-runtime-counter "0" + --loot-race-item-entry "38" + --loot-race-timeout "$loot_race_timeout_secs" + ) +elif ((rested_xp_requested)); then if ((!rested_xp_acknowledged)); then echo "Rested-XP smoke is destructive and requires WOW_BOT_ACK_DISPOSABLE_RESTED_XP=1" >&2 exit 2 @@ -255,20 +381,45 @@ if [[ "$ensure_accounts" =~ ^(1|true|TRUE|yes|YES|on|ON)$ ]]; then ensure_args+=(--ensure-test-accounts) fi -"$bot_exec" \ - --config "${WOW_BOT_CONFIG:-config.example.json}" \ - --single "$account" \ - "${mode_args[@]}" \ - "${ensure_args[@]}" \ - --no-cleanup-groups \ - --no-auto-teleport \ - --report "$report_path" \ - >"$log_path" 2>&1 +selection_args=(--single "$account") +smoke_subject="$account" +if ((loot_race_requested || loot_item_capture_requested)); then + selection_args=() + if ((loot_item_capture_requested)); then + smoke_subject="TESTBOT2@bot.local (single-session item capture)" + else + smoke_subject="TESTBOT2@bot.local and TESTBOT3@bot.local" + fi +fi + +bot_command=( + "$bot_exec" + --config "${WOW_BOT_CONFIG:-config.example.json}" + "${selection_args[@]}" + "${mode_args[@]}" + "${ensure_args[@]}" + --no-cleanup-groups + --no-auto-teleport + --report "$report_path" +) + +if ((RC_CALLER_XTRACE)); then + set -x +fi + +if ((loot_race_requested || loot_item_capture_requested)); then + # Preserve PID ownership across the shell boundary. TERM/INT reaches the + # Rust handler installed before fixture mutation, and SIGKILL cannot leave a + # detached bot child continuing to mutate the durable fixture. + exec "${bot_command[@]}" >"$log_path" 2>&1 +fi + +"${bot_command[@]}" >"$log_path" 2>&1 -echo "RustyCore smoke passed for $account" +echo "RustyCore smoke passed for $smoke_subject" echo "log: $log_path" echo "report: $report_path" if command -v jq >/dev/null 2>&1; then - jq '{login_only, quest_smoke, stand_state_smoke, bank_smoke, homebind_smoke, inventory_swap_smoke, rested_xp_smoke, results: [.results[] | {account, world_auth, enum_characters, player_login_verified, stand_state_smoke, stand_state_smoke_passed, stand_states_requested, stand_states_confirmed, stand_state_failure, bank_smoke, bank_smoke_passed, bank_banker_entry, bank_banker_spawn_guid, bank_banker_guid_counter, bank_item_guid, bank_item_entry, bank_inventory_slot, bank_bank_slot, bank_open_confirmed, bank_deposit_persisted, bank_relogin_after_deposit, bank_withdraw_persisted, bank_failure, homebind_smoke, homebind_smoke_passed, homebind_innkeeper_entry, homebind_innkeeper_spawn_guid, homebind_innkeeper_guid_counter, homebind_spell_go_seen, homebind_bind_point_update_seen, homebind_player_bound_seen, homebind_gossip_complete_seen, homebind_db_persisted, homebind_relogin_verified, homebind_failure, inventory_swap_smoke, inventory_swap_smoke_passed, inventory_swap_item_guid_a, inventory_swap_item_guid_b, inventory_swap_item_entry_a, inventory_swap_item_entry_b, inventory_swap_slot_a, inventory_swap_slot_b, inventory_swap_forward_persisted, inventory_swap_relogin_after_forward, inventory_swap_reverse_persisted, inventory_swap_failure, rested_xp_smoke, rested_xp_smoke_passed, rested_xp_offline_wilderness_bonus, rested_xp_offline_resting_bonus, rested_xp_target_entry, rested_xp_target_spawn_guid, rested_xp_target_guid_counter, rested_xp_packet_amount, rested_xp_packet_original, rested_xp_db_xp_before, rested_xp_db_xp_after, rested_xp_db_rest_before, rested_xp_db_rest_after, rested_xp_relog_verified, rested_xp_failure, quest_smoke_passed, quest_target_entry, quest_target_spawn_guid, quest_target_guid_counter, quest_ids_seen, quest_titles_seen, quest_accept_sent, quest_accept_confirm_seen, quest_db_verified, quest_db_status, quest_failure, join_result}]}' "$report_path" + jq '{login_only, quest_smoke, stand_state_smoke, bank_smoke, homebind_smoke, inventory_swap_smoke, rested_xp_smoke, loot_race_smoke, results: [.results[] | {account, world_auth, enum_characters, player_login_verified, stand_state_smoke, stand_state_smoke_passed, stand_states_requested, stand_states_confirmed, stand_state_failure, bank_smoke, bank_smoke_passed, bank_banker_entry, bank_banker_spawn_guid, bank_banker_guid_counter, bank_item_guid, bank_item_entry, bank_inventory_slot, bank_bank_slot, bank_open_confirmed, bank_deposit_persisted, bank_relogin_after_deposit, bank_withdraw_persisted, bank_failure, homebind_smoke, homebind_smoke_passed, homebind_innkeeper_entry, homebind_innkeeper_spawn_guid, homebind_innkeeper_guid_counter, homebind_spell_go_seen, homebind_bind_point_update_seen, homebind_player_bound_seen, homebind_gossip_complete_seen, homebind_db_persisted, homebind_relogin_verified, homebind_failure, inventory_swap_smoke, inventory_swap_smoke_passed, inventory_swap_item_guid_a, inventory_swap_item_guid_b, inventory_swap_item_entry_a, inventory_swap_item_entry_b, inventory_swap_slot_a, inventory_swap_slot_b, inventory_swap_forward_persisted, inventory_swap_relogin_after_forward, inventory_swap_reverse_persisted, inventory_swap_failure, rested_xp_smoke, rested_xp_smoke_passed, rested_xp_offline_wilderness_bonus, rested_xp_offline_resting_bonus, rested_xp_target_entry, rested_xp_target_spawn_guid, rested_xp_target_guid_counter, rested_xp_packet_amount, rested_xp_packet_original, rested_xp_db_xp_before, rested_xp_db_xp_after, rested_xp_db_rest_before, rested_xp_db_rest_after, rested_xp_relog_verified, rested_xp_failure, loot_race_smoke, loot_race_smoke_passed, loot_race_target_entry, loot_race_target_spawn_guid, loot_race_target_runtime_counter, loot_race_party_confirmed, loot_race_target_discovered, loot_race_loot_opened, loot_race_loot_list_id, loot_race_loot_coins, loot_race_item_push_seen, loot_race_loot_removed_seen, loot_race_money_notify_amount, loot_race_coin_removed_seen, loot_race_db_item_total, loot_race_db_money_delta, loot_race_relog_verified, loot_race_failure, quest_smoke_passed, quest_target_entry, quest_target_spawn_guid, quest_target_guid_counter, quest_ids_seen, quest_titles_seen, quest_accept_sent, quest_accept_confirm_seen, quest_db_verified, quest_db_status, quest_failure, join_result}]}' "$report_path" fi diff --git a/tools/wow-test-bot/src/bot_srp6.rs b/tools/wow-test-bot/src/bot_srp6.rs index 78c8a0b43..8fd3fcb72 100644 --- a/tools/wow-test-bot/src/bot_srp6.rs +++ b/tools/wow-test-bot/src/bot_srp6.rs @@ -17,6 +17,10 @@ use rand::{thread_rng, RngCore}; use reqwest::Client; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use std::time::Duration; + +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const BOT_SRP_N_HEX: &str = "894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7"; const BNET_SRP_V1_N_HEX: &str = concat!( @@ -58,19 +62,31 @@ pub fn bnet_v1_registration_material_like_cpp( email: &str, password: &str, ) -> (String, [u8; 32], Vec) { + let mut salt = [0u8; 32]; + thread_rng().fill_bytes(&mut salt); + let (normalized_email, verifier) = bnet_v1_verifier_for_salt_like_cpp(email, password, &salt); + (normalized_email, salt, verifier) +} + +/// Recompute the Battle.net SRP v1 verifier for an existing salt without +/// mutating account state. The loot QA preflight uses this to prove that the +/// configured credentials already belong to the exact disposable fixture. +pub fn bnet_v1_verifier_for_salt_like_cpp( + email: &str, + password: &str, + salt: &[u8], +) -> (String, Vec) { let normalized_email = utf8_to_upper_only_latin_like_cpp(email); let username = bnet_srp_username_like_cpp(&normalized_email); let password = utf8_to_upper_only_latin_like_cpp(password); - let mut salt = [0u8; 32]; - thread_rng().fill_bytes(&mut salt); let inner = sha256_concat(&[username.as_bytes(), b":", password.as_bytes()]); - let outer = sha256_concat(&[&salt, &inner]); + let outer = sha256_concat(&[salt, &inner]); let x = BigUint::from_bytes_le(&outer); let n = BigUint::parse_bytes(BNET_SRP_V1_N_HEX.as_bytes(), 16) .expect("failed to parse BNet SRP v1 modulus"); let g = BigUint::from(BOT_SRP_G); let verifier = g.modpow(&x, &n).to_bytes_le(); - (normalized_email, salt, verifier) + (normalized_email, verifier) } fn decode_server_hex(value: &str) -> Result, hex::FromHexError> { @@ -223,6 +239,8 @@ pub async fn authenticate_bot( Client::builder() .danger_accept_invalid_certs(true) .http1_only() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() }; @@ -396,6 +414,8 @@ async fn authenticate_direct_login_fallback( let client = Client::builder() .danger_accept_invalid_certs(true) .http1_only() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) .pool_max_idle_per_host(0) .redirect(reqwest::redirect::Policy::none()) .build()?; @@ -437,6 +457,20 @@ async fn authenticate_direct_login_fallback( mod tests { use super::*; + #[test] + fn stored_salt_verifier_preflight_is_deterministic() { + let (email, salt, verifier) = + bnet_v1_registration_material_like_cpp("testbot@bot.local", "fixture-password"); + let (recomputed_email, recomputed) = + bnet_v1_verifier_for_salt_like_cpp("testbot@bot.local", "fixture-password", &salt); + assert_eq!(email, recomputed_email); + assert_eq!(verifier, recomputed); + assert_ne!( + verifier, + bnet_v1_verifier_for_salt_like_cpp("testbot@bot.local", "different-password", &salt).1 + ); + } + #[test] fn test_bot_srp6_calculations() { let mut client = BotSrp6Client::new("test@example.com", "password123"); diff --git a/tools/wow-test-bot/src/loot_race.rs b/tools/wow-test-bot/src/loot_race.rs new file mode 100644 index 000000000..f7e08e012 --- /dev/null +++ b/tools/wow-test-bot/src/loot_race.rs @@ -0,0 +1,6796 @@ +//! Two-client live smoke for atomic shared world-loot claims. +//! +//! This is deliberately a destructive QA-only workflow. The two-session race +//! uses a wrapper-installed, shared Tattered Chest GameObject, while the strict +//! one-session capture continues to use stationary Doctor Maleficus. Callers +//! must acknowledge that consuming either live fixture requires a world +//! restart before it can be reused. + +use super::*; +use mysql::params; +use mysql::prelude::Queryable; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::{Barrier, Mutex}; +use tokio_util::sync::CancellationToken; + +pub(super) const DEFAULT_ACCOUNT_A: &str = "TESTBOT2@bot.local"; +pub(super) const DEFAULT_ACCOUNT_B: &str = "TESTBOT3@bot.local"; +// Race and strict capture intentionally use different fixture kinds. The +// wrapper installs exactly one non-pooled Tattered Chest spawn before the +// world starts; Doctor Maleficus remains the creature-only capture fixture. +// The public constant names remain stable for existing callers; main.rs now +// exposes GameObject spellings and retains `--loot-race-creature-*` aliases. +pub(super) const DEFAULT_CREATURE_ENTRY: u32 = 2_846; +pub(super) const DEFAULT_CREATURE_SPAWN_GUID: u64 = 9_106_001; +pub(super) const DEFAULT_RUNTIME_COUNTER: u64 = 0; +pub(super) const DEFAULT_ITEM_ENTRY: u32 = 38; +pub(super) const DEFAULT_CAPTURE_CREATURE_ENTRY: u32 = 21_779; +pub(super) const DEFAULT_CAPTURE_CREATURE_SPAWN_GUID: u64 = 1_117; +pub(super) const DEFAULT_CAPTURE_RUNTIME_COUNTER: u64 = 0; +pub(super) const DEFAULT_CAPTURE_ITEM_ENTRY: u32 = 30_712; +pub(super) const DEFAULT_TIMEOUT_SECS: u64 = 30; +pub(super) const DEFAULT_WORKFLOW_DEADLINE_SECS: u64 = 900; +pub(super) const ACK_FLAG: &str = "--ack-disposable-overworld-loot-race"; +const GUARDED_FIXTURE_HEALTH_MODIFIER: f32 = 0.0001; +const RACE_GAMEOBJECT_MAP_ID: u16 = 0; +const RACE_GAMEOBJECT_X: f64 = -8_946.95; +const RACE_GAMEOBJECT_Y: f64 = -132.493; +const RACE_GAMEOBJECT_Z: f64 = 83.5312; +const RACE_GAMEOBJECT_LOOT_ID: u32 = 2_278; +const RACE_GAMEOBJECT_MONEY: u32 = 10; +const RACE_GAMEOBJECT_ADDON_FACTION: u16 = 101; +const RACE_GAMEOBJECT_RESPAWN_SECS: u32 = 300; +const RACE_GAMEOBJECT_STATE: u8 = 1; +const RACE_GAMEOBJECT_ANIM_PROGRESS: u8 = 255; +const RACE_GAMEOBJECT_TEMPLATE_DATA: [i32; 35] = [ + 57, + RACE_GAMEOBJECT_LOOT_ID as i32, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, +]; +const PERSONAL_LOOT_METHOD_LIKE_CPP: u8 = 5; +const HIGH_GUID_CREATURE: u64 = 8; +const HIGH_GUID_GAMEOBJECT: u64 = 11; + +const CMSG_PARTY_INVITE: u16 = 0x3604; +const CMSG_PARTY_INVITE_RESPONSE: u16 = 0x3606; +const CMSG_LEAVE_GROUP: u16 = 0x364C; +const CMSG_GAME_OBJ_USE: u16 = 0x34EE; +const CMSG_LOOT_UNIT: u16 = 0x320F; +const CMSG_LOOT_ITEM: u16 = 0x3211; +const CMSG_LOOT_MONEY: u16 = 0x3210; +const SMSG_PARTY_INVITE: u16 = 0x25BD; +const SMSG_PARTY_UPDATE: u16 = 0x25F4; +const SMSG_PARTY_COMMAND_RESULT: u16 = 0x2796; +const SMSG_PARTY_MEMBER_FULL_STATE: u16 = 0x2759; +const SMSG_LOOT_RESPONSE: u16 = 0x2614; +const SMSG_LOOT_REMOVED: u16 = 0x2615; +const SMSG_LOOT_MONEY_NOTIFY: u16 = 0x261C; +const SMSG_COIN_REMOVED: u16 = 0x2617; +const SMSG_ITEM_PUSH_RESULT: u16 = 0x2623; +const RESPONSE_SETTLE: Duration = Duration::from_secs(2); +const LOOT_ITEM_CAPTURE_FENCE_SERIAL: u32 = 0x4C4F_4F54; +// The Doctor's Key (30712) is a key and C++ stores this exact fixture in the +// first keyring destination represented by the committed capture. +const LOOT_ITEM_CAPTURE_KEYRING_SLOT: u8 = 106; +// Normal C++ logout consumes 20 seconds before SMSG_LOGOUT_COMPLETE. Leave a +// bounded disconnect-save margin before any destructive fixture restoration. +const LOOT_FIXTURE_OFFLINE_WAIT_SECS: u64 = 90; +const LOOT_LOGOUT_DB_CONFIRM_WAIT_SECS: u64 = 5; +const LOOT_DB_OPERATION_TIMEOUT_SECS: u64 = 30; +const LOOT_CLEANUP_TIMEOUT_SECS: u64 = 180; +const FIXTURE_JOURNAL_VERSION: u32 = 1; +const FIXTURE_JOURNAL_ENV: &str = "WOW_BOT_FIXTURE_JOURNAL"; +const HIGH_GUID_ITEM: u64 = 3; +const HIGH_GUID_LOOT_OBJECT: u64 = 15; +const GUID_HIGH_TYPE_MASK: u64 = 0x3F; +const GUID_REALM_SPECIFIC_MASK: u64 = 0xFFFF; +const GUID_REALM_MASK: u64 = 0x1FFF; +const GUID_MAP_MASK: u64 = 0x1FFF; +const GUID_ENTRY_MASK: u64 = 0x7F_FFFF; +const GUID_SUBTYPE_MASK: u64 = 0x3F; +const GUID_SERVER_MASK: u64 = 0xFF_FFFF; +const GUID_COUNTER_MASK: u64 = 0xFF_FFFF_FFFF; +// C++ Player.cpp `MAX_MONEY_AMOUNT` and Rust's represented player cap. +const MAX_PLAYER_MONEY_LIKE_CPP: u64 = 99_999_999_999; +const CHARACTER_PROGRESS_TABLES: &[&str] = &[ + "character_achievement_progress", + "character_achievement", + "character_queststatus_objectives_criteria_progress", + "character_queststatus_objectives_criteria", + "character_queststatus_objectives", + "character_queststatus_daily", + "character_queststatus_monthly", + "character_queststatus_rewarded", + "character_queststatus_seasonal", + "character_queststatus_weekly", + "character_queststatus", + "character_reputation", +]; + +#[derive(Debug, Clone)] +pub(super) struct LootRaceCli { + pub account_a: String, + pub account_b: String, + pub entry: u32, + pub spawn_guid: u64, + pub runtime_counter: u64, + pub item_entry: u32, + pub timeout_secs: u64, + pub workflow_deadline_secs: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LootRacePhase { + Race, + CaptureItem, + VerifyRelog, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum LootRaceTargetKind { + Creature, + GameObject, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LootFixturePurpose { + Race, + CaptureItem, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogoutCompletionRoute { + Realm, + Instance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PartyPacketRoute { + Realm, + Instance, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct LootRaceTarget { + kind: LootRaceTargetKind, + pub entry: u32, + pub spawn_guid: u64, + /// Optional operator override. Zero means discover the complete live GUID + /// from SMSG_UPDATE_OBJECT after the exact SQL spawn has passed preflight. + pub runtime_counter_override: u64, + pub map_id: u16, + pub x: f64, + pub y: f64, + pub z: f64, + pub item_entry: u32, +} + +#[derive(Debug, Clone)] +pub(super) struct LootRaceOptions { + pub phase: LootRacePhase, + pub participant: usize, + pub character_guid: u64, + pub peer_name: String, + pub peer_character_guid: u64, + pub killer_character_guid: u64, + pub target: LootRaceTarget, + pub timeout_secs: u64, + sync: Arc, +} + +#[derive(Debug, Clone)] +struct CharacterFixture { + bot: config::BotConfig, + name: String, + race: u8, + money: u64, + core: CharacterCoreSnapshot, + position: CharacterPositionSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct CharacterCoreSnapshot { + level: u8, + xp: u32, + health: u32, + powers: [u32; 10], + rest_state: u8, + rest_bonus: f32, + explored_zones: Option, + known_titles: Option, + chosen_title: u32, +} + +/// Persistent rows that a creature kill, item acquisition, or the resulting +/// C++ criteria fanout can mutate. These are snapshotted for both dedicated +/// disposable characters and restored transactionally after every run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CharacterProgressSnapshot { + achievements: Vec<(u64, u32, i64)>, + achievement_progress: Vec<(u64, u32, u64, i64)>, + quest_status: Vec<(u64, u32, u8, u8, i64, i64)>, + quest_daily: Vec<(u64, u32, i64)>, + quest_monthly: Vec<(u64, u32)>, + quest_objectives: Vec<(u64, u32, u8, i32)>, + quest_objective_criteria: Vec<(u64, u32)>, + quest_objective_criteria_progress: Vec<(u64, u32, u64, i64)>, + quest_rewarded: Vec<(u64, u32, u8)>, + quest_seasonal: Vec<(u64, u32, u32, i64)>, + quest_weekly: Vec<(u64, u32)>, + reputation: Vec<(u64, u16, i32, u16)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RespawnSnapshot { + respawn_time: i64, + map_id: u16, + instance_id: u32, +} + +#[derive(Debug, Clone)] +struct LootRaceFixture { + characters: [CharacterFixture; 2], + target: LootRaceTarget, + respawn: Option, + respawn_type: u16, + gameobject_state: Option, + progress: CharacterProgressSnapshot, + journal: FixtureJournal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct JournalCharacterFixture { + account: String, + account_id: u32, + character_guid: u64, + name: String, + race: u8, + money: u64, + core: CharacterCoreSnapshot, + position: CharacterPositionSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FixtureJournalRecord { + version: u32, + created_by_pid: u32, + characters: [JournalCharacterFixture; 2], + target: LootRaceTarget, + respawn: Option, + respawn_type: u16, + gameobject_state: Option, + progress: CharacterProgressSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CleanupMarkerRecord { + version: u32, + journal_sha256: String, + cleanup_pid: u32, +} + +#[derive(Debug, Clone)] +struct FixtureJournal { + path: PathBuf, +} + +fn configured_fixture_journal_path() -> Result { + let raw = std::env::var(FIXTURE_JOURNAL_ENV).map_err(|_| { + anyhow!("loot workflows require {FIXTURE_JOURNAL_ENV}=") + })?; + let path = PathBuf::from(raw); + if !path.is_absolute() || path.file_name().is_none() { + bail!("{FIXTURE_JOURNAL_ENV} must name an absolute file path"); + } + let parent = path + .parent() + .ok_or_else(|| anyhow!("fixture journal has no parent directory"))?; + let metadata = fs::symlink_metadata(parent) + .with_context(|| format!("Inspect fixture-journal directory {}", parent.display()))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + bail!( + "fixture-journal parent {} must be a real directory", + parent.display() + ); + } + Ok(path) +} + +fn cleanup_marker_path(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.cleanup-complete", path.display())) +} + +pub(super) fn validate_journal_contract() -> Result<()> { + let path = configured_fixture_journal_path()?; + let marker = cleanup_marker_path(&path); + if fs::symlink_metadata(&path).is_ok() { + bail!( + "pending fixture journal {} exists; run --recover-loot-fixture before another QA run", + path.display() + ); + } + if fs::symlink_metadata(&marker).is_ok() { + bail!( + "stale cleanup marker {} exists; each QA run must use a fresh journal path", + marker.display() + ); + } + Ok(()) +} + +impl FixtureJournalRecord { + fn from_fixture(fixture: &LootRaceFixture) -> Self { + Self { + version: FIXTURE_JOURNAL_VERSION, + created_by_pid: std::process::id(), + characters: fixture + .characters + .clone() + .map(|character| JournalCharacterFixture { + account: character.bot.account, + account_id: character.bot.account_id, + character_guid: character.bot.character_guid, + name: character.name, + race: character.race, + money: character.money, + core: character.core, + position: character.position, + }), + target: fixture.target.clone(), + respawn: fixture.respawn.clone(), + respawn_type: fixture.respawn_type, + gameobject_state: fixture.gameobject_state, + progress: fixture.progress.clone(), + } + } + + fn into_fixture(self, journal: FixtureJournal) -> Result { + if self.version != FIXTURE_JOURNAL_VERSION { + bail!( + "unsupported fixture-journal version {}; expected {}", + self.version, + FIXTURE_JOURNAL_VERSION + ); + } + Ok(LootRaceFixture { + characters: self.characters.map(|character| CharacterFixture { + bot: config::BotConfig { + account: character.account, + password: String::new(), + character_guid: character.character_guid, + account_id: character.account_id, + lfg_role: 0, + class: String::new(), + enabled: false, + session_key_bnet: String::new(), + }, + name: character.name, + race: character.race, + money: character.money, + core: character.core, + position: character.position, + }), + target: self.target, + respawn: self.respawn, + respawn_type: self.respawn_type, + gameobject_state: self.gameobject_state, + progress: self.progress, + journal, + }) + } +} + +impl FixtureJournal { + fn configured() -> Result { + Ok(Self { + path: configured_fixture_journal_path()?, + }) + } + + fn persist(&self, fixture: &LootRaceFixture) -> Result<()> { + let record = FixtureJournalRecord::from_fixture(fixture); + let payload = serde_json::to_vec_pretty(&record) + .context("Serialize durable loot-fixture recovery journal")?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&self.path) + .with_context(|| { + format!( + "Create durable loot-fixture journal {}", + self.path.display() + ) + })?; + file.write_all(&payload) + .context("Write durable loot-fixture recovery journal")?; + file.write_all(b"\n") + .context("Terminate durable loot-fixture recovery journal")?; + file.sync_all() + .context("fsync durable loot-fixture recovery journal")?; + sync_parent_directory(&self.path)?; + Ok(()) + } + + fn load(path: PathBuf) -> Result { + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("Inspect pending fixture journal {}", path.display()))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + bail!("pending fixture journal must be a regular non-symlink file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mode() & 0o777 != 0o600 { + bail!("pending fixture journal permissions must be exactly 0600"); + } + } + let bytes = fs::read(&path) + .with_context(|| format!("Read pending fixture journal {}", path.display()))?; + let record: FixtureJournalRecord = + serde_json::from_slice(&bytes).context("Parse pending fixture journal")?; + record.into_fixture(Self { path }) + } + + fn complete(&self) -> Result<()> { + use sha2::{Digest, Sha256}; + + let journal_bytes = fs::read(&self.path) + .with_context(|| format!("Read fixture journal {}", self.path.display()))?; + let digest = hex::encode(Sha256::digest(&journal_bytes)); + let marker = cleanup_marker_path(&self.path); + if fs::symlink_metadata(&marker).is_ok() { + validate_cleanup_marker(&marker, Some(&digest))?; + } else { + let temp_marker = + PathBuf::from(format!("{}.tmp.{}", marker.display(), std::process::id())); + let marker_payload = CleanupMarkerRecord { + version: FIXTURE_JOURNAL_VERSION, + journal_sha256: digest.clone(), + cleanup_pid: std::process::id(), + }; + let write_result = (|| -> Result<()> { + let mut marker_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_marker) + .with_context(|| { + format!("Create temporary cleanup marker {}", temp_marker.display()) + })?; + serde_json::to_writer_pretty(&mut marker_file, &marker_payload) + .context("Write fixture cleanup marker")?; + marker_file + .write_all(b"\n") + .context("Terminate fixture cleanup marker")?; + marker_file + .sync_all() + .context("fsync fixture cleanup marker")?; + fs::hard_link(&temp_marker, &marker).with_context(|| { + format!( + "Atomically publish cleanup marker without replacement {}", + marker.display() + ) + })?; + fs::remove_file(&temp_marker).with_context(|| { + format!("Remove temporary cleanup marker {}", temp_marker.display()) + })?; + sync_parent_directory(&marker) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temp_marker); + } + write_result?; + validate_cleanup_marker(&marker, Some(&digest))?; + } + fs::remove_file(&self.path) + .with_context(|| format!("Remove completed journal {}", self.path.display()))?; + sync_parent_directory(&marker)?; + Ok(()) + } +} + +fn validate_cleanup_marker(path: &Path, expected_digest: Option<&str>) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("Inspect cleanup marker {}", path.display()))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + bail!("cleanup marker must be a regular non-symlink file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mode() & 0o777 != 0o600 { + bail!("cleanup marker permissions must be exactly 0600"); + } + } + let marker: CleanupMarkerRecord = serde_json::from_slice( + &fs::read(path).with_context(|| format!("Read cleanup marker {}", path.display()))?, + ) + .context("Parse cleanup marker")?; + if marker.version != FIXTURE_JOURNAL_VERSION + || marker.journal_sha256.len() != 64 + || !marker + .journal_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + || expected_digest.is_some_and(|expected| marker.journal_sha256 != expected) + { + bail!("cleanup marker does not match the durable journal contract"); + } + Ok(()) +} + +fn sync_parent_directory(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path {} has no parent", path.display()))?; + let directory = fs::File::open(parent) + .with_context(|| format!("Open directory {} for fsync", parent.display()))?; + directory + .sync_all() + .with_context(|| format!("fsync directory {}", parent.display())) +} + +pub(super) async fn recover_pending_fixture() -> Result<()> { + if !recover_pending_fixture_if_present().await? { + let path = configured_fixture_journal_path()?; + bail!("no pending fixture journal exists at {}", path.display()); + } + Ok(()) +} + +pub(super) async fn recover_pending_fixture_if_present() -> Result { + let path = configured_fixture_journal_path()?; + if fs::symlink_metadata(&path).is_err() { + let marker = cleanup_marker_path(&path); + if fs::symlink_metadata(&marker).is_ok() { + validate_cleanup_marker(&marker, None)?; + return Ok(true); + } + return Ok(false); + } + tokio::time::timeout( + Duration::from_secs(LOOT_CLEANUP_TIMEOUT_SECS), + tokio::task::spawn_blocking(move || { + let fixture = FixtureJournal::load(path)?; + cleanup_fixture(&fixture) + }), + ) + .await + .map_err(|_| anyhow!("fixture recovery exceeded {LOOT_CLEANUP_TIMEOUT_SECS}s"))? + .map_err(|error| anyhow!("fixture recovery DB worker join failed: {error}"))??; + Ok(true) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LootWindow { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + coins: u32, + item_entry: u32, + quantity: u32, + loot_list_id: u8, + loot_method: u8, +} + +#[derive(Debug, Clone, Default)] +struct WireEvidence { + item_pushes: Vec, + loot_removed: Vec, + money_notifies: Vec, + coin_removed: Vec<(u64, u64)>, + inventory_failures: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LootRemovedEvidence { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + loot_list_id: u8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ItemPush { + player_low: u64, + player_high: u64, + slot: u8, + slot_in_bag: i32, + quest_log_item_id: i32, + quantity: i32, + quantity_in_inventory: i32, + dungeon_encounter_id: i32, + item_guid_low: u64, + item_guid_high: u64, + pushed: bool, + created: bool, + display_text: u8, + is_bonus_roll: bool, + is_encounter_loot: bool, + item_entry: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WireItemGrant { + participant: usize, + push: ItemPush, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExpectedPersistedItemGrant { + owner_guid: u64, + push: ItemPush, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExpectedPersistedMoneyGrant { + owner_guid: u64, + amount: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PersistedItemGrantRow { + item_guid: u64, + owner_guid: u64, + item_entry: u32, + count: u32, + inventory_owner: Option, + bag_guid: Option, + slot: Option, + bag_slot: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InventoryFailure { + result: i32, + item_0_low: u64, + item_0_high: u64, + item_1_low: u64, + item_1_high: u64, + container_b_slot: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MoneyNotify { + money: u64, + money_mod: u64, + sole_looter: bool, +} + +#[derive(Debug)] +struct LootRaceSync { + logged_in: Barrier, + party_ready: Barrier, + positioned: Barrier, + use_ready: Barrier, + response_received: Barrier, + windows_ready: Barrier, + item_claim: Barrier, + item_observed: Barrier, + money_claim: Barrier, + money_observed: Barrier, + before_leave: Barrier, + windows: Mutex<[Option; 2]>, + evidence: Mutex<[WireEvidence; 2]>, + cancel_reason: StdMutex>, + cancellation: CancellationToken, + /// Complete live ObjectGuid observed on the wire. C++ includes realm bits + /// in the high half, so reconstructing it from the SQL spawn/counter is + /// not equivalent to preserving this value. + runtime_guid: StdMutex>, +} + +impl LootRaceSync { + fn new() -> Self { + Self { + logged_in: Barrier::new(2), + party_ready: Barrier::new(2), + positioned: Barrier::new(2), + use_ready: Barrier::new(2), + response_received: Barrier::new(2), + windows_ready: Barrier::new(2), + item_claim: Barrier::new(2), + item_observed: Barrier::new(2), + money_claim: Barrier::new(2), + money_observed: Barrier::new(2), + before_leave: Barrier::new(2), + windows: Mutex::new([None, None]), + evidence: Mutex::new([WireEvidence::default(), WireEvidence::default()]), + cancel_reason: StdMutex::new(None), + cancellation: CancellationToken::new(), + runtime_guid: StdMutex::new(None), + } + } + + fn cancel(&self, reason: impl Into) { + let reason = reason.into(); + if let Ok(mut stored) = self.cancel_reason.lock() { + if stored.is_none() { + *stored = Some(reason); + } + } + self.cancellation.cancel(); + } + + fn cancellation_error(&self) -> Result<()> { + if !self.cancellation.is_cancelled() { + return Ok(()); + } + let reason = self + .cancel_reason + .lock() + .ok() + .and_then(|reason| reason.clone()) + .unwrap_or_else(|| "peer failed without a recorded reason".to_string()); + bail!("loot-race cancelled because {reason}") + } + + async fn cancelled(&self) { + self.cancellation.cancelled().await; + } +} + +impl LootRaceOptions { + fn resolved_runtime_guid(&self) -> Result<(u64, u64)> { + self.sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))? + .as_ref() + .copied() + .ok_or_else(|| { + anyhow!( + "loot-race target entry {} spawn {} has no discovered live ObjectGuid", + self.target.entry, + self.target.spawn_guid + ) + }) + } + + pub(super) fn resolved_runtime_counter(&self) -> Result { + Ok(self.resolved_runtime_guid()?.0 & OBJECT_GUID_COUNTER_MASK) + } + + fn resolved_packed_guid(&self) -> Result> { + let (low, high) = self.resolved_runtime_guid()?; + Ok(build_packed_guid(low, high)) + } +} + +pub(super) fn validate_cli( + race_enabled: bool, + capture_enabled: bool, + acknowledged: bool, + bots: &[config::BotConfig], + cli: &LootRaceCli, +) -> Result<()> { + if race_enabled && capture_enabled { + bail!("--loot-race-smoke and --loot-item-capture are separate workflows"); + } + let enabled = race_enabled || capture_enabled; + if !enabled { + if acknowledged { + bail!( + "{} is only valid with --loot-race-smoke or --loot-item-capture", + ACK_FLAG + ); + } + return Ok(()); + } + if !acknowledged { + bail!( + "the selected loot workflow requires {}; this acknowledges consuming a disposable world-loot fixture whose live runtime cannot be restored without a world restart", + ACK_FLAG + ); + } + if cli.account_a.eq_ignore_ascii_case(&cli.account_b) { + bail!("loot-race account A and B must be different"); + } + if bots.len() != 2 { + bail!( + "the guarded loot fixture requires exactly the two selected configured bots; capture mode logs in only account A and keeps account B offline" + ); + } + for expected in [&cli.account_a, &cli.account_b] { + if !bots + .iter() + .any(|bot| bot.account.eq_ignore_ascii_case(expected)) + { + bail!("loot-race account `{expected}` is not an enabled configured bot"); + } + } + if cli.entry == 0 || cli.spawn_guid == 0 || cli.item_entry == 0 { + bail!("loot-race target entry/spawn/item entry must all be nonzero"); + } + if race_enabled + && (cli.entry, cli.spawn_guid, cli.item_entry) + != ( + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY, + ) + { + bail!( + "the two-client race is pinned to wrapper-owned Tattered Chest entry/spawn/item {}/{}/{}; custom race fixtures are not cancel-safe", + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY + ); + } + if cli.runtime_counter & !OBJECT_GUID_COUNTER_MASK != 0 { + bail!("loot-race runtime counter exceeds the 40-bit ObjectGuid counter field"); + } + if cli.timeout_secs == 0 { + bail!("--loot-race-timeout must be greater than zero"); + } + if cli.workflow_deadline_secs == 0 || cli.workflow_deadline_secs <= cli.timeout_secs { + bail!("--loot-workflow-deadline must be greater than the per-phase loot timeout"); + } + Ok(()) +} + +pub(super) async fn run_workflow( + mut bots: Vec, + cli: LootRaceCli, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + shutdown: CancellationToken, +) -> Result> { + let workflow_deadline = + tokio::time::Instant::now() + Duration::from_secs(cli.workflow_deadline_secs); + if shutdown.is_cancelled() { + bail!("loot-race cancelled before fixture setup"); + } + bots.sort_by_key(|bot| { + if bot.account.eq_ignore_ascii_case(&cli.account_a) { + 0 + } else { + 1 + } + }); + let setup_bots = bots.clone(); + let setup_cli = cli.clone(); + let setup_shutdown = shutdown.clone(); + let fixture = tokio::task::spawn_blocking(move || { + prepare_fixture( + &setup_bots, + &setup_cli, + LootFixturePurpose::Race, + &setup_shutdown, + ) + }) + .await + .map_err(|error| anyhow!("loot-race fixture DB worker join failed: {error}"))??; + + let sync = Arc::new(LootRaceSync::new()); + let mut handles = tokio::task::JoinSet::new(); + for participant in 0..2 { + let bot = bots[participant].clone(); + let options = LootRaceOptions { + phase: LootRacePhase::Race, + participant, + character_guid: fixture.characters[participant].bot.character_guid, + peer_name: fixture.characters[1 - participant].name.clone(), + peer_character_guid: fixture.characters[1 - participant].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync: Arc::clone(&sync), + }; + let task_sync = Arc::clone(&sync); + handles.spawn(async move { + let run = run_bot( + bot, + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + ) + .await; + match &run { + Err(error) => task_sync.cancel(format!( + "participant {participant} transport/login failed: {error:#}" + )), + Ok(result) if result.loot_race_smoke_passed == Some(false) => { + task_sync.cancel(format!( + "participant {participant} failed: {}", + result + .loot_race_failure + .as_deref() + .unwrap_or("unknown loot-race failure") + )); + } + _ => {} + } + run + }); + } + + let mut results = Vec::with_capacity(2); + let mut task_error = None; + loop { + let joined = tokio::select! { + joined = handles.join_next() => joined, + _ = shutdown.cancelled() => { + let message = "loot-race received SIGINT/SIGTERM".to_string(); + sync.cancel(message.clone()); + handles.abort_all(); + while handles.join_next().await.is_some() {} + task_error = Some(message); + break; + } + _ = tokio::time::sleep_until(workflow_deadline) => { + let message = format!( + "loot-race exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + ); + sync.cancel(message.clone()); + handles.abort_all(); + while handles.join_next().await.is_some() {} + task_error = Some(message); + break; + } + }; + let Some(joined) = joined else { break }; + match joined { + Ok(Ok(result)) => results.push(result), + Ok(Err(error)) => { + let message = error.to_string(); + sync.cancel(message.clone()); + handles.abort_all(); + task_error.get_or_insert(message); + } + Err(error) => { + let message = format!("loot-race task join failed: {error}"); + sync.cancel(message.clone()); + handles.abort_all(); + task_error.get_or_insert(message); + } + } + } + results.sort_by_key(|result| result.account_id); + if shutdown.is_cancelled() { + task_error.get_or_insert_with(|| "loot-race received SIGINT/SIGTERM".to_string()); + } else if tokio::time::Instant::now() >= workflow_deadline { + task_error.get_or_insert_with(|| { + format!( + "loot-race exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + ) + }); + } + + if task_error.is_none() + && results.len() == 2 + && results + .iter() + .all(|result| result.loot_race_smoke_passed == Some(true)) + { + let expected_source_coins = results[0] + .loot_race_loot_coins + .map(u64::from) + .ok_or_else(|| anyhow!("loot-race passed without recording source money")); + let expected_item_grant = expected_persisted_item_grant(&fixture, &sync).await; + let expected_money_grant = match expected_source_coins.as_ref() { + Ok(source_coins) => { + expected_persisted_money_grant(&fixture, &sync, *source_coins).await + } + Err(error) => Err(anyhow!(error.to_string())), + }; + let verification = match ( + expected_source_coins, + expected_item_grant, + expected_money_grant, + ) { + (Ok(expected_source_coins), Ok(expected_item_grant), Ok(expected_money_grant)) => { + if expected_source_coins != expected_money_grant.amount { + Err(anyhow!( + "wire money winner amount {} did not consume exact source pool {expected_source_coins}", + expected_money_grant.amount + )) + } else { + let fixture_for_db = fixture.clone(); + match tokio::task::spawn_blocking(move || { + verify_persisted_grants( + &fixture_for_db, + expected_money_grant, + expected_item_grant, + ) + }) + .await + { + Ok(verification) => verification, + Err(error) => Err(anyhow!( + "loot-race verification DB worker join failed: {error}" + )), + } + } + } + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + }; + match verification { + Ok((item_total, money_delta, item_grant, money_grant)) => { + for result in &mut results { + result.loot_race_db_item_total = Some(item_total); + result.loot_race_db_money_delta = Some(money_delta); + } + let mut relog_ok = true; + for participant in 0..2 { + let options = LootRaceOptions { + phase: LootRacePhase::VerifyRelog, + participant, + character_guid: fixture.characters[participant].bot.character_guid, + peer_name: fixture.characters[1 - participant].name.clone(), + peer_character_guid: fixture.characters[1 - participant].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync: Arc::clone(&sync), + }; + let relog = tokio::select! { + _ = shutdown.cancelled() => { + Err(anyhow!("loot-race relog cancelled by SIGINT/SIGTERM")) + } + result = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[participant].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + )) => match result { + Ok(run) => run, + Err(_) => Err(anyhow!( + "loot-race relog exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + match relog { + Ok(relog) if relog.loot_race_relog_verified => { + if let Some(result) = results + .iter_mut() + .find(|result| result.account_id == bots[participant].account_id) + { + result.loot_race_relog_verified = true; + } + } + Ok(_) | Err(_) => relog_ok = false, + } + } + let fixture_for_relog = fixture.clone(); + let after_relog = match tokio::task::spawn_blocking(move || { + verify_persisted_grants(&fixture_for_relog, money_grant, item_grant) + }) + .await + { + Ok(verification) => verification, + Err(error) => Err(anyhow!("loot-race relog DB worker join failed: {error}")), + }; + if !matches!( + after_relog, + Ok(persisted) if persisted == (item_total, money_delta, item_grant, money_grant) + ) { + relog_ok = false; + } + for result in &mut results { + result.loot_race_smoke_passed = Some( + result.loot_race_smoke_passed.unwrap_or(false) + && relog_ok + && result.loot_race_relog_verified, + ); + if !result.loot_race_smoke_passed.unwrap_or(false) + && result.loot_race_failure.is_none() + { + result.loot_race_failure = Some( + "loot grants did not survive a clean logout/relogin unchanged".into(), + ); + } + } + } + Err(error) => { + for result in &mut results { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + } + } + } + + if let Some(ref error) = task_error { + for result in &mut results { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure.get_or_insert(error.clone()); + } + } + + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|error| anyhow!("loot-race cleanup DB worker join failed: {error}"))?; + if let Err(error) = cleanup { + bail!("loot-race fixture cleanup failed: {error:#}"); + } + if results.len() != 2 { + bail!( + "loot-race produced {} results instead of 2{}", + results.len(), + task_error + .as_deref() + .map(|error| format!(": {error}")) + .unwrap_or_default() + ); + } + Ok(results) +} + +/// Record one deterministic, item-only loot action with exactly one connected +/// client. The second guarded bot remains offline; it is retained only because +/// the shared disposable-fixture snapshot/cleanup predates this capture mode. +pub(super) async fn run_single_item_capture_workflow( + mut bots: Vec, + cli: LootRaceCli, + dungeon_id: u32, + lfg_secs: u64, + auto_teleport: bool, + shutdown: CancellationToken, +) -> Result> { + let workflow_deadline = + tokio::time::Instant::now() + Duration::from_secs(cli.workflow_deadline_secs); + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled before fixture setup"); + } + bots.sort_by_key(|bot| { + if bot.account.eq_ignore_ascii_case(&cli.account_a) { + 0 + } else { + 1 + } + }); + let setup_bots = bots.clone(); + let setup_cli = cli.clone(); + let setup_shutdown = shutdown.clone(); + let fixture = tokio::task::spawn_blocking(move || { + prepare_fixture( + &setup_bots, + &setup_cli, + LootFixturePurpose::CaptureItem, + &setup_shutdown, + ) + }) + .await + .map_err(|error| anyhow!("loot-item capture fixture DB worker join failed: {error}"))??; + + let sync = Arc::new(LootRaceSync::new()); + let options = LootRaceOptions { + phase: LootRacePhase::CaptureItem, + participant: 0, + character_guid: fixture.characters[0].bot.character_guid, + peer_name: fixture.characters[1].name.clone(), + peer_character_guid: fixture.characters[1].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync: Arc::clone(&sync), + }; + let run = tokio::select! { + _ = shutdown.cancelled() => Err(anyhow!("loot-item capture received SIGINT/SIGTERM")), + result = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[0].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(options), + None, + )) => match result { + Ok(run) => run, + Err(_) => Err(anyhow!( + "loot-item capture exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + + let mut result = match run { + Ok(result) => Some(result), + Err(error) => { + let fixture_for_cleanup = fixture.clone(); + let cleanup = + tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|join| { + anyhow!("loot-item capture cleanup DB worker join failed: {join}") + })?; + if let Err(cleanup_error) = cleanup { + bail!( + "loot-item capture failed ({error:#}) and fixture cleanup also failed ({cleanup_error:#})" + ); + } + return Err(error); + } + }; + + if result + .as_ref() + .is_some_and(|result| result.loot_race_smoke_passed == Some(true)) + { + let fixture_for_verification = fixture.clone(); + match tokio::task::spawn_blocking(move || { + verify_single_item_capture_persistence(&fixture_for_verification) + }) + .await + { + Ok(Ok(item_total)) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_db_item_total = Some(item_total); + result.loot_race_db_money_delta = Some(0); + } + Ok(Err(error)) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + Err(error) => { + let result = result + .as_mut() + .expect("successful wire result remains available"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture verification DB worker join failed: {error}" + )); + } + } + } + + if result + .as_ref() + .is_some_and(|result| result.loot_race_smoke_passed == Some(true)) + { + let relog_options = LootRaceOptions { + phase: LootRacePhase::VerifyRelog, + participant: 0, + character_guid: fixture.characters[0].bot.character_guid, + peer_name: fixture.characters[1].name.clone(), + peer_character_guid: fixture.characters[1].bot.character_guid, + killer_character_guid: fixture.characters[0].bot.character_guid, + target: fixture.target.clone(), + timeout_secs: cli.timeout_secs, + sync, + }; + let relog = tokio::select! { + _ = shutdown.cancelled() => { + Err(anyhow!("loot-item capture relog cancelled by SIGINT/SIGTERM")) + } + relog = tokio::time::timeout_at(workflow_deadline, run_bot( + bots[0].clone(), + dungeon_id, + lfg_secs, + auto_teleport, + false, + None, + None, + None, + None, + None, + Some(relog_options), + None, + )) => match relog { + Ok(relog) => relog, + Err(_) => Err(anyhow!( + "loot-item capture relog exceeded the {}s end-to-end deadline", + cli.workflow_deadline_secs + )), + }, + }; + match relog { + Ok(relog) if relog.loot_race_relog_verified => { + let fixture_for_verification = fixture.clone(); + let persisted_after_relog = tokio::task::spawn_blocking(move || { + verify_single_item_capture_persistence(&fixture_for_verification) + }) + .await; + let result = result + .as_mut() + .expect("successful capture result remains available for relog merge"); + result.world_auth &= relog.world_auth; + result.enum_characters &= relog.enum_characters; + result.player_login_verified &= relog.player_login_verified; + result.seen_opcodes.extend(relog.seen_opcodes); + match persisted_after_relog { + Ok(Ok(item_total)) if Some(item_total) == result.loot_race_db_item_total => { + result.loot_race_relog_verified = true; + } + Ok(Ok(item_total)) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture persisted item total changed across relog: before {:?}, after {item_total}", + result.loot_race_db_item_total + )); + } + Ok(Err(error)) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture post-relog persistence verification failed: {error}" + )); + } + Err(error) => { + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(format!( + "loot-item capture post-relog DB worker join failed: {error}" + )); + } + } + } + Ok(_) => { + let result = result + .as_mut() + .expect("successful capture result remains available for relog failure"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some( + "loot-item capture relog did not verify a clean logout/login cycle".into(), + ); + } + Err(error) => { + let result = result + .as_mut() + .expect("successful capture result remains available for relog error"); + result.loot_race_smoke_passed = Some(false); + result.loot_race_failure = Some(error.to_string()); + } + } + } + + let deadline_exceeded_before_cleanup = tokio::time::Instant::now() >= workflow_deadline; + let fixture_for_cleanup = fixture.clone(); + let cleanup = tokio::task::spawn_blocking(move || cleanup_fixture(&fixture_for_cleanup)) + .await + .map_err(|error| anyhow!("loot-item capture cleanup DB worker join failed: {error}"))?; + if let Err(error) = cleanup { + bail!("loot-item capture fixture cleanup failed: {error:#}"); + } + + let workflow_failure = if shutdown.is_cancelled() { + Some("loot-item capture was cancelled before end-to-end verification completed") + } else if deadline_exceeded_before_cleanup { + Some("loot-item capture exceeded its end-to-end deadline during final verification") + } else { + None + }; + if let Some(failure) = workflow_failure { + let result = result + .as_mut() + .expect("successful bot run remains available after cleanup"); + result.loot_race_smoke_passed = Some(false); + result + .loot_race_failure + .get_or_insert_with(|| failure.to_owned()); + } + + Ok(vec![result.expect("successful bot run remains available")]) +} + +fn record_discovered_runtime_guid(options: &LootRaceOptions, candidate: (u64, u64)) -> Result { + let (low, high) = candidate; + let counter = low & OBJECT_GUID_COUNTER_MASK; + if counter == 0 { + bail!( + "loot-race discovered an empty runtime counter for entry {} spawn {}", + options.target.entry, + options.target.spawn_guid + ); + } + let high_type = (high >> 58) & GUID_HIGH_TYPE_MASK; + let expected_high_type = match options.target.kind { + LootRaceTargetKind::Creature => HIGH_GUID_CREATURE, + LootRaceTargetKind::GameObject => HIGH_GUID_GAMEOBJECT, + }; + let high_map = (high >> 29) & GUID_MAP_MASK; + let high_entry = (high >> 6) & GUID_ENTRY_MASK; + if high_type != expected_high_type + || high_map != u64::from(options.target.map_id) + || high_entry != u64::from(options.target.entry) + { + bail!( + "loot-race discovered malformed {:?} ObjectGuid {low:#018X}/{high:#018X} for entry {} map {}", + options.target.kind, + options.target.entry, + options.target.map_id + ); + } + let configured = options.target.runtime_counter_override; + if configured != 0 && counter != configured { + bail!( + "loot-race runtime counter override {configured} did not match discovered counter {counter} for SQL spawn {}", + options.target.spawn_guid + ); + } + + let mut resolved = options + .sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))?; + if let Some(previous) = *resolved { + if previous != candidate { + bail!( + "loot-race bots discovered different live ObjectGuids for exact SQL spawn {}: first={:#018X}/{:#018X}, current={low:#018X}/{high:#018X}", + options.target.spawn_guid, + previous.0, + previous.1 + ); + } + } else { + *resolved = Some(candidate); + } + Ok(counter) +} + +pub(super) fn target_seen_in_update( + options: &LootRaceOptions, + opcode: u16, + payload: &[u8], +) -> Result> { + if options.phase == LootRacePhase::VerifyRelog || opcode != SMSG_UPDATE_OBJECT { + return Ok(None); + } + // Keep the complete GUID seen on the wire: C++ includes realm/server bits + // that must not be reconstructed from the SQL spawn id. C++ + // `GameObject::LoadFromDB` stores the SQL spawn id in `m_spawnId`, while + // `GameObject::Create` builds the live ObjectGuid with the map-local + // `GenerateLowGuid()` counter. The guarded DB + // preflight therefore proves entry/map uniqueness and wire discovery owns + // the independent runtime counter. + let Some(candidate) = find_loot_target_guid_in_update_object(payload, &options.target)? else { + return Ok(None); + }; + record_discovered_runtime_guid(options, candidate).map(Some) +} + +fn find_loot_target_guid_in_update_object( + payload: &[u8], + target: &LootRaceTarget, +) -> Result> { + let expected_high_type = match target.kind { + LootRaceTargetKind::Creature => HIGH_GUID_CREATURE, + LootRaceTargetKind::GameObject => HIGH_GUID_GAMEOBJECT, + }; + let mut candidates = std::collections::BTreeSet::new(); + for offset in 0..payload.len().saturating_sub(2) { + if !matches!(payload[offset], 1 | 2) { + continue; + } + let Some((_, low, high)) = parse_packed_guid(&payload[offset + 1..]) else { + continue; + }; + if ((high >> 58) & GUID_HIGH_TYPE_MASK) != expected_high_type + || ((high >> 29) & GUID_MAP_MASK) != u64::from(target.map_id) + || ((high >> 6) & GUID_ENTRY_MASK) != u64::from(target.entry) + { + continue; + } + candidates.insert((low, high)); + } + + if candidates.len() > 1 { + bail!( + "loot-race update contained {} distinct live ObjectGuid candidates for {:?} entry {} map {}: {candidates:?}", + candidates.len(), + target.kind, + target.entry, + target.map_id + ); + } + Ok(candidates.into_iter().next()) +} + +pub(super) async fn run_phase( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + options: &LootRaceOptions, + target_seen: bool, + result: &mut BotRunResult, +) -> Result<()> { + let run = run_phase_inner( + bot_index, + stream, + crypt, + inflater, + realm_connection, + options, + target_seen, + result, + ) + .await; + if let Err(error) = &run { + options.sync.cancel(format!( + "participant {} phase {:?} failed: {error:#}", + options.participant, options.phase + )); + } + run +} + +async fn run_phase_inner( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + options: &LootRaceOptions, + mut target_seen: bool, + result: &mut BotRunResult, +) -> Result<()> { + if options.phase == LootRacePhase::VerifyRelog { + result.loot_race_target_runtime_counter = Some(options.resolved_runtime_counter()?); + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + realm_connection.as_mut(), + options.character_guid, + result, + ) + .await?; + result.loot_race_relog_verified = true; + result.loot_race_smoke_passed = Some(true); + return Ok(()); + } + let realm = realm_connection + .as_mut() + .ok_or_else(|| anyhow!("loot-race requires separate realm and instance sockets"))?; + + // C++ can defer nearby-object visibility until the client confirms that its + // active mover is initialized. Reuse the same real movement handshake as + // the rested-XP combat smoke before proving the exact creature is live. + send_encrypted_packet( + stream, + crypt, + CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE, + &build_move_init_active_mover_complete_payload(0), + ) + .await?; + target_seen |= + drain_until_target_or_quiet(bot_index, stream, crypt, inflater, realm, options, result) + .await?; + if !target_seen { + let override_detail = if options.target.runtime_counter_override == 0 { + "auto-discovery".to_string() + } else { + format!("override {}", options.target.runtime_counter_override) + }; + bail!( + "world-loot target entry {} spawn {} ({override_detail}) was not present in SMSG_UPDATE_OBJECT; require a fresh world/runtime and matching override", + options.target.entry, + options.target.spawn_guid + ); + } + result.loot_race_target_runtime_counter = Some(options.resolved_runtime_counter()?); + result.loot_race_target_discovered = true; + if options.phase == LootRacePhase::CaptureItem { + return run_single_item_capture_phase( + bot_index, stream, crypt, inflater, realm, options, result, + ) + .await; + } + options.sync.cancellation_error()?; + wait_phase(options, &options.sync.logged_in, "both bots logged in").await?; + + form_party(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_party_confirmed = true; + wait_phase(options, &options.sync.party_ready, "PERSONAL party formed").await?; + + let (player_low, player_high) = create_player_guid_raw(options.character_guid, realm_id()); + let player_x = options.target.x + 1.0 + options.participant as f64; + let player_y = options.target.y; + let player_z = options.target.z; + let player_orientation = + (options.target.y - player_y).atan2(options.target.x - player_x) as f32; + let movement = build_move_heartbeat_payload( + player_low, + player_high, + player_x as f32, + player_y as f32, + player_z as f32, + player_orientation, + ); + send_encrypted_packet(stream, crypt, CMSG_MOVE_HEARTBEAT, &movement).await?; + wait_phase( + options, + &options.sync.positioned, + "both bots positioned at the shared chest", + ) + .await?; + + if options.target.kind != LootRaceTargetKind::GameObject { + bail!("two-client Race must use the guarded shared GameObject fixture"); + } + wait_phase( + options, + &options.sync.use_ready, + "simultaneous CMSG_GAME_OBJ_USE", + ) + .await?; + // C++ `WorldPackets::GameObject::GameObjUse::Read` consumes one packed + // GameObject ObjectGuid; both clients deliberately send the same wire GUID. + send_encrypted_packet( + stream, + crypt, + CMSG_GAME_OBJ_USE, + &options.resolved_packed_guid()?, + ) + .await?; + info!( + "[Bot {}] loot-race phase: CMSG_GAME_OBJ_USE sent for shared spawn {}", + bot_index, options.target.spawn_guid + ); + let window = + wait_for_loot_window(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_loot_opened = true; + result.loot_race_loot_list_id = Some(window.loot_list_id); + result.loot_race_loot_coins = Some(window.coins); + info!( + "[Bot {}] loot-race phase: SMSG_LOOT_RESPONSE received (loot_list_id={}, coins={})", + bot_index, window.loot_list_id, window.coins + ); + options.sync.windows.lock().await[options.participant] = Some(window.clone()); + wait_phase( + options, + &options.sync.response_received, + "both SMSG_LOOT_RESPONSE packets received", + ) + .await?; + wait_phase( + options, + &options.sync.windows_ready, + "shared loot windows recorded", + ) + .await?; + validate_shared_windows(options).await?; + + wait_phase(options, &options.sync.item_claim, "simultaneous item claim").await?; + let item_claim = build_loot_item_claim(&window); + send_encrypted_packet(stream, crypt, CMSG_LOOT_ITEM, &item_claim).await?; + let expected_loot_owner = options.resolved_runtime_guid()?; + let mut evidence = collect_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + expected_loot_owner, + RESPONSE_SETTLE, + options, + result, + ) + .await?; + options.sync.evidence.lock().await[options.participant] = evidence.clone(); + wait_phase( + options, + &options.sync.item_observed, + "item outcomes observed", + ) + .await?; + validate_item_outcome(options, result).await?; + + wait_phase( + options, + &options.sync.money_claim, + "simultaneous money claim", + ) + .await?; + send_encrypted_packet(stream, crypt, CMSG_LOOT_MONEY, &[0]).await?; + evidence.merge( + collect_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + expected_loot_owner, + RESPONSE_SETTLE, + options, + result, + ) + .await?, + ); + options.sync.evidence.lock().await[options.participant] = evidence; + wait_phase( + options, + &options.sync.money_observed, + "money outcomes observed", + ) + .await?; + // The money settle window can also surface a delayed item response. Merge + // first, then re-run the exact item fanout proof so a late duplicate, + // foreign push/failure, or extra removal cannot escape the earlier fence. + validate_item_outcome(options, result).await?; + validate_money_outcome(options, result).await?; + + wait_phase(options, &options.sync.before_leave, "party cleanup").await?; + send_encrypted_packet(&mut realm.stream, &mut realm.crypt, CMSG_LEAVE_GROUP, &[0]).await?; + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + Some(realm), + options.character_guid, + result, + ) + .await?; + result.loot_race_smoke_passed = Some(true); + Ok(()) +} + +async fn run_single_item_capture_phase( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + if options.participant != 0 || options.character_guid != options.killer_character_guid { + bail!("loot-item capture requires account A as its sole connected killer"); + } + + let (player_low, player_high) = create_player_guid_raw(options.character_guid, realm_id()); + let player_x = options.target.x + 1.0; + let player_y = options.target.y; + let player_z = options.target.z; + let player_orientation = + (options.target.y - player_y).atan2(options.target.x - player_x) as f32; + let movement = build_move_heartbeat_payload( + player_low, + player_high, + player_x as f32, + player_y as f32, + player_z as f32, + player_orientation, + ); + send_encrypted_packet(stream, crypt, CMSG_MOVE_HEARTBEAT, &movement).await?; + + kill_target_once(bot_index, stream, crypt, inflater, realm, options, result).await?; + send_encrypted_packet( + stream, + crypt, + CMSG_LOOT_UNIT, + &options.resolved_packed_guid()?, + ) + .await?; + let window = + wait_for_loot_window(bot_index, stream, crypt, inflater, realm, options, result).await?; + result.loot_race_loot_opened = true; + result.loot_race_loot_list_id = Some(window.loot_list_id); + result.loot_race_loot_coins = Some(window.coins); + + // Capture-diff starts at this exact CMSG. Opening the corpse (including its + // random coin roll) is deliberately outside the item-only window. + let item_claim = build_loot_item_claim(&window); + send_encrypted_packet(stream, crypt, CMSG_LOOT_ITEM, &item_claim).await?; + let evidence = collect_single_item_capture_evidence( + bot_index, + stream, + crypt, + inflater, + realm, + options.resolved_runtime_guid()?, + options.timeout_secs, + result, + ) + .await?; + let expected_player = create_player_guid_raw(options.character_guid, realm_id()); + validate_single_item_capture_evidence( + &evidence, + expected_player, + options.target.item_entry, + &window, + )?; + result.loot_race_item_push_seen = true; + result.loot_race_loot_removed_seen = true; + + send_and_verify_loot_item_capture_fence( + bot_index, + stream, + crypt, + inflater, + options.timeout_secs, + result, + ) + .await?; + logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + Some(realm), + options.character_guid, + result, + ) + .await?; + result.loot_race_smoke_passed = Some(true); + Ok(()) +} + +/// Read only until the two C++-anchored item-claim responses have arrived. +/// +/// The two-client race deliberately keeps a settle window so it can prove the +/// absence/presence of competing outcomes on both sockets. A capture golden +/// has a different requirement: put the fixed ping fence immediately after +/// the one `LootRemoved` and one `ItemPushResult`, rather than admitting an +/// arbitrary two seconds of periodic traffic into the strict diff window. +async fn collect_single_item_capture_evidence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + expected_loot_owner: (u64, u64), + timeout_secs: u64, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + let mut evidence = WireEvidence::default(); + + loop { + if evidence.item_pushes.len() == 1 && evidence.loot_removed.len() == 1 { + return Ok(evidence); + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!( + "timed out waiting for the capture item responses (item pushes={}, loot removals={})", + evidence.item_pushes.len(), + evidence.loot_removed.len() + ); + } + + // Do not `select!` two in-progress encrypted reads: cancelling the + // losing `read_exact` could consume part of a framed packet. Poll + // socket readiness briefly, then finish any selected frame without + // cancellation. + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(5)), + remaining, + "loot-item capture realm response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + validate_single_item_capture_candidate(&evidence)?; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + continue; + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-item capture instance response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + validate_single_item_capture_candidate(&evidence)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } +} + +fn validate_single_item_capture_candidate(evidence: &WireEvidence) -> Result<()> { + if evidence.item_pushes.len() > 1 || evidence.loot_removed.len() > 1 { + bail!( + "loot-item capture observed duplicate functional responses before its fence (item pushes={}, loot removals={})", + evidence.item_pushes.len(), + evidence.loot_removed.len() + ); + } + if !evidence.inventory_failures.is_empty() { + bail!("loot-item capture observed an inventory failure before its success fence"); + } + if !evidence.money_notifies.is_empty() || !evidence.coin_removed.is_empty() { + bail!("loot-item capture observed money-claim traffic before its item-only fence"); + } + Ok(()) +} + +fn validate_single_item_capture_evidence( + evidence: &WireEvidence, + expected_player: (u64, u64), + expected_item_entry: u32, + window: &LootWindow, +) -> Result<()> { + if evidence.item_pushes.len() != 1 { + bail!( + "single-session item capture observed item pushes {:?}; expected exactly one", + evidence.item_pushes + ); + } + let push = &evidence.item_pushes[0]; + if push.item_entry != expected_item_entry + || push.quantity != 1 + || push.quantity_in_inventory != 1 + || push.slot != INVENTORY_SLOT_BAG_0 + || push.slot_in_bag != i32::from(LOOT_ITEM_CAPTURE_KEYRING_SLOT) + || (push.player_low, push.player_high) != expected_player + { + bail!( + "single-session item capture observed item pushes {:?}; expected exactly item {} quantity 1 in keyring slot {}/{} for the sole character", + evidence.item_pushes, + expected_item_entry, + INVENTORY_SLOT_BAG_0, + LOOT_ITEM_CAPTURE_KEYRING_SLOT + ); + } + let expected_removal = LootRemovedEvidence { + owner_low: window.owner_low, + owner_high: window.owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + if evidence.loot_removed != [expected_removal] { + bail!( + "single-session item capture observed removals {:?}; expected exactly {:?}", + evidence.loot_removed, + expected_removal + ); + } + if !evidence.inventory_failures.is_empty() { + bail!("single-session item capture unexpectedly failed inventory storage"); + } + if !evidence.money_notifies.is_empty() || !evidence.coin_removed.is_empty() { + bail!("item-only capture unexpectedly emitted money-claim packets"); + } + Ok(()) +} + +async fn send_and_verify_loot_item_capture_fence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + timeout_secs: u64, + result: &mut BotRunResult, +) -> Result<()> { + let payload = build_ping_payload(LOOT_ITEM_CAPTURE_FENCE_SERIAL); + send_encrypted_packet(stream, crypt, CMSG_PING, &payload).await?; + info!( + "[Bot {}] deterministic loot-item CMSG_PING fence sent (serial=0x{:08X})", + bot_index, LOOT_ITEM_CAPTURE_FENCE_SERIAL + ); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for loot-item capture-fence SMSG_PONG"); + } + let (opcode, payload) = + tokio::time::timeout(remaining, read_encrypted_packet(stream, crypt, inflater)) + .await + .map_err(|_| { + anyhow!("timed out waiting for loot-item capture-fence SMSG_PONG") + })??; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if opcode == SMSG_PONG { + if payload != LOOT_ITEM_CAPTURE_FENCE_SERIAL.to_le_bytes() { + bail!( + "loot-item capture-fence SMSG_PONG mismatch: expected 0x{:08X}, got {:02X?}", + LOOT_ITEM_CAPTURE_FENCE_SERIAL, + payload + ); + } + return Ok(()); + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } +} + +fn logout_completion_route( + opcode: u16, + route: LogoutCompletionRoute, +) -> Option { + (opcode == SMSG_LOGOUT_COMPLETE).then_some(route) +} + +fn wait_for_loot_character_offline(character_guid: u64, timeout: Duration) -> Result<()> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + let deadline = std::time::Instant::now() + timeout; + loop { + let online: u8 = conn + .exec_first( + "SELECT online FROM characters WHERE guid = ?", + (character_guid,), + ) + .map_err(|error| anyhow!("Check loot logout offline state: {error}"))? + .ok_or_else(|| anyhow!("Loot character {character_guid} disappeared during logout"))?; + if online == 0 { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + bail!( + "loot character {character_guid} remained online after its bounded logout DB proof" + ); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Finish a loot workflow across the real 3.4.3 socket topology. +/// +/// C++ routes `SMSG_LOGOUT_RESPONSE` on instance and +/// `SMSG_LOGOUT_COMPLETE` on realm. Rust currently may complete on instance, +/// so both sockets are drained safely and the observed route is reported. In +/// every case, success additionally requires the exact character row to be +/// offline; a closed socket by itself is never accepted as logout proof. +async fn logout_and_wait_routed_like_cpp( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + mut realm_connection: Option<&mut EncryptedWorldConnection>, + character_guid: u64, + result: &mut BotRunResult, +) -> Result<()> { + send_encrypted_packet(stream, crypt, CMSG_LOGOUT_REQUEST, &[0]).await?; + info!("[Bot {}] ✅ CMSG_LOGOUT_REQUEST sent", bot_index); + + let deadline = + tokio::time::Instant::now() + Duration::from_secs(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); + let mut instance_open = true; + let mut realm_open = realm_connection.is_some(); + let mut completion_route = None; + let mut transport_errors = Vec::new(); + + while tokio::time::Instant::now() < deadline && completion_route.is_none() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + if !realm_open && !instance_open { + tokio::time::sleep(remaining).await; + break; + } + + if realm_open { + let realm = realm_connection + .as_deref_mut() + .expect("realm-open state requires the preserved realm connection"); + match read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot logout realm packet", + ) + .await + { + Ok(Some((opcode, payload))) => { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 realm loot logout {}", + bot_index, + parse_packet(opcode, &payload) + ); + completion_route = + logout_completion_route(opcode, LogoutCompletionRoute::Realm); + } + Ok(None) => {} + Err(error) => { + realm_open = false; + transport_errors.push(format!("realm: {error}")); + } + } + } + + if completion_route.is_some() { + break; + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + if instance_open { + let readiness_wait = if realm_open { + Duration::from_millis(1) + } else { + remaining.min(Duration::from_millis(50)) + }; + match read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + readiness_wait, + remaining, + "loot logout instance packet", + ) + .await + { + Ok(Some((opcode, payload))) => { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + info!( + "[Bot {}] 📦 instance loot logout {}", + bot_index, + parse_packet(opcode, &payload) + ); + completion_route = + logout_completion_route(opcode, LogoutCompletionRoute::Instance); + if completion_route.is_none() { + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload) + .await?; + } + } + Ok(None) => {} + Err(error) => { + instance_open = false; + transport_errors.push(format!("instance: {error}")); + } + } + } + } + + let offline = tokio::task::spawn_blocking(move || { + wait_for_loot_character_offline( + character_guid, + Duration::from_secs(LOOT_LOGOUT_DB_CONFIRM_WAIT_SECS), + ) + }) + .await + .map_err(|error| anyhow!("loot logout DB worker join failed: {error}"))?; + if let Err(error) = offline { + let route = completion_route + .map(|route| format!("{route:?}")) + .unwrap_or_else(|| "none".to_string()); + let transport = if transport_errors.is_empty() { + "none".to_string() + } else { + transport_errors.join("; ") + }; + bail!( + "loot logout was not proven for character {character_guid} (LogoutComplete route={route}, transport errors={transport}): {error}" + ); + } + + match completion_route { + Some(LogoutCompletionRoute::Realm) => info!( + "[Bot {}] ✅ realm SMSG_LOGOUT_COMPLETE and exact offline DB state confirmed", + bot_index + ), + Some(LogoutCompletionRoute::Instance) => warn!( + "[Bot {}] SMSG_LOGOUT_COMPLETE arrived on instance instead of the C++ realm route; exact offline DB state confirmed", + bot_index + ), + None => warn!( + "[Bot {}] no SMSG_LOGOUT_COMPLETE arrived within {}s; exact offline DB state confirmed as the bounded fallback{}", + bot_index, + NORMAL_LOGOUT_COMPLETE_WAIT_SECS, + if transport_errors.is_empty() { + String::new() + } else { + format!(" ({})", transport_errors.join("; ")) + } + ), + } + Ok(()) +} + +pub(super) async fn best_effort_close( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm_connection: &mut Option, + character_guid: u64, + result: &mut BotRunResult, +) { + if let Some(realm) = realm_connection.as_mut() { + let _ = send_encrypted_packet(&mut realm.stream, &mut realm.crypt, CMSG_LEAVE_GROUP, &[0]) + .await; + } + let _ = logout_and_wait_routed_like_cpp( + bot_index, + stream, + crypt, + inflater, + realm_connection.as_mut(), + character_guid, + result, + ) + .await; +} + +impl WireEvidence { + fn merge(&mut self, other: Self) { + self.item_pushes.extend(other.item_pushes); + self.loot_removed.extend(other.loot_removed); + self.money_notifies.extend(other.money_notifies); + self.coin_removed.extend(other.coin_removed); + self.inventory_failures.extend(other.inventory_failures); + } +} + +async fn wait_phase(options: &LootRaceOptions, barrier: &Barrier, label: &str) -> Result<()> { + options.sync.cancellation_error()?; + info!( + "[Bot {}] loot-race phase: waiting for {label}", + options.participant + 1 + ); + tokio::time::timeout(Duration::from_secs(options.timeout_secs), async { + tokio::select! { + _ = barrier.wait() => Ok(()), + _ = options.sync.cancelled() => options.sync.cancellation_error(), + } + }) + .await + .map_err(|_| anyhow!("loot-race timed out waiting for {label}"))??; + info!( + "[Bot {}] loot-race phase: {label} complete", + options.participant + 1 + ); + Ok(()) +} + +/// C++ `Opcodes.cpp` maps these party packets to +/// `CONNECTION_TYPE_REALM`. Seeing one on the instance socket is a routing +/// defect, not harmless traffic that the atomic-loot harness may discard. +fn validate_party_packet_route_like_cpp(opcode: u16, route: PartyPacketRoute) -> Result<()> { + if route == PartyPacketRoute::Instance + && matches!( + opcode, + SMSG_PARTY_INVITE + | SMSG_PARTY_UPDATE + | SMSG_PARTY_COMMAND_RESULT + | SMSG_PARTY_MEMBER_FULL_STATE + ) + { + bail!( + "realm-only party opcode 0x{opcode:04X} arrived on instance while forming the loot-race party; C++ Opcodes.cpp requires CONNECTION_TYPE_REALM" + ); + } + Ok(()) +} + +struct PartyUpdateCursor<'a> { + payload: &'a [u8], + offset: usize, +} + +impl<'a> PartyUpdateCursor<'a> { + fn new(payload: &'a [u8]) -> Self { + Self { payload, offset: 0 } + } + + fn take(&mut self, len: usize, field: &str) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| anyhow!("PartyUpdate {field} offset overflow"))?; + let bytes = self.payload.get(self.offset..end).ok_or_else(|| { + anyhow!( + "malformed PartyUpdate: {field} needs {len} byte(s) at offset {}, payload has {}", + self.offset, + self.payload.len() + ) + })?; + self.offset = end; + Ok(bytes) + } + + fn read_u8(&mut self, field: &str) -> Result { + Ok(self.take(1, field)?[0]) + } + + fn read_u16(&mut self, field: &str) -> Result { + Ok(u16::from_le_bytes( + self.take(2, field)?.try_into().expect("exact u16 slice"), + )) + } + + fn read_u32(&mut self, field: &str) -> Result { + Ok(u32::from_le_bytes( + self.take(4, field)?.try_into().expect("exact u32 slice"), + )) + } + + fn read_i32(&mut self, field: &str) -> Result { + Ok(i32::from_le_bytes( + self.take(4, field)?.try_into().expect("exact i32 slice"), + )) + } + + fn read_packed_guid(&mut self, field: &str) -> Result<(u64, u64)> { + let (consumed, low, high) = parse_packed_guid(&self.payload[self.offset..]) + .ok_or_else(|| anyhow!("malformed PartyUpdate {field} packed ObjectGuid"))?; + self.offset = self + .offset + .checked_add(consumed) + .ok_or_else(|| anyhow!("PartyUpdate {field} offset overflow"))?; + Ok((low, high)) + } +} + +/// Decode the C++ `PartyPackets.cpp::PartyUpdate::Write` prefix and each +/// `PartyPlayerInfo`. The race must prove an actual two-player HOME party; a +/// stray, destroyed, raid, LFG, or malformed PartyUpdate is not coordination. +fn validate_party_update_like_cpp( + payload: &[u8], + receiver_guid: (u64, u64), + peer_guid: (u64, u64), + expected_leader_guid: (u64, u64), +) -> Result<()> { + const GROUP_CATEGORY_HOME_LIKE_CPP: u8 = 0; + const GROUP_TYPE_NORMAL_LIKE_CPP: u8 = 1; + const EXPECTED_PARTY_MEMBERS: u32 = 2; + + let mut cursor = PartyUpdateCursor::new(payload); + let party_flags = cursor.read_u16("PartyFlags")?; + let party_index = cursor.read_u8("PartyIndex")?; + let party_type = cursor.read_u8("PartyType")?; + let my_index = cursor.read_i32("MyIndex")?; + let party_guid = cursor.read_packed_guid("PartyGUID")?; + let _sequence_num = cursor.read_u32("SequenceNum")?; + let leader_guid = cursor.read_packed_guid("LeaderGUID")?; + let _leader_faction_group = cursor.read_u8("LeaderFactionGroup")?; + let player_count = cursor.read_u32("PlayerList.size")?; + let optional_bits = cursor.read_u8("optional-value bits")?; + let has_lfg_info = optional_bits & 0x80 != 0; + let has_loot_settings = optional_bits & 0x40 != 0; + let has_difficulty_settings = optional_bits & 0x20 != 0; + + if party_flags != 0 + || party_index != GROUP_CATEGORY_HOME_LIKE_CPP + || party_type != GROUP_TYPE_NORMAL_LIKE_CPP + { + bail!( + "loot-race PartyUpdate was not a normal HOME party: flags={party_flags:#06X} index={party_index} type={party_type}" + ); + } + if party_guid == (0, 0) { + bail!("loot-race PartyUpdate carried an empty PartyGUID"); + } + if leader_guid != expected_leader_guid { + bail!( + "loot-race PartyUpdate leader {:#018X}/{:#018X} did not match inviter {:#018X}/{:#018X}", + leader_guid.0, + leader_guid.1, + expected_leader_guid.0, + expected_leader_guid.1 + ); + } + if player_count != EXPECTED_PARTY_MEMBERS { + bail!( + "loot-race PartyUpdate carried {player_count} player(s), expected {EXPECTED_PARTY_MEMBERS}" + ); + } + if optional_bits & 0x1F != 0 { + bail!( + "malformed PartyUpdate optional-value bit padding {:#04X}", + optional_bits & 0x1F + ); + } + // C++ `Group::SendUpdateToPlayer` includes loot and difficulty settings + // for a non-LFG group with more than one member. + if has_lfg_info || !has_loot_settings || !has_difficulty_settings { + bail!( + "loot-race PartyUpdate optional values did not describe a normal two-player group: lfg={has_lfg_info} loot={has_loot_settings} difficulty={has_difficulty_settings}" + ); + } + + let mut roster = Vec::with_capacity(EXPECTED_PARTY_MEMBERS as usize); + for player_index in 0..player_count { + // `PartyPlayerInfo::operator<<` writes 15 MSB-first bits, then the + // following packed GUID byte-write aligns to the next byte. + let info_bits = u16::from_be_bytes( + cursor + .take(2, "PartyPlayerInfo bit fields")? + .try_into() + .expect("exact bit-field slice"), + ); + if info_bits & 1 != 0 { + bail!("malformed PartyUpdate player {player_index}: nonzero aligned bit padding"); + } + let info_bits = info_bits >> 1; + let name_len = usize::from((info_bits >> 9) & 0x3F); + let voice_len_plus_one = usize::from((info_bits >> 3) & 0x3F); + let connected = info_bits & 0x04 != 0; + if name_len == 0 || voice_len_plus_one == 0 { + bail!( + "malformed PartyUpdate player {player_index}: name_len={name_len} voice_len_plus_one={voice_len_plus_one}" + ); + } + + let guid = cursor.read_packed_guid("PartyPlayerInfo.GUID")?; + let subgroup = cursor.read_u8("PartyPlayerInfo.Subgroup")?; + let _flags = cursor.read_u8("PartyPlayerInfo.Flags")?; + let _roles = cursor.read_u8("PartyPlayerInfo.RolesAssigned")?; + let _class = cursor.read_u8("PartyPlayerInfo.Class")?; + let _faction = cursor.read_u8("PartyPlayerInfo.FactionGroup")?; + let _name = cursor.take(name_len, "PartyPlayerInfo.Name")?; + let _voice_state = cursor.take(voice_len_plus_one - 1, "PartyPlayerInfo.VoiceStateID")?; + + if !connected || subgroup != 0 { + bail!( + "loot-race PartyUpdate player {player_index} was not a connected HOME subgroup member: connected={connected} subgroup={subgroup}" + ); + } + roster.push(guid); + } + + let receiver_index = usize::try_from(my_index) + .ok() + .filter(|index| *index < roster.len()) + .ok_or_else(|| { + anyhow!("loot-race PartyUpdate MyIndex {my_index} was outside the roster") + })?; + if roster[receiver_index] != receiver_guid { + bail!( + "loot-race PartyUpdate MyIndex {my_index} identified {:#018X}/{:#018X}, expected receiver {:#018X}/{:#018X}", + roster[receiver_index].0, + roster[receiver_index].1, + receiver_guid.0, + receiver_guid.1 + ); + } + let mut expected_roster = [receiver_guid, peer_guid]; + expected_roster.sort_unstable(); + roster.sort_unstable(); + if roster.as_slice() != expected_roster { + bail!( + "loot-race PartyUpdate roster {roster:?} did not contain exactly receiver/peer {expected_roster:?}" + ); + } + + let loot_method = cursor.read_u8("PartyLootSettings.Method")?; + let _loot_master = cursor.read_packed_guid("PartyLootSettings.LootMaster")?; + let _loot_threshold = cursor.read_u8("PartyLootSettings.Threshold")?; + if loot_method != PERSONAL_LOOT_METHOD_LIKE_CPP { + bail!( + "loot-race PartyUpdate loot method {loot_method} was not C++ PERSONAL_LOOT ({PERSONAL_LOOT_METHOD_LIKE_CPP})" + ); + } + let _difficulty_settings = cursor.take(12, "PartyDifficultySettings")?; + if cursor.offset != payload.len() { + bail!( + "malformed PartyUpdate left {} trailing byte(s)", + payload.len() - cursor.offset + ); + } + Ok(()) +} + +async fn form_party( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + if options.participant == 0 { + let (peer_low, peer_high) = create_player_guid_raw(options.peer_character_guid, realm_id()); + let payload = build_party_invite(&options.peer_name, peer_low, peer_high)?; + send_encrypted_packet( + &mut realm.stream, + &mut realm.crypt, + CMSG_PARTY_INVITE, + &payload, + ) + .await?; + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_UPDATE, + result, + ) + .await?; + } else { + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_INVITE, + result, + ) + .await?; + send_encrypted_packet( + &mut realm.stream, + &mut realm.crypt, + CMSG_PARTY_INVITE_RESPONSE, + &[0x40], + ) + .await?; + wait_for_realm_opcode( + bot_index, + stream, + crypt, + inflater, + realm, + options, + SMSG_PARTY_UPDATE, + result, + ) + .await?; + } + Ok(()) +} + +async fn wait_for_realm_opcode( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + expected: u16, + result: &mut BotRunResult, +) -> Result> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + loop { + options.sync.cancellation_error()?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for realm opcode 0x{expected:04X}"); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race realm packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Realm)?; + if opcode == SMSG_PARTY_UPDATE { + validate_party_update_like_cpp( + &payload, + create_player_guid_raw(options.character_guid, realm_id()), + create_player_guid_raw(options.peer_character_guid, realm_id()), + create_player_guid_raw(options.killer_character_guid, realm_id()), + )?; + } + if opcode == expected { + return Ok(payload); + } + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-race instance packet while forming party", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Instance)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } +} + +async fn drain_until_target_or_quiet( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut seen = false; + while tokio::time::Instant::now() < deadline { + options.sync.cancellation_error()?; + let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(100), + Duration::from_secs(2), + "loot-race target discovery", + ) + .await? + else { + continue; + }; + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if let Some(counter) = target_seen_in_update(options, opcode, &payload)? { + result.loot_race_target_runtime_counter = Some(counter); + seen = true; + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + if seen { + break; + } + } + // Do not let unrelated queued realm traffic grow without bound. + let _ = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + Duration::from_secs(1), + "loot-race realm discovery drain", + ) + .await?; + Ok(seen) +} + +async fn kill_target_once( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + let (target_low, target_high) = options.resolved_runtime_guid()?; + let (killer_low, killer_high) = + create_player_guid_raw(options.killer_character_guid, realm_id()); + send_encrypted_packet( + stream, + crypt, + CMSG_ATTACK_SWING, + &build_packed_guid(target_low, target_high), + ) + .await?; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + let mut positive_damage = 0i64; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!( + "timed out waiting for the disposable loot-race creature to die after {positive_damage} observed damage" + ); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race killer combat packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + if opcode == SMSG_ATTACKER_STATE_UPDATE { + let update = parse_attacker_state_update_summary(&payload) + .context("malformed loot-race SMSG_ATTACKER_STATE_UPDATE")?; + if (update.attacker_guid_low, update.attacker_guid_high) + == (killer_low, killer_high) + && (update.victim_guid_low, update.victim_guid_high) + == (target_low, target_high) + { + if update.damage < 0 { + bail!( + "loot-race killer reported negative damage {}", + update.damage + ); + } + positive_damage += i64::from(update.damage); + if update.over_damage >= 0 { + if positive_damage == 0 { + bail!("loot-race target death had no positive killer damage evidence"); + } + return Ok(()); + } + } + } else if opcode == SMSG_ATTACK_STOP { + let stop = parse_attack_stop_summary(&payload) + .context("malformed loot-race SMSG_ATTACK_STOP")?; + if (stop.attacker_guid_low, stop.attacker_guid_high) == (killer_low, killer_high) + && (stop.victim_guid_low, stop.victim_guid_high) == (target_low, target_high) + { + if !stop.now_dead { + bail!("server stopped the loot-race attack before the target died"); + } + if positive_damage == 0 { + bail!("loot-race target death had no positive killer damage evidence"); + } + return Ok(()); + } + } + } + if let Some((opcode, _payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + remaining, + "loot-race killer realm combat packet", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + } + } +} + +async fn wait_for_loot_window( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(options.timeout_secs); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for SMSG_LOOT_RESPONSE"); + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + remaining.min(Duration::from_millis(50)), + remaining, + "loot-race loot response", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + if opcode == SMSG_LOOT_RESPONSE { + let response = parse_loot_response(&payload)?; + let expected_items = response + .items + .iter() + .filter(|item| { + item.item_entry == options.target.item_entry && item.quantity == 1 + }) + .collect::>(); + let wrong_item_shape = expected_items.len() != 1 + || (options.phase == LootRacePhase::CaptureItem && response.items.len() != 1); + if wrong_item_shape { + bail!( + "loot response contained {} total rows and {} matching single-item rows for expected entry {}; strict capture requires one of each and race requires exactly one expected row", + response.items.len(), + expected_items.len(), + options.target.item_entry, + ); + } + let item = expected_items[0]; + let (owner_low, owner_high) = options.resolved_runtime_guid()?; + if (response.owner_low, response.owner_high) != (owner_low, owner_high) { + bail!("loot response owner did not match the exact acknowledged world spawn"); + } + validate_loot_object_guid_like_cpp( + response.loot_low, + response.loot_high, + options.target.map_id, + realm_id(), + )?; + let wrong_money_shape = match options.phase { + LootRacePhase::CaptureItem => response.coins != 0, + LootRacePhase::Race => response.coins != RACE_GAMEOBJECT_MONEY, + LootRacePhase::VerifyRelog => true, + }; + let wrong_method = options.phase == LootRacePhase::Race + && response.loot_method != PERSONAL_LOOT_METHOD_LIKE_CPP; + if !response.acquired + || response.failure_reason != 17 + || wrong_money_shape + || wrong_method + { + bail!( + "loot response did not match the acquired item/money/method contract for {:?} (failure={}, acquired={}, coins={}, method={})", + options.phase, + response.failure_reason, + response.acquired, + response.coins, + response.loot_method + ); + } + return Ok(LootWindow { + owner_low: response.owner_low, + owner_high: response.owner_high, + loot_low: response.loot_low, + loot_high: response.loot_high, + coins: response.coins, + item_entry: item.item_entry, + quantity: item.quantity, + loot_list_id: item.loot_list_id, + loot_method: response.loot_method, + }); + } + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + let _ = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + Duration::from_millis(1), + remaining, + "loot-race realm while opening", + ) + .await?; + } +} + +async fn collect_evidence( + bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + inflater: &mut ServerPacketInflater, + realm: &mut EncryptedWorldConnection, + expected_loot_owner: (u64, u64), + window: Duration, + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result { + let deadline = tokio::time::Instant::now() + window; + let mut evidence = WireEvidence::default(); + while tokio::time::Instant::now() < deadline { + options.sync.cancellation_error()?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + &mut realm.stream, + &mut realm.crypt, + &mut realm.inflater, + remaining.min(Duration::from_millis(20)), + remaining, + "loot-race realm evidence", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + } + if let Some((opcode, payload)) = read_encrypted_packet_if_ready( + stream, + crypt, + inflater, + Duration::from_millis(1), + remaining, + "loot-race instance evidence", + ) + .await? + { + result.seen_opcodes.push(format!("0x{opcode:04X}")); + record_evidence(opcode, &payload, expected_loot_owner, &mut evidence)?; + handle_instance_housekeeping(bot_index, stream, crypt, opcode, &payload).await?; + } + } + Ok(evidence) +} + +fn record_evidence( + opcode: u16, + payload: &[u8], + expected_loot_owner: (u64, u64), + evidence: &mut WireEvidence, +) -> Result<()> { + match opcode { + SMSG_ITEM_PUSH_RESULT => evidence.item_pushes.push(parse_item_push(payload)?), + SMSG_LOOT_REMOVED => { + let (owner_used, owner_low, owner_high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED owner guid"))?; + if (owner_low, owner_high) != expected_loot_owner { + bail!( + "SMSG_LOOT_REMOVED owner ({owner_low:#x}, {owner_high:#x}) did not match discovered world-object GUID ({:#x}, {:#x})", + expected_loot_owner.0, + expected_loot_owner.1 + ); + } + let (loot_used, loot_low, loot_high) = parse_packed_guid(&payload[owner_used..]) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED loot guid"))?; + let list_id = *payload + .get(owner_used + loot_used) + .ok_or_else(|| anyhow!("malformed SMSG_LOOT_REMOVED list id"))?; + if owner_used + loot_used + 1 != payload.len() { + bail!("SMSG_LOOT_REMOVED has unexpected trailing bytes"); + } + evidence.loot_removed.push(LootRemovedEvidence { + owner_low, + owner_high, + loot_low, + loot_high, + loot_list_id: list_id, + }); + } + SMSG_LOOT_MONEY_NOTIFY => { + if payload.len() != 17 { + bail!("malformed SMSG_LOOT_MONEY_NOTIFY"); + } + evidence.money_notifies.push(MoneyNotify { + money: u64::from_le_bytes(payload[0..8].try_into()?), + money_mod: u64::from_le_bytes(payload[8..16].try_into()?), + sole_looter: payload[16] & 0x80 != 0, + }); + } + SMSG_COIN_REMOVED => { + let (used, low, high) = + parse_packed_guid(payload).ok_or_else(|| anyhow!("malformed SMSG_COIN_REMOVED"))?; + if used != payload.len() { + bail!("SMSG_COIN_REMOVED has unexpected trailing bytes"); + } + evidence.coin_removed.push((low, high)); + } + SMSG_INVENTORY_CHANGE_FAILURE => evidence + .inventory_failures + .push(parse_inventory_failure(payload)?), + _ => {} + } + Ok(()) +} + +async fn validate_shared_windows(options: &LootRaceOptions) -> Result<()> { + let windows = options.sync.windows.lock().await; + let left = windows[0] + .as_ref() + .ok_or_else(|| anyhow!("bot A did not record a loot window"))?; + let right = windows[1] + .as_ref() + .ok_or_else(|| anyhow!("bot B did not record a loot window"))?; + if left != right { + bail!("two bots did not receive the same shared loot authority: {left:?} vs {right:?}"); + } + Ok(()) +} + +/// Validate the exact `ObjectGuid::Create` wire shape. +/// +/// C++ `ObjectGuidFactory::CreateWorldObject` substitutes the active realm +/// when its realm argument is zero, then encodes a map-local LootObject with +/// zero subtype/server/entry and a nonzero map sequence counter. +fn validate_loot_object_guid_like_cpp( + low: u64, + high: u64, + expected_map_id: u16, + expected_realm_id: u32, +) -> Result<()> { + let high_type = (high >> 58) & GUID_HIGH_TYPE_MASK; + let realm = (high >> 42) & GUID_REALM_MASK; + let map = (high >> 29) & GUID_MAP_MASK; + let entry = (high >> 6) & GUID_ENTRY_MASK; + let subtype = high & GUID_SUBTYPE_MASK; + let server = (low >> 40) & GUID_SERVER_MASK; + let counter = low & GUID_COUNTER_MASK; + let expected_realm = u64::from(expected_realm_id) & GUID_REALM_MASK; + let expected_map = u64::from(expected_map_id) & GUID_MAP_MASK; + + if high_type != HIGH_GUID_LOOT_OBJECT + || realm != expected_realm + || map != expected_map + || entry != 0 + || subtype != 0 + || server != 0 + || counter == 0 + { + bail!( + "loot response LootObject GUID has invalid C++ structure: type={high_type}, realm={realm}, map={map}, entry={entry}, subtype={subtype}, server={server}, counter={counter}; expected type={HIGH_GUID_LOOT_OBJECT}, realm={expected_realm}, map={expected_map}, entry/subtype/server=0 and nonzero counter" + ); + } + + Ok(()) +} + +async fn validate_item_outcome(options: &LootRaceOptions, result: &mut BotRunResult) -> Result<()> { + let window = options.sync.windows.lock().await[options.participant] + .clone() + .ok_or_else(|| anyhow!("loot-race participant has no recorded loot window"))?; + let (owner_low, owner_high) = options.resolved_runtime_guid()?; + let expected_removal = LootRemovedEvidence { + owner_low, + owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + let evidence = options.sync.evidence.lock().await; + let character_guids = if options.participant == 0 { + [options.character_guid, options.peer_character_guid] + } else { + [options.peer_character_guid, options.character_guid] + }; + let grant = validate_atomic_item_wire_outcome_like_cpp( + &evidence, + character_guids, + options.target.item_entry, + window.quantity, + expected_removal, + realm_id(), + )?; + // `ItemPushResult::PlayerGUID`, rather than the receiving socket, names + // the winner. This remains correct when #55 adds C++'s group broadcast. + result.loot_race_item_push_seen = grant.owner_guid == options.character_guid; + result.loot_race_loot_removed_seen = + evidence[options.participant].loot_removed == [expected_removal]; + Ok(()) +} + +/// Prove one logical item grant without mistaking C++ party fanout for a +/// duplicate grant. +/// +/// Issue #106 owns atomic claim authority, while the complete +/// `StoreLootItem` side-effect cascade remains #55. The current Rust path may +/// send only to the winner; C++ broadcasts the same packet to both group +/// members for the default item. Both shapes represent one grant, but any +/// second packet on one socket or any divergent packet fails closed. +fn validate_atomic_item_wire_outcome_like_cpp( + evidence: &[WireEvidence; 2], + character_guids: [u64; 2], + expected_item_entry: u32, + expected_quantity: u32, + expected_removal: LootRemovedEvidence, + expected_realm_id: u32, +) -> Result { + for (participant, entry) in evidence.iter().enumerate() { + if entry.loot_removed.as_slice() != [expected_removal] { + bail!( + "participant {participant} observed LootRemoved fanout {:?}; expected exactly {:?}", + entry.loot_removed, + expected_removal + ); + } + if entry.item_pushes.len() > 1 { + bail!( + "participant {participant} observed {} ItemPush packets; one logical grant permits at most one observation per socket", + entry.item_pushes.len() + ); + } + } + + // Deliberately inspect every ItemPush before checking the expected entry. + // Filtering by entry would hide a foreign or late duplicate grant. + let wire_grants = evidence + .iter() + .enumerate() + .flat_map(|(participant, entry)| { + entry + .item_pushes + .iter() + .copied() + .map(move |push| WireItemGrant { participant, push }) + }) + .collect::>(); + let first = wire_grants + .first() + .copied() + .ok_or_else(|| anyhow!("atomic ITEM race emitted no ItemPush result"))?; + if wire_grants.iter().any(|grant| grant.push != first.push) { + bail!("atomic ITEM race emitted divergent ItemPush observations: {wire_grants:?}"); + } + + let push = first.push; + let expected_quantity = i32::try_from(expected_quantity) + .map_err(|_| anyhow!("loot-race expected item quantity exceeds i32"))?; + if push.item_entry != expected_item_entry + || push.quantity != expected_quantity + || push.quantity_in_inventory != expected_quantity + { + bail!( + "atomic ITEM push entry/quantity/inventory {:?} did not match expected {expected_item_entry}/{expected_quantity}/{expected_quantity}", + (push.item_entry, push.quantity, push.quantity_in_inventory) + ); + } + if push.slot != INVENTORY_SLOT_BAG_0 + || !(i32::from(INVENTORY_SLOT_ITEM_START)..i32::from(INVENTORY_SLOT_ITEM_START + 16)) + .contains(&push.slot_in_bag) + { + bail!( + "atomic ITEM push slot {}/{} was not one of the preflight-guaranteed base-backpack destinations", + push.slot, + push.slot_in_bag + ); + } + if push.pushed + || push.created + || push.display_text != 1 + || push.is_bonus_roll + || push.is_encounter_loot + || push.dungeon_encounter_id != 0 + { + bail!( + "atomic ITEM push flags were not the ordinary overworld StoreLootItem shape: {push:?}" + ); + } + let expected_item_high = + (HIGH_GUID_ITEM << 58) | ((u64::from(expected_realm_id) & GUID_REALM_SPECIFIC_MASK) << 42); + if push.item_guid_low == 0 + || push.item_guid_low & !GUID_COUNTER_MASK != 0 + || push.item_guid_high != expected_item_high + { + bail!( + "atomic ITEM push GUID {:#018X}/{:#018X} was not a nonempty C++ Item GUID for realm {}", + push.item_guid_low, + push.item_guid_high, + expected_realm_id + ); + } + + let winner = character_guids + .iter() + .position(|guid| { + create_player_guid_raw(*guid, expected_realm_id) == (push.player_low, push.player_high) + }) + .ok_or_else(|| { + anyhow!( + "atomic ITEM push winner {:#018X}/{:#018X} was neither disposable character", + push.player_low, + push.player_high + ) + })?; + let loser = 1 - winner; + if evidence[winner].item_pushes.as_slice() != [push] { + bail!("atomic ITEM winner socket did not receive exactly its direct ItemPush"); + } + if !evidence[loser].item_pushes.is_empty() && evidence[loser].item_pushes.as_slice() != [push] { + bail!("atomic ITEM loser observed a non-identical group ItemPush"); + } + + let loot_gone = InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }; + if !evidence[winner].inventory_failures.is_empty() + || evidence[loser].inventory_failures.as_slice() != [loot_gone] + { + bail!( + "atomic ITEM failure fanout was winner={:?}, loser={:?}; expected no winner failure and one exact EQUIP_ERR_LOOT_GONE (50)", + evidence[winner].inventory_failures, + evidence[loser].inventory_failures + ); + } + + Ok(ExpectedPersistedItemGrant { + owner_guid: character_guids[winner], + push, + }) +} + +async fn validate_money_outcome( + options: &LootRaceOptions, + result: &mut BotRunResult, +) -> Result<()> { + let window = options.sync.windows.lock().await[options.participant] + .clone() + .ok_or_else(|| anyhow!("loot-race participant has no recorded loot window"))?; + let evidence = options.sync.evidence.lock().await; + let source_coins = u64::from(window.coins); + let expected_source = (window.loot_low, window.loot_high); + let winner = validate_serialized_gameobject_money_wire_outcome_like_cpp( + &evidence, + expected_source, + source_coins, + )?; + result.loot_race_money_notify_amount = Some(if winner == options.participant { + source_coins + } else { + 0 + }); + result.loot_race_coin_removed_seen = evidence[options.participant] + .coin_removed + .contains(&expected_source); + Ok(()) +} + +/// Validate the two serialized C++ `HandleLootMoneyOpcode` observations. +/// +/// Both clients send `CMSG_LOOT_MONEY`. For `LOOT_CHEST`, C++ deliberately +/// keeps `shareMoney=false`: the first serialized requester receives the whole +/// pool with `SoleLooter=true`, then `Loot::LootMoney()` sets gold to zero. The +/// second requester receives one zero notification. Each request still calls +/// `Loot::NotifyMoneyRemoved`, so both active viewers observe two removals. +fn validate_serialized_gameobject_money_wire_outcome_like_cpp( + evidence: &[WireEvidence; 2], + expected_source: (u64, u64), + expected_pool: u64, +) -> Result { + if expected_pool == 0 { + bail!("loot-race cannot prove a positive serialized money winner"); + } + + let positive = MoneyNotify { + money: expected_pool, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + + let mut winner = None; + for (participant, entry) in evidence.iter().enumerate() { + match entry.money_notifies.as_slice() { + [notify] if *notify == positive => { + if winner.replace(participant).is_some() { + bail!("loot-race observed more than one positive chest-money winner"); + } + } + [notify] if *notify == zero => {} + _ => { + bail!( + "loot-race participant {participant} observed money notifications {:?}; C++ LOOT_CHEST requires one requester-local whole-pool {expected_pool} or serialized zero notification, both SoleLooter=true", + entry.money_notifies + ); + } + } + + let matching_removals = entry + .coin_removed + .iter() + .filter(|source| **source == expected_source) + .count(); + if entry.coin_removed.len() != 2 || matching_removals != 2 { + bail!( + "loot-race participant {participant} observed CoinRemoved sources {:?}; C++ calls NotifyMoneyRemoved once for each of the two serialized requests", + entry.coin_removed + ); + } + } + + winner.ok_or_else(|| anyhow!("loot-race emitted no positive chest-money winner")) +} + +async fn handle_instance_housekeeping( + _bot_index: usize, + stream: &mut TcpStream, + crypt: &mut WorldCrypt, + opcode: u16, + payload: &[u8], +) -> Result<()> { + if opcode == SMSG_TIME_SYNC_REQUEST { + let sequence = parse_time_sync_request_sequence(payload)?; + let response = build_time_sync_response_payload(sequence, current_millis_u32()); + send_encrypted_packet(stream, crypt, CMSG_TIME_SYNC_RESPONSE, &response).await?; + } + Ok(()) +} + +fn build_party_invite(name: &str, target_low: u64, target_high: u64) -> Result> { + if name.len() > 0x1FF { + bail!("party invite name is too long"); + } + // C++ reads HasPartyIndex as one bit and then ResetBitPos(), so the two + // string lengths begin at the next byte rather than sharing that bit byte. + let mut payload = vec![0]; + payload.extend_from_slice(&pack_msb_fields(&[(name.len() as u32, 9), (0, 9)])); + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(target_low, target_high)); + payload.extend_from_slice(name.as_bytes()); + Ok(payload) +} + +fn build_loot_item_claim(window: &LootWindow) -> Vec { + let mut payload = 1u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&build_packed_guid(window.loot_low, window.loot_high)); + payload.push(window.loot_list_id); + payload.push(0); // IsSoftInteract=false, flushed to a complete byte. + payload +} + +fn pack_msb_fields(fields: &[(u32, usize)]) -> Vec { + let mut bytes = Vec::new(); + let mut current = 0u8; + let mut used = 0usize; + for &(value, width) in fields { + for bit in (0..width).rev() { + current |= (((value >> bit) & 1) as u8) << (7 - used); + used += 1; + if used == 8 { + bytes.push(current); + current = 0; + used = 0; + } + } + } + if used != 0 { + bytes.push(current); + } + bytes +} + +#[derive(Debug)] +struct ParsedLootItem { + item_entry: u32, + quantity: u32, + loot_list_id: u8, +} + +#[derive(Debug)] +struct ParsedLootResponse { + owner_low: u64, + owner_high: u64, + loot_low: u64, + loot_high: u64, + failure_reason: u8, + loot_method: u8, + coins: u32, + acquired: bool, + items: Vec, +} + +fn parse_loot_response(payload: &[u8]) -> Result { + let (owner_used, owner_low, owner_high) = + parse_packed_guid(payload).ok_or_else(|| anyhow!("malformed loot response owner guid"))?; + let (loot_used, loot_low, loot_high) = parse_packed_guid(&payload[owner_used..]) + .ok_or_else(|| anyhow!("malformed loot response loot guid"))?; + let mut offset = owner_used + loot_used; + let failure_reason = take_u8(payload, &mut offset)?; + let _acquire_reason = take_u8(payload, &mut offset)?; + let loot_method = take_u8(payload, &mut offset)?; + let _threshold = take_u8(payload, &mut offset)?; + let coins = take_u32(payload, &mut offset)?; + let item_count = take_u32(payload, &mut offset)? as usize; + let currency_count = take_u32(payload, &mut offset)? as usize; + let bits = take_u8(payload, &mut offset)?; + let acquired = bits & 0x80 != 0; + let mut items = Vec::with_capacity(item_count); + for _ in 0..item_count { + let _item_bits = take_u8(payload, &mut offset)?; + let item_entry = take_i32(payload, &mut offset)?; + if item_entry <= 0 { + bail!("loot response has nonpositive item entry {item_entry}"); + } + let _random_seed = take_i32(payload, &mut offset)?; + let _random_property = take_i32(payload, &mut offset)?; + let has_bonus = take_u8(payload, &mut offset)? & 0x80 != 0; + let mod_count = usize::from(take_u8(payload, &mut offset)? >> 2); + offset = offset + .checked_add(mod_count * 5) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("loot response item modifications are truncated"))?; + if has_bonus { + let _context = take_u8(payload, &mut offset)?; + let bonus_count = take_u32(payload, &mut offset)? as usize; + offset = offset + .checked_add(bonus_count * 4) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("loot response item bonuses are truncated"))?; + } + let quantity = take_u32(payload, &mut offset)?; + let _loot_item_type = take_u8(payload, &mut offset)?; + let loot_list_id = take_u8(payload, &mut offset)?; + items.push(ParsedLootItem { + item_entry: item_entry as u32, + quantity, + loot_list_id, + }); + } + // The fixture forbids currencies; parsing their variable bit tail is not + // needed and accepting it would make the preflight weaker. + if currency_count != 0 { + bail!("loot-race fixture unexpectedly produced {currency_count} currencies"); + } + if offset != payload.len() { + bail!( + "loot response has {} unexpected trailing bytes", + payload.len() - offset + ); + } + Ok(ParsedLootResponse { + owner_low, + owner_high, + loot_low, + loot_high, + failure_reason, + loot_method, + coins, + acquired, + items, + }) +} + +fn parse_item_push(payload: &[u8]) -> Result { + let (player_used, player_low, player_high) = parse_packed_guid(payload) + .ok_or_else(|| anyhow!("malformed SMSG_ITEM_PUSH_RESULT player guid"))?; + let mut offset = player_used; + let slot = take_u8(payload, &mut offset)?; + let slot_in_bag = take_i32(payload, &mut offset)?; + let quest_log_item_id = take_i32(payload, &mut offset)?; + let quantity = take_i32(payload, &mut offset)?; + let quantity_in_inventory = take_i32(payload, &mut offset)?; + let dungeon_encounter_id = take_i32(payload, &mut offset)?; + // Battle-pet metadata is part of the 3.4.3 layout but is unrelated to + // this ordinary item fixture. Consume it so the following GUID/bit fields + // remain aligned. + for _ in 0..4 { + let _ = take_i32(payload, &mut offset)?; + } + let (item_guid_used, item_guid_low, item_guid_high) = parse_packed_guid(&payload[offset..]) + .ok_or_else(|| anyhow!("malformed SMSG_ITEM_PUSH_RESULT item guid"))?; + offset += item_guid_used; + let flags = take_u8(payload, &mut offset)?; + if flags & 0x01 != 0 { + bail!("SMSG_ITEM_PUSH_RESULT has a nonzero reserved padding bit"); + } + let pushed = flags & 0x80 != 0; + let created = flags & 0x40 != 0; + let display_text = (flags >> 3) & 0x07; + let is_bonus_roll = flags & 0x04 != 0; + let is_encounter_loot = flags & 0x02 != 0; + let item_entry = take_i32(payload, &mut offset)?; + if item_entry <= 0 { + bail!("item push has nonpositive item entry {item_entry}"); + } + let _random_properties_seed = take_i32(payload, &mut offset)?; + let _random_properties_id = take_i32(payload, &mut offset)?; + let item_bonus_bits = take_u8(payload, &mut offset)?; + if item_bonus_bits & 0x7F != 0 { + bail!("SMSG_ITEM_PUSH_RESULT ItemBonus has nonzero padding bits"); + } + let has_item_bonus = item_bonus_bits & 0x80 != 0; + let modification_bits = take_u8(payload, &mut offset)?; + if modification_bits & 0x03 != 0 { + bail!("SMSG_ITEM_PUSH_RESULT ItemModList has nonzero padding bits"); + } + let modification_count = usize::from(modification_bits >> 2); + for _ in 0..modification_count { + let _value = take_i32(payload, &mut offset)?; + let _modifier_type = take_u8(payload, &mut offset)?; + } + if has_item_bonus { + let _context = take_u8(payload, &mut offset)?; + let bonus_count = usize::try_from(take_u32(payload, &mut offset)?)?; + let bonus_bytes = bonus_count + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| anyhow!("SMSG_ITEM_PUSH_RESULT bonus-list length overflow"))?; + offset = offset + .checked_add(bonus_bytes) + .filter(|end| *end <= payload.len()) + .ok_or_else(|| anyhow!("SMSG_ITEM_PUSH_RESULT item bonuses are truncated"))?; + } + if offset != payload.len() { + bail!( + "SMSG_ITEM_PUSH_RESULT has {} unexpected trailing bytes", + payload.len() - offset + ); + } + Ok(ItemPush { + player_low, + player_high, + slot, + slot_in_bag, + quest_log_item_id, + quantity, + quantity_in_inventory, + dungeon_encounter_id, + item_guid_low, + item_guid_high, + pushed, + created, + display_text, + is_bonus_roll, + is_encounter_loot, + item_entry: item_entry as u32, + }) +} + +fn parse_inventory_failure(payload: &[u8]) -> Result { + if payload.len() < 4 { + bail!("malformed SMSG_INVENTORY_CHANGE_FAILURE result"); + } + let result = i32::from_le_bytes(payload[0..4].try_into()?); + let (item_0_used, item_0_low, item_0_high) = parse_packed_guid(&payload[4..]) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE item 0"))?; + let item_1_offset = 4 + item_0_used; + let (item_1_used, item_1_low, item_1_high) = parse_packed_guid(&payload[item_1_offset..]) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE item 1"))?; + let end = item_1_offset + item_1_used; + let container_b_slot = *payload + .get(end) + .ok_or_else(|| anyhow!("malformed SMSG_INVENTORY_CHANGE_FAILURE container slot"))?; + // EQUIP_ERR_LOOT_GONE has no result-specific tail in C++. + if result == 50 && end + 1 != payload.len() { + bail!("EQUIP_ERR_LOOT_GONE has unexpected trailing bytes"); + } + Ok(InventoryFailure { + result, + item_0_low, + item_0_high, + item_1_low, + item_1_high, + container_b_slot, + }) +} + +fn take_u8(payload: &[u8], offset: &mut usize) -> Result { + let value = *payload + .get(*offset) + .ok_or_else(|| anyhow!("packet truncated at byte {}", *offset))?; + *offset += 1; + Ok(value) +} + +fn take_u32(payload: &[u8], offset: &mut usize) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| anyhow!("packet offset overflow"))?; + let bytes: [u8; 4] = payload + .get(*offset..end) + .ok_or_else(|| anyhow!("packet truncated at byte {}", *offset))? + .try_into()?; + *offset = end; + Ok(u32::from_le_bytes(bytes)) +} + +fn take_i32(payload: &[u8], offset: &mut usize) -> Result { + Ok(take_u32(payload, offset)? as i32) +} + +fn generated_fixture_health_like_cpp(base_health: u32, health_modifier: f32) -> u32 { + (base_health as f32 * health_modifier).ceil() as u32 +} + +fn loot_db_opts(url: &str, label: &str) -> Result { + let opts = + mysql::Opts::from_url(url).map_err(|error| anyhow!("Bad {label} DB URL: {error}"))?; + Ok(mysql::OptsBuilder::from_opts(opts) + .tcp_connect_timeout(Some(Duration::from_secs(10))) + .read_timeout(Some(Duration::from_secs(LOOT_DB_OPERATION_TIMEOUT_SECS))) + .write_timeout(Some(Duration::from_secs(LOOT_DB_OPERATION_TIMEOUT_SECS))) + .into()) +} + +fn validate_guarded_fixture_health(health_modifier: f32, generated_max_health: u32) -> Result<()> { + if (health_modifier - GUARDED_FIXTURE_HEALTH_MODIFIER).abs() > 0.000_000_1 + || generated_max_health != 1 + { + bail!( + "loot fixture must be loaded through the pre-start health guard: expected HealthModifier {GUARDED_FIXTURE_HEALTH_MODIFIER} and generated base health 1, got modifier {health_modifier} and health {generated_max_health}; restart the exact world artifact with RUST_CAPTURE_LOOT_FIXTURE_GUARD=1" + ); + } + Ok(()) +} + +fn prepare_gameobject_race_fixture( + bots: &[config::BotConfig], + cli: &LootRaceCli, + shutdown: &CancellationToken, +) -> Result { + if shutdown.is_cancelled() { + bail!("loot-race cancelled before GameObject fixture preflight"); + } + if (cli.entry, cli.spawn_guid, cli.item_entry) + != ( + DEFAULT_CREATURE_ENTRY, + DEFAULT_CREATURE_SPAWN_GUID, + DEFAULT_ITEM_ENTRY, + ) + { + bail!("GameObject race fixture did not match the pinned wrapper-owned contract"); + } + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts) + .map_err(|error| anyhow!("Connect to world DB failed: {error}"))?; + let spawn: mysql::Row = world + .exec_first( + "SELECT guid, id, map, zoneId, areaId, spawnDifficulties, phaseUseFlags, \ + PhaseId, PhaseGroup, terrainSwapMap, position_x, position_y, position_z, \ + orientation, rotation0, rotation1, rotation2, rotation3, spawntimesecs, \ + animprogress, state, ScriptName, StringId, VerifiedBuild \ + FROM gameobject WHERE guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load wrapper-owned GameObject race spawn: {error}"))? + .ok_or_else(|| { + anyhow!( + "wrapper-owned world.gameobject spawn {} is absent; start the world through the loot-race fixture guard", + cli.spawn_guid + ) + })?; + let spawn_guid: u64 = required_row_value(&spawn, "guid")?; + let entry: u32 = required_row_value(&spawn, "id")?; + let map_id: u16 = required_row_value(&spawn, "map")?; + let zone_id: u16 = required_row_value(&spawn, "zoneId")?; + let area_id: u16 = required_row_value(&spawn, "areaId")?; + let difficulties: String = required_row_value(&spawn, "spawnDifficulties")?; + let phase_flags: u8 = required_row_value(&spawn, "phaseUseFlags")?; + let phase_id: i32 = required_row_value(&spawn, "PhaseId")?; + let phase_group: i32 = required_row_value(&spawn, "PhaseGroup")?; + let terrain_swap_map: i32 = required_row_value(&spawn, "terrainSwapMap")?; + let x: f64 = required_row_value(&spawn, "position_x")?; + let y: f64 = required_row_value(&spawn, "position_y")?; + let z: f64 = required_row_value(&spawn, "position_z")?; + let orientation: f32 = required_row_value(&spawn, "orientation")?; + let rotations = [ + required_row_value::(&spawn, "rotation0")?, + required_row_value::(&spawn, "rotation1")?, + required_row_value::(&spawn, "rotation2")?, + required_row_value::(&spawn, "rotation3")?, + ]; + let spawntime: u32 = required_row_value(&spawn, "spawntimesecs")?; + let anim_progress: u8 = required_row_value(&spawn, "animprogress")?; + let state: u8 = required_row_value(&spawn, "state")?; + let spawn_script: String = required_row_value(&spawn, "ScriptName")?; + let string_id: Option = required_row_value(&spawn, "StringId")?; + let verified_build: i32 = required_row_value(&spawn, "VerifiedBuild")?; + let position_matches = (x - RACE_GAMEOBJECT_X).abs() <= 0.01 + && (y - RACE_GAMEOBJECT_Y).abs() <= 0.01 + && (z - RACE_GAMEOBJECT_Z).abs() <= 0.01; + if spawn_guid != DEFAULT_CREATURE_SPAWN_GUID + || entry != DEFAULT_CREATURE_ENTRY + || map_id != RACE_GAMEOBJECT_MAP_ID + || zone_id != 0 + || area_id != 0 + || difficulties != "0" + || phase_flags != 0 + || phase_id != 0 + || phase_group != 0 + || terrain_swap_map != -1 + || !position_matches + || orientation.abs() > f32::EPSILON + || rotations + .iter() + .any(|rotation| rotation.abs() > f32::EPSILON) + || spawntime != RACE_GAMEOBJECT_RESPAWN_SECS + || anim_progress != RACE_GAMEOBJECT_ANIM_PROGRESS + || state != RACE_GAMEOBJECT_STATE + || !spawn_script.is_empty() + || string_id.is_some() + || verified_build != 0 + { + bail!( + "wrapper-owned GameObject spawn drifted from the exact QA contract: guid={spawn_guid} entry={entry} map={map_id} zone={zone_id} area={area_id} difficulties={difficulties:?} phase={phase_flags}/{phase_id}/{phase_group} terrain={terrain_swap_map} pos=({x},{y},{z}) orientation={orientation} rotations={rotations:?} respawn={spawntime} anim={anim_progress} state={state} script={spawn_script:?} string_id={string_id:?} build={verified_build}" + ); + } + let same_entry_map_spawns: Vec = world + .exec_map( + "SELECT guid FROM gameobject WHERE id = ? AND map = ? ORDER BY guid", + (entry, map_id), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Check GameObject race map/entry spawn uniqueness: {error}"))?; + validate_unique_sql_spawn(&same_entry_map_spawns, spawn_guid, entry, map_id)?; + + let template: mysql::Row = world + .exec_first( + "SELECT type, displayId, name, size, \ + Data0, Data1, Data2, Data3, Data4, Data5, Data6, Data7, Data8, Data9, \ + Data10, Data11, Data12, Data13, Data14, Data15, Data16, Data17, Data18, Data19, \ + Data20, Data21, Data22, Data23, Data24, Data25, Data26, Data27, Data28, Data29, \ + Data30, Data31, Data32, Data33, Data34, ContentTuningId, AIName, ScriptName, \ + StringId, VerifiedBuild \ + FROM gameobject_template WHERE entry = ?", + (entry,), + ) + .map_err(|error| anyhow!("Load Tattered Chest template: {error}"))? + .ok_or_else(|| anyhow!("Tattered Chest template {entry} is absent"))?; + let go_type: u8 = required_row_value(&template, "type")?; + let display_id: u32 = required_row_value(&template, "displayId")?; + let name: String = required_row_value(&template, "name")?; + let size: f32 = required_row_value(&template, "size")?; + let mut template_data = [0_i32; 35]; + for (index, value) in template_data.iter_mut().enumerate() { + *value = required_row_value(&template, &format!("Data{index}"))?; + } + let content_tuning_id: u32 = required_row_value(&template, "ContentTuningId")?; + let ai_name: String = required_row_value(&template, "AIName")?; + let template_script: String = required_row_value(&template, "ScriptName")?; + let template_string_id: Option = required_row_value(&template, "StringId")?; + let template_build: i32 = required_row_value(&template, "VerifiedBuild")?; + if go_type != 3 + || display_id != 259 + || name != "Tattered Chest" + || (size - 1.0).abs() > f32::EPSILON + || template_data != RACE_GAMEOBJECT_TEMPLATE_DATA + || content_tuning_id != 0 + || !ai_name.is_empty() + || !template_script.is_empty() + || template_string_id.is_some() + || template_build != 11_723 + { + bail!( + "Tattered Chest template/data drifted from the exact shared C++ fixture contract: type={go_type} display={display_id} name={name:?} size={size} data={template_data:?} content_tuning={content_tuning_id} ai={ai_name:?} script={template_script:?} string_id={template_string_id:?} build={template_build}" + ); + } + + let loot_rows: Vec<(u32, u32, f32, u8, u16, u8, u8, u8)> = world + .exec( + "SELECT Item, Reference, Chance, QuestRequired, LootMode, GroupId, MinCount, MaxCount \ + FROM gameobject_loot_template WHERE Entry = ? ORDER BY Item, Reference", + (RACE_GAMEOBJECT_LOOT_ID,), + ) + .map_err(|error| anyhow!("Load Tattered Chest loot template: {error}"))?; + if loot_rows.len() != 1 + || loot_rows[0].0 != DEFAULT_ITEM_ENTRY + || loot_rows[0].1 != 0 + || (loot_rows[0].2 - 100.0).abs() > f32::EPSILON + || loot_rows[0].3 != 0 + || loot_rows[0].4 != 1 + || loot_rows[0].5 != 0 + || loot_rows[0].6 != 1 + || loot_rows[0].7 != 1 + { + bail!( + "GameObject loot id {RACE_GAMEOBJECT_LOOT_ID} was not exactly one unconditional item-{DEFAULT_ITEM_ENTRY} grant: {loot_rows:?}" + ); + } + let loot_conditions: u64 = world + .exec_first( + "SELECT COUNT(*) FROM conditions \ + WHERE SourceTypeOrReferenceId = 4 AND SourceGroup = ?", + (RACE_GAMEOBJECT_LOOT_ID,), + ) + .map_err(|error| anyhow!("Check Tattered Chest loot conditions: {error}"))? + .unwrap_or(0); + let addon_rows: Vec<(u16, u32, u32, u32, i32, i32, i32, i32, i32, u32, u32)> = world + .exec( + "SELECT faction, flags, Mingold, Maxgold, artkit0, artkit1, artkit2, artkit3, artkit4, \ + WorldEffectID, AIAnimKitID \ + FROM gameobject_template_addon WHERE entry = ?", + (entry,), + ) + .map_err(|error| anyhow!("Load guarded Tattered Chest addon: {error}"))?; + if addon_rows.as_slice() + != [( + RACE_GAMEOBJECT_ADDON_FACTION, + 0, + RACE_GAMEOBJECT_MONEY, + RACE_GAMEOBJECT_MONEY, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + )] + { + bail!( + "wrapper-owned GameObject addon must match exact faction/flags/artkits/effects and deterministic money {0}/{0}, got {addon_rows:?}", + RACE_GAMEOBJECT_MONEY + ); + } + let event_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM game_event_gameobject WHERE guid = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject event ownership: {error}"))? + .unwrap_or(0); + let pool_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM pool_members WHERE type = 1 AND spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject pool ownership: {error}"))? + .unwrap_or(0); + let linked_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM linked_respawn WHERE guid = ? OR linkedGuid = ?", + (spawn_guid, spawn_guid), + ) + .map_err(|error| anyhow!("Check GameObject linked respawn: {error}"))? + .unwrap_or(0); + let spawn_addon_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM gameobject_addon WHERE guid = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject spawn addon ownership: {error}"))? + .unwrap_or(0); + let override_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM gameobject_overrides WHERE spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject override ownership: {error}"))? + .unwrap_or(0); + let spawn_group_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM spawn_group WHERE spawnType = 1 AND spawnId = ?", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Check GameObject spawn-group ownership: {error}"))? + .unwrap_or(0); + if loot_conditions != 0 + || event_rows != 0 + || pool_rows != 0 + || linked_rows != 0 + || spawn_addon_rows != 0 + || override_rows != 0 + || spawn_group_rows != 0 + { + bail!( + "GameObject fixture has conditional/spawn metadata: conditions={loot_conditions} event={event_rows} pool={pool_rows} linked={linked_rows} addon={spawn_addon_rows} override={override_rows} spawn_group={spawn_group_rows}" + ); + } + + let character_url = characters_db_url()?; + let character_opts = loot_db_opts(&character_url, "characters")?; + let mut character_db = mysql::Conn::new(character_opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + ensure_no_online_characters(&mut character_db, "GameObject loot-race fixture setup")?; + let respawn_rows: Vec<(i64, u16, u32)> = character_db + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = 1 AND spawnId = ? ORDER BY mapId, instanceId", + (spawn_guid,), + ) + .map_err(|error| anyhow!("Load GameObject respawn snapshot: {error}"))?; + if !respawn_rows.is_empty() { + bail!( + "GameObject spawn {spawn_guid} already has {} type=1 respawn row(s); restart through a clean fixture guard", + respawn_rows.len() + ); + } + + let (characters, progress) = snapshot_loot_fixture_characters( + &mut character_db, + bots, + DEFAULT_ITEM_ENTRY, + 0, + RACE_GAMEOBJECT_MONEY, + None, + )?; + info!( + "Loot-race shared GameObject fixture: `{name}` entry={entry} spawn={spawn_guid} map={map_id} runtime=wire-discovered loot={RACE_GAMEOBJECT_LOOT_ID} item={DEFAULT_ITEM_ENTRY} money={RACE_GAMEOBJECT_MONEY} respawn={spawntime}s state={state}; C++ GameObject.cpp shared group-rules path" + ); + let journal = FixtureJournal::configured()?; + let fixture = LootRaceFixture { + characters, + target: LootRaceTarget { + kind: LootRaceTargetKind::GameObject, + entry, + spawn_guid, + runtime_counter_override: cli.runtime_counter, + map_id, + x, + y, + z, + item_entry: DEFAULT_ITEM_ENTRY, + }, + respawn: None, + respawn_type: 1, + gameobject_state: Some(state), + progress, + journal, + }; + if shutdown.is_cancelled() { + bail!("loot-race cancelled before durable fixture snapshot"); + } + fixture.journal.persist(&fixture)?; + if shutdown.is_cancelled() { + bail!("loot-race cancelled after durable snapshot and before relocation"); + } + relocate_loot_fixture_characters(&mut character_db, &fixture.characters, map_id, x, y, z)?; + Ok(fixture) +} + +fn prepare_fixture( + bots: &[config::BotConfig], + cli: &LootRaceCli, + purpose: LootFixturePurpose, + shutdown: &CancellationToken, +) -> Result { + if shutdown.is_cancelled() { + bail!("loot workflow cancelled before fixture preflight"); + } + if bots.len() != 2 { + bail!("internal loot-race setup requires exactly two bots"); + } + for bot in bots { + if !bot.account.to_ascii_uppercase().ends_with("@BOT.LOCAL") { + bail!( + "refusing destructive loot-race setup for non-local account {}", + bot.account + ); + } + } + + if purpose == LootFixturePurpose::Race { + return prepare_gameobject_race_fixture(bots, cli, shutdown); + } + + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts) + .map_err(|error| anyhow!("Connect to world DB failed: {error}"))?; + let row: mysql::Row = world + .exec_first( + "SELECT c.guid, c.id, c.map, c.spawnDifficulties, c.spawntimesecs, \ + c.position_x, c.position_y, c.position_z, c.orientation, \ + c.phaseUseFlags, c.PhaseId, c.PhaseGroup, c.wander_distance, c.MovementType, \ + ct.name, ct.type, ct.unit_class, ct.unit_flags, ct.flags_extra, \ + d.MinLevel, d.MaxLevel, d.HealthScalingExpansion, d.HealthModifier, \ + d.LootID, d.GoldMin, d.GoldMax, \ + d.StaticFlags1 \ + FROM creature c \ + JOIN creature_template ct ON ct.entry = c.id \ + JOIN creature_template_difficulty d ON d.Entry = c.id AND d.DifficultyID = 0 \ + WHERE c.guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load loot-race creature fixture: {error}"))? + .ok_or_else(|| anyhow!("No world.creature row for spawn {}", cli.spawn_guid))?; + let spawn_guid: u64 = required_row_value(&row, "guid")?; + let entry: u32 = required_row_value(&row, "id")?; + let map_id: u16 = required_row_value(&row, "map")?; + let difficulties: String = required_row_value(&row, "spawnDifficulties")?; + let spawntime: u32 = required_row_value(&row, "spawntimesecs")?; + let x: f64 = required_row_value(&row, "position_x")?; + let y: f64 = required_row_value(&row, "position_y")?; + let z: f64 = required_row_value(&row, "position_z")?; + let _orientation: f32 = required_row_value(&row, "orientation")?; + let phase_flags: u8 = required_row_value(&row, "phaseUseFlags")?; + let phase_id: i32 = required_row_value(&row, "PhaseId")?; + let phase_group: i32 = required_row_value(&row, "PhaseGroup")?; + let wander_distance: f32 = required_row_value(&row, "wander_distance")?; + let movement_type: u8 = required_row_value(&row, "MovementType")?; + let name: String = required_row_value(&row, "name")?; + let creature_type: u8 = required_row_value(&row, "type")?; + let unit_class: u8 = required_row_value(&row, "unit_class")?; + let unit_flags: u32 = required_row_value(&row, "unit_flags")?; + let flags_extra: u32 = required_row_value(&row, "flags_extra")?; + let min_level: u8 = required_row_value(&row, "MinLevel")?; + let max_level: u8 = required_row_value(&row, "MaxLevel")?; + let health_scaling_expansion: i32 = required_row_value(&row, "HealthScalingExpansion")?; + let health_modifier: f32 = required_row_value(&row, "HealthModifier")?; + let loot_id: u32 = required_row_value(&row, "LootID")?; + let min_gold: u32 = required_row_value(&row, "GoldMin")?; + let max_gold: u32 = required_row_value(&row, "GoldMax")?; + let static_flags1: u32 = required_row_value(&row, "StaticFlags1")?; + if spawn_guid != cli.spawn_guid || entry != cli.entry || loot_id == 0 { + bail!("creature fixture does not match the exact spawn/entry/loot-id contract"); + } + let same_entry_map_spawns: Vec = world + .exec_map( + "SELECT guid FROM creature WHERE id = ? AND map = ? ORDER BY guid", + (entry, map_id), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Check loot-race map/entry spawn uniqueness: {error}"))?; + validate_unique_sql_spawn(&same_entry_map_spawns, cli.spawn_guid, entry, map_id)?; + if !matches!(map_id, 0 | 1 | 530 | 571) { + bail!("loot-race creature must be on a known overworld map, got map {map_id}"); + } + if !difficulties + .split(',') + .any(|difficulty| difficulty.trim() == "0") + { + bail!("creature fixture is not available on overworld difficulty 0"); + } + if phase_flags != 0 || phase_id != 0 || phase_group != 0 { + bail!("creature fixture has phase behavior outside this focused smoke"); + } + if wander_distance != 0.0 || movement_type != 0 { + bail!("creature fixture must be stationary for deterministic two-client engagement"); + } + if spawntime == 0 || spawntime > 3_600 { + bail!("creature respawn interval {spawntime} is outside the acknowledged QA boundary"); + } + if purpose != LootFixturePurpose::CaptureItem { + bail!("internal error: creature fixture is reserved for loot-item capture"); + } + if min_gold != 0 || max_gold != 0 { + bail!("loot-item capture creature must have no random money pool"); + } + if min_level == 0 || max_level < min_level || max_level > 70 { + bail!("loot-item capture creature exceeds the bounded combat target level"); + } + let health_expansion_index = match health_scaling_expansion { + -1 | 2 => 2, + 0 => 0, + 1 => 1, + value => bail!( + "loot fixture has unsupported HealthScalingExpansion {value}; expected the C++ -1..=2 domain" + ), + }; + let base_health_rows: Vec<(u8, u32, u32, u32)> = world + .exec( + "SELECT level, basehp0, basehp1, basehp2 \ + FROM creature_classlevelstats \ + WHERE class = ? AND level BETWEEN ? AND ? ORDER BY level", + (unit_class, min_level, max_level), + ) + .map_err(|error| anyhow!("Load loot fixture class-level health: {error}"))?; + let expected_level_rows = usize::from(max_level - min_level) + 1; + if base_health_rows.len() != expected_level_rows { + bail!( + "loot fixture class {} level range {}..{} resolved {} class-level health row(s), expected {expected_level_rows}", + unit_class, + min_level, + max_level, + base_health_rows.len() + ); + } + let guarded_max_health = base_health_rows + .iter() + .map(|(_, hp0, hp1, hp2)| { + let base_health = [*hp0, *hp1, *hp2][health_expansion_index].max(1); + generated_fixture_health_like_cpp(base_health, health_modifier) + }) + .max() + .unwrap_or(0); + validate_guarded_fixture_health(health_modifier, guarded_max_health)?; + const UNATTACKABLE_UNIT_FLAGS: u32 = 0x0000_0002 | 0x0000_0100 | 0x0001_0000 | 0x0200_0000; + const UNSUITABLE_STATIC_FLAGS1: u32 = 0x0000_0004 | 0x0000_0020 | 0x0000_0200; + if unit_flags & UNATTACKABLE_UNIT_FLAGS != 0 + || static_flags1 & UNSUITABLE_STATIC_FLAGS1 != 0 + || creature_type == CREATURE_TYPE_CRITTER + { + bail!("creature fixture is not a normal attackable shared-loot target"); + } + const UNSUITABLE_FLAGS_EXTRA: u32 = 0x0000_0080 | 0x0000_0400 | 0x0000_2000 | 0x0000_4000; + if flags_extra & UNSUITABLE_FLAGS_EXTRA != 0 { + bail!("creature fixture has trigger/ghost/no-combat/world-event flags"); + } + let loot_rows: Vec<(u32, u32, f32, u8, u16, u8, u8, u8)> = world + .exec( + "SELECT Item, Reference, Chance, QuestRequired, LootMode, GroupId, MinCount, MaxCount \ + FROM creature_loot_template WHERE Entry = ? ORDER BY Item, Reference", + (loot_id,), + ) + .map_err(|error| anyhow!("Load loot-race creature loot rows: {error}"))?; + if purpose == LootFixturePurpose::CaptureItem && loot_rows.len() != 1 { + bail!( + "creature loot id {loot_id} contains {} rows; strict capture requires exactly one logical item pool", + loot_rows.len() + ); + } + let matching_item_rows = loot_rows + .iter() + .filter(|&&(item, _, _, _, _, _, _, _)| item == cli.item_entry) + .collect::>(); + if matching_item_rows.len() != 1 { + bail!( + "creature loot id {loot_id} contains {} rows for expected item {}; expected exactly one", + matching_item_rows.len(), + cli.item_entry + ); + } + let &(_item, reference, chance, quest_required, loot_mode, group_id, min_count, max_count) = + matching_item_rows[0]; + if reference != 0 + || (chance - 100.0).abs() > f32::EPSILON + || quest_required != 0 + || loot_mode != 1 + || group_id != 0 + || min_count != 1 + || max_count != 1 + { + bail!("expected creature item row is not one unconditional normal single-item grant"); + } + let condition_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM conditions \ + WHERE SourceTypeOrReferenceId = 1 AND SourceGroup = ?", + (loot_id,), + ) + .map_err(|error| anyhow!("Check loot-race creature loot conditions: {error}"))? + .unwrap_or(0); + if condition_rows != 0 { + bail!("creature loot id {loot_id} has conditions outside this focused smoke"); + } + let event_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM game_event_creature WHERE guid = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Check loot-race game-event ownership: {error}"))? + .unwrap_or(0); + let pool_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM pool_members WHERE type = 0 AND spawnId = ?", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Check loot-race pool ownership: {error}"))? + .unwrap_or(0); + let linked_rows: u64 = world + .exec_first( + "SELECT COUNT(*) FROM linked_respawn WHERE guid = ? OR linkedGuid = ?", + (cli.spawn_guid, cli.spawn_guid), + ) + .map_err(|error| anyhow!("Check loot-race linked respawn: {error}"))? + .unwrap_or(0); + if event_rows != 0 || pool_rows != 0 || linked_rows != 0 { + bail!("creature fixture is event/pool/linked managed and cannot be consumed safely"); + } + + let character_url = characters_db_url()?; + let character_opts = loot_db_opts(&character_url, "characters")?; + let mut character_db = mysql::Conn::new(character_opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + ensure_no_online_characters(&mut character_db, "loot fixture setup")?; + let respawn_rows: Vec<(i64, u16, u32)> = character_db + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = 0 AND spawnId = ? ORDER BY mapId, instanceId", + (cli.spawn_guid,), + ) + .map_err(|error| anyhow!("Load loot-race respawn snapshot: {error}"))?; + if !respawn_rows.is_empty() { + bail!( + "creature spawn {} already has {} persisted respawn timer row(s); use a fresh runtime/fixture", + cli.spawn_guid, + respawn_rows.len() + ); + } + let respawn = respawn_rows.into_iter().next(); + + let (fixture_characters, progress) = snapshot_loot_fixture_characters( + &mut character_db, + bots, + cli.item_entry, + max_level, + max_gold, + Some((bots[0].character_guid, LOOT_ITEM_CAPTURE_KEYRING_SLOT)), + )?; + + info!( + "Loot-race disposable fixture: `{}` entry={} spawn={} map={} runtime_counter={} level={}..{} guarded_base_health={} loot={} item={} gold={}..{} respawn={}s; live creature cannot be restored without restart", + name, + entry, + cli.spawn_guid, + map_id, + if cli.runtime_counter == 0 { + "auto".to_string() + } else { + cli.runtime_counter.to_string() + }, + min_level, + max_level, + guarded_max_health, + loot_id, + cli.item_entry, + min_gold, + max_gold, + spawntime, + ); + let journal = FixtureJournal::configured()?; + let fixture = LootRaceFixture { + characters: fixture_characters, + target: LootRaceTarget { + kind: LootRaceTargetKind::Creature, + entry, + spawn_guid: cli.spawn_guid, + runtime_counter_override: cli.runtime_counter, + map_id, + x, + y, + z, + item_entry: cli.item_entry, + }, + respawn: respawn.map(|(respawn_time, map_id, instance_id)| RespawnSnapshot { + respawn_time, + map_id, + instance_id, + }), + respawn_type: 0, + gameobject_state: None, + progress, + journal, + }; + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled before durable fixture snapshot"); + } + fixture.journal.persist(&fixture)?; + if shutdown.is_cancelled() { + bail!("loot-item capture cancelled after durable snapshot and before relocation"); + } + relocate_loot_fixture_characters(&mut character_db, &fixture.characters, map_id, x, y, z)?; + Ok(fixture) +} + +fn snapshot_loot_fixture_characters( + character_db: &mut mysql::Conn, + bots: &[config::BotConfig], + item_entry: u32, + target_max_level: u8, + maximum_money_gain: u32, + required_empty_top_level_slot: Option<(u64, u8)>, +) -> Result<([CharacterFixture; 2], CharacterProgressSnapshot)> { + let mut fixtures = Vec::with_capacity(2); + for bot in bots { + let character: mysql::Row = character_db + .exec_first( + "SELECT account, name, race, level, xp, health, \ + power1, power2, power3, power4, power5, power6, power7, power8, power9, power10, \ + restState, rest_bonus, exploredZones, knownTitles, chosenTitle, \ + online, at_login, money, map, zone, instance_id, \ + position_x, position_y, position_z, orientation \ + FROM characters WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|error| anyhow!("Load loot-race character {}: {error}", bot.character_guid))? + .ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; + let account: u32 = required_row_value(&character, "account")?; + let name: String = required_row_value(&character, "name")?; + let race: u8 = required_row_value(&character, "race")?; + let level: u8 = required_row_value(&character, "level")?; + let xp: u32 = required_row_value(&character, "xp")?; + let health: u32 = required_row_value(&character, "health")?; + let powers = [ + required_row_value(&character, "power1")?, + required_row_value(&character, "power2")?, + required_row_value(&character, "power3")?, + required_row_value(&character, "power4")?, + required_row_value(&character, "power5")?, + required_row_value(&character, "power6")?, + required_row_value(&character, "power7")?, + required_row_value(&character, "power8")?, + required_row_value(&character, "power9")?, + required_row_value(&character, "power10")?, + ]; + let rest_state: u8 = required_row_value(&character, "restState")?; + let rest_bonus: f32 = required_row_value(&character, "rest_bonus")?; + let explored_zones: Option = required_row_value(&character, "exploredZones")?; + let known_titles: Option = required_row_value(&character, "knownTitles")?; + let chosen_title: u32 = required_row_value(&character, "chosenTitle")?; + let online: u8 = required_row_value(&character, "online")?; + let at_login: u16 = required_row_value(&character, "at_login")?; + let money: u64 = required_row_value(&character, "money")?; + let old_map: u32 = required_row_value(&character, "map")?; + let zone: u32 = required_row_value(&character, "zone")?; + let instance_id: u32 = required_row_value(&character, "instance_id")?; + let old_x: f64 = required_row_value(&character, "position_x")?; + let old_y: f64 = required_row_value(&character, "position_y")?; + let old_z: f64 = required_row_value(&character, "position_z")?; + let orientation: f32 = required_row_value(&character, "orientation")?; + if account != bot.account_id || online != 0 || at_login != 0 { + bail!( + "loot-race character {} owner/online/at_login safety check failed", + bot.character_guid + ); + } + if health == 0 || level <= target_max_level { + bail!( + "loot-race character {} must be alive and above target level {}", + bot.character_guid, + target_max_level + ); + } + let maximum_safe_start = MAX_PLAYER_MONEY_LIKE_CPP + .checked_sub(u64::from(maximum_money_gain)) + .ok_or_else(|| anyhow!("loot-race creature maximum gold exceeds the player cap"))?; + if money > maximum_safe_start { + bail!( + "loot-race character {} lacks headroom for the fixture's maximum money roll", + bot.character_guid + ); + } + let account_chars: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|error| anyhow!("Count dedicated loot-race characters: {error}"))? + .unwrap_or(0); + if account_chars != 1 { + bail!( + "loot-race requires one dedicated character per game account; {} has {account_chars}", + bot.account + ); + } + let group_rows: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM (\ + SELECT guid FROM group_member WHERE memberGuid = ? \ + UNION ALL \ + SELECT guid FROM `groups` WHERE leaderGuid = ?\ + ) AS persisted_group_state", + (bot.character_guid, bot.character_guid), + ) + .map_err(|error| anyhow!("Check loot-race group state: {error}"))? + .unwrap_or(0); + if group_rows != 0 { + bail!( + "loot-race character {} is already in a persisted group", + bot.character_guid + ); + } + let persistent_auras: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM character_aura WHERE guid = ?", + (bot.character_guid,), + ) + .map_err(|error| anyhow!("Check loot-race persistent auras: {error}"))? + .unwrap_or(0); + if persistent_auras != 0 { + bail!( + "loot-race character {} has {persistent_auras} persistent aura row(s); money modifiers must be absent", + bot.character_guid + ); + } + let owned_expected: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM item_instance WHERE owner_guid = ? AND itemEntry = ?", + (bot.character_guid, item_entry), + ) + .map_err(|error| anyhow!("Check existing loot-race item: {error}"))? + .unwrap_or(0); + if owned_expected != 0 { + bail!( + "loot-race character {} already owns item entry {}", + bot.character_guid, + item_entry + ); + } + let occupied: Vec = character_db + .exec_map( + "SELECT slot FROM character_inventory WHERE guid = ? AND bag = 0", + (bot.character_guid,), + |slot: u8| slot, + ) + .map_err(|error| anyhow!("Load loot-race backpack slots: {error}"))?; + if !(INVENTORY_SLOT_ITEM_START..INVENTORY_SLOT_ITEM_START + 16) + .any(|slot| !occupied.contains(&slot)) + { + bail!( + "loot-race character {} has no empty backpack slot", + bot.character_guid + ); + } + validate_required_empty_top_level_slot( + bot.character_guid, + &occupied, + required_empty_top_level_slot, + )?; + fixtures.push(CharacterFixture { + bot: bot.clone(), + name, + race, + money, + core: CharacterCoreSnapshot { + level, + xp, + health, + powers, + rest_state, + rest_bonus, + explored_zones, + known_titles, + chosen_title, + }, + position: CharacterPositionSnapshot { + map_id: old_map, + zone_id: zone, + instance_id, + x: old_x, + y: old_y, + z: old_z, + orientation, + }, + }); + } + if faction_for_race(fixtures[0].race) != faction_for_race(fixtures[1].race) { + bail!( + "loot-race characters must be the same faction so C++ party invite rules permit grouping" + ); + } + let fixture_characters: [CharacterFixture; 2] = fixtures + .try_into() + .map_err(|_| anyhow!("internal loot-race character count mismatch"))?; + let guid_a = fixture_characters[0].bot.character_guid; + let guid_b = fixture_characters[1].bot.character_guid; + let guild_members: u64 = character_db + .exec_first( + "SELECT COUNT(*) FROM guild_member WHERE guid IN (?, ?)", + (guid_a, guid_b), + ) + .map_err(|error| anyhow!("Check loot-race guild criteria isolation: {error}"))? + .unwrap_or(0); + if guild_members != 0 { + bail!( + "loot-race disposable characters have {guild_members} guild membership row(s); a kill/item criteria update could mutate guild-wide state" + ); + } + let progress = load_character_progress_snapshot(character_db, guid_a, guid_b)?; + Ok((fixture_characters, progress)) +} + +fn validate_required_empty_top_level_slot( + character_guid: u64, + occupied: &[u8], + required: Option<(u64, u8)>, +) -> Result<()> { + if let Some((required_guid, required_slot)) = required { + if character_guid == required_guid && occupied.contains(&required_slot) { + bail!( + "loot-item capture character {character_guid} requires exact top-level keyring slot {required_slot} empty" + ); + } + } + Ok(()) +} + +fn relocate_loot_fixture_characters( + character_db: &mut mysql::Conn, + fixture_characters: &[CharacterFixture; 2], + map_id: u16, + x: f64, + y: f64, + z: f64, +) -> Result<()> { + let mut tx = character_db + .start_transaction(mysql::TxOpts::default()) + .map_err(|error| anyhow!("Start loot-race relocation transaction: {error}"))?; + // C++ Player::LoadFromDB and Rust `restored_saved_health_like_cpp` both + // clamp this sentinel to the recomputed max health on login. The original + // health remains in CharacterCoreSnapshot and cleanup restores it exactly; + // powers are intentionally untouched. + for (index, fixture) in fixture_characters.iter().enumerate() { + let player_x = x + 1.0 + index as f64; + let player_orientation = 0.0_f64.atan2(x - player_x); + tx.exec_drop( + "UPDATE characters SET map = :new_map, zone = 0, instance_id = 0, \ + position_x = :new_x, position_y = :new_y, position_z = :new_z, \ + orientation = :new_orientation, health = :new_health \ + WHERE guid = :guid AND account = :account AND online = 0 AND at_login = 0 \ + AND map = :old_map AND zone = :old_zone AND instance_id = :old_instance \ + AND position_x = :old_x AND position_y = :old_y AND position_z = :old_z \ + AND orientation = :old_orientation AND health = :old_health", + mysql::params! { + "new_map" => u32::from(map_id), + "new_x" => player_x, + "new_y" => y, + "new_z" => z, + "new_orientation" => player_orientation, + "new_health" => u32::MAX, + "guid" => fixture.bot.character_guid, + "account" => fixture.bot.account_id, + "old_map" => fixture.position.map_id, + "old_zone" => fixture.position.zone_id, + "old_instance" => fixture.position.instance_id, + "old_x" => fixture.position.x, + "old_y" => fixture.position.y, + "old_z" => fixture.position.z, + "old_orientation" => fixture.position.orientation, + "old_health" => fixture.core.health, + }, + ) + .map_err(|error| anyhow!("Relocate loot-race character: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "loot-race character {} drifted after its durable snapshot; relocation applied to {} rows", + fixture.bot.character_guid, + tx.affected_rows() + ); + } + } + tx.commit() + .map_err(|error| anyhow!("Commit loot-race relocation: {error}"))?; + Ok(()) +} + +fn validate_unique_sql_spawn( + spawns: &[u64], + configured_spawn: u64, + entry: u32, + map_id: u16, +) -> Result<()> { + if spawns.len() != 1 { + bail!( + "loot-race target entry {entry} map {map_id} has {} SQL spawns ({spawns:?}); runtime GUID auto-discovery requires exactly one", + spawns.len() + ); + } + if spawns[0] != configured_spawn { + bail!( + "loot-race target entry {entry} map {map_id} uniquely resolves SQL spawn {}, not configured spawn {configured_spawn}", + spawns[0] + ); + } + Ok(()) +} + +fn ensure_no_online_characters(conn: &mut mysql::Conn, stage: &str) -> Result<()> { + let online: u64 = conn + .query_first("SELECT COUNT(*) FROM characters WHERE online <> 0") + .map_err(|error| anyhow!("Check global online-character isolation at {stage}: {error}"))? + .unwrap_or(0); + validate_online_character_count(online, stage) +} + +fn validate_online_character_count(online: u64, stage: &str) -> Result<()> { + if online != 0 { + bail!( + "loot capture requires exclusive world access at {stage}, but {online} character(s) are marked online" + ); + } + Ok(()) +} + +fn load_character_progress_snapshot( + conn: &mut mysql::Conn, + guid_a: u64, + guid_b: u64, +) -> Result { + let guids = (guid_a, guid_b); + Ok(CharacterProgressSnapshot { + achievements: conn + .exec( + "SELECT guid, achievement, `date` FROM character_achievement \ + WHERE guid IN (?, ?) ORDER BY guid, achievement", + guids, + ) + .map_err(|error| anyhow!("Snapshot character achievements: {error}"))?, + achievement_progress: conn + .exec( + "SELECT guid, criteria, counter, `date` FROM character_achievement_progress \ + WHERE guid IN (?, ?) ORDER BY guid, criteria", + guids, + ) + .map_err(|error| anyhow!("Snapshot character achievement criteria: {error}"))?, + quest_status: conn + .exec( + "SELECT guid, quest, status, explored, acceptTime, endTime \ + FROM character_queststatus WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest status: {error}"))?, + quest_daily: conn + .exec( + "SELECT guid, quest, `time` FROM character_queststatus_daily \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character daily quests: {error}"))?, + quest_monthly: conn + .exec( + "SELECT guid, quest FROM character_queststatus_monthly \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character monthly quests: {error}"))?, + quest_objectives: conn + .exec( + "SELECT guid, quest, objective, data FROM character_queststatus_objectives \ + WHERE guid IN (?, ?) ORDER BY guid, quest, objective", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest objectives: {error}"))?, + quest_objective_criteria: conn + .exec( + "SELECT guid, questObjectiveId FROM character_queststatus_objectives_criteria \ + WHERE guid IN (?, ?) ORDER BY guid, questObjectiveId", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest objective criteria: {error}"))?, + quest_objective_criteria_progress: conn + .exec( + "SELECT guid, criteriaId, counter, `date` \ + FROM character_queststatus_objectives_criteria_progress \ + WHERE guid IN (?, ?) ORDER BY guid, criteriaId", + guids, + ) + .map_err(|error| anyhow!("Snapshot character quest criteria progress: {error}"))?, + quest_rewarded: conn + .exec( + "SELECT guid, quest, active FROM character_queststatus_rewarded \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character rewarded quests: {error}"))?, + quest_seasonal: conn + .exec( + "SELECT guid, quest, event, completedTime FROM character_queststatus_seasonal \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character seasonal quests: {error}"))?, + quest_weekly: conn + .exec( + "SELECT guid, quest FROM character_queststatus_weekly \ + WHERE guid IN (?, ?) ORDER BY guid, quest", + guids, + ) + .map_err(|error| anyhow!("Snapshot character weekly quests: {error}"))?, + reputation: conn + .exec( + "SELECT guid, faction, standing, flags FROM character_reputation \ + WHERE guid IN (?, ?) ORDER BY guid, faction", + guids, + ) + .map_err(|error| anyhow!("Snapshot character reputation: {error}"))?, + }) +} + +fn restore_character_progress_snapshot( + tx: &mut mysql::Transaction<'_>, + guid_a: u64, + guid_b: u64, + snapshot: &CharacterProgressSnapshot, +) -> Result<()> { + for table in CHARACTER_PROGRESS_TABLES { + tx.exec_drop( + format!("DELETE FROM `{table}` WHERE guid IN (?, ?)"), + (guid_a, guid_b), + ) + .map_err(|error| anyhow!("Clear loot-fixture progress table {table}: {error}"))?; + } + + for &(guid, achievement, date) in &snapshot.achievements { + tx.exec_drop( + "INSERT INTO character_achievement (guid, achievement, `date`) VALUES (?, ?, ?)", + (guid, achievement, date), + ) + .map_err(|error| anyhow!("Restore character achievements: {error}"))?; + } + for &(guid, criteria, counter, date) in &snapshot.achievement_progress { + tx.exec_drop( + "INSERT INTO character_achievement_progress (guid, criteria, counter, `date`) \ + VALUES (?, ?, ?, ?)", + (guid, criteria, counter, date), + ) + .map_err(|error| anyhow!("Restore character achievement criteria: {error}"))?; + } + for &(guid, quest, status, explored, accept_time, end_time) in &snapshot.quest_status { + tx.exec_drop( + "INSERT INTO character_queststatus \ + (guid, quest, status, explored, acceptTime, endTime) VALUES (?, ?, ?, ?, ?, ?)", + (guid, quest, status, explored, accept_time, end_time), + ) + .map_err(|error| anyhow!("Restore character quest status: {error}"))?; + } + for &(guid, quest, time) in &snapshot.quest_daily { + tx.exec_drop( + "INSERT INTO character_queststatus_daily (guid, quest, `time`) VALUES (?, ?, ?)", + (guid, quest, time), + ) + .map_err(|error| anyhow!("Restore character daily quests: {error}"))?; + } + for &(guid, quest) in &snapshot.quest_monthly { + tx.exec_drop( + "INSERT INTO character_queststatus_monthly (guid, quest) VALUES (?, ?)", + (guid, quest), + ) + .map_err(|error| anyhow!("Restore character monthly quests: {error}"))?; + } + for &(guid, quest, objective, data) in &snapshot.quest_objectives { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives (guid, quest, objective, data) \ + VALUES (?, ?, ?, ?)", + (guid, quest, objective, data), + ) + .map_err(|error| anyhow!("Restore character quest objectives: {error}"))?; + } + for &(guid, objective) in &snapshot.quest_objective_criteria { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives_criteria (guid, questObjectiveId) \ + VALUES (?, ?)", + (guid, objective), + ) + .map_err(|error| anyhow!("Restore character quest objective criteria: {error}"))?; + } + for &(guid, criteria, counter, date) in &snapshot.quest_objective_criteria_progress { + tx.exec_drop( + "INSERT INTO character_queststatus_objectives_criteria_progress \ + (guid, criteriaId, counter, `date`) VALUES (?, ?, ?, ?)", + (guid, criteria, counter, date), + ) + .map_err(|error| anyhow!("Restore character quest criteria progress: {error}"))?; + } + for &(guid, quest, active) in &snapshot.quest_rewarded { + tx.exec_drop( + "INSERT INTO character_queststatus_rewarded (guid, quest, active) VALUES (?, ?, ?)", + (guid, quest, active), + ) + .map_err(|error| anyhow!("Restore character rewarded quests: {error}"))?; + } + for &(guid, quest, event, completed_time) in &snapshot.quest_seasonal { + tx.exec_drop( + "INSERT INTO character_queststatus_seasonal (guid, quest, event, completedTime) \ + VALUES (?, ?, ?, ?)", + (guid, quest, event, completed_time), + ) + .map_err(|error| anyhow!("Restore character seasonal quests: {error}"))?; + } + for &(guid, quest) in &snapshot.quest_weekly { + tx.exec_drop( + "INSERT INTO character_queststatus_weekly (guid, quest) VALUES (?, ?)", + (guid, quest), + ) + .map_err(|error| anyhow!("Restore character weekly quests: {error}"))?; + } + for &(guid, faction, standing, flags) in &snapshot.reputation { + tx.exec_drop( + "INSERT INTO character_reputation (guid, faction, standing, flags) VALUES (?, ?, ?, ?)", + (guid, faction, standing, flags), + ) + .map_err(|error| anyhow!("Restore character reputation: {error}"))?; + } + Ok(()) +} + +fn load_character_core_snapshot( + conn: &mut mysql::Conn, + character_guid: u64, +) -> Result { + let row: mysql::Row = conn + .exec_first( + "SELECT level, xp, health, \ + power1, power2, power3, power4, power5, power6, power7, power8, power9, power10, \ + restState, rest_bonus, exploredZones, knownTitles, chosenTitle \ + FROM characters WHERE guid = ?", + (character_guid,), + ) + .map_err(|error| anyhow!("Reload loot-fixture character core state: {error}"))? + .ok_or_else(|| anyhow!("Loot-fixture character {character_guid} disappeared"))?; + Ok(CharacterCoreSnapshot { + level: required_row_value(&row, "level")?, + xp: required_row_value(&row, "xp")?, + health: required_row_value(&row, "health")?, + powers: [ + required_row_value(&row, "power1")?, + required_row_value(&row, "power2")?, + required_row_value(&row, "power3")?, + required_row_value(&row, "power4")?, + required_row_value(&row, "power5")?, + required_row_value(&row, "power6")?, + required_row_value(&row, "power7")?, + required_row_value(&row, "power8")?, + required_row_value(&row, "power9")?, + required_row_value(&row, "power10")?, + ], + rest_state: required_row_value(&row, "restState")?, + rest_bonus: required_row_value(&row, "rest_bonus")?, + explored_zones: required_row_value(&row, "exploredZones")?, + known_titles: required_row_value(&row, "knownTitles")?, + chosen_title: required_row_value(&row, "chosenTitle")?, + }) +} + +async fn expected_persisted_item_grant( + fixture: &LootRaceFixture, + sync: &LootRaceSync, +) -> Result { + let window = sync.windows.lock().await[0] + .clone() + .ok_or_else(|| anyhow!("loot-race passed without a retained item window"))?; + let (owner_low, owner_high) = sync + .runtime_guid + .lock() + .map_err(|_| anyhow!("loot-race runtime GUID state was poisoned"))? + .as_ref() + .copied() + .ok_or_else(|| anyhow!("loot-race passed without a retained world-object ObjectGuid"))?; + let expected_removal = LootRemovedEvidence { + owner_low, + owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }; + let evidence = sync.evidence.lock().await; + validate_atomic_item_wire_outcome_like_cpp( + &evidence, + [ + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ], + fixture.target.item_entry, + window.quantity, + expected_removal, + realm_id(), + ) +} + +async fn expected_persisted_money_grant( + fixture: &LootRaceFixture, + sync: &LootRaceSync, + source_coins: u64, +) -> Result { + if source_coins != u64::from(RACE_GAMEOBJECT_MONEY) { + bail!( + "shared chest source money was {source_coins}, expected exact guarded pool {RACE_GAMEOBJECT_MONEY}" + ); + } + let window = sync.windows.lock().await[0] + .clone() + .ok_or_else(|| anyhow!("loot-race passed without a retained money window"))?; + let evidence = sync.evidence.lock().await; + let winner = validate_serialized_gameobject_money_wire_outcome_like_cpp( + &evidence, + (window.loot_low, window.loot_high), + source_coins, + )?; + Ok(ExpectedPersistedMoneyGrant { + owner_guid: fixture.characters[winner].bot.character_guid, + amount: source_coins, + }) +} + +fn validate_persisted_item_grant_like_cpp( + expected: ExpectedPersistedItemGrant, + persisted: PersistedItemGrantRow, +) -> Result<()> { + let quantity = u32::try_from(expected.push.quantity) + .map_err(|_| anyhow!("wire item quantity was negative"))?; + let quantity_in_inventory = u32::try_from(expected.push.quantity_in_inventory) + .map_err(|_| anyhow!("wire inventory quantity was negative"))?; + let slot_in_bag = u8::try_from(expected.push.slot_in_bag) + .map_err(|_| anyhow!("wire SlotInBag did not fit a persisted inventory slot"))?; + + if persisted.item_guid != expected.push.item_guid_low + || persisted.owner_guid != expected.owner_guid + || persisted.owner_guid != expected.push.player_low + || persisted.item_entry != expected.push.item_entry + || persisted.count != quantity + || persisted.count != quantity_in_inventory + { + bail!( + "wire-keyed persisted item {:?} did not match grant owner/item/count {:?}", + persisted, + expected + ); + } + // For this clean fixture CanStoreNewItem chooses a free top-level + // backpack slot. C++ serializes that as wire Slot=255 but persists bag=0; + // those values are intentionally not compared numerically. + if expected.push.slot != INVENTORY_SLOT_BAG_0 + || persisted.inventory_owner != Some(expected.owner_guid) + || persisted.bag_guid != Some(0) + || persisted.slot != Some(slot_in_bag) + || persisted.bag_slot.is_some() + { + bail!( + "wire slot {}/{} did not bind to the expected top-level character_inventory row {:?}", + expected.push.slot, + expected.push.slot_in_bag, + persisted + ); + } + Ok(()) +} + +fn verify_persisted_grants( + fixture: &LootRaceFixture, + expected_money_grant: ExpectedPersistedMoneyGrant, + expected_item_grant: ExpectedPersistedItemGrant, +) -> Result<( + u64, + u64, + ExpectedPersistedItemGrant, + ExpectedPersistedMoneyGrant, +)> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + ensure_no_online_characters(&mut conn, "loot-race persistence verification")?; + let guids = ( + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ); + let (item_total, item_rows, inventory_rows, persisted_item_owner): (u64, u64, u64, u64) = conn + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0), COUNT(DISTINCT ii.guid), \ + COUNT(DISTINCT ci.item), COALESCE(MIN(ii.owner_guid), 0) \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid AND ci.guid = ii.owner_guid \ + WHERE ii.itemEntry = ? AND ii.owner_guid IN (?, ?)", + (fixture.target.item_entry, guids.0, guids.1), + ) + .map_err(|error| anyhow!("Verify loot-race item persistence: {error}"))? + .ok_or_else(|| anyhow!("loot-race item aggregate query returned no row"))?; + let current_money: Vec<(u64, u64)> = conn + .exec( + "SELECT guid, money FROM characters WHERE guid IN (?, ?) ORDER BY guid", + guids, + ) + .map_err(|error| anyhow!("Verify loot-race money: {error}"))?; + if current_money.len() != 2 { + bail!("loot-race character rows disappeared during verification"); + } + if item_total != 1 || item_rows != 1 || inventory_rows != 1 { + bail!( + "atomic ITEM race persisted quantity/instances/inventory rows {item_total}/{item_rows}/{inventory_rows}; expected 1/1/1" + ); + } + if persisted_item_owner != expected_item_grant.owner_guid { + bail!( + "atomic ITEM race persisted owner {persisted_item_owner}, but wire winner was character {}", + expected_item_grant.owner_guid + ); + } + let persisted_tuple: Option<( + u64, + u64, + u32, + u32, + Option, + Option, + Option, + Option, + )> = conn + .exec_first( + "SELECT ii.guid, ii.owner_guid, ii.itemEntry, ii.count, \ + ci.guid, ci.bag, ci.slot, bag_ci.slot \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid \ + LEFT JOIN character_inventory bag_ci \ + ON ci.bag <> 0 AND bag_ci.item = ci.bag AND bag_ci.guid = ci.guid \ + WHERE ii.guid = ?", + (expected_item_grant.push.item_guid_low,), + ) + .map_err(|error| anyhow!("Verify wire-keyed loot-race item persistence: {error}"))?; + let persisted = persisted_tuple + .map( + |( + item_guid, + owner_guid, + item_entry, + count, + inventory_owner, + bag_guid, + slot, + bag_slot, + )| PersistedItemGrantRow { + item_guid, + owner_guid, + item_entry, + count, + inventory_owner, + bag_guid, + slot, + bag_slot, + }, + ) + .ok_or_else(|| { + anyhow!( + "wire ItemGUID counter {} did not identify a persisted item_instance row", + expected_item_grant.push.item_guid_low + ) + })?; + validate_persisted_item_grant_like_cpp(expected_item_grant, persisted)?; + let mut money_delta = 0u64; + for character in &fixture.characters { + let current = current_money + .iter() + .find_map(|(guid, money)| (*guid == character.bot.character_guid).then_some(*money)) + .ok_or_else(|| { + anyhow!( + "loot-race money verification omitted character {}", + character.bot.character_guid + ) + })?; + let character_delta = if character.bot.character_guid == expected_money_grant.owner_guid { + expected_money_grant.amount + } else { + 0 + }; + let expected = character + .money + .checked_add(character_delta) + .ok_or_else(|| anyhow!("loot-race expected money overflow"))?; + if current != expected { + bail!( + "atomic GAMEOBJECT MONEY race persisted character {} money {}; expected {} from wire winner {} and whole-pool amount {}", + character.bot.character_guid, + current, + expected, + expected_money_grant.owner_guid, + expected_money_grant.amount + ); + } + money_delta = money_delta + .checked_add(current - character.money) + .ok_or_else(|| anyhow!("loot-race persisted money delta overflow"))?; + } + if money_delta != expected_money_grant.amount { + bail!( + "atomic GAMEOBJECT MONEY race persisted total delta {money_delta}; expected one exact C++ whole-pool grant {}", + expected_money_grant.amount + ); + } + Ok(( + item_total, + money_delta, + expected_item_grant, + expected_money_grant, + )) +} + +fn verify_single_item_capture_persistence(fixture: &LootRaceFixture) -> Result { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + ensure_no_online_characters(&mut conn, "loot-item capture persistence verification")?; + + let owner = fixture.characters[0].bot.character_guid; + let offline_peer = fixture.characters[1].bot.character_guid; + let (item_total, item_rows, inventory_rows, persisted_owner): (u64, u64, u64, u64) = conn + .exec_first( + "SELECT COALESCE(SUM(ii.count), 0), COUNT(DISTINCT ii.guid), \ + COUNT(DISTINCT ci.item), COALESCE(MIN(ii.owner_guid), 0) \ + FROM item_instance ii \ + LEFT JOIN character_inventory ci \ + ON ci.item = ii.guid AND ci.guid = ii.owner_guid \ + WHERE ii.itemEntry = ? AND ii.owner_guid IN (?, ?)", + (fixture.target.item_entry, owner, offline_peer), + ) + .map_err(|error| anyhow!("Verify loot-item capture persistence: {error}"))? + .ok_or_else(|| anyhow!("loot-item capture aggregate query returned no row"))?; + if (item_total, item_rows, inventory_rows, persisted_owner) != (1, 1, 1, owner) { + bail!( + "single-session item capture persisted quantity/instances/inventory/owner {item_total}/{item_rows}/{inventory_rows}/{persisted_owner}; expected 1/1/1/{owner}" + ); + } + let persisted_slots: Vec<(u64, u8)> = conn + .exec( + "SELECT ci.bag, ci.slot FROM character_inventory ci \ + JOIN item_instance ii ON ii.guid = ci.item \ + WHERE ii.itemEntry = ? AND ii.owner_guid = ? AND ci.guid = ?", + (fixture.target.item_entry, owner, owner), + ) + .map_err(|error| anyhow!("Verify loot-item capture keyring slot: {error}"))?; + if persisted_slots.as_slice() != [(0, LOOT_ITEM_CAPTURE_KEYRING_SLOT)] { + bail!( + "single-session item capture persisted item in slots {persisted_slots:?}; expected exact top-level keyring slot 0/{LOOT_ITEM_CAPTURE_KEYRING_SLOT}" + ); + } + + let money_rows: Vec<(u64, u64)> = conn + .exec( + "SELECT guid, money FROM characters WHERE guid IN (?, ?) ORDER BY guid", + (owner, offline_peer), + ) + .map_err(|error| anyhow!("Verify loot-item capture money isolation: {error}"))?; + if money_rows.len() != 2 { + bail!("loot-item capture character rows disappeared during verification"); + } + for character in &fixture.characters { + let current = money_rows + .iter() + .find_map(|(guid, money)| (*guid == character.bot.character_guid).then_some(*money)) + .ok_or_else(|| { + anyhow!( + "loot-item capture money verification omitted character {}", + character.bot.character_guid + ) + })?; + if current != character.money { + bail!( + "item-only capture changed character {} money from {} to {} without CMSG_LOOT_MONEY", + character.bot.character_guid, + character.money, + current + ); + } + } + + Ok(item_total) +} + +fn cleanup_fixture(fixture: &LootRaceFixture) -> Result<()> { + let url = characters_db_url()?; + let opts = loot_db_opts(&url, "characters")?; + let mut conn = mysql::Conn::new(opts) + .map_err(|error| anyhow!("Connect to characters DB failed: {error}"))?; + wait_both_offline(&mut conn, fixture)?; + let guid_a = fixture.characters[0].bot.character_guid; + let guid_b = fixture.characters[1].bot.character_guid; + let gained_items: Vec = conn + .exec_map( + "SELECT guid FROM item_instance WHERE itemEntry = ? AND owner_guid IN (?, ?)", + (fixture.target.item_entry, guid_a, guid_b), + |guid: u64| guid, + ) + .map_err(|error| anyhow!("Load gained loot-race items for cleanup: {error}"))?; + let group_ids: Vec = conn + .exec_map( + "SELECT DISTINCT guid FROM group_member WHERE memberGuid IN (?, ?) \ + UNION SELECT guid FROM `groups` WHERE leaderGuid IN (?, ?)", + (guid_a, guid_b, guid_a, guid_b), + |guid: u32| guid, + ) + .map_err(|error| anyhow!("Load loot-race groups for cleanup: {error}"))?; + for group_id in &group_ids { + let unrelated_members: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM group_member \ + WHERE guid = ? AND memberGuid NOT IN (?, ?)", + (*group_id, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Check loot-race group cleanup scope: {error}"))? + .unwrap_or(0); + if unrelated_members != 0 { + bail!( + "refusing to delete loot-race group {group_id}: it gained {unrelated_members} unrelated members" + ); + } + } + if let Some(expected_state) = fixture.gameobject_state { + let world_url = world_db_url()?; + let world_opts = loot_db_opts(&world_url, "world")?; + let mut world = mysql::Conn::new(world_opts).map_err(|error| { + anyhow!("Connect to world DB during GameObject cleanup failed: {error}") + })?; + let observed: Option = world + .exec_first( + "SELECT state FROM gameobject WHERE guid = ? AND id = ?", + (fixture.target.spawn_guid, fixture.target.entry), + ) + .map_err(|error| anyhow!("Verify GameObject fixture SQL state: {error}"))?; + if observed != Some(expected_state) { + bail!( + "GameObject fixture SQL state drifted: expected {expected_state}, got {observed:?}; refusing to hide drift or restore normal PM2" + ); + } + } + let observed_respawn: Vec<(i64, u16, u32)> = conn + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = ? AND spawnId = ? ORDER BY mapId, instanceId, respawnTime", + (fixture.respawn_type, fixture.target.spawn_guid), + ) + .map_err(|error| anyhow!("Inspect loot-fixture respawn rows before cleanup: {error}"))?; + let baseline_respawn = fixture + .respawn + .iter() + .map(|row| (row.respawn_time, row.map_id, row.instance_id)) + .collect::>(); + let generated_respawn = validate_respawn_cleanup_scope( + fixture.target.map_id, + &baseline_respawn, + &observed_respawn, + )?; + let mut tx = conn + .start_transaction(mysql::TxOpts::default()) + .map_err(|error| anyhow!("Start loot-race cleanup transaction: {error}"))?; + for item_guid in gained_items { + tx.exec_drop( + "DELETE FROM character_inventory WHERE item = ?", + (item_guid,), + ) + .map_err(|error| anyhow!("Delete loot-race inventory row: {error}"))?; + tx.exec_drop( + "DELETE FROM item_instance_gems WHERE itemGuid = ?", + (item_guid,), + ) + .map_err(|error| anyhow!("Delete loot-race gem row: {error}"))?; + tx.exec_drop("DELETE FROM item_instance WHERE guid = ?", (item_guid,)) + .map_err(|error| anyhow!("Delete loot-race item row: {error}"))?; + } + for group_id in group_ids { + tx.exec_drop("DELETE FROM group_member WHERE guid = ?", (group_id,)) + .map_err(|error| anyhow!("Delete loot-race group members: {error}"))?; + tx.exec_drop("DELETE FROM `groups` WHERE guid = ?", (group_id,)) + .map_err(|error| anyhow!("Delete loot-race group row: {error}"))?; + } + restore_character_progress_snapshot(&mut tx, guid_a, guid_b, &fixture.progress)?; + for character in &fixture.characters { + tx.exec_drop( + "UPDATE characters SET \ + money = :money, level = :level, xp = :xp, health = :health, \ + power1 = :power1, power2 = :power2, power3 = :power3, \ + power4 = :power4, power5 = :power5, power6 = :power6, \ + power7 = :power7, power8 = :power8, power9 = :power9, power10 = :power10, \ + restState = :rest_state, rest_bonus = :rest_bonus, \ + exploredZones = :explored_zones, knownTitles = :known_titles, \ + chosenTitle = :chosen_title, \ + map = :map, zone = :zone, instance_id = :instance_id, \ + position_x = :position_x, position_y = :position_y, \ + position_z = :position_z, orientation = :orientation \ + WHERE guid = :guid AND online = 0", + mysql::params! { + "money" => character.money, + "level" => character.core.level, + "xp" => character.core.xp, + "health" => character.core.health, + "power1" => character.core.powers[0], + "power2" => character.core.powers[1], + "power3" => character.core.powers[2], + "power4" => character.core.powers[3], + "power5" => character.core.powers[4], + "power6" => character.core.powers[5], + "power7" => character.core.powers[6], + "power8" => character.core.powers[7], + "power9" => character.core.powers[8], + "power10" => character.core.powers[9], + "rest_state" => character.core.rest_state, + "rest_bonus" => character.core.rest_bonus, + "explored_zones" => character.core.explored_zones.clone(), + "known_titles" => character.core.known_titles.clone(), + "chosen_title" => character.core.chosen_title, + "map" => character.position.map_id, + "zone" => character.position.zone_id, + "instance_id" => character.position.instance_id, + "position_x" => character.position.x, + "position_y" => character.position.y, + "position_z" => character.position.z, + "orientation" => character.position.orientation, + "guid" => character.bot.character_guid, + }, + ) + .map_err(|error| anyhow!("Restore loot-race character snapshot: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "loot-race character {} became online or disappeared during cleanup", + character.bot.character_guid + ); + } + } + if fixture.target.kind == LootRaceTargetKind::Creature { + if let Some((respawn_time, map_id, instance_id)) = generated_respawn { + tx.exec_drop( + "DELETE FROM respawn \ + WHERE type = ? AND spawnId = ? AND respawnTime = ? AND mapId = ? AND instanceId = ?", + ( + fixture.respawn_type, + fixture.target.spawn_guid, + respawn_time, + map_id, + instance_id, + ), + ) + .map_err(|error| anyhow!("Delete exact generated loot respawn row: {error}"))?; + if tx.affected_rows() != 1 { + bail!( + "exact generated respawn row drifted before cleanup; deleted {} rows", + tx.affected_rows() + ); + } + } + } + tx.commit() + .map_err(|error| anyhow!("Commit loot-race cleanup: {error}"))?; + + ensure_no_online_characters(&mut conn, "loot fixture cleanup")?; + let remaining_expected_items: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM item_instance WHERE itemEntry = ? AND owner_guid IN (?, ?)", + (fixture.target.item_entry, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Verify loot-fixture item cleanup: {error}"))? + .unwrap_or(0); + if remaining_expected_items != 0 { + bail!( + "loot fixture cleanup left {remaining_expected_items} expected-item instance row(s) behind" + ); + } + let remaining_groups: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM (\ + SELECT guid FROM group_member WHERE memberGuid IN (?, ?) \ + UNION ALL \ + SELECT guid FROM `groups` WHERE leaderGuid IN (?, ?)\ + ) AS persisted_group_state", + (guid_a, guid_b, guid_a, guid_b), + ) + .map_err(|error| anyhow!("Verify loot-fixture group cleanup: {error}"))? + .unwrap_or(0); + if remaining_groups != 0 { + bail!("loot fixture cleanup left {remaining_groups} persisted group row(s) behind"); + } + if fixture.target.kind == LootRaceTargetKind::Creature { + let occupied_capture_slot: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM character_inventory WHERE guid = ? AND bag = 0 AND slot = ?", + (guid_a, LOOT_ITEM_CAPTURE_KEYRING_SLOT), + ) + .map_err(|error| anyhow!("Verify loot-item keyring-slot cleanup: {error}"))? + .unwrap_or(0); + if occupied_capture_slot != 0 { + bail!( + "loot fixture cleanup did not restore exact empty keyring slot 0/{LOOT_ITEM_CAPTURE_KEYRING_SLOT}" + ); + } + } + let restored_respawn: Vec<(i64, u16, u32)> = conn + .exec( + "SELECT respawnTime, mapId, instanceId FROM respawn \ + WHERE type = ? AND spawnId = ? ORDER BY mapId, instanceId", + (fixture.respawn_type, fixture.target.spawn_guid), + ) + .map_err(|error| anyhow!("Verify loot-fixture respawn cleanup: {error}"))?; + let expected_respawn = if fixture.target.kind == LootRaceTargetKind::GameObject { + observed_respawn + } else { + baseline_respawn + }; + if restored_respawn != expected_respawn { + bail!("loot fixture target respawn rows did not restore exactly"); + } + let restored_progress = load_character_progress_snapshot(&mut conn, guid_a, guid_b)?; + if restored_progress != fixture.progress { + bail!("loot fixture quest/achievement/criteria/reputation rows did not restore exactly"); + } + for character in &fixture.characters { + let restored_core = load_character_core_snapshot(&mut conn, character.bot.character_guid)?; + if restored_core != character.core { + bail!( + "loot fixture character {} XP/level/health/rest/exploration/title state did not restore exactly", + character.bot.character_guid + ); + } + let restored_position_and_money: (u64, u32, u32, u32, f64, f64, f64, f32) = conn + .exec_first( + "SELECT money, map, zone, instance_id, position_x, position_y, position_z, orientation \ + FROM characters WHERE guid = ?", + (character.bot.character_guid,), + ) + .map_err(|error| anyhow!("Reload loot-fixture money/position state: {error}"))? + .ok_or_else(|| { + anyhow!( + "Loot-fixture character {} disappeared during cleanup verification", + character.bot.character_guid + ) + })?; + let expected_position_and_money = ( + character.money, + character.position.map_id, + character.position.zone_id, + character.position.instance_id, + character.position.x, + character.position.y, + character.position.z, + character.position.orientation, + ); + if restored_position_and_money != expected_position_and_money { + bail!( + "loot fixture character {} money/position state did not restore exactly", + character.bot.character_guid + ); + } + } + + fixture.journal.complete()?; + Ok(()) +} + +fn validate_respawn_cleanup_scope( + target_map: u16, + baseline: &[(i64, u16, u32)], + observed: &[(i64, u16, u32)], +) -> Result> { + if observed == baseline { + return Ok(None); + } + if !baseline.is_empty() || observed.len() != 1 { + bail!( + "loot fixture respawn drift: baseline={baseline:?}, observed={observed:?}; refusing broad cleanup" + ); + } + let row = observed[0]; + if row.0 <= 0 || row.1 != target_map || row.2 != 0 { + bail!( + "loot fixture generated respawn has wrong time/map/instance: {row:?}, expected positive/{target_map}/0" + ); + } + Ok(Some(row)) +} + +fn wait_both_offline(conn: &mut mysql::Conn, fixture: &LootRaceFixture) -> Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(LOOT_FIXTURE_OFFLINE_WAIT_SECS); + loop { + let online: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE guid IN (?, ?) AND online <> 0", + ( + fixture.characters[0].bot.character_guid, + fixture.characters[1].bot.character_guid, + ), + ) + .map_err(|error| anyhow!("Check loot-race offline state: {error}"))? + .unwrap_or(2); + if online == 0 { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + bail!( + "loot-race characters remained online; refusing fixture restoration before disconnect saves" + ); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn faction_for_race(race: u8) -> u8 { + match race { + 1 | 3 | 4 | 7 | 11 | 22 | 25 => 0, + _ => 1, + } +} + +fn current_millis_u32() -> u32 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + const ITEM_TEST_REALM: u32 = 7; + const ITEM_TEST_ENTRY: u32 = 18_610; + const ITEM_TEST_CHARACTERS: [u64; 2] = [15, 16]; + + #[test] + fn loot_fixture_health_guard_produces_one_health_like_cpp() { + assert_eq!( + generated_fixture_health_like_cpp(115, GUARDED_FIXTURE_HEALTH_MODIFIER), + 1 + ); + assert!(validate_guarded_fixture_health(GUARDED_FIXTURE_HEALTH_MODIFIER, 1).is_ok()); + } + + #[tokio::test] + async fn cancellation_token_cannot_lose_a_preexisting_cancel() { + let sync = LootRaceSync::new(); + sync.cancel("deterministic peer failure"); + tokio::time::timeout(Duration::from_millis(50), sync.cancelled()) + .await + .expect("CancellationToken retains cancellation before waiter registration"); + assert!(sync + .cancellation_error() + .expect_err("cancelled sync must fail") + .to_string() + .contains("deterministic peer failure")); + } + + #[test] + fn respawn_cleanup_scope_is_exact_and_fail_closed() { + assert_eq!(validate_respawn_cleanup_scope(0, &[], &[]).unwrap(), None); + assert_eq!( + validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 0, 0)]).unwrap(), + Some((1_700_000_000, 0, 0)) + ); + assert!(validate_respawn_cleanup_scope( + 0, + &[], + &[(1_700_000_000, 0, 0), (1_700_000_001, 0, 1)] + ) + .is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 1, 0)]).is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(1_700_000_000, 0, 1)]).is_err()); + assert!(validate_respawn_cleanup_scope(0, &[], &[(0, 0, 0)]).is_err()); + assert!(validate_respawn_cleanup_scope( + 0, + &[(1_600_000_000, 0, 0)], + &[(1_700_000_000, 0, 0)] + ) + .is_err()); + } + + #[test] + fn cleanup_marker_publish_is_0600_and_both_present_recovery_is_idempotent() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "wow-test-bot-journal-{}-{unique}", + std::process::id() + )); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.join("fixture.journal"); + let journal = FixtureJournal { path: path.clone() }; + let payload = b"durable fixture snapshot\n"; + write_test_journal(&path, payload); + + journal.complete().unwrap(); + let marker = cleanup_marker_path(&path); + assert!(!path.exists()); + assert_eq!( + fs::metadata(&marker).unwrap().permissions().mode() & 0o777, + 0o600 + ); + validate_cleanup_marker(&marker, None).unwrap(); + + // Model SIGKILL after atomic marker rename but before journal unlink. + // Recovery re-verifies DB state, then accepts only the same digest. + write_test_journal(&path, payload); + journal.complete().unwrap(); + assert!(!path.exists()); + + // A different pending snapshot must not be hidden by a stale marker. + write_test_journal(&path, b"different snapshot\n"); + assert!(journal.complete().is_err()); + assert!(path.exists()); + + fs::remove_file(&path).unwrap(); + fs::remove_file(&marker).unwrap(); + fs::remove_dir(&directory).unwrap(); + } + + fn write_test_journal(path: &Path, payload: &[u8]) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(payload).unwrap(); + file.sync_all().unwrap(); + } + + #[test] + fn loot_fixture_health_guard_rejects_original_or_stale_runtime_data() { + let original_health = generated_fixture_health_like_cpp(115, 1.5); + assert_eq!(original_health, 173); + assert!(validate_guarded_fixture_health(1.5, original_health).is_err()); + assert!( + validate_guarded_fixture_health(GUARDED_FIXTURE_HEALTH_MODIFIER, 2).is_err(), + "a non-default classification multiplier or stale runtime must fail closed" + ); + } + + #[test] + fn single_item_fixture_requires_exact_keyring_destination_empty() { + let required = Some((15, LOOT_ITEM_CAPTURE_KEYRING_SLOT)); + validate_required_empty_top_level_slot(15, &[35, 50], required).unwrap(); + assert!(validate_required_empty_top_level_slot( + 15, + &[LOOT_ITEM_CAPTURE_KEYRING_SLOT], + required + ) + .is_err()); + validate_required_empty_top_level_slot(16, &[LOOT_ITEM_CAPTURE_KEYRING_SLOT], required) + .unwrap(); + } + + fn valid_atomic_item_push() -> ItemPush { + let (player_low, player_high) = + create_player_guid_raw(ITEM_TEST_CHARACTERS[0], ITEM_TEST_REALM); + ItemPush { + player_low, + player_high, + slot: INVENTORY_SLOT_BAG_0, + slot_in_bag: i32::from(INVENTORY_SLOT_ITEM_START), + quest_log_item_id: 0, + quantity: 1, + quantity_in_inventory: 1, + dungeon_encounter_id: 0, + item_guid_low: 0x1234, + item_guid_high: (HIGH_GUID_ITEM << 58) | (u64::from(ITEM_TEST_REALM) << 42), + pushed: false, + created: false, + display_text: 1, + is_bonus_roll: false, + is_encounter_loot: false, + item_entry: ITEM_TEST_ENTRY, + } + } + + fn valid_atomic_item_outcome( + group_broadcast: bool, + ) -> ([WireEvidence; 2], LootRemovedEvidence) { + let push = valid_atomic_item_push(); + let removal = LootRemovedEvidence { + owner_low: 268, + owner_high: 0x2000_0442_4015_44C0, + loot_low: 41, + loot_high: (HIGH_GUID_LOOT_OBJECT << 58) | (u64::from(ITEM_TEST_REALM) << 42), + loot_list_id: 3, + }; + let loot_gone = InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }; + ( + [ + WireEvidence { + item_pushes: vec![push], + loot_removed: vec![removal], + ..Default::default() + }, + WireEvidence { + item_pushes: group_broadcast.then_some(push).into_iter().collect(), + loot_removed: vec![removal], + inventory_failures: vec![loot_gone], + ..Default::default() + }, + ], + removal, + ) + } + + fn runtime_discovery_options(override_counter: u64) -> LootRaceOptions { + LootRaceOptions { + phase: LootRacePhase::CaptureItem, + participant: 0, + character_guid: 15, + peer_name: "Peer".to_string(), + peer_character_guid: 16, + killer_character_guid: 15, + target: LootRaceTarget { + kind: LootRaceTargetKind::Creature, + entry: 21_779, + spawn_guid: 1_117, + runtime_counter_override: override_counter, + map_id: 530, + x: -2_695.57, + y: 2_633.82, + z: 74.6837, + item_entry: 30_712, + }, + timeout_secs: 30, + sync: Arc::new(LootRaceSync::new()), + } + } + + fn gameobject_discovery_options(override_counter: u64) -> LootRaceOptions { + LootRaceOptions { + phase: LootRacePhase::Race, + participant: 0, + character_guid: 15, + peer_name: "Peer".to_string(), + peer_character_guid: 16, + killer_character_guid: 15, + target: LootRaceTarget { + kind: LootRaceTargetKind::GameObject, + entry: DEFAULT_CREATURE_ENTRY, + spawn_guid: DEFAULT_CREATURE_SPAWN_GUID, + runtime_counter_override: override_counter, + map_id: RACE_GAMEOBJECT_MAP_ID, + x: RACE_GAMEOBJECT_X, + y: RACE_GAMEOBJECT_Y, + z: RACE_GAMEOBJECT_Z, + item_entry: DEFAULT_ITEM_ENTRY, + }, + timeout_secs: 30, + sync: Arc::new(LootRaceSync::new()), + } + } + + fn gameobject_runtime_high(map_id: u16, entry: u32) -> u64 { + (HIGH_GUID_GAMEOBJECT << 58) | (u64::from(map_id) << 29) | (u64::from(entry) << 6) + } + + fn update_object_with_guid(low: u64, high: u64) -> Vec { + let mut payload = vec![1]; + payload.extend_from_slice(&build_packed_guid(low, high)); + payload + } + + fn append_party_player_info_for_test(payload: &mut Vec, guid: (u64, u64), name: &str) { + let name_len = u16::try_from(name.len()).unwrap(); + let voice_len_plus_one = 1u16; + // C++ PartyPackets.cpp writes 15 MSB-first bits and the following + // packed-GUID write pads the last low bit to the next byte. + let info_bits = ((name_len << 9) | (voice_len_plus_one << 3) | 0x04) << 1; + payload.extend_from_slice(&info_bits.to_be_bytes()); + payload.extend_from_slice(&build_packed_guid(guid.0, guid.1)); + payload.extend_from_slice(&[0, 0, 0, 1, 0]); + payload.extend_from_slice(name.as_bytes()); + } + + fn party_update_for_test( + party_flags: u16, + party_index: u8, + party_type: u8, + my_index: i32, + party_guid: (u64, u64), + leader_guid: (u64, u64), + roster: [(u64, u64); 2], + loot_method: u8, + ) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&party_flags.to_le_bytes()); + payload.push(party_index); + payload.push(party_type); + payload.extend_from_slice(&my_index.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(party_guid.0, party_guid.1)); + payload.extend_from_slice(&1u32.to_le_bytes()); + payload.extend_from_slice(&build_packed_guid(leader_guid.0, leader_guid.1)); + payload.push(0); + payload.extend_from_slice(&2u32.to_le_bytes()); + payload.push(0x60); // no LFG, with loot and difficulty settings + append_party_player_info_for_test(&mut payload, roster[0], "Leader"); + append_party_player_info_for_test(&mut payload, roster[1], "Peer"); + payload.push(loot_method); // PartyLootSettings.Method + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.push(2); // PartyLootSettings.Threshold + payload.extend_from_slice(&[0; 12]); // PartyDifficultySettings + payload + } + + #[test] + fn sql_spawn_uniqueness_is_required_for_entry_map_runtime_auto_discovery() { + validate_unique_sql_spawn(&[1_117], 1_117, 21_779, 530).unwrap(); + + let missing = validate_unique_sql_spawn(&[], 1_117, 21_779, 530) + .expect_err("a missing SQL spawn must fail closed"); + assert!(missing.to_string().contains("exactly one")); + + let ambiguous = validate_unique_sql_spawn(&[1_117, 2_268], 1_117, 21_779, 530) + .expect_err("same-entry map ambiguity must fail closed"); + assert!(ambiguous.to_string().contains("2 SQL spawns")); + + let mismatch = validate_unique_sql_spawn(&[1_117], 2_268, 21_779, 530) + .expect_err("the unique row must equal the configured spawn"); + assert!(mismatch.to_string().contains("not configured spawn")); + } + + #[test] + fn runtime_auto_discovery_preserves_cpp_full_guid_including_realm() { + const CPP_DOCTOR_COUNTER: u64 = 268; + const CPP_DOCTOR_HIGH: u64 = 0x2000_0442_4015_44C0; + let options = runtime_discovery_options(0); + let payload = update_object_with_guid(CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH); + + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &payload).unwrap(), + Some(CPP_DOCTOR_COUNTER) + ); + assert_eq!( + options.resolved_runtime_guid().unwrap(), + (CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH) + ); + assert_eq!( + options.resolved_packed_guid().unwrap(), + build_packed_guid(CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH) + ); + assert_eq!((CPP_DOCTOR_HIGH >> 42) & GUID_REALM_MASK, 1); + + let reconstructed_without_realm = create_creature_guid_raw(530, 21_779, 268); + assert_ne!( + reconstructed_without_realm, + (CPP_DOCTOR_COUNTER, CPP_DOCTOR_HIGH), + "the SQL spawn/counter helper must not replace the full discovered C++ GUID" + ); + } + + #[test] + fn runtime_override_and_two_bot_convergence_fail_closed() { + const CPP_DOCTOR_HIGH: u64 = 0x2000_0442_4015_44C0; + let strict = runtime_discovery_options(1_117); + let mismatch = target_seen_in_update( + &strict, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .expect_err("a stale SQL-spawn-as-counter override must be rejected"); + assert!(mismatch + .to_string() + .contains("did not match discovered counter 268")); + + let first = runtime_discovery_options(0); + let mut second = first.clone(); + second.participant = 1; + assert_eq!( + target_seen_in_update( + &first, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .unwrap(), + Some(268) + ); + assert_eq!( + target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(268, CPP_DOCTOR_HIGH), + ) + .unwrap(), + Some(268) + ); + + let different = target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(269, CPP_DOCTOR_HIGH), + ) + .expect_err("two bots must not bind different runtime counters"); + assert!(different.to_string().contains("different live ObjectGuids")); + } + + #[test] + fn gameobject_discovery_keeps_sql_spawn_and_runtime_counter_distinct_like_cpp() { + const LIVE_COUNTER: u64 = 40; + let options = gameobject_discovery_options(0); + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let exact = update_object_with_guid(LIVE_COUNTER, high); + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &exact).unwrap(), + Some(LIVE_COUNTER) + ); + assert_eq!( + options.resolved_runtime_guid().unwrap(), + (LIVE_COUNTER, high) + ); + assert_ne!(LIVE_COUNTER, DEFAULT_CREATURE_SPAWN_GUID); + } + + #[test] + fn gameobject_discovery_requires_exact_high_type_entry_and_map() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let creature_high = (HIGH_GUID_CREATURE << 58) + | (u64::from(RACE_GAMEOBJECT_MAP_ID) << 29) + | (u64::from(DEFAULT_CREATURE_ENTRY) << 6); + let wrong_entry_high = + gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY + 1); + let wrong_map_high = + gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID + 1, DEFAULT_CREATURE_ENTRY); + + for wrong_high in [creature_high, wrong_entry_high, wrong_map_high] { + let options = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update( + &options, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, wrong_high), + ) + .unwrap(), + None + ); + } + + let wrong_opcode = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update( + &wrong_opcode, + SMSG_LOOT_RESPONSE, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + None + ); + } + + #[test] + fn gameobject_runtime_override_checks_the_live_counter_not_the_sql_spawn() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let exact = gameobject_discovery_options(LIVE_COUNTER); + assert_eq!( + target_seen_in_update( + &exact, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + Some(LIVE_COUNTER) + ); + + let stale = gameobject_discovery_options(LIVE_COUNTER + 1); + let error = target_seen_in_update( + &stale, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .expect_err("a mismatching live GameObject counter override must fail closed"); + assert!(error + .to_string() + .contains("did not match discovered counter 40")); + } + + #[test] + fn gameobject_discovery_deduplicates_one_guid_and_rejects_packet_ambiguity() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + + let mut duplicate = update_object_with_guid(LIVE_COUNTER, high); + duplicate.extend_from_slice(&update_object_with_guid(LIVE_COUNTER, high)); + let options = gameobject_discovery_options(0); + assert_eq!( + target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &duplicate).unwrap(), + Some(LIVE_COUNTER) + ); + + let mut ambiguous = update_object_with_guid(LIVE_COUNTER, high); + ambiguous.extend_from_slice(&update_object_with_guid(LIVE_COUNTER + 1, high)); + let options = gameobject_discovery_options(0); + let error = target_seen_in_update(&options, SMSG_UPDATE_OBJECT, &ambiguous) + .expect_err("two distinct matching GameObjects in one update must fail closed"); + assert!(error.to_string().contains("2 distinct live ObjectGuid")); + } + + #[test] + fn gameobject_discovery_requires_two_bots_to_converge_on_one_full_guid() { + const LIVE_COUNTER: u64 = 40; + let high = gameobject_runtime_high(RACE_GAMEOBJECT_MAP_ID, DEFAULT_CREATURE_ENTRY); + let first = gameobject_discovery_options(0); + let mut second = first.clone(); + second.participant = 1; + + assert_eq!( + target_seen_in_update( + &first, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + Some(LIVE_COUNTER) + ); + assert_eq!( + target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER, high), + ) + .unwrap(), + Some(LIVE_COUNTER) + ); + + let error = target_seen_in_update( + &second, + SMSG_UPDATE_OBJECT, + &update_object_with_guid(LIVE_COUNTER + 1, high), + ) + .expect_err("two bots must not bind different live GameObject GUIDs"); + assert!(error.to_string().contains("different live ObjectGuids")); + } + + #[tokio::test] + async fn peer_failure_cancels_a_phase_barrier_without_waiting_for_timeout() { + let options = gameobject_discovery_options(0); + let sync = options.sync.clone(); + let waiter = tokio::spawn(async move { + wait_phase(&options, &options.sync.logged_in, "test peer barrier").await + }); + tokio::task::yield_now().await; + sync.cancel("peer failed before reaching the barrier"); + + let error = tokio::time::timeout(Duration::from_millis(250), waiter) + .await + .expect("cancellation must wake the peer promptly") + .expect("barrier waiter task must not panic") + .expect_err("the peer barrier must fail after cancellation"); + assert!(error + .to_string() + .contains("peer failed before reaching the barrier")); + } + + #[test] + fn logout_complete_is_recognized_on_cpp_realm_and_legacy_instance_routes() { + assert_eq!( + logout_completion_route(SMSG_LOGOUT_COMPLETE, LogoutCompletionRoute::Realm), + Some(LogoutCompletionRoute::Realm) + ); + assert_eq!( + logout_completion_route(SMSG_LOGOUT_COMPLETE, LogoutCompletionRoute::Instance), + Some(LogoutCompletionRoute::Instance) + ); + assert_eq!( + logout_completion_route(SMSG_LOOT_RESPONSE, LogoutCompletionRoute::Realm), + None + ); + } + + #[test] + fn cpp_realm_only_party_opcodes_fail_fast_on_instance() { + for opcode in [ + SMSG_PARTY_INVITE, + SMSG_PARTY_UPDATE, + SMSG_PARTY_COMMAND_RESULT, + SMSG_PARTY_MEMBER_FULL_STATE, + ] { + let error = validate_party_packet_route_like_cpp(opcode, PartyPacketRoute::Instance) + .unwrap_err(); + assert!(error.to_string().contains("CONNECTION_TYPE_REALM")); + } + } + + #[test] + fn party_route_guard_accepts_cpp_realm_and_unrelated_instance_packets() { + validate_party_packet_route_like_cpp(SMSG_PARTY_UPDATE, PartyPacketRoute::Realm).unwrap(); + validate_party_packet_route_like_cpp(SMSG_TIME_SYNC_REQUEST, PartyPacketRoute::Instance) + .unwrap(); + } + + #[test] + fn party_update_proves_exact_normal_home_two_player_roster() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + + validate_party_update_like_cpp(&payload, leader, peer, leader).unwrap(); + } + + #[test] + fn party_update_rejects_non_personal_loot_for_shared_chest_race() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test(0, 0, 1, 0, (77, 88), leader, [leader, peer], 0); + + let error = validate_party_update_like_cpp(&payload, leader, peer, leader) + .expect_err("shared chest race must pin C++ PERSONAL_LOOT"); + assert!(error.to_string().contains("PERSONAL_LOOT")); + } + + #[test] + fn party_update_rejects_non_home_empty_or_wrong_roster_states() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + + let non_home = party_update_for_test( + 0, + 1, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&non_home, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("normal HOME") + ); + + let empty_group = party_update_for_test( + 0, + 0, + 1, + 0, + (0, 0), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&empty_group, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("empty PartyGUID") + ); + + let duplicate = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, leader], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&duplicate, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("did not contain exactly") + ); + + let wrong_receiver_index = party_update_for_test( + 0, + 0, + 1, + 1, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + assert!( + validate_party_update_like_cpp(&wrong_receiver_index, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("expected receiver") + ); + } + + #[test] + fn party_update_rejects_truncated_or_trailing_payloads() { + let leader = create_player_guid_raw(15, ITEM_TEST_REALM); + let peer = create_player_guid_raw(16, ITEM_TEST_REALM); + let payload = party_update_for_test( + 0, + 0, + 1, + 0, + (77, 88), + leader, + [leader, peer], + PERSONAL_LOOT_METHOD_LIKE_CPP, + ); + + let truncated = &payload[..payload.len() - 1]; + assert!(validate_party_update_like_cpp(truncated, leader, peer, leader).is_err()); + + let mut trailing = payload; + trailing.push(0); + assert!( + validate_party_update_like_cpp(&trailing, leader, peer, leader) + .unwrap_err() + .to_string() + .contains("trailing byte") + ); + } + + #[test] + fn party_invite_matches_cpp_bit_and_field_order() { + let payload = build_party_invite("Peer", 0x11, 0x22).unwrap(); + assert_eq!(&payload[..4], &[0x00, 0x02, 0x00, 0x00]); + assert_eq!(&payload[4..8], &[0, 0, 0, 0]); + assert!(payload.ends_with(b"Peer")); + } + + #[test] + fn loot_item_claim_contains_one_exact_request_and_soft_interact_false() { + let window = LootWindow { + owner_low: 1, + owner_high: 2, + loot_low: 0x11, + loot_high: 0x22, + coins: 10, + item_entry: 46_052, + quantity: 1, + loot_list_id: 7, + loot_method: PERSONAL_LOOT_METHOD_LIKE_CPP, + }; + let payload = build_loot_item_claim(&window); + assert_eq!(&payload[..4], &1u32.to_le_bytes()); + assert_eq!(&payload[payload.len() - 2..], &[7, 0]); + } + + #[test] + fn creature_guid_and_loot_unit_match_cpp_wire() { + let (low, high) = create_creature_guid_raw(0, 62, 279_748); + assert_eq!(low, 279_748); + assert_eq!(high >> 58, 8); + assert_eq!((high >> 29) & 0x1FFF, 0); + assert_eq!((high >> 6) & 0x7F_FFFF, 62); + assert_eq!( + build_packed_guid(low, high), + vec![0x07, 0x83, 0xC4, 0x44, 0x04, 0x80, 0x0F, 0x20] + ); + } + + #[test] + fn loot_removed_requires_the_full_discovered_creature_owner_guid() { + const CREATURE_COUNTER: u64 = 268; + const CREATURE_HIGH: u64 = 0x2000_0442_4015_44C0; + let expected_owner = (CREATURE_COUNTER, CREATURE_HIGH); + let loot_obj = ( + 41, + (HIGH_GUID_LOOT_OBJECT << 58) | (1 << 42) | (u64::from(530u16) << 29), + ); + let removal_payload = |owner: (u64, u64)| { + let mut payload = build_packed_guid(owner.0, owner.1); + payload.extend_from_slice(&build_packed_guid(loot_obj.0, loot_obj.1)); + payload.push(3); + payload + }; + + let mut evidence = WireEvidence::default(); + record_evidence( + SMSG_LOOT_REMOVED, + &removal_payload(expected_owner), + expected_owner, + &mut evidence, + ) + .unwrap(); + assert_eq!( + evidence.loot_removed, + vec![LootRemovedEvidence { + owner_low: expected_owner.0, + owner_high: expected_owner.1, + loot_low: loot_obj.0, + loot_high: loot_obj.1, + loot_list_id: 3, + }] + ); + + let wrong_runtime_counter = (CREATURE_COUNTER + 1, CREATURE_HIGH); + let error = record_evidence( + SMSG_LOOT_REMOVED, + &removal_payload(wrong_runtime_counter), + expected_owner, + &mut evidence, + ) + .expect_err("a removal for another runtime creature must fail closed"); + assert!(error.to_string().contains("discovered world-object GUID")); + assert_eq!(evidence.loot_removed.len(), 1); + } + + #[test] + fn item_push_parser_consumes_the_complete_cpp_343_shape_and_rejects_a_tail() { + let mut payload = build_packed_guid(0x0102, 0); + payload.push(4); + for value in [-1, 777, 3, 9, 615, 123, 188, 26, 25] { + payload.extend_from_slice(&i32::to_le_bytes(value)); + } + payload.extend_from_slice(&build_packed_guid(0x0506, 0)); + // Pushed=true, Created=false, DisplayText=EncounterLoot, + // IsBonusRoll=false, IsEncounterLoot=true, then byte-align. + payload.push(0x92); + payload.extend_from_slice(&9001i32.to_le_bytes()); + payload.extend_from_slice(&12i32.to_le_bytes()); + payload.extend_from_slice(&(-77i32).to_le_bytes()); + payload.push(0x00); // ItemBonus absent, then byte-align. + payload.push(0x00); // ItemModList has zero 6-bit entries. + + assert_eq!( + parse_item_push(&payload).unwrap(), + ItemPush { + player_low: 0x0102, + player_high: 0, + slot: 4, + slot_in_bag: -1, + quest_log_item_id: 777, + quantity: 3, + quantity_in_inventory: 9, + dungeon_encounter_id: 615, + item_guid_low: 0x0506, + item_guid_high: 0, + pushed: true, + created: false, + display_text: 2, + is_bonus_roll: false, + is_encounter_loot: true, + item_entry: 9001, + } + ); + + payload.push(0xAA); + let error = parse_item_push(&payload) + .expect_err("SMSG_ITEM_PUSH_RESULT trailing bytes must fail closed"); + assert!(error.to_string().contains("unexpected trailing bytes")); + } + + #[test] + fn atomic_item_wire_proves_one_logical_grant_for_direct_or_cpp_group_fanout() { + for group_broadcast in [false, true] { + let (evidence, removal) = valid_atomic_item_outcome(group_broadcast); + let grant = validate_atomic_item_wire_outcome_like_cpp( + &evidence, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .unwrap(); + assert_eq!(grant.owner_guid, ITEM_TEST_CHARACTERS[0]); + assert_eq!(grant.push, valid_atomic_item_push()); + } + } + + #[test] + fn atomic_item_wire_rejects_foreign_push_extra_removal_and_late_duplicate() { + let (mut foreign_push, removal) = valid_atomic_item_outcome(true); + foreign_push[1].item_pushes[0].item_entry += 1; + assert!(validate_atomic_item_wire_outcome_like_cpp( + &foreign_push, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut extra_removal, removal) = valid_atomic_item_outcome(false); + extra_removal[0].loot_removed.push(removal); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &extra_removal, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut late_duplicate, removal) = valid_atomic_item_outcome(false); + validate_atomic_item_wire_outcome_like_cpp( + &late_duplicate, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .unwrap(); + late_duplicate[0].merge(WireEvidence { + item_pushes: vec![valid_atomic_item_push()], + ..Default::default() + }); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &late_duplicate, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + } + + #[test] + fn atomic_item_wire_rejects_foreign_or_additional_inventory_failure() { + let (mut foreign, removal) = valid_atomic_item_outcome(false); + foreign[1].inventory_failures[0].result = 49; + assert!(validate_atomic_item_wire_outcome_like_cpp( + &foreign, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + + let (mut additional, removal) = valid_atomic_item_outcome(false); + additional[0] + .inventory_failures + .push(additional[1].inventory_failures[0]); + assert!(validate_atomic_item_wire_outcome_like_cpp( + &additional, + ITEM_TEST_CHARACTERS, + ITEM_TEST_ENTRY, + 1, + removal, + ITEM_TEST_REALM, + ) + .is_err()); + } + + #[test] + fn persisted_item_row_binds_wire_guid_owner_entry_count_and_slot() { + let expected = ExpectedPersistedItemGrant { + owner_guid: ITEM_TEST_CHARACTERS[0], + push: valid_atomic_item_push(), + }; + let valid = PersistedItemGrantRow { + item_guid: expected.push.item_guid_low, + owner_guid: expected.owner_guid, + item_entry: expected.push.item_entry, + count: 1, + inventory_owner: Some(expected.owner_guid), + bag_guid: Some(0), + slot: Some(INVENTORY_SLOT_ITEM_START), + bag_slot: None, + }; + validate_persisted_item_grant_like_cpp(expected, valid).unwrap(); + + for malformed in [ + PersistedItemGrantRow { + item_guid: valid.item_guid + 1, + ..valid + }, + PersistedItemGrantRow { + owner_guid: valid.owner_guid + 1, + ..valid + }, + PersistedItemGrantRow { + item_entry: valid.item_entry + 1, + ..valid + }, + PersistedItemGrantRow { count: 2, ..valid }, + PersistedItemGrantRow { + slot: Some(INVENTORY_SLOT_ITEM_START + 1), + ..valid + }, + ] { + assert!(validate_persisted_item_grant_like_cpp(expected, malformed).is_err()); + } + } + + #[test] + fn tattered_chest_template_data_pins_shared_group_rules_and_loot_id() { + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA.len(), 35); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[0], 57); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[1], 2_278); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[3], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[10], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[12], 1); + assert_eq!(RACE_GAMEOBJECT_TEMPLATE_DATA[15], 1); + assert!(RACE_GAMEOBJECT_TEMPLATE_DATA + .iter() + .enumerate() + .all(|(index, value)| matches!(index, 0 | 1 | 3 | 10 | 12 | 15) || *value == 0)); + } + + #[test] + fn loot_object_guid_requires_exact_cpp_world_object_structure() { + let realm_id = 7u32; + let map_id = 571u16; + let counter = 41u64; + let high = + (HIGH_GUID_LOOT_OBJECT << 58) | (u64::from(realm_id) << 42) | (u64::from(map_id) << 29); + + validate_loot_object_guid_like_cpp(counter, high, map_id, realm_id).unwrap(); + + let malformed = [ + (counter, high ^ (1 << 58)), + (counter, high ^ (1 << 42)), + (counter, high ^ (1 << 29)), + (counter, high | (1 << 6)), + (counter, high | 1), + (counter | (1 << 40), high), + (0, high), + ]; + for (low, high) in malformed { + assert!( + validate_loot_object_guid_like_cpp(low, high, map_id, realm_id).is_err(), + "malformed LootObject unexpectedly passed: low={low:#x}, high={high:#x}" + ); + } + } + + #[test] + fn serialized_money_race_requires_one_positive_and_one_zero_fanout_like_cpp() { + let source = (0x11, 0x22); + let positive = MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + let evidence = [ + WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source, source], + ..Default::default() + }, + WireEvidence { + money_notifies: vec![zero], + coin_removed: vec![source, source], + ..Default::default() + }, + ]; + + assert_eq!( + validate_serialized_gameobject_money_wire_outcome_like_cpp(&evidence, source, 10) + .unwrap(), + 0 + ); + + let reversed = [evidence[1].clone(), evidence[0].clone()]; + assert_eq!( + validate_serialized_gameobject_money_wire_outcome_like_cpp(&reversed, source, 10) + .unwrap(), + 1 + ); + } + + #[test] + fn serialized_money_race_rejects_duplicate_positive_or_missing_coin_fanout() { + let source = (0x11, 0x22); + let positive = MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: true, + }; + let zero = MoneyNotify { + money: 0, + money_mod: 0, + sole_looter: true, + }; + let valid = WireEvidence { + money_notifies: vec![zero], + coin_removed: vec![source, source], + ..Default::default() + }; + + let duplicate_positive = WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source, source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[duplicate_positive.clone(), duplicate_positive], + source, + 10, + ) + .is_err()); + + let missing_coin = WireEvidence { + money_notifies: vec![positive], + coin_removed: vec![source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[valid.clone(), missing_coin], + source, + 10, + ) + .is_err()); + + let wrong_sole_looter = WireEvidence { + money_notifies: vec![MoneyNotify { + money: 10, + money_mod: 0, + sole_looter: false, + }], + coin_removed: vec![source, source], + ..Default::default() + }; + assert!(validate_serialized_gameobject_money_wire_outcome_like_cpp( + &[valid, wrong_sole_looter], + source, + 10, + ) + .is_err()); + } + + #[test] + fn loot_gone_failure_matches_cpp_wire_and_rejects_a_tail() { + let mut payload = 50i32.to_le_bytes().to_vec(); + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.extend_from_slice(&build_packed_guid(0, 0)); + payload.push(0); + assert_eq!( + parse_inventory_failure(&payload).unwrap(), + InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + } + ); + payload.push(0); + assert!(parse_inventory_failure(&payload).is_err()); + } + + #[test] + fn money_notify_reads_two_u64s_and_msb_sole_looter_bit() { + let mut payload = 3u64.to_le_bytes().to_vec(); + payload.extend_from_slice(&2u64.to_le_bytes()); + payload.push(0x80); + let mut evidence = WireEvidence::default(); + record_evidence(SMSG_LOOT_MONEY_NOTIFY, &payload, (0, 0), &mut evidence).unwrap(); + assert_eq!( + evidence.money_notifies, + vec![MoneyNotify { + money: 3, + money_mod: 2, + sole_looter: true, + }] + ); + payload.push(0); + assert!(record_evidence(SMSG_LOOT_MONEY_NOTIFY, &payload, (0, 0), &mut evidence).is_err()); + } + + #[test] + fn single_item_capture_requires_one_owner_push_one_removal_and_no_money() { + let window = LootWindow { + owner_low: 1, + owner_high: 2, + loot_low: 0x11, + loot_high: 0x22, + coins: 7, + item_entry: 56_147, + quantity: 1, + loot_list_id: 3, + loot_method: 0, + }; + let expected_player = (0x33, 0x44); + let evidence = WireEvidence { + item_pushes: vec![ItemPush { + player_low: expected_player.0, + player_high: expected_player.1, + item_entry: window.item_entry, + slot: INVENTORY_SLOT_BAG_0, + slot_in_bag: i32::from(LOOT_ITEM_CAPTURE_KEYRING_SLOT), + quantity: 1, + quantity_in_inventory: 1, + ..Default::default() + }], + loot_removed: vec![LootRemovedEvidence { + owner_low: window.owner_low, + owner_high: window.owner_high, + loot_low: window.loot_low, + loot_high: window.loot_high, + loot_list_id: window.loot_list_id, + }], + ..Default::default() + }; + + validate_single_item_capture_evidence( + &evidence, + expected_player, + window.item_entry, + &window, + ) + .unwrap(); + + let mut with_money = evidence.clone(); + with_money.money_notifies.push(MoneyNotify { + money: 7, + money_mod: 0, + sole_looter: true, + }); + assert!(validate_single_item_capture_evidence( + &with_money, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + + let mut displaced_key = evidence.clone(); + displaced_key.item_pushes[0].slot_in_bag = i32::from(INVENTORY_SLOT_ITEM_START); + assert!(validate_single_item_capture_evidence( + &displaced_key, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + + let mut wrong_route_owner = evidence; + wrong_route_owner.item_pushes[0].player_low ^= 1; + assert!(validate_single_item_capture_evidence( + &wrong_route_owner, + expected_player, + window.item_entry, + &window, + ) + .is_err()); + } + + #[test] + fn capture_collector_fails_closed_on_duplicate_or_failure_before_fence() { + let push = ItemPush { + player_low: 1, + player_high: 2, + item_entry: 56_147, + ..Default::default() + }; + validate_single_item_capture_candidate(&WireEvidence { + item_pushes: vec![push], + ..Default::default() + }) + .unwrap(); + + assert!(validate_single_item_capture_candidate(&WireEvidence { + item_pushes: vec![push, push], + ..Default::default() + }) + .is_err()); + assert!(validate_single_item_capture_candidate(&WireEvidence { + inventory_failures: vec![InventoryFailure { + result: 50, + item_0_low: 0, + item_0_high: 0, + item_1_low: 0, + item_1_high: 0, + container_b_slot: 0, + }], + ..Default::default() + }) + .is_err()); + } + + #[test] + fn capture_exclusivity_requires_zero_globally_online_characters() { + validate_online_character_count(0, "test preflight").unwrap(); + let error = validate_online_character_count(1, "test preflight") + .expect_err("one unrelated online character must fail closed"); + assert!(error.to_string().contains("exclusive world access")); + assert!(error.to_string().contains("1 character(s)")); + } + + #[test] + fn progress_restore_table_scope_has_no_duplicates_or_global_tables() { + let unique = CHARACTER_PROGRESS_TABLES + .iter() + .copied() + .collect::>(); + assert_eq!(unique.len(), CHARACTER_PROGRESS_TABLES.len()); + assert!(unique.contains("character_achievement")); + assert!(unique.contains("character_achievement_progress")); + assert!(unique.contains("character_queststatus_objectives_criteria_progress")); + assert!(unique.contains("character_reputation")); + assert!(!unique.iter().any(|table| table.starts_with("guild_"))); + } +} diff --git a/tools/wow-test-bot/src/main.rs b/tools/wow-test-bot/src/main.rs index aec4ad5e0..de598b5b2 100644 --- a/tools/wow-test-bot/src/main.rs +++ b/tools/wow-test-bot/src/main.rs @@ -9,10 +9,12 @@ use std::net::IpAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; mod bot_srp6; mod config; +mod loot_race; mod packet_parser; mod protocol; mod srp6_auth; @@ -51,7 +53,17 @@ const SMSG_PLAYER_BOUND: u16 = 0x2FF8; const SMSG_SPELL_GO: u16 = 0x2C36; const CMSG_LOGOUT_REQUEST: u16 = 0x34D6; const SMSG_LOGOUT_COMPLETE: u16 = 0x2684; -const RESTED_XP_GRACEFUL_LOGOUT_WAIT_SECS: u64 = 30; +// Login can legitimately contain more than 30 packets before +// SMSG_LOGIN_VERIFY_WORLD when another player is already on the map and its +// CREATE/broadcast traffic is interleaved. Keep the guard wall-clock based so +// a busy but healthy login cannot exhaust an arbitrary packet budget. +const LOGIN_VERIFY_TIMEOUT: Duration = Duration::from_secs(30); +const LOGIN_VERIFY_READ_SLICE: Duration = Duration::from_secs(5); +const INITIAL_NETWORK_IO_TIMEOUT: Duration = Duration::from_secs(15); +// C++ WorldSession::ShouldLogOut waits 20 wall-clock seconds after a normal +// logout request (`WorldSession.h`) before it can send SMSG_LOGOUT_COMPLETE. +// Keep a bounded margin for the world update that observes the expired timer. +const NORMAL_LOGOUT_COMPLETE_WAIT_SECS: u64 = 30; const RESTED_XP_DISCONNECT_SAVE_MAX_WAIT_SECS: u64 = 90; const INVENTORY_SLOT_BAG_0: u8 = 255; const INVENTORY_SLOT_ITEM_START: u8 = 35; @@ -102,6 +114,26 @@ struct ServerPacketInflater { decompressor: Decompress, } +#[derive(Debug, Clone, Copy)] +struct LoginVerifyBudget { + deadline: tokio::time::Instant, +} + +impl LoginVerifyBudget { + fn new(timeout: Duration) -> Self { + Self { + deadline: tokio::time::Instant::now() + timeout, + } + } + + fn next_read_timeout(self) -> Option { + let remaining = self + .deadline + .saturating_duration_since(tokio::time::Instant::now()); + (!remaining.is_zero()).then(|| remaining.min(LOGIN_VERIFY_READ_SLICE)) + } +} + impl Default for ServerPacketInflater { fn default() -> Self { Self { @@ -196,6 +228,18 @@ struct CliOptions { rested_xp_runtime_counter: Option, rested_xp_offline_secs: u64, rested_xp_timeout_secs: u64, + loot_race_smoke: bool, + loot_item_capture: bool, + ack_disposable_overworld_loot_race: bool, + loot_race_account_a: String, + loot_race_account_b: String, + loot_race_creature_entry: u32, + loot_race_creature_spawn_guid: u64, + loot_race_runtime_counter: u64, + loot_race_item_entry: u32, + loot_race_timeout_secs: u64, + loot_workflow_deadline_secs: u64, + recover_loot_fixture: bool, quest_smoke: bool, quest_creature_entry: Option, quest_creature_guid: Option, @@ -222,6 +266,7 @@ struct CliOptions { } #[derive(Debug, Clone, Serialize)] +#[cfg_attr(test, derive(Default))] struct BotRunResult { account: String, account_id: u32, @@ -298,6 +343,24 @@ struct BotRunResult { rested_xp_db_rest_after: Option, rested_xp_relog_verified: bool, rested_xp_failure: Option, + loot_race_smoke: bool, + loot_race_smoke_passed: Option, + loot_race_target_entry: Option, + loot_race_target_spawn_guid: Option, + loot_race_target_runtime_counter: Option, + loot_race_party_confirmed: bool, + loot_race_target_discovered: bool, + loot_race_loot_opened: bool, + loot_race_loot_list_id: Option, + loot_race_loot_coins: Option, + loot_race_item_push_seen: bool, + loot_race_loot_removed_seen: bool, + loot_race_money_notify_amount: Option, + loot_race_coin_removed_seen: bool, + loot_race_db_item_total: Option, + loot_race_db_money_delta: Option, + loot_race_relog_verified: bool, + loot_race_failure: Option, quest_smoke: bool, quest_smoke_passed: Option, quest_target_entry: Option, @@ -370,6 +433,13 @@ impl BotRunResult { && self.player_login_verified && self.rested_xp_smoke_passed.unwrap_or(false); } + if self.loot_race_smoke { + return self.world_auth + && self.enum_characters + && self.player_login_verified + && self.loot_race_smoke_passed.unwrap_or(false) + && self.loot_race_relog_verified; + } if login_only { return self.world_auth && self.enum_characters && self.player_login_verified; } @@ -392,6 +462,8 @@ struct RunReport { homebind_smoke: bool, inventory_swap_smoke: bool, rested_xp_smoke: bool, + loot_race_smoke: bool, + loot_item_capture: bool, quest_smoke: bool, results: Vec, } @@ -466,7 +538,7 @@ struct BankSmokeOptions { timeout_secs: u64, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)] struct CharacterPositionSnapshot { map_id: u32, zone_id: u32, @@ -823,6 +895,52 @@ fn parse_cli() -> Result { .map(|value| value.parse::()) .transpose()? .unwrap_or(DEFAULT_RESTED_XP_TIMEOUT_SECS), + loot_race_smoke: std::env::var("WOW_BOT_LOOT_RACE_SMOKE") + .ok() + .is_some_and(|value| is_truthy(&value)), + loot_item_capture: std::env::var("WOW_BOT_LOOT_ITEM_CAPTURE") + .ok() + .is_some_and(|value| is_truthy(&value)), + // Deliberately CLI-only: inherited environment cannot acknowledge + // disposable loot-fixture mutation on the caller's behalf. + ack_disposable_overworld_loot_race: false, + loot_race_account_a: std::env::var("WOW_BOT_LOOT_RACE_ACCOUNT_A") + .unwrap_or_else(|_| loot_race::DEFAULT_ACCOUNT_A.to_string()), + loot_race_account_b: std::env::var("WOW_BOT_LOOT_RACE_ACCOUNT_B") + .unwrap_or_else(|_| loot_race::DEFAULT_ACCOUNT_B.to_string()), + loot_race_creature_entry: std::env::var("WOW_BOT_LOOT_RACE_GAMEOBJECT_ENTRY") + .or_else(|_| std::env::var("WOW_BOT_LOOT_RACE_CREATURE_ENTRY")) + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_CREATURE_ENTRY), + loot_race_creature_spawn_guid: std::env::var("WOW_BOT_LOOT_RACE_GAMEOBJECT_SPAWN_GUID") + .or_else(|_| std::env::var("WOW_BOT_LOOT_RACE_CREATURE_SPAWN_GUID")) + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_CREATURE_SPAWN_GUID), + loot_race_runtime_counter: std::env::var("WOW_BOT_LOOT_RACE_RUNTIME_COUNTER") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_RUNTIME_COUNTER), + loot_race_item_entry: std::env::var("WOW_BOT_LOOT_RACE_ITEM_ENTRY") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_ITEM_ENTRY), + loot_race_timeout_secs: std::env::var("WOW_BOT_LOOT_RACE_TIMEOUT_SECS") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_TIMEOUT_SECS), + loot_workflow_deadline_secs: std::env::var("WOW_BOT_LOOT_WORKFLOW_DEADLINE_SECS") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(loot_race::DEFAULT_WORKFLOW_DEADLINE_SECS), + recover_loot_fixture: false, quest_smoke: std::env::var("WOW_BOT_QUEST_SMOKE") .ok() .map(|v| is_truthy(&v)) @@ -987,6 +1105,38 @@ fn parse_cli() -> Result { opts.rested_xp_timeout_secs = next_arg(&mut args, "--rested-xp-timeout")?.parse()?; } + "--loot-race-smoke" => opts.loot_race_smoke = true, + "--loot-item-capture" => opts.loot_item_capture = true, + arg if arg == loot_race::ACK_FLAG => opts.ack_disposable_overworld_loot_race = true, + "--loot-race-account-a" => { + opts.loot_race_account_a = next_arg(&mut args, "--loot-race-account-a")?; + } + "--loot-race-account-b" => { + opts.loot_race_account_b = next_arg(&mut args, "--loot-race-account-b")?; + } + flag @ ("--loot-race-gameobject-entry" | "--loot-race-creature-entry") => { + opts.loot_race_creature_entry = next_arg(&mut args, flag)?.parse()?; + } + flag @ ("--loot-race-gameobject-spawn-guid" | "--loot-race-creature-spawn-guid") => { + opts.loot_race_creature_spawn_guid = next_arg(&mut args, flag)?.parse()?; + } + "--loot-race-runtime-counter" => { + opts.loot_race_runtime_counter = + next_arg(&mut args, "--loot-race-runtime-counter")?.parse()?; + } + "--loot-race-item-entry" => { + opts.loot_race_item_entry = + next_arg(&mut args, "--loot-race-item-entry")?.parse()?; + } + "--loot-race-timeout" => { + opts.loot_race_timeout_secs = + next_arg(&mut args, "--loot-race-timeout")?.parse()?; + } + "--loot-workflow-deadline" => { + opts.loot_workflow_deadline_secs = + next_arg(&mut args, "--loot-workflow-deadline")?.parse()?; + } + "--recover-loot-fixture" => opts.recover_loot_fixture = true, "--quest-smoke" => opts.quest_smoke = true, "--quest-creature-entry" => { opts.quest_creature_entry = @@ -1057,6 +1207,17 @@ fn parse_cli() -> Result { _ => bail!("Unknown argument `{}`. Use --help.", arg), } } + if opts.loot_item_capture + && opts.loot_race_creature_entry == loot_race::DEFAULT_CREATURE_ENTRY + && opts.loot_race_creature_spawn_guid == loot_race::DEFAULT_CREATURE_SPAWN_GUID + && opts.loot_race_runtime_counter == loot_race::DEFAULT_RUNTIME_COUNTER + && opts.loot_race_item_entry == loot_race::DEFAULT_ITEM_ENTRY + { + opts.loot_race_creature_entry = loot_race::DEFAULT_CAPTURE_CREATURE_ENTRY; + opts.loot_race_creature_spawn_guid = loot_race::DEFAULT_CAPTURE_CREATURE_SPAWN_GUID; + opts.loot_race_runtime_counter = loot_race::DEFAULT_CAPTURE_RUNTIME_COUNTER; + opts.loot_race_item_entry = loot_race::DEFAULT_CAPTURE_ITEM_ENTRY; + } Ok(opts) } @@ -1080,6 +1241,84 @@ fn is_truthy(value: &str) -> bool { ) } +fn validate_provisioning_mode(loot_mode: bool, ensure_test_accounts: bool) -> Result<()> { + if loot_mode && ensure_test_accounts { + bail!( + "loot workflows forbid --ensure-test-accounts/WOW_BOT_ENSURE_TEST_ACCOUNTS; provision fixtures separately, then run the read-only identity preflight" + ); + } + Ok(()) +} + +fn install_loot_termination_token() -> Result { + let token = CancellationToken::new(); + let signal_token = token.clone(); + #[cfg(unix)] + let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .context("Install loot-fixture SIGINT handler before mutation")?; + #[cfg(unix)] + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("Install loot-fixture SIGTERM handler before mutation")?; + if std::env::var("WOW_BOT_REQUIRE_PARENT_DEATH_GUARD").is_ok_and(|value| is_truthy(&value)) { + #[cfg(target_os = "linux")] + { + // SAFETY: getppid takes no pointers and has no preconditions. + let parent_before = unsafe { libc::getppid() }; + if parent_before <= 1 { + bail!("loot parent-death guard has no live supervising parent"); + } + // SAFETY: PR_SET_PDEATHSIG accepts an integer signal number. SIGTERM + // is handled by the streams registered synchronously above. + let result = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) }; + if result != 0 { + return Err(std::io::Error::last_os_error()) + .context("Arm Linux parent-death SIGTERM before fixture mutation"); + } + // Close the documented prctl race: if the supervisor disappeared + // before PR_SET_PDEATHSIG was armed, refuse to enter the fixture. + // SAFETY: getppid takes no pointers and has no preconditions. + let parent_after = unsafe { libc::getppid() }; + if parent_after != parent_before { + bail!("loot supervisor changed while arming parent-death guard"); + } + } + #[cfg(not(target_os = "linux"))] + bail!("WOW_BOT_REQUIRE_PARENT_DEATH_GUARD is supported only on Linux"); + } + tokio::spawn(async move { + #[cfg(unix)] + { + tokio::select! { + _ = interrupt.recv() => {} + _ = terminate.recv() => {} + } + } + #[cfg(not(unix))] + if let Err(error) = tokio::signal::ctrl_c().await { + error!("Termination handler failed: {error}"); + } + signal_token.cancel(); + }); + Ok(token) +} + +async fn finish_guarded_loot_result( + result: Result>, +) -> Result> { + match result { + Ok(results) => Ok(results), + Err(primary) => match loot_race::recover_pending_fixture_if_present().await { + Ok(true) => Err(primary.context( + "loot workflow failed; the durable fixture journal was recovered before exit", + )), + Ok(false) => Err(primary), + Err(recovery) => bail!( + "loot workflow failed ({primary:#}) and durable fixture recovery also failed ({recovery:#}); leave the normal world stopped and run --recover-loot-fixture" + ), + }, + } +} + fn validate_rested_xp_cli_values( enabled: bool, acknowledged_disposable: bool, @@ -1209,7 +1448,7 @@ fn print_help() { " --cleanup-groups Delete stale group rows for configured bot GUIDs before run" ); println!(" --require-group Treat missing party info/group formation as failure"); - println!(" --ensure-test-accounts Upsert local TESTBOT auth rows before login"); + println!(" --ensure-test-accounts Create missing local TESTBOT auth rows; validate existing rows without rewriting them"); println!(" --login-only Stop after SMSG_LOGIN_VERIFY_WORLD; do not run LFG"); println!(" --stand-state-smoke After login, verify Sit then Stand state round-trips"); println!( @@ -1254,6 +1493,38 @@ fn print_help() { println!( " Env: WOW_BOT_RESTED_XP_SMOKE, WOW_BOT_RESTED_XP_CREATURE_ENTRY, WOW_BOT_RESTED_XP_CREATURE_GUID, WOW_BOT_RESTED_XP_RUNTIME_COUNTER, WOW_BOT_RESTED_XP_OFFLINE_SECS, WOW_BOT_RESTED_XP_TIMEOUT_SECS" ); + println!( + " --loot-race-smoke Race ITEM and MONEY claims on one shared chest from two real sessions" + ); + println!( + " --loot-item-capture One real session kills/opens/claims only the item and emits a fixed capture fence" + ); + println!( + " {} REQUIRED acknowledgement: mutates disposable loot fixtures (capture also kills its creature)", + loot_race::ACK_FLAG + ); + println!( + " --loot-race-account-a First disposable contender/capture killer (default TESTBOT2@bot.local)" + ); + println!( + " --loot-race-account-b Second disposable bot (default TESTBOT3@bot.local)" + ); + println!(" --loot-race-gameobject-entry Exact chest entry (default 2846 Tattered Chest)"); + println!( + " --loot-race-gameobject-spawn-guid Exact wrapper-owned world.gameobject spawn (default 9106001)" + ); + println!(" Legacy aliases: --loot-race-creature-entry / --loot-race-creature-spawn-guid"); + println!(" --loot-race-runtime-counter Optional strict live counter override (default 0=auto-discover full GUID)"); + println!(" --loot-race-item-entry Exact shared-chest loot item (default 38)"); + println!(" --loot-race-timeout Per coordination/loot timeout (capture also uses it for combat; default 30)"); + println!(" --loot-workflow-deadline Hard end-to-end loot deadline before guarded cleanup (default 900)"); + println!(" --recover-loot-fixture Restore one pending WOW_BOT_FIXTURE_JOURNAL; no login/service actions"); + println!( + " Race uses a guarded temporary chest; capture keeps its separate Doctor creature fixture" + ); + println!( + " Env capture mode: WOW_BOT_LOOT_ITEM_CAPTURE=1 (uses account A; account B remains offline as a guarded fixture snapshot)" + ); println!(" --quest-smoke After login, right-click/query one questgiver NPC"); println!(" --quest-creature-entry Creature entry to resolve from world.creature"); println!(" --quest-creature-guid Optional world.creature spawn guid override"); @@ -1316,26 +1587,51 @@ fn password_env_name(account: &str) -> String { } fn ensure_test_accounts(bots: &[config::BotConfig]) -> Result<()> { + use mysql::prelude::Queryable; + let auth_db = auth_db_url()?; let char_db = characters_db_url()?; - let auth_opts = mysql::Opts::from_url(&auth_db).map_err(|e| anyhow!("Bad auth DB URL: {e}"))?; - let char_opts = - mysql::Opts::from_url(&char_db).map_err(|e| anyhow!("Bad character DB URL: {e}"))?; + let auth_opts = qa_mysql_opts(&auth_db, "auth")?; + let char_opts = qa_mysql_opts(&char_db, "characters")?; let mut auth_conn = mysql::Conn::new(auth_opts).map_err(|e| anyhow!("Connect to auth DB failed: {e}"))?; let mut char_conn = mysql::Conn::new(char_opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + // Validate every existing character before the first auth write. Account + // provisioning is intentionally create-only: an ID collision must never + // become authority to rewrite credentials or character ownership. for bot in bots { - ensure_local_bot_account(&mut auth_conn, bot)?; - ensure_local_bot_character_owner(&mut char_conn, bot)?; - sync_realm_character_count(&mut auth_conn, &mut char_conn, bot)?; + validate_local_bot_character_owner(&mut char_conn, bot)?; + } + for bot in bots { + let character_count: u64 = char_conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Count characters for account {}: {e}", bot.account_id))? + .unwrap_or(0); + provision_local_bot_account_create_only(&mut auth_conn, bot, character_count)?; } Ok(()) } -fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { +fn qa_mysql_opts(url: &str, label: &str) -> Result { + let opts = mysql::Opts::from_url(url).map_err(|e| anyhow!("Bad {label} DB URL: {e}"))?; + Ok(mysql::OptsBuilder::from_opts(opts) + .tcp_connect_timeout(Some(Duration::from_secs(10))) + .read_timeout(Some(Duration::from_secs(30))) + .write_timeout(Some(Duration::from_secs(30))) + .into()) +} + +fn provision_local_bot_account_create_only( + conn: &mut mysql::Conn, + bot: &config::BotConfig, + character_count: u64, +) -> Result<()> { use mysql::prelude::Queryable; let (email, bnet_salt, bnet_verifier) = @@ -1347,69 +1643,86 @@ fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> "Refusing to bootstrap non-local bot account {email}; set WOW_BOT_ALLOW_NONLOCAL_ACCOUNT_BOOTSTRAP=1 if this is intentional" ); } - - let bnet_id = if let Some(id) = conn - .exec_first::( - "SELECT id FROM battlenet_accounts WHERE email = ?", + let game_username = game_account_username(&bot.account)?; + let bnet_rows: Vec<(u32, String)> = conn + .exec( + "SELECT id, email FROM battlenet_accounts WHERE email = ? ORDER BY id", (&email,), ) - .map_err(|e| anyhow!("Lookup BNet account {email}: {e}"))? - { - conn.exec_drop( - "UPDATE battlenet_accounts \ - SET srp_version = 1, salt = ?, verifier = ?, failed_logins = 0, locked = 0, lock_country = '00' \ - WHERE id = ?", - (bnet_salt.to_vec(), bnet_verifier, id), + .map_err(|e| anyhow!("Lookup BNet account {email}: {e}"))?; + if bnet_rows.len() > 1 { + bail!( + "Refusing account provisioning: BNet email {email} has {} rows", + bnet_rows.len() + ); + } + let game_account_exists = conn + .exec_first::("SELECT id FROM account WHERE id = ?", (bot.account_id,)) + .map_err(|e| anyhow!("Lookup game account id {}: {e}", bot.account_id))? + .is_some(); + + // Existing identities are validation-only. This makes repeated + // provisioning idempotent while forbidding credential rewrites. + match create_only_provisioning_plan(!bnet_rows.is_empty(), game_account_exists)? { + CreateOnlyProvisioningPlan::ValidateExisting => { + validate_exact_bot_identity(conn, None, bot)?; + validate_realm_character_count(conn, bot, character_count)?; + info!( + "[Bot {}] existing local auth fixture validated without mutation", + bot.account_id + ); + return Ok(()); + } + CreateOnlyProvisioningPlan::CreateBoth => {} + } + + let colliding_accounts: u64 = conn + .exec_first( + "SELECT COUNT(*) FROM account WHERE username = ? OR email = ? OR reg_mail = ?", + (&game_username, &email, &email), ) - .map_err(|e| anyhow!("Update BNet account {email}: {e}"))?; - id + .map_err(|e| anyhow!("Check game-account identity collisions: {e}"))? + .unwrap_or(0); + if !game_account_exists && colliding_accounts != 0 { + bail!( + "Refusing account provisioning: username/email for {} already belongs to another game account", + bot.account + ); + } + + let expected_numchars = u8::try_from(character_count).map_err(|_| { + anyhow!("Character count {character_count} exceeds realmcharacters capacity") + })?; + let mut tx = conn + .start_transaction(mysql::TxOpts::default()) + .map_err(|e| anyhow!("Start create-only account transaction: {e}"))?; + let bnet_id = if let Some((id, _)) = bnet_rows.first() { + *id } else { - conn.exec_drop( + tx.exec_drop( "INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, 1, ?, ?)", (&email, bnet_salt.to_vec(), bnet_verifier), ) .map_err(|e| anyhow!("Insert BNet account {email}: {e}"))?; - u32::try_from(conn.last_insert_id()).map_err(|_| anyhow!("BNet account id overflow"))? + u32::try_from( + tx.last_insert_id() + .ok_or_else(|| anyhow!("BNet account insert returned no id"))?, + ) + .map_err(|_| anyhow!("BNet account id overflow"))? }; - let game_username = game_account_username(&bot.account)?; - // The 3.4.3 world login path below authenticates through - // account.session_key_bnet. These legacy Grunt fields are still NOT NULL in - // the auth schema, so keep them initialized for local bot rows. - let account_salt = random_32(); - let account_verifier = fixed_le_32(Vec::new()); - let account_exists = conn - .exec_first::("SELECT id FROM account WHERE id = ?", (bot.account_id,)) - .map_err(|e| anyhow!("Lookup game account id {}: {e}", bot.account_id))? - .is_some(); - - if account_exists { - conn.exec_drop( - "UPDATE account \ - SET username = ?, salt = ?, verifier = ?, reg_mail = ?, email = ?, battlenet_account = ?, \ - battlenet_index = 1, expansion = 9, failed_logins = 0, locked = 0, lock_country = '00', online = 0 \ - WHERE id = ?", - ( - &game_username, - account_salt.to_vec(), - account_verifier, - &email, - &email, - bnet_id, - bot.account_id, - ), - ) - .map_err(|e| anyhow!("Update game account {}: {e}", bot.account_id))?; - } else { - conn.exec_drop( + if !game_account_exists { + // The 3.4.3 world login path authenticates through + // account.session_key_bnet. Legacy Grunt fields remain NOT NULL. + tx.exec_drop( "INSERT INTO account \ (id, username, salt, verifier, reg_mail, email, joindate, battlenet_account, battlenet_index, expansion) \ VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, 1, 9)", ( bot.account_id, &game_username, - account_salt.to_vec(), - account_verifier, + random_32().to_vec(), + fixed_le_32(Vec::new()), &email, &email, bnet_id, @@ -1418,24 +1731,86 @@ fn ensure_local_bot_account(conn: &mut mysql::Conn, bot: &config::BotConfig) -> .map_err(|e| anyhow!("Insert game account {}: {e}", bot.account_id))?; } - conn.exec_drop( - "DELETE FROM battlenet_account_bans WHERE id = ?", - (bnet_id,), - ) - .map_err(|e| anyhow!("Clear BNet bans for {email}: {e}"))?; - conn.exec_drop( - "UPDATE account_banned SET active = 0 WHERE id = ?", - (bot.account_id,), - ) - .map_err(|e| anyhow!("Clear game account bans for {}: {e}", bot.account_id))?; - + let existing_realm_count: Option = tx + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters for account {}: {e}", bot.account_id))?; + match existing_realm_count { + Some(actual) if actual != expected_numchars => bail!( + "Refusing to rewrite realmcharacters for account {}: expected {}, found {}", + bot.account_id, + expected_numchars, + actual + ), + Some(_) => {} + None => tx + .exec_drop( + "INSERT INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", + (expected_numchars, bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Insert realmcharacters for account {}: {e}", bot.account_id))?, + } + tx.commit() + .map_err(|e| anyhow!("Commit create-only account transaction: {e}"))?; + validate_exact_bot_identity(conn, None, bot)?; + validate_realm_character_count(conn, bot, character_count)?; info!( - "[Bot {}] ensured local auth account {} / character GUID {}", - bot.account_id, email, bot.character_guid + "[Bot {}] created missing local auth rows without rewriting existing identities", + bot.account_id ); Ok(()) } +fn validate_realm_character_count( + auth_conn: &mut mysql::Conn, + bot: &config::BotConfig, + character_count: u64, +) -> Result<()> { + use mysql::prelude::Queryable; + + let expected = u8::try_from(character_count).map_err(|_| { + anyhow!("Character count {character_count} exceeds realmcharacters capacity") + })?; + let actual: Option = auth_conn + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters for account {}: {e}", bot.account_id))?; + if actual != Some(expected) { + bail!( + "realmcharacters for account {} must remain {}, found {actual:?}; create-only provisioning will not rewrite it", + bot.account_id, + expected + ); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreateOnlyProvisioningPlan { + ValidateExisting, + CreateBoth, +} + +fn create_only_provisioning_plan( + bnet_exists: bool, + game_account_exists: bool, +) -> Result { + match (bnet_exists, game_account_exists) { + (true, true) => Ok(CreateOnlyProvisioningPlan::ValidateExisting), + (false, false) => Ok(CreateOnlyProvisioningPlan::CreateBoth), + (true, false) => bail!( + "Refusing create-only provisioning: a BNet identity already exists without the configured game account" + ), + (false, true) => bail!( + "Refusing create-only provisioning: the numeric game-account ID already exists without the configured BNet identity" + ), + } +} + fn game_account_username(account: &str) -> Result { let local_part = account .split('@') @@ -1461,52 +1836,186 @@ fn random_32() -> [u8; 32] { bytes } -fn ensure_local_bot_character_owner(conn: &mut mysql::Conn, bot: &config::BotConfig) -> Result<()> { +fn validate_local_bot_character_owner( + conn: &mut mysql::Conn, + bot: &config::BotConfig, +) -> Result<()> { use mysql::prelude::Queryable; - let owner = conn - .exec_first::( - "SELECT account FROM characters WHERE guid = ?", + let (owner, online, at_login) = conn + .exec_first::<(u32, u8, u16), _, _>( + "SELECT account, online, at_login FROM characters WHERE guid = ?", (bot.character_guid,), ) .map_err(|e| anyhow!("Lookup character {}: {e}", bot.character_guid))? .ok_or_else(|| anyhow!("No characters row for guid {}", bot.character_guid))?; if owner != bot.account_id { - warn!( - "[Bot {}] character GUID {} belonged to account {}; reassigning to test account", - bot.account_id, bot.character_guid, owner + bail!( + "Refusing to reassign character GUID {} from account {} to configured test account {}", + bot.character_guid, + owner, + bot.account_id + ); + } + if online != 0 || at_login != 0 { + bail!( + "Character GUID {} is not an offline clean fixture (online={online}, at_login={at_login})", + bot.character_guid ); - conn.exec_drop( - "UPDATE characters SET account = ? WHERE guid = ?", - (bot.account_id, bot.character_guid), - ) - .map_err(|e| anyhow!("Update owner for character {}: {e}", bot.character_guid))?; } Ok(()) } -fn sync_realm_character_count( +fn validate_exact_bot_identity( auth_conn: &mut mysql::Conn, - char_conn: &mut mysql::Conn, + mut character_conn: Option<&mut mysql::Conn>, bot: &config::BotConfig, ) -> Result<()> { use mysql::prelude::Queryable; - let count = char_conn - .exec_first::( - "SELECT COUNT(*) FROM characters WHERE account = ?", + let expected_email = bot_srp6::utf8_to_upper_only_latin_like_cpp(&bot.account); + let expected_username = game_account_username(&bot.account)?; + let bnet_rows: Vec<(u32, String, i8, Vec, Vec, u32, u8, u8)> = auth_conn + .exec( + "SELECT id, email, srp_version, salt, verifier, failed_logins, locked, online \ + FROM battlenet_accounts WHERE email = ? ORDER BY id", + (&expected_email,), + ) + .map_err(|e| anyhow!("Load exact BNet fixture {}: {e}", bot.account))?; + if bnet_rows.len() != 1 { + bail!( + "Expected exactly one BNet row for {}, found {}", + bot.account, + bnet_rows.len() + ); + } + let (bnet_id, email, srp_version, salt, verifier, failed_logins, locked, bnet_online) = + &bnet_rows[0]; + let (_, expected_verifier) = + bot_srp6::bnet_v1_verifier_for_salt_like_cpp(&bot.account, &bot.password, salt); + if !email.eq_ignore_ascii_case(&expected_email) + || *srp_version != 1 + || salt.len() != 32 + || *verifier != expected_verifier + || *failed_logins != 0 + || *locked != 0 + || *bnet_online != 0 + { + bail!( + "BNet fixture {} does not exactly match configured credentials/offline state", + bot.account + ); + } + + let account = auth_conn + .exec_first::<( + String, + String, + String, + Option, + Option, + u8, + u32, + u8, + u8, + ), _, _>( + "SELECT username, reg_mail, email, battlenet_account, battlenet_index, expansion, \ + failed_logins, locked, online FROM account WHERE id = ?", (bot.account_id,), ) - .map_err(|e| anyhow!("Count characters for account {}: {e}", bot.account_id))? + .map_err(|e| anyhow!("Load exact game-account fixture {}: {e}", bot.account_id))? + .ok_or_else(|| anyhow!("No game-account row for id {}", bot.account_id))?; + if !account.0.eq_ignore_ascii_case(&expected_username) + || !account.1.eq_ignore_ascii_case(&expected_email) + || !account.2.eq_ignore_ascii_case(&expected_email) + || account.3 != Some(*bnet_id) + || account.4 != Some(1) + || account.5 != 9 + || account.6 != 0 + || account.7 != 0 + || account.8 != 0 + { + bail!( + "Game account {} does not exactly match username/email/BNet/offline fixture contract", + bot.account_id + ); + } + let game_accounts_on_bnet: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM account WHERE battlenet_account = ?", + (*bnet_id,), + ) + .map_err(|e| anyhow!("Count game accounts on BNet fixture: {e}"))? .unwrap_or(0); - auth_conn - .exec_drop( - "REPLACE INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", - (count, bot.account_id, realm_id()), + if game_accounts_on_bnet != 1 { + bail!( + "BNet fixture {} must own exactly one game account, found {game_accounts_on_bnet}", + bot.account + ); + } + let bnet_bans: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM battlenet_account_bans WHERE id = ?", + (*bnet_id,), + ) + .map_err(|e| anyhow!("Check BNet fixture bans: {e}"))? + .unwrap_or(0); + let game_bans: u64 = auth_conn + .exec_first( + "SELECT COUNT(*) FROM account_banned WHERE id = ? AND active <> 0", + (bot.account_id,), ) - .map_err(|e| anyhow!("Sync realmcharacters for account {}: {e}", bot.account_id))?; + .map_err(|e| anyhow!("Check game-account fixture bans: {e}"))? + .unwrap_or(0); + if bnet_bans != 0 || game_bans != 0 { + bail!( + "Configured bot fixture is banned (bnet rows={bnet_bans}, active game rows={game_bans}); provisioning will not clear bans" + ); + } + + if let Some(char_conn) = character_conn.as_mut() { + validate_local_bot_character_owner(char_conn, bot)?; + let count: u64 = char_conn + .exec_first( + "SELECT COUNT(*) FROM characters WHERE account = ?", + (bot.account_id,), + ) + .map_err(|e| anyhow!("Count dedicated fixture characters: {e}"))? + .unwrap_or(0); + if count != 1 { + bail!( + "Loot fixture account {} must own exactly one character, found {count}", + bot.account_id + ); + } + let realm_count: Option = auth_conn + .exec_first( + "SELECT numchars FROM realmcharacters WHERE acctid = ? AND realmid = ?", + (bot.account_id, realm_id()), + ) + .map_err(|e| anyhow!("Load realmcharacters fixture count: {e}"))?; + if realm_count != Some(1) { + bail!( + "realmcharacters fixture count for account {} must be exactly 1, found {realm_count:?}", + bot.account_id + ); + } + } + Ok(()) +} + +fn validate_exact_loot_bot_identities(bots: &[config::BotConfig]) -> Result<()> { + let auth_opts = qa_mysql_opts(&auth_db_url()?, "auth")?; + let char_opts = qa_mysql_opts(&characters_db_url()?, "characters")?; + let mut auth_conn = + mysql::Conn::new(auth_opts).map_err(|e| anyhow!("Connect to auth DB failed: {e}"))?; + let mut character_conn = + mysql::Conn::new(char_opts).map_err(|e| anyhow!("Connect to characters DB failed: {e}"))?; + for bot in bots { + validate_exact_bot_identity(&mut auth_conn, Some(&mut character_conn), bot)?; + } Ok(()) } @@ -1518,6 +2027,25 @@ async fn main() -> Result<()> { info!("═══════════════════════════════════════════════════"); let cli = parse_cli()?; + if cli.recover_loot_fixture { + let conflicting_mode = cli.ensure_test_accounts + || cli.login_only + || cli.stand_state_smoke + || cli.bank_smoke + || cli.homebind_smoke + || cli.inventory_swap_smoke + || cli.rested_xp_smoke + || cli.loot_race_smoke + || cli.loot_item_capture + || cli.quest_smoke + || cli.single_account.is_some(); + if conflicting_mode { + bail!("--recover-loot-fixture must be used alone"); + } + loot_race::recover_pending_fixture().await?; + info!("Pending loot fixture recovered and cleanup marker written"); + return Ok(()); + } let app_config = config::AppConfig::load_or_create(&cli.config_path)?; let mut bots: Vec = app_config.get_enabled_bots().into_iter().cloned().collect(); @@ -1525,6 +2053,17 @@ async fn main() -> Result<()> { if let Some(account) = &cli.single_account { bots.retain(|bot| bot.account.eq_ignore_ascii_case(account)); } + if cli.loot_race_smoke || cli.loot_item_capture { + if cli.single_account.is_some() { + bail!( + "--single is incompatible with the loot workflows; select the guarded fixture with the loot account flags" + ); + } + bots.retain(|bot| { + bot.account.eq_ignore_ascii_case(&cli.loot_race_account_a) + || bot.account.eq_ignore_ascii_case(&cli.loot_race_account_b) + }); + } apply_password_overrides(&mut bots); if bots.is_empty() { @@ -1542,26 +2081,23 @@ async fn main() -> Result<()> { password_env_name(missing_passwords[0]) ); } - if cli.ensure_test_accounts { - let bots_for_db = bots.clone(); - tokio::task::spawn_blocking(move || ensure_test_accounts(&bots_for_db)) - .await - .map_err(|e| anyhow!("DB worker join failed while ensuring test accounts: {}", e))? - .map_err(|e| anyhow!("Failed to ensure test accounts: {}", e))?; - } + let loot_mode = cli.loot_race_smoke || cli.loot_item_capture; + validate_provisioning_mode(loot_mode, cli.ensure_test_accounts)?; let post_login_mode_count = [ cli.stand_state_smoke, cli.bank_smoke, cli.homebind_smoke, cli.inventory_swap_smoke, cli.rested_xp_smoke, + cli.loot_race_smoke, + cli.loot_item_capture, cli.quest_smoke, ] .into_iter() .filter(|enabled| *enabled) .count(); if post_login_mode_count > 1 { - bail!("stand-state, bank, homebind, inventory-swap, rested-xp, and quest smoke are separate post-login modes"); + bail!("stand-state, bank, homebind, inventory-swap, rested-xp, loot-race, loot-item-capture, and quest smoke are separate post-login modes"); } if cli.bank_smoke && bots.len() != 1 { bail!("--bank-smoke requires exactly one bot; select it with --single"); @@ -1600,6 +2136,38 @@ async fn main() -> Result<()> { cli.rested_xp_timeout_secs, current_epoch_secs(), )?; + let loot_race_cli = loot_race::LootRaceCli { + account_a: cli.loot_race_account_a.clone(), + account_b: cli.loot_race_account_b.clone(), + entry: cli.loot_race_creature_entry, + spawn_guid: cli.loot_race_creature_spawn_guid, + runtime_counter: cli.loot_race_runtime_counter, + item_entry: cli.loot_race_item_entry, + timeout_secs: cli.loot_race_timeout_secs, + workflow_deadline_secs: cli.loot_workflow_deadline_secs, + }; + loot_race::validate_cli( + cli.loot_race_smoke, + cli.loot_item_capture, + cli.ack_disposable_overworld_loot_race, + &bots, + &loot_race_cli, + )?; + if loot_mode { + loot_race::validate_journal_contract()?; + let bots_for_validation = bots.clone(); + tokio::task::spawn_blocking(move || { + validate_exact_loot_bot_identities(&bots_for_validation) + }) + .await + .map_err(|e| anyhow!("Loot identity-preflight DB worker join failed: {e}"))??; + } else if cli.ensure_test_accounts { + let bots_for_db = bots.clone(); + tokio::task::spawn_blocking(move || ensure_test_accounts(&bots_for_db)) + .await + .map_err(|e| anyhow!("DB worker join failed while provisioning test accounts: {e}"))? + .map_err(|e| anyhow!("Failed to provision test accounts: {e}"))?; + } let stand_state_options = if cli.stand_state_smoke { Some(stand_state_smoke_options_from_cli(&cli)?) } else { @@ -1646,6 +2214,10 @@ async fn main() -> Result<()> { "inventory-swap-smoke" } else if cli.rested_xp_smoke { "rested-xp-smoke" + } else if cli.loot_race_smoke { + "loot-race-smoke" + } else if cli.loot_item_capture { + "loot-item-capture" } else if cli.quest_smoke { "quest-smoke" } else if cli.login_only { @@ -1667,13 +2239,49 @@ async fn main() -> Result<()> { && !cli.homebind_smoke && !cli.inventory_swap_smoke && !cli.rested_xp_smoke + && !cli.loot_race_smoke + && !cli.loot_item_capture { cleanup_bot_group_state(&bots)?; } - let expected_bot_count = bots.len(); + let expected_bot_count = if cli.loot_item_capture { 1 } else { bots.len() }; let mut results = Vec::new(); - if cli.sequential || bots.len() == 1 { + if cli.loot_item_capture { + let shutdown = install_loot_termination_token()?; + results = finish_guarded_loot_result( + loot_race::run_single_item_capture_workflow( + bots, + loot_race_cli, + dungeon_id, + timeout_secs, + auto_teleport, + shutdown, + ) + .await, + ) + .await?; + for result in &results { + log_bot_summary(result, require_proposal, require_group, cli.login_only); + } + } else if cli.loot_race_smoke { + let shutdown = install_loot_termination_token()?; + results = finish_guarded_loot_result( + loot_race::run_workflow( + bots, + loot_race_cli, + dungeon_id, + timeout_secs, + auto_teleport, + shutdown, + ) + .await, + ) + .await?; + for result in &results { + log_bot_summary(result, require_proposal, require_group, cli.login_only); + } + } else if cli.sequential || bots.len() == 1 { for bot in bots { info!("\n[Bot {}] Starting...", bot.account); let run = if cli.bank_smoke { @@ -1733,6 +2341,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options.clone(), ) .await @@ -1770,6 +2379,7 @@ async fn main() -> Result<()> { None, None, None, + None, quest_options_for_bot, ) .await; @@ -1803,6 +2413,8 @@ async fn main() -> Result<()> { cli.homebind_smoke, cli.inventory_swap_smoke, cli.rested_xp_smoke, + cli.loot_race_smoke, + cli.loot_item_capture, cli.quest_smoke, &results, )?; @@ -1928,6 +2540,7 @@ async fn run_bot( homebind_options: Option, inventory_swap_options: Option, rested_xp_options: Option, + loot_race_options: Option, quest_options: Option, ) -> Result { let bot_index = bot.account_id as usize; @@ -2038,6 +2651,30 @@ async fn run_bot( rested_xp_db_rest_after: None, rested_xp_relog_verified: false, rested_xp_failure: None, + loot_race_smoke: loot_race_options.is_some(), + loot_race_smoke_passed: None, + loot_race_target_entry: loot_race_options + .as_ref() + .map(|options| options.target.entry), + loot_race_target_spawn_guid: loot_race_options + .as_ref() + .map(|options| options.target.spawn_guid), + loot_race_target_runtime_counter: loot_race_options + .as_ref() + .and_then(|options| options.resolved_runtime_counter().ok()), + loot_race_party_confirmed: false, + loot_race_target_discovered: false, + loot_race_loot_opened: false, + loot_race_loot_list_id: None, + loot_race_loot_coins: None, + loot_race_item_push_seen: false, + loot_race_loot_removed_seen: false, + loot_race_money_notify_amount: None, + loot_race_coin_removed_seen: false, + loot_race_db_item_total: None, + loot_race_db_money_delta: None, + loot_race_relog_verified: false, + loot_race_failure: None, quest_smoke: quest_options.is_some(), quest_smoke_passed: None, quest_target_entry: None, @@ -2157,15 +2794,19 @@ async fn run_bot( world_port() ); let world_addr = format!("{}:{}", world_host(), world_port()); - let mut stream = TcpStream::connect(&world_addr) - .await - .map_err(|e| anyhow!("Failed to connect to world server: {}", e))?; + let mut stream = + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, TcpStream::connect(&world_addr)) + .await + .map_err(|_| anyhow!("Timed out connecting to world server {world_addr}"))? + .map_err(|e| anyhow!("Failed to connect to world server: {}", e))?; info!("[Bot {}] ✅ TCP connected", bot_index); // ── Step 3: World Server Handshake ────────────────────────────────────── info!("[Bot {}] Step 3: Handshake...", bot_index); let mut init_buf = vec![0u8; 256]; - let n = stream.read(&mut init_buf).await?; + let n = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, stream.read(&mut init_buf)) + .await + .map_err(|_| anyhow!("Timed out reading SERVER_INIT"))??; if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { bail!( "Unexpected server init: {:?}", @@ -2174,13 +2815,22 @@ async fn run_bot( } info!("[Bot {}] ✅ SERVER_INIT received", bot_index); - stream.write_all(CLIENT_INIT).await?; - stream.flush().await?; + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, async { + stream.write_all(CLIENT_INIT).await?; + stream.flush().await + }) + .await + .map_err(|_| anyhow!("Timed out writing CLIENT_INIT"))??; info!("[Bot {}] ✅ CLIENT_INIT sent", bot_index); // ── Step 4: Read SMSG_AUTH_CHALLENGE ──────────────────────────────────── info!("[Bot {}] Step 4: Reading SMSG_AUTH_CHALLENGE...", bot_index); - let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + let (opcode, challenge_data) = tokio::time::timeout( + INITIAL_NETWORK_IO_TIMEOUT, + read_unencrypted_packet(&mut stream), + ) + .await + .map_err(|_| anyhow!("Timed out reading SMSG_AUTH_CHALLENGE"))??; if opcode != 0x3048 { bail!( "Expected SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", @@ -2341,17 +2991,28 @@ async fn run_bot( info!("[Bot {}] ✅ CMSG_PLAYER_LOGIN sent", bot_index); let mut login_ok = false; - let preserve_realm_connection = - stand_state_options.is_some() || homebind_options.is_some() || rested_xp_options.is_some(); - for _ in 0..30 { + let preserve_realm_connection = stand_state_options.is_some() + || homebind_options.is_some() + || rested_xp_options.is_some() + || loot_race_options.is_some(); + let mut loot_race_target_seen = false; + let login_budget = LoginVerifyBudget::new(LOGIN_VERIFY_TIMEOUT); + while let Some(read_timeout) = login_budget.next_read_timeout() { match tokio::time::timeout( - Duration::from_secs(5), + read_timeout, read_encrypted_packet(&mut stream, &mut crypt, &mut server_inflater), ) .await { Ok(Ok((op, payload))) => { result.seen_opcodes.push(format!("0x{:04X}", op)); + if let Some(options) = loot_race_options.as_ref() { + if let Some(counter) = loot_race::target_seen_in_update(options, op, &payload)? + { + loot_race_target_seen = true; + result.loot_race_target_runtime_counter = Some(counter); + } + } if let Some(options) = quest_options.as_ref() { record_quest_objective_login_signal(op, &payload, options, &mut result); } @@ -2412,7 +3073,9 @@ async fn run_bot( warn!("[Bot {}] Login read error: {}", bot_index, e); break; } - Err(_) => break, + // A quiet five-second slice does not invalidate an otherwise + // healthy login. The absolute deadline above remains the guard. + Err(_) => continue, } } if !login_ok { @@ -2540,6 +3203,35 @@ async fn run_bot( return Ok(result); } + if let Some(loot_race_options) = loot_race_options { + if let Err(error) = loot_race::run_phase( + bot_index, + &mut stream, + &mut crypt, + &mut server_inflater, + &mut realm_connection, + &loot_race_options, + loot_race_target_seen, + &mut result, + ) + .await + { + result.loot_race_failure = Some(error.to_string()); + result.loot_race_smoke_passed = Some(false); + loot_race::best_effort_close( + bot_index, + &mut stream, + &mut crypt, + &mut server_inflater, + &mut realm_connection, + loot_race_options.character_guid, + &mut result, + ) + .await; + } + return Ok(result); + } + if login_only { info!( "[Bot {}] ✅ Login-only smoke passed: world_auth=true enum_characters=true player_login=true", @@ -2817,6 +3509,29 @@ fn log_bot_summary( ); return; } + if result.loot_race_smoke { + info!( + "✅ Bot {}: SUCCESS loot_race target={:?}/{:?}/counter={:?} party={} discovered={} opened={} list={:?} coins={:?} item_push={} removed={} money_notify={:?} coin_removed={} db_item={:?} db_money_delta={:?} relog={} failure={:?}", + result.account, + result.loot_race_target_entry, + result.loot_race_target_spawn_guid, + result.loot_race_target_runtime_counter, + result.loot_race_party_confirmed, + result.loot_race_target_discovered, + result.loot_race_loot_opened, + result.loot_race_loot_list_id, + result.loot_race_loot_coins, + result.loot_race_item_push_seen, + result.loot_race_loot_removed_seen, + result.loot_race_money_notify_amount, + result.loot_race_coin_removed_seen, + result.loot_race_db_item_total, + result.loot_race_db_money_delta, + result.loot_race_relog_verified, + result.loot_race_failure, + ); + return; + } info!( "✅ Bot {}: SUCCESS login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", result.account, @@ -2930,6 +3645,29 @@ fn log_bot_summary( ); return; } + if result.loot_race_smoke { + error!( + "❌ Bot {}: FAILED loot_race target={:?}/{:?}/counter={:?} party={} discovered={} opened={} list={:?} coins={:?} item_push={} removed={} money_notify={:?} coin_removed={} db_item={:?} db_money_delta={:?} relog={} failure={:?}", + result.account, + result.loot_race_target_entry, + result.loot_race_target_spawn_guid, + result.loot_race_target_runtime_counter, + result.loot_race_party_confirmed, + result.loot_race_target_discovered, + result.loot_race_loot_opened, + result.loot_race_loot_list_id, + result.loot_race_loot_coins, + result.loot_race_item_push_seen, + result.loot_race_loot_removed_seen, + result.loot_race_money_notify_amount, + result.loot_race_coin_removed_seen, + result.loot_race_db_item_total, + result.loot_race_db_money_delta, + result.loot_race_relog_verified, + result.loot_race_failure, + ); + return; + } error!( "❌ Bot {}: FAILED login={{auth:{}, enum:{}, player:{}}} join={:?}/{:?} proposal={} group={} teleport_denied={:?}", result.account, @@ -2958,6 +3696,8 @@ fn write_report_if_requested( homebind_smoke: bool, inventory_swap_smoke: bool, rested_xp_smoke: bool, + loot_race_smoke: bool, + loot_item_capture: bool, quest_smoke: bool, results: &[BotRunResult], ) -> Result<()> { @@ -2979,6 +3719,8 @@ fn write_report_if_requested( homebind_smoke, inventory_swap_smoke, rested_xp_smoke, + loot_race_smoke, + loot_item_capture, quest_smoke, results: results.to_vec(), }; @@ -3770,6 +4512,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(wilderness_options), None, + None, ) .await?; if !combined.rested_xp_smoke_passed.unwrap_or(false) { @@ -3831,6 +4574,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(resting_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, resting_result); @@ -3903,6 +4647,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(consume_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, consume_result); @@ -3932,6 +4677,7 @@ async fn run_rested_xp_smoke_workflow_inner( None, Some(verify_options), None, + None, ) .await?; merge_rested_xp_results(&mut combined, verify_result); @@ -4040,6 +4786,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await; @@ -4071,6 +4818,7 @@ async fn run_bank_smoke_workflow( None, None, None, + None, ) .await { @@ -4139,6 +4887,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await; @@ -4204,6 +4953,7 @@ async fn run_homebind_smoke_workflow( None, None, None, + None, ) .await { @@ -5053,7 +5803,7 @@ async fn disconnect_rested_xp_and_wait( .await .context("send rested-XP CMSG_LOGOUT_REQUEST")?; info!("[Bot {}] ✅ rested-XP CMSG_LOGOUT_REQUEST sent", bot_index); - let logout_wait_secs = timeout_secs.min(RESTED_XP_GRACEFUL_LOGOUT_WAIT_SECS); + let logout_wait_secs = timeout_secs.min(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); let logout_deadline = tokio::time::Instant::now() + Duration::from_secs(logout_wait_secs); let client_clock_origin = tokio::time::Instant::now(); let mut logout_complete = false; @@ -5391,6 +6141,7 @@ async fn run_inventory_swap_smoke_workflow( Some(forward_options), None, None, + None, ) .await; @@ -5422,6 +6173,7 @@ async fn run_inventory_swap_smoke_workflow( Some(reverse_options), None, None, + None, ) .await { @@ -5660,7 +6412,8 @@ async fn logout_and_wait( ) -> Result<()> { send_encrypted_packet(stream, crypt, CMSG_LOGOUT_REQUEST, &[0]).await?; info!("[Bot {}] ✅ CMSG_LOGOUT_REQUEST sent", bot_index); - let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + let deadline = + tokio::time::Instant::now() + Duration::from_secs(NORMAL_LOGOUT_COMPLETE_WAIT_SECS); while tokio::time::Instant::now() < deadline { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); match tokio::time::timeout( @@ -9227,7 +9980,7 @@ fn build_player_login(guid: u64, realm_id: u32, far_clip: f32) -> Vec { fn create_player_guid_raw(guid: u64, realm_id: u32) -> (u64, u64) { // C++ ObjectGuid::Create(realmId, guid). - let high = (2u64 << 58) | ((u64::from(realm_id) & 0x1FFF) << 42); + let high = (2u64 << 58) | ((u64::from(realm_id) & 0xFFFF) << 42); (guid, high) } @@ -9611,6 +10364,40 @@ fn build_packed_guid(low: u64, high: u64) -> Vec { mod tests { use super::*; + #[test] + fn loot_result_requires_verified_relog_for_success() { + let mut result = BotRunResult { + world_auth: true, + enum_characters: true, + player_login_verified: true, + loot_race_smoke: true, + loot_race_smoke_passed: Some(true), + ..BotRunResult::default() + }; + + assert!(!result.success(false, false, false)); + + result.loot_race_relog_verified = true; + assert!(result.success(false, false, false)); + } + + #[test] + fn login_verify_budget_is_time_based_not_packet_count() { + let budget = LoginVerifyBudget::new(Duration::from_secs(1)); + + // The former `for _ in 0..30` guard disconnected a second concurrent + // client after 30 fast CREATE/broadcast packets. Observing packets must + // not consume the wall-clock budget. + for _ in 0..64 { + assert!(budget.next_read_timeout().is_some()); + } + + assert_eq!( + LoginVerifyBudget::new(Duration::ZERO).next_read_timeout(), + None + ); + } + fn pack_msb_fields(fields: &[(u32, usize)]) -> Vec { let mut bytes = Vec::new(); let mut current = 0u8; @@ -9819,7 +10606,7 @@ mod tests { } #[test] - fn creature_guid_uses_runtime_counter_and_zero_realm_like_cpp() { + fn legacy_creature_guid_constructor_keeps_counter_map_and_entry_fields() { let (low, high) = create_creature_guid_raw(571, 15_513, 77_001); assert_eq!(low, 77_001); @@ -10363,6 +11150,44 @@ mod tests { .to_string() .contains("invalid PlayerSave.Stats.MinLevel value")); } + + #[test] + fn pinned_instance_port_rejects_invalid_or_different_connect_to_target() { + assert!(validate_pinned_instance_port(8086, None).is_ok()); + assert!(validate_pinned_instance_port(8086, Some("8086")).is_ok()); + + let different = validate_pinned_instance_port(9000, Some("8086")) + .expect_err("a different advertised instance port must fail closed"); + assert!(different + .to_string() + .contains("advertised instance port 9000")); + assert!(validate_pinned_instance_port(8086, Some("0")).is_err()); + assert!(validate_pinned_instance_port(8086, Some("not-a-port")).is_err()); + } + + #[test] + fn loot_mode_rejects_generic_account_provisioning() { + assert!(validate_provisioning_mode(false, false).is_ok()); + assert!(validate_provisioning_mode(false, true).is_ok()); + assert!(validate_provisioning_mode(true, false).is_ok()); + let error = validate_provisioning_mode(true, true) + .expect_err("loot mode must reject provisioning before any DB mutation"); + assert!(error.to_string().contains("forbid --ensure-test-accounts")); + } + + #[test] + fn create_only_provisioning_rejects_partial_identity_collisions() { + assert_eq!( + create_only_provisioning_plan(false, false).unwrap(), + CreateOnlyProvisioningPlan::CreateBoth + ); + assert_eq!( + create_only_provisioning_plan(true, true).unwrap(), + CreateOnlyProvisioningPlan::ValidateExisting + ); + assert!(create_only_provisioning_plan(true, false).is_err()); + assert!(create_only_provisioning_plan(false, true).is_err()); + } } fn build_quest_giver_query_quest(packed_guid: &[u8], quest_id: u32) -> Vec { @@ -10452,6 +11277,9 @@ async fn connect_to_instance( ); } + let expected_port = std::env::var("INSTANCE_PORT").ok(); + validate_pinned_instance_port(connect_to.port, expected_port.as_deref())?; + let target_host = std::env::var("INSTANCE_HOST").unwrap_or_else(|_| connect_to.address.to_string()); let addr = format!("{}:{}", target_host, connect_to.port); @@ -10459,12 +11287,15 @@ async fn connect_to_instance( "[Bot {}] Connecting to instance socket {}...", bot_index, addr ); - let mut stream = TcpStream::connect(&addr) + let mut stream = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, TcpStream::connect(&addr)) .await + .map_err(|_| anyhow!("Timed out connecting to instance socket {addr}"))? .map_err(|e| anyhow!("Failed to connect to instance socket {}: {}", addr, e))?; let mut init_buf = vec![0u8; 256]; - let n = stream.read(&mut init_buf).await?; + let n = tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, stream.read(&mut init_buf)) + .await + .map_err(|_| anyhow!("Timed out reading instance SERVER_INIT"))??; if !init_buf[..n].starts_with(&SERVER_INIT[..SERVER_INIT.len().min(n)]) { bail!( "Unexpected instance server init: {:?}", @@ -10472,10 +11303,19 @@ async fn connect_to_instance( ); } - stream.write_all(CLIENT_INIT).await?; - stream.flush().await?; + tokio::time::timeout(INITIAL_NETWORK_IO_TIMEOUT, async { + stream.write_all(CLIENT_INIT).await?; + stream.flush().await + }) + .await + .map_err(|_| anyhow!("Timed out writing instance CLIENT_INIT"))??; - let (opcode, challenge_data) = read_unencrypted_packet(&mut stream).await?; + let (opcode, challenge_data) = tokio::time::timeout( + INITIAL_NETWORK_IO_TIMEOUT, + read_unencrypted_packet(&mut stream), + ) + .await + .map_err(|_| anyhow!("Timed out reading instance SMSG_AUTH_CHALLENGE"))??; if opcode != 0x3048 { bail!( "Expected instance SMSG_AUTH_CHALLENGE (0x3048), got 0x{:04X}", @@ -10528,6 +11368,26 @@ async fn connect_to_instance( Ok((stream, WorldCrypt::new_with_counters(&enc_key, 2, 2))) } +fn validate_pinned_instance_port(advertised_port: u16, expected_port: Option<&str>) -> Result<()> { + let Some(expected_port) = expected_port else { + return Ok(()); + }; + let expected_port = expected_port + .parse::() + .with_context(|| "INSTANCE_PORT must be a valid nonzero TCP port")?; + if expected_port == 0 { + bail!("INSTANCE_PORT must be a valid nonzero TCP port"); + } + if advertised_port != expected_port { + bail!( + "SMSG_CONNECT_TO advertised instance port {}, expected pinned INSTANCE_PORT {}", + advertised_port, + expected_port + ); + } + Ok(()) +} + /// Pack u64 into WoW's packed format (mask + non-zero bytes) fn pack_u64(value: u64) -> (u8, Vec) { let mut mask = 0u8;