diff --git a/.cargo/config.toml b/.cargo/config.toml index 023168790..aef786295 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,14 +1,3 @@ -# The Module is the only crate here that builds for wasm32-unknown-unknown, and its Runtime Script -# Host pulls piccolo -> ahash -> getrandom 0.3, which has NO backend on that target and refuses to -# compile without one. getrandom 0.3 selects its backend by cfg, not by Cargo feature, so this file -# is the only place the choice can live. -# -# "custom" means the Module supplies the implementation itself — see `runtime_script::fixed_entropy` -# for the function and for why it is a fixed stream. The alternative, getrandom's `wasm_js` backend -# (what the Runtime Script Prototype used to get a compile), emits wasm-bindgen imports that a SpacetimeDB host -# cannot resolve, so a module built that way fails to instantiate. -# -# A `RUSTFLAGS` environment variable overrides this table wholesale. That failure is loud (getrandom -# stops compiling), never silent. +# SpacetimeDB cannot resolve wasm-bindgen imports; use the Module's custom entropy backend. [target.wasm32-unknown-unknown] rustflags = ['--cfg', 'getrandom_backend="custom"'] diff --git a/.claude/skills/fan-out/SKILL.md b/.claude/skills/fan-out/SKILL.md index efe8c728f..84790af2a 100644 --- a/.claude/skills/fan-out/SKILL.md +++ b/.claude/skills/fan-out/SKILL.md @@ -52,8 +52,12 @@ Match model to ticket difficulty: the strongest tier for the tracer and the inte Work the **frontier** — the tickets whose blockers are all done: 1. Run the tracer first, alone, and integrate it. -2. Spawn one agent per unblocked ticket, concurrently, each in its own worktree so they cannot collide. Brief each agent to read the folder's `README.md` plus its own ticket file, then `/implement` it. -3. Integrate the parallel branches, then run the integration ticket serially on the combined result. +2. Spawn one agent per unblocked ticket, concurrently, each in its own worktree so they cannot collide. + Brief each agent to read its checkout's `AGENTS.md`, the folder's `README.md` and its own ticket, + then `/implement` it. Require the + [comment-review result](../../../CODING_STANDARDS.md#comment-review) in its handoff. +3. Integrate the parallel branches, then run the integration ticket serially on the combined result, + including its comment review. If the harness cannot spawn agents, work the frontier yourself in the same order. diff --git a/.claude/skills/file-pr/SKILL.md b/.claude/skills/file-pr/SKILL.md index 4db1a8618..cfd0d4da7 100644 --- a/.claude/skills/file-pr/SKILL.md +++ b/.claude/skills/file-pr/SKILL.md @@ -9,6 +9,9 @@ metadata: Before filing, check whether a PR for this branch already exists. Review the diff locally against `origin/main` to make sure its contents match the goal. +Complete the [comment review](../../../CODING_STANDARDS.md#comment-review) on that final diff and +include its scope and result under verification in the PR description. + PR titles usually become commit messages, so follow the repository's title conventions. Look at recently merged PRs and Git history for examples. Prefer a concise, human-readable title that explains why the change matters: BAD diff --git a/.claude/skills/orchestrate-backlog/AGENT-BRIEFING.md b/.claude/skills/orchestrate-backlog/AGENT-BRIEFING.md index 12e8d8b6e..64aa2fcf4 100644 --- a/.claude/skills/orchestrate-backlog/AGENT-BRIEFING.md +++ b/.claude/skills/orchestrate-backlog/AGENT-BRIEFING.md @@ -9,8 +9,11 @@ Lines to carry in every implementer dispatch prompt. The orchestrator pastes wha ## Verification +- Read `AGENTS.md` in your checkout. Complete the + [comment review](../../../CODING_STANDARDS.md#comment-review) and include its scope and result in + your handoff. - Before excepting any failing test, confirm it fails identically on the unmodified base commit; report it as pre-existing with that evidence. -- Format touched files individually (`rustfmt ` and inspect the diff). Repo-wide formatting rewrites ~75 files of pre-existing drift. +- Format the files you change and run the formatting check in `docs/testing.md`. - Zero new clippy warnings in touched files; wasm check for module changes. ## Scope discipline diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 8a792561b..40d2602af 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -48,6 +48,8 @@ changed lines. It must assess both the Spec and repository standards, and return - Concrete failure cases with file locations and supporting evidence. Separate demonstrated defects from hypotheses that need a check. - The smallest checks that could settle disputed behavior or missing coverage. +- The [comment-review result](../../../CODING_STANDARDS.md#comment-review), with its scope and file + locations for findings. - Whether it is confident the outcome is complete, what supports that judgment, and what could still make the solution wrong. A clean diff or passing unit suite alone does not establish this. diff --git a/.claude/skills/verify-lyracore/drive-benilla.sh b/.claude/skills/verify-lyracore/drive-benilla.sh index edc9b20f6..67461e261 100755 --- a/.claude/skills/verify-lyracore/drive-benilla.sh +++ b/.claude/skills/verify-lyracore/drive-benilla.sh @@ -1,13 +1,4 @@ #!/usr/bin/env bash -# Run one scripted Benilla session against a LyraCore Realm and keep its evidence. -# -# BENILLA_DIR= WOW_DATA=<1.12.1 Data dir> \ -# WOW_USER=TEST WOW_PASS=test123 WOW_CHAR=Tester \ -# WOW_PROBE_CHAT='.tele goldshire' WOW_PROBE_LUA='ProbeLog(GetZoneText())' \ -# .claude/skills/verify-lyracore/drive-benilla.sh [exit-after-secs] -# -# Every other WOW_* variable passes through to Benilla. Output: $EVIDENCE (default -# /tmp/lyracore-verify/) gets benilla.log, probe.log and shot.png. Exit code is Benilla's. set -uo pipefail : "${BENILLA_DIR:?set BENILLA_DIR to a Benilla checkout}" @@ -16,7 +7,7 @@ secs=${1:-60} out=${EVIDENCE:-/tmp/lyracore-verify/$(date -u +%Y%m%dT%H%M%SZ)} mkdir -p "$out" -# BENILLA_HOME keeps the run off the operator's own benilla-config (settings, saved variables). +# BENILLA_HOME isolates this run from the Operator's saved settings. scratch=$(mktemp -d) trap 'rm -rf "$scratch"' EXIT diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 07c0589c4..aea4aade8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,3 @@ -# Blank issues stay enabled on purpose: the maintainers file most of the queue themselves, in the -# expected/actual/how it shows/fix direction shape CONTRIBUTING.md describes, and the forms are -# aimed at people reporting from the outside. blank_issues_enabled: true contact_links: - name: Troubleshooting guide diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 497cdfda1..d1e3dabef 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -41,6 +41,7 @@ In `.github/workflows/runtime-scripts.yml`, when the change reaches `datascripts Other checks: +- [ ] Comment review per `CODING_STANDARDS.md#comment-review`: state the scope and result. - [ ] Module or schema changes: a plain `spacetime publish` against your local stack migrates cleanly (**never** `publish -c`) - [ ] `shellcheck --severity=warning` on any shell script you changed - [ ] Markdown links you added resolve diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 9b3ec8d96..b99ba9d96 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -1,8 +1,5 @@ name: Module durable tests and Gateway bindings -# Durable tests start private SpacetimeDB instances and publish the Module with test fixtures. -# The Rust workflow's --lib run does not execute these ignored integration tests. - on: pull_request: push: @@ -16,7 +13,6 @@ permissions: contents: read jobs: - # Balance measured target durations, reserving time for each job's builds and Gateway checks. durable: runs-on: ubuntu-latest timeout-minutes: 30 @@ -40,16 +36,10 @@ jobs: if: matrix.shard != 2 run: ./lyracore bindings - # Build failures report before any fixture starts. Each test process asks Cargo for the - # current artifact, caches its bytes, and publishes private copies through --bin-path. - name: Build the module wasm run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers,package_test_fixture - # Each test owns a whole database process, so the thread cap is a memory bound, not a CPU one. - # - # Deadmines fixtures need the separately installed Package's reducers. - # `lethal_floor`'s ranged phase races the module's own cast-event sweep and health regen, and - # is flaky on `main` with or without this change. + # Each test owns a standalone process; the thread limit bounds memory use. - name: Run the durable suite, shard ${{ matrix.shard }} of 3 run: | mapfile -t targets < <(python3 scripts/durable-test-targets.py "${{ matrix.shard }}") @@ -84,7 +74,6 @@ jobs: if: matrix.shard == 0 run: cargo test --locked -p lyracore-gateway --bin lyracore-gateway benilla_durable_ -- --ignored --test-threads=1 - # No Package registers a client command here, so the Module's fixture command carries it. - name: Run the cross-Shard command transport tests if: matrix.shard == 1 run: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::party_command_durable_tests -- --ignored --test-threads=1 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7460266ff..a91998982 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,12 +1,5 @@ name: Rust -# The Rust gates from docs/danger-zones.md, verbatim, plus the wasm compile that only the -# deploy-time preflight covered before. Nothing here needs a SpacetimeDB node: the module tests that -# do are integration tests, and `--lib` leaves them out. -# -# rustup reads rust-toolchain.toml and installs the pinned toolchain, rustfmt, clippy and the -# wasm32-unknown-unknown target on the first cargo call, so nothing here repeats the version. - on: pull_request: push: @@ -27,8 +20,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check formatting run: cargo fmt --all -- --check - # The SpacetimeDB build aborts on a print-family macro anywhere in Module source, even in a - # comment or a test. Use `writeln!(std::io::stderr(), ...)` for a test diagnostic. + # SpacetimeDB scans comments and test code for forbidden print macros. - name: Check for print macros in Module source run: | roots=(module/src) @@ -58,7 +50,6 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - uses: ./.github/actions/setup-spacetime - run: ./lyracore bindings - # Share compiled dependencies while keeping each crate's result separate. - run: cargo test -p lyracore-importer - run: cargo test -p lyracore-module --lib --features=debug_reducers - run: cargo test -p lyracore-module --test build_script @@ -74,16 +65,13 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - # `lyracore publish` builds this feature set. A default-feature check would omit published reducers. - run: cargo check --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers - # A Core change that breaks an official Package fails here, before the Package's next publish. official-packages: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # A paired packages PR uses the same branch name; build against it when it exists. - id: packages-ref timeout-minutes: 2 env: diff --git a/.gitignore b/.gitignore index 035914bc5..22c320565 100644 --- a/.gitignore +++ b/.gitignore @@ -1,67 +1,40 @@ /target **/*.rs.bk -# Local lifecycle state, logs, generated Gateway bindings, process ownership locks, and tool caches: the `lyracore` -# CLI (.lyracore/cli//) and the wire harness (.lyracore/wire-harness//, #246 — a checkout -# of LyraCoreProject/wire-harness plus its own target/, which is why this must stay ignored). /.lyracore/ -# Operator-pulled third-party data (cmangos classic-db) + generated bundles — NEVER committed (licensing firewall) /.import/ -# Blizzard client data — extracted DBCs / MPQ archives are COPYRIGHTED and MUST NEVER be committed. -# The importer parses the operator's own client MPQs IN MEMORY and emits only derived game_* values; -# this is a backstop in case a stray extract is ever written to the tree (the code never writes one). +# Client archives and extracted game data must stay local. **/*.dbc **/*.MPQ **/*.mpq DBFilesClient/ -# Runtime logs (gateway/wine stderr captured by the wow-mcp harness) — never committed. gateway/gateway-stderr.log *-stderr.log -# Stray `cargo doc` output at repo root. /doc/ -# Heap-profiling output from the `dhat-heap` feature (work-item 292, Runbook C). dhat-heap.json __pycache__/ -# cargo-mutants report output. Regenerated on every run, so it is never committed. This repository -# carries no mutants configuration file; the maintainers run cargo-mutants from their own repository. /mutants.out/ /mutants.out.old/ .scratch/ -# Research notes stay local. /docs/research/ /docs/verification/*-research.md -# Agent-created Git worktrees are local execution state, not repository content. /.claude/worktrees/ -# Local fan-out briefs are agent execution state, not repository content. /.claude/tickets/ -# The Datascript toolchain's Bun project. `package.json`, `bun.lock`, `tsconfig.json` and the -# Datascripts under `src/` ARE committed — the pin and the code are repository content. -# -# `generated/` is not: it is a ~400-file, 2 MB projection of the Module wasm that -# `lyracore packages build` reproduces from `spacetime generate --lang typescript` on every run. -# Committing it would put a large mechanical diff in every schema change and, worse, create a -# second source of truth that can disagree with the Module. The Module is the schema's authority. -# -# `generated/base-snapshot.json` rides the same rule for a second reason: the importer derives it -# from the OPERATOR's own client data (`--spell-snapshot`). It carries only mapped `game_*` values, -# never client bytes, but it is the operator's own derived data and stays on the operator's machine. /datascripts/generated/ /datascripts/node_modules/ /datascripts/runtime-scripts/node_modules/ -# Installed Packages are local to this checkout. Reference Packages live in the collection. /packages/* !/packages/README.md -# Generated Package output stays local. The collection may commit Script Artifacts, never Deltas. /packages/*/data/.generated/ diff --git a/AGENTS.md b/AGENTS.md index ff812401c..0daa353e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,9 @@ We build complex things as simply as possible, and we look for ways to remove co - Boy scout rule: fix defects in code you already change. Exceptions: a fix that spreads to other files, or that changes behaviour the user must weigh. Name those in one line and carry on. Fix everything else, even when it feels too big. That feeling reads high. -- Legacy code carries essay comments and issue references. Match its naming and idiom, not its - comment density. +- Apply [the comment policy](CODING_STANDARDS.md#comments-and-documentation) when writing or reviewing + source, tests, scripts and configuration. Complete its + [comment review](CODING_STANDARDS.md#comment-review) before handoff. ## Words @@ -32,6 +33,8 @@ We build complex things as simply as possible, and we look for ways to remove co ownership up front. Choose each subagent's model and effort for its subtask: a cheaper model for scoped work, the top tier for hard design, concurrency or adversarial review. The spawning agent owns that choice. +- Require implementation and review subagents to read `AGENTS.md` in their checkout and return the + comment-review result. Review the combined diff after integrating parallel work. - Touch production or a live database only when the user tells you to or confirms it. Before you touch anything adjacent to one, name it. diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 206538084..93d333c26 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -107,13 +107,45 @@ Use this order when two good practices compete: ## Comments and documentation -- Comments explain why a constraint exists, what an interface promises, or which non-obvious protocol - fact controls the code. Let names and types explain ordinary mechanics. -- Keep interface comments concise but complete about invariants, ordering, failure modes, and important - performance characteristics. -- Do not put issue numbers in code comments. Record durable reasoning in the comment or an ADR so it - remains understandable without tracker history. -- Update or remove nearby comments and docs when behavior changes. Stale explanation is a defect. +- Write code without explanatory comments by default. Let names, types and tests carry ordinary + mechanics. This applies to source, tests, scripts, configuration and documentation comments such + as Rust's `///` and `//!`. +- Keep concise interface contracts where names and types leave caller obligations, return values or + failure behavior unclear. Keep non-obvious local technical facts: opcodes, packet layouts, regexes, + units, constants, compatibility quirks, and safety, atomicity or ordering constraints. +- Use one short line within the normal line width. Put longer contracts and reasoning in the relevant + document and leave a short pointer beside the interface. A pointer names the document and when to + read it. Preserve these pointers when removing nearby explanation; do not flatten an essay onto one + line or split it into adjacent one-line comments. +- Preserve comments that tools consume, including script metadata, lint directives and editor type + annotations. Preserve interpreter directives, required attribution and generated-file notices. + These follow the format their consumer requires, including multiple lines when necessary. +- Remove narration, section dividers, commented-out code, tracker history and descriptions that repeat + the adjacent code. Record work still needed in the tracker. +- Keep durable design reasoning that needs more than one line in the relevant existing document or + an ADR. Check it against the code before moving it; deletion is appropriate for stale or redundant + explanation. Standalone documentation remains part of the project. +- Change generated comments through their generator when we own it. Leave third-party generated + output alone. Comment-like text in strings and test fixtures is data; preserve its meaning. +- Update or remove a retained comment when behavior changes. Review exceptions for their technical + content, not merely their length. Apply this policy equally to human and agent contributions. + +### Comment review + +Before handing off changes or completing a review: + +- Inspect the final diff with surrounding code, including new files. Review added and changed comments + and retained comments whose code changed in source, tests, scripts and configuration. +- Match each reviewed comment to a policy exception and check its claim against the implementation. + Check that documentation pointers resolve. Remove narration even when it fits on one line. +- Check removed comments for lost contracts, safety constraints, tool annotations and documentation + pointers. Preserve required meaning in code, a permitted comment or a linked document. +- Repeat the review for subsequent edits. In the handoff, state the scope checked and whether it + passed, has findings with file locations, or does not apply because no source, tests, scripts or + configuration changed. Cite retained or relocated contracts when they need reviewer judgment. + +Formatting and tests do not establish that a comment is necessary or true; report this review +separately from those checks. ## Completion and review diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6211bfd9f..8e0dac999 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,11 @@ Three documents are required reading: 2. [`CODING_STANDARDS.md`](CODING_STANDARDS.md): how new and changed code is written. 3. [`CORE_TERMS.md`](CORE_TERMS.md): the terms the code, the comments and the reviews use. +Human and agent contributions follow the same +[comment policy](CODING_STANDARDS.md#comments-and-documentation). Code has no explanatory comments by +default; the policy defines which contracts, technical facts, documentation pointers and tool +annotations belong beside it. + Look everything else up when a change needs it: - [`docs/quickstart.md`](docs/quickstart.md): from a clone to a connected client. diff --git a/Cargo.toml b/Cargo.toml index 9527c1c07..4a4727ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,31 +8,17 @@ members = [ "gateway", "importer", ] -# The headless build-5875 wire client is deliberately not a member. It lives in its own repository, -# LyraCoreProject/wire-harness, and is consumed as a pinned release; `.wire-harness-rev` holds the -# pin and `lyracore dev smoke` resolves it. It is a server-agnostic protocol tool, and a workspace -# membership was the coupling that made "does this run against any other 5875 core?" unanswerable. -# The SpacetimeDB module compiles to wasm and pulls its own (large) dependency tree; -# keep workspace-wide settings minimal so the module and the native gateway can diverge. [workspace.package] edition = "2021" license = "MIT OR Apache-2.0" -# Two facts set this floor, both re-checked at the 2.7.1 bump: every `spacetimedb-*` crate declares -# `rust-version = "1.93.0"`, and they declare `edition = "2024"`, which Cargo below 1.85 cannot even -# parse. So the real MSRV equals the toolchain pinned in `rust-toolchain.toml` and there is nothing -# lower to test. Keep the two numbers equal. Lowering this needs a build attempt, not an assumption. +# Keep this floor equal to rust-toolchain.toml; SpacetimeDB 2.7.1 requires Rust 1.93.0. rust-version = "1.93.0" repository = "https://github.com/LyraCoreProject/LyraCore" homepage = "https://github.com/LyraCoreProject/LyraCore" -# Every crate here is internal to the LyraCore server. None is a library anyone should depend on from -# crates.io, so the whole workspace is publish-gated once rather than crate by crate. publish = false -# A function above the threshold in `clippy.toml` no longer fits in one head. Existing warnings -# mark untouched long functions; changed code stays below the 200-line ceiling. Lower it as those -# functions shrink. [workspace.lints.clippy] too_many_lines = "warn" diff --git a/clippy.toml b/clippy.toml index 04a404c3f..f5522e678 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,5 +1 @@ -# `too_many_lines` (warn, workspace-wide) holds a ceiling rather than describing today's code: the -# default 100 fires on 140 pre-existing functions and buries every real finding. 200 is the size the -# split of the four longest production functions targeted, so only untouched code exceeds it. Lower -# it as those shrink. too-many-lines-threshold = 200 diff --git a/crates/lyracore-package-delta/Cargo.toml b/crates/lyracore-package-delta/Cargo.toml index c288dee23..a1170f0d0 100644 --- a/crates/lyracore-package-delta/Cargo.toml +++ b/crates/lyracore-package-delta/Cargo.toml @@ -9,17 +9,9 @@ homepage.workspace = true publish.workspace = true description = "LyraCore: the versioned Package Delta artifact and the claim conflict tracer." -# The LIBRARY is pure and deterministic, like `lyracore-shared`: no IO, no clock, no randomness. -# `serde_json` is the one dependency and is used ONLY to tokenize untrusted JSON; the canonical -# writer is hand-built so member order, whitespace and number formatting are this crate's promise, -# not a library's default. [dependencies] serde_json = "1" -# The author-facing check: parse a set of artifact files, trace them together, print the plan. It is -# a bin rather than part of the library so the file reading stays out of everything that links this -# crate — including the wasm module, which never builds a dependency's bins. It carries no client -# data and no database client, so `lyracore packages build` can run it in under a second. [[bin]] name = "lyracore-delta-check" path = "src/bin/lyracore-delta-check.rs" diff --git a/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs b/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs index 52d76e205..b094c0bca 100644 --- a/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs +++ b/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs @@ -1,26 +1,3 @@ -//! `lyracore-delta-check ...` — parse a set of generated Package artifacts, trace -//! them together, and print the plan they form. -//! -//! Two artifact kinds arrive, because a Package ships every kind it has into one generated -//! directory: Package Deltas and Script Artifacts. Each file is routed on its own `kind` member and -//! traced with its own kind, and both traces have to come out clear. -//! -//! This is the check a Datascript author gets after a build, before anything reaches a shard. It -//! needs no client data, no database and no realm: it reads the named files and nothing else, which -//! is why it can run on any machine in under a second. -//! -//! Exit status is the whole answer: 0 when every artifact parses and the Packages agree, 1 when an -//! artifact is refused, two Packages claim the same column of one row, or two Packages claim one -//! Package twice, or two Packages claim one Runtime Script identity. The output names the file and -//! the exact conflict in every case, -//! because the reader is the author who has to fix one line of a Datascript or one script file. -//! -//! Trace ALL of a realm's enabled artifacts in one invocation. Conflicts exist BETWEEN Packages, so -//! checking one file at a time can only ever prove that one file parses. -//! -//! `lyracore-delta-check --print-events` is a second, unrelated mode: it prints the Event Binding -//! catalogue and exits, reading no file and tracing nothing. See [`print_events`]. - use std::path::Path; use std::process::ExitCode; @@ -109,7 +86,6 @@ fn main() -> ExitCode { ExitCode::FAILURE } -/// One generated artifact, already routed to the parser its own `kind` chose. enum Artifact { Delta(PackageDelta), Script(ScriptArtifact), @@ -131,13 +107,6 @@ fn print_events(args: &[String]) -> ExitCode { ExitCode::SUCCESS } -/// Read one file and hand it to the parser its own `kind` names. -/// -/// Routing on `kind` rather than on the file name: a Package chooses its own artifact file names, -/// and the member inside the file is the only thing that says what it holds. A kind this build does -/// not read reaches [`PackageDelta::parse`], which refuses it by name. -/// -/// A refusal names the FILE, so an author with several Packages open knows which one to fix. fn read_artifact(path: &Path) -> Result { let json = std::fs::read_to_string(path) .map_err(|e| format!("{}: cannot read: {e}", path.display()))?; @@ -149,9 +118,6 @@ fn read_artifact(path: &Path) -> Result { .map_err(refuse) } -/// The plan: every Package with its file and row counts, then every claimed row, then every -/// conflict. The same shape the importer's Package Delta stage prints, so an author and an operator -/// read the same report. fn report( delta_paths: &[&String], deltas: &[PackageDelta], diff --git a/crates/lyracore-package-delta/src/canonical.rs b/crates/lyracore-package-delta/src/canonical.rs index bc46f0408..303fd5d55 100644 --- a/crates/lyracore-package-delta/src/canonical.rs +++ b/crates/lyracore-package-delta/src/canonical.rs @@ -1,22 +1,3 @@ -//! The canonical form of a Package Delta. -//! -//! Two artifacts that say the same thing must produce the same bytes, so that a source hash, a diff -//! and a signature all mean what they look like they mean. The writer is hand-built rather than -//! derived, because the byte stability is this crate's promise and must not move under a library's -//! formatting default. -//! -//! The rules, all of them: -//! -//! * No whitespace anywhere, and no trailing newline. -//! * Object members appear in a fixed declared order; `fields` members appear sorted by name. -//! * Claims appear sorted by table, then spell, then effect index. -//! * Integers are plain decimal. An integer column written as `100.0` or `1e2` is refused at the -//! parse rather than rounded here, so there is only ever one spelling to write. -//! * An unsigned 64-bit value is a decimal string with no sign, no padding and no separators. -//! * A float is the shortest decimal that reads back as the same `f32`, always with a decimal -//! point, so `1`, `1.0` and `1.00` all become `1.0`. -//! * A string escapes only what JSON requires, using the short escape where one exists. - use core::fmt; use crate::delta::{Claim, PackageDelta, PrimaryKey, DELTA_VERSION}; @@ -60,9 +41,6 @@ fn write_claim(out: &mut String, claim: &Claim) { out.push_str("}}"); } -// One arm per key shape, and no wildcard: the canonical member order IS this crate's promise, so a -// key that arrives without stating its order must fail the build rather than fall into a fallback. -// That is what makes this function long; splitting it would trade the guarantee for the line count. #[allow(clippy::too_many_lines)] fn write_key(out: &mut String, key: PrimaryKey) { match key { @@ -186,9 +164,6 @@ fn write_key(out: &mut String, key: PrimaryKey) { } } -/// A key object, its members in the order the key declares them. That order is part of the -/// canonical form: two artifacts that name the same row have to hash the same, so it is fixed here -/// rather than left to whatever a generator emitted. fn write_members(out: &mut String, members: &[(&str, u64)]) { out.push('{'); for (index, (name, value)) in members.iter().enumerate() { @@ -210,8 +185,6 @@ fn write_value(out: &mut String, value: &FieldValue) { out.push('}'); } -/// One claimed value in its canonical JSON spelling, without the surrounding type tag. Shared with -/// `FieldValue`'s `Display`, so a conflict report quotes a value exactly as the artifact writes it. pub(crate) fn scalar_literal(value: &FieldValue) -> String { let mut out = String::new(); match value { @@ -231,9 +204,6 @@ pub(crate) fn scalar_literal(value: &FieldValue) -> String { out } -/// `f32` `Display` already gives the shortest decimal that reads back as the same value and never -/// uses exponent form. The only thing missing is a decimal point on a whole number, which keeps a -/// float column visually and textually distinct from an integer one. fn write_f32(out: &mut String, value: f32) { let text = value.to_string(); out.push_str(&text); diff --git a/crates/lyracore-package-delta/src/delta.rs b/crates/lyracore-package-delta/src/delta.rs index 817312281..0c79954ae 100644 --- a/crates/lyracore-package-delta/src/delta.rs +++ b/crates/lyracore-package-delta/src/delta.rs @@ -1,9 +1,3 @@ -//! The Package Delta artifact: its types, its one refusing constructor per level, and the parser -//! that builds it from untrusted JSON. -//! -//! Nothing in this module writes anywhere. A `PackageDelta` that exists has already passed every -//! check the artifact promises, so an applier reads it without re-deciding anything. - use std::collections::BTreeMap; use std::fmt; @@ -29,22 +23,12 @@ use crate::ids::{ }; use crate::schema::{FieldType, FieldValue, Table}; -/// The artifact version this build reads and writes. pub const DELTA_VERSION: u64 = 1; -/// The identity of the Package a claim came from. -/// -/// Lowercase on purpose: two Packages differing only in case would canonicalize to two rows that -/// look like the same Package in a conflict report. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PackageId(String); impl PackageId { - /// Accepts 1 to 64 characters of `a-z`, `0-9`, `-`, `_` or `.`. - /// - /// # Errors - /// [`DeltaError::InvalidPackageId`] when the identity is empty, too long, or uses any other - /// character. pub fn new(id: impl Into) -> Result { let id = id.into(); let shaped = !id.is_empty() @@ -59,7 +43,6 @@ impl PackageId { } } - /// The identity as written. #[must_use] pub fn as_str(&self) -> &str { &self.0 @@ -72,20 +55,10 @@ impl fmt::Display for PackageId { } } -/// The digest of the Datascript source a Package Delta was generated from. -/// -/// The artifact only carries it; nothing here recomputes it. It exists so a replay can tell that a -/// Package's source changed without diffing every claim, and so a conflict report can name the -/// source that produced a claim. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SourceHash(String); impl SourceHash { - /// Accepts exactly 64 lowercase hexadecimal characters, the shape of the BLAKE3 digest the - /// importer already uses. - /// - /// # Errors - /// [`DeltaError::InvalidSourceHash`] for any other length or character. pub fn new(hex: impl Into) -> Result { let hex = hex.into(); let shaped = hex.len() == 64 @@ -99,7 +72,6 @@ impl SourceHash { } } - /// The digest as written. #[must_use] pub fn as_str(&self) -> &str { &self.0 @@ -112,20 +84,13 @@ impl fmt::Display for SourceHash { } } -/// What a claim does to its row. -/// -/// There is no `Delete`. Uninstalling a Package replays the affected families without its claims, so -/// there is no pre-image a delete could be undone from. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Operation { - /// The row does not exist yet; the claim carries every column. Insert, - /// The row exists; the claim carries only the columns it changes. Update, } impl Operation { - /// The value the artifact's `operation` member carries. #[must_use] pub const fn as_str(self) -> &'static str { match self { @@ -134,11 +99,6 @@ impl Operation { } } - /// Resolves an `operation` member. - /// - /// # Errors - /// [`DeltaError::DeleteNotSupported`] for `delete`, which is a refusal rather than a typo, and - /// [`DeltaError::UnknownOperation`] for anything else. pub fn parse(name: &str) -> Result { match name { "insert" => Ok(Self::Insert), @@ -157,275 +117,148 @@ impl fmt::Display for Operation { } } -/// The row a claim names, typed by its table. -/// -/// One variant per [`Table`] variant, in the same family-grouped order, so the derived ordering is -/// the canonical one: `game_spell` before `game_spell_effect`, then by spell, then by effect index. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PrimaryKey { - // ---- spell ---- - /// A `game_spell` header row. Spell { - /// The spell. spell_id: u32, }, - /// A `game_spell_effect` child row. The durable key is derived, never authored. SpellEffect { - /// The spell the effect belongs to. spell_id: u32, - /// Which of the spell's effects, 0..=[`MAX_SPELL_EFFECT_INDEX`]. effect_index: u8, }, - // ---- items ---- - /// A `game_item_template` row. Item { - /// The item. entry: u32, }, - // ---- quests ---- - /// A `game_quest_template` header row. Quest { - /// The quest. entry: u32, }, - /// A `game_quest_text` row. 1:1 with its quest by `quest_entry`. QuestText { - /// The quest this text belongs to. quest_entry: u32, }, - /// A `game_quest_objective` row. The durable key is derived, never authored. QuestObjective { - /// The quest this objective belongs to. quest_entry: u32, - /// Which objective slot, 0..=[`MAX_QUEST_OBJECTIVE_INDEX`]. obj_index: u8, }, - /// A `game_quest_cast_objective` row. The durable key is derived, never authored. QuestCastObjective { - /// The quest this cast requirement belongs to. quest_entry: u32, - /// Which objective slot, 0..=[`MAX_QUEST_OBJECTIVE_INDEX`]. obj_index: u8, }, - /// A `game_quest_reward_item` row. The durable key is derived, never authored. QuestRewardItem { - /// The quest this reward belongs to. quest_entry: u32, - /// The rewarded item. A quest rewards one item at most once, so this is the natural key. item_entry: u32, }, - /// A `game_quest_reward_choice` row. The durable key is derived, never authored. QuestRewardChoice { - /// The quest this reward belongs to. quest_entry: u32, - /// Which choice slot, 0..=[`MAX_QUEST_REWARD_CHOICE_INDEX`]. choice_index: u8, }, - // ---- loot ---- - /// A `game_pickpocket_loot` row. PickpocketLoot { - /// The row. id: u64, }, - /// A `game_gameobject_loot` row. GameobjectLoot { - /// The row. id: u64, }, - /// A `game_skinning_loot` row. SkinningLoot { - /// The row. id: u64, }, - /// A `game_fishing_loot` row. FishingLoot { - /// The row. id: u64, }, - // ---- casts ---- - /// A `game_creature_cast` row. Update-only: the key names a creature template, which no - /// Package may invent. CreatureCast { - /// The caster creature template. creature_entry: u32, }, - /// A `game_creature_spell` row. CreatureSpell { - /// The row. id: u64, }, - // ---- trainers ---- - /// A `game_trainer_spell` row. TrainerSpell { - /// The row. id: u64, }, - // ---- gossip ---- - /// A `game_gossip_menu` row. Update-only: the key names a creature template, which no Package - /// may invent. GossipMenu { - /// The creature template whose menu this is. entry: u32, }, - /// A `game_gossip_menu_profile` row. GossipMenuProfile { - /// The menu. menu_id: u32, }, - /// A `game_gossip_menu_profile_option` row. GossipMenuProfileOption { - /// The row. row_id: u32, }, - /// A `game_gossip_option` row. GossipOption { - /// The row. row_id: u32, }, - /// A `game_npc_text` row. NpcText { - /// The greeting text. text_id: u32, }, - /// A `game_npc_text_slot` row. NpcTextSlot { - /// The row. id: u64, }, - // ---- globals ---- - /// A `game_class_level_stats` row. Update-only: the key names a class and a level the client - /// fixes. ClassLevelStats { - /// The class. class: u8, - /// The character level. level: u8, }, - /// A `game_level_stats` row. Update-only, for the same reason as [`PrimaryKey::ClassLevelStats`]. LevelStats { - /// The race. race: u8, - /// The class. class: u8, - /// The character level. level: u8, }, - /// A `game_start_position` row. Update-only: the key names a race and a class the client fixes. StartPosition { - /// The race. race: u8, - /// The class. class: u8, }, - /// A `game_graveyard_zone` row. GraveyardZone { - /// The row. row_id: u64, }, - /// A `game_areatrigger_teleport` row. Update-only: the key is an `AreaTrigger.dbc` trigger id. AreatriggerTeleport { - /// The trigger. trigger_id: u32, }, - /// A `game_createinfo_spell` row. CreateinfoSpell { - /// The row. id: u64, }, - /// A `game_createinfo_action` row. CreateinfoAction { - /// The row. row_id: u64, }, - // ---- spellmeta ---- - /// A `game_spell_chain` row. The key IS a spell identifier, so it takes the spell family's - /// identifier policy. SpellChain { - /// The spell whose rank link this is. spell_id: u32, }, - /// A `game_spell_learn` row. SpellLearn { - /// The row. id: u64, }, - /// A `game_spell_proc_event` row. The key IS a spell identifier, like - /// [`PrimaryKey::SpellChain`]. SpellProcEvent { - /// The spell whose proc overlay this is. spell_id: u32, }, - // ---- creatures ---- - /// A `game_creature_template` row. CreatureTemplate { - /// The creature template. entry: u32, }, - /// A `game_creature_spawn` row. The durable guid is derived, never authored. CreatureSpawn { - /// The map this spawn stands on. It is what routes the claim to a Shard, and it is part of - /// the claim's identity rather than a column, so no Package can move a placed creature onto - /// a map another Shard owns. map_id: u32, - /// The creature template this spawn places. Half the derived guid. entry: u32, - /// Which spawn of that template — the cmangos `creature.guid`. The other half. spawn_id: u32, }, - // ---- gameobjects ---- - /// A `game_gameobject_template` row. GameobjectTemplate { - /// The gameobject template. entry: u32, }, - /// A `game_gameobject_trap` row. The key IS a gameobject template entry, so it takes the - /// gameobjects band rather than one of its own. GameobjectTrap { - /// The gameobject template this trap describes. entry: u32, }, - /// A `game_gameobject` row. The durable guid is derived, never authored. GameobjectSpawn { - /// The map this gameobject stands on. It routes the claim, like - /// [`PrimaryKey::CreatureSpawn`]'s. map_id: u32, - /// Which placed gameobject — the cmangos `gameobject.guid`. The whole derived guid. spawn_id: u32, }, - // ---- creature-ai ---- - /// A `game_creature_ai_broadcast_text` row. CreatureAiBroadcastText { - /// The broadcast text. id: u32, }, - /// A `game_creature_ai_summon` row. CreatureAiSummon { - /// The summon placement. id: u32, }, - /// A `game_quest_event_requirement` row. QuestEventRequirement { - /// The row. id: u64, }, } impl PrimaryKey { - /// Names a spell header row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for spell 0, and [`DeltaError::SpellIdFixtureReserved`] for a - /// seeded fixture spell. pub fn spell(spell_id: u32) -> Result { check_claimable_spell_id(Table::Spell, spell_id)?; Ok(Self::Spell { spell_id }) } - /// Names one effect of a spell. The packed durable key follows from the two components. - /// - /// # Errors - /// The same identifier refusals as [`PrimaryKey::spell`], plus - /// [`DeltaError::EffectIndexOutOfRange`] for a slot no spell has. pub fn spell_effect(spell_id: u32, effect_index: u8) -> Result { check_claimable_spell_id(Table::SpellEffect, spell_id)?; if effect_index > MAX_SPELL_EFFECT_INDEX { @@ -437,42 +270,21 @@ impl PrimaryKey { }) } - /// Names an item row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for entry 0, and [`DeltaError::ItemIdFixtureReserved`] for a - /// seeded fixture item. pub fn item(entry: u32) -> Result { check_claimable_item_id(entry)?; Ok(Self::Item { entry }) } - /// Names a quest header row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for quest 0, and [`DeltaError::QuestIdFixtureReserved`] for a - /// seeded fixture quest. pub fn quest(entry: u32) -> Result { check_claimable_quest_id(Table::Quest, entry)?; Ok(Self::Quest { entry }) } - /// Names a quest's text row. Shares the quest's own identifier policy: it is 1:1 with the - /// header by `quest_entry`, not a separately banded child. - /// - /// # Errors - /// Same as [`PrimaryKey::quest`]. pub fn quest_text(quest_entry: u32) -> Result { check_claimable_quest_id(Table::QuestText, quest_entry)?; Ok(Self::QuestText { quest_entry }) } - /// Names one objective slot of a quest. The packed durable key follows from the two - /// components. - /// - /// # Errors - /// The same identifier refusals as [`PrimaryKey::quest`], plus - /// [`DeltaError::QuestObjectiveIndexOutOfRange`] for a slot no quest has. pub fn quest_objective(quest_entry: u32, obj_index: u8) -> Result { check_claimable_quest_id(Table::QuestObjective, quest_entry)?; if obj_index > MAX_QUEST_OBJECTIVE_INDEX { @@ -484,10 +296,6 @@ impl PrimaryKey { }) } - /// Names the spell-cast requirement on one objective slot of a quest. - /// - /// # Errors - /// Same as [`PrimaryKey::quest_objective`]. pub fn quest_cast_objective(quest_entry: u32, obj_index: u8) -> Result { check_claimable_quest_id(Table::QuestCastObjective, quest_entry)?; if obj_index > MAX_QUEST_OBJECTIVE_INDEX { @@ -499,13 +307,6 @@ impl PrimaryKey { }) } - /// Names a guaranteed reward-item row. The packed durable key follows from the two - /// components. - /// - /// # Errors - /// The same identifier refusals as [`PrimaryKey::quest`], applied to `quest_entry`. - /// `item_entry` names the OTHER side of the relation (a `game_item_template` row) and is not - /// itself checked here. The items family's own policy governs whether that row exists. pub fn quest_reward_item(quest_entry: u32, item_entry: u32) -> Result { check_claimable_quest_id(Table::QuestRewardItem, quest_entry)?; Ok(Self::QuestRewardItem { @@ -514,12 +315,6 @@ impl PrimaryKey { }) } - /// Names one choice-reward slot of a quest. The packed durable key follows from the two - /// components. - /// - /// # Errors - /// The same identifier refusals as [`PrimaryKey::quest`], plus - /// [`DeltaError::QuestRewardChoiceIndexOutOfRange`] for a slot no quest has. pub fn quest_reward_choice(quest_entry: u32, choice_index: u8) -> Result { check_claimable_quest_id(Table::QuestRewardChoice, quest_entry)?; if choice_index > MAX_QUEST_REWARD_CHOICE_INDEX { @@ -531,83 +326,41 @@ impl PrimaryKey { }) } - /// Names a pickpocket-loot row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::LootIdFixtureReserved`] for a - /// seeded fixture row. pub fn pickpocket_loot(id: u64) -> Result { check_claimable_loot_id(Table::PickpocketLoot, id)?; Ok(Self::PickpocketLoot { id }) } - /// Names a gameobject (chest) loot row. - /// - /// # Errors - /// Same as [`PrimaryKey::pickpocket_loot`]. pub fn gameobject_loot(id: u64) -> Result { check_claimable_loot_id(Table::GameobjectLoot, id)?; Ok(Self::GameobjectLoot { id }) } - /// Names a skinning-loot row. - /// - /// # Errors - /// Same as [`PrimaryKey::pickpocket_loot`]. pub fn skinning_loot(id: u64) -> Result { check_claimable_loot_id(Table::SkinningLoot, id)?; Ok(Self::SkinningLoot { id }) } - /// Names a fishing-loot row. - /// - /// # Errors - /// Same as [`PrimaryKey::pickpocket_loot`]. pub fn fishing_loot(id: u64) -> Result { check_claimable_loot_id(Table::FishingLoot, id)?; Ok(Self::FishingLoot { id }) } - /// Names a caster creature's single-spell cast row. - /// - /// The table is update-only, so it has no Package identifier band. Its key still names a - /// creature template and takes the creature fixture policy. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for creature entry 0 or an entry the spawn guid's field cannot - /// hold, and [`DeltaError::CreatureIdFixtureReserved`] for a seeded fixture creature. pub fn creature_cast(creature_entry: u32) -> Result { check_claimable_creature_entry(Table::CreatureCast, "creature_entry", creature_entry)?; Ok(Self::CreatureCast { creature_entry }) } - /// Names a caster creature's rotation-spell row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::CastIdFixtureReserved`] for a - /// seeded fixture row. pub fn creature_spell(id: u64) -> Result { check_claimable_cast_id(id)?; Ok(Self::CreatureSpell { id }) } - /// Names one spell a trainer teaches. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::TrainerIdFixtureReserved`] for a - /// seeded fixture row. pub fn trainer_spell(id: u64) -> Result { check_claimable_trainer_id(id)?; Ok(Self::TrainerSpell { id }) } - /// Names the greeting a creature template shows. - /// - /// No fixture or band check: the table is update-only, and its key names a creature template. - /// Unlike `game_creature_cast`, this table does not yet apply the creature fixture policy. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for creature entry 0. pub fn gossip_menu(entry: u32) -> Result { if entry == 0 { return Err(DeltaError::MalformedKey { @@ -618,64 +371,31 @@ impl PrimaryKey { Ok(Self::GossipMenu { entry }) } - /// Names a gossip menu profile. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for menu 0, and [`DeltaError::GossipIdFixtureReserved`] for a - /// seeded fixture row. pub fn gossip_menu_profile(menu_id: u32) -> Result { check_claimable_gossip_id(Table::GossipMenuProfile, "menu_id", u64::from(menu_id))?; Ok(Self::GossipMenuProfile { menu_id }) } - /// Names one option of a gossip menu profile. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for row 0, and [`DeltaError::GossipIdFixtureReserved`] for a - /// seeded fixture row. pub fn gossip_menu_profile_option(row_id: u32) -> Result { check_claimable_gossip_id(Table::GossipMenuProfileOption, "row_id", u64::from(row_id))?; Ok(Self::GossipMenuProfileOption { row_id }) } - /// Names one option of a creature's own gossip menu. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for row 0, and [`DeltaError::GossipIdFixtureReserved`] for a - /// seeded fixture row. pub fn gossip_option(row_id: u32) -> Result { check_claimable_gossip_id(Table::GossipOption, "row_id", u64::from(row_id))?; Ok(Self::GossipOption { row_id }) } - /// Names one NPC greeting text. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for text 0, and [`DeltaError::GossipIdFixtureReserved`] for a - /// seeded fixture row. pub fn npc_text(text_id: u32) -> Result { check_claimable_gossip_id(Table::NpcText, "text_id", u64::from(text_id))?; Ok(Self::NpcText { text_id }) } - /// Names one weighted greeting slot. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::GossipIdFixtureReserved`] for a - /// seeded fixture row. pub fn npc_text_slot(id: u64) -> Result { check_claimable_gossip_id(Table::NpcTextSlot, "id", id)?; Ok(Self::NpcTextSlot { id }) } - /// Names one class-and-level stat-curve row. - /// - /// No band check: the table is update-only, and the key names a class and a level the client - /// fixes. The level is still bounded, so a claim cannot name a row no curve carries. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for class 0, and [`DeltaError::StatsLevelOutOfRange`] for a - /// level outside 1..=[`MAX_STATS_LEVEL`]. pub fn class_level_stats(class: u8, level: u8) -> Result { if class == 0 { return Err(DeltaError::MalformedKey { @@ -687,43 +407,22 @@ impl PrimaryKey { Ok(Self::ClassLevelStats { class, level }) } - /// Names one race-class-and-level stat-curve row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for race or class 0, and [`DeltaError::StatsLevelOutOfRange`] - /// for a level outside 1..=[`MAX_STATS_LEVEL`]. pub fn level_stats(race: u8, class: u8, level: u8) -> Result { check_race_and_class(Table::LevelStats, race, class)?; check_stats_level(level)?; Ok(Self::LevelStats { race, class, level }) } - /// Names one (race, class) start position. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for race or class 0. pub fn start_position(race: u8, class: u8) -> Result { check_race_and_class(Table::StartPosition, race, class)?; Ok(Self::StartPosition { race, class }) } - /// Names one graveyard-to-zone link. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for row 0, and [`DeltaError::GlobalsIdFixtureReserved`] for a - /// seeded fixture row. pub fn graveyard_zone(row_id: u64) -> Result { check_claimable_globals_id(Table::GraveyardZone, "row_id", row_id)?; Ok(Self::GraveyardZone { row_id }) } - /// Names one portal's destination. - /// - /// No band check: the table is update-only, and its key is an `AreaTrigger.dbc` trigger id, - /// client data this crate does not police. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for trigger 0. pub fn areatrigger_teleport(trigger_id: u32) -> Result { if trigger_id == 0 { return Err(DeltaError::MalformedKey { @@ -734,43 +433,21 @@ impl PrimaryKey { Ok(Self::AreatriggerTeleport { trigger_id }) } - /// Names one character-creation spell grant. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::GlobalsIdFixtureReserved`] for a - /// seeded fixture row. pub fn createinfo_spell(id: u64) -> Result { check_claimable_globals_id(Table::CreateinfoSpell, "id", id)?; Ok(Self::CreateinfoSpell { id }) } - /// Names one character-creation action-bar button. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for row 0, and [`DeltaError::GlobalsIdFixtureReserved`] for a - /// seeded fixture row. pub fn createinfo_action(row_id: u64) -> Result { check_claimable_globals_id(Table::CreateinfoAction, "row_id", row_id)?; Ok(Self::CreateinfoAction { row_id }) } - /// Names one spell's rank-chain link. - /// - /// The key is a spell identifier, so it takes the spell family's fixture check. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for spell 0, and [`DeltaError::SpellIdFixtureReserved`] for a - /// seeded fixture spell. pub fn spell_chain(spell_id: u32) -> Result { check_claimable_spell_id(Table::SpellChain, spell_id)?; Ok(Self::SpellChain { spell_id }) } - /// Names one auto-learn dependent. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::SpellmetaIdFixtureReserved`] for a - /// seeded fixture row. pub fn spell_learn(id: u64) -> Result { if id == 0 { return Err(DeltaError::MalformedKey { @@ -784,37 +461,16 @@ impl PrimaryKey { Ok(Self::SpellLearn { id }) } - /// Names one spell's proc overlay. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for spell 0, and [`DeltaError::SpellIdFixtureReserved`] for a - /// seeded fixture spell. pub fn spell_proc_event(spell_id: u32) -> Result { check_claimable_spell_id(Table::SpellProcEvent, spell_id)?; Ok(Self::SpellProcEvent { spell_id }) } - /// Names a creature template row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for entry 0 or an entry the spawn guid's 24-bit field cannot - /// hold, and [`DeltaError::CreatureIdFixtureReserved`] for a seeded fixture creature. pub fn creature_template(entry: u32) -> Result { check_claimable_creature_entry(Table::CreatureTemplate, "entry", entry)?; Ok(Self::CreatureTemplate { entry }) } - /// Names one placed creature. The durable guid follows from the template and the spawn - /// identifier; the map routes the claim and never reaches the guid. - /// - /// `entry` names the OTHER side of the relation (a `game_creature_template` row) and takes no - /// band or fixture check of its own — the `quest_reward_item` shape. It is still bounded, - /// because it rides inside the derived guid. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for entry or spawn identifier 0, or for either above the - /// guid's 24-bit field, and [`DeltaError::CreatureIdFixtureReserved`] for a seeded fixture - /// spawn identifier. pub fn creature_spawn(map_id: u32, entry: u32, spawn_id: u32) -> Result { check_guid_component(Table::CreatureSpawn, "entry", entry)?; check_claimable_creature_spawn_id(spawn_id)?; @@ -825,65 +481,36 @@ impl PrimaryKey { }) } - /// Names a gameobject template row. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for entry 0, and - /// [`DeltaError::GameobjectIdFixtureReserved`] for a seeded fixture gameobject. pub fn gameobject_template(entry: u32) -> Result { check_claimable_gameobject_id(Table::GameobjectTemplate, "entry", entry)?; Ok(Self::GameobjectTemplate { entry }) } - /// Names one gameobject template's trap metadata. - /// - /// # Errors - /// Same as [`PrimaryKey::gameobject_template`]. pub fn gameobject_trap(entry: u32) -> Result { check_claimable_gameobject_id(Table::GameobjectTrap, "entry", entry)?; Ok(Self::GameobjectTrap { entry }) } - /// Names one placed gameobject. The durable guid follows from the spawn identifier; the map - /// routes the claim. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for spawn identifier 0, and - /// [`DeltaError::GameobjectIdFixtureReserved`] for a seeded fixture spawn identifier. pub fn gameobject_spawn(map_id: u32, spawn_id: u32) -> Result { check_claimable_gameobject_id(Table::GameobjectSpawn, "spawn_id", spawn_id)?; Ok(Self::GameobjectSpawn { map_id, spawn_id }) } - /// Names one `EventAI` broadcast text. - /// - /// # Errors - /// [`DeltaError::MalformedKey`] for id 0, and [`DeltaError::CreatureAiIdFixtureReserved`] for a - /// seeded fixture row. pub fn creature_ai_broadcast_text(id: u32) -> Result { check_claimable_creature_ai_id(Table::CreatureAiBroadcastText, u64::from(id))?; Ok(Self::CreatureAiBroadcastText { id }) } - /// Names one `EventAI` summon placement. - /// - /// # Errors - /// Same as [`PrimaryKey::creature_ai_broadcast_text`]. pub fn creature_ai_summon(id: u32) -> Result { check_claimable_creature_ai_id(Table::CreatureAiSummon, u64::from(id))?; Ok(Self::CreatureAiSummon { id }) } - /// Names one quest that needs its `EventAI` source event. - /// - /// # Errors - /// Same as [`PrimaryKey::creature_ai_broadcast_text`]. pub fn quest_event_requirement(id: u64) -> Result { check_claimable_creature_ai_id(Table::QuestEventRequirement, id)?; Ok(Self::QuestEventRequirement { id }) } - /// The table this row lives in. #[must_use] pub const fn table(self) -> Table { match self { @@ -930,16 +557,6 @@ impl PrimaryKey { } } - /// The map this row sits on, or `None` when the row is a global catalogue entry every Shard - /// loads. - /// - /// This is the WHOLE routing rule for a Package claim, and it is deliberately one accessor: a - /// spatial claim reaches only the Shards whose World Import Scope owns this map, and the - /// importer's existing scope answers that question (`importer/src/package_delta.rs`). No table - /// gets a second way to say where it belongs. - /// - /// The match carries no wildcard, so a table that arrives without stating whether it is global - /// or spatial does not compile. #[must_use] pub const fn map_id(self) -> Option { match self { @@ -987,15 +604,6 @@ impl PrimaryKey { } } - /// The spell this row belongs to, for a spell-family key. - /// - /// Every caller sits inside the spell family's own dispatch (`module/src/package_import/ - /// spell.rs`), where `check_claims_belong_to` has already refused a foreign-family row before - /// this could run — so a foreign-family key reaching here is an internal invariant break, not - /// untrusted input. - /// - /// # Panics - /// If called on a non-spell key. #[must_use] pub fn spell_id(self) -> u32 { match self { @@ -1006,17 +614,6 @@ impl PrimaryKey { } } - /// The quest this row belongs to, for a quest-family key. The header's own `entry` counts, - /// every child table names the same quest by `quest_entry`, so this is the one identifier a - /// quest-family row is always Package-owned or not through. - /// - /// Every caller sits inside the quest family's own dispatch - /// (`module/src/package_import/quest.rs`), where `check_claims_belong_to` has already refused - /// a foreign-family row before this could run, so a foreign-family key reaching here is an - /// internal invariant break, not untrusted input. - /// - /// # Panics - /// If called on a non-quest key. #[must_use] pub fn quest_entry(self) -> u32 { match self { @@ -1032,8 +629,6 @@ impl PrimaryKey { } } - /// The durable primary-key value: `spell_id` for a header, the packed - /// `(spell_id << 2) | effect_index` for an effect, `entry` for an item. #[must_use] pub const fn row_id(self) -> u64 { match self { @@ -1180,10 +775,6 @@ impl fmt::Display for PrimaryKey { } } -/// Refuses an `insert` at an identifier outside its family's Package band. -/// -/// The band is a per-family policy, so this match carries one arm per family and no wildcard: a -/// family that arrives without stating its band cannot compile. fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { match key { PrimaryKey::Spell { spell_id } | PrimaryKey::SpellEffect { spell_id, .. } => { @@ -1228,9 +819,6 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { Err(DeltaError::LootIdNotClientSafe { id }) } } - // The one arm that is never "inside the band, or refused": `game_creature_cast`'s key - // names a creature template, which no Package may invent, so every insert is refused - // regardless of identifier. PrimaryKey::CreatureCast { .. } => Err(DeltaError::InsertNotSupported { table: Table::CreatureCast, }), @@ -1248,8 +836,6 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { Err(DeltaError::TrainerIdNotClientSafe { id }) } } - // The other update-only tables, the same shape: the key names a creature template, a race, - // a class, a level or a client trigger id, none of which a Package may invent. PrimaryKey::GossipMenu { .. } => insert_refused(Table::GossipMenu), PrimaryKey::ClassLevelStats { .. } => insert_refused(Table::ClassLevelStats), PrimaryKey::LevelStats { .. } => insert_refused(Table::LevelStats), @@ -1263,8 +849,6 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { PrimaryKey::GraveyardZone { row_id: id } | PrimaryKey::CreateinfoSpell { id } | PrimaryKey::CreateinfoAction { row_id: id } => globals_band(id), - // The two spellmeta tables whose key IS a spell identifier: what a Package may invent here - // is exactly what it may invent in `game_spell`. PrimaryKey::SpellChain { spell_id } | PrimaryKey::SpellProcEvent { spell_id } => { check_band( is_package_spell_id(spell_id), @@ -1275,9 +859,6 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { is_package_spellmeta_id(id), DeltaError::SpellmetaIdNotClientSafe { id }, ), - // One band per family, checked against the identifier the family owns: a template's own - // `entry`, a spawn's own `spawn_id`. The map in a spawn key routes the claim and is never - // banded — every real map identifier is one a Package may place a row on. PrimaryKey::CreatureTemplate { entry: id } | PrimaryKey::CreatureSpawn { spawn_id: id, .. } => check_band( is_package_creature_id(id), @@ -1289,7 +870,6 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { is_package_gameobject_id(id), DeltaError::GameobjectIdNotClientSafe { id }, ), - // One band over three independent identifier spaces, the loot shape. PrimaryKey::CreatureAiBroadcastText { id } | PrimaryKey::CreatureAiSummon { id } => { creature_ai_band(u64::from(id)) } @@ -1297,14 +877,10 @@ fn check_inventable(key: PrimaryKey) -> Result<(), DeltaError> { } } -/// The refusal every insert on an update-only table takes. fn insert_refused(table: Table) -> Result<(), DeltaError> { Err(DeltaError::InsertNotSupported { table }) } -/// `Ok` when the identifier is inside its family's Package band, `refusal` otherwise. The arms of -/// [`check_inventable`] that state only "inside the band, or refused" read as one line each through -/// this rather than repeating the `if`. fn check_band(inside: bool, refusal: DeltaError) -> Result<(), DeltaError> { if inside { Ok(()) @@ -1313,7 +889,6 @@ fn check_band(inside: bool, refusal: DeltaError) -> Result<(), DeltaError> { } } -/// One band covers all five insertable gossip tables, so they share one check. fn gossip_band(id: u64) -> Result<(), DeltaError> { check_band( is_package_gossip_id(id), @@ -1321,7 +896,6 @@ fn gossip_band(id: u64) -> Result<(), DeltaError> { ) } -/// One band covers all three insertable globals tables, so they share one check. fn globals_band(id: u64) -> Result<(), DeltaError> { check_band( is_package_globals_id(id), @@ -1329,10 +903,6 @@ fn globals_band(id: u64) -> Result<(), DeltaError> { ) } -/// A spell identifier is refused the same way under every operation when it is 0 or fixture-owned. -/// -/// `game_spell_chain` and `game_spell_proc_event` share it: both key on a spell identifier, so the -/// row each names is metadata for a `game_spell` row and cannot outlive it. fn check_claimable_spell_id(table: Table, spell_id: u32) -> Result<(), DeltaError> { if spell_id == 0 { return Err(DeltaError::MalformedKey { @@ -1346,8 +916,6 @@ fn check_claimable_spell_id(table: Table, spell_id: u32) -> Result<(), DeltaErro Ok(()) } -/// A quest identifier is refused the same way under every operation, on every table that names -/// one, when it is 0 or fixture-owned. fn check_claimable_quest_id(table: Table, quest_entry: u32) -> Result<(), DeltaError> { if quest_entry == 0 { return Err(DeltaError::MalformedKey { @@ -1361,8 +929,6 @@ fn check_claimable_quest_id(table: Table, quest_entry: u32) -> Result<(), DeltaE Ok(()) } -/// A loot row identifier is refused the same way under every operation when it is 0 or -/// fixture-owned. fn check_claimable_loot_id(table: Table, id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1376,8 +942,6 @@ fn check_claimable_loot_id(table: Table, id: u64) -> Result<(), DeltaError> { Ok(()) } -/// A `game_creature_spell` identifier is refused the same way under every operation when it is 0 -/// or fixture-owned. fn check_claimable_cast_id(id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1391,8 +955,6 @@ fn check_claimable_cast_id(id: u64) -> Result<(), DeltaError> { Ok(()) } -/// A `game_trainer_spell` identifier is refused the same way under every operation when it is 0 -/// or fixture-owned. fn check_claimable_trainer_id(id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1406,8 +968,6 @@ fn check_claimable_trainer_id(id: u64) -> Result<(), DeltaError> { Ok(()) } -/// A gossip identifier is refused the same way under every operation when it is 0 or -/// fixture-owned. `member` names the key member so the refusal quotes the table's own spelling. fn check_claimable_gossip_id(table: Table, member: &str, id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1421,8 +981,6 @@ fn check_claimable_gossip_id(table: Table, member: &str, id: u64) -> Result<(), Ok(()) } -/// A globals identifier is refused the same way under every operation when it is 0 or -/// fixture-owned. fn check_claimable_globals_id(table: Table, member: &str, id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1436,7 +994,6 @@ fn check_claimable_globals_id(table: Table, member: &str, id: u64) -> Result<(), Ok(()) } -/// Both stat curves stop at [`MAX_STATS_LEVEL`], and neither carries a level-0 row. fn check_stats_level(level: u8) -> Result<(), DeltaError> { if level == 0 || level > MAX_STATS_LEVEL { return Err(DeltaError::StatsLevelOutOfRange { level }); @@ -1444,8 +1001,6 @@ fn check_stats_level(level: u8) -> Result<(), DeltaError> { Ok(()) } -/// Race 0 and class 0 are wildcards in the createinfo tables, but a real key column in the three -/// update-only globals tables, where neither is a row. fn check_race_and_class(table: Table, race: u8, class: u8) -> Result<(), DeltaError> { if race == 0 { return Err(DeltaError::MalformedKey { @@ -1462,8 +1017,6 @@ fn check_race_and_class(table: Table, race: u8, class: u8) -> Result<(), DeltaEr Ok(()) } -/// A creature template entry is refused wherever a claim names one when it is 0, wider than the -/// spawn guid's field, or fixture-owned. fn check_claimable_creature_entry( table: Table, member: &str, @@ -1476,8 +1029,6 @@ fn check_claimable_creature_entry( Ok(()) } -/// A creature SPAWN identifier takes the same checks against its own identifier space: the fixture -/// creature cluster names template entries, and real imported spawn identifiers run through it. fn check_claimable_creature_spawn_id(spawn_id: u32) -> Result<(), DeltaError> { check_guid_component(Table::CreatureSpawn, "spawn_id", spawn_id)?; if is_fixture_reserved_creature_spawn_id(spawn_id) { @@ -1486,8 +1037,7 @@ fn check_claimable_creature_spawn_id(spawn_id: u32) -> Result<(), DeltaError> { Ok(()) } -/// Both halves of a creature spawn guid are 24 bits wide, so a wider value would wrap into another -/// creature's row rather than address its own. +// Creature spawn GUIDs allocate 24 bits to each component. fn check_guid_component(table: Table, member: &str, value: u32) -> Result<(), DeltaError> { if value == 0 { return Err(DeltaError::MalformedKey { @@ -1507,9 +1057,6 @@ fn check_guid_component(table: Table, member: &str, value: u32) -> Result<(), De Ok(()) } -/// A gameobject identifier is refused the same way under every operation when it is 0 or -/// fixture-owned. A gameobject guid carries the spawn identifier at its full width, so there is no -/// component bound here. fn check_claimable_gameobject_id(table: Table, member: &str, id: u32) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1523,7 +1070,6 @@ fn check_claimable_gameobject_id(table: Table, member: &str, id: u32) -> Result< Ok(()) } -/// One band covers all three insertable creature-ai tables, so they share one check. fn creature_ai_band(id: u64) -> Result<(), DeltaError> { check_band( is_package_creature_ai_id(id), @@ -1531,8 +1077,6 @@ fn creature_ai_band(id: u64) -> Result<(), DeltaError> { ) } -/// A creature-ai identifier is refused the same way under every operation when it is 0 or -/// fixture-owned. fn check_claimable_creature_ai_id(table: Table, id: u64) -> Result<(), DeltaError> { if id == 0 { return Err(DeltaError::MalformedKey { @@ -1546,7 +1090,6 @@ fn check_claimable_creature_ai_id(table: Table, id: u64) -> Result<(), DeltaErro Ok(()) } -/// An item identifier is refused the same way under every operation when it is 0 or fixture-owned. fn check_claimable_item_id(entry: u32) -> Result<(), DeltaError> { if entry == 0 { return Err(DeltaError::MalformedKey { @@ -1560,9 +1103,6 @@ fn check_claimable_item_id(entry: u32) -> Result<(), DeltaError> { Ok(()) } -/// One Package's claim on one row. -/// -/// The table follows from the key, so a claim cannot name a table its key does not describe. #[derive(Debug, Clone, PartialEq)] pub struct Claim { key: PrimaryKey, @@ -1571,22 +1111,6 @@ pub struct Claim { } impl Claim { - /// Builds a claim, refusing everything the artifact promises it will refuse. - /// - /// The identifier policy is operation-dependent, and this is the only place it is applied: - /// - /// * An `insert` invents a row, so its identifier must sit in its family's Package band — for - /// a spell, the Package spell range ([`is_package_spell_id`]). Nothing else is safe: a real - /// client identifier would collide with the next base import. - /// * An `update` changes a row someone else owns, so any identifier is allowed except a - /// fixture-reserved one, which [`PrimaryKey`] already refused. Tuning real spells is the - /// common case and must stay possible. - /// - /// # Errors - /// [`DeltaError::SpellIdNotClientSafe`] for an out-of-range insert; - /// [`DeltaError::KeyColumnClaimed`], [`DeltaError::UnknownField`] or - /// [`DeltaError::FieldTypeMismatch`] for a bad column; [`DeltaError::IncompleteInsert`] for a - /// partial insert; [`DeltaError::EmptyUpdate`] for an update that states no change. pub fn new( key: PrimaryKey, operation: Operation, @@ -1654,32 +1178,27 @@ impl Claim { }) } - /// The row this claim names. #[must_use] pub const fn key(&self) -> PrimaryKey { self.key } - /// The table this claim's row lives in. #[must_use] pub const fn table(&self) -> Table { self.key.table() } - /// What this claim does to the row. #[must_use] pub const fn operation(&self) -> Operation { self.operation } - /// The claimed columns, in canonical (name-sorted) order. #[must_use] pub const fn fields(&self) -> &BTreeMap { &self.fields } } -/// True for a column that is part of the row's primary key, and therefore never claimable. fn is_key_column(table: Table, name: &str) -> bool { match table { Table::Spell | Table::SpellChain | Table::SpellProcEvent => name == "spell_id", @@ -1724,8 +1243,6 @@ fn is_key_column(table: Table, name: &str) -> bool { } } -/// One Package's whole set of claims: its identity, the source it was generated from, and the rows -/// it changes. #[derive(Debug, Clone, PartialEq)] pub struct PackageDelta { package: PackageId, @@ -1734,11 +1251,6 @@ pub struct PackageDelta { } impl PackageDelta { - /// Builds a delta and puts its claims in canonical order. - /// - /// # Errors - /// [`DeltaError::DuplicateClaim`] when the Package names one row twice. A Package states each - /// row once and lists every column it sets there, so ordering the claims can never lose one. pub fn new( package: PackageId, source_hash: SourceHash, @@ -1760,24 +1272,12 @@ impl PackageDelta { }) } - /// Reads an artifact from untrusted JSON. - /// - /// Every refusal happens here, before any caller could act on a claim. - /// - /// # Errors - /// [`DeltaError::WrongArtifactKind`] when the bytes are another artifact kind — a Package ships - /// every kind it has into one directory, so a reader walking `*.json` meets them all and has to - /// be told which it is holding rather than shown a complaint about a member. Otherwise any - /// [`DeltaError`] the members raise; the parse stops at the first problem. pub fn parse(json: &str) -> Result { let root: Value = serde_json::from_str(json).map_err(|e| DeltaError::Malformed(e.to_string()))?; let root = object(&root, "")?; - // A Package Delta carries no `kind`: version 1 shipped before there was a second kind to - // tell it from, and adding one now would change every artifact's canonical bytes. So a - // `kind` member is proof these bytes are NOT this artifact, and it is checked before the - // closed member list below, which would otherwise report it as an unexpected member. + // Version-1 Package Deltas omit kind; check it before reporting unexpected members. if let Some(kind) = root.get("kind") { let found = match kind { Value::String(kind) if kind == crate::script::SCRIPT_ARTIFACT_KIND => { @@ -1829,37 +1329,26 @@ impl PackageDelta { Self::new(package, source_hash, claims) } - /// Writes the artifact back as canonical JSON. - /// - /// Byte-stable: two artifacts that say the same thing produce the same bytes, whatever member - /// order, whitespace or number spelling they were written with. #[must_use] pub fn to_canonical_json(&self) -> String { canonical::write_delta(self) } - /// The Package this delta belongs to. #[must_use] pub const fn package(&self) -> &PackageId { &self.package } - /// The digest of the Datascript source this delta was generated from. #[must_use] pub const fn source_hash(&self) -> &SourceHash { &self.source_hash } - /// The claims, in canonical order. #[must_use] pub fn claims(&self) -> &[Claim] { &self.claims } - /// How many rows of each kind this Package claims. - /// - /// Counted from the claims alone, so it describes this Package on its own — an applier records - /// it as provenance and a dry-run check prints it, and both read the same numbers. #[must_use] pub fn claim_counts(&self) -> ClaimCounts { let mut counts = ClaimCounts::default(); @@ -1887,10 +1376,6 @@ impl PackageDelta { | Table::GameobjectLoot | Table::SkinningLoot | Table::FishingLoot - // `Table::CreatureCast` never reaches this arm at runtime — `check_inventable` - // refuses every insert on it before a `Claim` can exist — but the match stays - // exhaustive at the type level, the same way the loot tables sit beside tables - // whose own policy is stricter than "any identifier in the band". | Table::CreatureCast | Table::CreatureSpell | Table::TrainerSpell @@ -1913,8 +1398,6 @@ impl PackageDelta { | Table::CreatureAiBroadcastText | Table::CreatureAiSummon | Table::QuestEventRequirement - // Update-only, like `Table::CreatureCast` above: refused before a `Claim` can - // exist, listed only to keep the match exhaustive at the type level. | Table::GossipMenu | Table::ClassLevelStats | Table::LevelStats @@ -1927,27 +1410,11 @@ impl PackageDelta { } } -/// How many rows of each kind one Package Delta claims. -/// -/// These are the counts `game_package_import` records, so the field names are durable column names -/// and a family adds counters rather than renaming these. [`PackageDelta::claim_counts`] tallies -/// them with a match over every table, which is where a new family states what its inserts count -/// as. -/// -/// `inserted_spells`/`inserted_effects` are the spell family's own counts, kept under their -/// original names rather than renamed for a later family. `inserted_rows` is the family-generic -/// successor: every family's inserts count here, spell's included, so a reader that knows only this -/// column still sees the whole picture. A non-spell family (items today) leaves the legacy pair at -/// zero and reports through `inserted_rows` alone. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ClaimCounts { - /// Rows the Package changes but does not own, across every table. pub updated_rows: u64, - /// `game_spell` rows the Package invents. pub inserted_spells: u64, - /// `game_spell_effect` rows the Package invents. pub inserted_effects: u64, - /// Rows the Package invents, across every table its family owns. pub inserted_rows: u64, } @@ -1981,7 +1448,6 @@ fn parse_claim(value: &Value, index: usize) -> Result { Claim::new(key, operation, fields) } -/// The `key` members a claim on `table` must carry, exactly. fn expected_key_members(table: Table) -> &'static [&'static str] { match table { Table::Spell | Table::SpellChain | Table::SpellProcEvent => &["spell_id"], @@ -2050,9 +1516,6 @@ fn parse_key(value: &Value, table: Table) -> Result { build_key(key, table) } -/// Builds the typed key once [`parse_key`] has confirmed every expected member is present and no -/// other member is. Split out so neither function trips `clippy::too_many_lines` as the catalogue -/// grows. fn build_key(key: &Map, table: Table) -> Result { match table { Table::Spell => PrimaryKey::spell(key_u32(key, table, "spell_id")?), @@ -2139,18 +1602,14 @@ fn build_key(key: &Map, table: Table) -> Result, table: Table, name: &str) -> Result { key_number(key, table, name, u64::from(u8::MAX)).map(|n| n as u8) } -/// A key member narrowed to `u32`. fn key_u32(key: &Map, table: Table, name: &str) -> Result { key_number(key, table, name, u64::from(u32::MAX)).map(|n| n as u32) } -/// A key member at its full `u64` width. fn key_u64(key: &Map, table: Table, name: &str) -> Result { key_number(key, table, name, u64::MAX) } @@ -2212,8 +1671,7 @@ fn unsigned(raw: &Value, field: &str, ty: FieldType, max: u64) -> Result Result { let text = raw .as_str() @@ -2231,9 +1689,6 @@ fn signed(raw: &Value, field: &str) -> Result { .ok_or_else(|| out_of_range(raw, field, FieldType::I32)) } -/// JSON carries one float width, so a claimed `f32` arrives as an `f64` and is narrowed here. A -/// value that narrows to an infinity, or that was never finite, does not survive a round trip -/// through the artifact and is refused rather than stored. fn float(raw: &Value, field: &str) -> Result { let wide = raw .as_f64() @@ -2281,8 +1736,6 @@ pub(crate) fn member<'a>( }) } -/// Refuses any member outside the closed list. `allowed` is sorted, matching the parsed map, so the -/// member named in the error is the same one for the same input. pub(crate) fn expect_members( map: &Map, path: &str, diff --git a/crates/lyracore-package-delta/src/error.rs b/crates/lyracore-package-delta/src/error.rs index b36eb9aba..bb028989b 100644 --- a/crates/lyracore-package-delta/src/error.rs +++ b/crates/lyracore-package-delta/src/error.rs @@ -1,15 +1,3 @@ -//! Why a Package artifact was refused. -//! -//! Every refusal here happens before an applier sees the artifact, so no partial write is possible. -//! Each variant names the smallest thing that was wrong, because the reader is a package author who -//! has to fix one line of a Datascript. -//! -//! A refusal about an identifier band or a key shape is family-specific and says so in its name -//! (`SpellIdNotClientSafe`, `EffectIndexOutOfRange`, `ScriptIdNotInPackageBand`). An Import Family -//! adds its own variants rather than widening one, so no message loses the detail an author needs -//! to fix the claim. The envelope refusals above them — malformed JSON, an unknown member, a bad -//! Package identity — are shared, because every artifact kind carries the same envelope. - use core::fmt; use crate::ids::{ @@ -28,334 +16,177 @@ use crate::ids::{ use crate::schema::{FieldType, Table}; use crate::script::HOOK_EVENT_NAMES; -/// A Package artifact that cannot be trusted, and the reason. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeltaError { - /// The bytes are not JSON at all. Malformed(String), - /// The bytes hold a different artifact kind than the parser that read them. - /// - /// One Package's generated directory holds every kind it ships, side by side, so a router that - /// hands the wrong file to the wrong parser must be told exactly that rather than shown a - /// member-by-member complaint about an artifact that was never meant for it. WrongArtifactKind { - /// What the parser reads. expected: &'static str, - /// What the bytes actually are. found: String, }, - /// The artifact declares a version this build does not implement. UnsupportedVersion { - /// The declared version. found: String, }, - /// A required member is absent. `path` is a dotted location inside the artifact. MissingMember { - /// Where the member belonged. path: String, }, - /// A member this build does not know. Refused rather than ignored, so a typo in a Datascript - /// never becomes a silently dropped claim. UnexpectedMember { - /// Where the member appeared. path: String, - /// The member's name. member: String, }, - /// A member holds the wrong JSON shape. WrongJsonType { - /// Where the member appeared. path: String, - /// What was expected there. expected: &'static str, }, - /// The `package` member is not a usable Package identity. InvalidPackageId { - /// The rejected value. found: String, }, - /// The `source_hash` member is not a 64-character lowercase hexadecimal digest. InvalidSourceHash { - /// The rejected value. found: String, }, - /// The `table` member names a table outside the closed list. UnknownTable { - /// The rejected name. found: String, }, - /// The `operation` member names an operation outside the closed list. UnknownOperation { - /// The rejected name. found: String, }, - /// A claim asked for row deletion. Packages override a row to inert instead; there is no - /// pre-image ledger that could undo a delete on replay. DeleteNotSupported, - /// The `key` member does not describe a row of its table. MalformedKey { - /// The table the key was meant for. table: Table, - /// What is wrong with it. detail: String, }, - /// An inserted spell sits outside the range a Package may invent. SpellIdNotClientSafe { - /// The rejected identifier. spell_id: u32, }, - /// The claim targets a seeded fixture row. SpellIdFixtureReserved { - /// The rejected identifier. spell_id: u32, }, - /// The effect index names a slot no spell has. EffectIndexOutOfRange { - /// The rejected index. effect_index: u8, }, - /// An inserted item sits outside the range a Package may invent. ItemIdNotClientSafe { - /// The rejected identifier. entry: u32, }, - /// The claim targets a seeded fixture row. ItemIdFixtureReserved { - /// The rejected identifier. entry: u32, }, - /// An inserted quest sits outside the range a Package may invent. QuestIdNotClientSafe { - /// The rejected identifier. entry: u32, }, - /// The claim targets a seeded fixture row. QuestIdFixtureReserved { - /// The rejected identifier. entry: u32, }, - /// The objective index names a slot no quest has. QuestObjectiveIndexOutOfRange { - /// The rejected index. obj_index: u8, }, - /// The reward-choice index names a slot no quest has. QuestRewardChoiceIndexOutOfRange { - /// The rejected index. choice_index: u8, }, - /// An inserted loot row sits outside the range a Package may invent. LootIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. LootIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted `game_creature_spell` row sits outside the range a Package may invent. CastIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. CastIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted `game_trainer_spell` row sits outside the range a Package may invent. TrainerIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. TrainerIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted gossip row sits outside the range a Package may invent. GossipIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. GossipIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted globals row sits outside the range a Package may invent. GlobalsIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. GlobalsIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted `game_spell_learn` row sits outside the range a Package may invent. SpellmetaIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. SpellmetaIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// An inserted creature template or creature spawn sits outside the range a Package may - /// invent. CreatureIdNotClientSafe { - /// The rejected identifier. id: u32, }, - /// The claim targets a seeded fixture row. CreatureIdFixtureReserved { - /// The rejected identifier. id: u32, }, - /// An inserted gameobject template, trap or spawn sits outside the range a Package may invent. GameobjectIdNotClientSafe { - /// The rejected identifier. id: u32, }, - /// The claim targets a seeded fixture row. GameobjectIdFixtureReserved { - /// The rejected identifier. id: u32, }, - /// An inserted broadcast text, summon placement or quest event requirement sits outside the - /// range a Package may invent. CreatureAiIdNotClientSafe { - /// The rejected identifier. id: u64, }, - /// The claim targets a seeded fixture row. CreatureAiIdFixtureReserved { - /// The rejected identifier. id: u64, }, - /// The stat-curve key names a character level no vanilla realm reaches. StatsLevelOutOfRange { - /// The rejected level. level: u8, }, - /// An `insert` named a table that permits no inserts at all: its primary key names an entity - /// another Import Family owns, which no Package may invent. `game_creature_cast` - /// ([`Table::CreatureCast`]) is the worked example. InsertNotSupported { - /// The table that refused the insert. table: Table, }, - /// A claim names a column the table does not have. UnknownField { - /// The claimed table. table: Table, - /// The rejected column name. field: String, }, - /// A value declares a type tag outside the closed list. UnknownFieldType { - /// The claimed column. field: String, - /// The rejected type tag. found: String, }, - /// A value declares a type the column does not take. FieldTypeMismatch { - /// The claimed table. table: Table, - /// The claimed column. field: String, - /// The column's type. expected: FieldType, - /// The declared type. found: FieldType, }, - /// A value does not fit the type it declares. ValueOutOfRange { - /// The claimed column. field: String, - /// The declared type. ty: FieldType, - /// The value as written. literal: String, }, - /// A claim names a primary-key column in `fields`. The key already carries it. KeyColumnClaimed { - /// The claimed table. table: Table, - /// The primary-key column that was claimed. field: String, }, - /// An insert does not carry the whole row. IncompleteInsert { - /// The claimed table. table: Table, - /// The absent columns, in declaration order. missing: Vec, }, - /// An update claims no columns, so it states no change. EmptyUpdate { - /// The claimed table. table: Table, }, - /// One Package Delta claims the same row twice. A Package names each row once and lists every - /// column it sets there. DuplicateClaim { - /// The claimed table. table: Table, - /// The row, as the conflict report writes it. key: String, }, - /// A refusal only the script family can raise. - /// - /// Grouped rather than flattened in beside the claim refusals: the script family shares this - /// enum's envelope variants — malformed JSON, an unknown member, a bad Package identity — and - /// nothing else, so its own refusals read better as one named group than as six more arms a - /// claim reader has to skip past. A family with refusals of its own follows this shape. Script(ScriptRefusal), } -/// Why a Script Artifact was refused, for the refusals no other Import Family can raise. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScriptRefusal { - /// A shipped Runtime Script sits outside the range a Package may ship in. - IdNotInPackageBand { - /// The rejected identifier. - script_id: u32, - }, - /// The `name` member is not a usable Runtime Script name. - InvalidName { - /// The rejected value. - found: String, - }, - /// The `event` member names neither a Module hook event nor a Package Event of the artifact's - /// own Package. - UnknownEvent { - /// The rejected name. - found: String, - /// The artifact's own Package, which is the only Package whose events it may bind. - package: String, - }, - /// A Runtime Script ships no Lua at all. An empty script is a Datascript that emitted nothing, - /// not a script that does nothing. - EmptySource { - /// The script that carries no source. - name: String, - }, - /// One Script Artifact ships two scripts at one identifier. - DuplicateId { - /// The repeated identifier. - script_id: u32, - }, - /// One Script Artifact ships two scripts under one name. - DuplicateName { - /// The repeated name. - name: String, - }, + IdNotInPackageBand { script_id: u32 }, + InvalidName { found: String }, + UnknownEvent { found: String, package: String }, + EmptySource { name: String }, + DuplicateId { script_id: u32 }, + DuplicateName { name: String }, } impl fmt::Display for ScriptRefusal { @@ -429,18 +260,12 @@ impl fmt::Display for DeltaError { | Self::CreatureAiIdNotClientSafe { .. } | Self::CreatureAiIdFixtureReserved { .. } | Self::StatsLevelOutOfRange { .. } => fmt_identifier_policy(self, f), - // The script family groups its own refusals behind one variant, so it delegates as a - // whole rather than adding six arms here. Self::Script(refusal) => refusal.fmt(f), other => fmt_general(other, f), } } } -/// The identifier-band refusals, split out of [`DeltaError`]'s `Display` so neither half of the -/// message trips `clippy::too_many_lines` as a family's bands add their own variants. Split again -/// by kind for the same reason: "outside the band a Package may invent in" here, "reserved, or -/// outside the table's real domain" in [`fmt_reserved_or_out_of_range`]. fn fmt_identifier_policy(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> fmt::Result { match err { DeltaError::SpellIdNotClientSafe { spell_id } => write!( @@ -520,8 +345,6 @@ fn fmt_identifier_policy(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> fmt::R } } -/// The other half of the identifier policy: an identifier the fixtures own, and an index outside -/// the real domain of the table it names. fn fmt_reserved_or_out_of_range(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> fmt::Result { match err { DeltaError::SpellIdFixtureReserved { spell_id } => write!( @@ -610,7 +433,6 @@ fn fmt_reserved_or_out_of_range(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> } } -/// Every refusal that is not an identifier-band policy. fn fmt_general(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> fmt::Result { match err { DeltaError::Malformed(detail) => write!(f, "not valid JSON: {detail}"), @@ -703,8 +525,6 @@ fn fmt_general(err: &DeltaError, f: &mut fmt::Formatter<'_>) -> fmt::Result { impl std::error::Error for DeltaError {} -/// The whole catalogue as a prose list — "`a`", "`a` or `b`", "`a`, `b` or `c`" — so an Import -/// Family that adds tables extends the refusal without touching the message. fn known_tables() -> String { prose_list( Table::ALL.iter().map(|table| format!("`{table}`")), @@ -712,8 +532,6 @@ fn known_tables() -> String { ) } -/// The hook catalogue as the same prose list, so an event added to the Module extends this refusal -/// without touching the message. fn known_events() -> String { prose_list( HOOK_EVENT_NAMES.iter().map(|event| format!("`{event}`")), diff --git a/crates/lyracore-package-delta/src/ids.rs b/crates/lyracore-package-delta/src/ids.rs index 55c0893e0..3b8b0946c 100644 --- a/crates/lyracore-package-delta/src/ids.rs +++ b/crates/lyracore-package-delta/src/ids.rs @@ -1,66 +1,13 @@ -//! Reserved identifier bands, and the rule that decides which of them a Package may claim. -//! -//! Every constant here is a policy number, not a derived one. They live together so a maintainer -//! changes a band in exactly one place. -//! -//! # A family's Package identifier band -//! -//! Each Import Family whose Packages may INVENT rows gets one band, named -//! `PACKAGE__ID_FLOOR` and `PACKAGE__ID_CEIL`. The floor follows one formula, and -//! the spell band below is the worked example of it: -//! -//! * **Two decimal orders above the highest identifier a real client can hold** for that table. -//! One order of headroom is what a fixture floor takes; two removes the question for a later -//! client build. -//! * **Clear of every reserved band** — [`RESERVED_ID_FLOOR`]..=[`RESERVED_ID_CEIL`], which spans -//! the project-wide fixture band and every curated import band — so no reserved row can ever -//! fall inside a Package band. -//! * **Readable at a glance**, so the leading digits alone say "a Package invented this row". -//! -//! Every band states both ends and both ends are checked by `const _: () = assert!(…)` below: floor -//! at or under ceiling, and the whole band outside the reserved span. A maintainer moving a number -//! gets a compile error rather than a silently unenforceable policy. -//! -//! A table with no real client identifiers takes the same formula with its first clause satisfied -//! vacuously, so its band may sit BELOW the reserved span rather than above it. The script band is -//! the worked example of that case, and it is why the reserved span states a floor as well as a -//! ceiling. - -// =============================================================================================== -// Reserved bands. Shared by every family: a Package band has to clear all of them. -// =============================================================================================== - -/// Lowest identifier in the project-wide `509xxxx` fixture-reserved band (`docs/danger-zones.md`, -/// `lyracore_shared::constants::STORAGE_ID_FLOOR`). Nothing outside the fixtures may write into it. pub const FIXTURE_RESERVED_ID_FLOOR: u32 = 5_090_000; -/// Highest identifier in the project-wide `509xxxx` fixture-reserved band. pub const FIXTURE_RESERVED_ID_CEIL: u32 = 5_099_999; -/// Highest identifier any CURATED import band owns. -/// -/// The importer hands out fixed identifiers to rows it invents rather than reads, so its own reload -/// can delete the whole span. The highest such band is the curated trainer overrides -/// (`CURATED_TRAINER_ID_BASE` 5,200,000 plus a 500-wide span, `importer/src/spell.rs`); the -/// createinfo item band (`CREATEINFO_ITEM_ID_FLOOR` 1,000,000, `importer/src/main.rs`) sits below -/// it. A new curated band above this one moves this number. pub const CURATED_RESERVED_ID_CEIL: u32 = 5_200_499; -/// Lowest identifier any CURATED import band owns: the createinfo item band -/// (`CREATEINFO_ITEM_ID_FLOOR`, `importer/src/main.rs`), which sits below the curated trainer -/// overrides. A new curated band below this one moves this number. pub const CURATED_RESERVED_ID_FLOOR: u32 = 1_000_000; -/// Lowest identifier ANY reserved band owns. A Package band that sits below the reserved span must -/// clear it. -/// -/// Derived from the bands above rather than restated, so adding a band is one edit. pub const RESERVED_ID_FLOOR: u32 = lower(FIXTURE_RESERVED_ID_FLOOR, CURATED_RESERVED_ID_FLOOR); -/// Highest identifier ANY reserved band owns. A Package band that sits above the reserved span must -/// clear it. -/// -/// Derived from the bands above rather than restated, so adding a band is one edit. pub const RESERVED_ID_CEIL: u32 = higher(FIXTURE_RESERVED_ID_CEIL, CURATED_RESERVED_ID_CEIL); const fn higher(a: u32, b: u32) -> u32 { @@ -83,53 +30,19 @@ const _: () = assert!(FIXTURE_RESERVED_ID_FLOOR <= FIXTURE_RESERVED_ID_CEIL); const _: () = assert!(CURATED_RESERVED_ID_FLOOR <= CURATED_RESERVED_ID_CEIL); const _: () = assert!(RESERVED_ID_FLOOR <= RESERVED_ID_CEIL); -// =============================================================================================== -// spell -// =============================================================================================== - -/// Packed `game_spell_effect` primary key: `(spell_id << 2) | effect_index`. -/// -/// This formula is already canonical and load-bearing in the Module (`module/src/spell/tables.rs`, -/// the `SpellEffect.id` column) and in the importer's spell load. A Package Delta never authors the -/// packed value; it names the spell and the effect index, and this derives the key. Two bits carry -/// the index, so the spell identifier keeps its full `u32` width inside a `u64`. #[must_use] pub const fn packed_spell_effect_id(spell_id: u32, effect_index: u8) -> u64 { (spell_id as u64) << 2 | effect_index as u64 } -/// Highest `effect_index` a claim may name. -/// -/// A vanilla `SpellEntry` carries exactly three effect slots, so the real domain is 0..=2. The -/// packed key reserves two bits and could hold a 3, but no importer or seeder ever writes that row, -/// so a claim on it would target a row that cannot exist. pub const MAX_SPELL_EFFECT_INDEX: u8 = 2; -/// Lowest spell identifier a Package may INSERT. -/// -/// The worked example of the band formula in this module's header. `LyraCore` serves build 5875 -/// only, whose highest `Spell.dbc` entry is far below 50,000 — that is why the importer's -/// `SYNTHETIC_SPELL_ID_FLOOR` (`importer/src/spell.rs`) sits there. That floor is a *fixture* floor, -/// not a client-safe one: it leaves less than one decimal order of headroom over real data, and a -/// later client build would run straight through it. Two orders removes the question, and the whole -/// `6xxxxxx` decade is above every reserved band and unclaimed, so a `6` in the millions column -/// means "a Package invented this row". -/// -/// A real client renders a spell by looking the identifier up in its OWN `Spell.dbc`, so a Package -/// spell shows no tooltip on an unmodified client. That is expected: the identifier is safe because -/// it can never collide with real or fixture data, not because the client knows it. pub const PACKAGE_SPELL_ID_FLOOR: u32 = 6_000_000; -/// Highest spell identifier a Package may INSERT. One million identifiers, which is more than the -/// whole vanilla catalogue. pub const PACKAGE_SPELL_ID_CEIL: u32 = 6_999_999; -/// Lowest spell identifier in the seeded fixture cluster (`module/src/seed.rs`, -/// `module/src/seed/fixtures.rs`). The Module asserts its own fixture spells stay inside -/// `50_000..51_000`; the importer's wholesale clear spares the same rows. pub const FIXTURE_SPELL_ID_FLOOR: u32 = 50_000; -/// Highest spell identifier in the seeded fixture cluster. pub const FIXTURE_SPELL_ID_CEIL: u32 = 50_999; const _: () = assert!(FIXTURE_SPELL_ID_FLOOR <= FIXTURE_SPELL_ID_CEIL); @@ -137,482 +50,214 @@ const _: () = assert!(PACKAGE_SPELL_ID_FLOOR <= PACKAGE_SPELL_ID_CEIL); const _: () = assert!(FIXTURE_SPELL_ID_CEIL < FIXTURE_RESERVED_ID_FLOOR); const _: () = assert!(RESERVED_ID_CEIL < PACKAGE_SPELL_ID_FLOOR); -/// True when a Package may INSERT a spell at this identifier. #[must_use] pub const fn is_package_spell_id(spell_id: u32) -> bool { spell_id >= PACKAGE_SPELL_ID_FLOOR && spell_id <= PACKAGE_SPELL_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation: a fixture row is what the Module's own tests assert against, and a Package overwriting -/// one turns those tests into a report about the Package. #[must_use] pub const fn is_fixture_reserved_spell_id(spell_id: u32) -> bool { (spell_id >= FIXTURE_SPELL_ID_FLOOR && spell_id <= FIXTURE_SPELL_ID_CEIL) || (spell_id >= FIXTURE_RESERVED_ID_FLOOR && spell_id <= FIXTURE_RESERVED_ID_CEIL) } -// =============================================================================================== -// items -// =============================================================================================== - -/// Lowest item identifier a Package may INSERT. -/// -/// The second application of the band formula in this module's header. A ClassicDB-shaped -/// `item_template` dump for this client build tops out under 24,000 (`module/src/seed.rs`'s own -/// synthetic-fixture comment); two decimal orders above that is still comfortably under -/// [`RESERVED_ID_CEIL`], so — exactly as for [`PACKAGE_SPELL_ID_FLOOR`] — the reserved bands are -/// the binding constraint, not the client headroom. The floor sits one whole decade above the -/// Package spell range so the millions column stays a family-at-a-glance signal across tables, not -/// only within one: `6` means a Package spell, `7` means a Package item. pub const PACKAGE_ITEM_ID_FLOOR: u32 = 7_000_000; -/// Highest item identifier a Package may INSERT. One million identifiers, the same width as the -/// Package spell range. pub const PACKAGE_ITEM_ID_CEIL: u32 = 7_999_999; const _: () = assert!(PACKAGE_ITEM_ID_FLOOR <= PACKAGE_ITEM_ID_CEIL); const _: () = assert!(RESERVED_ID_CEIL < PACKAGE_ITEM_ID_FLOOR); -/// True when a Package may INSERT an item at this identifier. #[must_use] pub const fn is_package_item_id(entry: u32) -> bool { entry >= PACKAGE_ITEM_ID_FLOOR && entry <= PACKAGE_ITEM_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. -/// -/// Unlike spells, items have no family-specific fixture cluster: the seeded item fixtures either -/// ride real vanilla entries (`module/src/seed.rs`'s starter weapon, Hearthstone, Soul Shard) or -/// sit inside the project-wide `509xxxx` band (`FIXTURE_BLADE`/`FIXTURE_JERKY`/`FIXTURE_REINS`, -/// `module/src/seed/fixtures.rs`) — so the project-wide band is the whole check. #[must_use] pub const fn is_fixture_reserved_item_id(entry: u32) -> bool { entry >= FIXTURE_RESERVED_ID_FLOOR && entry <= FIXTURE_RESERVED_ID_CEIL } -// =============================================================================================== -// script -// =============================================================================================== - -/// Lowest `game_script.script_id` a Package may ship. -/// -/// The band formula's third application, and the case where its first clause is vacuous. A -/// Runtime Script has no client-side counterpart at all: no DBC holds one, no import reads one, and -/// no client ever names one. "Two decimal orders above the highest identifier a real client can -/// hold" is therefore satisfied by any positive number, and the band is free to sit LOW rather than -/// being pushed above the reserved span the way the spell band was. -/// -/// It sits low deliberately. Every `game_script` row is Package-shipped, so the leading digits are -/// not distinguishing a Package row from an imported one — there are no imported ones. What they -/// distinguish is a shipped script from the two values that are not scripts: `0`, which -/// `game_spell_effect.script_id` already uses to mean "pure data, no script", and the -/// `1`..=99,999 span left free below this floor for anything the engine may one day ship itself. -/// Six digits, and the hundred-thousands column says "a Package shipped this script". pub const PACKAGE_SCRIPT_ID_FLOOR: u32 = 100_000; -/// Highest `game_script.script_id` a Package may ship. Nine hundred thousand identifiers, against a -/// realm that will run tens of scripts. pub const PACKAGE_SCRIPT_ID_CEIL: u32 = 999_999; const _: () = assert!(PACKAGE_SCRIPT_ID_FLOOR <= PACKAGE_SCRIPT_ID_CEIL); const _: () = assert!(PACKAGE_SCRIPT_ID_FLOOR > 0); -/// The whole band sits below every reserved band, which is what makes a fixture-reserved script -/// identifier unspellable rather than merely refused: there is no runtime fixture check for scripts -/// because a value inside a reserved band cannot pass the band check above it. Moving either number -/// into the reserved span fails the build here instead of quietly making that true. const _: () = assert!(PACKAGE_SCRIPT_ID_CEIL < RESERVED_ID_FLOOR); -/// True when a Package may ship a Runtime Script at this identifier. #[must_use] pub const fn is_package_script_id(script_id: u32) -> bool { script_id >= PACKAGE_SCRIPT_ID_FLOOR && script_id <= PACKAGE_SCRIPT_ID_CEIL } -// =============================================================================================== -// quests -// =============================================================================================== - -/// Lowest quest identifier a Package may INSERT. -/// -/// The third application of the band formula in this module's header, one decade above the -/// Package item range so the millions column keeps naming a family: `6` spell, `7` item, `8` -/// quest. A cmangos-shaped `quest_template` dump for this client build stays far below this floor, -/// so, as for items, [`RESERVED_ID_CEIL`] is the binding constraint, not the client headroom. -/// -/// One band covers the whole quest family, the same way the Package spell range covers both -/// `game_spell` and `game_spell_effect`: every child table (`game_quest_text` and the rest) names -/// its owning quest by `quest_entry`, so a child row is only ever as Package-owned as the quest it -/// belongs to. Nothing below checks a child table's own surrogate `id` against this band. pub const PACKAGE_QUEST_ID_FLOOR: u32 = 8_000_000; -/// Highest quest identifier a Package may INSERT. pub const PACKAGE_QUEST_ID_CEIL: u32 = 8_999_999; const _: () = assert!(PACKAGE_QUEST_ID_FLOOR <= PACKAGE_QUEST_ID_CEIL); const _: () = assert!(RESERVED_ID_CEIL < PACKAGE_QUEST_ID_FLOOR); -/// True when a Package may INSERT a quest at this identifier. #[must_use] pub const fn is_package_quest_id(entry: u32) -> bool { entry >= PACKAGE_QUEST_ID_FLOOR && entry <= PACKAGE_QUEST_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. Quests have no family-specific fixture cluster of their own, so the project-wide -/// band is the whole check, the same shape as [`is_fixture_reserved_item_id`]. #[must_use] pub const fn is_fixture_reserved_quest_id(entry: u32) -> bool { entry >= FIXTURE_RESERVED_ID_FLOOR && entry <= FIXTURE_RESERVED_ID_CEIL } -/// Highest `obj_index` a claim may name on `game_quest_objective` or `game_quest_cast_objective`. -/// -/// [`crate::quest::MAX_OBJECTIVES`](../../../module/src/quest.rs) caps a quest at 4 objective -/// slots (0-based), the same "real domain is narrower than the packed field width" shape -/// [`MAX_SPELL_EFFECT_INDEX`] documents for spell effects. pub const MAX_QUEST_OBJECTIVE_INDEX: u8 = 3; -/// Highest `choice_index` a claim may name on `game_quest_reward_choice`. -/// -/// `QuestRewardChoice::choice_index` is documented `0..=5` in the Module (cmangos carries six -/// `RewChoiceItemId`/`Count` slots per quest). pub const MAX_QUEST_REWARD_CHOICE_INDEX: u8 = 5; -/// Packed `game_quest_objective` / `game_quest_cast_objective` primary key: -/// `(quest_entry << 8) | obj_index`. -/// -/// Neither table's real `id` column derives from a formula the way `game_spell_effect.id` does. -/// both are plain surrogates the importer assigns in dump order. A Package Delta needs a -/// deterministic key it can compute without a live counter, so it derives one here instead, -/// mirroring [`packed_spell_effect_id`]'s shape: the wide component low, the narrow index high -/// enough to never collide with it. `obj_index` is bounded by [`MAX_QUEST_OBJECTIVE_INDEX`], so 8 -/// bits is more room than it ever uses; `quest_entry` keeps its full `u32` width below that. #[must_use] pub const fn packed_quest_objective_id(quest_entry: u32, obj_index: u8) -> u64 { (quest_entry as u64) << 8 | obj_index as u64 } -/// Packed `game_quest_reward_choice` primary key: `(quest_entry << 8) | choice_index`. -/// -/// Same shape and reasoning as [`packed_quest_objective_id`]; `choice_index` is bounded by -/// [`MAX_QUEST_REWARD_CHOICE_INDEX`]. #[must_use] pub const fn packed_quest_reward_choice_id(quest_entry: u32, choice_index: u8) -> u64 { (quest_entry as u64) << 8 | choice_index as u64 } -/// Packed `game_quest_reward_item` primary key: `(quest_entry << 32) | item_entry`. -/// -/// A quest does not guarantee-reward the same item through two separate rows, so the pair is -/// already a natural unique key. Packing it needs no bound beyond each half's own `u32` width, -/// the two exactly fill a `u64`. #[must_use] pub const fn packed_quest_reward_item_id(quest_entry: u32, item_entry: u32) -> u64 { (quest_entry as u64) << 32 | item_entry as u64 } -// =============================================================================================== -// loot -// =============================================================================================== - -/// Lowest loot-row identifier a Package may INSERT. -/// -/// The fourth application of the band formula, one decade above the Package quest range: `9` -/// names a Package loot row. -/// -/// Unlike the families above, no loot table's owning entity is ever Package-invented. A -/// `game_pickpocket_loot` row's `creature_entry`, a `game_gameobject_loot` row's `loot_id`, a -/// `game_skinning_loot` row's `skin_loot_id`, and a `game_fishing_loot` row's `zone_id` all name -/// real client data (creatures, gameobjects, and zones stay out of this issue's scope). So this -/// band is checked against a loot row's OWN surrogate `id`, the same way [`PACKAGE_ITEM_ID_FLOOR`] -/// is checked against `game_item_template.entry`, not against an owning identifier the way the -/// quest family's child tables are. One band still covers the whole family: the four loot tables -/// are independent `SpacetimeDB` tables with independent primary-key spaces, so sharing one range -/// across them cannot collide. pub const PACKAGE_LOOT_ID_FLOOR: u64 = 9_000_000; -/// Highest loot-row identifier a Package may INSERT. pub const PACKAGE_LOOT_ID_CEIL: u64 = 9_999_999; const _: () = assert!(PACKAGE_LOOT_ID_FLOOR <= PACKAGE_LOOT_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_LOOT_ID_FLOOR); -/// True when a Package may INSERT a loot row at this identifier. #[must_use] pub const fn is_package_loot_id(id: u64) -> bool { id >= PACKAGE_LOOT_ID_FLOOR && id <= PACKAGE_LOOT_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. Loot rows have no family-specific fixture cluster of their own, so the project-wide -/// band is the whole check. #[must_use] pub const fn is_fixture_reserved_loot_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -// =============================================================================================== -// casts -// =============================================================================================== - -/// Lowest `game_creature_spell.id` a Package may INSERT. -/// -/// The fifth application of the band formula, one decade above the Package loot range: `10` names -/// a Package cast row. Past 9,999,999 the millions column takes two digits, which is the cost of -/// keeping the family-at-a-glance signal going. -/// -/// `game_creature_cast` carries no band of its own: its primary key is `creature_entry`, a creature -/// template entry, which no Package may invent -/// ([`crate::DeltaError::InsertNotSupported`]). Only `game_creature_spell`'s own surrogate `id` is -/// checked here, the loot shape. pub const PACKAGE_CAST_ID_FLOOR: u64 = 10_000_000; -/// Highest `game_creature_spell.id` a Package may INSERT. pub const PACKAGE_CAST_ID_CEIL: u64 = 10_999_999; const _: () = assert!(PACKAGE_CAST_ID_FLOOR <= PACKAGE_CAST_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_CAST_ID_FLOOR); -/// True when a Package may INSERT a `game_creature_spell` row at this identifier. #[must_use] pub const fn is_package_cast_id(id: u64) -> bool { id >= PACKAGE_CAST_ID_FLOOR && id <= PACKAGE_CAST_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The cast family has no fixture cluster of its own, so the project-wide band is the -/// whole check, the same shape as [`is_fixture_reserved_loot_id`]. #[must_use] pub const fn is_fixture_reserved_cast_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -// =============================================================================================== -// trainers -// =============================================================================================== - -/// Lowest `game_trainer_spell.id` a Package may INSERT. -/// -/// The sixth application of the band formula, one decade above the Package cast range: `11` names -/// a Package trainer row. -/// -/// Distinct from [`CURATED_RESERVED_ID_CEIL`]'s `CURATED_TRAINER_ID_BASE` span -/// (`importer/src/spell.rs`, 5,200,000 plus a 500-wide span): that band is where the IMPORTER hands -/// out fixed identifiers to curated overrides it invents rather than reads, already covered by -/// [`RESERVED_ID_CEIL`]. This band is where a PACKAGE may insert one. The two are never merged. pub const PACKAGE_TRAINER_ID_FLOOR: u64 = 11_000_000; -/// Highest `game_trainer_spell.id` a Package may INSERT. pub const PACKAGE_TRAINER_ID_CEIL: u64 = 11_999_999; const _: () = assert!(PACKAGE_TRAINER_ID_FLOOR <= PACKAGE_TRAINER_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_TRAINER_ID_FLOOR); -/// True when a Package may INSERT a `game_trainer_spell` row at this identifier. #[must_use] pub const fn is_package_trainer_id(id: u64) -> bool { id >= PACKAGE_TRAINER_ID_FLOOR && id <= PACKAGE_TRAINER_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The trainer family has no fixture cluster of its own, so the project-wide band is -/// the whole check, the same shape as [`is_fixture_reserved_loot_id`]. #[must_use] pub const fn is_fixture_reserved_trainer_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -// =============================================================================================== -// gossip -// =============================================================================================== - -/// Lowest identifier a Package may INSERT in the gossip family. -/// -/// The seventh application of the band formula, one decade above the Package trainer range: `12` -/// names a Package gossip row. One band covers the whole family, the loot shape: five independent -/// `SpacetimeDB` tables with independent primary-key spaces cannot collide by sharing a range. It -/// is checked against `game_gossip_menu_profile.menu_id`, `game_gossip_menu_profile_option.row_id`, -/// `game_gossip_option.row_id`, `game_npc_text.text_id` and `game_npc_text_slot.id`. -/// -/// `game_gossip_menu` carries no band: its primary key is a creature template entry, which no -/// Package may invent ([`crate::DeltaError::InsertNotSupported`]). -/// -/// The band sits above every identifier the gossip base import spares by range: that import deletes -/// `text_id` / `row_id` / `id` below 50,000 and `game_gossip_menu.entry` below 1,000,000 -/// (`importer/src/main.rs`, `docs/danger-zones.md`), so a Package row in those tables survives a -/// base reload. The two menu-profile tables it clears whole; a Package row there is restored by the -/// Package Delta stage the reload runs next, the way every family's reload restores its claims. pub const PACKAGE_GOSSIP_ID_FLOOR: u64 = 12_000_000; -/// Highest identifier a Package may INSERT in the gossip family. pub const PACKAGE_GOSSIP_ID_CEIL: u64 = 12_999_999; const _: () = assert!(PACKAGE_GOSSIP_ID_FLOOR <= PACKAGE_GOSSIP_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_GOSSIP_ID_FLOOR); const _: () = assert!(PACKAGE_TRAINER_ID_CEIL < PACKAGE_GOSSIP_ID_FLOOR); -/// True when a Package may INSERT a gossip row at this identifier. #[must_use] pub const fn is_package_gossip_id(id: u64) -> bool { id >= PACKAGE_GOSSIP_ID_FLOOR && id <= PACKAGE_GOSSIP_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The gossip family has no fixture cluster of its own, so the project-wide band is the -/// whole check, the same shape as [`is_fixture_reserved_loot_id`]. #[must_use] pub const fn is_fixture_reserved_gossip_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -// =============================================================================================== -// globals -// =============================================================================================== - -/// Lowest identifier a Package may INSERT in the globals family. -/// -/// The eighth application of the band formula, one decade above the Package gossip range: `13` -/// names a Package globals row. Checked against `game_graveyard_zone.row_id`, -/// `game_createinfo_spell.id` and `game_createinfo_action.row_id` — the three tables of the family -/// whose key is a free surrogate. -/// -/// The family's other four tables carry no band at all, because none of their keys is free to -/// invent: `game_class_level_stats`, `game_level_stats` and `game_start_position` key on a race, -/// class and level the client fixes, and `game_areatrigger_teleport` keys on an `AreaTrigger.dbc` -/// trigger id. All four are update-only ([`crate::DeltaError::InsertNotSupported`]). pub const PACKAGE_GLOBALS_ID_FLOOR: u64 = 13_000_000; -/// Highest identifier a Package may INSERT in the globals family. pub const PACKAGE_GLOBALS_ID_CEIL: u64 = 13_999_999; const _: () = assert!(PACKAGE_GLOBALS_ID_FLOOR <= PACKAGE_GLOBALS_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_GLOBALS_ID_FLOOR); const _: () = assert!(PACKAGE_GOSSIP_ID_CEIL < PACKAGE_GLOBALS_ID_FLOOR); -/// True when a Package may INSERT a globals row at this identifier. #[must_use] pub const fn is_package_globals_id(id: u64) -> bool { id >= PACKAGE_GLOBALS_ID_FLOOR && id <= PACKAGE_GLOBALS_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The globals family has no fixture cluster of its own, so the project-wide band is the -/// whole check. #[must_use] pub const fn is_fixture_reserved_globals_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -/// Highest `level` a claim may name on `game_class_level_stats` or `game_level_stats`. -/// -/// Vanilla stops at 60 and the cmangos stat curves carry no row above it, so a claim past this -/// would name a row that cannot exist. The same "real domain is narrower than the packed field -/// width" shape [`MAX_SPELL_EFFECT_INDEX`] documents: the packed key reserves a whole byte. pub const MAX_STATS_LEVEL: u8 = 60; -/// Packed `game_class_level_stats` primary key: `(class << 8) | level`. -/// -/// Already canonical and load-bearing in the Module (`module/src/stats.rs`'s `class_level_key`), -/// which is why this restates the formula rather than inventing one the way -/// [`packed_quest_objective_id`] had to. A Package Delta never authors the packed value; it names -/// the class and the level, and this derives the key. #[must_use] pub const fn packed_class_level_id(class: u8, level: u8) -> u64 { ((class as u64) << 8) | level as u64 } -/// Packed `game_level_stats` primary key: `(race << 16) | (class << 8) | level`. -/// -/// The Module's `race_class_level_key` (`module/src/stats.rs`), restated for the same reason as -/// [`packed_class_level_id`]. #[must_use] pub const fn packed_race_class_level_id(race: u8, class: u8, level: u8) -> u64 { ((race as u64) << 16) | ((class as u64) << 8) | level as u64 } -/// Packed `game_start_position` primary key: `(race << 8) | class`. -/// -/// The Module packs the same pair into a `u16` (`module/src/config.rs`'s `StartPosition`), and -/// `module/src/config.rs`'s `game_start_item` uses the identical packing for its own `race_class`. #[must_use] pub const fn packed_race_class_id(race: u8, class: u8) -> u64 { ((race as u64) << 8) | class as u64 } -// =============================================================================================== -// spellmeta -// =============================================================================================== - -/// Lowest `game_spell_learn.id` a Package may INSERT. -/// -/// The ninth application of the band formula, one decade above the Package globals range: `14` -/// names a Package spell-metadata row. -/// -/// It covers ONE table. `game_spell_chain` and `game_spell_proc_event` both key on a spell -/// identifier, not a surrogate, so what a Package may invent there is exactly what it may invent in -/// `game_spell` — [`PACKAGE_SPELL_ID_FLOOR`]..=[`PACKAGE_SPELL_ID_CEIL`], and -/// [`is_fixture_reserved_spell_id`] for the fixtures. Giving those two tables a band of their own -/// would let a Package write metadata for a spell that cannot exist. pub const PACKAGE_SPELLMETA_ID_FLOOR: u64 = 14_000_000; -/// Highest `game_spell_learn.id` a Package may INSERT. pub const PACKAGE_SPELLMETA_ID_CEIL: u64 = 14_999_999; const _: () = assert!(PACKAGE_SPELLMETA_ID_FLOOR <= PACKAGE_SPELLMETA_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_SPELLMETA_ID_FLOOR); const _: () = assert!(PACKAGE_GLOBALS_ID_CEIL < PACKAGE_SPELLMETA_ID_FLOOR); -/// True when a Package may INSERT a `game_spell_learn` row at this identifier. #[must_use] pub const fn is_package_spellmeta_id(id: u64) -> bool { id >= PACKAGE_SPELLMETA_ID_FLOOR && id <= PACKAGE_SPELLMETA_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. `game_spell_learn` has no fixture cluster of its own, so the project-wide band is the -/// whole check. Its two spell-keyed siblings use [`is_fixture_reserved_spell_id`] instead, which -/// also covers the seeded fixture spell cluster. #[must_use] pub const fn is_fixture_reserved_spellmeta_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 } -// =============================================================================================== -// creatures -// =============================================================================================== - -/// Highest value the creature guid encoding leaves for a creature template entry and for a spawn -/// identifier. -/// -/// `game_creature_spawn.guid` packs both into 24-bit fields (`importer/src/main.rs`'s `world_guid`, -/// restated by [`packed_creature_spawn_guid`]), so an entry or a spawn identifier above this would -/// wrap and address another creature's row. Every creature band below is asserted against it. +// Creature GUIDs allocate 24 bits each to the template entry and spawn identifier. pub const MAX_CREATURE_GUID_COMPONENT: u32 = 0x00FF_FFFF; -/// Lowest identifier a Package may INSERT in the creatures family. -/// -/// The tenth application of the band formula in this module's header, one decade above the Package -/// spell metadata range: `15` names a Package creature row. One band covers both of the family's -/// insertable tables, the loot shape — `game_creature_template.entry` and a `game_creature_spawn` -/// claim's `spawn_id`, which are independent identifier spaces. -/// -/// The ceiling has a second constraint the earlier families did not: both components sit in 24-bit -/// fields of the spawn guid, so the whole band must stay under -/// [`MAX_CREATURE_GUID_COMPONENT`]. It does, with room to spare, which is also why the gameobject -/// band above it is the last one this encoding can hold. pub const PACKAGE_CREATURE_ID_FLOOR: u32 = 15_000_000; -/// Highest identifier a Package may INSERT in the creatures family. pub const PACKAGE_CREATURE_ID_CEIL: u32 = 15_999_999; -/// Lowest creature template entry in the seeded fixture cluster (`module/src/seed/fixtures.rs`'s -/// Test Wolf and its siblings). The Module asserts its own fixture creatures stay inside -/// `51_000..52_000`, one decade above the fixture spell cluster. pub const FIXTURE_CREATURE_ID_FLOOR: u32 = 51_000; -/// Highest creature template entry in the seeded fixture cluster. pub const FIXTURE_CREATURE_ID_CEIL: u32 = 51_999; const _: () = assert!(FIXTURE_CREATURE_ID_FLOOR <= FIXTURE_CREATURE_ID_CEIL); @@ -620,44 +265,24 @@ const _: () = assert!(FIXTURE_CREATURE_ID_CEIL < FIXTURE_RESERVED_ID_FLOOR); const _: () = assert!(PACKAGE_CREATURE_ID_FLOOR <= PACKAGE_CREATURE_ID_CEIL); const _: () = assert!(RESERVED_ID_CEIL < PACKAGE_CREATURE_ID_FLOOR); const _: () = assert!(PACKAGE_SPELLMETA_ID_CEIL < PACKAGE_CREATURE_ID_FLOOR as u64); -/// Both halves of a creature spawn guid are 24 bits wide. A band that ran past them would let two -/// Package spawns share one durable guid. const _: () = assert!(PACKAGE_CREATURE_ID_CEIL <= MAX_CREATURE_GUID_COMPONENT); -/// True when a Package may INSERT a creature template or a creature spawn at this identifier. #[must_use] pub const fn is_package_creature_id(id: u32) -> bool { id >= PACKAGE_CREATURE_ID_FLOOR && id <= PACKAGE_CREATURE_ID_CEIL } -/// True when the creature TEMPLATE entry belongs to a seeded fixture. No Package may claim one, -/// under any operation, for the reason [`is_fixture_reserved_spell_id`] gives: a fixture row is -/// what the Module's own tests assert against. -/// -/// Both bands, the spell shape. A spawn identifier takes -/// [`is_fixture_reserved_creature_spawn_id`] instead — the `51xxx` cluster is a template-entry -/// space, and real imported spawn identifiers run straight through it. #[must_use] pub const fn is_fixture_reserved_creature_id(id: u32) -> bool { (id >= FIXTURE_CREATURE_ID_FLOOR && id <= FIXTURE_CREATURE_ID_CEIL) || (id >= FIXTURE_RESERVED_ID_FLOOR && id <= FIXTURE_RESERVED_ID_CEIL) } -/// True when the creature SPAWN identifier belongs to a seeded fixture: the project-wide band -/// alone, because the fixture cluster above names template entries rather than spawns. #[must_use] pub const fn is_fixture_reserved_creature_spawn_id(id: u32) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR && id <= FIXTURE_RESERVED_ID_CEIL } -/// Packed `game_creature_spawn` primary key: `(HIGHGUID_UNIT << 48) | (entry << 24) | spawn_id`. -/// -/// Already canonical and load-bearing in the importer (`importer/src/main.rs`'s `world_guid`), -/// which is why this restates the formula rather than inventing one, the way -/// [`packed_class_level_id`] restates the Module's. A Package Delta never authors the guid; it -/// names the creature template and the spawn identifier, and this derives the key. Both components -/// are masked to their 24-bit fields, which is what [`MAX_CREATURE_GUID_COMPONENT`] guards the -/// band against. #[must_use] pub const fn packed_creature_spawn_guid(entry: u32, spawn_id: u32) -> u64 { const HIGHGUID_UNIT: u64 = 0xF130; @@ -666,88 +291,44 @@ pub const fn packed_creature_spawn_guid(entry: u32, spawn_id: u32) -> u64 { | (spawn_id & MAX_CREATURE_GUID_COMPONENT) as u64 } -// =============================================================================================== -// gameobjects -// =============================================================================================== - -/// Lowest identifier a Package may INSERT in the gameobjects family. -/// -/// The eleventh application of the band formula, one decade above the Package creature range: `16` -/// names a Package gameobject row. One band covers the family's three insertable tables — -/// `game_gameobject_template.entry`, `game_gameobject_trap.entry` and a `game_gameobject` claim's -/// `spawn_id`. The first two share ONE identifier space on purpose: a trap row describes the -/// template of the same entry, so a Package trap is exactly as Package-owned as its template. pub const PACKAGE_GAMEOBJECT_ID_FLOOR: u32 = 16_000_000; -/// Highest identifier a Package may INSERT in the gameobjects family. pub const PACKAGE_GAMEOBJECT_ID_CEIL: u32 = 16_999_999; const _: () = assert!(PACKAGE_GAMEOBJECT_ID_FLOOR <= PACKAGE_GAMEOBJECT_ID_CEIL); const _: () = assert!(RESERVED_ID_CEIL < PACKAGE_GAMEOBJECT_ID_FLOOR); const _: () = assert!(PACKAGE_CREATURE_ID_CEIL < PACKAGE_GAMEOBJECT_ID_FLOOR); -/// True when a Package may INSERT a gameobject template, trap or spawn at this identifier. #[must_use] pub const fn is_package_gameobject_id(id: u32) -> bool { id >= PACKAGE_GAMEOBJECT_ID_FLOOR && id <= PACKAGE_GAMEOBJECT_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The gameobjects family has no fixture cluster of its own, so the project-wide band -/// is the whole check. #[must_use] pub const fn is_fixture_reserved_gameobject_id(id: u32) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR && id <= FIXTURE_RESERVED_ID_CEIL } -/// Packed `game_gameobject` primary key: `(HIGHGUID_GAMEOBJECT << 48) | spawn_id`. -/// -/// The importer's `go_guid` (`importer/src/main.rs`), restated for the same reason as -/// [`packed_creature_spawn_guid`]. A gameobject guid carries no template entry, so the spawn -/// identifier alone fills the low 48 bits — the whole Package gameobject band fits with four -/// decimal orders to spare, and `module/src/gameobject.rs`'s pool tag at bit 47 stays clear of it. #[must_use] pub const fn packed_gameobject_spawn_guid(spawn_id: u32) -> u64 { const HIGHGUID_GAMEOBJECT: u64 = 0xF110; (HIGHGUID_GAMEOBJECT << 48) | spawn_id as u64 } -// =============================================================================================== -// creature-ai -// =============================================================================================== - -/// Lowest identifier a Package may INSERT in the creature-ai family. -/// -/// The twelfth application of the band formula in this module's header, one decade above the -/// Package gameobject range: `17` names a Package `EventAI` row. One band covers the family's three -/// insertable tables — `game_creature_ai_broadcast_text.id`, `game_creature_ai_summon.id` and -/// `game_quest_event_requirement.id`, three independent identifier spaces, the loot shape. -/// -/// Stated at `u64` width because the third of those columns is a `u64` and the first two are -/// `u32`s that widen into it. The ceiling still fits a `u32` with three decimal orders to spare, -/// which the assert below holds. pub const PACKAGE_CREATURE_AI_ID_FLOOR: u64 = 17_000_000; -/// Highest identifier a Package may INSERT in the creature-ai family. pub const PACKAGE_CREATURE_AI_ID_CEIL: u64 = 17_999_999; const _: () = assert!(PACKAGE_CREATURE_AI_ID_FLOOR <= PACKAGE_CREATURE_AI_ID_CEIL); const _: () = assert!((RESERVED_ID_CEIL as u64) < PACKAGE_CREATURE_AI_ID_FLOOR); const _: () = assert!((PACKAGE_GAMEOBJECT_ID_CEIL as u64) < PACKAGE_CREATURE_AI_ID_FLOOR); -/// Two of the three keys are `u32` columns, so a band that ran past `u32::MAX` could not be -/// claimed in them at all. const _: () = assert!(PACKAGE_CREATURE_AI_ID_CEIL <= u32::MAX as u64); -/// True when a Package may INSERT a broadcast text, a summon placement or a quest event -/// requirement at this identifier. #[must_use] pub const fn is_package_creature_ai_id(id: u64) -> bool { id >= PACKAGE_CREATURE_AI_ID_FLOOR && id <= PACKAGE_CREATURE_AI_ID_CEIL } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. The creature-ai family has no fixture cluster of its own, so the project-wide band -/// is the whole check. #[must_use] pub const fn is_fixture_reserved_creature_ai_id(id: u64) -> bool { id >= FIXTURE_RESERVED_ID_FLOOR as u64 && id <= FIXTURE_RESERVED_ID_CEIL as u64 diff --git a/crates/lyracore-package-delta/src/lib.rs b/crates/lyracore-package-delta/src/lib.rs index e6c0d0d3c..8fc3195c0 100644 --- a/crates/lyracore-package-delta/src/lib.rs +++ b/crates/lyracore-package-delta/src/lib.rs @@ -1,9 +1,9 @@ -//! Package Delta format. See `docs/package-delta-format.md` for the contract and rationale. +//! Before changing artifact parsing or claims, read docs/package-delta-format.md. #![forbid(unsafe_code)] -#![warn(missing_docs, clippy::pedantic)] -// The parser narrows every integer with an explicit bound check immediately before the cast, so the -// `as` conversions in `delta.rs` are the checked ones, not the lossy ones this lint looks for. +#![warn(clippy::pedantic)] +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] +// Integer casts follow explicit bounds checks in the parser. #![allow(clippy::cast_possible_truncation)] mod canonical; @@ -54,13 +54,6 @@ pub use script::{ }; pub use trace::{trace, ClaimConflict, ClaimTrace, ClaimedField, TracedRow}; -/// Reads an artifact and writes it back in canonical form. -/// -/// Equivalent input produces identical bytes, whatever member order, whitespace or number spelling -/// it arrived with. -/// -/// # Errors -/// Any [`DeltaError`] the parse would raise. pub fn canonicalize(json: &str) -> Result { Ok(PackageDelta::parse(json)?.to_canonical_json()) } diff --git a/crates/lyracore-package-delta/src/schema.rs b/crates/lyracore-package-delta/src/schema.rs index e70f92d96..89407afa2 100644 --- a/crates/lyracore-package-delta/src/schema.rs +++ b/crates/lyracore-package-delta/src/schema.rs @@ -1,259 +1,74 @@ -//! The tables a Package Delta may claim, their claimable columns, and the typed values those -//! columns take. -//! -//! The catalogue is hand-written against the Module's own table definitions. It is deliberately a -//! closed list: an unknown table or column fails the parse instead of reaching a writer that would -//! discover the problem against a live shard. -//! -//! # Import Families -//! -//! Every table belongs to exactly one Import Family, the unit a base import clears and reloads and -//! the unit a Package Delta apply runs for. [`Table`] is one closed enum whose variants are grouped -//! by family rather than a family registry, so the catalogue stays one list and every `match` on it -//! stays exhaustive. - use core::fmt; -/// The Import Family that owns the spell tables. pub const SPELL_FAMILY: &str = "spell"; -/// The Import Family that owns the item catalogue. The same name the `--dump` importer's `items` -/// `--family` block stamps, so an apply for this family lines up with the base import it follows. pub const ITEM_FAMILY: &str = "items"; -/// The Import Family that owns the quest tables. The same name the `--dump` importer's `quests` -/// `--family` block stamps. pub const QUEST_FAMILY: &str = "quests"; -/// The Import Family that owns the non-creature loot tables (pickpocket, gameobject/chest, -/// skinning, fishing). The same name the `--dump` importer's `loot` `--family` block stamps. -/// `game_creature_loot` itself reloads under the `items` family (`importer/src/main.rs`) and is -/// out of this issue's scope. See this crate's `lib.rs` for why. pub const LOOT_FAMILY: &str = "loot"; -/// The Import Family that owns the caster-creature spell tables (`game_creature_cast`, -/// `game_creature_spell`). The same name the `--dump` importer's `casts` `--family` block stamps. pub const CAST_FAMILY: &str = "casts"; -/// The Import Family that owns `game_trainer_spell`. The same name the `--dump` importer's -/// `trainers` `--family` block stamps. pub const TRAINER_FAMILY: &str = "trainers"; -/// The Import Family that owns the gossip tables: the per-creature menu, the menu profiles a -/// Package or a relay selects at runtime, their options, and the NPC greeting text they resolve to. -/// The same name the `--dump` importer's `gossip` `--family` block stamps. pub const GOSSIP_FAMILY: &str = "gossip"; -/// The Import Family that owns the small world-wide reference tables: the two stat curves, the -/// start position, the graveyard zone links, the areatrigger destinations, and the two createinfo -/// tables. The same name the `--dump` importer's `globals` `--family` block stamps. -/// -/// `game_start_item` is loaded by the same block and is deliberately NOT in this catalogue. That -/// table has two owners: the `--dump` globals pass owns identifiers from 1,000,000 up, and the -/// `--dbc` `CharStartOutfit` pass owns everything below. Only the first has a Package Delta stage, -/// so a claim on a `--dbc`-owned row would be reverted by a pass this family cannot follow, with -/// nothing to reapply it. -/// -/// `game_graveyard` and `game_area_trigger` are NOT in it either, for a plainer reason: both are -/// `--dbc` tables, not part of this family's block at all. They are still what this family's -/// references are checked against. pub const GLOBALS_FAMILY: &str = "globals"; -/// The Import Family that owns the spell metadata tables: the rank chain, the auto-learn -/// dependents, and the proc overlay. The same name the `--dump` importer's `spellmeta` `--family` -/// block stamps. -/// -/// Distinct from [`SPELL_FAMILY`], which owns the spell catalogue itself. Two of this family's -/// three tables key on a spell identifier, so they take the spell family's identifier policy while -/// still belonging to this family's apply. pub const SPELLMETA_FAMILY: &str = "spellmeta"; -/// The Import Family that owns the creature catalogue and its spawns -/// (`game_creature_template`, `game_creature_spawn`). The same name the `--dump` importer's -/// `creatures` `--family` block stamps. -/// -/// `game_creature_waypoint` is loaded by the same block and is deliberately NOT in this catalogue. -/// A waypoint names its creature by spawn guid and carries no map of its own, so a claim on one -/// states no map for the routing rule every spatial claim is filtered by. Reaching a patrol from a -/// Package is a named gap, not a table this build can route. pub const CREATURE_FAMILY: &str = "creatures"; -/// The Import Family that owns the gameobject catalogue and its spawns -/// (`game_gameobject_template`, `game_gameobject_trap`, `game_gameobject`). The same name the -/// `--dump` importer's `gameobjects` `--family` block stamps. -/// -/// `game_gameobject_pool` and `game_gameobject_pool_member` are NOT in it. No base import writes -/// either: they are seeded (`module/src/seed.rs`) or built by a debug reducer, so there is no -/// family reload for a pool claim to replay after, and a pool member only becomes a live node when -/// something arms the pool. Worth a maintainer's second look if authored pools are wanted. pub const GAMEOBJECT_FAMILY: &str = "gameobjects"; -/// The Import Family that owns the `EventAI` catalogue: the lines a speak action says, the placements -/// a summon action spawns at, and the quests that need their source event before ordinary -/// objectives can complete them. The same name the `--dump` importer's `creature-ai` `--family` -/// block stamps. -/// -/// The family's scripted definitions are deliberately NOT in this catalogue. -/// `game_creature_ai_definition` carries a creature's whole rule set as a nested -/// `Vec`, which no scalar column shape can state; the only claim left would be one -/// opaque blob, and a Package Delta claims typed rows, never a script blob. Its base import is a -/// whole-family reducer replace rather than row SQL, so there is no row for a claim to merge into -/// either. `game_creature_ai_relay_definition` is out for both of those reasons. -/// -/// `game_creature_ai_event` is out for a plainer one. It is the retained flat migration schema: no -/// import writes it and no runtime reads it (`module/src/creatures/eventai/tables.rs`), so a claim -/// there would apply cleanly and change nothing a player could see. `game_creature_ai_state` and -/// `game_creature_ai_rule_state` are live state a running Shard writes, not import data. -/// `game_creature_ai_spell_metadata` is loaded by the `--dbc` spell pass, not by this family's -/// block, so a claim on it would be reverted by an import this family cannot follow. -/// -/// Reaching a creature's rules from a Package is a named gap, not a table this build can claim. pub const CREATURE_AI_FAMILY: &str = "creature-ai"; -/// A table a Package Delta may claim rows in. The names are the durable table names, so an applier -/// needs no translation step. -/// -/// The variants are grouped by Import Family, in that family's canonical order. A family arrives as -/// a block of variants; every `match` on this enum that carries no wildcard then fails to compile -/// until the new tables are handled, which is how the catalogue, the identifier rules, the key -/// shapes and the Module's setters stay in step. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Table { - // ---- spell ---- - /// `game_spell` — the spell header. Spell, - /// `game_spell_effect` — one effect of a spell. SpellEffect, - // ---- items ---- - /// `game_item_template` — the item catalogue header. Item, - // ---- quests ---- - /// `game_quest_template`, the quest header. Quest, - /// `game_quest_text`, a quest's free-text body, 1:1 by `quest_entry`. QuestText, - /// `game_quest_objective`, one kill/collect/use objective slot of a quest. QuestObjective, - /// `game_quest_cast_objective`, the spell-cast requirement on one objective slot. QuestCastObjective, - /// `game_quest_reward_item`, one guaranteed turn-in reward item. QuestRewardItem, - /// `game_quest_reward_choice`, one pick-1-of-N turn-in reward item. QuestRewardChoice, - // ---- loot ---- - /// `game_pickpocket_loot`, a creature's pickpocket table. PickpocketLoot, - /// `game_gameobject_loot`, a lootable gameobject's chest table. GameobjectLoot, - /// `game_skinning_loot`, a level-banded skinning table. SkinningLoot, - /// `game_fishing_loot`, a zone's fishing table. FishingLoot, - // ---- casts ---- - /// `game_creature_cast` — a caster creature's single nuke/debuff spell. Update-only: the - /// primary key names a creature template, which no Package may invent. See - /// [`crate::DeltaError::InsertNotSupported`]. CreatureCast, - /// `game_creature_spell` — one rotation-spell row of a caster creature. CreatureSpell, - // ---- trainers ---- - /// `game_trainer_spell` — one spell a trainer teaches. TrainerSpell, - // ---- gossip ---- - /// `game_gossip_menu` — which greeting text a creature template shows. Update-only: the - /// primary key names a creature template, which no Package may invent. See - /// [`crate::DeltaError::InsertNotSupported`]. GossipMenu, - /// `game_gossip_menu_profile` — a source gossip menu kept by menu id, for runtime selection. - /// Insert and partial update: `menu_id` is an identifier space of its own, not a creature. GossipMenuProfile, - /// `game_gossip_menu_profile_option` — one clickable line of a menu profile. Insert and - /// partial update on its own surrogate `row_id`. GossipMenuProfileOption, - /// `game_gossip_option` — one clickable line of a creature's own menu. Insert and partial - /// update on its own surrogate `row_id`; the creature it belongs to is a claimed column, not - /// the key. GossipOption, - /// `game_npc_text` — one greeting body, by text id. Insert and partial update. NpcText, - /// `game_npc_text_slot` — one weighted greeting variant of a text id. Insert and partial - /// update on its own surrogate `id`. NpcTextSlot, - // ---- globals ---- - /// `game_class_level_stats` — base health and mana for one (class, level). Update-only: the - /// key names a class and a level the client fixes, neither of which a Package may invent. ClassLevelStats, - /// `game_level_stats` — the five base attributes for one (race, class, level). Update-only, - /// for the same reason as [`Table::ClassLevelStats`]. LevelStats, - /// `game_start_position` — where a fresh character of one (race, class) starts. Update-only: - /// the key names a race and a class the client fixes. StartPosition, - /// `game_graveyard_zone` — which zone an imported graveyard serves. Insert and partial update - /// on its own surrogate `row_id`. GraveyardZone, - /// `game_areatrigger_teleport` — where a portal sends the player. Update-only: the key is an - /// `AreaTrigger.dbc` trigger id, client data no Package may invent. AreatriggerTeleport, - /// `game_createinfo_spell` — one spell a fresh character of a matching (race, class) starts - /// with. Insert and partial update on its own surrogate `id`. CreateinfoSpell, - /// `game_createinfo_action` — one default action-bar button for a matching (race, class). - /// Insert and partial update on its own surrogate `row_id`. CreateinfoAction, - // ---- spellmeta ---- - /// `game_spell_chain` — one spell's rank-chain link. Insert and partial update, but the key IS - /// a spell identifier, so an insert must name a Package spell - /// ([`crate::PACKAGE_SPELL_ID_FLOOR`]) rather than an identifier of this family's own. SpellChain, - /// `game_spell_learn` — one "learning this also teaches that" dependent. Insert and partial - /// update on its own surrogate `id`. SpellLearn, - /// `game_spell_proc_event` — the proc overlay for one spell. Insert and partial update, with - /// the same spell-identifier policy as [`Table::SpellChain`]. SpellProcEvent, - // ---- creatures ---- - /// `game_creature_template` — the creature catalogue header. Insert and partial update on its - /// own `entry`, inside the Package creature range. A global catalogue: every Shard loads the - /// whole of it, so a claim here reaches every Shard. CreatureTemplate, - /// `game_creature_spawn` — one placed creature. Insert and partial update; the key names the - /// map, the creature template and the spawn identifier, and the durable guid is derived from - /// the last two. SPATIAL: the map in the key is what routes the claim, so only the Shards - /// whose World Import Scope owns that map ever see it. CreatureSpawn, - // ---- gameobjects ---- - /// `game_gameobject_template` — the gameobject catalogue header. Insert and partial update on - /// its own `entry`, inside the Package gameobject range. A global catalogue, like - /// [`Table::CreatureTemplate`]. GameobjectTemplate, - /// `game_gameobject_trap` — the spell and cooldown one TRAP template fires. Insert and partial - /// update; its key IS a gameobject template entry, so it takes the same band rather than one of - /// its own. Global, like the template it describes. GameobjectTrap, - /// `game_gameobject` — one placed gameobject. Insert and partial update; the key names the map - /// and the spawn identifier, and the durable guid is derived from the second. SPATIAL, like - /// [`Table::CreatureSpawn`]. GameobjectSpawn, - // ---- creature-ai ---- - /// `game_creature_ai_broadcast_text` — one line an `EventAI` speak action says, with the emotes - /// it plays alongside. Insert and partial update on its own `id`, inside the Package `EventAI` - /// band. A global catalogue: the table names no map, so a claim here reaches every Shard. CreatureAiBroadcastText, - /// `game_creature_ai_summon` — where an `EventAI` summon action places its creature. Insert and - /// partial update on its own `id`. Global, like the text above: the row is a named placement - /// the summoning creature resolves, not a row that belongs to a map. CreatureAiSummon, - /// `game_quest_event_requirement` — a quest that needs its `EventAI` source event before - /// ordinary objectives can complete it. Insert and partial update on its own `id`. It names a - /// quest but the `creature-ai` block loads it, so it belongs to this family: an apply that ran - /// under `quests` would be reverted by the block that owns it. QuestEventRequirement, } impl Table { - /// Every table this build knows, in canonical order, so a refusal can name the whole catalogue - /// and a test can walk it. Kept in step with [`Table::parse`] by `tests/families.rs`. pub const ALL: &'static [Self] = &[ Self::Spell, Self::SpellEffect, @@ -297,7 +112,6 @@ impl Table { Self::QuestEventRequirement, ]; - /// The durable table name, and the value the artifact's `table` member carries. #[must_use] pub const fn as_str(self) -> &'static str { match self { @@ -344,11 +158,6 @@ impl Table { } } - /// The Import Family that owns this table. - /// - /// The same name `game_import_meta.family` records, the `apply_package_deltas` reducer takes, - /// and `game_package_import.id` carries before the slash. An applier is called for one family - /// at a time, so this is what tells it a claim belongs to the import it is running. #[must_use] pub const fn family(self) -> &'static str { match self { @@ -390,7 +199,6 @@ impl Table { } } - /// Resolves a `table` member. `None` for anything outside the closed list. #[must_use] pub fn parse(name: &str) -> Option { match name { @@ -438,25 +246,6 @@ impl Table { } } - /// The columns a claim may name, in the table's declaration order. - /// - /// Primary-key columns are absent on purpose. A claim carries them in its `key`, so naming one - /// in `fields` would let a Package state the same identifier twice and disagree with itself. - /// A derived `id` (`game_spell_effect`, and every packed quest child key below) is absent for - /// the same reason plus one more: it is derived, never authored. - /// - /// `game_creature_quest` and `game_gameobject_quest` (which creature/gameobject starts or ends - /// a quest) are deliberately NOT in this catalogue: every one of their non-`id` columns names - /// the relation itself (creature/gameobject, quest, START-or-END role), so a claim on them - /// would set zero columns. This is the shape `tests/families.rs`'s - /// `every_table_has_claimable_columns` exists to catch. Reaching a Package quest from a giver - /// is left as a named gap for a later change, not forced past that guard. - /// - /// `game_creature_cast` is the borderline case that DOES stay in the catalogue: both its - /// columns describe the creature-to-spell relation too, but `spell_id` is not a key column, so - /// a claim on it sets a real value — retargeting which spell a caster mob uses. It is - /// update-only for a different reason: its key names a creature template, which no Package may - /// invent. See [`crate::DeltaError::InsertNotSupported`]. #[must_use] pub const fn columns(self) -> &'static [Column] { match self { @@ -503,7 +292,6 @@ impl Table { } } - /// The type of one claimable column, or `None` when the table has no such column. #[must_use] pub fn column_type(self, name: &str) -> Option { self.columns() @@ -519,12 +307,9 @@ impl fmt::Display for Table { } } -/// One claimable column. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Column { - /// The durable column name, and the `fields` member name in the artifact. pub name: &'static str, - /// The type a claim on this column must declare. pub ty: FieldType, } @@ -532,9 +317,6 @@ const fn column(name: &'static str, ty: FieldType) -> Column { Column { name, ty } } -// ---- spell ---- - -/// `game_spell` minus its `spell_id` primary key. const SPELL_COLUMNS: &[Column] = &[ column("name", FieldType::Str), column("power_type", FieldType::U8), @@ -562,7 +344,6 @@ const SPELL_COLUMNS: &[Column] = &[ column("proc_charges", FieldType::U8), ]; -/// `game_spell_effect` minus its derived `id` key and the `spell_id` / `effect_index` the key names. const SPELL_EFFECT_COLUMNS: &[Column] = &[ column("kind", FieldType::U8), column("base_points", FieldType::I32), @@ -581,14 +362,6 @@ const SPELL_EFFECT_COLUMNS: &[Column] = &[ column("enters_combat", FieldType::Bool), ]; -// ---- items ---- - -/// `game_item_template` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/items/tables.rs`'s `ItemTemplate` struct, the same -/// convention `SPELL_COLUMNS` follows against `module/src/spell/tables.rs` — declaration order, -/// name and type must match exactly, or `every_claimable_item_column_has_a_setter` -/// (`module/src/package_import/items.rs`) fails against a live setter. const ITEM_COLUMNS: &[Column] = &[ column("class", FieldType::U8), column("subclass", FieldType::U8), @@ -649,12 +422,6 @@ const ITEM_COLUMNS: &[Column] = &[ column("allowed_race", FieldType::U32), ]; -// ---- quests ---- - -/// `game_quest_template` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestTemplate` struct, the same convention -/// `ITEM_COLUMNS` follows. const QUEST_COLUMNS: &[Column] = &[ column("min_level", FieldType::U32), column("quest_level", FieldType::U32), @@ -678,9 +445,6 @@ const QUEST_COLUMNS: &[Column] = &[ column("quest_type", FieldType::U32), ]; -/// `game_quest_text` minus its `quest_entry` primary key. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestText` struct. const QUEST_TEXT_COLUMNS: &[Column] = &[ column("details", FieldType::Str), column("objectives", FieldType::Str), @@ -688,42 +452,21 @@ const QUEST_TEXT_COLUMNS: &[Column] = &[ column("request_items_text", FieldType::Str), ]; -/// `game_quest_objective` minus its derived `id` key and the `quest_entry` / `obj_index` the key -/// names. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestObjective` struct. const QUEST_OBJECTIVE_COLUMNS: &[Column] = &[ column("kind", FieldType::U8), column("target_entry", FieldType::U32), column("required_count", FieldType::U32), ]; -/// `game_quest_cast_objective` minus its derived `id` key and the `quest_entry` / `obj_index` the -/// key names. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestCastObjective` struct. const QUEST_CAST_OBJECTIVE_COLUMNS: &[Column] = &[column("spell_id", FieldType::U32)]; -/// `game_quest_reward_item` minus its derived `id` key and the `quest_entry` / `item_entry` the -/// key names. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestRewardItem` struct. const QUEST_REWARD_ITEM_COLUMNS: &[Column] = &[column("count", FieldType::U32)]; -/// `game_quest_reward_choice` minus its derived `id` key and the `quest_entry` / `choice_index` -/// the key names. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestRewardChoice` struct. const QUEST_REWARD_CHOICE_COLUMNS: &[Column] = &[ column("item_entry", FieldType::U32), column("count", FieldType::U32), ]; -// ---- loot ---- - -/// `game_pickpocket_loot` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/loot/mod.rs`'s `GamePickpocketLoot` struct. const PICKPOCKET_LOOT_COLUMNS: &[Column] = &[ column("creature_entry", FieldType::U32), column("item_entry", FieldType::U32), @@ -733,9 +476,6 @@ const PICKPOCKET_LOOT_COLUMNS: &[Column] = &[ column("quest_only", FieldType::Bool), ]; -/// `game_gameobject_loot` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/loot/mod.rs`'s `GameObjectLoot` struct. const GAMEOBJECT_LOOT_COLUMNS: &[Column] = &[ column("loot_id", FieldType::U32), column("item_entry", FieldType::U32), @@ -745,10 +485,6 @@ const GAMEOBJECT_LOOT_COLUMNS: &[Column] = &[ column("quest_only", FieldType::Bool), ]; -/// `game_skinning_loot` minus its `id` primary key. No cmangos skinning row is -/// ever quest-gated (`module/src/loot/mod.rs`). -/// -/// Hand-maintained against `module/src/loot/mod.rs`'s `GameSkinningLoot` struct. const SKINNING_LOOT_COLUMNS: &[Column] = &[ column("skin_loot_id", FieldType::U32), column("item_entry", FieldType::U32), @@ -757,10 +493,6 @@ const SKINNING_LOOT_COLUMNS: &[Column] = &[ column("group_id", FieldType::U32), ]; -/// `game_fishing_loot` minus its `id` primary key. Fishing junk is not -/// quest-gated in the family this importer imports (`module/src/loot/mod.rs`). -/// -/// Hand-maintained against `module/src/loot/mod.rs`'s `GameFishingLoot` struct. const FISHING_LOOT_COLUMNS: &[Column] = &[ column("zone_id", FieldType::U32), column("item_entry", FieldType::U32), @@ -769,18 +501,8 @@ const FISHING_LOOT_COLUMNS: &[Column] = &[ column("group_id", FieldType::U32), ]; -// ---- casts ---- - -/// `game_creature_cast` minus its `creature_entry` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `CreatureCast` struct. The table is -/// update-only ([`crate::DeltaError::InsertNotSupported`]), but a claim still names a real column: -/// `spell_id` is not part of the key. const CREATURE_CAST_COLUMNS: &[Column] = &[column("spell_id", FieldType::U32)]; -/// `game_creature_spell` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `CreatureSpell` struct. const CREATURE_SPELL_COLUMNS: &[Column] = &[ column("creature_entry", FieldType::U32), column("spell_id", FieldType::U32), @@ -789,13 +511,7 @@ const CREATURE_SPELL_COLUMNS: &[Column] = &[ column("condition_value", FieldType::U8), ]; -// ---- trainers ---- - -/// `game_trainer_spell` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/trainer.rs`'s `TrainerSpell` struct. `spell_id` on a -/// profession offering (`learn_skill_line > 0`) is a synthetic marker, never a `game_spell` row — -/// see `module/src/package_import/trainers.rs`'s `check_references`. +// Profession spell_id is a synthetic marker, not a game_spell reference. const TRAINER_SPELL_COLUMNS: &[Column] = &[ column("trainer_entry", FieldType::U32), column("spell_id", FieldType::U32), @@ -805,23 +521,10 @@ const TRAINER_SPELL_COLUMNS: &[Column] = &[ column("learn_skill_cap", FieldType::U32), ]; -// ---- gossip ---- - -/// `game_gossip_menu` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `GossipMenu` struct. Update-only -/// ([`crate::DeltaError::InsertNotSupported`]), but a claim still sets a real value: retargeting -/// which greeting a creature shows. const GOSSIP_MENU_COLUMNS: &[Column] = &[column("text_id", FieldType::U32)]; -/// `game_gossip_menu_profile` minus its `menu_id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `GossipMenuProfile` struct. const GOSSIP_MENU_PROFILE_COLUMNS: &[Column] = &[column("text_id", FieldType::U32)]; -/// `game_gossip_menu_profile_option` minus its `row_id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `GossipMenuProfileOption` struct. const GOSSIP_MENU_PROFILE_OPTION_COLUMNS: &[Column] = &[ column("menu_id", FieldType::U32), column("option_index", FieldType::U32), @@ -834,11 +537,6 @@ const GOSSIP_MENU_PROFILE_OPTION_COLUMNS: &[Column] = &[ column("cond_value2", FieldType::U32), ]; -/// `game_gossip_option` minus its `row_id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `GossipOption` struct. `entry` names -/// the creature template whose menu this line belongs to; it is a claimed column here, not part of -/// the key, so a Package may add an option to a real creature. const GOSSIP_OPTION_COLUMNS: &[Column] = &[ column("entry", FieldType::U32), column("option_index", FieldType::U32), @@ -851,14 +549,8 @@ const GOSSIP_OPTION_COLUMNS: &[Column] = &[ column("cond_value2", FieldType::U32), ]; -/// `game_npc_text` minus its `text_id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `NpcText` struct. const NPC_TEXT_COLUMNS: &[Column] = &[column("text", FieldType::Str)]; -/// `game_npc_text_slot` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `NpcTextSlot` struct. const NPC_TEXT_SLOT_COLUMNS: &[Column] = &[ column("text_id", FieldType::U32), column("slot_index", FieldType::U8), @@ -867,21 +559,11 @@ const NPC_TEXT_SLOT_COLUMNS: &[Column] = &[ column("probability", FieldType::F32), ]; -// ---- globals ---- - -/// `game_class_level_stats` minus its packed `class_level` key and the `class` / `level` that key -/// names. -/// -/// Hand-maintained against `module/src/stats.rs`'s `ClassLevelStats` struct. const CLASS_LEVEL_STATS_COLUMNS: &[Column] = &[ column("base_health", FieldType::U32), column("base_mana", FieldType::U32), ]; -/// `game_level_stats` minus its packed `race_class_level` key and the `race` / `class` / `level` -/// that key names. -/// -/// Hand-maintained against `module/src/stats.rs`'s `LevelStats` struct. const LEVEL_STATS_COLUMNS: &[Column] = &[ column("strength", FieldType::U32), column("agility", FieldType::U32), @@ -890,9 +572,6 @@ const LEVEL_STATS_COLUMNS: &[Column] = &[ column("spirit", FieldType::U32), ]; -/// `game_start_position` minus its packed `race_class` key and the `race` / `class` that key names. -/// -/// Hand-maintained against `module/src/config.rs`'s `StartPosition` struct. const START_POSITION_COLUMNS: &[Column] = &[ column("map_id", FieldType::U32), column("zone_id", FieldType::U32), @@ -903,20 +582,12 @@ const START_POSITION_COLUMNS: &[Column] = &[ column("display_id", FieldType::U32), ]; -/// `game_graveyard_zone` minus its `row_id` primary key. -/// -/// Hand-maintained against `module/src/graveyard.rs`'s `GraveyardZone` struct. const GRAVEYARD_ZONE_COLUMNS: &[Column] = &[ column("safe_loc_id", FieldType::U32), column("zone_id", FieldType::U32), column("faction", FieldType::U32), ]; -/// `game_areatrigger_teleport` minus its `trigger_id` primary key. -/// -/// Hand-maintained against `module/src/quest.rs`'s `AreatriggerTeleport` struct. Update-only -/// ([`crate::DeltaError::InsertNotSupported`]), but every column here is a real value: a Package -/// may send an existing portal somewhere else. const AREATRIGGER_TELEPORT_COLUMNS: &[Column] = &[ column("target_map", FieldType::U32), column("x", FieldType::F32), @@ -926,21 +597,14 @@ const AREATRIGGER_TELEPORT_COLUMNS: &[Column] = &[ column("name", FieldType::Str), ]; -/// `game_createinfo_spell` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/spell/spellbook.rs`'s `CreateinfoSpell` struct. `race` 0 and -/// `class` 0 are wildcards, which is why neither is bounded here beyond its own width. +// Race 0 and class 0 are wildcards. const CREATEINFO_SPELL_COLUMNS: &[Column] = &[ column("race", FieldType::U8), column("class", FieldType::U8), column("spell_id", FieldType::U32), ]; -/// `game_createinfo_action` minus its `row_id` primary key. -/// -/// Hand-maintained against `module/src/action_bar.rs`'s `CreateinfoAction` struct. `action` is -/// polymorphic on `action_type` (0 names a spell, other values an item or a macro), so no single -/// catalogue answers it and no reference check is made on it. +// action_type selects whether action names a spell, item or macro. const CREATEINFO_ACTION_COLUMNS: &[Column] = &[ column("race", FieldType::U8), column("class", FieldType::U8), @@ -949,11 +613,6 @@ const CREATEINFO_ACTION_COLUMNS: &[Column] = &[ column("action_type", FieldType::U8), ]; -// ---- spellmeta ---- - -/// `game_spell_chain` minus its `spell_id` primary key. -/// -/// Hand-maintained against `module/src/spell/spellbook.rs`'s `SpellChain` struct. const SPELL_CHAIN_COLUMNS: &[Column] = &[ column("prev_spell", FieldType::U32), column("first_spell", FieldType::U32), @@ -961,17 +620,11 @@ const SPELL_CHAIN_COLUMNS: &[Column] = &[ column("req_spell", FieldType::U32), ]; -/// `game_spell_learn` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/spell/spellbook.rs`'s `SpellLearn` struct. const SPELL_LEARN_COLUMNS: &[Column] = &[ column("parent_spell", FieldType::U32), column("learn_spell", FieldType::U32), ]; -/// `game_spell_proc_event` minus its `spell_id` primary key. -/// -/// Hand-maintained against `module/src/spell/tables.rs`'s `SpellProcEvent` struct. const SPELL_PROC_EVENT_COLUMNS: &[Column] = &[ column("proc_flags", FieldType::U32), column("proc_ex", FieldType::U32), @@ -983,12 +636,6 @@ const SPELL_PROC_EVENT_COLUMNS: &[Column] = &[ column("icd_ms", FieldType::U32), ]; -// ---- creatures ---- - -/// `game_creature_template` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `CreatureTemplate` struct, the same -/// convention `ITEM_COLUMNS` follows. const CREATURE_TEMPLATE_COLUMNS: &[Column] = &[ column("name", FieldType::Str), column("subname", FieldType::Str), @@ -1018,14 +665,7 @@ const CREATURE_TEMPLATE_COLUMNS: &[Column] = &[ column("trainer_class", FieldType::U8), ]; -/// `game_creature_spawn` minus its derived `guid` key, the `map_id` and `entry` that key names, -/// and the three columns the shard owns rather than the artifact. -/// -/// Hand-maintained against `module/src/creatures/spawn.rs`'s `CreatureSpawn` struct. `map_id` and -/// `entry` sit in the claim's key: the map is what routes the claim to a Shard, and the entry is -/// half the derived guid, so neither may be re-stated here and disagree with itself. `respawn_at`, -/// `despawn_at` and `life_seq` are absent because they are live state a running Shard writes — an -/// import stamps them, an artifact cannot. +// Spawn lifetime columns belong to the running Shard and cannot be claimed. const CREATURE_SPAWN_COLUMNS: &[Column] = &[ column("x", FieldType::F32), column("y", FieldType::F32), @@ -1035,11 +675,6 @@ const CREATURE_SPAWN_COLUMNS: &[Column] = &[ column("respawn_secs", FieldType::U32), ]; -// ---- gameobjects ---- - -/// `game_gameobject_template` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/gameobject.rs`'s `GameObjectTemplate` struct. const GAMEOBJECT_TEMPLATE_COLUMNS: &[Column] = &[ column("type_id", FieldType::U8), column("display_id", FieldType::U32), @@ -1053,21 +688,12 @@ const GAMEOBJECT_TEMPLATE_COLUMNS: &[Column] = &[ column("size", FieldType::F32), ]; -/// `game_gameobject_trap` minus its `entry` primary key. -/// -/// Hand-maintained against `module/src/gameobject.rs`'s `GameObjectTrap` struct. const GAMEOBJECT_TRAP_COLUMNS: &[Column] = &[ column("spell_id", FieldType::U32), column("cooldown_secs", FieldType::U32), ]; -/// `game_gameobject` minus its derived `guid` key, the `map_id` that key names, and the columns -/// the shard owns rather than the artifact. -/// -/// Hand-maintained against `module/src/gameobject.rs`'s `GameObject` struct. `created_at`, -/// `respawn_at_micros` and `instance_id` are live state. `grid_x`, `grid_y` and `cell` are DERIVED -/// from `x` and `y` in the same write (`module/src/tripwires.rs`'s grid-cell tripwire enforces it), -/// so a claim on one could put the row in a cell its coordinates do not sit in. +// Grid columns derive from coordinates in the same write and cannot be claimed. const GAMEOBJECT_SPAWN_COLUMNS: &[Column] = &[ column("template_entry", FieldType::U32), column("x", FieldType::F32), @@ -1081,13 +707,6 @@ const GAMEOBJECT_SPAWN_COLUMNS: &[Column] = &[ column("rotation_3", FieldType::F32), ]; -// ---- creature-ai ---- - -/// `game_creature_ai_broadcast_text` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/creatures/eventai/tables.rs`'s `CreatureAiBroadcastText` -/// struct, the same convention `ITEM_COLUMNS` follows. The three emote pairs are client emote -/// identifiers and the delays that stagger them, so a claim tunes presentation and nothing else. const CREATURE_AI_BROADCAST_TEXT_COLUMNS: &[Column] = &[ column("male_text", FieldType::Str), column("female_text", FieldType::Str), @@ -1101,11 +720,6 @@ const CREATURE_AI_BROADCAST_TEXT_COLUMNS: &[Column] = &[ column("emote_id_3", FieldType::U32), ]; -/// `game_creature_ai_summon` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/creatures/eventai/tables.rs`'s `CreatureAiSummon` struct. -/// The position is absolute world space with no map beside it: the summoning creature's own map is -/// where the summon lands, which is why this table is global rather than spatial. const CREATURE_AI_SUMMON_COLUMNS: &[Column] = &[ column("x", FieldType::F32), column("y", FieldType::F32), @@ -1114,41 +728,21 @@ const CREATURE_AI_SUMMON_COLUMNS: &[Column] = &[ column("lifetime_ms", FieldType::U32), ]; -/// `game_quest_event_requirement` minus its `id` primary key. -/// -/// Hand-maintained against `module/src/quest.rs`'s `QuestEventRequirement` struct. One claimable -/// column, and it is a reference: `module/src/package_import/creature_ai.rs` refuses a claim whose -/// quest will not be there after the plan lands. const QUEST_EVENT_REQUIREMENT_COLUMNS: &[Column] = &[column("quest_entry", FieldType::U32)]; -/// The type tag a claimed value carries. -/// -/// Every value states its type explicitly. That makes a wrong-width claim a parse failure rather -/// than a silent truncation at apply, and it is what lets `U64` travel as a decimal string: JSON has -/// one number type and it loses precision above 2^53, so an unsigned 64-bit value that went through -/// a JSON number would come back wrong with nothing to notice it. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum FieldType { - /// JSON number, 0..=255. U8, - /// JSON number, 0..=65535. U16, - /// JSON number, 0..=4294967295. U32, - /// JSON **string** of decimal digits, 0..=18446744073709551615. U64, - /// JSON number, -2147483648..=2147483647. I32, - /// JSON number, finite, representable as `f32`. F32, - /// JSON boolean. Bool, - /// JSON string. Str, } impl FieldType { - /// The value the artifact's `type` member carries. #[must_use] pub const fn as_str(self) -> &'static str { match self { @@ -1163,7 +757,6 @@ impl FieldType { } } - /// Resolves a `type` member. `None` for anything outside the closed list. #[must_use] pub fn parse(name: &str) -> Option { match name { @@ -1186,33 +779,19 @@ impl fmt::Display for FieldType { } } -/// One claimed column value, already narrowed to the column's type. -/// -/// `PartialEq` is total here even though it holds an `f32`: the parser refuses a non-finite float, -/// so no `NaN` can reach a `FieldValue`. Two Packages claiming the same column with the same value -/// therefore compare equal, which is what the tracer needs to describe a conflict exactly. #[derive(Debug, Clone, PartialEq)] pub enum FieldValue { - /// An 8-bit unsigned value. U8(u8), - /// A 16-bit unsigned value. U16(u16), - /// A 32-bit unsigned value. U32(u32), - /// A 64-bit unsigned value. Travels as a decimal string. U64(u64), - /// A 32-bit signed value. I32(i32), - /// A finite 32-bit float. F32(f32), - /// A boolean. Bool(bool), - /// A string. Str(String), } impl FieldValue { - /// The type tag this value serializes with. #[must_use] pub const fn field_type(&self) -> FieldType { match self { @@ -1228,8 +807,6 @@ impl FieldValue { } } -/// The value in its canonical JSON spelling, without the surrounding type tag, so a conflict report -/// quotes it exactly as the artifact writes it. impl fmt::Display for FieldValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&crate::canonical::scalar_literal(self)) diff --git a/crates/lyracore-package-delta/src/script.rs b/crates/lyracore-package-delta/src/script.rs index 450e083e7..0142d6cc6 100644 --- a/crates/lyracore-package-delta/src/script.rs +++ b/crates/lyracore-package-delta/src/script.rs @@ -1,63 +1,3 @@ -//! The **Script Artifact**: the whole Runtime Scripts one Package ships, and the tracer that puts -//! several Packages' scripts together. -//! -//! A Package Delta states COLUMNS of rows the base import owns, so two Packages can each hold part -//! of one row and the tracer's job is to merge them. A Runtime Script has no base import behind it -//! and no other owner: the Package that ships a script ships all of it. So this artifact carries -//! WHOLE rows, and two Packages meeting on one row is never a merge — it is two Packages claiming -//! one identity, which only a human can settle. -//! -//! That difference is the whole reason this is a second artifact kind rather than more tables in -//! the claim schema. Everything else it shares: the envelope, the canonical bytes, the refuse-early -//! discipline, and the Import Family the applier runs it for. -//! -//! Nothing here writes anywhere or reads a clock. A [`ScriptArtifact`] that exists has already -//! passed every check the artifact promises. -//! -//! # The artifact -//! -//! ```json -//! { -//! "kind": "script", -//! "version": 1, -//! "package": "example.sharper-fireballs", -//! "source_hash": "0000000000000000000000000000000000000000000000000000000000000000", -//! "scripts": [ -//! { -//! "script_id": 100001, -//! "name": "sharper-fireballs.greet", -//! "event": "on_login", -//! "priority": 0, -//! "enabled": true, -//! "source": "if event.actor then send_chat(event.actor, \"hello\") end" -//! } -//! ] -//! } -//! ``` -//! -//! `kind` is what tells the two artifacts apart. A Package Delta shipped its version 1 without one, -//! so a file carrying NO `kind` is a Package Delta and a file carrying one states what it is — -//! see [`artifact_kind`], which reads that member alone so a router can pick a parser without -//! committing to it. -//! -//! # What it refuses -//! -//! Unknown versions and members, a script outside the Package script range, a name that is not a -//! name, an event that is neither the Module's nor this Package's, an empty source, and one Package -//! shipping two scripts at one identifier or under one name. All at [`ScriptArtifact::parse`], -//! before an applier sees the artifact. -//! -//! There is no `operation` member and no delete. A Package ships the set of scripts it has; an -//! apply reconciles the shard to exactly that set, so removing a script from a Datascript removes -//! it from the shard on the next apply with nothing to state and nothing to undo. -//! -//! # What it traces -//! -//! [`trace_scripts`] puts several Packages' artifacts together. A Package appearing twice, or two -//! Packages shipping one `script_id` or name, is a [`ScriptConflict`]. There is no merge case and -//! there are no priority numbers to break the tie with: `priority` orders scripts at an event, -//! never Packages against each other. - use core::fmt; use serde_json::Value; @@ -67,33 +7,19 @@ use crate::delta::{expect_members, member, object, string, PackageId, SourceHash use crate::error::{DeltaError, ScriptRefusal}; use crate::ids::is_package_script_id; -/// The Import Family the script artifact belongs to. -/// -/// The same name `apply_package_deltas` takes and `game_package_import.family` records. Unlike -/// every other family it has no base import: no DBC and no dump holds a Runtime Script, so the -/// family's "reload" is applying the enabled plan and nothing else. pub const SCRIPT_FAMILY: &str = "script"; -/// The value the artifact's `kind` member carries. pub const SCRIPT_ARTIFACT_KIND: &str = "script"; -/// The Script Artifact version this build reads and writes. pub const SCRIPT_VERSION: u64 = 1; include!("../../../datascripts/runtime-scripts/event-names.rs"); -/// Which artifact a file in a Package's generated directory holds. -/// -/// One Package ships every kind it has into one directory, so a reader that walks `*.json` meets -/// all of them and has to route rather than parse. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ArtifactKind { - /// A Package Delta: column claims on rows a base import owns. Carries no `kind` member, - /// because version 1 of that artifact shipped before there was a second kind to tell it from. + // Version-1 Package Deltas omit kind for canonical-byte compatibility. Delta, - /// A Script Artifact: whole Runtime Script rows. Script, - /// A kind this build does not read. Held as written so a refusal can quote it. Other(String), } @@ -107,14 +33,6 @@ impl fmt::Display for ArtifactKind { } } -/// Which artifact these bytes hold, from the root `kind` member alone. -/// -/// Reads nothing else, so a router picks a parser without paying for a parse it may not want and -/// without deciding anything the real parser will decide better. -/// -/// `None` when the bytes are not a JSON object with a string-or-absent `kind`: no parser in this -/// crate can read them, so a router hands them to the one it wanted and lets that parser report -/// exactly what is wrong with them. #[must_use] pub fn artifact_kind(json: &str) -> Option { let root: Value = serde_json::from_str(json).ok()?; @@ -126,21 +44,10 @@ pub fn artifact_kind(json: &str) -> Option { } } -/// The unique, human-readable name of one Runtime Script. -/// -/// Same character rules as a [`PackageId`], and for the same reason: the name reaches a diagnostic, -/// a conflict report and an operator's SQL, and a name differing from another only in case would -/// look like one script in all three. Convention prefixes it with the shipping Package, but nothing -/// enforces that — two Packages colliding on a name is a refusal that says so, which is more useful -/// than a naming rule that makes the collision unspellable and the mistake invisible. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ScriptName(String); impl ScriptName { - /// Accepts 1 to 64 characters of `a-z`, `0-9`, `-`, `_` or `.`. - /// - /// # Errors - /// [`DeltaError::InvalidScriptName`] for anything else. pub fn new(name: impl Into) -> Result { let name = name.into(); let shaped = !name.is_empty() @@ -157,7 +64,6 @@ impl ScriptName { } } - /// The name as written. #[must_use] pub fn as_str(&self) -> &str { &self.0 @@ -170,28 +76,10 @@ impl fmt::Display for ScriptName { } } -/// The event a Runtime Script is bound to: one name from [`HOOK_EVENT_NAMES`], or a Package Event -/// the shipping Package fires itself. -/// -/// A core event is a closed list rather than a free string, checked at the parse. The Module fires -/// a fixed set of them, so a script bound to a name outside it would be a script that silently -/// never runs — the single hardest failure for a package author to diagnose. -/// -/// A **Package Event** is spelled `.`, where `` is the artifact's own -/// Package identity and `` is a lowercase letter followed by lowercase letters, digits or -/// `_`. The prefix is what -/// keeps the same guarantee for an open name: a Package may only bind events it fires itself, so -/// two Packages cannot reach each other's decisions and a typo cannot land on a name some other -/// Package happens to own. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct EventBinding(String); impl EventBinding { - /// Resolves an `event` member against the catalogue, then against `package`'s own events. - /// - /// # Errors - /// [`DeltaError::Script`] with [`ScriptRefusal::UnknownEvent`], which names every event this - /// build fires and the shape of a Package Event the artifact's own Package may bind. pub fn parse(name: &str, package: &PackageId) -> Result { let known = HOOK_EVENT_NAMES.contains(&name) || name @@ -208,7 +96,6 @@ impl EventBinding { } } - /// The event name, as the artifact writes it and the Module's dispatch spells it. #[must_use] pub fn as_str(&self) -> &str { &self.0 @@ -221,25 +108,12 @@ impl fmt::Display for EventBinding { } } -/// The `` half of a Package Event: a lowercase letter, then lowercase letters, digits or `_`. -/// -/// Narrower than a [`ScriptName`] on purpose. No `.`, so the Package prefix stays the only dot and -/// the split between "whose event" and "which event" can never be ambiguous; no `-`, so the name -/// reads the same in Lua, in a Rust identifier and in `spacetime sql`. fn is_package_event_name(name: &str) -> bool { let mut bytes = name.bytes(); bytes.next().is_some_and(|b| b.is_ascii_lowercase()) && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') } -/// One whole Runtime Script, exactly as it will sit in `game_script`. -/// -/// Every member is authored. The content hash the table also carries is NOT here: it is derived -/// from `source` by the applier, so it cannot disagree with the source it describes. -// `script_id` repeats the type name because it is the DURABLE column name, shared with -// `game_script.script_id` and with `game_spell_effect.script_id` that will point at it. A shorter -// field here would make the artifact, the table and the accessor three different words for one -// thing. #[allow(clippy::struct_field_names)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Script { @@ -252,11 +126,6 @@ pub struct Script { } impl Script { - /// Builds a script, refusing everything the artifact promises it will refuse. - /// - /// # Errors - /// [`DeltaError::ScriptIdNotInPackageBand`] for an identifier a Package may not ship at, and - /// [`DeltaError::EmptyScriptSource`] for a script with no Lua in it. pub fn new( script_id: u32, name: ScriptName, @@ -286,47 +155,37 @@ impl Script { }) } - /// The identifier this script keeps on every Shard. #[must_use] pub const fn script_id(&self) -> u32 { self.script_id } - /// The unique name. #[must_use] pub const fn name(&self) -> &ScriptName { &self.name } - /// The event this script runs for. #[must_use] pub const fn event(&self) -> &EventBinding { &self.event } - /// Where this script sits among the scripts bound to its event: lower runs first, and - /// `script_id` breaks a tie. #[must_use] pub const fn priority(&self) -> i32 { self.priority } - /// Whether the Package ships this script switched on. A disabled script is still applied to the - /// shard — it is a fact about the Package — but no event invokes it. #[must_use] pub const fn enabled(&self) -> bool { self.enabled } - /// The Lua the Runtime Script Host runs. #[must_use] pub fn source(&self) -> &str { &self.source } } -/// One Package's whole set of Runtime Scripts: its identity, the source it was generated from, and -/// every script it ships. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScriptArtifact { package: PackageId, @@ -335,12 +194,6 @@ pub struct ScriptArtifact { } impl ScriptArtifact { - /// Builds an artifact and puts its scripts in canonical order. - /// - /// # Errors - /// [`DeltaError::DuplicateScriptId`] or [`DeltaError::DuplicateScriptName`] when one Package - /// ships two scripts at one identity. Both are checked here rather than at the tracer, because - /// a Package disagreeing with ITSELF is a Datascript defect, not a decision between Packages. pub fn new( package: PackageId, source_hash: SourceHash, @@ -372,22 +225,11 @@ impl ScriptArtifact { }) } - /// Reads an artifact from untrusted JSON. - /// - /// Every refusal happens here, before any caller could act on a script. - /// - /// # Errors - /// [`DeltaError::WrongArtifactKind`] when the bytes are a Package Delta or a kind this build - /// does not read, and any other [`DeltaError`] the members raise. The parse stops at the first - /// problem. pub fn parse(json: &str) -> Result { let root: Value = serde_json::from_str(json).map_err(|e| DeltaError::Malformed(e.to_string()))?; let root = object(&root, "")?; - // Before the closed member list, not after: a Package Delta read by this parser would - // otherwise be reported as an unexpected `claims` member, which describes the symptom - // rather than the mistake. What kind these bytes are is the first question. match root.get("kind") { Some(Value::String(kind)) if kind == SCRIPT_ARTIFACT_KIND => {} Some(Value::String(kind)) => { @@ -446,11 +288,6 @@ impl ScriptArtifact { Self::new(package, source_hash, scripts) } - /// Writes the artifact back as canonical JSON. - /// - /// Byte-stable on the same terms as a Package Delta's canonical form: no whitespace, members in - /// a fixed declared order, scripts sorted by identifier, and strings escaping only what JSON - /// requires. Two artifacts that say the same thing produce the same bytes. #[must_use] pub fn to_canonical_json(&self) -> String { let mut out = String::new(); @@ -485,19 +322,16 @@ impl ScriptArtifact { out } - /// The Package this artifact belongs to. #[must_use] pub const fn package(&self) -> &PackageId { &self.package } - /// The digest of the Datascript source this artifact was generated from. #[must_use] pub const fn source_hash(&self) -> &SourceHash { &self.source_hash } - /// The scripts, in canonical (identifier) order. #[must_use] pub fn scripts(&self) -> &[Script] { &self.scripts @@ -559,46 +393,26 @@ fn parse_script(value: &Value, index: usize, package: &PackageId) -> Result, @@ -640,34 +453,22 @@ pub struct ScriptTrace { } impl ScriptTrace { - /// The merged scripts, ordered by identifier so one set of Packages always produces one plan. #[must_use] pub fn scripts(&self) -> &[TracedScript] { &self.scripts } - /// Every disagreement, in the order they were found. Reported in full rather than one at a - /// time: the operator fixing them wants the whole list in one pass. #[must_use] pub fn conflicts(&self) -> &[ScriptConflict] { &self.conflicts } - /// True when every Package supplied one artifact and no two Packages disagree. #[must_use] pub fn is_clear(&self) -> bool { self.conflicts.is_empty() } } -/// Merge several Packages' Script Artifacts into one plan, reporting every collision. -/// -/// A script is never merged with another: the whole row belongs to one Package. The tracer first -/// requires one artifact per Package, then checks each identifier and name across Packages. -/// -/// A conflicting script is still carried into [`ScriptTrace::scripts`], from whichever Package -/// shipped it first. That keeps the plan describable in a report; it is not applyable, because an -/// applier refuses a plan that is not clear. #[must_use] pub fn trace_scripts(artifacts: &[ScriptArtifact]) -> ScriptTrace { let mut trace = ScriptTrace::default(); diff --git a/crates/lyracore-package-delta/src/trace.rs b/crates/lyracore-package-delta/src/trace.rs index 44dd5efc2..5d0651b7e 100644 --- a/crates/lyracore-package-delta/src/trace.rs +++ b/crates/lyracore-package-delta/src/trace.rs @@ -1,26 +1,15 @@ -//! The conflict tracer: what several Packages together would do to one Import Family, and where -//! they disagree. -//! -//! Read-only and pure. It decides nothing about applying; it produces the merged picture and the -//! list of disagreements, which is exactly what a dry-run check prints and what an applier refuses -//! on. - use std::collections::BTreeMap; use std::fmt; use crate::delta::{Operation, PackageDelta, PackageId, PrimaryKey}; use crate::schema::{FieldValue, Table}; -/// One column of a merged row, and the Package that set it. #[derive(Debug, Clone, PartialEq)] pub struct ClaimedField { - /// The value the Package claimed. pub value: FieldValue, - /// The Package that claimed it. pub claimed_by: PackageId, } -/// One row after every Package's claims on it have been merged. #[derive(Debug, Clone, PartialEq)] pub struct TracedRow { key: PrimaryKey, @@ -29,71 +18,50 @@ pub struct TracedRow { } impl TracedRow { - /// The row this describes. #[must_use] pub const fn key(&self) -> PrimaryKey { self.key } - /// The table the row lives in. #[must_use] pub const fn table(&self) -> Table { self.key.table() } - /// The durable primary-key value, packed for an effect row. #[must_use] pub const fn row_id(&self) -> u64 { self.key.row_id() } - /// `Insert` when any Package invents this row, `Update` when they all change an existing one. #[must_use] pub const fn operation(&self) -> Operation { self.operation } - /// The merged columns, in canonical (name-sorted) order, each with the Package that set it. #[must_use] pub const fn fields(&self) -> &BTreeMap { &self.fields } } -/// Two Packages disagreeing about one row. -/// -/// Each variant names both Packages and the exact claim, because the package author reading it has -/// to find one line in someone else's Datascript. #[derive(Debug, Clone, PartialEq)] pub enum ClaimConflict { - /// Both Packages set the same column of the same row. Column { - /// The row. key: PrimaryKey, - /// The column both Packages set. field: String, - /// The Package that claimed the column first. holder: PackageId, - /// What the holder set it to. holder_value: FieldValue, - /// The Package that claimed it afterwards. challenger: PackageId, - /// What the challenger set it to. challenger_value: FieldValue, }, - /// Both Packages invent a row at the same primary key. InsertedRow { - /// The row. key: PrimaryKey, - /// The Package that inserted first. holder: PackageId, - /// The Package that inserted afterwards. challenger: PackageId, }, } impl ClaimConflict { - /// The row the two Packages disagree about. #[must_use] pub const fn key(&self) -> PrimaryKey { match self { @@ -133,7 +101,6 @@ impl fmt::Display for ClaimConflict { } } -/// What a set of Package Deltas together would do, and where they disagree. #[derive(Debug, Clone, PartialEq)] pub struct ClaimTrace { rows: Vec, @@ -141,35 +108,22 @@ pub struct ClaimTrace { } impl ClaimTrace { - /// The merged rows, in canonical order. Present whether or not there are conflicts, so a - /// dry-run check can print the whole picture alongside the problems. #[must_use] pub fn rows(&self) -> &[TracedRow] { &self.rows } - /// Every disagreement found, in canonical order. All of them, not just the first: an author - /// fixing package conflicts wants the whole list in one pass. #[must_use] pub fn conflicts(&self) -> &[ClaimConflict] { &self.conflicts } - /// True when the Packages can be applied together. #[must_use] pub fn is_clear(&self) -> bool { self.conflicts.is_empty() } } -/// Merges the claims of several Package Deltas and reports every disagreement. -/// -/// Two Packages claiming different columns of one row merge. Claiming the same column, or inserting -/// the same primary key, is a conflict. There are no priority numbers: the tracer does not pick a -/// winner, it reports that a human has to. -/// -/// The Package that claims a column first becomes its `holder` in the report. That decides only how -/// the disagreement reads, never the outcome — a conflict is a conflict from either direction. #[must_use] pub fn trace(deltas: &[PackageDelta]) -> ClaimTrace { let mut rows: BTreeMap = BTreeMap::new(); @@ -235,9 +189,6 @@ pub fn trace(deltas: &[PackageDelta]) -> ClaimTrace { } } -/// Conflicts sort by row, then by column, then by the two Packages, so the report reads the same -/// way for the same input. An inserted-row conflict sorts before the column conflicts on that row, -/// because it is the reason the row is contested at all. fn conflict_order(conflict: &ClaimConflict) -> (PrimaryKey, &str, &str, &str) { match conflict { ClaimConflict::InsertedRow { diff --git a/crates/lyracore-package-delta/tests/artifact_kinds.rs b/crates/lyracore-package-delta/tests/artifact_kinds.rs index 85d92a242..9958eb590 100644 --- a/crates/lyracore-package-delta/tests/artifact_kinds.rs +++ b/crates/lyracore-package-delta/tests/artifact_kinds.rs @@ -1,9 +1,3 @@ -//! Telling the two artifacts apart. -//! -//! One Package ships every artifact kind it has into `data/.generated/`, so a reader walking -//! `*.json` meets both a Package Delta and a Script Artifact in one directory. Getting this wrong -//! would abort a whole import stage on a file that was never meant for it. - use lyracore_package_delta::{artifact_kind, ArtifactKind, PackageDelta, ScriptArtifact}; const HASH_A: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -34,8 +28,6 @@ fn an_artifact_naming_the_script_kind_is_a_script_artifact() { ); } -/// A kind from a later build must be recognisable as "not mine" rather than mistaken for either of -/// the two this build reads. #[test] fn an_unknown_kind_is_reported_by_name() { let json = r#"{"kind":"loot-table","version":1}"#; @@ -46,9 +38,6 @@ fn an_unknown_kind_is_reported_by_name() { ); } -/// The router declines to guess when it cannot read the member at all. The parser the caller -/// wanted then produces the real refusal, which is a better message than anything a router could -/// invent. #[test] fn bytes_no_parser_can_read_have_no_kind() { for bytes in ["{ not even valid }", "[]", r#""a string""#, r#"{"kind":7}"#] { @@ -56,8 +45,6 @@ fn bytes_no_parser_can_read_have_no_kind() { } } -// ---- each parser refuses the other kind by name ---- - #[test] fn the_delta_parser_refuses_a_script_artifact_by_name() { let refusal = PackageDelta::parse(&script_artifact()).expect_err("the artifact is refused"); @@ -95,8 +82,6 @@ fn both_parsers_refuse_a_kind_neither_of_them_reads() { ); } -/// The Package Delta's own canonical bytes must keep reading as a Package Delta — the `kind` check -/// is new, and version 1 artifacts on disk carry no such member. #[test] fn a_canonical_package_delta_still_reads_as_one() { let delta = PackageDelta::parse(DELTA).expect("artifact parses"); diff --git a/crates/lyracore-package-delta/tests/canonical_form.rs b/crates/lyracore-package-delta/tests/canonical_form.rs index 8966b374a..d6a6668d8 100644 --- a/crates/lyracore-package-delta/tests/canonical_form.rs +++ b/crates/lyracore-package-delta/tests/canonical_form.rs @@ -1,5 +1,3 @@ -//! Canonical serialization: equivalent artifacts must produce identical bytes. - mod common; use common::{ @@ -9,8 +7,6 @@ use lyracore_package_delta::{canonicalize, PackageDelta}; #[test] fn member_order_whitespace_and_number_spelling_do_not_change_the_bytes() { - // The same two claims, written two ways: reordered members, different indentation, a padded - // unsigned 64-bit string, and a float spelled with a trailing zero. let spelled_one = format!( r#"{{ "version": 1, @@ -45,13 +41,6 @@ fn member_order_whitespace_and_number_spelling_do_not_change_the_bytes() { assert_eq!(one, two); } -/// The bytes are frozen, not merely self-consistent. -/// -/// A shard records `game_package_import.artifact_hash` as the BLAKE3 digest of these bytes, and a -/// Package's Build Identity records the artifact's own hash. Both are stored on live realms, so a -/// canonical form that moved — even in a way that still round-trips — would invalidate every stored -/// digest and force a realm-wide reapply. This case pins one artifact that exercises every value -/// type, both key shapes and the claim ordering, so any such move fails here first. #[test] fn a_frozen_artifact_still_canonicalizes_to_the_same_bytes() { let written = format!( @@ -138,8 +127,6 @@ fn an_unsigned_64_bit_value_is_written_as_a_decimal_string() { let canonical = canonicalize(&source).expect("artifact parses"); - // The largest u64 survives intact. Through a JSON number it would have come back as - // 18446744073709551616, with nothing to notice the change. assert!( canonical.contains(r#""family_flags":{"type":"u64","value":"18446744073709551615"}"#), "{canonical}" @@ -204,10 +191,7 @@ fn claims_are_ordered_by_table_then_spell_then_effect_index() { #[test] fn a_string_has_one_canonical_escaping() { - // `bs` is a single backslash. Building the JSON this way keeps the long escapes out of the Rust - // literal, where they would be a second layer of escaping to reason about. let bs = "\\"; - // The input spells a tab and a capital A as long escapes, next to a quote and a backslash. let written = format!("{bs}u0009{bs}u0041{bs}u0007{bs}\"{bs}{bs}"); let source = one_spell_update( "example.pkg", @@ -217,8 +201,6 @@ fn a_string_has_one_canonical_escaping() { let canonical = canonicalize(&source).expect("artifact parses"); - // Canonical form takes the short escape where JSON has one, leaves a printable character alone, - // and keeps the long form only for a control character that has no short escape. let expected = format!("{bs}tA{bs}u0007{bs}\"{bs}{bs}"); assert!( canonical.contains(&format!( @@ -228,8 +210,6 @@ fn a_string_has_one_canonical_escaping() { ); } -/// The item family's key shape canonicalizes the same deliberate way the spell family's does: -/// a fixed member, plain decimal, no whitespace. #[test] fn an_item_claim_canonicalizes_to_a_pinned_member_order() { let source = one_item_update( @@ -258,7 +238,5 @@ fn a_character_json_does_not_require_escaping_stays_as_itself() { let canonical = canonicalize(&source).expect("artifact parses"); - // Escaping it would be a second spelling of one artifact, which is what canonical form exists - // to prevent. assert!(canonical.contains("\"Caf\u{e9} Bolt\""), "{canonical}"); } diff --git a/crates/lyracore-package-delta/tests/cast_identifiers.rs b/crates/lyracore-package-delta/tests/cast_identifiers.rs index 172f57c13..496ad0270 100644 --- a/crates/lyracore-package-delta/tests/cast_identifiers.rs +++ b/crates/lyracore-package-delta/tests/cast_identifiers.rs @@ -1,8 +1,3 @@ -//! The casts family's identifier policy: `game_creature_spell` follows the loot shape, with its -//! band checked against the row's own surrogate `id`. `game_creature_cast` has no Package band -//! because it is update-only and every `insert` is refused. Both tables refuse the Module's -//! fixture creature cluster at their own creature-entry boundary. - mod common; use common::{ @@ -24,8 +19,6 @@ fn artifact(package: &str, claims: &str) -> String { const A_PRIORITY: &str = r#"{"priority":{"type":"u8","value":20}}"#; const A_SPELL_ID: &str = r#"{"spell_id":{"type":"u32","value":200}}"#; -// ---- game_creature_spell: the loot-shaped band ---- - #[test] fn a_creature_spell_key_is_the_id_itself() { let key = PrimaryKey::creature_spell(10_000_001).expect("a Package cast row is claimable"); @@ -166,8 +159,6 @@ fn an_incomplete_creature_spell_insert_is_refused() { } } -// ---- game_creature_cast: update-only, no band at all ---- - #[test] fn a_creature_cast_key_is_the_creature_entry_itself() { let key = PrimaryKey::creature_cast(REAL_CREATURE_CAST).expect("a real creature is claimable"); diff --git a/crates/lyracore-package-delta/tests/claim_counts.rs b/crates/lyracore-package-delta/tests/claim_counts.rs index 7261533c3..92e7a473f 100644 --- a/crates/lyracore-package-delta/tests/claim_counts.rs +++ b/crates/lyracore-package-delta/tests/claim_counts.rs @@ -1,6 +1,3 @@ -//! What one Package Delta reports about its own size, which is what an applier records as -//! provenance and what a dry-run check prints. - mod common; use common::{ @@ -38,8 +35,6 @@ fn a_package_counts_its_updates_and_its_inserts_apart() { assert_eq!(counts.updated_rows, 2); assert_eq!(counts.inserted_spells, 1); assert_eq!(counts.inserted_effects, 2); - // `inserted_rows` is the family-generic total: the spell writer fills it too, as the sum of - // its two legacy counts. assert_eq!(counts.inserted_rows, 3); } @@ -58,8 +53,6 @@ fn a_package_that_only_tunes_existing_rows_inserts_nothing() { assert_eq!(counts.inserted_rows, 0); } -/// The items family has no legacy pair of its own — every insert counts through `inserted_rows` -/// alone, and `inserted_spells`/`inserted_effects` stay at zero. #[test] fn an_item_package_counts_its_inserts_through_the_family_generic_total_only() { let claims = [ diff --git a/crates/lyracore-package-delta/tests/common/mod.rs b/crates/lyracore-package-delta/tests/common/mod.rs index c151c1655..6a7714ec3 100644 --- a/crates/lyracore-package-delta/tests/common/mod.rs +++ b/crates/lyracore-package-delta/tests/common/mod.rs @@ -1,23 +1,13 @@ -//! Artifact fragments the behaviour tests share. -//! -//! The JSON here is written by hand, the way a Datascript generator would emit it, so the tests -//! exercise the parser rather than a builder that could drift with it. - #![allow(dead_code)] -/// A source digest, in the shape the importer's BLAKE3 hashes already take. pub const HASH_A: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -/// A second, different source digest. pub const HASH_B: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; -/// A spell inside the Package spell range, safe to insert. pub const PACKAGE_SPELL: u32 = 6_000_001; -/// A real vanilla spell (Fireball), safe to update and never safe to insert. pub const REAL_SPELL: u32 = 133; -/// Every claimable `game_spell` column, so an `insert` carries the whole row. pub const WHOLE_SPELL_ROW: &str = r#"{ "name": { "type": "string", "value": "Kindled Bolt" }, "power_type": { "type": "u8", "value": 0 }, @@ -45,7 +35,6 @@ pub const WHOLE_SPELL_ROW: &str = r#"{ "proc_charges": { "type": "u8", "value": 0 } }"#; -/// Every claimable `game_spell_effect` column, so an `insert` carries the whole row. pub const WHOLE_EFFECT_ROW: &str = r#"{ "kind": { "type": "u8", "value": 2 }, "base_points": { "type": "i32", "value": 120 }, @@ -64,13 +53,11 @@ pub const WHOLE_EFFECT_ROW: &str = r#"{ "enters_combat": { "type": "bool", "value": false } }"#; -/// Wraps claims in an artifact envelope. #[must_use] pub fn artifact(package: &str, claims: &str) -> String { format!(r#"{{"version":1,"package":"{package}","source_hash":"{HASH_A}","claims":[{claims}]}}"#) } -/// One `game_spell` claim. #[must_use] pub fn spell_claim(spell_id: u32, operation: &str, fields: &str) -> String { format!( @@ -78,7 +65,6 @@ pub fn spell_claim(spell_id: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_spell_effect` claim. #[must_use] pub fn effect_claim(spell_id: u32, effect_index: u8, operation: &str, fields: &str) -> String { format!( @@ -86,19 +72,15 @@ pub fn effect_claim(spell_id: u32, effect_index: u8, operation: &str, fields: &s ) } -/// The shortest artifact that parses: one Package, one partial update on one real spell. #[must_use] pub fn one_spell_update(package: &str, spell_id: u32, fields: &str) -> String { artifact(package, &spell_claim(spell_id, "update", fields)) } -/// An item inside the Package item range, safe to insert. pub const PACKAGE_ITEM: u32 = 7_000_001; -/// A real vanilla item (Worn Shortsword), safe to update and never safe to insert. pub const REAL_ITEM: u32 = 25; -/// Every claimable `game_item_template` column, so an `insert` carries the whole row. pub const WHOLE_ITEM_ROW: &str = r#"{ "class": { "type": "u8", "value": 2 }, "subclass": { "type": "u8", "value": 7 }, @@ -159,7 +141,6 @@ pub const WHOLE_ITEM_ROW: &str = r#"{ "allowed_race": { "type": "u32", "value": 255 } }"#; -/// One `game_item_template` claim. #[must_use] pub fn item_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -167,19 +148,15 @@ pub fn item_claim(entry: u32, operation: &str, fields: &str) -> String { ) } -/// The shortest artifact that parses: one Package, one partial update on one real item. #[must_use] pub fn one_item_update(package: &str, entry: u32, fields: &str) -> String { artifact(package, &item_claim(entry, "update", fields)) } -/// A quest inside the Package quest range, safe to insert. pub const PACKAGE_QUEST: u32 = 8_000_001; -/// A real vanilla quest entry, safe to update and never safe to insert. -pub const REAL_QUEST: u32 = 1; // "Undertaking", the first Elwynn quest, always imported in-box. +pub const REAL_QUEST: u32 = 1; -/// Every claimable `game_quest_template` column, so an `insert` carries the whole row. pub const WHOLE_QUEST_ROW: &str = r#"{ "min_level": { "type": "u32", "value": 1 }, "quest_level": { "type": "u32", "value": 3 }, @@ -203,7 +180,6 @@ pub const WHOLE_QUEST_ROW: &str = r#"{ "quest_type": { "type": "u32", "value": 0 } }"#; -/// One `game_quest_template` claim. #[must_use] pub fn quest_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -211,7 +187,6 @@ pub fn quest_claim(entry: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_quest_text` claim. #[must_use] pub fn quest_text_claim(quest_entry: u32, operation: &str, fields: &str) -> String { format!( @@ -219,7 +194,6 @@ pub fn quest_text_claim(quest_entry: u32, operation: &str, fields: &str) -> Stri ) } -/// One `game_quest_objective` claim. #[must_use] pub fn quest_objective_claim( quest_entry: u32, @@ -232,7 +206,6 @@ pub fn quest_objective_claim( ) } -/// One `game_quest_cast_objective` claim. #[must_use] pub fn quest_cast_objective_claim( quest_entry: u32, @@ -245,7 +218,6 @@ pub fn quest_cast_objective_claim( ) } -/// One `game_quest_reward_item` claim. #[must_use] pub fn quest_reward_item_claim( quest_entry: u32, @@ -258,7 +230,6 @@ pub fn quest_reward_item_claim( ) } -/// One `game_quest_reward_choice` claim. #[must_use] pub fn quest_reward_choice_claim( quest_entry: u32, @@ -271,13 +242,10 @@ pub fn quest_reward_choice_claim( ) } -/// A loot row inside the Package loot range, safe to insert. pub const PACKAGE_LOOT: u64 = 9_000_001; -/// A real imported pickpocket-loot row id, safe to update and never safe to insert. pub const REAL_PICKPOCKET_LOOT: u64 = 1; -/// Every claimable `game_pickpocket_loot` column, so an `insert` carries the whole row. pub const WHOLE_PICKPOCKET_LOOT_ROW: &str = r#"{ "creature_entry": { "type": "u32", "value": 6 }, "item_entry": { "type": "u32", "value": 25 }, @@ -287,7 +255,6 @@ pub const WHOLE_PICKPOCKET_LOOT_ROW: &str = r#"{ "quest_only": { "type": "bool", "value": false } }"#; -/// One `game_pickpocket_loot` claim. #[must_use] pub fn pickpocket_loot_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -295,11 +262,8 @@ pub fn pickpocket_loot_claim(id: u64, operation: &str, fields: &str) -> String { ) } -/// A caster creature's template entry, safe to update on `game_creature_cast`. This table is -/// update-only, so there is no "safe to insert" identifier at all. pub const REAL_CREATURE_CAST: u32 = 6; -/// One `game_creature_cast` claim. #[must_use] pub fn creature_cast_claim(creature_entry: u32, operation: &str, fields: &str) -> String { format!( @@ -307,13 +271,10 @@ pub fn creature_cast_claim(creature_entry: u32, operation: &str, fields: &str) - ) } -/// A `game_creature_spell` row inside the Package cast range, safe to insert. pub const PACKAGE_CREATURE_SPELL: u64 = 10_000_001; -/// A real imported `game_creature_spell` row id, safe to update and never safe to insert. pub const REAL_CREATURE_SPELL: u64 = 1; -/// Every claimable `game_creature_spell` column, so an `insert` carries the whole row. pub const WHOLE_CREATURE_SPELL_ROW: &str = r#"{ "creature_entry": { "type": "u32", "value": 6 }, "spell_id": { "type": "u32", "value": 133 }, @@ -322,7 +283,6 @@ pub const WHOLE_CREATURE_SPELL_ROW: &str = r#"{ "condition_value": { "type": "u8", "value": 0 } }"#; -/// One `game_creature_spell` claim. #[must_use] pub fn creature_spell_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -330,14 +290,10 @@ pub fn creature_spell_claim(id: u64, operation: &str, fields: &str) -> String { ) } -/// A `game_trainer_spell` row inside the Package trainer range, safe to insert. pub const PACKAGE_TRAINER_SPELL: u64 = 11_000_001; -/// A real imported `game_trainer_spell` row id, safe to update and never safe to insert. pub const REAL_TRAINER_SPELL: u64 = 1; -/// Every claimable `game_trainer_spell` column, so an `insert` carries the whole row. A class -/// offering (`learn_skill_line` 0): `spell_id` names a real `game_spell` row. pub const WHOLE_TRAINER_SPELL_ROW: &str = r#"{ "trainer_entry": { "type": "u32", "value": 100 }, "spell_id": { "type": "u32", "value": 133 }, @@ -347,7 +303,6 @@ pub const WHOLE_TRAINER_SPELL_ROW: &str = r#"{ "learn_skill_cap": { "type": "u32", "value": 75 } }"#; -/// One `game_trainer_spell` claim. #[must_use] pub fn trainer_spell_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -355,24 +310,16 @@ pub fn trainer_spell_claim(id: u64, operation: &str, fields: &str) -> String { ) } -// ---- gossip ---- - -/// A gossip row inside the Package gossip range, safe to insert. One band covers all five -/// insertable gossip tables, so one constant serves them all. pub const PACKAGE_GOSSIP: u64 = 12_000_001; -/// A real creature template entry with a gossip menu, safe to update and never safe to insert. pub const REAL_GOSSIP_MENU: u32 = 6; -/// A real imported `game_npc_text` row, safe to update and never safe to insert. pub const REAL_NPC_TEXT: u32 = 1; -/// Every claimable `game_npc_text` column, so an `insert` carries the whole row. pub const WHOLE_NPC_TEXT_ROW: &str = r#"{ "text": { "type": "string", "value": "The forge is cold, friend." } }"#; -/// Every claimable `game_npc_text_slot` column, so an `insert` carries the whole row. pub const WHOLE_NPC_TEXT_SLOT_ROW: &str = r#"{ "text_id": { "type": "u32", "value": 12000001 }, "slot_index": { "type": "u8", "value": 0 }, @@ -381,7 +328,6 @@ pub const WHOLE_NPC_TEXT_SLOT_ROW: &str = r#"{ "probability": { "type": "f32", "value": 1.0 } }"#; -/// Every claimable `game_gossip_option` column, so an `insert` carries the whole row. pub const WHOLE_GOSSIP_OPTION_ROW: &str = r#"{ "entry": { "type": "u32", "value": 6 }, "option_index": { "type": "u32", "value": 0 }, @@ -394,7 +340,6 @@ pub const WHOLE_GOSSIP_OPTION_ROW: &str = r#"{ "cond_value2": { "type": "u32", "value": 0 } }"#; -/// One `game_gossip_menu` claim. #[must_use] pub fn gossip_menu_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -402,7 +347,6 @@ pub fn gossip_menu_claim(entry: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_npc_text` claim. #[must_use] pub fn npc_text_claim(text_id: u32, operation: &str, fields: &str) -> String { format!( @@ -410,7 +354,6 @@ pub fn npc_text_claim(text_id: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_npc_text_slot` claim. #[must_use] pub fn npc_text_slot_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -418,7 +361,6 @@ pub fn npc_text_slot_claim(id: u64, operation: &str, fields: &str) -> String { ) } -/// One `game_gossip_option` claim. #[must_use] pub fn gossip_option_claim(row_id: u32, operation: &str, fields: &str) -> String { format!( @@ -426,29 +368,22 @@ pub fn gossip_option_claim(row_id: u32, operation: &str, fields: &str) -> String ) } -// ---- globals ---- - -/// A globals row inside the Package globals range, safe to insert. pub const PACKAGE_GLOBALS: u64 = 13_000_001; -/// A real imported `game_graveyard_zone` row, safe to update and never safe to insert. pub const REAL_GRAVEYARD_ZONE: u64 = 1; -/// Every claimable `game_graveyard_zone` column, so an `insert` carries the whole row. pub const WHOLE_GRAVEYARD_ZONE_ROW: &str = r#"{ "safe_loc_id": { "type": "u32", "value": 105 }, "zone_id": { "type": "u32", "value": 12 }, "faction": { "type": "u32", "value": 0 } }"#; -/// Every claimable `game_createinfo_spell` column, so an `insert` carries the whole row. pub const WHOLE_CREATEINFO_SPELL_ROW: &str = r#"{ "race": { "type": "u8", "value": 1 }, "class": { "type": "u8", "value": 1 }, "spell_id": { "type": "u32", "value": 133 } }"#; -/// One `game_class_level_stats` claim. #[must_use] pub fn class_level_stats_claim(class: u8, level: u8, operation: &str, fields: &str) -> String { format!( @@ -456,7 +391,6 @@ pub fn class_level_stats_claim(class: u8, level: u8, operation: &str, fields: &s ) } -/// One `game_start_position` claim. #[must_use] pub fn start_position_claim(race: u8, class: u8, operation: &str, fields: &str) -> String { format!( @@ -464,7 +398,6 @@ pub fn start_position_claim(race: u8, class: u8, operation: &str, fields: &str) ) } -/// One `game_graveyard_zone` claim. #[must_use] pub fn graveyard_zone_claim(row_id: u64, operation: &str, fields: &str) -> String { format!( @@ -472,7 +405,6 @@ pub fn graveyard_zone_claim(row_id: u64, operation: &str, fields: &str) -> Strin ) } -/// One `game_createinfo_spell` claim. #[must_use] pub fn createinfo_spell_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -480,7 +412,6 @@ pub fn createinfo_spell_claim(id: u64, operation: &str, fields: &str) -> String ) } -/// One `game_areatrigger_teleport` claim. #[must_use] pub fn areatrigger_teleport_claim(trigger_id: u32, operation: &str, fields: &str) -> String { format!( @@ -488,15 +419,10 @@ pub fn areatrigger_teleport_claim(trigger_id: u32, operation: &str, fields: &str ) } -// ---- spellmeta ---- - -/// A `game_spell_learn` row inside the Package spell metadata range, safe to insert. pub const PACKAGE_SPELL_LEARN: u64 = 14_000_001; -/// A real imported `game_spell_learn` row, safe to update and never safe to insert. pub const REAL_SPELL_LEARN: u64 = 1; -/// Every claimable `game_spell_chain` column, so an `insert` carries the whole row. pub const WHOLE_SPELL_CHAIN_ROW: &str = r#"{ "prev_spell": { "type": "u32", "value": 133 }, "first_spell": { "type": "u32", "value": 133 }, @@ -504,13 +430,11 @@ pub const WHOLE_SPELL_CHAIN_ROW: &str = r#"{ "req_spell": { "type": "u32", "value": 0 } }"#; -/// Every claimable `game_spell_learn` column, so an `insert` carries the whole row. pub const WHOLE_SPELL_LEARN_ROW: &str = r#"{ "parent_spell": { "type": "u32", "value": 133 }, "learn_spell": { "type": "u32", "value": 168 } }"#; -/// One `game_spell_chain` claim. #[must_use] pub fn spell_chain_claim(spell_id: u32, operation: &str, fields: &str) -> String { format!( @@ -518,7 +442,6 @@ pub fn spell_chain_claim(spell_id: u32, operation: &str, fields: &str) -> String ) } -/// One `game_spell_learn` claim. #[must_use] pub fn spell_learn_claim(id: u64, operation: &str, fields: &str) -> String { format!( @@ -526,7 +449,6 @@ pub fn spell_learn_claim(id: u64, operation: &str, fields: &str) -> String { ) } -/// One `game_spell_proc_event` claim. #[must_use] pub fn spell_proc_event_claim(spell_id: u32, operation: &str, fields: &str) -> String { format!( @@ -534,25 +456,16 @@ pub fn spell_proc_event_claim(spell_id: u32, operation: &str, fields: &str) -> S ) } -// ---- creatures ---- - -/// A creature identifier inside the Package creature range, safe to insert. One band covers the -/// template and the spawn. pub const PACKAGE_CREATURE: u32 = 15_000_001; -/// A real imported creature template, safe to update and never safe to insert. -pub const REAL_CREATURE: u32 = 6; // Kobold Vermin. +pub const REAL_CREATURE: u32 = 6; -/// A real imported spawn identifier, safe to update and never safe to insert. pub const REAL_CREATURE_SPAWN: u32 = 4_242; -/// The map a spatial fixture claim sits on: Eastern Kingdoms, which every in-box scope owns. pub const REAL_MAP: u32 = 0; -/// A map no in-box World Import Scope owns, so a claim on it routes to no Shard in this build. pub const FOREIGN_MAP: u32 = 571; -/// Every claimable `game_creature_template` column, so an `insert` carries the whole row. pub const WHOLE_CREATURE_TEMPLATE_ROW: &str = r#"{ "name": { "type": "string", "value": "Kindled Sentinel" }, "subname": { "type": "string", "value": "Forge Guard" }, @@ -582,7 +495,6 @@ pub const WHOLE_CREATURE_TEMPLATE_ROW: &str = r#"{ "trainer_class": { "type": "u8", "value": 0 } }"#; -/// Every claimable `game_creature_spawn` column, so an `insert` carries the whole row. pub const WHOLE_CREATURE_SPAWN_ROW: &str = r#"{ "x": { "type": "f32", "value": -8949.95 }, "y": { "type": "f32", "value": -132.493 }, @@ -592,7 +504,6 @@ pub const WHOLE_CREATURE_SPAWN_ROW: &str = r#"{ "respawn_secs": { "type": "u32", "value": 300 } }"#; -/// One `game_creature_template` claim. #[must_use] pub fn creature_template_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -600,7 +511,6 @@ pub fn creature_template_claim(entry: u32, operation: &str, fields: &str) -> Str ) } -/// One `game_creature_spawn` claim. #[must_use] pub fn creature_spawn_claim( map_id: u32, @@ -614,19 +524,12 @@ pub fn creature_spawn_claim( ) } -// ---- gameobjects ---- - -/// A gameobject identifier inside the Package gameobject range, safe to insert. One band covers -/// the template, the trap and the spawn. pub const PACKAGE_GAMEOBJECT: u32 = 16_000_001; -/// A real imported gameobject template, safe to update and never safe to insert. -pub const REAL_GAMEOBJECT: u32 = 1_731; // Copper Vein. +pub const REAL_GAMEOBJECT: u32 = 1_731; -/// A real imported gameobject spawn identifier. pub const REAL_GAMEOBJECT_SPAWN: u32 = 7_777; -/// Every claimable `game_gameobject_template` column, so an `insert` carries the whole row. pub const WHOLE_GAMEOBJECT_TEMPLATE_ROW: &str = r#"{ "type_id": { "type": "u8", "value": 3 }, "display_id": { "type": "u32", "value": 259 }, @@ -640,13 +543,11 @@ pub const WHOLE_GAMEOBJECT_TEMPLATE_ROW: &str = r#"{ "size": { "type": "f32", "value": 1.0 } }"#; -/// Every claimable `game_gameobject_trap` column, so an `insert` carries the whole row. pub const WHOLE_GAMEOBJECT_TRAP_ROW: &str = r#"{ "spell_id": { "type": "u32", "value": 133 }, "cooldown_secs": { "type": "u32", "value": 4 } }"#; -/// Every claimable `game_gameobject` column, so an `insert` carries the whole row. pub const WHOLE_GAMEOBJECT_SPAWN_ROW: &str = r#"{ "template_entry": { "type": "u32", "value": 16000001 }, "x": { "type": "f32", "value": -8949.95 }, @@ -660,7 +561,6 @@ pub const WHOLE_GAMEOBJECT_SPAWN_ROW: &str = r#"{ "rotation_3": { "type": "f32", "value": 0.0 } }"#; -/// One `game_gameobject_template` claim. #[must_use] pub fn gameobject_template_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -668,7 +568,6 @@ pub fn gameobject_template_claim(entry: u32, operation: &str, fields: &str) -> S ) } -/// One `game_gameobject_trap` claim. #[must_use] pub fn gameobject_trap_claim(entry: u32, operation: &str, fields: &str) -> String { format!( @@ -676,7 +575,6 @@ pub fn gameobject_trap_claim(entry: u32, operation: &str, fields: &str) -> Strin ) } -/// One `game_gameobject` claim. #[must_use] pub fn gameobject_spawn_claim(map_id: u32, spawn_id: u32, operation: &str, fields: &str) -> String { format!( @@ -684,13 +582,10 @@ pub fn gameobject_spawn_claim(map_id: u32, spawn_id: u32, operation: &str, field ) } -/// A broadcast text, summon placement or quest event requirement inside the Package EventAI range. pub const PACKAGE_CREATURE_AI: u32 = 17_000_001; -/// A real imported broadcast text identifier. pub const REAL_BROADCAST_TEXT: u32 = 900; -/// Every claimable `game_creature_ai_broadcast_text` column, so an `insert` carries the whole row. pub const WHOLE_BROADCAST_TEXT_ROW: &str = r#"{ "male_text": { "type": "string", "value": "The forge remembers." }, "female_text": { "type": "string", "value": "The forge remembers." }, @@ -704,7 +599,6 @@ pub const WHOLE_BROADCAST_TEXT_ROW: &str = r#"{ "emote_id_3": { "type": "u32", "value": 0 } }"#; -/// Every claimable `game_creature_ai_summon` column, so an `insert` carries the whole row. pub const WHOLE_SUMMON_ROW: &str = r#"{ "x": { "type": "f32", "value": -8949.95 }, "y": { "type": "f32", "value": -132.493 }, @@ -713,12 +607,10 @@ pub const WHOLE_SUMMON_ROW: &str = r#"{ "lifetime_ms": { "type": "u32", "value": 30000 } }"#; -/// Every claimable `game_quest_event_requirement` column, so an `insert` carries the whole row. pub const WHOLE_QUEST_EVENT_REQUIREMENT_ROW: &str = r#"{ "quest_entry": { "type": "u32", "value": 8000001 } }"#; -/// One `game_creature_ai_broadcast_text` claim. #[must_use] pub fn broadcast_text_claim(id: u32, operation: &str, fields: &str) -> String { format!( @@ -726,7 +618,6 @@ pub fn broadcast_text_claim(id: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_creature_ai_summon` claim. #[must_use] pub fn summon_claim(id: u32, operation: &str, fields: &str) -> String { format!( @@ -734,7 +625,6 @@ pub fn summon_claim(id: u32, operation: &str, fields: &str) -> String { ) } -/// One `game_quest_event_requirement` claim. #[must_use] pub fn quest_event_requirement_claim(id: u64, operation: &str, fields: &str) -> String { format!( diff --git a/crates/lyracore-package-delta/tests/conflict_tracer.rs b/crates/lyracore-package-delta/tests/conflict_tracer.rs index 0f055bb63..47794c786 100644 --- a/crates/lyracore-package-delta/tests/conflict_tracer.rs +++ b/crates/lyracore-package-delta/tests/conflict_tracer.rs @@ -1,5 +1,3 @@ -//! Tracing several Packages together: what merges, what conflicts, and what the report says. - mod common; use common::{ @@ -132,8 +130,6 @@ fn one_package_inserting_and_another_updating_the_same_row_is_not_a_key_collisio let traced = trace(&[owner, tuner]); - // `gcd_ms` is one of the columns the complete insert carries, so the two Packages do collide on - // that column. What they must NOT collide on is the primary key. assert!( !traced .conflicts() @@ -165,7 +161,6 @@ fn a_spell_header_and_its_child_effect_are_different_rows() { assert!(traced.is_clear(), "{:?}", traced.conflicts()); assert_eq!(traced.rows().len(), 2); - // 133 for the header, 133 * 4 + 0 for the effect. assert_eq!(traced.rows()[0].row_id(), 133); assert_eq!(traced.rows()[1].row_id(), 532); } @@ -246,7 +241,6 @@ fn the_conflict_report_reads_the_same_way_for_the_same_input() { }; assert_eq!(render(&build()), render(&build())); - // Sorted by row, then by column: spell 133 before spell 200, cooldown before gcd. let report = render(&build()); let lines: Vec<&str> = report.lines().collect(); assert_eq!(lines.len(), 4); @@ -290,8 +284,6 @@ fn tracing_nothing_is_clear() { assert!(traced.rows().is_empty()); } -/// The item family traces the same way the spell family does: two Packages tuning different -/// columns of one item merge, cleanly. #[test] fn two_packages_claiming_different_columns_of_one_item_merge() { let first = delta( @@ -310,8 +302,6 @@ fn two_packages_claiming_different_columns_of_one_item_merge() { assert_eq!(traced.rows()[0].table(), Table::Item); } -/// Two Packages inventing the same item entry conflict the same way two Packages inventing the -/// same spell do. #[test] fn two_packages_inserting_the_same_item_entry_conflict() { let first = delta( diff --git a/crates/lyracore-package-delta/tests/creature_ai_identifiers.rs b/crates/lyracore-package-delta/tests/creature_ai_identifiers.rs index 33b299824..412d92670 100644 --- a/crates/lyracore-package-delta/tests/creature_ai_identifiers.rs +++ b/crates/lyracore-package-delta/tests/creature_ai_identifiers.rs @@ -1,9 +1,3 @@ -//! The creature-ai family's identifier policy and its global keys. -//! -//! One band covers three tables that share nothing else: a broadcast text, a summon placement and -//! a quest event requirement are three independent identifier spaces. None of the three names a -//! map, so every claim in this family is global and reaches every Shard. - mod common; use common::{ @@ -33,8 +27,6 @@ fn every_creature_ai_key_is_its_own_row_identifier() { assert_eq!(requirement.table(), Table::QuestEventRequirement); } -/// The `creature-ai` base import writes these three tables with global SQL, so a claim on one -/// reaches every Shard. Nothing in the family carries a map to route by. #[test] fn no_creature_ai_claim_states_a_map() { for key in [ @@ -84,8 +76,6 @@ fn an_insert_outside_the_package_range_is_refused() { } } -/// Every insertable creature-ai table shares one band, so one out-of-range identifier is refused -/// the same way whichever of them names it. #[test] fn one_band_covers_every_insertable_creature_ai_table() { let out_of_band = PACKAGE_CREATURE_AI_ID_FLOOR - 1; @@ -143,8 +133,6 @@ fn the_package_creature_ai_band_is_its_own() { assert!(!is_fixture_reserved_creature_ai_id(id)); } -/// Row 0 is not a row. Every creature-ai table keys on a surrogate identifier, so the refusal is -/// the same on all three. #[test] fn row_zero_is_not_a_creature_ai_row() { for claim in [ @@ -160,8 +148,6 @@ fn row_zero_is_not_a_creature_ai_row() { } } -/// The claim carries the row identifier in its key, so naming it again in `fields` would let a -/// Package state it twice and disagree with itself. #[test] fn a_creature_ai_claim_may_not_restate_its_key() { assert_eq!( diff --git a/crates/lyracore-package-delta/tests/creature_identifiers.rs b/crates/lyracore-package-delta/tests/creature_identifiers.rs index c82a8c394..84099fda8 100644 --- a/crates/lyracore-package-delta/tests/creature_identifiers.rs +++ b/crates/lyracore-package-delta/tests/creature_identifiers.rs @@ -1,9 +1,3 @@ -//! The creatures family's identifier policy and its spatial key. -//! -//! `game_creature_template` follows the item shape: one band, checked against the row's own entry. -//! `game_creature_spawn` is the build's first SPATIAL table: its key names the map that routes the -//! claim as well as the two components its durable guid is derived from. - mod common; use common::{ @@ -29,8 +23,6 @@ fn a_creature_template_key_is_the_entry_itself() { assert_eq!(key.table(), Table::CreatureTemplate); } -/// The guid is the importer's `world_guid`: the client's Unit tag, the template entry, the spawn -/// identifier. Derived from the key so no artifact ever writes one. #[test] fn a_spawn_key_derives_the_importers_creature_guid() { let key = PrimaryKey::creature_spawn(REAL_MAP, REAL_CREATURE, REAL_CREATURE_SPAWN) @@ -46,8 +38,6 @@ fn a_spawn_key_derives_the_importers_creature_guid() { ); } -/// The map is what routes a spatial claim to a Shard, and it is the only reason it is in the key. -/// Two spawns of one template on two maps are two claims. #[test] fn a_spawn_claim_states_the_map_it_routes_by() { let key = PrimaryKey::creature_spawn(FOREIGN_MAP, REAL_CREATURE, REAL_CREATURE_SPAWN) @@ -63,8 +53,6 @@ fn a_spawn_claim_states_the_map_it_routes_by() { ); } -/// The map does not reach the durable guid, so the same spawn on two maps is one durable row. The -/// Module's preflight is what refuses that plan; here it is only stated. #[test] fn the_map_stays_out_of_the_derived_guid() { let here = PrimaryKey::creature_spawn(REAL_MAP, REAL_CREATURE, REAL_CREATURE_SPAWN) @@ -112,8 +100,6 @@ fn an_insert_outside_the_package_range_is_refused() { } } -/// One band covers both insertable tables: the template's own entry and the spawn's own spawn -/// identifier. The template a spawn NAMES is the other side of the relation and takes no band. #[test] fn one_band_covers_the_template_and_the_spawn_identifier() { let out_of_band = PACKAGE_CREATURE_ID_FLOOR - 1; @@ -188,8 +174,6 @@ fn a_fixture_reserved_creature_is_refused_under_every_operation() { } } -/// The Module seeds its creature fixtures at `51xxx` and asserts against them, so a Package tuning -/// one would turn those tests into a report about the Package. #[test] fn a_fixture_cluster_creature_template_is_refused_under_every_operation() { for id in [FIXTURE_CREATURE_ID_FLOOR, 51_001, FIXTURE_CREATURE_ID_CEIL] { @@ -206,8 +190,6 @@ fn a_fixture_cluster_creature_template_is_refused_under_every_operation() { } } -/// The fixture cluster names TEMPLATE entries. Real imported spawn identifiers run straight -/// through the same numbers, so a spawn takes the project-wide band alone. #[test] fn the_fixture_creature_cluster_does_not_reach_spawn_identifiers() { PackageDelta::parse(&artifact( @@ -232,8 +214,6 @@ fn the_fixture_creature_cluster_does_not_reach_spawn_identifiers() { ); } -/// Both halves of the guid are 24 bits wide, so a wider component would wrap into another -/// creature's row instead of addressing its own. #[test] fn a_key_component_wider_than_the_guid_field_is_refused() { let too_wide = MAX_CREATURE_GUID_COMPONENT + 1; @@ -251,7 +231,6 @@ fn a_key_component_wider_than_the_guid_field_is_refused() { ); } -/// The whole band has to fit both 24-bit fields, or a Package spawn could not be addressed at all. #[test] fn the_whole_package_creature_band_fits_the_guid_fields() { const { assert!(PACKAGE_CREATURE_ID_CEIL <= MAX_CREATURE_GUID_COMPONENT) }; @@ -261,8 +240,6 @@ fn the_whole_package_creature_band_fits_the_guid_fields() { assert!(!is_fixture_reserved_creature_id(PACKAGE_CREATURE_ID_FLOOR)); } -/// The key already carries the map, the template and the spawn identifier, so a claim that also -/// set them as columns could disagree with itself. #[test] fn a_spawn_may_not_claim_its_own_key_columns() { for column in ["map_id", "entry", "spawn_id", "guid"] { @@ -289,8 +266,6 @@ fn a_spawn_may_not_claim_its_own_key_columns() { } } -/// A spawn's live state — when it respawns, when its corpse decays, which life is standing — is -/// written by the running Shard, never by an artifact. #[test] fn a_spawn_may_not_claim_the_shards_live_state() { for column in ["respawn_at", "despawn_at", "life_seq"] { diff --git a/crates/lyracore-package-delta/tests/datascript_interop.rs b/crates/lyracore-package-delta/tests/datascript_interop.rs index 8a9ab70ee..a44c0620d 100644 --- a/crates/lyracore-package-delta/tests/datascript_interop.rs +++ b/crates/lyracore-package-delta/tests/datascript_interop.rs @@ -1,20 +1,3 @@ -//! The contract between the Datascript authoring library and this crate. -//! -//! The library writes artifacts in TypeScript; this crate reads them in Rust. Three things have to -//! hold across that seam, and each has a test here: -//! -//! * an artifact the library emits parses, traces clean, and canonicalizes to the SAME bytes it was -//! written with; -//! * both sides name the same durable columns, so a schema move cannot leave one behind; -//! * both sides spell an `f32` identically, which is the only value type where two languages could -//! plausibly disagree. -//! -//! # Licensing -//! -//! Every fixture here is hand-written and every value in it is invented. `base-snapshot.json` is a -//! synthetic Base Snapshot, and `fire-nova.expected.json` was emitted from it. No client data, and -//! nothing derived from client data, is involved. - use std::collections::BTreeSet; use std::path::PathBuf; @@ -73,7 +56,7 @@ fn the_frozen_artifact_traces_without_a_conflict() { let traced = trace(&[fire_nova()]); assert!(traced.is_clear(), "{:?}", traced.conflicts()); - assert_eq!(traced.rows().len(), 15); // five ranks, each a header and two effects + assert_eq!(traced.rows().len(), 15); } #[test] @@ -85,7 +68,6 @@ fn the_rank_ladder_inserts_five_package_spells_and_their_child_effects() { assert_eq!(counts.updated_rows, 0); } -/// The child effects a clone creates follow the packed key rule, derived rather than authored. #[test] fn every_child_effect_key_packs_from_its_spell_and_index() { for claim in fire_nova().claims() { @@ -103,7 +85,6 @@ fn every_child_effect_key_packs_from_its_spell_and_index() { } } -/// Every insert the library emits carries the whole row, because a clone copies every column. #[test] fn every_inserted_row_carries_every_column_of_its_table() { for claim in fire_nova().claims() { @@ -113,10 +94,6 @@ fn every_inserted_row_carries_every_column_of_its_table() { } } -/// The Datascript catalogue and this crate's catalogue are two hand-maintained lists of one schema. -/// A column added to one and not the other would let an author write a claim the parser refuses, or -/// hide a column an author should be able to set. The scanner is deliberately narrow: it reads the -/// `column:` members of the two exported catalogues and nothing else. #[test] fn the_datascript_column_catalogue_names_the_same_columns_as_this_crate() { let source = authoring_library("schema.ts"); @@ -135,7 +112,6 @@ fn the_datascript_column_catalogue_names_the_same_columns_as_this_crate() { } } -/// The `column: "…"` members of one exported catalogue, in declaration order. fn durable_columns(source: &str, marker: &str) -> Vec { let start = source .find(marker) @@ -153,9 +129,6 @@ fn durable_columns(source: &str, marker: &str) -> Vec { .collect() } -/// The two writers agree on every float, including the ones a shortest-representation algorithm has -/// to think about. The fixture's literals came from Rust; the TypeScript suite asserts the same -/// file, so neither side can drift alone. #[test] fn both_writers_spell_a_float_the_same_way() { let cases: serde_json::Value = @@ -189,11 +162,6 @@ fn both_writers_spell_a_float_the_same_way() { ); } -// --------------------------------------------------------------------------------------------- -// Refusal parity: the rules the authoring library mirrors, asserted here on the artifact the -// library would have had to write to break each one. -// --------------------------------------------------------------------------------------------- - fn artifact(table: &str, key: &str, operation: &str, fields: &str) -> String { format!( r#"{{"version":1,"package":"example.refusals","source_hash":"{}","claims":[{{"table":"{table}","key":{key},"operation":"{operation}","fields":{fields}}}]}}"#, diff --git a/crates/lyracore-package-delta/tests/delta_check_script_artifacts.rs b/crates/lyracore-package-delta/tests/delta_check_script_artifacts.rs index f3bd3a03d..902cd24cf 100644 --- a/crates/lyracore-package-delta/tests/delta_check_script_artifacts.rs +++ b/crates/lyracore-package-delta/tests/delta_check_script_artifacts.rs @@ -1,12 +1,3 @@ -//! `lyracore-delta-check ...` over a MIXED set of artifacts. -//! -//! A Package ships every artifact kind it has into one generated directory, so the set -//! `lyracore packages build` hands this binary holds Package Deltas and Script Artifacts together. -//! Each file is routed on its own `kind` member, each kind is traced with its own tracer, and the -//! exit status covers both. -//! -//! These run the real binary: the routing lives in its argument handling, not in the library. - use std::process::{Command, Output}; const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -66,8 +57,6 @@ fn a_script_artifact_and_a_package_delta_check_out_together() { assert!(report.contains("bolt.greet"), "{report}"); } -/// A Package Event of the artifact's OWN Package is bindable; nothing has to teach this binary -/// about it, because the parser already knows whose event it is. #[test] fn a_package_event_of_the_artifacts_own_package_checks_out() { let dir = temp_dir("package-event"); @@ -105,8 +94,6 @@ fn an_event_outside_the_catalogue_refuses_the_run() { ); } -/// The collision the tracer exists for: two Packages claiming one Runtime Script identity. It has -/// to fail the run exactly the way a Claim Conflict does. #[test] fn two_packages_shipping_one_script_id_fail_the_run() { let dir = temp_dir("collision"); @@ -150,8 +137,6 @@ fn two_artifacts_for_one_package_fail_even_when_the_scripts_do_not_collide() { assert!(stdout.contains("more than one Script Artifact"), "{stdout}"); } -/// A kind this build does not read still meets the Package Delta parser, which names it rather than -/// complaining about a missing `claims` member. #[test] fn an_artifact_of_an_unknown_kind_is_refused_by_name() { let dir = temp_dir("unknown-kind"); diff --git a/crates/lyracore-package-delta/tests/families.rs b/crates/lyracore-package-delta/tests/families.rs index e00bf3cf2..ac10e320e 100644 --- a/crates/lyracore-package-delta/tests/families.rs +++ b/crates/lyracore-package-delta/tests/families.rs @@ -1,17 +1,9 @@ -//! The table catalogue and the Import Family each table belongs to. -//! -//! An applier is called for one family at a time, so `Table::family` decides which claims that call -//! may touch. These cases hold the catalogue, the parser and the family map together. - use lyracore_package_delta::{ Table, CAST_FAMILY, CREATURE_AI_FAMILY, CREATURE_FAMILY, GAMEOBJECT_FAMILY, GLOBALS_FAMILY, GOSSIP_FAMILY, ITEM_FAMILY, LOOT_FAMILY, QUEST_FAMILY, SPELLMETA_FAMILY, SPELL_FAMILY, TRAINER_FAMILY, }; -/// `Table::ALL`, `Table::as_str` and `Table::parse` are three hand-maintained lists of one -/// catalogue. The match below carries no wildcard, so a table added to the enum stops this case -/// compiling until all three learn it. #[test] fn every_table_in_the_catalogue_parses_back_to_itself() { for table in Table::ALL { @@ -155,9 +147,6 @@ fn the_globals_tables_belong_to_the_globals_import_family() { assert_eq!(GLOBALS_FAMILY, "globals"); } -/// The spell metadata tables belong to `spellmeta`, NOT to `spell`, even though two of the three -/// key on a spell identifier. A base import reloads them in their own family's block, so an apply -/// that ran them under `spell` would be reverted by the block that owns them. #[test] fn the_spell_metadata_tables_belong_to_the_spellmeta_import_family() { for table in [Table::SpellChain, Table::SpellLearn, Table::SpellProcEvent] { @@ -187,8 +176,6 @@ fn the_gameobject_tables_belong_to_the_gameobjects_import_family() { assert_eq!(GAMEOBJECT_FAMILY, "gameobjects"); } -/// `game_gameobject_loot` names a gameobject but reloads under the loot family's own block, so an -/// apply that ran it under `gameobjects` would be reverted by the block that owns it. #[test] fn the_gameobject_loot_table_stays_with_the_loot_family() { assert_eq!(Table::GameobjectLoot.family(), LOOT_FAMILY); @@ -207,17 +194,12 @@ fn the_eventai_catalogue_tables_belong_to_the_creature_ai_import_family() { assert_eq!(CREATURE_AI_FAMILY, "creature-ai"); } -/// `game_quest_event_requirement` names a quest, but the `creature-ai` block loads it. An apply -/// that ran it under `quests` would be reverted by the block that owns it. #[test] fn the_quest_event_requirement_table_stays_with_the_creature_ai_family() { assert_eq!(Table::QuestEventRequirement.family(), CREATURE_AI_FAMILY); assert_ne!(Table::QuestEventRequirement.family(), QUEST_FAMILY); } -/// The `creature-ai` family loads its scripted definitions through a reducer, as a nested rule -/// payload. There is no scalar column shape for it, so no claim may name one — the catalogue -/// carries the family's row-typed tables and nothing else. #[test] fn no_eventai_definition_table_is_claimable() { for name in [ @@ -232,8 +214,6 @@ fn no_eventai_definition_table_is_claimable() { } } -/// A family name travels as a reducer argument and as `game_import_meta.family`, where the importer -/// writes it in lowercase. A name that did not match would route an apply at nothing. #[test] fn every_family_name_is_a_lowercase_import_family_name() { for table in Table::ALL { @@ -247,7 +227,6 @@ fn every_family_name_is_a_lowercase_import_family_name() { } } -/// A table with no claimable columns would accept an `insert` that carries nothing. #[test] fn every_table_has_claimable_columns() { for table in Table::ALL { diff --git a/crates/lyracore-package-delta/tests/gameobject_identifiers.rs b/crates/lyracore-package-delta/tests/gameobject_identifiers.rs index e080012c2..9c84cabe8 100644 --- a/crates/lyracore-package-delta/tests/gameobject_identifiers.rs +++ b/crates/lyracore-package-delta/tests/gameobject_identifiers.rs @@ -1,10 +1,3 @@ -//! The gameobjects family's identifier policy and its spatial key. -//! -//! One band covers three tables. The template and the trap share ONE identifier space, because a -//! trap row describes the template of the same entry; the spawn has its own. `game_gameobject` is -//! spatial: its key names the map that routes the claim beside the spawn identifier its durable -//! guid derives from. - mod common; use common::{ @@ -21,8 +14,6 @@ use lyracore_package_delta::{ const A_NAME: &str = r#"{"name":{"type":"string","value":"Kindled Cache"}}"#; const A_POSITION: &str = r#"{"x":{"type":"f32","value":1.5}}"#; -/// The guid is the importer's `go_guid`: the client's GameObject tag and the spawn identifier. No -/// template entry rides in it, unlike a creature's. #[test] fn a_spawn_key_derives_the_importers_gameobject_guid() { let key = PrimaryKey::gameobject_spawn(REAL_MAP, REAL_GAMEOBJECT_SPAWN) @@ -91,8 +82,6 @@ fn an_insert_outside_the_package_range_is_refused() { } } -/// Every insertable gameobject table shares one band, so one out-of-range identifier is refused -/// the same way whichever of them names it. #[test] fn one_band_covers_every_insertable_gameobject_table() { let out_of_band = PACKAGE_GAMEOBJECT_ID_FLOOR - 1; @@ -155,8 +144,6 @@ fn the_package_gameobject_band_is_its_own() { assert!(!is_fixture_reserved_gameobject_id(PACKAGE_GAMEOBJECT)); } -/// The key carries the map and the spawn identifier, and the grid columns are derived from the -/// coordinates in the same write, so neither may be claimed. #[test] fn a_gameobject_spawn_may_not_claim_a_key_or_a_derived_column() { for column in ["map_id", "spawn_id", "guid"] { diff --git a/crates/lyracore-package-delta/tests/globals_identifiers.rs b/crates/lyracore-package-delta/tests/globals_identifiers.rs index 1c5de0dce..8ecda27be 100644 --- a/crates/lyracore-package-delta/tests/globals_identifiers.rs +++ b/crates/lyracore-package-delta/tests/globals_identifiers.rs @@ -1,7 +1,3 @@ -//! The globals family's identifier policy. Three of its seven tables follow the loot shape (one -//! band, checked against the row's own surrogate key). The other four permit no insert at all: -//! their key names a race, a class, a level or an `AreaTrigger.dbc` trigger id. - mod common; use common::{ @@ -63,8 +59,6 @@ fn an_insert_outside_the_package_range_is_refused() { } } -/// The three insertable globals tables share one band, so one out-of-range identifier is refused -/// the same way whichever of them names it. #[test] fn one_band_covers_every_insertable_globals_table() { let out_of_band = PACKAGE_GLOBALS_ID_FLOOR - 1; @@ -95,8 +89,6 @@ fn an_update_may_name_a_real_imported_graveyard_zone_row() { assert_eq!(delta.claims()[0].key().row_id(), REAL_GRAVEYARD_ZONE); } -/// The four update-only tables: each key names something the client fixes, so no identifier makes -/// an insert legal. #[test] fn the_update_only_globals_tables_refuse_every_insert() { let cases = [ @@ -142,8 +134,6 @@ fn a_stat_curve_row_the_client_already_has_may_be_tuned() { ); } -/// The packed key the Module reads is derived here, never authored, so a claim that names the same -/// class and level names the same durable row. #[test] fn a_stat_curve_key_packs_the_class_and_level_the_module_packs() { let key = PrimaryKey::class_level_stats(2, 60).expect("a real class and level are claimable"); diff --git a/crates/lyracore-package-delta/tests/gossip_identifiers.rs b/crates/lyracore-package-delta/tests/gossip_identifiers.rs index a71c2be69..4fab7bb8a 100644 --- a/crates/lyracore-package-delta/tests/gossip_identifiers.rs +++ b/crates/lyracore-package-delta/tests/gossip_identifiers.rs @@ -1,7 +1,3 @@ -//! The gossip family's identifier policy. Five of its six tables follow the loot shape (one band, -//! checked against each row's own key). `game_gossip_menu` is the sixth: its key names a creature -//! template, so it permits no insert at all, the `game_creature_cast` shape. - mod common; use common::{ @@ -64,8 +60,6 @@ fn an_insert_outside_the_package_range_is_refused() { } } -/// Every insertable gossip table shares one band, so one out-of-range identifier is refused the -/// same way whichever of them names it. #[test] fn one_band_covers_every_insertable_gossip_table() { let out_of_band = PACKAGE_GOSSIP_ID_FLOOR - 1; @@ -97,8 +91,6 @@ fn an_update_may_name_a_real_imported_npc_text_row() { assert_eq!(delta.claims()[0].key().row_id(), u64::from(REAL_NPC_TEXT)); } -/// `game_gossip_menu`'s key names a creature template, which no Package may invent, so the table -/// permits no insert at any identifier — the `game_creature_cast` rule. #[test] fn no_gossip_menu_row_may_be_inserted() { for entry in [REAL_GOSSIP_MENU, PACKAGE_GOSSIP as u32] { @@ -178,8 +170,6 @@ fn the_package_range_is_disjoint_from_every_reserved_identifier() { } } -/// The trainer, gossip and globals bands are one decade apart, so an identifier that reads as a -/// valid gossip row must read as nothing else. #[test] fn the_gossip_band_neighbours_do_not_overlap_it() { for id in [PACKAGE_GOSSIP_ID_FLOOR, PACKAGE_GOSSIP_ID_CEIL] { diff --git a/crates/lyracore-package-delta/tests/item_identifiers.rs b/crates/lyracore-package-delta/tests/item_identifiers.rs index 03da3be63..5c56a80e4 100644 --- a/crates/lyracore-package-delta/tests/item_identifiers.rs +++ b/crates/lyracore-package-delta/tests/item_identifiers.rs @@ -1,7 +1,3 @@ -//! The item identifier policy: which rows a Package may claim. Mirrors `spell_identifiers.rs`, -//! items' worked-example counterpart — items have one table, so there is no packed child key to -//! test here. - mod common; use common::{artifact, item_claim, WHOLE_ITEM_ROW}; @@ -30,7 +26,6 @@ fn a_package_may_insert_an_item_inside_the_package_range() { #[test] fn an_insert_outside_the_package_range_is_refused() { - // A real client item, the value just below the floor, and the value just above the ceiling. for entry in [25, PACKAGE_ITEM_ID_FLOOR - 1, PACKAGE_ITEM_ID_CEIL + 1] { let json = artifact("example.pkg", &item_claim(entry, "insert", WHOLE_ITEM_ROW)); @@ -44,7 +39,6 @@ fn an_insert_outside_the_package_range_is_refused() { #[test] fn an_update_may_name_a_real_client_item() { - // Tuning a real item is the common case, and the reason the range applies to inserts alone. let json = artifact("example.pkg", &item_claim(25, "update", A_BUY_PRICE)); let delta = PackageDelta::parse(&json).expect("a real item is updatable"); @@ -54,8 +48,6 @@ fn an_update_may_name_a_real_client_item() { #[test] fn no_operation_may_claim_the_project_wide_fixture_reserved_band() { - // Items have no family-specific fixture cluster the way spells do — the shared band is the - // whole check. `5_090_050` is `FIXTURE_BLADE` (`module/src/seed/fixtures.rs`). for entry in [5_090_000, 5_090_050, 5_099_999] { let update = artifact("example.pkg", &item_claim(entry, "update", A_BUY_PRICE)); assert_eq!( @@ -120,7 +112,6 @@ fn an_incomplete_item_insert_is_refused() { match PackageDelta::parse(&json).expect_err("the insert must be refused") { DeltaError::IncompleteInsert { table, missing } => { assert_eq!(table, lyracore_package_delta::Table::Item); - // Every claimable column except the one the claim carried. assert_eq!(missing.len(), 56); assert!(missing.contains(&"name".to_owned())); assert!(!missing.contains(&"buy_price".to_owned())); diff --git a/crates/lyracore-package-delta/tests/loot_identifiers.rs b/crates/lyracore-package-delta/tests/loot_identifiers.rs index 868436ba8..7ffef9206 100644 --- a/crates/lyracore-package-delta/tests/loot_identifiers.rs +++ b/crates/lyracore-package-delta/tests/loot_identifiers.rs @@ -1,8 +1,3 @@ -//! The loot identifier policy: which rows a Package may claim. Mirrors `item_identifiers.rs`. A -//! loot row's key is flat, the same shape as an item's `entry`, because (unlike the quest family) -//! no loot table's owning entity (creature, gameobject/chest, or zone) is ever Package-invented. -//! The band therefore applies to a loot row's OWN `id`, not to an owning identifier. - mod common; use common::{artifact, pickpocket_loot_claim, WHOLE_PICKPOCKET_LOOT_ROW}; @@ -127,8 +122,6 @@ fn an_incomplete_loot_insert_is_refused() { } } -/// The four loot tables are independent SpacetimeDB tables, so sharing one numeric band across -/// them cannot collide: the same `id` is a valid key on each. #[test] fn the_same_id_is_independently_claimable_on_every_loot_table() { for table in [ diff --git a/crates/lyracore-package-delta/tests/print_events_cli.rs b/crates/lyracore-package-delta/tests/print_events_cli.rs index 57bb5bbae..a60b2c6de 100644 --- a/crates/lyracore-package-delta/tests/print_events_cli.rs +++ b/crates/lyracore-package-delta/tests/print_events_cli.rs @@ -1,10 +1,3 @@ -//! `lyracore-delta-check --print-events` — the Event Binding catalogue on stdout, nothing else. -//! -//! T2's TypeScript authoring surface is generated from this output, so the shape is the contract: -//! one event per line, in catalogue order, no artifact read, no file touched. These tests run the -//! real binary rather than calling a library function, because the binary's argument handling is -//! part of what T2's build invokes. - use std::process::{Command, Output}; use lyracore_package_delta::HOOK_EVENT_NAMES; @@ -28,8 +21,6 @@ fn print_events_lists_every_catalogue_name_in_order_one_per_line() { assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); } -/// A generator re-running this twice must see the same bytes, or a diff-based "is this stale" -/// check (T2's `packages check`) would fail on a rebuild that changed nothing. #[test] fn print_events_output_is_byte_stable_across_runs() { let first = run(&["--print-events"]).stdout; diff --git a/crates/lyracore-package-delta/tests/quest_identifiers.rs b/crates/lyracore-package-delta/tests/quest_identifiers.rs index 19170b7d9..4c1d9227d 100644 --- a/crates/lyracore-package-delta/tests/quest_identifiers.rs +++ b/crates/lyracore-package-delta/tests/quest_identifiers.rs @@ -1,10 +1,3 @@ -//! The quest identifier policy: which rows a Package may claim. Mirrors `item_identifiers.rs`, and -//! the packed child keys mirror `spell_identifiers.rs`'s `game_spell_effect` cases. -//! -//! One band covers the whole family: `game_quest_text` and every child table below name their -//! owning quest by `quest_entry`, so a child row is only ever as Package-owned as the quest it -//! belongs to. There is no separate child band to test. - mod common; use common::{ @@ -136,8 +129,6 @@ fn an_incomplete_quest_insert_is_refused() { } } -// ---- quest text: 1:1 with the header by `quest_entry`, same band ---- - #[test] fn quest_text_shares_the_quest_header_band() { let json = artifact( @@ -169,8 +160,6 @@ fn quest_text_outside_the_package_range_may_not_be_inserted() { ); } -// ---- packed child keys ---- - #[test] fn an_objectives_packed_key_derives_from_the_quest_and_the_slot() { let key = PrimaryKey::quest_objective(PACKAGE_QUEST_ID_FLOOR, 2).expect("slot 2 is claimable"); @@ -248,8 +237,6 @@ fn a_reward_choice_index_past_six_is_refused() { ); } -/// The key columns (`quest_entry`, `obj_index`/`item_entry`/`choice_index`) may never also appear -/// in `fields`. The key already names them. #[test] fn a_child_table_refuses_its_own_key_column_as_a_field() { let json = artifact( diff --git a/crates/lyracore-package-delta/tests/refusals.rs b/crates/lyracore-package-delta/tests/refusals.rs index 328c49d14..34d40df7f 100644 --- a/crates/lyracore-package-delta/tests/refusals.rs +++ b/crates/lyracore-package-delta/tests/refusals.rs @@ -1,5 +1,3 @@ -//! Everything a Package Delta refuses, and refuses before an applier could act on any of it. - mod common; use common::{ @@ -113,8 +111,6 @@ fn a_value_that_does_not_fit_its_declared_type_is_refused() { #[test] fn an_unsigned_64_bit_value_written_as_a_json_number_is_refused() { - // A JSON number cannot carry a u64 without losing precision above 2^53, so the artifact does not - // accept one even when the value would have fitted. let json = one_spell_update( "example.pkg", REAL_SPELL, @@ -209,10 +205,6 @@ fn row_deletion_is_refused() { assert_eq!(refuse(&json), DeltaError::DeleteNotSupported); } -/// `game_creature_cast`'s key names a creature template, which no Package may invent — the worked -/// example of an update-only table. Five more follow it (`game_gossip_menu`, the two stat curves, -/// `game_start_position`, `game_areatrigger_teleport`); see `gossip_identifiers.rs` and -/// `globals_identifiers.rs` for their own coverage. #[test] fn an_insert_on_an_update_only_table_is_refused_by_name() { let json = artifact( @@ -257,7 +249,6 @@ fn an_insert_that_does_not_carry_the_whole_row_is_refused() { match refuse(&json) { DeltaError::IncompleteInsert { table, missing } => { assert_eq!(table, Table::Spell); - // Every claimable column except the one the claim carried. assert_eq!(missing.len(), 23); assert!(missing.contains(&"name".to_owned())); assert!(!missing.contains(&"cooldown_ms".to_owned())); diff --git a/crates/lyracore-package-delta/tests/script_artifact.rs b/crates/lyracore-package-delta/tests/script_artifact.rs index bc6e16659..d73418d31 100644 --- a/crates/lyracore-package-delta/tests/script_artifact.rs +++ b/crates/lyracore-package-delta/tests/script_artifact.rs @@ -1,8 +1,3 @@ -//! The Script Artifact's shape: what it reads, and the bytes it writes back. -//! -//! The JSON here is written by hand, the way a Datascript generator would emit it, so these -//! exercise the parser rather than a builder that could drift with it. - use lyracore_package_delta::{ScriptArtifact, HOOK_EVENT_NAMES, PACKAGE_SCRIPT_ID_FLOOR}; const HASH_A: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -47,7 +42,6 @@ fn an_artifact_may_ship_no_scripts_at_all() { assert!(parsed.scripts().is_empty()); } -/// A disabled script is applied to the Shard and never invoked, so it has to survive the parse. #[test] fn a_disabled_script_is_read_rather_than_dropped() { let parsed = ScriptArtifact::parse(&artifact( @@ -75,8 +69,6 @@ fn scripts_come_back_in_identifier_order_however_they_were_written() { assert_eq!(ids, [100_002, 100_009]); } -/// Every event the Module fires must be bindable, or a package author meets a refusal for an event -/// that plainly exists. #[test] fn every_event_in_the_catalogue_is_bindable() { for event in HOOK_EVENT_NAMES { @@ -92,8 +84,6 @@ fn every_event_in_the_catalogue_is_bindable() { } } -/// A Package Event is how a Package asks its own scripts a question the hook catalogue has no name -/// for. The artifact's own Package identity is the prefix, so the parser accepts it as written. #[test] fn a_package_binds_a_package_event_of_its_own() { let parsed = ScriptArtifact::parse(&artifact( @@ -114,8 +104,6 @@ fn a_package_binds_a_package_event_of_its_own() { ); } -/// The Package Event travels through the canonical bytes unchanged: it is the label the Module's -/// dispatch matches on, so a rewrite of it would unbind every script that uses it. #[test] fn a_package_event_survives_the_canonical_round_trip() { let parsed = ScriptArtifact::parse(&artifact( @@ -140,10 +128,6 @@ fn a_package_event_survives_the_canonical_round_trip() { ); } -// =========================================================================================== -// Canonical bytes -// =========================================================================================== - #[test] fn the_canonical_form_has_a_fixed_member_order_and_no_whitespace() { let parsed = ScriptArtifact::parse(&artifact( @@ -163,9 +147,6 @@ fn the_canonical_form_has_a_fixed_member_order_and_no_whitespace() { ); } -/// The canonical bytes are what a digest, a diff and a provenance row all describe, so how the -/// artifact was spelled must not reach them. -/// Member order, whitespace and the sign on a number are all spellings, not content. #[test] fn how_the_artifact_was_written_cannot_change_its_canonical_bytes() { let compact = artifact( @@ -198,8 +179,6 @@ fn how_the_artifact_was_written_cannot_change_its_canonical_bytes() { ); } -/// The applier packs several artifacts one per line, which only works because a canonical artifact -/// escapes every control character. Lua source is the member most likely to hold a newline. #[test] fn a_canonical_artifact_never_contains_a_raw_newline_however_the_lua_is_written() { let json = format!( diff --git a/crates/lyracore-package-delta/tests/script_conflicts.rs b/crates/lyracore-package-delta/tests/script_conflicts.rs index ba5bc7c56..d1ec5d652 100644 --- a/crates/lyracore-package-delta/tests/script_conflicts.rs +++ b/crates/lyracore-package-delta/tests/script_conflicts.rs @@ -1,8 +1,3 @@ -//! Several Packages' scripts, together: what merges (nothing) and what collides. -//! -//! A Package Delta's tracer merges columns because a row has a base import behind it. A Runtime -//! Script has none, so there is nothing to merge and identity is the only thing to trace. - use lyracore_package_delta::{trace_scripts, ScriptArtifact, ScriptConflict}; const HASH_A: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -34,8 +29,6 @@ fn packages_shipping_different_scripts_all_reach_the_plan() { assert_eq!(trace.scripts().len(), 2); } -/// Several Packages may bind to ONE event without disagreeing. That is the normal case, and -/// `priority` is what orders them — not a conflict to resolve. #[test] fn two_packages_binding_to_one_event_is_not_a_conflict() { let trace = trace_scripts(&[ @@ -139,8 +132,6 @@ fn one_package_cannot_supply_two_artifacts_even_when_the_scripts_do_not_collide( )); } -/// The operator fixing a broken Package Inventory wants the whole list in one pass, not one -/// refusal per re-run. #[test] fn every_disagreement_is_reported_rather_than_the_first() { let trace = trace_scripts(&[ @@ -163,8 +154,6 @@ fn every_disagreement_is_reported_rather_than_the_first() { assert_eq!(trace.conflicts().len(), 2); } -/// One Package's own scripts never collide with each other here: the artifact parser already -/// refused that, so a conflict in a trace is always BETWEEN Packages. #[test] fn one_package_alone_can_never_produce_a_conflict() { let trace = trace_scripts(&[artifact( diff --git a/crates/lyracore-package-delta/tests/script_refusals.rs b/crates/lyracore-package-delta/tests/script_refusals.rs index eff6bcaf6..9a541ae3a 100644 --- a/crates/lyracore-package-delta/tests/script_refusals.rs +++ b/crates/lyracore-package-delta/tests/script_refusals.rs @@ -1,8 +1,3 @@ -//! Everything a Script Artifact refuses, and the identifier band it refuses against. -//! -//! Each of these is a package author's mistake that must never reach a Shard, and each refusal has -//! to name the smallest thing that was wrong. - use lyracore_package_delta::ids::{ CURATED_RESERVED_ID_FLOOR, FIXTURE_RESERVED_ID_CEIL, FIXTURE_RESERVED_ID_FLOOR, }; @@ -48,7 +43,6 @@ fn an_unknown_version_is_refused() { assert!(refusal.to_string().contains("version 9"), "{refusal}"); } -/// A typo in a Datascript must never become a silently dropped member. #[test] fn an_unknown_member_at_the_root_is_refused_rather_than_ignored() { let json = format!( @@ -83,8 +77,6 @@ fn a_script_missing_a_member_is_refused_by_that_members_name() { assert!(refusal.contains("scripts[0].source"), "{refusal}"); } -// ---- the identifier band ---- - #[test] fn a_script_below_the_package_band_is_refused() { let refusal = refusal(&one( @@ -119,8 +111,6 @@ fn a_script_above_the_package_band_is_refused() { ); } -/// `0` is what `game_spell_effect.script_id` already means by "pure data, no script", so it can -/// never name one. #[test] fn script_zero_is_not_a_script() { let refusal = refusal(&one(0, "bolt.a", "on_login", "grant_xp(event.actor, 1)")); @@ -159,8 +149,6 @@ fn no_reserved_identifier_is_inside_the_package_script_band() { } } -// ---- names, events and source ---- - #[test] fn a_name_that_is_not_a_name_is_refused() { for bad in ["", "Bolt.Greet", "bolt greet", "bolt/greet"] { @@ -173,8 +161,6 @@ fn a_name_that_is_not_a_name_is_refused() { } } -/// The hardest failure to diagnose is a script that simply never runs, so an event outside the -/// catalogue is refused at author time and the refusal lists the events that exist. #[test] fn an_event_outside_the_hook_catalogue_is_refused_and_names_the_ones_that_exist() { let refusal = refusal(&one( @@ -189,9 +175,6 @@ fn an_event_outside_the_hook_catalogue_is_refused_and_names_the_ones_that_exist( assert!(refusal.contains("`on_go_used`"), "{refusal}"); } -/// A Package Event belongs to the Package that fires it. Binding another Package's event would let -/// one Package read a decision it has no part in, and would break the moment that Package renamed -/// the event — so the prefix must be the artifact's own identity. #[test] fn a_package_event_belonging_to_another_package_is_refused() { let refusal = refusal(&one( @@ -208,8 +191,6 @@ fn a_package_event_belonging_to_another_package_is_refused() { assert!(refusal.contains("`example.bolt.`"), "{refusal}"); } -/// The `` half is narrower than a script name: no dots, so the Package prefix stays the only -/// one, and nothing but lowercase letters, digits and `_` after the first letter. #[test] fn a_package_event_with_a_misshapen_name_is_refused() { for bad in [ @@ -237,7 +218,6 @@ fn a_script_with_no_source_is_refused() { assert!(refusal.contains("bolt.a"), "{refusal}"); } -/// Whitespace is not a script either. A Datascript that emitted only a newline emitted nothing. #[test] fn a_script_whose_source_is_only_whitespace_is_refused() { let refusal = refusal(&one(100_001, "bolt.a", "on_login", r" \n\t ")); @@ -245,8 +225,6 @@ fn a_script_whose_source_is_only_whitespace_is_refused() { assert!(refusal.contains("carries no source"), "{refusal}"); } -// ---- one Package disagreeing with itself ---- - #[test] fn one_package_shipping_two_scripts_at_one_identifier_is_refused() { let scripts = [ diff --git a/crates/lyracore-package-delta/tests/spell_identifiers.rs b/crates/lyracore-package-delta/tests/spell_identifiers.rs index b5508720b..4260ba452 100644 --- a/crates/lyracore-package-delta/tests/spell_identifiers.rs +++ b/crates/lyracore-package-delta/tests/spell_identifiers.rs @@ -1,6 +1,3 @@ -//! The spell identifier policy: which rows a Package may claim, and how a child effect key is -//! derived from the spell it belongs to. - mod common; use common::{artifact, effect_claim, spell_claim, WHOLE_EFFECT_ROW, WHOLE_SPELL_ROW}; @@ -9,8 +6,6 @@ use lyracore_package_delta::{ MAX_SPELL_EFFECT_INDEX, PACKAGE_SPELL_ID_CEIL, PACKAGE_SPELL_ID_FLOOR, }; -/// Every fixture spell the seed and the fixtures module park in the reserved cluster. A Package that -/// overwrote one of these would turn the Module's own tests into a report about the Package. const FIXTURE_SPELLS: [u32; 6] = [50_000, 50_072, 50_110, 50_137, 50_310, 50_311]; const A_COOLDOWN: &str = r#"{"cooldown_ms":{"type":"u32","value":1500}}"#; @@ -18,8 +13,6 @@ const A_BASE_POINT: &str = r#"{"base_points":{"type":"i32","value":7}}"#; #[test] fn a_spell_effect_key_is_the_spell_shifted_left_by_two_plus_the_effect_index() { - // Worked by hand from the formula the Module's `SpellEffect.id` column already uses: - // 133 * 4 = 532, plus the effect index. let expected = [(0_u8, 532_u64), (1, 533), (2, 534)]; for (effect_index, row_id) in expected { @@ -31,7 +24,6 @@ fn a_spell_effect_key_is_the_spell_shifted_left_by_two_plus_the_effect_index() { #[test] fn a_package_spell_effect_key_stays_inside_a_u64() { - // 6000001 * 4 = 24000004, plus effect index 2. let key = PrimaryKey::spell_effect(6_000_001, 2).expect("a Package spell has this effect"); assert_eq!(key.row_id(), 24_000_006); @@ -58,8 +50,6 @@ fn a_package_may_insert_a_spell_inside_the_package_range() { #[test] fn an_insert_outside_the_package_range_is_refused() { - // A real client spell, a curated trainer identifier, the value just below the floor, and the - // value just above the ceiling. for spell_id in [ 133, 5_200_000, @@ -94,8 +84,6 @@ fn an_inserted_effect_obeys_the_same_range_as_its_spell() { #[test] fn an_update_may_name_a_real_client_spell() { - // Tuning a real spell is the common case, and the reason the range applies to inserts alone: a - // Package that could not touch spell 133 could not change Fireball. let json = artifact("example.pkg", &spell_claim(133, "update", A_COOLDOWN)); let delta = PackageDelta::parse(&json).expect("a real spell is updatable"); @@ -153,8 +141,6 @@ fn no_operation_may_claim_the_project_wide_fixture_reserved_band() { #[test] fn the_bands_next_to_the_fixture_clusters_stay_claimable() { - // The edges matter: one off in either direction and a real spell becomes unclaimable, or a - // fixture becomes claimable. for spell_id in [49_999, 51_000, 5_089_999, 5_100_000] { let json = artifact("example.pkg", &spell_claim(spell_id, "update", A_COOLDOWN)); diff --git a/crates/lyracore-package-delta/tests/spellmeta_identifiers.rs b/crates/lyracore-package-delta/tests/spellmeta_identifiers.rs index 0a6f8c1c0..0d2ae193e 100644 --- a/crates/lyracore-package-delta/tests/spellmeta_identifiers.rs +++ b/crates/lyracore-package-delta/tests/spellmeta_identifiers.rs @@ -1,7 +1,3 @@ -//! The spellmeta family's identifier policy. `game_spell_learn` follows the loot shape on its own -//! band. `game_spell_chain` and `game_spell_proc_event` key on a spell identifier instead, so what -//! a Package may invent there is exactly what it may invent in `game_spell`. - mod common; use common::{ @@ -69,8 +65,6 @@ fn a_spell_learn_insert_outside_the_package_range_is_refused() { } } -/// A rank-chain row's key IS a spell identifier, so a Package may only invent one for a spell it -/// could have invented itself. The spellmeta band does not apply here and must not. #[test] fn a_spell_chain_insert_takes_the_package_spell_band() { for spell_id in [PACKAGE_SPELL_ID_FLOOR, PACKAGE_SPELL_ID_CEIL] { @@ -100,8 +94,6 @@ fn a_spell_chain_insert_takes_the_package_spell_band() { ); } -/// A proc overlay for a real vanilla spell can be tuned but never invented: the base import owns -/// that row's identifier, so an inserted one could neither be reconciled nor survive a reload. #[test] fn a_proc_overlay_for_a_real_spell_is_updatable_but_not_insertable() { let update = artifact( @@ -147,8 +139,6 @@ fn an_update_may_name_a_real_imported_spell_learn_row() { assert_eq!(delta.claims()[0].key().row_id(), REAL_SPELL_LEARN); } -/// A spell-keyed spellmeta row inherits the spell family's fixture cluster too, not only the -/// project-wide band. #[test] fn no_operation_may_claim_a_fixture_spells_metadata() { for spell_id in [50_000u32, 50_500, 50_999, 5_090_000, 5_099_999] { diff --git a/crates/lyracore-package-delta/tests/trainer_identifiers.rs b/crates/lyracore-package-delta/tests/trainer_identifiers.rs index 25cfc2b82..72b26d365 100644 --- a/crates/lyracore-package-delta/tests/trainer_identifiers.rs +++ b/crates/lyracore-package-delta/tests/trainer_identifiers.rs @@ -1,7 +1,3 @@ -//! The trainers family's identifier policy. `game_trainer_spell` follows the loot shape (band -//! checked against the row's own surrogate `id`), the same as `game_creature_spell` in -//! `cast_identifiers.rs`. - mod common; use common::{ @@ -139,8 +135,6 @@ fn an_incomplete_trainer_spell_insert_is_refused() { } } -/// The cast and trainer bands are one decade apart (10M/11M), so an identifier that reads as a -/// valid trainer row must not also read as a cast row. #[test] fn the_cast_and_trainer_bands_do_not_overlap() { use lyracore_package_delta::is_package_cast_id; diff --git a/crates/lyracore-shared/Cargo.toml b/crates/lyracore-shared/Cargo.toml index 96a6cfd38..a3a2d7ab0 100644 --- a/crates/lyracore-shared/Cargo.toml +++ b/crates/lyracore-shared/Cargo.toml @@ -9,8 +9,6 @@ homepage.workspace = true publish.workspace = true description = "LyraCore: protocol-agnostic constants and helpers shared by the SpacetimeDB module and the gateway." -# Intentionally NO external dependencies: this crate is consumed both by the wasm module -# and the native gateway, so it must stay portable and trivially compilable. [dependencies] [lints] diff --git a/crates/lyracore-shared/src/auction.rs b/crates/lyracore-shared/src/auction.rs index 0374fc3a8..73135a3d9 100644 --- a/crates/lyracore-shared/src/auction.rs +++ b/crates/lyracore-shared/src/auction.rs @@ -1,9 +1,6 @@ -//! Shared auction-house protocol rules. - -/// Maximum squared 3-D distance for a player to use a named auctioneer: 10 yards. +// Squared interaction distance in yards. pub const INTERACTION_RANGE_SQ: f32 = 100.0; -/// Vanilla's minimum raise: five percent of the current bid, rounded up. pub fn bid_increment(current_bid: u32) -> u32 { if current_bid == 0 { 0 @@ -14,7 +11,6 @@ pub fn bid_increment(current_bid: u32) -> u32 { } } -/// Full next offer required by the auction protocol row and the bid validator. pub fn minimum_next_bid(start_bid: u32, current_bid: u32) -> Option { if current_bid == 0 { Some(start_bid) @@ -23,9 +19,6 @@ pub fn minimum_next_bid(start_bid: u32, current_bid: u32) -> Option { } } -/// The Auction Cut: the house's share of a bid, truncated (`cm:AuctionHouseMgr.cpp:733-736`). The -/// seller pays it out of the proceeds at Settlement and out of the purse at Cancellation. `None` when -/// the rate is not a percentage. pub fn auction_cut(bid: u32, consignment_rate: u32) -> Option { if consignment_rate > 100 { return None; @@ -33,8 +26,6 @@ pub fn auction_cut(bid: u32, consignment_rate: u32) -> Option { u32::try_from(u64::from(bid) * u64::from(consignment_rate) / 100).ok() } -/// Why the Module refused an auction Durable Request. The tag is the whole reducer error text, -/// so neither tier matches on human prose. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AuctionRefusal { ItemNotFound, @@ -67,13 +58,10 @@ impl AuctionRefusal { } } -/// `FactionTemplate.dbc` group bit for a template's own team (`cm:Server/DBCEnums.h:92-95`). +// FactionTemplate.dbc group bits, cm:Server/DBCEnums.h:92-95. const FACTION_MASK_ALLIANCE: u32 = 2; const FACTION_MASK_HORDE: u32 = 4; -/// Vanilla's fixed auctioneer-template-to-house table (`cm:AuctionHouseMgr.cpp:461-518`). A -/// template outside the table falls back to its faction group's own house, then to the neutral -/// house (Booty Bay/Gadgetzan/Everlook, house 7). pub fn house_for_faction_template(template_id: u32, faction_group_mask: u32) -> u32 { match template_id { 12 => 1, @@ -89,9 +77,6 @@ pub fn house_for_faction_template(template_id: u32, faction_group_mask: u32) -> } } -/// The listing pool a house belongs to: Alliance houses 1-3 and Horde houses 4-6 each pool their -/// listings, and house 7 stands alone as the neutral market -/// (`cm:AuctionHouseMgr.cpp:51-63,444-459`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AuctionMarket { Alliance, @@ -99,8 +84,6 @@ pub enum AuctionMarket { Neutral, } -/// The market a house's listings pool into. An id outside 1-7 is not an imported house; it pools -/// as neutral rather than joining a team's listings. pub fn market_of(house_id: u32) -> AuctionMarket { match house_id { 1..=3 => AuctionMarket::Alliance, @@ -109,8 +92,6 @@ pub fn market_of(house_id: u32) -> AuctionMarket { } } -/// Stable terminal outcome codes shared by bid Hold and decision rows. `CANCELLED` is the one -/// accepting outcome of a Cancellation Hold. `ITEM_NOT_FOUND` and `DATABASE` refuse either operation. pub mod bid_outcome { pub const PENDING: u8 = 0; pub const ACCEPTED: u8 = 1; @@ -122,24 +103,19 @@ pub mod bid_outcome { pub const CANCELLED: u8 = 7; } -/// Stable `operation` codes of the bid Hold and decision rows. Every row written before -/// Cancellation existed reads as `BID`. +// Older Hold rows default to BID. pub mod hold_operation { pub const BID: u8 = 0; pub const CANCEL: u8 = 1; } -/// Stable `game_auction_notice.kind` codes, in the vanilla notice table's own order -/// (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`). Distinct from an Auction Mail's -/// `MailAuctionAnswers` subject code: a live notice has two kinds (New bid, Removed) with no mail -/// twin, and a mail action (Successful) with no live notice. +// Notice kinds use a different numbering from Auction Mail subject codes. pub mod auction_notice { pub const OUTBID: u8 = 0; pub const WON: u8 = 1; pub const SOLD: u8 = 2; pub const EXPIRED: u8 = 3; pub const NEW_BID: u8 = 4; - /// To the displaced bidder when the seller cancels (`cm:AuctionHouseHandler.cpp:167-189`). pub const REMOVED: u8 = 5; } @@ -193,9 +169,6 @@ mod tests { assert_eq!(super::minimum_next_bid(100, u32::MAX), None); } - // House ids and template rows: `cm:AuctionHouseMgr.cpp:461-518`. The DBC names each house - // Stormwind (1), Alliance (2), Darnassus (3), Undercity (4), Thunder Bluff (5), Horde (6) and - // Blackwater (7). #[test] fn house_for_faction_template_resolves_the_stormwind_row() { assert_eq!(super::house_for_faction_template(12, 0), 1); @@ -247,11 +220,9 @@ mod tests { #[test] fn house_for_faction_template_falls_back_to_blackwater_for_an_unlisted_or_unknown_template() { assert_eq!(super::house_for_faction_template(999, 0), 7); - // A Monster-only group belongs to neither team. assert_eq!(super::house_for_faction_template(999, 8), 7); } - // Market pooling: `cm:AuctionHouseMgr.cpp:51-63,444-459`. #[test] fn market_of_pools_stormwind_alliance_and_darnassus_together() { for house in [1, 2, 3] { diff --git a/crates/lyracore-shared/src/aura.rs b/crates/lyracore-shared/src/aura.rs index f7ad6b561..f21f872f0 100644 --- a/crates/lyracore-shared/src/aura.rs +++ b/crates/lyracore-shared/src/aura.rs @@ -1,5 +1,3 @@ -//! Pure folds over active aura values. - use crate::spell::taxonomy::*; #[derive(Clone, Copy, Debug)] @@ -10,7 +8,7 @@ pub struct AuraAmount { pub stacks: u8, } -/// Legacy zero-stack rows contribute once. Arithmetic follows the Module's signed aura fold. +// Legacy zero-stack rows contribute once. pub fn stacked_amount(amount: i32, stacks: u8) -> i32 { amount * i32::from(stacks.max(1)) } @@ -34,13 +32,10 @@ pub fn speed_bonus(auras: impl IntoIterator, speed_kind: u8) .sum() } -/// Movement retains at least ten percent of its base speed. pub fn move_speed_with_pct(base: f32, pct: i32) -> f32 { base * (100 + pct).max(10) as f32 / 100.0 } -/// Running combines movement auras with mounted speed only while a mount aura is active. -/// Unmounted always bonuses multiply; their product competes with the strongest not-stack bonus. pub fn run_speed(base: f32, auras: impl IntoIterator) -> f32 { let (mut movement, mut slow, mut mount, mut mounted) = (0, 0, 0, false); let (mut always, mut not_stack) = (1.0_f32, 1.0_f32); @@ -80,7 +75,6 @@ pub fn moves_run_speed(kind: u8, parameter: i32) -> bool { .any(|speed| parameter == i32::from(speed)) } -/// Swimming uses its strongest positive speed aura and retains the movement floor. pub fn swim_speed(base: f32, auras: impl IntoIterator) -> f32 { let (mut bonus, mut slow) = (0, 0); for aura in auras { diff --git a/crates/lyracore-shared/src/bank.rs b/crates/lyracore-shared/src/bank.rs index 29a54e4e7..3208de232 100644 --- a/crates/lyracore-shared/src/bank.rs +++ b/crates/lyracore-shared/src/bank.rs @@ -1,23 +1,13 @@ -//! Bank bag slot purchase vocabulary: the vanilla price ladder and the wire result codes. -//! -//! The prices are the well-known vanilla copper values, typed by hand. No DBC is read, copied, or -//! shipped — the licensing firewall stands. - -/// Copper charged for each bank bag slot, cheapest (first bought) first: 10s, 1g, 10g, 25g, 25g, 25g. +// Prices are in copper. pub const BANK_SLOT_PRICES: [u32; 6] = [1_000, 10_000, 100_000, 250_000, 250_000, 250_000]; -/// How many bank bag slots a character can ever own. pub const MAX_BANK_BAG_SLOTS: u8 = BANK_SLOT_PRICES.len() as u8; -/// Copper for the NEXT slot given how many are already owned. `None` once all six are owned, which is -/// the "no more to buy" refusal. Pure — unit-tested. pub fn next_bank_slot_price(owned: u8) -> Option { BANK_SLOT_PRICES.get(owned as usize).copied() } -/// `SMSG_BUY_BANK_SLOT_RESULT` values, in the client's own numbering. The module tags a refused -/// purchase with `[N]` from this set so the gateway maps the outcome by code instead of matching error -/// prose (the trainer `[N]` precedent). These ARE the wire values — relay them, don't remap them. +// SMSG_BUY_BANK_SLOT_RESULT wire values; relay without remapping. pub mod result { pub const FAILED_TOO_MANY: u8 = 0; pub const INSUFFICIENT_FUNDS: u8 = 1; diff --git a/crates/lyracore-shared/src/calendar.rs b/crates/lyracore-shared/src/calendar.rs index ec30a681b..e4f8b4ebc 100644 --- a/crates/lyracore-shared/src/calendar.rs +++ b/crates/lyracore-shared/src/calendar.rs @@ -1,27 +1,13 @@ -//! Proleptic-Gregorian calendar arithmetic for the realm clock. -//! -//! Both tiers read the same wall clock and must agree about what day it is: the gateway packs the -//! date into `SMSG_LOGIN_SETTIMESPEED`, and the module derives the weather season from it. A realm -//! whose sky and whose clock disagreed about the date would be harder to explain than either being -//! wrong on its own, so the conversion lives here once. -//! -//! Pure integer math with no clock read of its own — each caller supplies its own timestamp, and -//! the realm clock is UTC. - -/// `(year, month 1..=12, day 1..=31)` for a count of days since 1970-01-01. -/// -/// Howard Hinnant's `civil_from_days` (). The -/// era arithmetic shifts the year to start in March so the leap day lands last and needs no special -/// case. Exact for every timestamp a realm can hold, including dates before 1970. +// Howard Hinnant civil_from_days, with a March-based year. pub fn civil_from_days(days: i64) -> (i64, u32, u32) { - let shifted = days + 719_468; // days since 0000-03-01 - let era = shifted.div_euclid(146_097); // one 400-year cycle - let day_of_era = shifted - era * 146_097; // 0..=146096 + let shifted = days + 719_468; // Days since 0000-03-01. + let era = shifted.div_euclid(146_097); // One 400-year cycle. + let day_of_era = shifted - era * 146_097; let year_of_era = - (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // 0..=399 + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; let march_year = year_of_era + era * 400; let day_of_march_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let march_month = (5 * day_of_march_year + 2) / 153; // 0 is March + let march_month = (5 * day_of_march_year + 2) / 153; // Month 0 is March. let day = (day_of_march_year - (153 * march_month + 2) / 5 + 1) as u32; let month = if march_month < 10 { march_month + 3 @@ -40,9 +26,6 @@ pub fn civil_from_days(days: i64) -> (i64, u32, u32) { mod tests { use super::*; - /// Worked dates rather than the algorithm run backwards: the epoch itself, the era arithmetic's - /// own March hinge, both leap-year rules (2000 is a leap year, 1900 is not), and a day before - /// the epoch. #[test] fn known_dates_convert_from_their_day_counts() { for (days, expected) in [ diff --git a/crates/lyracore-shared/src/channel.rs b/crates/lyracore-shared/src/channel.rs index 64e5a7590..a4dd3dbc3 100644 --- a/crates/lyracore-shared/src/channel.rs +++ b/crates/lyracore-shared/src/channel.rs @@ -1,7 +1,3 @@ -//! The Chat Channel wire contract both crates import: notice codes, member and channel flags, op -//! codes, the built-in catalogue, the name rules and the typed [`ChannelRefusal`]. - -/// `SMSG_CHANNEL_NOTIFY` notice codes (cm:Channel.h:33-69, gtker vanilla `chat_notify.rs`). pub mod notice { pub const JOINED: u8 = 0x00; pub const LEFT: u8 = 0x01; @@ -37,9 +33,7 @@ pub mod notice { pub const THROTTLED: u8 = 0x1F; } -/// Channel Membership flags as both cores define them (cm:Channel.h:117-128, -/// vm:Channel.h:121-127). gtker's `ChannelMemberFlags` names disagree (it calls 0x04 MODERATOR), -/// so nothing that encodes a member list may use its named constants. +// gtker names 0x04 MODERATOR; vanilla uses 0x04 for VOICED. pub mod member_flag { pub const OWNER: u8 = 0x01; pub const MODERATOR: u8 = 0x02; @@ -47,7 +41,6 @@ pub mod member_flag { pub const MUTED: u8 = 0x08; } -/// Wire channel flags (cm:Channel.h:85-101), sent in YOU_JOINED and `SMSG_CHANNEL_LIST`. pub mod channel_flag { pub const CUSTOM: u8 = 0x01; pub const TRADE: u8 = 0x04; @@ -57,8 +50,6 @@ pub mod channel_flag { pub const LFG: u8 = 0x40; } -/// The op byte of the `realm_channel_op` reducer. Every code is reserved here so the reducer -/// signature never changes as ops are added. pub mod channel_op { pub const JOIN: u8 = 0; pub const LEAVE: u8 = 1; @@ -76,33 +67,25 @@ pub mod channel_op { pub const MODERATE: u8 = 13; } -/// The ChatChannels.dbc flag bits that decide the wire flags (cm:Channel.h:103-115). mod dbc_flag { pub const TRADE: u32 = 0x0_0008; pub const CITY_ONLY2: u32 = 0x0_0020; pub const LFG: u32 = 0x4_0000; } -/// One row of the 1.12 client's ChatChannels.dbc. Realm-core receives no DBC imports and the 1.12 -/// join packet carries no channel id, so the catalogue is matched by its enUS name. A localized -/// client's built-in names become custom channels. +// Build 5875 carries no channel id on join; localized names become custom channels. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BuiltinChannel { - /// The ChatChannels id. pub id: u32, - /// The ChatChannels flags column. pub dbc_flags: u32, - /// The pattern up to its `%s`, or the whole name when it has none. + // Pattern prefix before %s, or the complete unzoned name. pub prefix: &'static str, - /// The pattern carries a zone name (`%s`), so the name only has to start with `prefix`. pub zoned: bool, } pub const WORLD_DEFENSE_ID: u32 = 23; -/// A Guild member never joins GuildRecruitment (cm:Channel.cpp:94-95). pub const GUILD_RECRUITMENT_ID: u32 = 25; -/// dbc:ChatChannels.dbc, every row of the local 1.12.1 client. pub const BUILTIN_CHANNELS: [BuiltinChannel; 6] = [ BuiltinChannel { id: 1, @@ -143,8 +126,6 @@ pub const BUILTIN_CHANNELS: [BuiltinChannel; 6] = [ ]; impl BuiltinChannel { - /// The wire flags the way cm:Channel.cpp:29-48 derives them: GENERAL, plus TRADE and CITY from - /// their dbc bits, plus LFG or NOT_LFG. pub fn wire_flags(self) -> u8 { let mut flags = channel_flag::GENERAL; if self.dbc_flags & dbc_flag::TRADE != 0 { @@ -161,16 +142,12 @@ impl BuiltinChannel { flags } - /// Both cores require an honor rank to speak in WorldDefense (cm:Channel.cpp:604-606, - /// vm:Channel.cpp:648). LyraCore has no honor ranks, so nobody speaks there. + // WorldDefense requires honor ranks, which this realm does not implement. pub fn players_may_speak(self) -> bool { self.id != WORLD_DEFENSE_ID } } -/// The built-in channel `name` names, if any. A zoned pattern matches as a case-insensitive -/// prefix and the others as a whole name. Stricter than the mangos substring search -/// (cm:DBCStores.cpp:762-770), so a custom channel named "My General - x" stays custom. pub fn classify(name: &str) -> Option { BUILTIN_CHANNELS.into_iter().find(|builtin| { if builtin.zoned { @@ -182,23 +159,16 @@ pub fn classify(name: &str) -> Option { }) } -/// cm:ChannelHandler.cpp:24-29 and cm:ChannelHandler.cpp:31-38. pub const MAX_NAME_CHARS: usize = 128; pub const MAX_PASSWORD_CHARS: usize = 128; -/// A channel name as the Realm stores it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChannelName { - /// The lowercase name: with the team, the channel's identity (cm:ChannelMgr.cpp:47-61). pub key: String, - /// The spelling the wire echoes. pub display: String, } impl ChannelName { - /// Cap at [`MAX_NAME_CHARS`], then collapse each run of space, tab, bell and newline into one - /// space, in that order (cm:ChannelHandler.cpp:24-29, cm Util.cpp `stripLineInvisibleChars`). - /// No validity check: a lookup by an invalid name simply finds no channel. pub fn normalize(raw: &str) -> Self { let mut display = String::with_capacity(raw.len()); let mut in_gap = false; @@ -220,8 +190,6 @@ impl ChannelName { } } -/// A name a new channel may carry: [`ChannelName::normalize`], then the first character must be a -/// letter. Non-ASCII first characters pass, as in vm:ChannelHandler.cpp:29-37. pub fn channel_name(raw: &str) -> Result { let name = ChannelName::normalize(raw); match name.display.chars().next() { @@ -230,8 +198,6 @@ pub fn channel_name(raw: &str) -> Result { } } -/// A join password longer than [`MAX_PASSWORD_CHARS`] answers WRONG_PASSWORD -/// (cm:ChannelHandler.cpp:31-38). pub fn check_password(password: &str) -> Result<(), ChannelRefusal> { if password.chars().count() > MAX_PASSWORD_CHARS { Err(ChannelRefusal::WrongPassword) @@ -240,8 +206,6 @@ pub fn check_password(password: &str) -> Result<(), ChannelRefusal> { } } -/// Why the Module refused a channel op or a channel line. Each renders one notice to the actor -/// alone. The tag is the whole reducer error text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChannelRefusal { NotMember, @@ -276,8 +240,6 @@ impl ChannelRefusal { Self::NotModerated, ]; - /// Tags share the `chat:` family with `ChatRefusal`, because a refused channel line comes back - /// from the `realm_chat` reducer with the same tag. pub fn as_tag(self) -> &'static str { match self { Self::NotMember => "chat:channel:not_member", @@ -302,7 +264,6 @@ impl ChannelRefusal { .find(|refusal| refusal.as_tag() == tag) } - /// The notice the actor gets. pub fn notice(self) -> u8 { match self { Self::NotMember => notice::NOT_MEMBER, @@ -326,7 +287,6 @@ impl ChannelRefusal { mod tests { use super::*; - /// cm:Channel.h:33-69, `enum ChatNotify`. #[test] fn notice_codes_are_the_vanilla_chat_notify_values() { let pinned = [ @@ -368,8 +328,6 @@ mod tests { } } - /// cm:Channel.h:117-128 and vm:Channel.h:121-127 agree. gtker's MODERATOR is 0x04, which is - /// VOICED here. #[test] fn member_flags_are_the_cores_values() { assert_eq!(member_flag::OWNER, 0x01); @@ -378,7 +336,6 @@ mod tests { assert_eq!(member_flag::MUTED, 0x08); } - /// cm:Channel.h:85-101. #[test] fn channel_flags_are_the_cores_values() { assert_eq!(channel_flag::CUSTOM, 0x01); @@ -410,9 +367,6 @@ mod tests { assert_eq!(ops, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); } - /// Each dbc:ChatChannels.dbc row with its wire flags worked out by hand from the dbc flags: - /// General 0x3 and LocalDefense 0x10003 give GENERAL|NOT_LFG; Trade 0x3B adds TRADE (0x08) - /// and CITY (0x20); GuildRecruitment 0x20032 adds CITY only. #[test] fn every_builtin_channel_classifies_with_its_wire_flags() { let rows = [ @@ -525,7 +479,6 @@ mod tests { assert_eq!(ChannelRefusal::parse_tag("chat:not_in_group"), None); } - /// cm:Channel.h:33-69: the notice each Refusal answers. #[test] fn each_refusal_renders_its_notice() { let expected = [ diff --git a/crates/lyracore-shared/src/chat.rs b/crates/lyracore-shared/src/chat.rs index e703e5a04..0ebffc525 100644 --- a/crates/lyracore-shared/src/chat.rs +++ b/crates/lyracore-shared/src/chat.rs @@ -1,14 +1,8 @@ -//! The Realm Chat wire contract both crates import: Chat Kind codes, chat tags, language codes, -//! the language Gate and the typed [`ChatRefusal`] the Gateway maps to client feedback. - use crate::channel::ChannelRefusal; use crate::constants::player_flags; -/// Chat Kinds: the 1.12 `ChatMsg` wire values (cm:SharedDefines.h:1545-1582). A Realm Chat Line -/// stores the wire value itself, so neither tier keeps a translation table. +// ChatMsg wire values, cm:SharedDefines.h:1545-1582. pub mod chat_kind { - /// Say, yell and `/e` are proximity chat and never Realm Chat Lines. They are here so the - /// language Gate judges every player line by its wire kind. pub const SAY: u8 = 0x00; pub const PARTY: u8 = 0x01; pub const RAID: u8 = 0x02; @@ -26,25 +20,18 @@ pub mod chat_kind { pub const RAID_WARNING: u8 = 0x58; } -/// The last byte of `SMSG_MESSAGECHAT` (cm:Chat.h:70-74). The client prints `` or `` -/// before the speaker's name. +// Last byte of SMSG_MESSAGECHAT, cm:Chat.h:70-74. pub mod chat_tag { pub const NONE: u8 = 0; pub const AFK: u8 = 1; pub const DND: u8 = 2; } -/// `game_chat_event.chat_type` discriminants: the World Shard's own broadcast chat table (say, -/// yell, a creature's text emote, and a Character's `/e` custom emote). Not [`chat_kind`] above — -/// that module names the 1.12 wire values a Realm Chat Line carries; this table predates it and -/// keeps its own numbering. +// game_chat_event uses its own numbering, distinct from ChatMsg wire values. pub mod broadcast_chat { pub const SAY: u8 = 0; pub const YELL: u8 = 1; - /// Creature-authored text emote (`CHAT_TYPE_TEXT_EMOTE` on the source wire). EventAI is its - /// only source. pub const CREATURE_TEXT_EMOTE: u8 = 2; - /// A Character's `/e` custom emote (cm:ChatHandler.cpp:172-226, cm:Player.cpp:16591-16599). pub const EMOTE: u8 = 3; pub const CREATURE_BOSS_EMOTE: u8 = 4; pub const CREATURE_WHISPER: u8 = 5; @@ -55,12 +42,11 @@ pub mod broadcast_chat { matches!(chat_type, CREATURE_ZONE_YELL | CREATURE_ZONE_EMOTE) } - /// `/e` and text-emote (`/wave`, `/dance`, …) proximity range: `ListenRange.TextEmote` - /// (cm:mangosd.conf.dist.in:1092), same as `ListenRange.Say`. + // Text emotes use ListenRange.TextEmote, equal to ListenRange.Say. pub const TEXT_EMOTE_RANGE_YD: f32 = 25.0; } -/// 1.12 `Language` wire values (gtker vanilla `language.rs`). +// Build-5875 Language wire values. pub mod language { pub const UNIVERSAL: u32 = 0; pub const ORCISH: u32 = 1; @@ -76,12 +62,9 @@ pub mod language { pub const GNOMISH: u32 = 13; pub const TROLL: u32 = 14; pub const GUTTERSPEAK: u32 = 33; - /// `SendAddonMessage` traffic. Never shown in a chat frame. pub const ADDON: u32 = 0xFFFF_FFFF; } -/// The speaker's chat tag from the live entity's `PLAYER_FLAGS`. DND wins when both bits are set -/// (vm:Player.cpp:1760-1770). pub fn chat_tag_for(flags: u32) -> u8 { if flags & player_flags::DND != 0 { chat_tag::DND @@ -92,14 +75,6 @@ pub fn chat_tag_for(flags: u32) -> u8 { } } -/// The language a line from `race` goes out in, or the Refusal. -/// -/// - The addon language passes only for the kinds `SendAddonMessage` can reach -/// (vm:ChatHandler.cpp:78-118). Guild and officer keep it rather than forcing Universal. -/// - Whisper, guild, officer and the notice kinds always go out in Universal -/// (cm:Player.cpp:16603-16604, cm:ChatHandler.cpp:369, cm:ChatHandler.cpp:409). -/// - Universal passes for every kind. -/// - Any other language passes only when the race knows it (cm:ChatHandler.cpp:100-111). pub fn speakable_language(kind: u8, race: u8, language: u32) -> Result { use chat_kind::*; if language == language::ADDON { @@ -122,51 +97,34 @@ pub fn speakable_language(kind: u8, race: u8, language: u32) -> Result &'static [u32] { use language::*; match race { - 1 => &[COMMON], // Human - 2 => &[ORCISH], // Orc - 3 => &[COMMON, DWARVISH], // Dwarf - 4 => &[COMMON, DARNASSIAN], // Night Elf - 5 => &[ORCISH, GUTTERSPEAK], // Undead - 6 => &[ORCISH, TAURAHE], // Tauren - 7 => &[COMMON, GNOMISH], // Gnome - 8 => &[ORCISH, TROLL], // Troll + 1 => &[COMMON], + 2 => &[ORCISH], + 3 => &[COMMON, DWARVISH], + 4 => &[COMMON, DARNASSIAN], + 5 => &[ORCISH, GUTTERSPEAK], + 6 => &[ORCISH, TAURAHE], + 7 => &[COMMON, GNOMISH], + 8 => &[ORCISH, TROLL], _ => &[], } } -/// Why the Module refused a Realm Chat Line. The tag is the whole reducer error text, so neither -/// tier matches on human prose. Vanilla answers most of these with silence. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ChatRefusal { - /// A party or raid line from a Character in no Group. NotInGroup, - /// A raid, raid leader or raid warning line from a Character whose Group is a Party. NotRaid, - /// A raid leader line from a Raid member who does not lead it. NotRaidLeader, - /// A raid warning from a Raid member who neither leads it nor assists. NotRaidLeaderOrAssistant, - /// The speaker's race does not know the language. UnknownLanguage, - /// The Chat Kind has no audience rule, or cannot carry the addon language. UnsupportedKind, - /// Nothing is left after trimming. EmptyMessage, - /// A guild or officer line from a Character in no Guild (`cm:ChatHandler.cpp:367-369`). NotInGuild, - /// A guild or officer line from a member whose Guild Rank lacks the speak right - /// (`cm:Guild.cpp:559-561,580-582`). NoGuildChatRight, - /// A channel line the channel refused. Answered with the channel's notice. Channel(ChannelRefusal), - /// A whisper to a Character of the other team (cm:ChatHandler.cpp:268-275). Answered with - /// `SMSG_CHAT_WRONG_FACTION`. WrongFaction, } @@ -224,7 +182,6 @@ impl ChatRefusal { mod tests { use super::*; - /// cm:SharedDefines.h:1545-1582, `enum ChatMsg`. #[test] fn chat_kinds_are_the_vanilla_chat_msg_values() { assert_eq!(chat_kind::SAY, 0); @@ -244,20 +201,15 @@ mod tests { assert_eq!(chat_kind::RAID_WARNING, 88); } - /// `game_chat_event.chat_type` discriminants + the shared text-emote range, pinned so a - /// later edit shows up as a failing assertion instead of a silent drift between the two crates - /// that both read them. #[test] fn broadcast_chat_discriminants_and_text_emote_range_are_pinned() { assert_eq!(broadcast_chat::SAY, 0); assert_eq!(broadcast_chat::YELL, 1); assert_eq!(broadcast_chat::CREATURE_TEXT_EMOTE, 2); assert_eq!(broadcast_chat::EMOTE, 3); - assert_eq!(broadcast_chat::TEXT_EMOTE_RANGE_YD, 25.0); // cm:mangosd.conf.dist.in:1092 + assert_eq!(broadcast_chat::TEXT_EMOTE_RANGE_YD, 25.0); } - /// Every player race against every language that a race can know, written out from the dbc - /// rows: true where SkillRaceClassInfo grants the language skill. #[test] fn a_race_speaks_exactly_its_racial_languages() { const COMMON: u32 = 7; @@ -278,7 +230,6 @@ mod tests { GNOMISH, TROLL, ]; - // Common, Orcish, Dwarvish, Darnassian, Gutterspeak, Taurahe, Gnomish, Troll let rows: [(&str, u8, [bool; 8]); 8] = [ ( "Human", @@ -337,7 +288,6 @@ mod tests { } } - /// No player race knows the NPC and GM languages. #[test] fn nobody_speaks_the_languages_no_race_learns() { for race in 1..=8 { @@ -365,7 +315,6 @@ mod tests { } } - /// Whisper, guild, officer and the notice kinds go out in Universal whatever the client sent. #[test] fn forced_universal_kinds_ignore_the_requested_language() { for kind in [6, 7, 3, 4, 20, 21, 22] { @@ -374,7 +323,6 @@ mod tests { } } - /// vm:ChatHandler.cpp:78-118: party, raid, raid leader, raid warning, guild, officer and channel. #[test] fn the_addon_language_passes_only_for_addon_kinds() { for kind in [1, 2, 87, 88, 3, 4, 14] { @@ -404,8 +352,6 @@ mod tests { } } - /// cm:ChatHandler.cpp:100-111 checks the language skill before it reads the kind, so say, yell - /// and `/e` refuse a language the race does not know, and never carry the addon language. #[test] fn proximity_kinds_refuse_a_language_the_race_does_not_know() { for kind in [0, 5, 8] { @@ -424,7 +370,6 @@ mod tests { } } - /// cm:Player.h:370-371 bits; vm:Player.cpp:1760-1770 lets DND win. #[test] fn the_chat_tag_follows_the_away_bits() { assert_eq!(chat_tag_for(0), 0); diff --git a/crates/lyracore-shared/src/constants.rs b/crates/lyracore-shared/src/constants.rs index 6dc1b82ea..56a69385b 100644 --- a/crates/lyracore-shared/src/constants.rs +++ b/crates/lyracore-shared/src/constants.rs @@ -1,6 +1,4 @@ -//! Game/protocol constants the slice relies on. - -/// Object `TypeId` values (1.12). Used as the `object_type_id` byte in update blocks. +// Build-5875 object_type_id byte in update blocks. pub mod type_id { pub const OBJECT: u8 = 0; pub const ITEM: u8 = 1; @@ -12,192 +10,99 @@ pub mod type_id { pub const CORPSE: u8 = 7; } -/// `OBJECT_FIELD_TYPE` is a *mask* of (1 << TypeId) for every type the object is. -/// A player is OBJECT | UNIT | PLAYER. +// OBJECT_FIELD_TYPE contains 1 << TypeId for every inherited type. pub mod type_mask { use super::type_id; pub const OBJECT: u32 = 1 << type_id::OBJECT; pub const UNIT: u32 = 1 << type_id::UNIT; pub const PLAYER_BIT: u32 = 1 << type_id::PLAYER; - /// Value to store in `game_world_entity.type_mask` for a player: 0x19. pub const PLAYER: u32 = OBJECT | UNIT | PLAYER_BIT; - /// Value to store in `game_world_entity.type_mask` for a creature (no PLAYER bit): 0x9. - /// The codec branches on the PLAYER bit to build a Unit vs Player CREATE_OBJECT. pub const CREATURE: u32 = OBJECT | UNIT; } -/// `UNIT_DYNAMIC_FLAGS` bits (vanilla 1.12.1). These drive client-side render/interaction state that -/// is independent of `UNIT_FIELD_FLAGS`. Stored in `game_world_entity.dynamic_flags` and emitted by -/// the codec at CREATE and via a VALUES relay on change. pub mod unit_dynamic_flags { - /// `UNIT_DYNFLAG_LOOTABLE` (0x1). Set on a corpse that has loot for the viewer → the client - /// shows the loot cursor. A real corpse with no loot carries no dynamic flag at all. pub const LOOTABLE: u32 = 0x0001; - /// `UNIT_DYNFLAG_TAPPED` (0x4). Stored on a live creature once it has a Loot Tag. pub const TAPPED: u32 = 0x0004; - /// `UNIT_DYNFLAG_TAPPED_BY_PLAYER` (0x8). Added by the gateway only for a viewer entitled by - /// the Loot Tag. Never store this viewer-relative bit on `game_world_entity`. + // Viewer-relative bit; never store it on game_world_entity. pub const TAPPED_BY_PLAYER: u32 = 0x0008; - /// `UNIT_DYNFLAG_DEAD` (0x20). **NOT a corpse marker.** In vanilla this is *feign death*: the - /// client renders the unit lying down but treats it as ALIVE and still attackable (used for - /// fake-dead ambush mobs and a feigning hunter). A genuinely dead creature is signalled to the - /// client by `UNIT_FIELD_HEALTH = 0` alone — do NOT set this on a kill (it re-enables the attack - /// cursor on the corpse). Kept here only to document the bit and prevent its misuse. + // This bit means feign death; real corpses use UNIT_FIELD_HEALTH = 0. pub const DEAD: u32 = 0x0020; } -/// `PLAYER_FLAGS` bits (vanilla 1.12, descriptor idx 190). Stored in `game_world_entity.player_flags`. pub mod player_flags { - /// `PLAYER_FLAGS_AFK` (0x02), cm:Player.h:370. pub const AFK: u32 = 0x0002; - /// `PLAYER_FLAGS_DND` (0x04), cm:Player.h:371. pub const DND: u32 = 0x0004; pub const GHOST: u32 = 0x0010; } -/// `UNIT_NPC_FLAGS` (vanilla 1.12) — what interactions a creature offers; stored in -/// `game_world_entity.npc_flags` and surfaced to the client (gossip-eye / vendor cursor / `!`). pub mod npc_flags { - /// `UNIT_NPC_FLAG_GOSSIP` (0x1) — right-click opens a gossip menu. pub const GOSSIP: u32 = 0x0000_0001; - /// `UNIT_NPC_FLAG_VENDOR` (0x4) — sells items (`CMSG_LIST_INVENTORY` → the vendor window). NOTE: - /// vanilla 1.12 / cmangos-classic numbering — VENDOR is 0x4, not the 0x80 of later cores (where 0x80 - /// is INNKEEPER). Confirmed against the cmangos creature_template (every npc_vendor creature has 0x4). + // Vanilla VENDOR is 0x4; later expansions use a different numbering. pub const VENDOR: u32 = 0x0000_0004; - /// `UNIT_NPC_FLAG_FLIGHTMASTER` (0x8) — exposes the vanilla taxi service. pub const TAXI: u32 = 0x0000_0008; - /// `UNIT_NPC_FLAG_TRAINER` (0x10) — teaches spells (`CMSG_TRAINER_LIST` → the trainer window). vanilla - /// 1.12 / cmangos-classic numbering; class trainers (Llane Beshere, Priestess Anetta, …) carry this. pub const TRAINER: u32 = 0x0000_0010; - /// `UNIT_NPC_FLAG_SPIRITHEALER` (0x20, vanilla 1.12 / cmangos-classic) — the graveyard Spirit - /// Healer that resurrects a GHOST in place at reduced vitals (`CMSG_SPIRIT_HEALER_ACTIVATE`). - /// Spirit Healer (entry 6491) ships `npc_flags=0x21 = GOSSIP|SPIRITHEALER`; the 5875 client renders - /// the spirit-healer dialog on a ghost's right-click directly from this replicated flag. NOTE: - /// DISTINCT from REPAIR (0x4000) — armorers carry that flag, not SPIRITHEALER. pub const SPIRITHEALER: u32 = 0x0000_0020; - /// `UNIT_NPC_FLAG_INNKEEPER` (0x80, vanilla 1.12 / cmangos-classic) — binds the player's hearthstone - /// home. cmangos 1.12 numbers INNKEEPER 0x80 (NOT the 0x10000 of later TBC+ cores); proven by the - /// dump's bit histogram (54 innkeepers carry 0x80) and by the 5875 client rendering the inn icon from - /// the raw flag. Innkeeper Farley(295) ships `npc_flags=135 = GOSSIP|QUESTGIVER|VENDOR|INNKEEPER`. The - /// Northshire/Goldshire innkeepers carry this; the gossip "Make this inn your home" → `bind_home`. + // Vanilla INNKEEPER is 0x80. pub const INNKEEPER: u32 = 0x0000_0080; - /// `UNIT_NPC_FLAG_BANKER` (0x100, vanilla 1.12 / cmangos-classic) — opens the bank window and - /// gates every move into or out of a bank slot. pub const BANKER: u32 = 0x0000_0100; - /// `UNIT_NPC_FLAG_PETITIONER` (0x200, `cm:Unit.h:519`) — sells Guild Charters. pub const PETITIONER: u32 = 0x0000_0200; - /// `UNIT_NPC_FLAG_TABARDDESIGNER` (0x400, `cm:Unit.h:520`) — opens the tabard designer. pub const TABARDDESIGNER: u32 = 0x0000_0400; - /// `UNIT_NPC_FLAG_AUCTIONEER` (0x1000, vanilla 1.12) — opens an auction-house window. pub const AUCTIONEER: u32 = 0x0000_1000; - /// `UNIT_NPC_FLAG_REPAIR` (0x4000, vanilla 1.12 / cmangos-classic) — repairs item durability - /// (`CMSG_REPAIR_ITEM`). Armorers (Corina Steele 54, Quartermaster Hudson 1249, Hicks 1645, …) - /// carry 0x4000|0x4 (REPAIR+VENDOR). NOTE: 0x1000 (4096) is AUCTIONEER in this numbering, NOT repair. + // Vanilla REPAIR is 0x4000; 0x1000 is AUCTIONEER. pub const REPAIR: u32 = 0x0000_4000; } -/// GameObject `type_id` values (cmangos `GAMEOBJECT_TYPE_*`). `module/src/gameobject.rs`'s `go_type` -/// module owns the full live dispatch set and re-exports the values the gateway also needs from -/// here, so its questgiver and chest routing cannot drift from the module. pub mod go_type { - /// `GAMEOBJECT_TYPE_QUESTGIVER` (2) — a GO whose `use` (right-click) opens the quest window - /// (Wanted Poster GO 68 starts q176; the Lost Guards corpses GO 55/56 drive the q37/q45/q71 END - /// chain) rather than rolling loot or toggling state. pub const QUESTGIVER: u8 = 2; - /// `GAMEOBJECT_TYPE_CHEST` (3) — the only GameObject type whose right-click belongs to the - /// gateway loot-window lifecycle. pub const CHEST: u8 = 3; - /// `GAMEOBJECT_TYPE_MEETINGSTONE` (23). The client sends `CMSG_MEETINGSTONE_JOIN` on use, not - /// `CMSG_GAMEOBJ_USE`. `data0` is the minimum level, `data1` the maximum and `data2` the dungeon - /// area (cm:Entities/GameObject.h:266-272). + // Meeting Stones send CMSG_MEETINGSTONE_JOIN, not CMSG_GAMEOBJ_USE. pub const MEETINGSTONE: u8 = 23; } -/// `UNIT_FIELD_BYTES_1` byte-3 visibility flags (vanilla 1.12). Drives client render state, stored in -/// `game_world_entity.unit_bytes_1`. The byte-3 position is 1.12-specific (it moved in WotLK). +// Build 5875 stores visibility flags in byte 3 of UNIT_FIELD_BYTES_1. pub mod unit_vis_flags { - /// `UNIT_VIS_FLAG_GHOST` — bit value 0x01 in BYTE 3 of `UNIT_FIELD_BYTES_1`, i.e. `0x0100_0000` in - /// the packed u32. The semi-transparent ghost render. OR this into `unit_bytes_1`; the - /// codec's `unpack4` → `set_unit_bytes_1(a,b,c,d)` then places it in byte 3 (`d`). pub const GHOST: u32 = 0x0100_0000; } -/// `UNIT_FIELD_BYTES_2` BYTE 0 — the sheath state (vanilla 1.12), stored in -/// `game_world_entity.unit_bytes_2`. Says whether a weapon is DRAWN or STOWED; the per-item -/// `item_template.sheath` byte (a different field, sent in the item query) says WHERE a stowed -/// weapon hangs. Both are needed to render a sheathed weapon correctly. +// UNIT_FIELD_BYTES_2 byte 0 is sheath state; item_template.sheath is the stow location. pub mod sheath_state { - /// Nothing drawn — weapons hang in their stow positions. The state every row starts in. pub const UNARMED: u8 = 0; - /// Melee weapon(s) drawn and held. pub const MELEE: u8 = 1; - /// Ranged weapon drawn and held. pub const RANGED: u8 = 2; - /// The client's `CMSG_SETSHEATHED` payload is attacker-controlled: anything outside 0..=2 is a - /// malformed or hostile packet, and writing it into byte 0 would desync every observer's render. pub fn is_valid(state: u8) -> bool { matches!(state, UNARMED | MELEE | RANGED) } - /// Replace byte 0 of a packed `unit_bytes_2`, preserving bytes 1-3 (PvP flags, pet flags, - /// shapeshift form) so a sheath change never clobbers a neighbour that later starts using them. pub fn packed_with(unit_bytes_2: u32, state: u8) -> u32 { (unit_bytes_2 & 0xFFFF_FF00) | state as u32 } } -/// `UNIT_FIELD_FLAGS` bits (vanilla 1.12), stored in `game_world_entity.unit_flags`, sent in the CREATE -/// and relayed as a VALUES change. pub mod unit_flags { - /// `UNIT_FLAG_PLAYER_CONTROLLED` (0x00000008) marks a live Character as controlled by a player. pub const PLAYER_CONTROLLED: u32 = 0x0000_0008; - /// `UNIT_FLAG_NOT_SELECTABLE` (0x02000000) in the vanilla 1.12 UnitFlags enum. The client does - /// not offer an interaction cursor for such a unit, and server-side service gates must agree. pub const NOT_SELECTABLE: u32 = 0x0200_0000; - /// `UNIT_FLAG_IN_COMBAT` (0x00080000) — the unit is fighting. Observers render the in-combat state; - /// set at any hostile action (`combat::enter_combat`), cleared ~`COMBAT_DROP_MS` after the last one - /// (the tick's combat-drop pass). Covers a pure caster, whom the auto-attack stance can't show. pub const IN_COMBAT: u32 = 0x0008_0000; - /// `UNIT_FLAG_TAXI_FLIGHT` (0x00100000) — the player is under server-controlled flight-path - /// movement. This is presentation/state, not the route cursor; the authoritative route lives in - /// the module's active-taxi-flight row. pub const TAXI_FLIGHT: u32 = 0x0010_0000; } -/// Aura+spell tracer — a minimal ADDITIVE spell pipeline to live-test the spell-tier wire format -/// (one castable self-buff). Battle Shout: a real spell every L1 Human Warrior owns, so the client -/// resolves its icon/SFX from its own `Spell.dbc`. The aura renders in slot 0 — the only gtker-public -/// aura slot (slots 1..47 need the deferred hand-rolled update-mask encoder). pub mod tracer_spell { - pub const SPELL_ID: u32 = 6673; // Battle Shout (Rank 1) - pub const AURA_SLOT: u8 = 0; // default first slot; a new distinct aura takes the lowest free slot - pub const AURA_SLOTS: u8 = 48; // total UNIT_FIELD_AURA slots (the raw-send path reaches them all) - /// The 1.12 client sections `UNIT_FIELD_AURA` by SLOT INDEX, not by an aura flag: slots `0..32` render - /// as the buff frame, `32..48` as the debuff frame. `pick_aura_slot` must keep buffs below this and - /// debuffs at/above it, or a debuff shows in the buff row (and can starve buff slots). + pub const SPELL_ID: u32 = 6673; + pub const AURA_SLOT: u8 = 0; + pub const AURA_SLOTS: u8 = 48; // Total UNIT_FIELD_AURA slots. + // Slots 0..32 render buffs; slots 32..48 render debuffs. pub const BUFF_SLOT_COUNT: u8 = 32; - pub const AURA_FLAGS: u8 = 0x09; // positive + effect-index-0 (cmangos AFLAG); try 0x1F if invisible - pub const AURA_DURATION_MICROS: i64 = 30_000_000; // server-side expiry (client shows the DBC timer) + pub const AURA_FLAGS: u8 = 0x09; + pub const AURA_DURATION_MICROS: i64 = 30_000_000; } -/// The hand-authored starter weapon a fresh character carries in its first backpack -/// slot. Entry 25 is the real vanilla L1-Warrior "Worn Shortsword", so the client resolves its icon -/// from its own data once we answer `CMSG_ITEM_QUERY_SINGLE`. Values are hand-authored (licensing -/// firewall: derived, not bulk-imported). pub mod starter_item { - pub const ENTRY: u32 = 25; // "Worn Shortsword" - /// `HIGHGUID_ITEM` — tag in bits 48..63 of an item GUID so the client routes it as an item - /// (cmangos `HIGHGUID_ITEM = 0x4000`). + pub const ENTRY: u32 = 25; + // HIGHGUID_ITEM occupies bits 48..63 of an item GUID. pub const HIGHGUID_ITEM: u64 = 0x4000; - /// `INVENTORY_SLOT_ITEM_START` (23) — the first backpack slot. Vanilla inventory layout: - /// equipment 0..18, equipped-bag slots 19..22, backpack item slots 23..38. Stored as the - /// instance's `slot` and passed to gtker's typed `set_player_field_inv(ItemSlot, Guid)`. + // Vanilla inventory slots: equipment 0..18, bags 19..22, backpack 23..38. pub const BACKPACK_SLOT_0: u8 = 23; - /// `EQUIPMENT_SLOT_MAINHAND` (15) — the main-hand weapon slot. A L1 Warrior starts with - /// the Worn Shortsword EQUIPPED here (vanilla-authentic), so the client renders it on the 3D model - /// via `PLAYER_VISIBLE_ITEM` (set for equipment slots 0..18 in addition to the inv-slot guid). pub const MAINHAND_SLOT: u8 = 15; pub const CLASS_WEAPON: u8 = 2; pub const SUBCLASS_SWORD_1H: u8 = 7; @@ -206,49 +111,34 @@ pub mod starter_item { pub const ITEM_LEVEL: u8 = 2; pub const REQUIRED_LEVEL: u8 = 1; pub const MAX_DURABILITY: u32 = 20; - pub const BUY_PRICE: u32 = 35; // copper - pub const SELL_PRICE: u32 = 7; // copper - pub const DISPLAY_ID: u32 = 1542; // a basic one-hand-sword inventory model in the 5875 client + pub const BUY_PRICE: u32 = 35; // Copper. + pub const SELL_PRICE: u32 = 7; // Copper. + pub const DISPLAY_ID: u32 = 1542; pub const DAMAGE_MIN: f32 = 1.0; pub const DAMAGE_MAX: f32 = 3.0; pub const DELAY_MS: u32 = 1900; - /// Hearthstone (item entry 6948) — every character starts with one; using it recalls to the bound - /// home (`Character::home_*`). Granted into the backpack by `grant_starter_item`. pub const HEARTHSTONE_ENTRY: u32 = 6948; - /// Backpack slot the starter Hearthstone lands in — the LAST backpack slot, so it never collides with - /// the per-class outfit which stows from `BACKPACK_SLOT_0` (23) upward. + // Last backpack slot avoids the class outfit, which fills upward from slot 23. pub const HEARTHSTONE_SLOT: u8 = 38; } -/// Dual Wield (spell 674, `IDS_ROGUE`) — the talent/ability that lets a character equip a second -/// one-hand weapon into the off-hand slot and swing it as a reduced second attack stream. A L10 Rogue -/// can learn it in vanilla; Warrior Dual Wield (level 20) is out of scope for this constant's consumer -/// (`combat::equipped_offhand_weapon_damage` / `items::rules::resolve_equip_slot`/`can_equip_into`). pub mod dual_wield { pub const SPELL_ID: u32 = 674; } -/// The two armor upgrades a class trainer teaches at level 40. Knowing the PASSIVE is the -/// proficiency; the wrapper is the purchasable `game_trainer_spell` row that teaches it, so a -/// Character ends up with both spells in its spellbook. Nothing here is granted at creation — base -/// armor tiers come from the class alone (`item::Proficiency::derive`). +// The learned passive grants proficiency; the trainer wrapper only teaches it. pub mod armor_proficiency { - /// Plate Mail, taught by Warrior and Paladin trainers. pub const PLATE_PASSIVE_SPELL_ID: u32 = 750; pub const PLATE_TRAINER_SPELL_ID: u32 = 7109; - /// Mail, taught by Hunter and Shaman trainers (both classes start in leather). pub const MAIL_PASSIVE_SPELL_ID: u32 = 8737; pub const MAIL_TRAINER_SPELL_ID: u32 = 8738; } -/// The 6 vanilla movement speeds emitted in a CREATE block's movement section. -/// (Flight speeds were added in TBC and are absent here.) +// Movement speeds are in yards per second; vanilla has no flight speed fields. pub mod speeds { pub const WALK: f32 = 2.5; pub const RUN: f32 = 7.0; - /// Warrior Charge rush speed — the caster spline-rushes far faster than a normal run (mangos - /// `MoveCharge` uses ~25 yd/s). Tuning knob for the charge feel; not a movement-cap for anything else. pub const CHARGE: f32 = 25.0; pub const RUN_BACK: f32 = 4.5; pub const SWIM: f32 = 4.722_222; @@ -256,21 +146,18 @@ pub mod speeds { pub const TURN_RATE: f32 = std::f32::consts::PI; } -/// Canonical Human/Warrior starting fixture (Elwynn Forest). Confirm the exact float Z -/// against a 1.12 `playercreateinfo` when seeding — a wrong Z drops the character. pub mod start_human_warrior { - pub const RACE: u8 = 1; // Human - pub const CLASS: u8 = 1; // Warrior - pub const MAP_ID: u32 = 0; // Eastern Kingdoms - pub const ZONE_ID: u32 = 12; // Elwynn Forest + pub const RACE: u8 = 1; + pub const CLASS: u8 = 1; + pub const MAP_ID: u32 = 0; + pub const ZONE_ID: u32 = 12; pub const X: f32 = -8949.95; pub const Y: f32 = -132.493; pub const Z: f32 = 83.5312; pub const ORIENTATION: f32 = 0.0; } -/// Vanilla 5875 represents known taxi nodes as eight 32-bit words. Node ids are one-based bit -/// positions in that fixed mask, so a value outside this range cannot cross the protocol intact. +// Taxi node ids are one-based bit positions in a fixed eight-word mask. pub mod taxi_protocol { pub const NODE_MASK_WORDS: u32 = 8; pub const CLIENT_NODE_ID_MIN: u32 = 1; @@ -279,9 +166,7 @@ pub mod taxi_protocol { pub const REPLY_OPEN: u8 = 2; pub const REPLY_ACTIVATE: u8 = 3; - // Stable module-to-gateway activation results. They deliberately match the vanilla - // ActivateTaxiReply discriminants, but remain shared primitive constants so the wasm module - // does not depend on the wire-codec crate and the gateway never has to parse refusal prose. + // Activation results match vanilla ActivateTaxiReply discriminants. pub const ACTIVATE_OK: u8 = 0; pub const ACTIVATE_UNSPECIFIED_SERVER_ERROR: u8 = 1; pub const ACTIVATE_NO_SUCH_PATH: u8 = 2; @@ -297,14 +182,7 @@ pub mod taxi_protocol { pub const ACTIVATE_NOT_STANDING: u8 = 12; } -/// Reserved, hand-authored taxi catalogue used by the headless flight flow. Its storage ids sit -/// beside the existing 509xxxx scenario rows so a DBC import can wholesale-replace its derived -/// catalogue and then restore this route without colliding with client-derived primary keys. The -/// separate client ids fit the vanilla protocol mask and are protected by a unique table index. -/// -/// These values are shared by the native importer and the wasm module. Keeping the two writers on -/// one definition prevents a fresh database and a post-import database from growing different -/// versions of the fixture. +// Reserved storage ids survive DBC import; client ids must fit the vanilla taxi mask. pub mod taxi_fixture { pub const STORAGE_ID_FLOOR: u32 = 5_090_000; @@ -313,8 +191,6 @@ pub mod taxi_fixture { pub const PATH_ID: u32 = 5_090_102; pub const POINT_IDS: [u32; 3] = [5_090_103, 5_090_104, 5_090_105]; - // These synthetic wire ids deliberately exercise the final two bits of the fixed 256-bit mask. - // They are useful to headless protocol tests, but do not create matching map art in a real client. pub const SOURCE_CLIENT_NODE_ID: u32 = super::taxi_protocol::CLIENT_NODE_ID_MAX - 1; pub const DESTINATION_CLIENT_NODE_ID: u32 = super::taxi_protocol::CLIENT_NODE_ID_MAX; @@ -332,8 +208,7 @@ pub mod taxi_fixture { pub const SOURCE_NAME: &str = "Northshire Test Flight"; pub const DESTINATION_NAME: &str = "Northshire Test Landing"; pub const FARE: u32 = 25; - // Build-5875 display ids used only by the synthetic fixture. Imported nodes retain the exact - // Horde-first / Alliance-second pair from the operator's TaxiNodes.dbc. + // TaxiNodes.dbc stores the Horde display id before the Alliance display id. pub const MOUNT_DISPLAY_HORDE: u32 = 295; pub const MOUNT_DISPLAY_ALLIANCE: u32 = 1147; @@ -342,50 +217,27 @@ pub mod taxi_fixture { (0xF130_u64 << 48) | ((FLIGHT_MASTER_ENTRY as u64) << 24) | 1; } -/// Gossip menu OPTION: `game_gossip_option.action` codes as they land verbatim from -/// the cmangos dump's `gossip_menu_option.OptionType`/`option_id` column (the importer copies it -/// through unchanged — this module documents what the values MEAN so the gateway dispatch and the -/// importer agree without duplicating magic numbers). `[V]` — confirm against your own dump; only -/// GOSSIP/BANKER/VENDOR/TAXI/TRAINER/INNKEEPER are read by the dispatcher today, the rest are -/// inert (submenu navigation remains deferred). pub mod gossip_option { pub const GOSSIP: u32 = 1; - /// Quests reach the window through its QUEST section, never an option row, so the importer drops - /// these — the dump's rows carry the literal placeholder label "GOSSIP_OPTION_QUESTGIVER". pub const QUESTGIVER: u32 = 2; - pub const VENDOR: u32 = 3; // opens the vendor window (routes to build_list_inventory_raw) - pub const TAXI: u32 = 4; // flight master (system 136, not wired — inert) - pub const TRAINER: u32 = 5; // opens SMSG_TRAINER_LIST - pub const INNKEEPER: u32 = 8; // binds the caller's hearthstone home (bind_home) - pub const BANKER: u32 = 9; // opens the bank window - pub const PETITIONER: u32 = 10; // opens the Guild Charter list (`cm:GossipDef.h:45`) - pub const TABARDDESIGNER: u32 = 11; // opens the tabard designer (`cm:GossipDef.h:46`) - /// cmangos `GOSSIP_OPTION_UNLEARNTALENTS`. NOT what the raw dump carries — every "I wish to - /// unlearn my talents." row imports with `action=GOSSIP` (cmangos gates it in C++ code at - /// GossipHello, not via this column), so the importer reclassifies that specific row's text to - /// this action at import time (`resolve_gossip_option_text`'s caller in `importer/src/main.rs`). - /// Gated to level 10+ (talents don't exist below that) by `filtered_gossip_options`. + pub const VENDOR: u32 = 3; + pub const TAXI: u32 = 4; + pub const TRAINER: u32 = 5; + pub const INNKEEPER: u32 = 8; + pub const BANKER: u32 = 9; + pub const PETITIONER: u32 = 10; + pub const TABARDDESIGNER: u32 = 11; + // The dump encodes talent reset as GOSSIP; the importer reclassifies its option text. pub const UNLEARNTALENTS: u32 = 16; } -/// Minimum level a talent point exists at (mirrors vanilla's `PLAYER_LEVEL_MIN_TALENTS` — the client -/// hides the talent pane below this). Gates the "I wish to unlearn my talents." gossip option. pub const MIN_TALENT_LEVEL: u8 = 10; -/// `game_gossip_option.cond_type`, the MINIMAL condition set enforces (quest-status -/// gates, the common case in the dump). Anything the importer can't map to one of these folds to -/// `NONE` (fail-open + logged), so an unsupported condition never wrongly HIDES an option. The one -/// exception is `NEVER`, for a gate whose subject does not exist here at all. +// Unsupported conditions map to NONE; nonexistent subjects map to NEVER. pub mod gossip_condition { - /// Always show the option (no gate, or an unsupported/unmapped condition — fail-open). pub const NONE: u32 = 0; - /// Show only while `cond_value1` (a quest id) is in the player's quest log (accepted, whether or - /// not yet turned in) — `quest_status(guid, quest_id).0`. pub const QUEST_TAKEN: u32 = 1; - /// Show only once `cond_value1` (a quest id) has been turned in — `quest_status(guid, quest_id).1`. pub const QUEST_REWARDED: u32 = 2; - /// Never show. The fail-CLOSED placeholder for a condition whose subject does not exist here yet - /// — a seasonal event gate folded to `NONE` would pitch Children's Week in July. pub const NEVER: u32 = 3; } @@ -398,24 +250,19 @@ mod tests { assert_eq!(type_mask::PLAYER, 0x19); } - /// `packed_with` must touch BYTE 0 only. Byte 1 is PvP flags, byte 2 pet flags, byte 3 the - /// shapeshift form — a sheath toggle that cleared a druid's form byte would pop them out of Bear - /// on every `Z` press, which is exactly the class of bug a blind `= state as u32` would ship. #[test] fn packing_a_sheath_state_preserves_the_other_three_bytes() { - let neighbours = 0xAB_CD_EF_00_u32; // bytes 1-3 occupied, byte 0 clear + let neighbours = 0xAB_CD_EF_00_u32; assert_eq!( sheath_state::packed_with(neighbours, sheath_state::MELEE), 0xAB_CD_EF_01 ); - // And going back to stowed clears byte 0 without disturbing them. assert_eq!( sheath_state::packed_with(0xAB_CD_EF_02, sheath_state::UNARMED), 0xAB_CD_EF_00 ); } - /// The `CMSG_SETSHEATHED` payload is attacker-controlled; only 0/1/2 are real states. #[test] fn sheath_state_validation_rejects_out_of_range_bytes() { for ok in [ @@ -432,7 +279,6 @@ mod tests { #[test] fn creature_type_mask_is_0x9() { - // OBJECT | UNIT, no PLAYER bit — the codec branches on the PLAYER bit to pick Unit vs Player. assert_eq!(type_mask::CREATURE, 0x9); } @@ -447,22 +293,16 @@ mod tests { assert_eq!(npc_flags::AUCTIONEER, 0x1000); } - /// Pins the shared GameObject QUESTGIVER type id against cmangos. This is the - /// SINGLE source both `module/src/gameobject.rs::go_type::QUESTGIVER` and the gateway's - /// `CMSG_GAMEOBJ_USE` dispatch read, so a silent edit here would desync both sides at once. #[test] fn go_type_questgiver_is_cmangos_type_2() { assert_eq!(go_type::QUESTGIVER, 2); } - /// cmangos `GAMEOBJECT_TYPE_MEETINGSTONE`. The importer, the Module and the Gateway read it. #[test] fn go_type_meetingstone_is_cmangos_type_23() { assert_eq!(go_type::MEETINGSTONE, 23); } - /// The gossip option action codes the dispatcher matches on must be pairwise - /// distinct — a collision here would silently misroute one action to another's handler. #[test] fn gossip_option_actions_are_distinct() { use gossip_option::*; @@ -473,7 +313,6 @@ mod tests { 0, "gossip_option action codes must not collide" ); - // The cmangos `GossipOptionType` numbering. assert_eq!( (GOSSIP, QUESTGIVER, VENDOR, TAXI, TRAINER, INNKEEPER, BANKER), (1, 2, 3, 4, 5, 8, 9) @@ -482,8 +321,6 @@ mod tests { #[test] fn gossip_condition_none_is_the_fail_open_zero_value() { - // `NONE == 0` is load-bearing: the importer's fail-open path for an unmapped condition writes - // `cond_type = 0` and relies on it meaning "always show" (never a wrongly-hidden option). assert_eq!(gossip_condition::NONE, 0); assert_ne!( gossip_condition::QUEST_TAKEN, diff --git a/crates/lyracore-shared/src/duel.rs b/crates/lyracore-shared/src/duel.rs index 5a8363e48..e22521aab 100644 --- a/crates/lyracore-shared/src/duel.rs +++ b/crates/lyracore-shared/src/duel.rs @@ -1,13 +1,9 @@ -//! Stable module-to-gateway codes for the Duel lifecycle relay. - -/// Authoritative Duel states stored in `game_duel.state`. pub mod state { pub const REQUESTED: u8 = 0; pub const COUNTDOWN: u8 = 1; pub const ACTIVE: u8 = 2; } -/// Lifecycle edges stored in `game_duel_event.kind`. pub mod event_kind { pub const REQUESTED: u8 = 0; pub const COUNTDOWN: u8 = 1; @@ -17,7 +13,6 @@ pub mod event_kind { pub const IN_BOUNDS: u8 = 5; } -/// Terminal results stored in `game_duel_event.completion_kind`. pub mod completion_kind { pub const INTERRUPTED: u8 = 0; pub const WON: u8 = 2; diff --git a/crates/lyracore-shared/src/env.rs b/crates/lyracore-shared/src/env.rs index 812b351a4..7cafabd58 100644 --- a/crates/lyracore-shared/src/env.rs +++ b/crates/lyracore-shared/src/env.rs @@ -1,18 +1,11 @@ -//! Environmental damage: fall damage from the client-reported airborne time. Shared by the -//! module (applies the damage in `movement_update`) and the gateway (sends the matching -//! `SMSG_ENVIRONMENTAL_DAMAGE_LOG` flavor line) so the two can never drift. - -/// Vanilla's `MOVEMENTFLAG_SWIMMING` bit. It is also set while floating at the surface, so it -/// must be combined with the water-surface test in [`is_submerged`]. +// SWIMMING also applies at the surface; combine it with the liquid height. pub const MOVEMENT_FLAG_SWIMMING: u32 = 0x0020_0000; -/// Build-5875 transport movement carries a full guid and relative pose. pub const MOVEMENT_FLAG_ON_TRANSPORT: u32 = 0x0200_0000; -/// Default player collision height. +// Collision height in yards. pub const UNDERWATER_HEAD_HEIGHT: f32 = 2.0; -/// Whether the player's head is below a liquid surface. pub fn is_submerged( player_z: f32, liquid_level: f32, @@ -24,21 +17,17 @@ pub fn is_submerged( && player_z + UNDERWATER_HEAD_HEIGHT < liquid_level } -/// The breath bar's full duration and its surface refill time, pinned to vanilla's roughly -/// one-minute `Player::HandleDrowning` window and deliberately slower recovery. pub const FULL_AIR_MICROS: i64 = 60_000_000; pub const REFILL_MICROS: i64 = 10_000_000; pub const DROWNING_INTERVAL_MICROS: i64 = 1_000_000; pub const MAX_DROWNING_CATCHUP_TICKS: u32 = 5; -/// Pure state-machine result for a draining or refilling breath bar. #[derive(Debug, PartialEq, Eq)] pub struct BreathAdvance { pub remaining_air_micros: i64, pub drowning_ticks: u32, } -/// Advance a breath bar by elapsed time. pub fn advance_breath( remaining_air_micros: i64, draining: bool, @@ -71,16 +60,11 @@ pub fn advance_breath( } } -/// One fifth of maximum health per drowning tick. pub fn drowning_damage(max_health: u32) -> u32 { max_health.saturating_mul(20).saturating_add(99) / 100 } -/// Fall damage from airborne time: WoW gravity ~19.29 yd/s^2 gives free-fall height h = 0.5*g*t^2, -/// folded into the mangos height curve `damage% = 1.8%*yd - 24.26%` (safe under ~13.5 yd, which is -/// ~1.18 s airborne). `fall_time_ms` is the client's MovementInfo fall time on MSG_MOVE_FALL_LAND — -/// it INCLUDES the jump ascent, so a flat-ground hop (~750 ms) lands at 0. Client-authoritative like -/// all movement (a hacked client can lie; the movement anti-cheat owns that). Capped at max_health. +// Client fall_time_ms includes jump ascent; gravity is about 19.29 yd/s^2. pub fn fall_damage(fall_time_ms: u32, max_health: u32) -> u32 { let t = fall_time_ms as f32 / 1000.0; let height_yd = 0.5 * 19.29 * t * t; @@ -91,20 +75,15 @@ pub fn fall_damage(fall_time_ms: u32, max_health: u32) -> u32 { (max_health as f32 * perc.min(1.0)) as u32 } -/// `fall_time` (u32 ms) out of a raw vanilla MovementInfo body: flags u32 LE, time u32, x/y/z/o -/// 16 B, then [transport guid u64 + pos 16 B if flag 0x02000000], [pitch f32 if SWIMMING 0x0020_0000], -/// then fall_time. `None` on a short body or an on-transport mover (no fall damage on transports). -/// NOTE the wire TYPE: cmangos reads fall time as u32 MILLISECONDS; gtker's MovementInfo types the -/// same 4 bytes f32 (a field-type lie, like its field-name ones) — this parser and any gtker-side -/// consumer must agree on the raw-u32 reading (`f32::to_bits` at the gtker boundary). +// Fall time is raw u32 milliseconds; gtker types those same bytes as f32. pub fn fall_time_from_movement_info(body: &[u8]) -> Option { let flags = u32::from_le_bytes(body.get(0..4)?.try_into().ok()?); if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { return None; } - let mut off = 4 + 4 + 16; // flags + time + x/y/z/o + let mut off = 4 + 4 + 16; if flags & MOVEMENT_FLAG_SWIMMING != 0 { - off += 4; // pitch + off += 4; } Some(u32::from_le_bytes(body.get(off..off + 4)?.try_into().ok()?)) } @@ -116,11 +95,11 @@ mod tests { #[test] fn fall_damage_curve() { assert_eq!(fall_damage(0, 100), 0); - assert_eq!(fall_damage(800, 100), 0); // a flat-ground jump is safe - assert_eq!(fall_damage(1_180, 100), 0); // ~13.4 yd — the safe threshold - let d15 = fall_damage(1_500, 100); // ~21.7 yd → ~15% + assert_eq!(fall_damage(800, 100), 0); + assert_eq!(fall_damage(1_180, 100), 0); + let d15 = fall_damage(1_500, 100); assert!((10..25).contains(&d15), "{d15}"); - assert_eq!(fall_damage(10_000, 100), 100); // terminal: capped at max_health + assert_eq!(fall_damage(10_000, 100), 100); } #[test] @@ -128,17 +107,14 @@ mod tests { let mut body = vec![0u8; 24]; body.extend_from_slice(&1500u32.to_le_bytes()); assert_eq!(fall_time_from_movement_info(&body), Some(1500)); - // swimming: pitch shifts fall_time by 4 let mut swim = MOVEMENT_FLAG_SWIMMING.to_le_bytes().to_vec(); - swim.extend_from_slice(&[0u8; 20]); // time + pos - swim.extend_from_slice(&0f32.to_le_bytes()); // pitch + swim.extend_from_slice(&[0u8; 20]); + swim.extend_from_slice(&0f32.to_le_bytes()); swim.extend_from_slice(&2000u32.to_le_bytes()); assert_eq!(fall_time_from_movement_info(&swim), Some(2000)); - // transport: no fall damage let mut tr = 0x0200_0000u32.to_le_bytes().to_vec(); tr.extend_from_slice(&[0u8; 60]); assert_eq!(fall_time_from_movement_info(&tr), None); - // short body: None, never a panic assert_eq!(fall_time_from_movement_info(&[0u8; 10]), None); } @@ -147,10 +123,10 @@ mod tests { let surface = 10.0; let swimming = MOVEMENT_FLAG_SWIMMING; - assert!(!is_submerged(8.0, surface, true, swimming)); // head exactly at surface + assert!(!is_submerged(8.0, surface, true, swimming)); assert!(is_submerged(7.99, surface, true, swimming)); assert!(!is_submerged(8.01, surface, true, swimming)); - assert!(!is_submerged(0.0, surface, false, swimming)); // dry/off-slice cell + assert!(!is_submerged(0.0, surface, false, swimming)); assert!(!is_submerged(0.0, surface, true, 0)); } diff --git a/crates/lyracore-shared/src/faction.rs b/crates/lyracore-shared/src/faction.rs index 498abaf03..adde8f51d 100644 --- a/crates/lyracore-shared/src/faction.rs +++ b/crates/lyracore-shared/src/faction.rs @@ -1,21 +1,14 @@ -//! Shared race-to-team mapping for module and gateway faction checks. - -/// cmangos team-faction ids, as `game_graveyard_zone.faction` carries them (0 = both factions serve -/// a zone; 469 = Alliance-only; 67 = Horde-only). +// Graveyard faction ids: 0 serves both teams, 469 Alliance, 67 Horde. pub const TEAM_ALLIANCE: u32 = 469; pub const TEAM_HORDE: u32 = 67; -/// The team a `game_character.race` byte belongs to. -/// -/// Unknown races default to Alliance, matching the currently imported Alliance-only content. pub fn team_for_race(race: u8) -> u32 { match race { - 2 | 5 | 6 | 8 => TEAM_HORDE, // Orc / Undead / Tauren / Troll + 2 | 5 | 6 | 8 => TEAM_HORDE, _ => TEAM_ALLIANCE, } } -/// Whether two race bytes belong to the same team. pub fn same_team(race_a: u8, race_b: u8) -> bool { team_for_race(race_a) == team_for_race(race_b) } @@ -24,7 +17,6 @@ pub fn same_team(race_a: u8, race_b: u8) -> bool { mod tests { use super::*; - /// The mapping itself, both directions plus the unrecognized-byte default. #[test] fn every_race_byte_maps_to_a_team_and_an_unknown_one_defaults_alliance() { for alliance in [1, 3, 4, 7, 11] { @@ -36,7 +28,6 @@ mod tests { assert_eq!(team_for_race(200), TEAM_ALLIANCE); } - /// The predicate the mail gate reads: same side yes, opposing side no. #[test] fn same_team_pairs_races_by_side() { assert!(same_team(1, 3), "Human and Dwarf are one team"); @@ -58,7 +49,6 @@ pub fn reputation_rank(standing: i32) -> u8 { } } -/// Explicit relations take precedence over group masks. Faction zero has no explicit relation. pub fn relation_holds( asserting: &[u32; 4], vetoing: &[u32; 4], @@ -77,7 +67,6 @@ pub fn relation_holds( mask & other_group != 0 } -/// A standing row for a faction with a reputation bar overrides template hostility. pub fn refuses_interaction(reputation: Option<(i32, bool)>, template_hostile: bool) -> bool { reputation.map_or(template_hostile, |(standing, at_war)| { at_war || reputation_rank(standing) <= 2 diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index ec2ddadb2..cb457d5f3 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -1,22 +1,11 @@ -//! The module↔gateway GROUP wire contract — event-kind codes, the roster-payload grammar, and the -//! typed [`GroupRefusal`] the gateway turns into `PARTY_COMMAND_RESULT`. Cross-boundary constants -//! live HERE, both crates import: a module-side renumber, delimiter change, or new refusal becomes -//! a compile error on the gateway side instead of a runtime mismatch. Same precedent as -//! `type_mask`/`npc_flags`. - -/// Vanilla party size (cm:Group.h:40). Realm authority and Gateway projections share this bound. pub const GROUP_MAX_MEMBERS: usize = 5; -/// Vanilla raid size (cm:Group.h:41). pub const RAID_MAX_MEMBERS: usize = 40; -/// A Raid has 8 Subgroups, numbered 0 to 7 (cm:Group.h:42). pub const RAID_SUBGROUPS: u8 = 8; -/// Members in one Subgroup (cm:Group.h:42). pub const SUBGROUP_SIZE: usize = 5; -/// A Group is a Party until its leader converts it to a Raid. It never converts back. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum GroupKind { #[default] @@ -25,7 +14,6 @@ pub enum GroupKind { } impl GroupKind { - /// The `SMSG_GROUP_LIST` group-type byte (cm:Group.h:60-64), stored as `game_group.group_type`. pub const fn wire(self) -> u8 { match self { Self::Party => 0, @@ -33,7 +21,6 @@ impl GroupKind { } } - /// `None` for a byte no vanilla group type uses. pub const fn from_wire(byte: u8) -> Option { match byte { 0 => Some(Self::Party), @@ -42,8 +29,6 @@ impl GroupKind { } } - /// A stored `group_type`. Only [`Self::wire`] writes the column, so the Party fallback is - /// unreachable. It is the safe reading because it keeps the smaller member cap. pub fn from_wire_or_default(byte: u8) -> Self { Self::from_wire(byte).unwrap_or_default() } @@ -56,16 +41,13 @@ impl GroupKind { } } -/// A member's Subgroup and Assistant flag, kept as the `SMSG_GROUP_LIST` member-flags byte -/// `subgroup | 0x80 if assistant` (cm:Group.cpp:685, 695). Stored as `game_group_member.raid_slot`. -/// A Party member holds the default slot: Subgroup 0, no Assistant. +// SMSG_GROUP_LIST member flags encode subgroup | 0x80 for an Assistant. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct RaidSlot(u8); impl RaidSlot { const ASSISTANT: u8 = 0x80; - /// `None` for a Subgroup outside 0 to 7. pub const fn new(subgroup: u8, assistant: bool) -> Option { if subgroup >= RAID_SUBGROUPS { return None; @@ -77,9 +59,6 @@ impl RaidSlot { })) } - /// The slot a member joining a Raid takes: the first Subgroup with fewer than - /// [`SUBGROUP_SIZE`] members, without the Assistant flag (cm:Group.cpp:817-838). `current` is - /// every current member's slot. `None` when every Subgroup is full. pub fn for_raid_joiner(current: impl IntoIterator) -> Option { let mut sizes = [0usize; RAID_SUBGROUPS as usize]; for slot in current { @@ -90,13 +69,10 @@ impl RaidSlot { .and_then(|subgroup| Self::new(subgroup, false)) } - /// `None` for a byte with a bit set outside the Subgroup and the Assistant flag. pub const fn from_wire(byte: u8) -> Option { Self::new(byte & !Self::ASSISTANT, byte & Self::ASSISTANT != 0) } - /// A stored `raid_slot`. Only [`Self::wire`] writes the column, so the fallback to Subgroup 0 - /// without the Assistant flag is unreachable. pub fn from_wire_or_default(byte: u8) -> Self { Self::from_wire(byte).unwrap_or_default() } @@ -113,7 +89,6 @@ impl RaidSlot { self.0 } - /// The same Subgroup with the Assistant flag set or cleared. pub const fn with_assistant(self, assistant: bool) -> Self { Self(if assistant { self.0 | Self::ASSISTANT @@ -122,16 +97,7 @@ impl RaidSlot { }) } - /// The result of moving this Raid Slot's member to `destination` Subgroup - /// (cm:GroupHandler.cpp:492-525, cm:Group.cpp:1203-1251), shared by the Module and the Gateway - /// Fake so cmangos's capacity-and-destination rule lives in exactly one place. `destination_size` - /// counts the OTHER members already in `destination`, not the mover, since moving into the - /// Subgroup you already hold can never be blocked by your own presence there. - /// - /// `Err(InvalidSubgroup)` for a Subgroup of 8 or more. `Ok(None)` is the same-Subgroup no-op - /// (cm:Group.cpp:1234), which succeeds regardless of `destination_size`. `Err(SubgroupFull)` when - /// `destination_size` already holds [`SUBGROUP_SIZE`] members. Otherwise `Ok(Some(slot))` is the - /// mover's new Raid Slot, its Assistant bit kept. + // destination_size excludes the mover, so its current Subgroup cannot refuse itself. pub fn moved_to_subgroup( self, destination: u8, @@ -151,11 +117,7 @@ impl RaidSlot { ))) } - /// This Raid Slot and `other` after a Swap Subgroup between them (cm:GroupHandler.cpp:901-944). - /// `None` when both already sit in the same Subgroup. cmangos still answers that as success - /// (cm:GroupHandler.cpp:939), it just has nothing to swap. One atomic swap can never overfill a - /// Subgroup, so unlike [`moved_to_subgroup`](Self::moved_to_subgroup) this needs no capacity - /// argument. + // An atomic swap cannot overfill either Subgroup. pub fn swapped_with(self, other: Self) -> Option<(Self, Self)> { if self.subgroup() == other.subgroup() { return None; @@ -169,213 +131,80 @@ impl RaidSlot { } } -/// Group-event kinds (`game_group_event.kind`), what SMSG the gateway relays. Every producer shares -/// this one byte space: -/// -/// - 0-3: membership, below. -/// - 4-8: loot rolls and money shares, `crate::loot_roll::event_kind`. -/// - 9: retired. It was party chat, now a Realm Chat Line, and is never reused. -/// - 10-11: quest sharing, `crate::quest::share_event_kind`. -/// - 12-19: unassigned, left for the Realm-core chat seam. -/// - 20: the leader announcement, below. -/// - 21-26: Group Broadcasts, below. -/// - 27-30: the Meeting Stone Queue, `crate::meeting_stone::event_kind`. +// game_group_event kind 9 is retired and must not be reused. pub mod event_kind { - /// You are invited (`other_*` = the inviter) → `SMSG_GROUP_INVITE`. pub const INVITE: u8 = 0; - /// Your group's roster changed → the gateway sends `SMSG_GROUP_LIST` from the row's payload - /// (payload-carry: the roster is serialized in the SAME transaction as the membership change). + // The roster payload and membership change commit in the same transaction. pub const LIST: u8 = 1; - /// Your invite was declined (`other_*` = the decliner) → `SMSG_GROUP_DECLINE`. pub const DECLINE: u8 = 2; - /// You are no longer in a group (left / kicked / disbanded) → `SMSG_GROUP_DESTROYED`. pub const DESTROYED: u8 = 3; - // --- (group loot methods) / (money-loot split), the roll/master- - // loot/money-share relay REUSES this same per-recipient event table + relay (see - // `crate::loot_roll`'s module doc for why: identical shape — one recipient, a kind byte, a small - // payload — so a parallel new table would only add binding-checklist ceremony for zero - // behavioral gain). Kinds/payload grammar for these five live in `crate::loot_roll`, not here - // (loot is a distinct concern from group membership) — listed here too ONLY as the reserved - // kind-byte range so a future group-event kind can never collide with a loot-roll kind sharing - // the same table. - /// Reserved range start for `crate::loot_roll::event_kind` — kinds `4..=8` are loot-roll/money- - /// share kinds relayed through `game_group_event`, not group-membership kinds. The module doc - /// above lists every range on this table. pub const LOOT_ROLL_RESERVED_START: u8 = 4; - // Kind 9 was party chat, now a Realm Chat Line. It is retired and never reused. - /// The Group has a new leader (`other_guid`) → `SMSG_GROUP_SET_LEADER` with that leader's name. - /// Every member receives it before the LIST that names the new leader (cm:Group.cpp:464-470, - /// 498-501). + // Send SMSG_GROUP_SET_LEADER before the LIST naming the new leader. pub const SET_LEADER: u8 = 20; - /// A Ready Check started (`other_guid` = the starter) → the empty `MSG_RAID_READY_CHECK`. pub const READY_CHECK: u8 = 21; - /// A member answered the Ready Check (`other_guid` = the answerer, payload - /// [`super::encode_ready_check_answer`]) → `MSG_RAID_READY_CHECK` with the state, to the leader. pub const READY_CHECK_ANSWER: u8 = 22; - /// One Target Icon changed (payload [`super::TargetIcon::encode`]) → the partial - /// `MSG_RAID_TARGET_UPDATE`. pub const TARGET_ICON_UPDATE: u8 = 23; - /// Every Target Icon the Group holds (payload [`super::encode_target_icons`]) → the full - /// `MSG_RAID_TARGET_UPDATE`, to the member who asked. pub const TARGET_ICON_LIST: u8 = 24; - /// A member pinged the minimap (`other_guid` = the pinger, payload - /// [`super::encode_minimap_ping`]) → `MSG_MINIMAP_PING`. pub const MINIMAP_PING: u8 = 25; - /// A `/roll` result (`other_guid` = the roller, payload [`super::encode_random_roll`]) → - /// `MSG_RANDOM_ROLL`. pub const RANDOM_ROLL: u8 = 26; } -/// The REALM-CORE party ops: the `op` byte of the single operator-gated `realm_group_op` reducer the -/// gateway drives realm-wide membership with. -/// -/// One reducer with an op byte, rather than one reducer per op, keeps one generated binding and one -/// signature for every Gateway and durable-test caller. The ARGUMENT SHAPE is pinned here, in the -/// one file both crates import, so a renumber or an argument-slot change is a compile-visible edit -/// on both sides rather than a silent mis-dispatch. -/// -/// Argument slots (`realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c)`), per op: -/// - [`INVITE`] / [`UNINVITE`]: `target_guid` is the invitee/kicked member; the rest unused. -/// - [`ACCEPT`]: `arg_a` is the acceptor's class, 0 when the Gateway cannot read it. Realm-core -/// holds no Character rows, so the Seeker row a queued party gains needs it. The rest unused. -/// - [`DECLINE`]: `actor_guid` alone; every other slot unused. -/// - [`LEAVE`]: `actor_guid` leaves; `arg_a` = a [`super::leave_cause`] value. -/// - [`LOOT_METHOD`]: `arg_a` = loot setting, `target_guid` = the master looter, `arg_b` = the -/// quality threshold. (That is `CMSG_LOOT_METHOD`'s own field order, kept so the gateway hands the -/// three values straight through.) -/// - [`RAID_CONVERT`]: `actor_guid` alone. -/// - [`SET_LEADER`]: `target_guid` is the new leader. -/// - [`SET_ASSISTANT`]: `target_guid` is the member, `arg_a` is 1 to promote and 0 to demote. -/// - [`CHANGE_SUBGROUP`]: `target_guid` is the member to move, `arg_a` is the destination Subgroup. -/// - [`SWAP_SUBGROUP`]: `target_guid` is one member, `arg_c` the other. -/// - [`READY_CHECK_START`]: `actor_guid` alone. -/// - [`READY_CHECK_ANSWER`]: `arg_a` = the client's answer state. -/// - [`TARGET_ICON`]: `arg_a` = the Target Icon, or [`super::TARGET_ICON_LIST_REQUEST`]; -/// `target_guid` = the marked unit, 0 to clear the icon. -/// - [`MINIMAP_PING`]: `target_guid` = `x.to_bits()`, `arg_c` = `y.to_bits()`, so the floats -/// cross unchanged. -/// - [`RANDOM_ROLL`]: `target_guid` = the minimum, `arg_c` = the maximum. -/// -/// `arg_c` is a `u64` for an op that needs a second guid or a wide value: the subgroup swap's -/// second member, the minimap ping's `y` and the roll's maximum. Every other op sends 0 there. pub mod realm_op { - /// `CMSG_GROUP_INVITE`: `actor_guid`, ungrouped, the leader or an Assistant, invites - /// `target_guid`. pub const INVITE: u8 = 0; - /// `CMSG_GROUP_ACCEPT`: `actor_guid` accepts its pending invite. pub const ACCEPT: u8 = 1; - /// `CMSG_GROUP_DECLINE`: `actor_guid` declines its pending invite. pub const DECLINE: u8 = 2; - /// `CMSG_GROUP_DISBAND` (the client's "Leave Party"): `actor_guid` leaves its group. pub const LEAVE: u8 = 3; - /// `CMSG_GROUP_UNINVITE` and `CMSG_GROUP_UNINVITE_GUID`: the leader or an Assistant, - /// `actor_guid`, kicks `target_guid`. pub const UNINVITE: u8 = 4; - /// `CMSG_LOOT_METHOD`: leader `actor_guid` sets the party's loot rules. pub const LOOT_METHOD: u8 = 5; - /// `CMSG_GROUP_RAID_CONVERT`: leader `actor_guid` converts its Party to a Raid. pub const RAID_CONVERT: u8 = 6; - /// `CMSG_GROUP_SET_LEADER`: leader `actor_guid` passes the lead to `target_guid`. pub const SET_LEADER: u8 = 7; - /// `CMSG_GROUP_ASSISTANT_LEADER`: Raid leader `actor_guid` promotes or demotes an Assistant. pub const SET_ASSISTANT: u8 = 8; - /// `CMSG_GROUP_CHANGE_SUB_GROUP`: the leader or an Assistant moves `target_guid` to Subgroup - /// `arg_a`. pub const CHANGE_SUBGROUP: u8 = 9; - /// `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant swaps the Subgroups of `target_guid` - /// and `arg_c`. pub const SWAP_SUBGROUP: u8 = 10; - /// `MSG_RAID_READY_CHECK` without a body: the leader or an Assistant starts a Ready Check. pub const READY_CHECK_START: u8 = 11; - /// `MSG_RAID_READY_CHECK` with a body: `actor_guid` answers the Ready Check. pub const READY_CHECK_ANSWER: u8 = 12; - /// `MSG_RAID_TARGET_UPDATE`: `actor_guid` sets a Target Icon or asks for the full list. pub const TARGET_ICON: u8 = 13; - /// `MSG_MINIMAP_PING`: `actor_guid` pings a point on the minimap. pub const MINIMAP_PING: u8 = 14; - /// `MSG_RANDOM_ROLL`: `actor_guid` rolls a random number. pub const RANDOM_ROLL: u8 = 15; } -/// Why a [`realm_op::LEAVE`] runs, sent in `arg_a`. pub mod leave_cause { - /// The Character leaves, or the Gateway leaves for a session-less Character. pub const LEFT: u8 = 0; - /// The Character was deleted. The party authority also drops every Target Icon on it, because - /// the delete sweep on the Character's World Shard cannot reach Realm-core's icon rows. pub const CHARACTER_DELETED: u8 = 1; } -/// The group op one `game_bot_invite_intent` row asks the Gateway to run. -/// -/// A Package writes that row for a Character with no Session, so there is no client behind it and no -/// `ctx.sender()` to resolve; the Gateway turns the byte into the matching [`realm_op`] against the -/// party authority. Two values rather than a reuse of [`realm_op`]: those are what a CLIENT may -/// ask for, and a Package may only ask for these two. [`INVITE`] is `0` because the column was -/// END-appended to a table that had only ever carried invites, so a pre-existing row reads correctly -/// under the migration's own default. +// Older game_bot_invite_intent rows default to INVITE. pub mod bot_op { - /// `inviter_guid` invites `target_guid` into a party. pub const INVITE: u8 = 0; - /// `inviter_guid` leaves its party. `target_guid` is unused. pub const LEAVE: u8 = 1; } -/// Durable companion-command queues are partitioned into this many fair lanes on every source -/// Module. Gateway reads at most one head from each lane per dispatch turn. pub const COMMAND_DISPATCH_LANES: u8 = 8; pub const COMMAND_RESULT_WINDOW_MICROS: i64 = 30_000_000; -/// Why the Module refused a party Durable Request. The tag is the whole reducer error text, so -/// neither tier matches on human prose. Most variants become a `PartyResult` the client renders; -/// [`GroupRefusal::IntentAlreadyClaimed`] instead tells a losing Gateway callback to stop. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GroupRefusal { - /// The acting Character is temporarily unable to perform a party action. ActorUnavailable, - /// A Character may not invite itself. InviteSelf, - /// No Character row holds a guid the op named. NoSuchPlayer, - /// The invited Character has no live entity. TargetOffline, - /// The invited Character is already in a party. AlreadyInGroup, - /// The party is at its member cap. GroupFull, - /// The actor is in a Group but may not run the op there: it does not lead the Group, or is not - /// an Assistant where one may act. Also the answer when an Assistant names the leader for - /// removal (cm:GroupHandler.cpp:274-276). NotLeader, - /// The actor is in no party. NotInGroup, - /// The named target is in no party, or in a different one. TargetNotInGroup, - /// Accept or decline ran with no invite standing. NoPendingInvite, - /// The inviter is gone, or no longer leads its Group or assists in it. InviterUnavailable, - /// The actor tried to kick itself; leaving is the op for that. KickSelf, - /// The loot method, threshold, or master looter is not a legal setting. InvalidLootRules, - /// Another Gateway already claimed this bot invite intent. IntentAlreadyClaimed, - /// The Package has suppressed session-less actions for this Character. ActionSuppressed, - /// The invited Character belongs to the other team. Vanilla's default refuses a party across - /// factions (cm:GroupHandler.cpp:80). The Gateway applies this Gate realm-wide. WrongFaction, - /// The op needs a Raid, and the actor's Group is a Party. NotRaid, - /// A leader named itself as the new leader or as an Assistant (vm:GroupHandler.cpp:311, 554). TargetIsSelf, - /// A Change Subgroup or Swap Subgroup destination named a Subgroup outside 0 to 7. InvalidSubgroup, - /// A Change Subgroup destination already holds [`SUBGROUP_SIZE`] members. SubgroupFull, - /// A Target Icon index outside 0 to 7 that is not the list request (cm:Group.cpp:585-586). InvalidTargetIcon, } @@ -437,14 +266,11 @@ impl GroupRefusal { } } -/// A Group holds 8 Target Icons, numbered 0 to 7 (cm:Group.h:43). pub const TARGET_ICON_COUNT: u8 = 8; -/// The `MSG_RAID_TARGET_UPDATE` index that asks for every Target Icon instead of setting one -/// (cm:GroupHandler.cpp:457-459). +// MSG_RAID_TARGET_UPDATE index 0xFF requests the full list. pub const TARGET_ICON_LIST_REQUEST: u8 = 0xFF; -/// One Target Icon on one unit. `target_guid` 0 means the icon is clear. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TargetIcon { pub icon: u8, @@ -452,12 +278,10 @@ pub struct TargetIcon { } impl TargetIcon { - /// `icon,target_guid`, the `TARGET_ICON_UPDATE` payload. pub fn encode(self) -> String { format!("{},{}", self.icon, self.target_guid) } - /// `None` for a malformed payload or an icon outside 0 to 7. pub fn decode(payload: &str) -> Option { let (icon, target_guid) = payload.split_once(',')?; let icon = icon.parse().ok().filter(|&icon| icon < TARGET_ICON_COUNT)?; @@ -468,8 +292,6 @@ impl TargetIcon { } } -/// `icon,target_guid;...`, the `TARGET_ICON_LIST` payload and a Party roster's icon segment. An -/// empty list is an empty string. pub fn encode_target_icons(icons: &[TargetIcon]) -> String { icons .iter() @@ -478,8 +300,6 @@ pub fn encode_target_icons(icons: &[TargetIcon]) -> String { .join(";") } -/// `None` for a malformed entry, a clear icon, or two entries sharing an icon or a unit: a Group -/// holds each icon once and a unit carries one icon at most. pub fn decode_target_icons(payload: &str) -> Option> { let mut icons: Vec = Vec::new(); for entry in payload.split(';').filter(|entry| !entry.is_empty()) { @@ -495,7 +315,6 @@ pub fn decode_target_icons(payload: &str) -> Option> { Some(icons) } -/// The `READY_CHECK_ANSWER` payload: the client's state byte in decimal. pub fn encode_ready_check_answer(state: u8) -> String { state.to_string() } @@ -504,8 +323,7 @@ pub fn decode_ready_check_answer(payload: &str) -> Option { payload.parse().ok() } -/// `x,y` as `f32` bit patterns, the `MINIMAP_PING` payload. Bits rather than decimals, so every -/// float the client sent reaches the other members unchanged. +// Float bit patterns preserve the exact MINIMAP_PING coordinates. pub fn encode_minimap_ping(x: f32, y: f32) -> String { format!("{},{}", x.to_bits(), y.to_bits()) } @@ -518,12 +336,10 @@ pub fn decode_minimap_ping(payload: &str) -> Option<(f32, f32)> { )) } -/// `min,max,result`, the `RANDOM_ROLL` payload. pub fn encode_random_roll(min: u32, max: u32, result: u32) -> String { format!("{min},{max},{result}") } -/// `None` for a malformed payload or a result outside its range. pub fn decode_random_roll(payload: &str) -> Option<(u32, u32, u32)> { let mut parts = payload.split(','); let min = parts.next()?.parse().ok()?; @@ -535,12 +351,7 @@ pub fn decode_random_roll(payload: &str) -> Option<(u32, u32, u32)> { Some((min, max, result)) } -/// One roster as a LIST event carries it: what `SMSG_GROUP_LIST` shows, except presence. -/// -/// The Module writes this in the same transaction as the change it reports, so the roster and the -/// loot rules always agree. Presence is not in it: on Realm-core the Module has no live entities to -/// read, so the Gateway reads presence from the shard caches when it renders the list. Names are -/// blank on Realm-core for the same reason and the Gateway fills them the same way. +// The roster commits with its change; the Gateway supplies presence and missing names. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RosterPayload { pub leader: u64, @@ -548,11 +359,8 @@ pub struct RosterPayload { pub loot_threshold: u8, pub master_looter_guid: u64, pub kind: GroupKind, - /// Every member in join order, the recipient included. pub members: Vec, - /// A Party's Target Icons, sent again right after the list. In a Party the client clears its - /// marks on every `SMSG_GROUP_LIST`, and vmangos resends them after the list - /// (vm:Group.cpp:1343-1360). A Raid keeps its marks, so a Raid's roster carries none. + // A Party clears Target Icons on SMSG_GROUP_LIST, so resend them after the list. pub target_icons: Vec, } @@ -564,10 +372,6 @@ pub struct RosterMember { } impl RosterPayload { - /// `leader,loot_method,loot_threshold,master_looter_guid,kind|guid,name,slot;...`, with `kind` - /// and `slot` as wire bytes, then `|` and [`encode_target_icons`] when there are Target Icons. - /// The encoder owns the grammar, so it also strips the delimiters from names. Character - /// creation admits only letters today, but that rule lives elsewhere. pub fn encode(&self) -> String { let members: Vec = self .members @@ -593,9 +397,6 @@ impl RosterPayload { payload } - /// `None` for a malformed or extra field, an unknown kind, an invalid Raid Slot, no members, - /// more members than a Raid holds, or a malformed icon segment. The Gateway fails closed rather - /// than render a corrupt roster. pub fn decode(payload: &str) -> Option { let mut segments = payload.split('|'); let head = segments.next()?; @@ -646,7 +447,6 @@ impl RosterPayload { mod tests { use super::*; - /// Every producer of `game_group_event` shares one kind byte space, and kind 9 stays retired. #[test] fn every_group_event_kind_is_distinct_and_none_reuses_kind_9() { use crate::loot_roll::event_kind as roll; @@ -705,7 +505,6 @@ mod tests { } } - /// cm:Group.h:40-43: a party of 5, a raid of 40 in 8 Subgroups of 5. #[test] fn a_raid_is_eight_subgroups_of_five_and_a_party_caps_at_five() { assert_eq!(GROUP_MAX_MEMBERS, 5); @@ -716,8 +515,6 @@ mod tests { assert_eq!(GroupKind::Raid.member_cap(), 40); } - /// The group-type byte is `GROUP_FLAG_NORMAL = 0`, `GROUP_FLAG_RAID = 1` (cm:Group.h:60-64). It - /// is also the stored column, whose END-appended default 0 must read as a Party. #[test] fn the_group_kind_byte_is_the_vanilla_group_type() { assert_eq!(GroupKind::default(), GroupKind::Party); @@ -728,8 +525,6 @@ mod tests { assert_eq!(GroupKind::from_wire(2), None); } - /// The flags byte is `group | (assistant ? 0x80 : 0)` (cm:Group.cpp:685, 695). The stored - /// column's default 0 is Subgroup 0 without the Assistant flag. #[test] fn a_raid_slot_is_the_vanilla_member_flags_byte() { assert_eq!(RaidSlot::default().wire(), 0); @@ -743,8 +538,6 @@ mod tests { assert!(!RaidSlot::from_wire(0x03).unwrap().is_assistant()); } - /// The slots of a Raid whose Subgroup `n` holds `sizes[n]` members, the first of each an - /// Assistant. fn raid_of(sizes: [u8; 8]) -> Vec { (0u8..8) .flat_map(|subgroup| { @@ -754,8 +547,6 @@ mod tests { .collect() } - /// cm:Group.cpp:817-838: the first Subgroup below 5 members, else no room. An Assistant - /// counts like any member, and the joiner is never an Assistant. #[test] fn a_raid_joiner_takes_the_first_subgroup_with_room() { let joiner = |sizes| RaidSlot::for_raid_joiner(raid_of(sizes)); @@ -781,8 +572,6 @@ mod tests { } } - /// 3, 4: a full destination Subgroup refuses, an out-of-range one refuses, and the - /// Assistant bit survives an accepted move. #[test] fn a_subgroup_move_refuses_a_full_or_out_of_range_destination() { let mover = RaidSlot::new(0, true).unwrap(); @@ -803,16 +592,12 @@ mod tests { ); } - /// cm:Group.cpp:1234: moving to your own Subgroup succeeds and changes nothing, even when that - /// Subgroup is already full. The mover is presumably one of the five already counted there. #[test] fn a_subgroup_move_to_the_same_subgroup_is_a_no_op() { let mover = RaidSlot::new(3, false).unwrap(); assert_eq!(mover.moved_to_subgroup(3, 5), Ok(None)); } - /// 6: a swap between two full Subgroups needs no capacity Gate: the pair trade places - /// atomically, so neither Subgroup ever holds six members mid-swap. #[test] fn a_subgroup_swap_needs_no_capacity_gate() { let first = RaidSlot::new(0, true).unwrap(); @@ -827,7 +612,6 @@ mod tests { ); } - /// 7: cm:GroupHandler.cpp:939: two members already in one Subgroup swap to a no-op. #[test] fn a_subgroup_swap_within_one_subgroup_is_a_no_op() { let first = RaidSlot::new(4, true).unwrap(); @@ -835,8 +619,6 @@ mod tests { assert_eq!(first.swapped_with(second), None); } - /// Pinned against the grammar, not recomputed: kind `1` in the head, and each member's flags - /// byte in decimal, `0x81` = 129 for an Assistant in Subgroup 1. #[test] fn a_raid_roster_encodes_its_kind_and_slots_and_strips_hostile_names() { let roster = RosterPayload { @@ -893,34 +675,29 @@ mod tests { fn roster_decode_fails_closed_on_garbage() { for payload in [ "", - "x,3,2,0,0|1,A,0", // non-numeric leader - "2,3,2,0,0|", // no members - "2|1,A,0", // no loot rules - "2,3,2,0|1,A,1", // the old grammar, which had no kind - "2,3,2,0,2|1,A,0", // unknown kind - "2,3,2,0,0,9|1,A,0", // extra head field - "2,3,2,0,0|x,Bob,0", // non-numeric guid - "2,3,2,0,0|5,Bob", // no slot - "2,3,2,0,0|5", // no name, no slot - "2,3,2,0,0|5,Bob,8", // ninth Subgroup + "x,3,2,0,0|1,A,0", + "2,3,2,0,0|", + "2|1,A,0", + "2,3,2,0|1,A,1", + "2,3,2,0,2|1,A,0", + "2,3,2,0,0,9|1,A,0", + "2,3,2,0,0|x,Bob,0", + "2,3,2,0,0|5,Bob", + "2,3,2,0,0|5", + "2,3,2,0,0|5,Bob,8", "2,3,2,0,0|5,Bob,maybe", - "2,3,2,0,0|5,Bob,0,1", // extra member field - "2,3,2,0,0|5,Bob,0|7", // an icon with no unit - "2,3,2,0,0|5,Bob,0|8,99", // a ninth icon - "2,3,2,0,0|5,Bob,0|7,99;7,98", // one icon twice - "2,3,2,0,0|5,Bob,0|7,99;6,99", // one unit with two icons - "2,3,2,0,0|5,Bob,0|7,0", // a clear icon in a held list - "2,3,2,0,0|5,Bob,0|7,99|6,98", // a fourth segment + "2,3,2,0,0|5,Bob,0,1", + "2,3,2,0,0|5,Bob,0|7", + "2,3,2,0,0|5,Bob,0|8,99", + "2,3,2,0,0|5,Bob,0|7,99;7,98", + "2,3,2,0,0|5,Bob,0|7,99;6,99", + "2,3,2,0,0|5,Bob,0|7,0", + "2,3,2,0,0|5,Bob,0|7,99|6,98", ] { assert_eq!(RosterPayload::decode(payload), None, "{payload:?}"); } } - // ---- Realm-core party ops (group slice) ---- - - /// The intent op byte is a MIGRATION value as well as a wire one: `op` was END-appended to - /// `game_bot_invite_intent` with a `0` default, so every row written before the column existed - /// reads as an INVITE. Renumbering would turn those rows into leaves. #[test] fn bot_group_intent_op_codes_are_stable_and_distinct() { assert_eq!(bot_op::INVITE, 0, "the END-appended column defaults to 0"); @@ -928,9 +705,6 @@ mod tests { assert_ne!(bot_op::INVITE, bot_op::LEAVE); } - /// The op byte is a WIRE value: the gateway sends it, the module dispatches on it, and the two - /// are deployed separately. A renumber that only one side learns about silently runs the wrong - /// op — an ACCEPT arriving as a LEAVE — so the numbering is pinned, not merely defined. #[test] fn realm_group_op_codes_are_stable_and_distinct() { assert_eq!(realm_op::INVITE, 0); @@ -977,7 +751,6 @@ mod tests { ); } - /// Promoting sets only the Assistant bit and demoting clears only it; the Subgroup stays. #[test] fn promoting_or_demoting_keeps_the_subgroup() { let plain = RaidSlot::new(3, false).unwrap(); @@ -986,8 +759,6 @@ mod tests { assert_eq!(plain.with_assistant(false), plain); } - /// The leader announcement is kind 20 and collides with no kind another producer writes to - /// the same table. #[test] fn the_leader_announcement_kind_is_20_and_distinct() { use crate::loot_roll::event_kind as roll; @@ -1017,10 +788,6 @@ mod tests { assert_eq!(GroupRefusal::TargetIsSelf.as_tag(), "group:target_is_self"); } - // ---- Group Broadcasts ---- - - /// Every producer of `game_group_event` shares one kind byte, so a Group Broadcast kind that - /// collided with a membership, loot or quest-share kind would render the wrong packet. #[test] fn group_broadcast_kinds_are_pinned_and_distinct_from_every_other_kind() { use crate::loot_roll::event_kind as roll; @@ -1065,7 +832,6 @@ mod tests { ); } - /// `arg_a` defaults to 0 in every older LEAVE caller, so 0 must stay a plain leave. #[test] fn a_plain_leave_is_cause_zero_and_a_deleted_character_is_one() { assert_eq!(leave_cause::LEFT, 0); @@ -1084,14 +850,12 @@ mod tests { TargetIcon { icon, target_guid } } - /// cm:Group.h:43 holds 8 icons; 0xFF asks for the list (cm:GroupHandler.cpp:457). #[test] fn a_group_holds_eight_target_icons_and_0xff_asks_for_the_list() { assert_eq!(TARGET_ICON_COUNT, 8); assert_eq!(TARGET_ICON_LIST_REQUEST, 0xFF); } - /// Pinned against the grammar: the icon segment follows the members only when there is one. #[test] fn a_party_roster_carries_its_target_icons_after_the_members() { let mut roster = RosterPayload { @@ -1150,7 +914,6 @@ mod tests { } } - /// Decimal text would round a float; bit patterns carry every value the client sent. #[test] fn a_minimap_ping_carries_each_float_bit_for_bit() { assert_eq!(encode_minimap_ping(0.5, -0.25), "1056964608,3196059648"); diff --git a/crates/lyracore-shared/src/guild.rs b/crates/lyracore-shared/src/guild.rs index 66a626ce5..da0e32a8a 100644 --- a/crates/lyracore-shared/src/guild.rs +++ b/crates/lyracore-shared/src/guild.rs @@ -1,9 +1,4 @@ -//! The guild contract both tiers share: Rank Rights bits, the default Guild Ranks, the guild name -//! rule, Guild Event kinds and the typed [`GuildRefusal`]. The Module applies these rules on -//! Realm-core; the Gateway reads the same functions for its advisory checks and renders. - -/// Rank Rights bits as the 1.12 client reads them (`cm:Guild.h:45-63`). Every right except -/// [`EMPTY`] carries the `EMPTY` bit, so test a right with [`super::has_right`], never a bare `&`. +// Every Rank Right except EMPTY includes the EMPTY bit; test rights with has_right. pub mod rights { pub const EMPTY: u32 = 0x40; pub const GCHATLISTEN: u32 = 0x41; @@ -22,19 +17,13 @@ pub mod rights { pub const ALL: u32 = 0xF_F1FF; } -/// Does a Guild Rank with `rank_rights` hold `right`? The shared `EMPTY` bit is masked off first, -/// so a zero mask grants nothing. mangos tests `rights & right` and would grant every right to a -/// mask that carries only `EMPTY`; the client never sends one without other bits. pub fn has_right(rank_rights: u32, right: u32) -> bool { rank_rights & (right & !rights::EMPTY) != 0 } -/// A Guild has five to ten Guild Ranks (`cm:Guild.h:30-31`). pub const MIN_RANKS: usize = 5; pub const MAX_RANKS: usize = 10; -/// The five Guild Ranks a new Guild starts with, highest first (`cm:Guild.cpp:156-165`). The names -/// are the English `mangos_string` rows 811 to 815. pub const DEFAULT_RANKS: [(&str, u32); MIN_RANKS] = [ ("Guild Master", rights::ALL), ("Officer", rights::ALL), @@ -43,32 +32,24 @@ pub const DEFAULT_RANKS: [(&str, u32); MIN_RANKS] = [ ("Initiate", rights::GCHATLISTEN | rights::GCHATSPEAK), ]; -/// The Guild Leader's rank id. pub const LEADER_RANK: u32 = 0; -/// A new Guild's message of the day (`cm:Guild.cpp:123`). pub const DEFAULT_MOTD: &str = "No message set."; -/// Guild name length in Unicode scalars. +// Name lengths count Unicode scalars. pub const MIN_GUILD_NAME: usize = 2; pub const MAX_GUILD_NAME: usize = 24; -/// Length caps for the rest of a Guild's client-supplied text, in Unicode scalars -/// (`vm:src/game/Guild/Guild.h:36-40`). Over-length input is a [`GuildRefusal::TooLong`]. +// Text lengths count Unicode scalars, vm:src/game/Guild/Guild.h:36-40. pub const MAX_RANK_NAME: usize = 15; pub const MAX_NOTE: usize = 31; pub const MAX_INFO: usize = 500; pub const MAX_MOTD: usize = 128; -/// Does `text` fit within `max` Unicode scalars? Shared by every guild text field length gate. pub fn fits_length(text: &str, max: usize) -> bool { text.chars().count() <= max } -/// Guild name rule (`cm:ObjectMgr.cpp:8128-8139,8224-8240`): 2 to 24 Unicode scalars of digits, -/// spaces and letters of ONE script, as mangos `isValidString` checks a Charter name, so a -/// look-alike such as a Cyrillic "К" in a Latin name is refused. Spaces only separate words: no -/// leading, trailing or repeated space, so "Knights" and "Knights " cannot both exist. pub fn validate_guild_name(name: &str) -> Result<(), GuildRefusal> { let length = name.chars().count(); let spaced_once = !name.starts_with(' ') && !name.ends_with(' ') && !name.contains(" "); @@ -85,13 +66,10 @@ pub fn validate_guild_name(name: &str) -> Result<(), GuildRefusal> { } } -/// The letter sets mangos accepts in a name (`cm:Util.h:172-227`). #[derive(Clone, Copy)] enum Script { - /// Basic Latin plus the Latin-1 and Latin Extended-A letters mangos lists. Latin, Cyrillic, - /// Hangul, kana, CJK ideographs and the halfwidth and fullwidth forms. EastAsian, } @@ -127,9 +105,6 @@ impl Script { } } -/// The founding Gates, in the order `.guild create` reports them: a valid name, then a free name, -/// then a leader outside every Guild. Answers the [`name_key`] to store. The Module runs this on -/// Realm-core; a Gateway Fake runs the same function, so neither can drift from the other. pub fn founding_gate( name: &str, name_taken: impl FnOnce(&str) -> bool, @@ -146,19 +121,11 @@ pub fn founding_gate( Ok(key) } -/// The uniqueness key of a guild name. Two names that differ only by case collide, so "Knights" -/// and "knights" cannot both exist. pub fn name_key(name: &str) -> String { name.to_lowercase() } -/// `game_guild_event.kind`: what the Gateway renders for one Guild Event row. -/// -/// `0..=13` are broadcast kinds and equal the wire `GuildEvent` byte of SMSG_GUILD_EVENT -/// (`cm:Guild.h:100-116`). Addressed kinds start at `0x40` and are reserved in blocks: `0x40..=0x4F` -/// membership, `0x50..=0x5F` settings, `0x60..=0x6F` fees, `0x70..=0x7F` petitions. `0x80..=0x8F` -/// are broadcast kinds that only the Gateway renders. A petition kind carries `guild_id` 0, except -/// FOUNDER, which names the new Guild. +// Kinds 0..=13 match GuildEvent wire bytes; addressed kinds start at 0x40. pub mod event_kind { pub const PROMOTION: u8 = 0; pub const DEMOTION: u8 = 1; @@ -172,131 +139,72 @@ pub mod event_kind { pub const TABARD_CHANGED: u8 = 9; pub const SIGNED_ON: u8 = 12; pub const SIGNED_OFF: u8 = 13; - /// Addressed to an invite target: SMSG_GUILD_INVITE, not the generic SMSG_GUILD_EVENT builder. pub const INVITE: u8 = 0x40; - /// Addressed to an inviter: raw SMSG_GUILD_DECLINE, not the generic SMSG_GUILD_EVENT builder. pub const DECLINE: u8 = 0x41; - /// Addressed to the editor after a note edit: a fresh SMSG_GUILD_ROSTER for that viewer alone. pub const ROSTER_TO_ACTOR: u8 = 0x50; - /// Broadcast after a rank edit, add or delete: SMSG_GUILD_QUERY_RESPONSE then a per-viewer - /// SMSG_GUILD_ROSTER to every online member, each built for that viewer's own Guild Rank, so - /// officer notes keep following each viewer's VIEWOFFNOTE right instead of going blank for - /// everyone the way mangos' single shared broadcast roster does. + // Render each roster for its viewer so VIEWOFFNOTE controls officer-note disclosure. pub const ROSTER_REFRESH: u8 = 0x80; - /// Addressed to the offer's target: SMSG_PETITION_SHOW_SIGNATURES for the Guild Charter in - /// `other_guid`, read when the job runs. pub const PETITION_OFFERED: u8 = 0x70; - /// Addressed to the Petition owner and to the signer: SMSG_PETITION_SIGN_RESULTS OK for the - /// Guild Charter in `other_guid`, signed by `subject_guid`. pub const PETITION_SIGNED: u8 = 0x71; - /// Addressed to the Petition owner and to the signer: SMSG_PETITION_SIGN_RESULTS - /// ALREADY_SIGNED, when the signer's Realm Account already signed. pub const PETITION_ALREADY_SIGNED: u8 = 0x72; - /// Addressed to the Petition owner: MSG_PETITION_DECLINE naming the decliner, `subject_guid`. pub const PETITION_DECLINED: u8 = 0x73; - /// Addressed to each signer that joined at founding: SMSG_GUILD_COMMAND_RESULT FOUNDER with the - /// Guild name. pub const FOUNDER: u8 = 0x74; - /// Addressed to the Petition owner after a Signature is lost: SMSG_PETITION_QUERY_RESPONSE for - /// the Guild Charter in `other_guid`, read when the job runs. pub const PETITION_CHANGED: u8 = 0x75; - /// Is `kind` in the petition block? Every petition kind is addressed. pub fn is_petition(kind: u8) -> bool { (0x70..=0x7F).contains(&kind) } } -/// `game_guild_fee_hold.kind`: which guild operation a Fee Hold pays for. pub mod fee_kind { pub const EMBLEM: u8 = 1; pub const CHARTER: u8 = 2; } -/// A Guild Emblem costs 10 gold (`cm:GuildHandler.cpp:750-757`). pub const EMBLEM_COST_COPPER: u32 = 100_000; -/// The Guild Charter item, its display and its price (`cm:PetitionsHandler.cpp:39-42`). pub const GUILD_CHARTER_ENTRY: u32 = 5863; pub const CHARTER_DISPLAY_ID: u32 = 16161; pub const CHARTER_COST_COPPER: u32 = 1000; -/// Signatures a Petition needs to found a Guild, and the most it takes. Vanilla asks for nine -/// (`cm:World.cpp:589`); the client signs nine at most (`cm:PetitionsHandler.cpp:371`). pub const MIN_PETITION_SIGNATURES: usize = 9; pub const MAX_PETITION_SIGNATURES: usize = 9; -/// The key of the Signature in `slot` (`0..MAX_PETITION_SIGNATURES`) of Petition `petition_id`. -/// The Gateway's cache has no index on the Petition column, so it finds a Petition's Signatures by -/// these keys: at most nine keyed lookups instead of a scan of every Signature in the realm. pub fn petition_signature_key(petition_id: u32, slot: usize) -> u64 { (u64::from(petition_id) << 8) | (slot as u64 & 0xFF) } -/// Why the Module refused a guild Durable Request. The tag is the whole reducer error text, so -/// neither tier matches on human prose. A Refusal leaves every guild row unchanged. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GuildRefusal { - /// The acting Character has GM level 0. NotGameMaster, - /// The guild name breaks [`validate_guild_name`]. NameInvalid, - /// Another Guild holds the same [`name_key`]. NameExists, - /// The Character is already a member of a Guild. AlreadyInGuild, - /// The acting Character is in no Guild. NotInGuild, - /// The request named no Character. NoSuchCharacter, - /// The actor's Guild Rank lacks the Rank Right the op needs. NoPermission, - /// The named Character is not a member of the actor's Guild. TargetNotInGuild, - /// The op needs the Guild Leader. NotLeader, - /// The purse holds less than the guild fee. NotEnoughMoney, - /// The named NPC does not serve this guild operation, or not to this Character here. NpcRefused, - /// An Invite's team does not match the target's, or an Accept's team does not match the - /// Guild's. NotAllied, - /// The target already holds a pending Guild Invite. AlreadyInvited, - /// The actor holds no pending Guild Invite, or is already a member. NoPendingInvite, - /// The Guild Leader must pass leadership before leaving, or cannot be removed. LeaderCannotLeave, - /// The actor's Guild Rank cannot reach the target's. RankTooHigh, - /// The target already holds the Guild's lowest Guild Rank. RankTooLow, - /// The op named the actor as its own target. TargetIsSelf, - /// A text field (MOTD, info text, a note or a rank name) breaks its length cap. TooLong, - /// Add Rank at [`MAX_RANKS`], or Delete Rank at [`MIN_RANKS`]. RanksAtLimit, - /// The Character already owns an open Petition. AlreadyHasPetition, - /// No open Petition belongs to the named Guild Charter. NoSuchPetition, - /// The actor does not own the Petition. NotPetitionOwner, - /// A Petition owner cannot sign its own Petition. CantSignOwn, - /// The Petition holds [`MAX_PETITION_SIGNATURES`] Signatures. PetitionFull, - /// The Petition holds fewer than [`MIN_PETITION_SIGNATURES`] Signatures. NeedMoreSignatures, - /// The Guild Charter item template is missing from this World Shard. CharterUnavailable, - /// No free bag slot for the Guild Charter. BagsFull, - /// The payer already holds as many Guild Charters as the item allows. CharterLimit, - /// The signer's Realm Account is unknown, so one Signature per Account cannot hold. UnknownRealmAccount, } @@ -401,12 +309,10 @@ mod tests { "Tracer-Guild", "The_Guild", "Guild\tName", - // A Cyrillic capital Ka in front of Latin letters. "\u{41A}nights", "Knights ", " Knights", "Knights Two", - // The oe ligature is past the Latin letters mangos lists. "Ordre du Cœur", "Ιππότες", ] { diff --git a/crates/lyracore-shared/src/instance.rs b/crates/lyracore-shared/src/instance.rs index cd85b523d..6c58be7f0 100644 --- a/crates/lyracore-shared/src/instance.rs +++ b/crates/lyracore-shared/src/instance.rs @@ -1,7 +1,3 @@ -//! Shared vanilla dungeon classification and Instance Removal countdown. - -/// Vanilla dungeon and raid maps. Battlegrounds use a separate population lifecycle. -/// The client Map.dbc instance types and ClassicDB instance_template identify this set. pub const DUNGEON_MAPS: &[u32] = &[ 33, 34, 36, 43, 47, 48, 70, 90, 109, 129, 189, 209, 229, 230, 249, 289, 309, 329, 349, 389, 409, 429, 469, 509, 531, 533, @@ -11,17 +7,12 @@ pub fn is_dungeon_map(map_id: u32) -> bool { DUNGEON_MAPS.contains(&map_id) } -/// Source spawns on these maps remain dormant until their instance creates a population. -/// Battleground populations stay dormant until battleground creation is implemented. -/// Shared interiors, including the Deeprun Tram and faction halls, use instance zero. pub const BATTLEGROUND_MAPS: &[u32] = &[30, 489, 529]; pub fn has_instance_population(map_id: u32) -> bool { is_dungeon_map(map_id) || BATTLEGROUND_MAPS.contains(&map_id) } -/// The Instance Removal countdown in milliseconds (cm:Player.cpp:17723). The Module schedules the -/// expiry this far out, and the Gateway never shows the client more time than this. pub const INSTANCE_REMOVAL_MS: u32 = 60_000; #[cfg(test)] @@ -45,9 +36,6 @@ mod tests { assert!(!is_dungeon_map(1), "Kalimdor is open world"); } - /// The set is what the gateway's hosting check iterates. An EMPTY set makes that check - /// vacuous — it would pass for every configuration, including the one that files eight leases - /// with 0 entities — so "there is at least one dungeon to check" is itself the invariant. #[test] fn the_dungeon_set_is_never_empty() { assert!( diff --git a/crates/lyracore-shared/src/item.rs b/crates/lyracore-shared/src/item.rs index d83768e84..7b98d3a49 100644 --- a/crates/lyracore-shared/src/item.rs +++ b/crates/lyracore-shared/src/item.rs @@ -1,5 +1,3 @@ -//! Item rules shared by the module and gateway. - pub const fn is_carried_slot(slot: u8) -> bool { slot <= 38 || (slot >= 120 && slot <= 191) } @@ -12,7 +10,7 @@ pub fn carried_quantity(entry: u32, items: impl IntoIterator( items: impl IntoIterator, fields: impl Fn(&T) -> (u8, u32, Option), @@ -31,38 +29,21 @@ pub const fn grants_equipped_stats(slot: u8, max_durability: u32, durability: u3 slot <= 18 && !(max_durability > 0 && durability == 0) } -/// Why the Module refused an item Durable Request. The tag is the whole reducer error text, so -/// neither tier matches on human prose; the detail stays in the Module log. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ItemRefusal { - /// The named slot holds no item, or the item has no template. ItemNotFound, - /// No free backpack, bag, or bag-equip slot is left to receive the item. InventoryFull, - /// The destination slot is out of range, is not an equipment slot, or is already taken. WrongSlot, - /// The item cannot go on the body: the wrong slot family for its type, or a required level - /// the Character has not reached. CannotEquip, - /// The Character's class, race, or armor and weapon proficiency does not cover the item. NoProficiency, - /// The item needs a trained skill rank the Character does not hold. RequiredSkill, - /// The item needs a reputation standing the Character has not earned. RequiredReputation, - /// A dead Character cannot act on its inventory. PlayerDead, - /// The action reaches bank space with no banker in range. BankUnavailable, - /// The item carries no on-use spell, sits in the bank, or restores a power the class lacks. ItemNotUsable, - /// The action is blocked for now: a live bandage cooldown, or a cast Gate. NotRightNow, - /// The item template forbids destruction. Indestructible, - /// An equipped bag still contains items. BagNotEmpty, - /// The Module could not act and has no gameplay reason to name. Internal, } @@ -119,7 +100,6 @@ pub mod armor_subclass { pub const SHIELD: u8 = 6; } -/// `item_template.class` values this rule branches on (vanilla `ItemClass`). pub mod item_class { pub const WEAPON: u8 = 2; pub const ARMOR: u8 = 4; @@ -144,13 +124,6 @@ pub mod weapon_subclass { pub const FISHING_POLE: u8 = 20; } -/// What a Character may equip: the class weapon table plus the armor tiers it has actually reached. -/// Armor is class base set + the two upgrades a class trainer teaches at level 40 (Plate Mail, and -/// the Hunter/Shaman Mail upgrade), so a level-1 Warrior wears mail but not plate. -/// -/// Derive it once, then read it from either side: the Module equip Gate calls [`Proficiency::can_equip`], -/// the Gateway sends [`Proficiency::armor_subclass_mask`] in `SMSG_SET_PROFICIENCY`. One derivation -/// keeps the Refusal and the client's tint from drifting apart. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Proficiency { player_class: u8, @@ -158,19 +131,16 @@ pub struct Proficiency { } impl Proficiency { - /// `knows_plate` is the passive spell 750, `knows_mail_upgrade` the passive 8737 - /// (`constants::armor_proficiency`). A flag set for a class that cannot train that upgrade adds - /// nothing — a Mage holding 750 still wears cloth. An unknown class gets misc only. pub const fn derive(player_class: u8, knows_plate: bool, knows_mail_upgrade: bool) -> Self { use armor_subclass::*; let cloth = bit(MISC) | bit(CLOTH); let mut mask = match player_class { - 1 | 2 => cloth | bit(LEATHER) | bit(MAIL) | bit(SHIELD), // Warrior, Paladin - 3 | 4 | 11 => cloth | bit(LEATHER), // Hunter, Rogue, Druid - 7 => cloth | bit(LEATHER) | bit(SHIELD), // Shaman - 5 | 8 | 9 => cloth, // Priest, Mage, Warlock - _ => bit(MISC), // misc (rings, cloaks, trinkets) is never gated for anyone + 1 | 2 => cloth | bit(LEATHER) | bit(MAIL) | bit(SHIELD), + 3 | 4 | 11 => cloth | bit(LEATHER), + 7 => cloth | bit(LEATHER) | bit(SHIELD), + 5 | 8 | 9 => cloth, + _ => bit(MISC), }; if knows_plate && matches!(player_class, 1 | 2) { mask |= bit(PLATE); @@ -184,10 +154,6 @@ impl Proficiency { } } - /// Derive from a Character's spellbook: `knows` answers whether a spell id is in it. The Module - /// equip Gate asks its `game_player_spell` index, the Gateway asks the learned-spell list it - /// already gathered for the login batch. Which passives mean which tier is named HERE and only - /// here, so neither side can read a different spell than the other. pub fn from_spellbook(player_class: u8, knows: impl Fn(u32) -> bool) -> Self { use crate::constants::armor_proficiency::{MAIL_PASSIVE_SPELL_ID, PLATE_PASSIVE_SPELL_ID}; @@ -198,20 +164,16 @@ impl Proficiency { ) } - /// Every armor tier the class can ever reach, both upgrades included. The auction "usable" - /// filter shows a browser what its class could wear; the equip Gate must use - /// [`Self::from_spellbook`] with the Character's own spellbook instead. + // Auction filtering uses the class ceiling; equipping must use the learned passives. pub const fn class_ceiling(player_class: u8) -> Self { Self::derive(player_class, true, true) } - /// Armor subclasses as a `1 << subclass` bitmask — the `SMSG_SET_PROFICIENCY` payload shape. + // SMSG_SET_PROFICIENCY encodes armor subclasses as 1 << subclass. pub const fn armor_subclass_mask(self) -> u32 { self.armor_subclass_mask } - /// Whether this Character may equip an item of `item_class`/`item_subclass`. Item classes other - /// than armor and weapon carry no proficiency restriction. pub const fn can_equip(self, item_class: u8, item_subclass: u8) -> bool { match item_class { item_class::ARMOR => self.allows_armor(item_subclass), @@ -220,8 +182,6 @@ impl Proficiency { } } - /// Whether one armor subclass is covered. A subclass past the mask's width cannot be - /// represented, so it fails closed. pub const fn allows_armor(self, subclass: u8) -> bool { subclass < u32::BITS as u8 && self.armor_subclass_mask & bit(subclass) != 0 } @@ -312,7 +272,6 @@ mod tests { assert_eq!(loaded_ammo(items, |item| *item), Some((120, 5, Some(6)))); } - /// The nine playable classes (6 and 10 are unused in 1.12). const PLAYABLE: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11]; #[test] @@ -327,29 +286,25 @@ mod tests { ); } - /// What a class can equip with both level-40 upgrades trained — the auction "usable" filter's - /// question, and the shape the pre-training class table was written in. fn ceiling_can_equip(player_class: u8, item_class: u8, item_subclass: u8) -> bool { Proficiency::class_ceiling(player_class).can_equip(item_class, item_subclass) } - /// The armor subclasses a derivation admits, in ascending order. fn armor_set(proficiency: Proficiency) -> Vec { (0..=SHIELD) .filter(|&subclass| proficiency.allows_armor(subclass)) .collect() } - /// Base sets are class-static: nothing is granted at creation, and no upgrade is trained yet. #[test] fn untrained_classes_wear_their_vanilla_base_set() { let base = |class| armor_set(Proficiency::derive(class, false, false)); - assert_eq!(base(1), vec![MISC, CLOTH, LEATHER, MAIL, SHIELD]); // Warrior - assert_eq!(base(2), vec![MISC, CLOTH, LEATHER, MAIL, SHIELD]); // Paladin - assert_eq!(base(3), vec![MISC, CLOTH, LEATHER]); // Hunter - assert_eq!(base(4), vec![MISC, CLOTH, LEATHER]); // Rogue - assert_eq!(base(11), vec![MISC, CLOTH, LEATHER]); // Druid - assert_eq!(base(7), vec![MISC, CLOTH, LEATHER, SHIELD]); // Shaman + assert_eq!(base(1), vec![MISC, CLOTH, LEATHER, MAIL, SHIELD]); + assert_eq!(base(2), vec![MISC, CLOTH, LEATHER, MAIL, SHIELD]); + assert_eq!(base(3), vec![MISC, CLOTH, LEATHER]); + assert_eq!(base(4), vec![MISC, CLOTH, LEATHER]); + assert_eq!(base(11), vec![MISC, CLOTH, LEATHER]); + assert_eq!(base(7), vec![MISC, CLOTH, LEATHER, SHIELD]); for caster in [5u8, 8, 9] { assert_eq!( base(caster), @@ -359,7 +314,6 @@ mod tests { } } - /// Plate is the level-40 Warrior/Paladin purchase, and nobody else's. #[test] fn the_plate_upgrade_only_moves_warriors_and_paladins() { for class in PLAYABLE { @@ -384,7 +338,6 @@ mod tests { } } - /// Mail is the level-40 Hunter/Shaman purchase. Warriors and Paladins already have it. #[test] fn the_mail_upgrade_only_moves_hunters_and_shamans() { for class in PLAYABLE { @@ -409,7 +362,6 @@ mod tests { } } - /// A learned passive is not a wildcard: the class still decides what the spell unlocks. #[test] fn a_caster_holding_both_passives_still_wears_cloth() { let mage = Proficiency::derive(8, true, true); @@ -418,8 +370,6 @@ mod tests { assert!(!mage.can_equip(item_class::ARMOR, MAIL)); } - /// Fail closed: a class the model does not know wears accessories only, and a subclass outside - /// the mask's width is refused rather than shifted out of range. #[test] fn unknown_classes_and_subclasses_fail_closed() { for unknown in [0u8, 6, 10, 12, 255] { @@ -439,9 +389,6 @@ mod tests { } } - /// The Gateway sends the mask, the Module answers per subclass. If those two disagree, the - /// client tints an item usable that the equip Gate then refuses — the exact drift one shared - /// derivation exists to prevent. #[test] fn the_wire_mask_agrees_with_the_gate_for_every_class_and_flag() { for class in PLAYABLE { @@ -461,7 +408,6 @@ mod tests { } } - /// Weapons come from the static class table, untouched by armor training. #[test] fn armor_training_never_changes_the_weapon_table() { for class in PLAYABLE { @@ -476,8 +422,6 @@ mod tests { } } - /// The class ceiling is what a Character reaches with everything trained — the auction filter's - /// question. #[test] fn the_class_ceiling_is_the_fully_trained_derivation() { for class in PLAYABLE { @@ -499,8 +443,6 @@ mod tests { assert!(ceiling_can_equip(8, 7, 0)); } - /// The spellbook reader turns the two trained passives into the two flags, and reads nothing - /// else. A Character's spellbook holds hundreds of spells; only these two move armor. #[test] fn the_spellbook_reader_maps_the_two_passives_and_ignores_the_rest() { use crate::constants::armor_proficiency::{MAIL_PASSIVE_SPELL_ID, PLATE_PASSIVE_SPELL_ID}; @@ -523,7 +465,6 @@ mod tests { from(class, &[PLATE_PASSIVE_SPELL_ID, MAIL_PASSIVE_SPELL_ID]), Proficiency::class_ceiling(class) ); - // The trainer wrappers are what a Character BUYS; the passive they teach is what counts. assert_eq!( from(class, &[7109, 8738, 674, 201]), Proficiency::derive(class, false, false), diff --git a/crates/lyracore-shared/src/item_property.rs b/crates/lyracore-shared/src/item_property.rs index 0fd4447a7..d86856672 100644 --- a/crates/lyracore-shared/src/item_property.rs +++ b/crates/lyracore-shared/src/item_property.rs @@ -1,5 +1,3 @@ -//! Append-only Stat Kind codes used by item catalogue authors and the importer. - pub const UNKNOWN: u8 = 0; pub const STRENGTH: u8 = 1; pub const AGILITY: u8 = 2; @@ -62,15 +60,10 @@ pub fn equipped_stat(items: impl IntoIterator) -> i3 .fold(0i32, |sum, (_, _, _, amount)| sum.saturating_add(amount)) } -/// Authored compatibility entries as `(stored, client, kind, amount)`. Stored items and synthetic -/// enchanting spells use the stored ID, which the build 5875 client catalogue lacks. The Gateway -/// sends the client ID instead: the Enchanting result with the same Stat Kind and amount. Random -/// Properties own 70 and 73, which show the same text. +// Compatibility tuples are stored id, client id, Stat Kind, amount; build 5875 lacks stored ids. pub const COMPATIBILITY_ENCHANTMENTS: [(u32, u32, u8, i32); 2] = [(7745, 823, STRENGTH, 3), (7748, 724, STAMINA, 3)]; -/// The enchantment ID the client resolves for a stored enchantment ID. Every ID outside -/// `COMPATIBILITY_ENCHANTMENTS`, including 0, passes through. Stored IDs never change. pub fn client_enchantment_id(stored: u32) -> u32 { COMPATIBILITY_ENCHANTMENTS .iter() diff --git a/crates/lyracore-shared/src/lib.rs b/crates/lyracore-shared/src/lib.rs index a99418dbb..2e2996780 100644 --- a/crates/lyracore-shared/src/lib.rs +++ b/crates/lyracore-shared/src/lib.rs @@ -1,12 +1,3 @@ -//! Protocol-agnostic constants and helpers shared by the SpacetimeDB module and the -//! gateway. No external dependencies, no IO — safe to compile into the wasm module and the -//! native gateway alike. -//! -//! This crate deliberately holds only *values and pure functions* (opcode numbers, -//! bit-packing, spatial math). The byte-level (de)serialization lives in the gateway via -//! `wow_world_messages` (which owns the descriptor field indices); the table/reducer logic -//! lives in the module. See `docs/` for the design these constants implement. - pub mod auction; pub mod aura; pub mod bank; @@ -42,16 +33,10 @@ pub mod transfer; pub mod values_mask; pub mod vmap; -/// The exact error text the module's `start_attack` reducer returns when the target is a corpse. -/// The gateway pattern-matches this specific failure to reply `SMSG_ATTACKSWING_DEADTARGET` (so the -/// client leaves combat stance with proper feedback), while other failures stay silently ignored. -/// Shared here so the producer (module) and matcher (gateway) cannot drift out of sync. +// The Gateway maps this exact text to SMSG_ATTACKSWING_DEADTARGET. pub const ERR_ATTACK_TARGET_DEAD: &str = "target is dead"; -/// The exact error text `start_attack` returns when the target is FRIENDLY to the attacker (the -/// faction gate — neutral + hostile targets are attackable; only friendly are protected). The gateway -/// maps it to `SMSG_ATTACKSWING_CANT_ATTACK` so the client leaves stance cleanly. Shared so producer -/// (module) and matcher (gateway) can't drift. +// The Gateway maps this exact text to SMSG_ATTACKSWING_CANT_ATTACK. pub const ERR_ATTACK_FRIENDLY: &str = "target is friendly"; pub mod item_property; diff --git a/crates/lyracore-shared/src/loot.rs b/crates/lyracore-shared/src/loot.rs index 109641e12..3cd770a15 100644 --- a/crates/lyracore-shared/src/loot.rs +++ b/crates/lyracore-shared/src/loot.rs @@ -1,26 +1,13 @@ -//! The loot tier contract shared by the Module and the Gateway. - -/// Why the Module refused a loot Durable Request. The tag is the whole reducer error text, so -/// neither tier matches on human prose. The Module keeps the detail in its own log. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LootRefusal { - /// The Actor is not in the Loot Source's Loot Tag eligibility set. LootTagIneligible, - /// The Loot Source is on another map, in another instance, or beyond loot range. OutOfRange, - /// The named Loot Source does not exist, or is not a lootable creature corpse. NoLootSource, - /// The looter is not in the world, or is dead. LooterUnavailable, - /// The Loot Source has nothing left in the requested slot or purse. NothingToLoot, - /// No open Loot Roll answers this vote, or the voter already voted. RollUnavailable, - /// The Actor does not hold the master-looter right on that row. NotMasterLooter, - /// The master-loot recipient is no longer in the world. RecipientUnavailable, - /// The master-loot recipient has no room for the item. RecipientInventoryFull, } @@ -58,9 +45,6 @@ impl LootRefusal { } } -/// A failure in the trusted Gateway entry before a loot core can answer the request. These tags -/// cross the reducer boundary so legacy untagged gameplay compatibility cannot hide a broken -/// Operator or Actor invariant. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LootBoundaryFailure { OperatorRejected, @@ -116,7 +100,6 @@ mod tests { } } -/// A reservation is the grant; only an unreserved quest row requires current need. pub fn quest_take_allowed(reserved_for: u64, taker_guid: u64, taker_needs_item: bool) -> bool { reserved_for == taker_guid || (reserved_for == 0 && taker_needs_item) } @@ -146,7 +129,6 @@ pub struct QuestItemObjective { pub required_count: u32, } -/// Decide item need from active quests, objective kinds, quantities and Raid membership. pub fn needs_quest_item( in_raid: bool, active_quests: &[u32], diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index 3097fd447..d95407bf9 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -1,66 +1,26 @@ -//! Shared loot-roll, master-loot, and money-share wire codes. -//! Client transitions use the per-recipient `game_group_event` relay. -//! Realm-core owns roll votes; World Shards stage new rolls and settle the winning item. -//! See `docs/realm-loot-routing.md` for ordering across databases. +//! Before changing Loot Roll promotion or voting, read docs/realm-loot-routing.md. -/// Kinds carried in `game_group_event.kind` for a loot-roll/master-loot/money-share notification -/// Reserved range `4..=8`, see -/// `crate::group::event_kind::LOOT_ROLL_RESERVED_START`. pub mod event_kind { - /// A need/greed/pass window opened for one item → `SMSG_LOOT_START_ROLL`. pub const ROLL_START: u8 = 4; - /// A member's vote landed (server already rolled 1-100 for a need/greed vote) → `SMSG_LOOT_ROLL`, - /// relayed to every eligible member so the live vote/roll numbers are visible party-wide. pub const ROLL_VOTE: u8 = 5; - /// The roll resolved to a winner → `SMSG_LOOT_ROLL_WON`. Never fired for an all-pass resolution - /// (the row just unlocks FFA-in-group; vanilla shows no "won" line when everyone passes). pub const ROLL_WON: u8 = 6; - /// The master looter's eligible-recipient list for a just-appeared above-threshold row → - /// `SMSG_LOOT_MASTER_LIST`. Sent to the master only. pub const MASTER_LIST: u8 = 7; - /// a grouped money-loot split resolved, this recipient's copper share → - /// `SMSG_LOOT_MONEY_NOTIFY(share)`. Next free kind after `MASTER_LIST`; reuses the SAME - /// per-recipient relay (one recipient, a kind byte, a small payload) rather than a new gateway- - /// subscribed table, for the identical reasons the roll/master-loot kinds above do. Pushed to - /// EVERY recipient of a grouped split, including the looter itself (vanilla shows the looter its - /// own "Your share of the loot is X" line too, not the solo "You loot X copper" line) — a SOLO - /// money loot never pushes this kind at all (no `game_corpse_loot_eligible` snapshot for that - /// corpse), so the gateway sends no notify and the client's own local "You loot" line is the only - /// feedback. pub const MONEY_SHARE: u8 = 8; } -/// The REALM-CORE loot-roll ops: the `op` byte of the operator-gated `realm_loot_op` -/// reducer, mirroring `crate::group::realm_op`'s one-reducer-not-several trade (see that module's -/// doc for why: a hand-maintained gateway binding per reducer, `docs/danger-zones.md` §1.2). -/// -/// Argument slots (`realm_loot_op(op, corpse_guid, slot, item_entry, actor_guid, vote, -/// deadline_micros, recipients)`), per op: -/// - [`START`] — `corpse_guid`/`slot`/`item_entry`/`deadline_micros`/`recipients` carry the roll a -/// world shard already announced locally (`item_entry`/`deadline_micros` preserved so promotion -/// never restarts the 60s clock); `actor_guid`/`vote` unused. -/// - [`VOTE`] — `corpse_guid`/`slot`/`vote` as `CMSG_LOOT_ROLL` sends them; `actor_guid` is the -/// voter (the gateway's own `InWorld::self_guid`, never a client-supplied literal); `item_entry`/ -/// `deadline_micros`/`recipients` unused. +// START preserves the World Shard deadline so promotion does not restart the roll clock. pub mod loot_op { - /// Promote a world shard's freshly-created roll onto realm-core. pub const START: u8 = 0; - /// `CMSG_LOOT_ROLL` — `actor_guid` casts `vote` on the roll open at `(corpse_guid, slot)`. pub const VOTE: u8 = 1; } -/// Vote kinds (`game_loot_roll_vote.vote`) — matches the vanilla wire `RollVote` enum EXACTLY -/// (`Pass=0, Need=1, Greed=2`; `Disenchant=3` unused here) so a vote byte from `CMSG_LOOT_ROLL` -/// (gateway-decoded via `RollVote::as_int()`) round-trips to the module with ZERO translation table — -/// avoiding the silent-bug shape in this codebase: an invented enum ordering that doesn't match -/// the wire. +// RollVote wire values: Pass = 0, Need = 1, Greed = 2. pub mod vote_kind { pub const PASS: u8 = 0; pub const NEED: u8 = 1; pub const GREED: u8 = 2; } -/// Encode a `ROLL_START` payload: `corpse_guid,slot,item_entry,countdown_ms`. pub fn encode_start( corpse_guid: u64, slot: u8, @@ -71,7 +31,6 @@ pub fn encode_start( format!("{corpse_guid},{slot},{item_entry},{countdown_ms},{random_property_id}") } -/// Decode a `ROLL_START` payload back to `(corpse_guid, slot, item_entry, countdown_ms)`. pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; @@ -80,7 +39,7 @@ pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32, u32)> { let countdown_ms: u32 = p.next()?.parse().ok()?; let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0); if p.next().is_some() { - return None; // trailing garbage — fail closed + return None; } Some(( corpse_guid, @@ -91,9 +50,7 @@ pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32, u32)> { )) } -/// Encode a `ROLL_VOTE` payload: `corpse_guid,slot,item_entry,roll_number,vote_kind,auto_pass`. -/// The voter's own guid rides `GroupEvent.other_guid` (already resolved/looked-up by `push_event`), -/// not the payload — mirrors INVITE/DECLINE's convention. +// The voter guid is GroupEvent.other_guid, outside the payload. pub fn encode_vote( corpse_guid: u64, slot: u8, @@ -109,7 +66,6 @@ pub fn encode_vote( ) } -/// Decode a `ROLL_VOTE` payload back to `(corpse_guid, slot, item_entry, roll_number, vote_kind, auto_pass)`. pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; @@ -144,7 +100,6 @@ pub fn encode_won( format!("{corpse_guid},{slot},{item_entry},{winning_roll},{winning_vote},{random_property_id}") } -/// Decode a `ROLL_WON` payload back to `(corpse_guid, slot, item_entry, winning_roll, winning_vote)`. pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; @@ -166,13 +121,11 @@ pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { )) } -/// Encode a `MASTER_LIST` payload: `corpse_guid|guid,guid...` (the eligible-recipient set). pub fn encode_master_list(corpse_guid: u64, eligible: &[u64]) -> String { let guids: Vec = eligible.iter().map(|g| g.to_string()).collect(); format!("{corpse_guid}|{}", guids.join(",")) } -/// Decode a `MASTER_LIST` payload back to `(corpse_guid, [eligible guids])`. pub fn decode_master_list(payload: &str) -> Option<(u64, Vec)> { let (corpse_guid, rest) = payload.split_once('|')?; let corpse_guid: u64 = corpse_guid.parse().ok()?; @@ -183,13 +136,10 @@ pub fn decode_master_list(payload: &str) -> Option<(u64, Vec)> { (!guids.is_empty()).then_some((corpse_guid, guids)) } -/// Encode a `MONEY_SHARE` payload: just the recipient's copper share — `SMSG_LOOT_MONEY_NOTIFY` -/// carries no other field, unlike the roll payloads above (no corpse/slot/item to round-trip). pub fn encode_money_share(amount: u32) -> String { amount.to_string() } -/// Decode a `MONEY_SHARE` payload back to the recipient's copper share. pub fn decode_money_share(payload: &str) -> Option { payload.parse().ok() } @@ -224,8 +174,8 @@ mod tests { #[test] fn start_fails_closed_on_garbage() { assert!(decode_start("").is_none()); - assert!(decode_start("1,2,3").is_none()); // missing countdown - assert!(decode_start("1,2,3,4,5,6").is_none()); // trailing garbage + assert!(decode_start("1,2,3").is_none()); + assert!(decode_start("1,2,3,4,5,6").is_none()); assert!(decode_start("x,2,3,4").is_none()); } @@ -245,8 +195,6 @@ mod tests { #[test] fn vote_kind_matches_the_real_wire_rollvote_ordering() { - // RollVote::Pass=0, Need=1, Greed=2 (wow_world_base) — a mismatch here would silently - // misclassify every vote decoded from CMSG_LOOT_ROLL. assert_eq!(vote_kind::PASS, 0); assert_eq!(vote_kind::NEED, 1); assert_eq!(vote_kind::GREED, 2); @@ -270,7 +218,7 @@ mod tests { #[test] fn master_list_fails_closed_on_garbage() { assert!(decode_master_list("").is_none()); - assert!(decode_master_list("555|").is_none()); // no eligible guids at all + assert!(decode_master_list("555|").is_none()); assert!(decode_master_list("555|1,x,3").is_none()); } @@ -285,9 +233,6 @@ mod tests { assert_eq!(event_kind::MONEY_SHARE, event_kind::MASTER_LIST + 1); } - /// Every group-membership kind (0-3) and loot-roll/money-share kind (4-8) is pairwise distinct. - /// A renumber that silently collides two kinds would corrupt the relay (the gateway match would - /// resolve the wrong SMSG for a row). #[test] fn all_group_and_loot_event_kinds_are_distinct_0_through_8() { use crate::group::event_kind as g; @@ -319,14 +264,9 @@ mod tests { fn money_share_fails_closed_on_garbage() { assert!(decode_money_share("").is_none()); assert!(decode_money_share("12x").is_none()); - assert!(decode_money_share("-1").is_none()); // u32, no sign + assert!(decode_money_share("-1").is_none()); } - // ---- Realm-core loot-roll ops ---- - - /// The op byte is a WIRE value the gateway sends and the module dispatches on, deployed - /// separately — a renumber only one side learns about silently runs the wrong op (a VOTE - /// arriving as a START). Mirrors `realm_group_op_codes_are_stable_and_distinct` in `group.rs`. #[test] fn realm_loot_op_codes_are_stable_and_distinct() { assert_eq!(loot_op::START, 0); diff --git a/crates/lyracore-shared/src/mail.rs b/crates/lyracore-shared/src/mail.rs index 1ce015b05..9dd8b20f0 100644 --- a/crates/lyracore-shared/src/mail.rs +++ b/crates/lyracore-shared/src/mail.rs @@ -1,53 +1,36 @@ -//! Mail rules shared by the module and gateway. - pub const HAS_UNREAD_MAIL: f32 = 0.0; pub const NO_UNREAD_MAIL: f32 = -86400.0; pub const MAILBOX_GO_TYPE: u8 = 19; pub const MAILBOX_RANGE_SQ: f32 = 100.0; -/// The client's inbox holds 50 mails, so the list stops there (cmangos `MailHandler.cpp:42,557`). pub const INBOX_CAPACITY: usize = 50; -/// `MailCheckMask` bits (cmangos `Mails/Mail.h:65-73`). The client reads a mail's state from them. -/// READ is stored as `game_mail.was_read`; the other bits are stored in `game_mail.check_flags`. pub const CHECK_MASK_READ: u32 = 0x01; pub const CHECK_MASK_RETURNED: u32 = 0x02; pub const CHECK_MASK_COPIED: u32 = 0x04; pub const CHECK_MASK_COD_PAYMENT: u32 = 0x08; pub const CHECK_MASK_HAS_BODY: u32 = 0x10; -/// Every bit the client reads. Bits above it in `check_flags` are the server's own and never reach -/// the wire. A new server-only bit takes the lowest free one: 0x20 is taken, so the next is 0x40. +// Bits outside CLIENT_CHECK_MASK are durable state only and must stay off the wire. pub const CLIENT_CHECK_MASK: u32 = CHECK_MASK_READ | CHECK_MASK_RETURNED | CHECK_MASK_COPIED | CHECK_MASK_COD_PAYMENT | CHECK_MASK_HAS_BODY; -/// Server-only: a Letter Copy's Plain Letter was granted. Distinct from COPIED, which only tells the -/// client to hide the letter button: a copy stays refused after the granted item is destroyed, -/// mailed away, or traded, because this bit, not the item's presence, records that the grant -/// happened. +// This grant survives item destruction or transfer; COPIED only hides the letter button. pub const CHECK_FLAG_LETTER_GRANTED: u32 = 0x20; -/// `MailMessageType` codes (cmangos `Mails/Mail.h:54-61`), stored as `game_mail.sender_kind`. pub const SENDER_KIND_CHARACTER: u8 = 0; pub const SENDER_KIND_AUCTION: u8 = 2; pub const SENDER_KIND_CREATURE: u8 = 3; pub const SENDER_KIND_GAMEOBJECT: u8 = 4; -/// `Stationery.dbc` ids (cmangos `Mails/Mail.h:79-84`). pub const STATIONERY_DEFAULT: u32 = 41; pub const STATIONERY_AUCTION: u32 = 62; -/// Who a mail is from, as the client names it in the inbox. `game_mail` stores it as three -/// columns: `sender_kind`, `sender_guid` and `sender_entry`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum MailSender { Character(u64), - /// The `AuctionHouse.dbc` id (cmangos `Mails/Mail.cpp:81-84`). AuctionHouse(u32), - /// The creature template entry (cmangos `Mails/Mail.cpp:48-75`). Creature(u32), - /// The gameobject template entry. Gameobject(u32), } impl MailSender { - /// `(sender_kind, sender_guid, sender_entry)`. pub fn columns(self) -> (u8, u64, u32) { match self { Self::Character(guid) => (SENDER_KIND_CHARACTER, guid, 0), @@ -56,8 +39,7 @@ impl MailSender { Self::Gameobject(entry) => (SENDER_KIND_GAMEOBJECT, 0, entry), } } - /// The inverse of [`Self::columns`]. The Module writes no other kind, so any other code reads - /// as a Character mail. Every row written before the column existed is one. + // Older rows default to Character mail. pub fn from_columns(kind: u8, guid: u64, entry: u32) -> Self { match kind { SENDER_KIND_AUCTION => Self::AuctionHouse(entry), @@ -73,12 +55,9 @@ impl MailSender { } } } -/// The quest giver a Reward Letter is from. Only a creature or a gameobject ends a quest. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum QuestGiver { - /// The creature template entry. Creature(u32), - /// The gameobject template entry. Gameobject(u32), } @@ -90,8 +69,6 @@ impl QuestGiver { } } } -/// What makes a letter a Reward Letter: the quest giver it is from and the Mail Template it names. -/// A Character's letter has none. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct RewardHeader { pub giver: QuestGiver, @@ -99,8 +76,6 @@ pub struct RewardHeader { } impl RewardHeader { - /// The `(sender_kind, sender_entry, mail_template_id)` a mail escrow row and - /// `realm_mail_commit` carry. A Character's letter is all zeros. pub fn columns(header: Option) -> (u8, u32, u32) { match header { None => (SENDER_KIND_CHARACTER, 0, 0), @@ -110,8 +85,6 @@ impl RewardHeader { } } } - /// The inverse of [`Self::columns`]. Any other value is refused, so a damaged row never - /// commits as a Character's letter from its recipient to itself. pub fn from_columns( sender_kind: u8, sender_entry: u32, @@ -135,14 +108,10 @@ impl RewardHeader { } } const DAY_SECS: i64 = 86_400; -/// When a mail became visible to its recipient. A delivery instant of 0 means "when it was -/// created", which is true for every mail that was never delayed. +// Delivery time 0 means the mail arrived when created. pub fn arrived_at_secs(created_secs: i64, deliver_secs: i64) -> i64 { created_secs.max(deliver_secs) } -/// A mail expires 3 days after it arrives with a cash on delivery price and 30 days after it -/// arrives without one (cmangos `Mails/Mail.cpp:301-313`). The list countdown and the expiry both -/// read this, so they cannot drift apart. pub fn expires_at_secs(created_secs: i64, deliver_secs: i64, cod: u32) -> i64 { let lifetime_days = if cod > 0 { 3 } else { 30 }; arrived_at_secs(created_secs, deliver_secs).saturating_add(lifetime_days * DAY_SECS) @@ -199,8 +168,7 @@ pub fn cod_settlement( if cod == 0 || sender_guid == taker_guid { return None; } - // The payment keeps the letter's own subject. The client shows it as "COD Payment: " - // because the letter carries CHECK_MASK_COD_PAYMENT (cmangos `MailHandler.cpp:475-477`). + // CHECK_MASK_COD_PAYMENT makes the client prepend COD Payment to the subject. Some(CodSettlement { payer_guid: taker_guid, payee_guid: sender_guid, @@ -223,7 +191,6 @@ pub fn unread_mail_signal(has_unread: bool) -> f32 { NO_UNREAD_MAIL } } -/// The days left before [`expires_at_secs`], as the list packet's countdown. pub fn expiration_days(created_secs: i64, deliver_secs: i64, cod: u32, now_secs: i64) -> f32 { let left_secs = expires_at_secs(created_secs, deliver_secs, cod).saturating_sub(now_secs); (left_secs as f32 / DAY_SECS as f32).max(0.0) diff --git a/crates/lyracore-shared/src/meeting_stone.rs b/crates/lyracore-shared/src/meeting_stone.rs index 90b8be92f..802ab007a 100644 --- a/crates/lyracore-shared/src/meeting_stone.rs +++ b/crates/lyracore-shared/src/meeting_stone.rs @@ -1,17 +1,9 @@ -//! The Meeting Stone Queue wire contract: the `realm_meeting_stone_op` op bytes, the queue status -//! and join failure bytes the client renders, the typed [`MeetingStoneRefusal`], and the event kinds -//! and payload the Module writes to `game_group_event`. Both the Module and the Gateway import it. - -/// The `op` byte of `realm_meeting_stone_op`. Its own byte space, separate from -/// [`crate::group::realm_op`], because the queue has its own reducer. pub mod realm_op { - /// `CMSG_MEETINGSTONE_JOIN`: queue the actor, or the Party it leads, for `area_id`. pub const JOIN: u8 = 0; - /// `CMSG_MEETINGSTONE_LEAVE`: leave the queue. pub const LEAVE: u8 = 1; } -/// `SMSG_MEETINGSTONE_SETQUEUE`'s status byte (cm:LFG/LFGDefines.h:50-58). +// SMSG_MEETINGSTONE_SETQUEUE status byte, cm:LFG/LFGDefines.h:50-58. pub mod queue_status { pub const LEAVE_QUEUE: u8 = 0; pub const JOINED_QUEUE: u8 = 1; @@ -21,48 +13,30 @@ pub mod queue_status { pub const NONE: u8 = 5; } -/// `SMSG_MEETINGSTONE_JOINFAILED`'s reason byte (cm:LFG/LFGDefines.h:61-67). +// SMSG_MEETINGSTONE_JOINFAILED reason byte, cm:LFG/LFGDefines.h:61-67. pub mod join_failure { pub const PARTY_LEADER: u8 = 1; pub const FULL_GROUP: u8 = 2; pub const RAID_GROUP: u8 = 3; } -/// The `game_group_event` kinds the Meeting Stone Queue writes. They share the byte space that -/// [`crate::group::event_kind`] documents. pub mod event_kind { - /// `SMSG_MEETINGSTONE_SETQUEUE`. Payload: [`super::encode_queue`]. pub const QUEUE: u8 = 27; - /// `SMSG_MEETINGSTONE_MEMBER_ADDED`. `other_guid` is the added Character. pub const MEMBER_ADDED: u8 = 28; - /// `SMSG_MEETINGSTONE_IN_PROGRESS`. No payload. pub const IN_PROGRESS: u8 = 29; - /// `SMSG_MEETINGSTONE_COMPLETE`. No payload. pub const COMPLETE: u8 = 30; } -/// Why the Module refused a Meeting Stone Durable Request. The tag is the whole reducer error text. -/// Only the three party Refusals have a client answer ([`join_failure_for`]); both cores drop the -/// rest silently. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MeetingStoneRefusal { - /// The actor is in a Party it does not lead. NotLeader, - /// The actor leads a Raid. RaidGroup, - /// The actor's Party already has five members. PartyFull, - /// The actor has no World Session, no live entity, or is on a taxi flight. ActorUnavailable, - /// The GameObject is gone, is not type 23, or has no imported stone row. NotAMeetingStone, - /// The stone is out of reach. OutOfRange, - /// The stone is on another map or in another instance. OtherPartition, - /// The actor's level is outside the stone's range. LevelOutOfRange, - /// The stone names no dungeon area. UnknownArea, } @@ -100,8 +74,6 @@ impl MeetingStoneRefusal { } } -/// The `SMSG_MEETINGSTONE_JOINFAILED` reason for a Refusal, or `None` when the client gets nothing -/// (cm:LFG/LFGHandler.cpp:58-77). pub fn join_failure_for(refusal: MeetingStoneRefusal) -> Option { match refusal { MeetingStoneRefusal::NotLeader => Some(join_failure::PARTY_LEADER), @@ -111,12 +83,10 @@ pub fn join_failure_for(refusal: MeetingStoneRefusal) -> Option { } } -/// `area,status` in decimal, the [`event_kind::QUEUE`] payload. pub fn encode_queue(area_id: u32, status: u8) -> String { format!("{area_id},{status}") } -/// `None` for a malformed payload or a status the client does not know. pub fn decode_queue(payload: &str) -> Option<(u32, u8)> { let (area, status) = payload.split_once(',')?; let status: u8 = status.parse().ok()?; @@ -143,7 +113,6 @@ mod tests { assert_eq!(MeetingStoneRefusal::parse_tag("group:not_leader"), None); } - /// The three party Refusals answer with cmangos's failure byte; every other one is silent. #[test] fn only_the_party_refusals_answer_the_client() { use MeetingStoneRefusal::*; @@ -154,7 +123,6 @@ mod tests { assert_eq!(answered, [(NotLeader, 1), (RaidGroup, 3), (PartyFull, 2)]); } - /// cm:LFG/LFGDefines.h:50-67. #[test] fn the_status_and_failure_bytes_are_the_vanilla_values() { use queue_status::*; diff --git a/crates/lyracore-shared/src/movement_path.rs b/crates/lyracore-shared/src/movement_path.rs index bdd4d01ed..ce2d103e9 100644 --- a/crates/lyracore-shared/src/movement_path.rs +++ b/crates/lyracore-shared/src/movement_path.rs @@ -1,9 +1,7 @@ -//! Linear waypoint geometry shared by authoritative movement and the vanilla client relay. - pub type Point = (f32, f32, f32); pub const MAX_POINTS: usize = 64; -// Keep every destination-relative offset inside vanilla's signed quarter-yard XYZ fields. +// Vanilla offsets use signed quarter-yard XYZ fields. pub const MAX_DISTANCE: f32 = 112.0; pub fn distance(a: Point, b: Point) -> f32 { @@ -18,7 +16,6 @@ pub fn length(start: Point, points: &[Point]) -> f32 { .sum() } -/// Return the current position and the index of the next waypoint. At arrival the index is len. pub fn sample(start: Point, points: &[Point], fraction: f32) -> (Point, usize) { let mut remaining = length(start, points) * fraction.clamp(0.0, 1.0); let mut from = start; @@ -41,7 +38,6 @@ pub fn sample(start: Point, points: &[Point], fraction: f32) -> (Point, usize) { (from, points.len()) } -/// Progress along a path, provided the observed ground position lies on one of its segments. pub fn progress_2d(start: Point, points: &[Point], position: (f32, f32)) -> Option { let mut from = start; let mut travelled = 0.0; @@ -63,7 +59,7 @@ pub fn progress_2d(start: Point, points: &[Point], position: (f32, f32)) -> Opti closest.map(|(_, progress)| progress) } -/// Vanilla ground splines encode intermediate points relative to the final destination. +// Intermediate spline points are relative to the final destination. pub fn packed_offset(destination: Point, point: Point) -> Option { let values = [ (destination.0 - point.0) * 4.0, @@ -91,7 +87,7 @@ pub fn unpacked_point(destination: Point, packed: u32) -> Point { (destination.0 - x, destination.1 - y, destination.2 - z) } -/// Normalize before collision checks so the server follows the exact path encoded on the wire. +// Normalize before collision checks so authoritative movement matches the encoded path. pub fn wire_points(points: &[Point]) -> Option> { let &destination = points.last()?; if points.len() > MAX_POINTS diff --git a/crates/lyracore-shared/src/nav.rs b/crates/lyracore-shared/src/nav.rs index 0ded592ca..93cc47b01 100644 --- a/crates/lyracore-shared/src/nav.rs +++ b/crates/lyracore-shared/src/nav.rs @@ -1,25 +1,22 @@ +//! Before changing leg search or its callers, read docs/architecture.md#navigation-legs. + use crate::spatial::MAP_COORD_MAX; use crate::terrain::{interpolate, CELL_SIZE}; use crate::vmap::{TriClass, VmapTri}; -/// Walkability sub-cells per terrain-cell axis (64 → 0.5208 yd). +// 64 walk cells per terrain-cell axis, about 0.5208 yards each. pub const WALK_DIM: usize = 64; -/// Obstruction sub-cells per terrain-cell axis (32 → 1.0417 yd). +// 32 obstruction cells per terrain-cell axis, about 1.0417 yards each. pub const OBS_DIM: usize = 32; -pub const WALK_BYTES: usize = WALK_DIM * WALK_DIM / 8; // 512 -pub const OBS_BYTES: usize = OBS_DIM * OBS_DIM; // 1024 -/// Obstruction height quantum in yards (u8 → 127 yd span above `base_z`, saturating). +pub const WALK_BYTES: usize = WALK_DIM * WALK_DIM / 8; +pub const OBS_BYTES: usize = OBS_DIM * OBS_DIM; +// Height quantum in yards, spanning 127 yards above base_z. pub const OBS_STEP: f32 = 0.5; -/// Obs byte meaning "no obstruction in this column". pub const OBS_NONE: u8 = 255; -/// Walkable clearance band above ground a blocking triangle must intersect (yd): geometry -/// below `WALK_STEP_UP` is a step/floor you walk onto; above `WALK_HEIGHT` is a ceiling you -/// walk under. +// Geometry below WALK_STEP_UP is a step; geometry above WALK_HEIGHT is headroom. pub const WALK_STEP_UP: f32 = 0.9; pub const WALK_HEIGHT: f32 = 2.2; -/// World coord → sub-index 0..dim inside terrain cell `cell` on one axis, or None when the -/// coord isn't in that cell. Same DOWNWARD-counting convention as `terrain::cell_index`. pub fn sub_index(coord: f32, cell: u16, dim: usize) -> Option { let local = (MAP_COORD_MAX - coord) - cell as f32 * CELL_SIZE; if !(0.0..CELL_SIZE).contains(&local) { @@ -28,7 +25,6 @@ pub fn sub_index(coord: f32, cell: u16, dim: usize) -> Option { Some(((local / CELL_SIZE) * dim as f32) as usize).map(|i| i.min(dim - 1)) } -/// Center world coord of sub-cell `i` (inverse of `sub_index`, for rasterizer sampling). pub fn sub_center(cell: u16, i: usize, dim: usize) -> f32 { MAP_COORD_MAX - (cell as f32 * CELL_SIZE + (i as f32 + 0.5) * CELL_SIZE / dim as f32) } @@ -47,7 +43,6 @@ pub fn walk_set(walk: &mut [u8], nx: usize, ny: usize, walkable: bool) { } } -/// Obstruction top-Z for a column, or None when clear. pub fn obs_top(obs: &[u8], base_z: f32, ox: usize, oy: usize) -> Option { match obs.get(oy * OBS_DIM + ox) { None | Some(&OBS_NONE) => None, @@ -55,7 +50,6 @@ pub fn obs_top(obs: &[u8], base_z: f32, ox: usize, oy: usize) -> Option { } } -/// Raise a column's obstruction top to at least `top_z` (saturating at the u8 span). pub fn obs_raise(obs: &mut [u8], base_z: f32, ox: usize, oy: usize, top_z: f32) { let v = (((top_z - base_z) / OBS_STEP).ceil().max(0.0) as u32).min(OBS_NONE as u32 - 1) as u8; let slot = &mut obs[oy * OBS_DIM + ox]; @@ -78,7 +72,6 @@ mod tests { assert_eq!(sub_index(c, cell, dim), Some(i), "dim={dim} i={i}"); } } - // A coord in the NEXT cell over is rejected, not aliased into this one. assert_eq!(sub_index(-8913.0 - CELL_SIZE, cell, WALK_DIM), None); } @@ -97,24 +90,15 @@ mod tests { let mut obs = vec![OBS_NONE; OBS_BYTES]; let base = 80.0; assert_eq!(obs_top(&obs, base, 3, 3), None); - obs_raise(&mut obs, base, 3, 3, 86.0); // 6 yd wall + obs_raise(&mut obs, base, 3, 3, 86.0); assert!((obs_top(&obs, base, 3, 3).unwrap() - 86.0).abs() < OBS_STEP + 1e-3); - obs_raise(&mut obs, base, 3, 3, 82.0); // lower geometry must not shrink the column + obs_raise(&mut obs, base, 3, 3, 82.0); assert!(obs_top(&obs, base, 3, 3).unwrap() >= 86.0 - OBS_STEP); - obs_raise(&mut obs, base, 3, 3, base + 10_000.0); // saturates, no panic + obs_raise(&mut obs, base, 3, 3, base + 10_000.0); assert!(obs_top(&obs, base, 3, 3).is_some()); } } -// ============================================================================================= -// Runtime queries, pure algorithms over fetched chunks. The chunk source is -// a closure (module: one PK find per cell; tests: synthetic chunks) so ALL pathing/LoS logic -// unit-tests here without a database. Missing chunk = "no obstacles known" (the sparse-chunk contract: -// fully-clear cells emit no row), so every query degrades to today's straight-line behavior -// off-slice. -// ============================================================================================= - -/// One fetched nav cell (the module clones the row's blobs; a few KB per crossed cell). #[derive(Clone)] pub struct NavCellData { pub base_z: f32, @@ -122,7 +106,6 @@ pub struct NavCellData { pub obs: Vec, } -/// Internal per-query memo so a leg/ray touching the same cell many times fetches it once. struct Cache<'a, F: FnMut(u16, u16) -> Option> { fetch: &'a mut F, seen: std::collections::HashMap<(u16, u16), Option>, @@ -144,12 +127,10 @@ impl<'a, F: FnMut(u16, u16) -> Option> Cache<'a, F> { } } -/// Eye height added to both endpoints of a sight line (vanilla uses ~2 yd collision height). pub const LOS_EYE_HEIGHT: f32 = 2.0; -/// Sight-line sample spacing in yards (half the obs-grid resolution — can't skip a column). +// Half the obstruction resolution in yards, so no column is skipped. const LOS_STEP: f32 = 0.5; -/// The obs-grid column containing world (x, y), or None off-grid. fn obs_col(x: f32, y: f32) -> Option<(u16, u16, usize, usize)> { let (cx, cy) = ( crate::terrain::cell_index(x)?, @@ -163,15 +144,7 @@ fn obs_col(x: f32, y: f32) -> Option<(u16, u16, usize, usize)> { )) } -/// The raymarch behind `has_los` and `step_hit`: the first sample along a→b whose obstruction -/// column tops the ray, or None when clear. `exempt_start` skips samples in a's OWN obs column -/// — the committed-step gate needs it (a mover hugging a wall stands inside the blob's -/// conservative margin, and counting its own column as blocked would hold it in place forever — -/// the same live find that exempts the start cell in `line_walkable`), while sight checks keep -/// the strict test. -// Deliberate simplification: parametric sampling, not an exact DDA — at 0.5 yd steps over a -// 1.04 yd obs grid a column cannot be stepped over; move to exact cell walking when someone -// measures the difference. +// A committed step exempts its starting obstruction column so a mover can leave the margin. fn los_hit( fetch: &mut impl FnMut(u16, u16) -> Option, a: (f32, f32, f32), @@ -209,8 +182,6 @@ fn los_hit( None } -/// Line of sight from a to b: sample the segment every `LOS_STEP` and compare the ray's -/// interpolated Z (+eye height at both ends) against the obstruction column tops. pub fn has_los( fetch: &mut impl FnMut(u16, u16) -> Option, a: (f32, f32, f32), @@ -219,8 +190,6 @@ pub fn has_los( los_hit(fetch, a, b, false).is_none() } -/// Returns the first blocked sample so a committed step can stop short of it. -/// The mover's own obstruction column is exempt; `None` means the step is clear. pub fn step_hit( fetch: &mut impl FnMut(u16, u16) -> Option, a: (f32, f32, f32), @@ -229,11 +198,9 @@ pub fn step_hit( los_hit(fetch, a, b, true) } -/// Nav-grid resolution in yards (one walk sub-cell). pub const NAV_RES: f32 = CELL_SIZE / WALK_DIM as f32; -/// Global walk-grid coordinate, counting down from +MAP_COORD_MAX in 0..65536. -/// Classify the stored world coordinate before f32 rounding can move it across a cell edge. +// Classify before f32 rounding can move the coordinate across a cell edge. fn grid_coord(coord: f32) -> Option { let c = (f64::from(MAP_COORD_MAX) - f64::from(coord)) / f64::from(NAV_RES); if !(0.0..(1024 * WALK_DIM) as f64).contains(&c) { @@ -255,7 +222,6 @@ fn grid_walkable( if gx >= 1024 * dimension || gy >= 1024 * dimension { return false; } - // Grid coordinates already identify the imported cell and walkability bit. let cell = cache.get((gx / dimension) as u16, (gy / dimension) as u16); cell.is_none_or(|cell| { walk_get( @@ -266,8 +232,7 @@ fn grid_walkable( }) } -/// Check every crossed cell before accepting a direct route or removing a waypoint. -/// The start cell is exempt so a mover can escape a conservative obstruction margin. +// Exempt the start cell so a mover can leave a conservative obstruction margin. fn line_walkable( cache: &mut Cache Option>, from: (f32, f32), @@ -281,7 +246,6 @@ fn line_walkable( ) else { return false; }; - // Keep the world segment precise when measuring its grid-boundary intersections. let gx = (f64::from(MAP_COORD_MAX) - f64::from(from.0)) / f64::from(NAV_RES); let gy = (f64::from(MAP_COORD_MAX) - f64::from(from.1)) / f64::from(NAV_RES); let dx = (f64::from(from.0) - f64::from(to.0)) / f64::from(NAV_RES); @@ -310,9 +274,6 @@ fn line_walkable( true } -/// Retain the longest tested half-prefix of an attempted movement whose exact stored endpoint -/// crosses only walkable cells. The caller may derive `attempted` from a farther, walkable -/// waypoint; f32 rounding can put that shorter endpoint on the other side of a grid corner. pub fn walkable_prefix( fetch: &mut impl FnMut(u16, u16) -> Option, from: (f32, f32), @@ -342,11 +303,7 @@ pub fn walkable_prefix( from } -/// Short-leg grid A* with string-pulling. Returns world waypoints from AFTER `from` up to and -/// including `to`, or None when unreachable within `max_expansions`. The straight-line fast -/// path returns `[to]` with ZERO expansions — an open-field chase costs one line test. -/// Callers keep legs SHORT (the 500 ms tick's chase legs); long travel stays on the waypoint -/// graph. +/// May return a partial leg; use `find_leg_ex` to distinguish complete and partial plans. pub fn find_leg( fetch: &mut impl FnMut(u16, u16) -> Option, from: (f32, f32), @@ -356,13 +313,7 @@ pub fn find_leg( find_leg_ex(fetch, from, to, max_expansions).map(|(path, _, _)| path) } -/// `find_leg` + the expansion count + completeness (exposed for tests and the benchmark). -/// When the expansion budget exhausts before reaching the goal, returns the BEST-EFFORT path — -/// to the explored node nearest the goal (complete=false) — so a per-tick chase leg still makes -/// real progress around large obstacles and re-plans next tick, instead of falling back to a -/// straight wall-grind. Returns None only when the goal is unwalkable, off-grid, or no progress -/// at all is possible. -// The return tuple is the pathfinder's full result (leg, expansions used, whether it reached the goal). +/// Returns (waypoints, expansions, reached_goal); `None` means no usable leg. #[allow(clippy::type_complexity)] pub fn find_leg_ex( fetch: &mut impl FnMut(u16, u16) -> Option, @@ -378,7 +329,6 @@ pub fn find_leg_ex( } } -/// Planning success is distinct from arrival. Partial legs retain safe progress toward the goal. #[derive(Clone, Debug, PartialEq)] pub enum LegOutcome { Complete(Vec<(f32, f32)>), @@ -386,15 +336,12 @@ pub enum LegOutcome { Blocked, } -/// Search work is retained even when no progress is possible. #[derive(Clone, Debug, PartialEq)] pub struct LegSearch { pub outcome: LegOutcome, pub expansions: u32, } -/// Find a leg to a walkable point within the caller's stopping distance of the target. -/// The target itself may be inside a conservative obstacle margin. pub fn find_leg_in_range( fetch: &mut impl FnMut(u16, u16) -> Option, from: (f32, f32), @@ -408,7 +355,6 @@ pub fn find_leg_in_range( } } -/// Find a range-limited leg without discarding completeness or failed-search work. pub fn find_leg_in_range_ex( fetch: &mut impl FnMut(u16, u16) -> Option, from: (f32, f32), @@ -474,9 +420,7 @@ fn search_leg( if stop_dist == 0.0 && !target_walkable { return blocked(0); } - // Prefer progress toward the destination within the finite search budget. Weighting - // the octile estimate by 5/4 trades shortest-path optimality for fewer expansions. - // Equal scores prefer the nearer node; every edge still checks both corners. + // A 5/4 heuristic weight trades shortest paths for fewer expansions. use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap}; let h = |x: u32, y: u32| { @@ -495,7 +439,7 @@ fn search_leg( open.push(Reverse((priority(0, sx, sy), h(sx, sy), sx, sy))); let mut expanded = 0u32; let mut found = false; - let mut best = ((sx, sy), h(sx, sy)); // nearest-approach node for the partial fallback + let mut best = ((sx, sy), h(sx, sy)); while let Some(Reverse((score, _, x, y))) = open.pop() { let g0 = g_cost[&(x, y)]; if score != priority(g0, x, y) { @@ -511,7 +455,7 @@ fn search_leg( break; } if expanded == max_expansions { - break; // budget exhausted — fall through to the best-effort partial path + break; } expanded += 1; let hxy = h(x, y); @@ -536,7 +480,6 @@ fn search_leg( if !grid_walkable(&mut cache, nx, ny) { continue; } - // No cutting corners: a diagonal move needs both orthogonal neighbours open. if dx != 0 && dy != 0 && (!grid_walkable(&mut cache, nx, y) || !grid_walkable(&mut cache, x, ny)) @@ -560,7 +503,6 @@ fn search_leg( } best.0 }; - // Reconstruct, then string-pull: keep a waypoint only where the direct segment breaks. let mut cells = vec![end]; while let Some(&prev) = came.get(cells.last().unwrap()) { cells.push(prev); @@ -579,9 +521,6 @@ fn search_leg( while i + 1 < pts.len() { let mut j = pts.len() - 1; if !line_walkable(&mut cache, anchor, pts[j]) { - // Probe progressively farther points, then refine the first blocked interval. - // Visibility can reopen later in the path; taking an earlier safe waypoint - // avoids testing every distant point against the same intervening wall. let mut visible = i + 1; let mut stride = 1; while visible + stride < j { @@ -611,8 +550,6 @@ fn search_leg( anchor = (grid_to_world(end.0), grid_to_world(end.1)); path.push(anchor); } - // Land exactly on the requested destination (the goal cell center is ≤0.4 yd off) — only - // for a COMPLETE path; a partial one ends at the nearest-approach node by design. if found && (stop_dist == 0.0 || (anchor.0 - to.0).hypot(anchor.1 - to.1) > stop_dist) && line_walkable(&mut cache, anchor, to) @@ -662,10 +599,6 @@ mod runtime_tests { assert!(line_walkable(&mut Cache::new(&mut fetch), from, endpoint)); } - /// One synthetic chunk at the Northshire cell: a full-height wall along nx=32 (obs ox=16) - /// with a 4-sub-cell doorway at ny 30..34 (obs oy 15..17), plus a sealed 4-wall pocket in - /// the corner (nx 4..12, ny 4..12 ring). Every other cell in the world is "missing" (all - /// clear) — exactly the skip-all-clear contract. fn walled_cell() -> ((u16, u16), NavCellData) { let (cx, cy) = (cell_index(-8913.0).unwrap(), cell_index(-184.0).unwrap()); let mut walk = vec![0xFFu8; WALK_BYTES]; @@ -681,7 +614,6 @@ mod runtime_tests { obs_raise(&mut obs, base, 16, oy, base + 20.0); } } - // Sealed pocket: ring walls, hollow inside. for i in 4..=12 { for (nx, ny) in [(i, 4), (i, 12), (4, i), (12, i)] { walk_set(&mut walk, nx, ny, false); @@ -702,7 +634,6 @@ mod runtime_tests { move |x, y| (x == cx && y == cy).then(|| data.clone()) } - /// World coords of a sub-cell in the walled cell. fn at(nx: usize, ny: usize) -> (f32, f32) { let ((cx, cy), _) = walled_cell(); (sub_center(cx, nx, WALK_DIM), sub_center(cy, ny, WALK_DIM)) @@ -732,15 +663,12 @@ mod runtime_tests { #[test] fn los_blocked_by_wall_clear_through_doorway() { let z = 80.0; - // Across the wall at ny=50 (oy=25): blocked. let (ax, ay) = at(10, 50); let (bx, by) = at(54, 50); assert!(!has_los(&mut fetcher(), (ax, ay, z), (bx, by, z))); - // Straight through the doorway line (ny=31, oy=15): clear. let (ax, ay) = at(10, 31); let (bx, by) = at(54, 31); assert!(has_los(&mut fetcher(), (ax, ay, z), (bx, by, z))); - // Flying high over the wall (ray z above base+20): clear. let (ax, ay) = at(10, 50); let (bx, by) = at(54, 50); assert!(has_los( @@ -752,7 +680,6 @@ mod runtime_tests { #[test] fn find_leg_routes_through_the_doorway() { - // Start and goal on opposite sides of the wall, far from the door. let from = at(10, 50); let to = at(54, 50); let (path, expanded, complete) = @@ -762,7 +689,6 @@ mod runtime_tests { assert!(path .last() .is_some_and(|&p| (p.0 - to.0).abs() < 1e-3 && (p.1 - to.1).abs() < 1e-3)); - // Every string-pulled waypoint segment must itself be walkable — re-verify by sampling. let mut fetch = fetcher(); let mut cache_check = |a: (f32, f32), b: (f32, f32)| { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -803,7 +729,6 @@ mod runtime_tests { walk_set(&mut cell.walk, 0, 18, false); let mut fetch = |x, y| ((x, y) == (470, 476)).then(|| cell.clone()); let endpoints = [(1398.908, 1190.3529), (1400.2607, 1189.8438)]; - // The declared cell bounds, checked by segment intersection instead of route sampling. let bounds = [ (1_399.479_113_280_773_2, 1_399.999_946_594_238_3), (1_190.104_121_267_795_6, 1_190.624_954_581_260_7), @@ -831,7 +756,6 @@ mod runtime_tests { let (path, _, complete) = find_leg_ex(&mut fetcher(), from, to, 4096).unwrap(); assert!(complete); assert_eq!(path.last(), Some(&to)); - // This wall cell borders the doorway on the approach to the goal. let bounds = [ (-8_917.187_159_836_292, -8_916.666_326_522_827), (-182.291_659_712_791_44, -181.770_826_399_326_32), @@ -905,7 +829,6 @@ mod runtime_tests { #[test] fn open_field_fast_path_takes_zero_expansions() { - // Both points in the clear half of the cell — direct line, no A*. let from = at(40, 10); let to = at(60, 20); let (path, expanded, complete) = @@ -1051,11 +974,8 @@ mod runtime_tests { #[test] fn start_inside_a_blocked_margin_cell_still_fast_paths() { - // A chaser standing IN an unwalkable margin cell (movement isn't walkability-gated) - // must still see the open field ahead — its own cell is exempt from the line test. let ((cx, cy), _) = walled_cell(); let mut fetch = fetcher(); - // Stand exactly on the wall line (nx=32, ny=50 — unwalkable), aim into the open. let from = (sub_center(cx, 32, WALK_DIM), sub_center(cy, 50, WALK_DIM)); let to = at(40, 50); let (path, expanded, complete) = @@ -1069,8 +989,6 @@ mod runtime_tests { #[test] fn step_hit_stops_a_wall_crossing_step_and_clears_the_doorway() { let z = 80.0; - // Step across the wall at ny=50 (oy=25): the gate ray hits, and the hit sample sits ON - // the wall's obs column (ox=16) — the gated mover truncates short instead of crossing. let ((cx, _), _) = walled_cell(); let wall_x = sub_center(cx, 16, OBS_DIM); let (ax, ay) = at(10, 50); @@ -1081,7 +999,6 @@ mod runtime_tests { (hx - wall_x).abs() <= 0.8, "hit sample {hx:.2} not at the wall column {wall_x:.2}" ); - // Straight through the doorway line (ny=31, oy=15): clear, same as `has_los`. let (ax, ay) = at(10, 31); let (bx, by) = at(54, 31); assert!(step_hit(&mut fetcher(), (ax, ay, z), (bx, by, z)).is_none()); @@ -1089,15 +1006,11 @@ mod runtime_tests { #[test] fn step_hit_exempts_the_movers_own_obs_column() { - // A mover standing INSIDE the wall's obs column (the blob's conservative margin — movement - // isn't walkability-gated) must be able to step back into the open: its own column never - // holds it (the same live find as `line_walkable`'s start-cell exemption)… let z = 80.0; let ((cx, cy), _) = walled_cell(); let from = (sub_center(cx, 16, OBS_DIM), sub_center(cy, 25, OBS_DIM)); let to = at(10, 50); assert!(step_hit(&mut fetcher(), (from.0, from.1, z), (to.0, to.1, z)).is_none()); - // `has_los` keeps the strict test so sight semantics remain unchanged. assert!(!has_los( &mut fetcher(), (from.0, from.1, z), @@ -1107,14 +1020,11 @@ mod runtime_tests { #[test] fn no_path_fallback_straight_step_is_caught_by_the_gate() { - // A goal inside the wall makes `find_leg` return `None`. - // to a straight step AT the goal — the step-gate ray must hit at the wall column so the - // committed move truncates short of it instead of walking into geometry. let z = 80.0; let ((cx, _), _) = walled_cell(); let wall_x = sub_center(cx, 16, OBS_DIM); let from = at(10, 50); - let goal = at(32, 50); // ON the wall line — unwalkable by construction + let goal = at(32, 50); assert!(find_leg(&mut fetcher(), from, goal, 20_000).is_none()); let (hx, _) = step_hit(&mut fetcher(), (from.0, from.1, z), (goal.0, goal.1, z)) .expect("the straight fallback crosses the wall column"); @@ -1126,58 +1036,36 @@ mod runtime_tests { #[test] fn sealed_pocket_yields_partial_progress_and_walls_yield_none() { - let from = at(50, 8); // outside the pocket - let to = at(8, 8); // inside the sealed ring - // Unreachable but walkable goal: budget exhausts → best-effort PARTIAL path toward the - // pocket (the creature bumps the wall and re-plans, like a straight-line mob would). + let from = at(50, 8); + let to = at(8, 8); let (path, _, complete) = find_leg_ex(&mut fetcher(), from, to, 2_000).expect("partial progress"); assert!(!complete); assert!(!path.is_empty()); - // A wall-interior goal returns None immediately (goal cell unwalkable). assert!(find_leg(&mut fetcher(), from, at(32, 50), 20_000).is_none()); } } -// ============================================================================================= -// Derivation: collision triangles to one cell's blobs. This is the only rasterization policy. -// The importer's `--nav` mode and the module's coverage derivation both call `derive_cell`, so -// a margin or band change cannot drift between them. Terrain-only inputs (slope, holes) stay -// with their owner and fold in through `merge_cells`. -// ============================================================================================= - -/// Inflation margin for the point-in-triangle test (yd). Slightly over half a walk-cell diagonal, -/// so a thin vertical wall whose 2D projection is a sliver still hits the centers of the cells its -/// line crosses. Errs toward over-blocking by less than one cell. +// Inflation in yards exceeds half a walk-cell diagonal to catch thin walls. const RASTER_MARGIN: f32 = 0.35; -/// Agent-body inset for walk cells. The obstruction and line-of-sight grid keeps the true -/// footprint. +// Body radius in yards; the obstruction grid retains the true footprint. const AGENT_RADIUS: f32 = 0.5; -/// The walk-grid rasterization margin: sliver-catching plus the body inset. Public because a -/// caller binning triangles into cells must inflate its cell range by the same amount, or a -/// triangle hugging a border never reaches the neighbour cell its inset footprint spills into. +// Triangle bins must include this margin to cover inset geometry across cell boundaries. pub const WALK_MARGIN: f32 = RASTER_MARGIN + AGENT_RADIUS; -/// Gap tolerance when fusing a column's z-intervals (yd): stacked wall bands abut without -/// overlapping exactly. +// Vertical interval gap tolerance in yards. const OBS_GAP: f32 = 0.75; -/// Coplanar floor triangles can differ slightly after world-coordinate transforms. +// Transformed coplanar floors may differ by this many yards. const FLOOR_EPSILON_YD: f32 = 0.01; -/// One world-space collision triangle with its AABB and plane. `z_at` is the exact-rasterization -/// core: the triangle's z-interval over a 2D point, or None when the point falls outside the -/// inflated footprint. A coarse M2 bounding triangle spanning trunk to canopy therefore blocks -/// only near the trunk, where the plane-z is low, and leaves the space under the canopy walkable. -/// Stamping the whole AABB instead blocks the full canopy width. struct WorldTri { lo: [f32; 3], hi: [f32; 3], - /// WMO geometry participates in the obstruction grid. M2 doodads never do. is_wmo: bool, v: [[f32; 3]; 3], - /// Plane normal and d (n·p = d). Near-vertical planes fall back to the full z-range. + // Plane equation n.p = d; near-vertical planes use the full z range. n: [f32; 3], d: f32, } @@ -1210,9 +1098,6 @@ impl WorldTri { } } - /// The triangle's z-interval over 2D point (x, y), testing inside-ness with a `margin`-yd - /// outward inflation by signed edge distance, clipped to the XY bounds plus that margin. - /// Diagonal tips retain the conservative box corner. None means outside this footprint. fn z_at(&self, x: f32, y: f32, margin: f32) -> Option<(f32, f32)> { if x < self.lo[0] - margin || x > self.hi[0] + margin @@ -1221,25 +1106,22 @@ impl WorldTri { { return None; } - // 2D signed edge distances (positive = same side as the third vertex). for i in 0..3 { let (a, b, c) = (self.v[i], self.v[(i + 1) % 3], self.v[(i + 2) % 3]); let (ex, ey) = (b[0] - a[0], b[1] - a[1]); let len = (ex * ex + ey * ey).sqrt(); if len < 1e-6 { - continue; // degenerate edge in 2D (vertical tri seen edge-on): skip this test + continue; } let cross = |px: f32, py: f32| ex * (py - a[1]) - ey * (px - a[0]); let side_c = cross(c[0], c[1]); let side_p = cross(x, y); - // Normalize to a distance and require p within `margin` outside c's side. let dist = side_p * side_c.signum() / len; if dist < -margin { return None; } if margin == 0.0 && side_p == 0.0 { - // Assign a shared edge to one triangle. Adjacent stair treads must not overlap - // at a sample, while a ceiling mesh diagonal must remain covered. + // Shared edges must keep treads separate and cover mesh diagonals. let dx = ex * side_c.signum(); let dy = ey * side_c.signum(); if !(dy > 0.0 || (dy == 0.0 && dx < 0.0)) { @@ -1247,11 +1129,7 @@ impl WorldTri { } } } - // Steep plane (wall or fence side, more than 60° from horizontal): the plane-z is - // ill-conditioned over the projected sliver and clamps to an arbitrary end of the - // z-range. A fence side tilted about 1° clamps to the bottom, below the standing band, - // which leaves the fence walkable. Use the full z-span instead. Anything this steep is - // unwalkable surface anyway, since the terrain slope gate is 50°. + // Steep planes have unstable projected heights; use their full vertical span. let n_len = (self.n[0] * self.n[0] + self.n[1] * self.n[1] + self.n[2] * self.n[2]).sqrt(); if self.n[2].abs() < 0.5 * n_len { return Some((self.lo[2], self.hi[2])); @@ -1262,16 +1140,9 @@ impl WorldTri { } } -/// Sub-index range covering world span `[lo_c, hi_c]`, clamped to `cell_i`. A high coordinate -/// gives the low index. None when the span misses the cell entirely. -/// -/// Clamps in the cell's local offset space instead of re-testing each end with `sub_index`. At -/// ±17k yd an f32 boundary coordinate rounds to either side of the cell edge, and a `None` there -/// drops the whole triangle from the cell, so a blocker reaching past a cell border goes -/// unrasterized at random. +// Clamp in local coordinates; rounded world bounds can fall outside their own cell. fn clamp_axis(lo_c: f32, hi_c: f32, cell_i: u16, dim: usize) -> Option<(usize, usize)> { let local = |coord: f32| (MAP_COORD_MAX - coord) - cell_i as f32 * CELL_SIZE; - // High coord → low local offset, so the span's ends swap. let (lo_local, hi_local) = (local(hi_c), local(lo_c)); if hi_local <= 0.0 || lo_local >= CELL_SIZE { return None; @@ -1281,8 +1152,6 @@ fn clamp_axis(lo_c: f32, hi_c: f32, cell_i: u16, dim: usize) -> Option<(usize, u Some((index(lo_local), index(hi_local))) } -/// Sample terrain and nearby WMO floors, then extend those floors along connected steps or ramps. -/// Floors use their exact footprint so their edges cannot create support in empty space. fn standing_heights( cell_x: u16, cell_y: u16, @@ -1297,8 +1166,7 @@ fn standing_heights( i if i > dim => sub_center(cell, dim - 1, dim) - resolution, i => sub_center(cell, i - 1, dim), }; - // The collar checks floor edges across chunk boundaries. Terrain is continued from the - // nearest in-cell sample; binned geometry already includes the body-radius margin. + // Continue terrain into the collar; binned geometry already includes the body radius. let mut ground: Vec<_> = (0..side) .flat_map(|ny| { (0..side).map(move |nx| { @@ -1311,7 +1179,6 @@ fn standing_heights( .collect(); let mut floor = vec![f32::INFINITY; side * side]; for t in triangles { - // A floor must be WMO geometry with a slope no greater than 50 degrees. if !t.is_wmo || !crate::vmap::supports_walking(t.n, [0.0, 0.0, 1.0]) { continue; } @@ -1335,8 +1202,7 @@ fn standing_heights( } } } - // Keep the lowest model surface above terrain. Choosing a higher surface can promote a - // low ceiling to the floor and erase the room's headroom obstruction. + // The lowest surface above terrain prevents a low ceiling from becoming the floor. let mut pending = Vec::new(); for index in 0..ground.len() { if floor[index].is_finite() && floor[index] <= ground[index] + WALK_HEIGHT { @@ -1345,8 +1211,7 @@ fn standing_heights( pending.push(index); } } - // The terrain-relative selection band must not cut a continuous ramp in two. Consume each - // selected floor once; disconnected surfaces above that band remain overhead geometry. + // Continue connected ramps beyond the terrain-relative selection band. while let Some(index) = pending.pop() { let x = index % side; let y = index / side; @@ -1370,16 +1235,6 @@ fn standing_heights( ground } -/// Derive one cell's blobs from the collision triangles binned to it: every triangle whose AABB -/// touches the cell, inflated by `WALK_MARGIN`. Anything outside the cell is clamped away here. -/// -/// `heights` is the cell's 145-value MCNK height grid when a terrain chunk exists. The standing -/// band follows terrain, nearby walkable WMO floors, and their connected steps or ramps. This -/// single-layer grid supports ground-floor interiors; stacked floors still need navigation layers. -/// Without terrain, `base_z` is the lowest triangle vertex. -/// -/// Returns None when nothing in the cell blocks. A fully-clear cell emits no row, and both readers -/// treat a missing cell as "no obstacles known". pub fn derive_cell( cell_x: u16, cell_y: u16, @@ -1416,7 +1271,7 @@ pub fn derive_cell( for nx in 0..WALK_DIM { let index = (ny + 1) * (WALK_DIM + 2) + nx + 1; let g = walk_ground[index]; - // Subtracting rounded world heights can put an exact step just above the limit. + // Rounded world heights can put an exact step just above the limit. if [ index - 1, index + 1, @@ -1434,10 +1289,6 @@ pub fn derive_cell( } for t in &triangles { - // Walkability (all geometry): block a nav cell only when the triangle passes through the - // standing band above that cell's ground. Footprint and window are inflated by - // `WALK_MARGIN`, so cells within a body radius of geometry rasterize blocked and the - // center line that `find_leg` and `nav_step` walk keeps the body clear of wall corners. if let (Some((nx0, nx1)), Some((ny0, ny1))) = ( clamp_axis( t.lo[0] - WALK_MARGIN, @@ -1462,8 +1313,7 @@ pub fn derive_cell( continue; }; let g = walk_ground[(ny + 1) * (WALK_DIM + 2) + nx + 1]; - // A separate surface above the floor is headroom, even below step height. - // Only its exact footprint counts; an adjacent stair tread is still a step. + // A separate overhead surface needs clearance even when below step height. let overhead = t.is_wmo && t.n[2].abs() > 1e-6 && t.z_at(x, y, 0.0) @@ -1475,12 +1325,7 @@ pub fn derive_cell( } } } - // Obstruction and line of sight: WMO geometry only. Vanilla doodads such as trees block - // movement but never sight lines, so spells and aggro reach through a forest. Accumulate - // the z-intervals per column here; the rooted decision happens per column after merging, - // below. Deciding per triangle keeps only a wall's bottom band, because a wall is stacked - // triangle bands, and collapses obs tops to about 2 yd, which lets eye-height rays graze - // over the wall. + // M2 doodads block movement but do not block vanilla sight lines. if !t.is_wmo { continue; } @@ -1503,14 +1348,7 @@ pub fn derive_cell( } } - // Per-column obs: merge the accumulated z-intervals with `OBS_GAP` tolerance, then keep only - // the merged run that reaches down into the head-height band above this column's ground. - // Stacked wall bands fuse ground to top, so a full wall height blocks over-the-roof sight. A - // floating eave or roof run stays a separate high interval and is dropped, so standing under - // an overhang does not blank line of sight. - // - // Simplification: one top per obs column, with no (bottom, top) pair, so archways and bridges - // read as walls. Store both bytes when a real case needs to see through an opening. + // Only merged obstruction intervals rooted in the head-height band block sight. for oy in 0..OBS_DIM { for ox in 0..OBS_DIM { let ivals = &mut col_ivals[oy * OBS_DIM + ox]; @@ -1548,9 +1386,6 @@ pub fn derive_cell( dirty.then_some(NavCellData { base_z, walk, obs }) } -/// Merge two cells covering the same terrain cell: `walk` is the bitwise AND, since either source -/// may block, and `obs` keeps the taller column. Both blobs rebase onto the lower `base_z`, so no -/// column underflows when the two disagree about the cell's floor. pub fn merge_cells(a: &NavCellData, b: &NavCellData) -> NavCellData { let base_z = a.base_z.min(b.base_z); let walk: Vec = a.walk.iter().zip(&b.walk).map(|(x, y)| x & y).collect(); @@ -1590,14 +1425,11 @@ mod derive_tests { } } - /// World center of a walk sub-cell in the test cell. fn at(nx: usize, ny: usize) -> (f32, f32) { let (cx, cy) = test_cell(); (sub_center(cx, nx, WALK_DIM), sub_center(cy, ny, WALK_DIM)) } - /// Two triangles forming a vertical wall quad along the `nx` line, spanning walk rows - /// `ny0..=ny1` and rising `height` yd off the ground. fn wall(nx: usize, ny0: usize, ny1: usize, height: f32, class: TriClass) -> Vec { let (x, y0) = at(nx, ny0); let (_, y1) = at(nx, ny1); @@ -1614,8 +1446,6 @@ mod derive_tests { ] } - /// One horizontal triangle at `z` that covers the whole test cell (vertices well outside it, - /// so every sub-cell center falls inside the footprint). fn slab(z: f32, class: TriClass) -> VmapTri { let (cx, cy) = test_cell(); let (hx, hy) = ( @@ -1628,7 +1458,6 @@ mod derive_tests { } } - /// An all-clear terrain cell (what a slope/hole-free chunk contributes to a merge). fn open_terrain() -> NavCellData { NavCellData { base_z: GROUND_Z, @@ -1895,20 +1724,17 @@ mod derive_tests { derive_cell(cx, cy, Some(&flat_heights()), &tris).expect("the wall blocks the cell"); let merged = merge_cells(&open_terrain(), &coverage); - // Opposite sides of the wall, level with its far end so a detour exists. let (from, to) = (at(10, 20), at(54, 20)); let (detour, expanded, complete) = find_leg_ex(&mut cell_fetcher(Some(merged)), from, to, 20_000).expect("reachable"); assert!(complete && expanded > 0, "the wall must force real A*"); assert!(detour.len() > 1, "a detour needs a corner waypoint"); - // Terrain alone knows nothing about the wall: the same leg is one straight segment. let (straight, expanded, _) = find_leg_ex(&mut cell_fetcher(Some(open_terrain())), from, to, 20_000) .expect("open field"); assert_eq!((straight.len(), expanded), (1, 0)); - // The exact ray over the same triangles agrees the straight segment is blocked. let mut tri_fetch = |x: u16, y: u16| (x == cx && y == cy).then(|| tris.clone()); assert!(cast_ray( &mut tri_fetch, @@ -1936,13 +1762,10 @@ mod derive_tests { let (cx, cy) = test_cell(); let tris = wall(32, 3, 60, 12.0, wmo()); let cell = derive_cell(cx, cy, Some(&flat_heights()), &tris).expect("a wall blocks"); - // The wall line plus one sub-cell on each side (the agent-radius inset). for nx in 31..=33 { assert!(!walk_get(&cell.walk, nx, 32), "nx={nx} must be blocked"); } - // Two out (1.04 yd) is beyond the margin, so the inset is bounded. assert!(walk_get(&cell.walk, 30, 32) && walk_get(&cell.walk, 34, 32)); - // Sight keeps the true footprint, so only the wall's own obs column rises. assert!(obs_top(&cell.obs, cell.base_z, 16, 16).is_some()); assert!(obs_top(&cell.obs, cell.base_z, 15, 16).is_none()); assert!(obs_top(&cell.obs, cell.base_z, 17, 16).is_none()); @@ -1971,7 +1794,6 @@ mod derive_tests { #[test] fn a_triangle_spanning_a_cell_boundary_blocks_in_both_cells_clamped_to_each() { let (cx, cy) = test_cell(); - // One wall running along x from mid-cell into the next cell over (cx + 1 is lower x). let y = at(0, 32).1; let (x_a, x_b) = ( sub_center(cx, 40, WALK_DIM), @@ -1990,11 +1812,8 @@ mod derive_tests { ]; let here = derive_cell(cx, cy, Some(&flat_heights()), &tris).expect("blocks this cell"); let over = derive_cell(cx + 1, cy, Some(&flat_heights()), &tris).expect("and the next one"); - // Neither derivation drops the triangle for reaching past its cell, and each writes only - // its own sub-cells: the seam is blocked from both sides. assert!(!walk_get(&here.walk, 63, 32) && !walk_get(&over.walk, 0, 32)); assert!(!walk_get(&here.walk, 40, 32) && !walk_get(&over.walk, 10, 32)); - // Past each end of the wall the cell stays open: the span is clamped, not stamped whole. assert!(walk_get(&here.walk, 36, 32) && walk_get(&over.walk, 14, 32)); } @@ -2002,10 +1821,8 @@ mod derive_tests { fn geometry_outside_the_standing_band_is_not_a_blocker() { let (cx, cy) = test_cell(); let h = flat_heights(); - // A doorstep below step-up height: walk onto it, no row at all. let step = slab(GROUND_Z + WALK_STEP_UP - 0.5, wmo()); assert!(derive_cell(cx, cy, Some(&h), &[step]).is_none()); - // A ceiling well above head height: walk under it, no row at all. let ceiling = slab(GROUND_Z + 5.0, wmo()); assert!(derive_cell(cx, cy, Some(&h), &[ceiling]).is_none()); } @@ -2013,16 +1830,12 @@ mod derive_tests { #[test] fn the_standing_band_under_doodads_follows_terrain_ground() { let (cx, cy) = test_cell(); - // Ground rises 1 yd per outer-corner row along x (index i*17+j, i counts along x). let mut heights = vec![0.0f32; 145]; for i in 0..9 { for j in 0..9 { heights[i * 17 + j] = GROUND_Z + i as f32; } } - // One flat slab 5 yd above the cell's low end. Ground at walk column nx is - // GROUND_Z + (nx + 0.5) / 8, so the slab sits inside the band only where the ground has - // climbed to within [step-up, head height] of it. let cell = derive_cell( cx, cy, @@ -2045,10 +1858,10 @@ mod derive_tests { #[test] fn merging_ands_walkability_and_keeps_the_taller_obstruction() { let mut terrain = open_terrain(); - walk_set(&mut terrain.walk, 5, 5, false); // a hole or a steep quad + walk_set(&mut terrain.walk, 5, 5, false); obs_raise(&mut terrain.obs, terrain.base_z, 2, 2, GROUND_Z + 3.0); let mut coverage = NavCellData { - base_z: GROUND_Z - 4.0, // coverage floors on its own lowest triangle + base_z: GROUND_Z - 4.0, walk: vec![0xFFu8; WALK_BYTES], obs: vec![OBS_NONE; OBS_BYTES], }; @@ -2064,7 +1877,6 @@ mod derive_tests { ); assert!(!walk_get(&merged.walk, 5, 5) && !walk_get(&merged.walk, 9, 9)); assert!(walk_get(&merged.walk, 7, 7)); - // Contested column keeps the taller top; each source's own columns survive rebasing. let top = obs_top(&merged.obs, merged.base_z, 2, 2).unwrap(); assert!((top - (GROUND_Z + 9.0)).abs() <= OBS_STEP); let top = obs_top(&merged.obs, merged.base_z, 4, 4).unwrap(); diff --git a/crates/lyracore-shared/src/opcodes.rs b/crates/lyracore-shared/src/opcodes.rs index edef15837..12670f902 100644 --- a/crates/lyracore-shared/src/opcodes.rs +++ b/crates/lyracore-shared/src/opcodes.rs @@ -1,38 +1,26 @@ -//! Opcode numbers for the slice. Values are the well-known vanilla 1.12.1 (build 5875) -//! constants. The gateway uses `wow_world_messages` for the actual byte layouts; these -//! numbers are the gateway's inbound dispatch keys (matched directly in `world::dispatch`). -//! -//! Cross-check against `wow_world_messages` opcode enums before wiring a new handler — the codec -//! is the source of truth for layouts; this module is the source of truth for *which* -//! opcodes the slice handles. - -/// Logon-server commands (port 3724). 1-byte command tag, not the world framing. +// Logon command tags are one byte. pub mod logon { pub const CMD_AUTH_LOGON_CHALLENGE: u8 = 0x00; pub const CMD_AUTH_LOGON_PROOF: u8 = 0x01; pub const CMD_REALM_LIST: u8 = 0x10; } -/// World-server opcodes (port 8085). SMSG headers are 4 bytes, CMSG headers 6 bytes. +// SMSG headers are 4 bytes; CMSG headers are 6 bytes. pub mod world { - // --- auth handshake --- pub const SMSG_AUTH_CHALLENGE: u16 = 0x1EC; pub const CMSG_AUTH_SESSION: u32 = 0x1ED; pub const SMSG_AUTH_RESPONSE: u16 = 0x1EE; - // --- character select / login --- pub const CMSG_CHAR_ENUM: u32 = 0x037; pub const SMSG_CHAR_ENUM: u16 = 0x03B; pub const CMSG_PLAYER_LOGIN: u32 = 0x03D; - pub const CMSG_LOGOUT_REQUEST: u32 = 0x04B; // stretch - pub const SMSG_LOGOUT_RESPONSE: u16 = 0x04C; // stretch - pub const SMSG_LOGOUT_COMPLETE: u16 = 0x04D; // stretch + pub const CMSG_LOGOUT_REQUEST: u32 = 0x04B; + pub const SMSG_LOGOUT_RESPONSE: u16 = 0x04C; + pub const SMSG_LOGOUT_COMPLETE: u16 = 0x04D; - // --- /played --- pub const CMSG_PLAYED_TIME: u32 = 0x1CC; pub const SMSG_PLAYED_TIME: u16 = 0x1CD; - // --- login sequence (S->C) --- pub const SMSG_LOGIN_SETTIMESPEED: u16 = 0x042; pub const SMSG_TUTORIAL_FLAGS: u16 = 0x0FD; pub const SMSG_INITIALIZE_FACTIONS: u16 = 0x122; @@ -43,15 +31,12 @@ pub mod world { pub const SMSG_SET_REST_START: u16 = 0x21E; pub const SMSG_LOGIN_VERIFY_WORLD: u16 = 0x236; - // --- world state / object update --- pub const SMSG_UPDATE_OBJECT: u16 = 0x0A9; pub const SMSG_DESTROY_OBJECT: u16 = 0x0AA; - pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x1F6; // deferred + pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x1F6; } -/// `MSG_MOVE_*` opcodes. These flow **both** directions: the client sends its own movement, -/// the server relays the same opcode to nearby observers. The slice handles the subset that -/// looks like real movement (forward/stop/turn/facing/heartbeat). +// MSG_MOVE opcodes travel in both directions. pub mod movement { pub const MSG_MOVE_START_FORWARD: u32 = 0x0B5; pub const MSG_MOVE_START_BACKWARD: u32 = 0x0B6; @@ -71,7 +56,6 @@ pub mod movement { pub const MSG_MOVE_SET_FACING: u32 = 0x0DA; pub const MSG_MOVE_HEARTBEAT: u32 = 0x0EE; - /// The movement opcodes the slice routes to `movement_update`. pub const SLICE_MOVE_OPCODES: &[u32] = &[ MSG_MOVE_START_FORWARD, MSG_MOVE_START_BACKWARD, @@ -92,7 +76,6 @@ pub mod movement { MSG_MOVE_HEARTBEAT, ]; - /// True if `opcode` is a movement opcode the slice relays. pub fn is_slice_move(opcode: u32) -> bool { SLICE_MOVE_OPCODES.contains(&opcode) } @@ -104,7 +87,6 @@ mod tests { #[test] fn slice_move_membership() { - // Every one of the 17 SLICE_MOVE_OPCODES entries is recognized (not just a hand-picked subset). for &opcode in movement::SLICE_MOVE_OPCODES { assert!( movement::is_slice_move(opcode), @@ -112,11 +94,10 @@ mod tests { ); } assert_eq!(movement::SLICE_MOVE_OPCODES.len(), 17); - // Non-movement opcodes (world handshake/object codes) are never mistaken for movement. assert!(!movement::is_slice_move(world::CMSG_PLAYER_LOGIN)); assert!(!movement::is_slice_move(world::SMSG_UPDATE_OBJECT as u32)); assert!(!movement::is_slice_move(world::CMSG_AUTH_SESSION)); - assert!(!movement::is_slice_move(0)); // opcode 0 is never valid - assert!(!movement::is_slice_move(u32::MAX)); // an out-of-range sentinel + assert!(!movement::is_slice_move(0)); + assert!(!movement::is_slice_move(u32::MAX)); } } diff --git a/crates/lyracore-shared/src/packing.rs b/crates/lyracore-shared/src/packing.rs index deaeb0f6c..be59f5e60 100644 --- a/crates/lyracore-shared/src/packing.rs +++ b/crates/lyracore-shared/src/packing.rs @@ -1,35 +1,20 @@ -//! Bit-packing helpers for the few descriptor fields the slice writes as packed bytes. -//! -//! Byte order within each packed field follows the 1.12 convention. Confirm against the -//! generated `wow_world_messages` definitions before relying on the exact byte positions -//! for appearance (`PLAYER_BYTES*`); the semantic intent is documented inline. - -/// Pack `UNIT_FIELD_BYTES_0`: byte0 = race, byte1 = class, byte2 = gender, byte3 = power type. pub const fn unit_bytes_0(race: u8, class: u8, gender: u8, power_type: u8) -> u32 { (race as u32) | ((class as u32) << 8) | ((gender as u32) << 16) | ((power_type as u32) << 24) } -/// Pack `PLAYER_BYTES`: byte0 = skin, byte1 = face, byte2 = hair style, byte3 = hair color. pub const fn player_bytes(skin: u8, face: u8, hair_style: u8, hair_color: u8) -> u32 { (skin as u32) | ((face as u32) << 8) | ((hair_style as u32) << 16) | ((hair_color as u32) << 24) } -/// Rest state, stored in `PLAYER_BYTES_2` byte 3. MUST be a valid value (RESTED=0x01, -/// NORMAL=0x02): byte 3 = 0 makes the 5875 client's `GetRestState()` return a nil exhaustion -/// multiplier, crashing `MainMenuBar.lua` (the XP/rested bar) with an arithmetic-on-nil error. +// PLAYER_BYTES_2 byte 3 must be RESTED or NORMAL; zero crashes the client XP bar. pub const REST_STATE_NORMAL: u8 = 0x02; -/// RESTED — the client draws the zzz icon + turns the XP bar blue when byte 3 holds this. pub const REST_STATE_RESTED: u8 = 0x01; -/// Pack `PLAYER_BYTES_2`: byte0 = facial hair, byte2 = owned bank bag slot count, byte3 = rest state -/// (NORMAL so the client's XP/rested bar has a valid exhaustion state). Byte 1 is unused. +// PLAYER_BYTES_2: facial hair in byte 0, bank slots in byte 2, rest state in byte 3. pub const fn player_bytes_2(facial_hair: u8, bank_bag_slots: u8) -> u32 { player_bytes_2_with_rest(facial_hair, bank_bag_slots, false) } -/// Pack `PLAYER_BYTES_2` with an explicit rest state — `resting` → RESTED byte (zzz + blue bar), -/// else NORMAL. Used at spawn so a character who logged out in an inn logs back in already showing -/// rested, and the purchased bank bag slot count shows without a relog. pub const fn player_bytes_2_with_rest(facial_hair: u8, bank_bag_slots: u8, resting: bool) -> u32 { let rest = if resting { REST_STATE_RESTED @@ -39,21 +24,14 @@ pub const fn player_bytes_2_with_rest(facial_hair: u8, bank_bag_slots: u8, resti (facial_hair as u32) | ((bank_bag_slots as u32) << 16) | ((rest as u32) << 24) } -/// Replace byte 2 (owned bank bag slot count) on an already-packed `PLAYER_BYTES_2`, leaving byte 0 -/// (facial hair) and byte 3 (rest state — see the HAZARD note on [`REST_STATE_NORMAL`]) untouched. -/// Used by the purchase path, which knows the new count but not the other two bytes' current values. pub const fn with_bank_bag_slots(packed: u32, bank_bag_slots: u8) -> u32 { (packed & 0xFF00_FFFF) | ((bank_bag_slots as u32) << 16) } -/// Unpack a packed `u32` descriptor field back into its four little-endian bytes — the inverse of -/// the packers above. Lives here so the descriptor byte format has a single home (the gateway -/// codec unpacks the same fields the module/seed pack). pub const fn unpack4(v: u32) -> (u8, u8, u8, u8) { (v as u8, (v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8) } -/// Power type ids (the 4th byte of `UNIT_FIELD_BYTES_0`). pub mod power_type { pub const MANA: u8 = 0; pub const RAGE: u8 = 1; @@ -61,12 +39,11 @@ pub mod power_type { pub const ENERGY: u8 = 3; pub const HAPPINESS: u8 = 4; - /// The power type for a given class (slice-relevant classes). pub const fn for_class(class: u8) -> u8 { match class { - 1 => RAGE, // Warrior - 4 => ENERGY, // Rogue - _ => MANA, // everyone else (slice uses Warrior) + 1 => RAGE, + 4 => ENERGY, + _ => MANA, } } } @@ -77,19 +54,18 @@ mod tests { #[test] fn unit_bytes_0_layout() { - // Human(1) Warrior(1) male(0) rage(1) let v = unit_bytes_0(1, 1, 0, power_type::RAGE); - assert_eq!(v & 0xFF, 1); // race - assert_eq!((v >> 8) & 0xFF, 1); // class - assert_eq!((v >> 16) & 0xFF, 0); // gender - assert_eq!((v >> 24) & 0xFF, 1); // power type = rage + assert_eq!(v & 0xFF, 1); + assert_eq!((v >> 8) & 0xFF, 1); + assert_eq!((v >> 16) & 0xFF, 0); + assert_eq!((v >> 24) & 0xFF, 1); } #[test] fn for_class_maps_warrior_rage_rogue_energy_others_mana() { - assert_eq!(power_type::for_class(1), power_type::RAGE); // Warrior - assert_eq!(power_type::for_class(4), power_type::ENERGY); // Rogue - assert_eq!(power_type::for_class(2), power_type::MANA); // everyone else (e.g. Paladin) + assert_eq!(power_type::for_class(1), power_type::RAGE); + assert_eq!(power_type::for_class(4), power_type::ENERGY); + assert_eq!(power_type::for_class(2), power_type::MANA); } #[test] @@ -108,7 +84,6 @@ mod tests { unpack4(player_bytes(0x11, 0x22, 0x33, 0x44)), (0x11, 0x22, 0x33, 0x44) ); - // player_bytes_2 must carry a valid rest state in byte 3 (else the client XP-bar Lua crashes). assert_eq!(unpack4(player_bytes_2(7, 0)), (7, 0, 0, REST_STATE_NORMAL)); } @@ -123,7 +98,7 @@ mod tests { #[test] fn with_bank_bag_slots_touches_only_byte_2() { - let packed = player_bytes_2_with_rest(9, 1, true); // facial hair 9, RESTED + let packed = player_bytes_2_with_rest(9, 1, true); let updated = with_bank_bag_slots(packed, 4); assert_eq!(unpack4(updated), (9, 0, 4, REST_STATE_RESTED)); } diff --git a/crates/lyracore-shared/src/pet.rs b/crates/lyracore-shared/src/pet.rs index 21a7be5ad..ae13c5908 100644 --- a/crates/lyracore-shared/src/pet.rs +++ b/crates/lyracore-shared/src/pet.rs @@ -1,19 +1,8 @@ -//! The deterministic pet guid: derived from the owner's guid, so both the Module's own pet reads -//! and the Gateway's Member Stats read can find a pet with one keyed lookup instead of a table -//! scan. - -/// `HIGHGUID_UNIT` (0xF130): the high 16 bits of every creature guid. The 5875 client classifies -/// an object (Unit vs other) from its guid's HIGHGUID type, so a pet must carry it or the client -/// cannot treat it as a unit (broken nameplate, targeting, pet bar). +// The client classifies creature GUIDs by their high 16 bits. const HIGHGUID_UNIT: u64 = 0xF130; -/// Vanilla's separate namespace for a pet owned by another server-authored unit. const HIGHGUID_PET: u64 = 0xF140; -/// The deterministic pet guid for an owner. Player pets retain the established `HIGHGUID_UNIT` -/// namespace. A creature-owned spell guardian uses vanilla's `HIGHGUID_PET`, otherwise copying the -/// creature owner's low 48 bits would reproduce the owner's guid and collide on insert. Stable so -/// the Module's keyed `pet_of` read, its despawn delete, and the Gateway's Member Stats pet read -/// all agree. Pure. +// Creature-owned guardians use HIGHGUID_PET to avoid colliding with their owner guid. pub fn pet_guid_for(owner_guid: u64) -> u64 { let high = if owner_guid >> 48 == HIGHGUID_UNIT { HIGHGUID_PET @@ -29,14 +18,14 @@ mod tests { #[test] fn a_player_owners_pet_keeps_the_unit_namespace() { - let owner = 7; // a player guid: no HIGHGUID tag of its own + let owner = 7; assert_eq!(pet_guid_for(owner) >> 48, HIGHGUID_UNIT); assert_eq!(pet_guid_for(owner) & 0x0000_FFFF_FFFF_FFFF, 7); } #[test] fn a_creature_owners_guardian_uses_the_pet_namespace() { - let owner = (HIGHGUID_UNIT << 48) | 11; // the owner is itself a creature + let owner = (HIGHGUID_UNIT << 48) | 11; assert_eq!(pet_guid_for(owner) >> 48, HIGHGUID_PET); } } diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index e914ab593..d17fa4a6a 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -1,81 +1,35 @@ -//! Quest math shared between the module (which GRANTS the reward) and the gateway (which DISPLAYS it -//! in `SMSG_QUESTGIVER_QUEST_COMPLETE`). Keeping the formula here means the two can never drift — the -//! number the completion popup shows is exactly the number the turn-in awards. - -/// The XP reward for a quest of `quest_level`, used when its template carries no explicit `reward_xp` -/// (vanilla 1.12 has no RewXX column — it's derived). Simplified monotonic placeholder: `45*level + 40` -/// (saturating, so a garbage-high level can't wrap). Pure. Replace with the real per-level quest-XP -/// table when we ingest it (mirrors `xp::xp_to_next_level` being a documented stand-in). +// Placeholder XP curve for templates without an explicit reward. pub fn xp_reward(quest_level: u32) -> u32 { quest_level.saturating_mul(45).saturating_add(40) } -/// Quest RACE gate: `required_races` is a cmangos bitmask of allowed races (`0` = all races). Race -/// `race` (1-based, Human=1, Orc=2, …, the `UNIT_FIELD_BYTES_0` race byte) is allowed iff the mask is 0 -/// or its bit `1 << (race-1)` is set. Shared by the module's accept gate AND the gateway's `!` status so -/// the icon and the reducer agree on who can take a quest. Pure. pub fn race_allowed(required_races: u32, race: u8) -> bool { required_races == 0 || ((1..=32).contains(&race) && required_races & (1u32 << (race - 1)) != 0) } -/// Quest CLASS gate: `required_classes` is a cmangos bitmask of allowed classes (`0` = all classes). -/// Class `class` (1-based, Warrior=1, Paladin=2, …) is allowed iff the mask is 0 or bit `1 << (class-1)` -/// is set. The class twin of [`race_allowed`]. Pure. pub fn class_allowed(required_classes: u32, class: u8) -> bool { required_classes == 0 || ((1..=32).contains(&class) && required_classes & (1u32 << (class - 1)) != 0) } -/// [V] vanilla's max-level (60) quest XP→copper conversion factor. cmangos's real -/// `RewMoneyMaxLevel`/gold-at-cap table isn't reachable in this sandbox (no client/dump/live-node -/// access to confirm it) — this is a flat, documented placeholder: 6 copper per XP point the quest -/// would otherwise have granted. Shared by the module's turn-in payout -/// (`module/src/quest.rs::apply_turn_in_quest`) and the gateway's `max_level_money_reward` wire stub -/// (`gateway/src/codec/quest.rs::build_quest_query_response_raw`) so the quest-log preview number is -/// EXACTLY what a level-60 turn-in pays — replace both call sites together if a real coefficient is -/// ever sourced. +// Placeholder conversion of six copper per XP at the level cap. pub const MAX_LEVEL_XP_TO_COPPER: u32 = 6; -/// The level-cap money conversion for a quest that would have granted `xp` experience. Pure. pub fn max_level_money_reward(xp: u32) -> u32 { xp.saturating_mul(MAX_LEVEL_XP_TO_COPPER) } -/// Authentic vanilla quest XP — the same award the mangos-family cores are observed to pay (a -/// behavioural reference, not a derivation; the arithmetic below is deliberately not theirs): the reward is -/// `RewMoneyMaxLevel / 0.6` — the dump's populated per-quest field (ingested as -/// `QuestTemplate.reward_money_max_level`). Replaces the `xp_reward` placeholder, which awarded ~half -/// of this. Integer `ceil(mm*5/3)` on purpose — a float `/0.6` is inexact (0.6 has no exact binary -/// form) and mis-rounds at exact boundaries (270/0.6 → 450.0000…16 → ceil 451, wrong). Vanilla's max -/// quest level is 60, so cmangos's 61-65 divisor ladder is unreachable and omitted. -/// -/// The over-level PENALTY (cmangos reduces the reward when `player_level > quest_level + 5`, down to -/// 10% for grey quests) is a deferred refinement: when added it must be applied IDENTICALLY at the -/// award (`module/src/quest.rs`) and the gateway preview so the completion popup always equals the -/// grant — the whole reason this math lives in one shared place. `reward_money_max_level == 0` (a -/// no-XP quest, ~18% of rows) returns 0. Pure. +// Vanilla XP is ceil(RewMoneyMaxLevel / 0.6). pub fn quest_xp(reward_money_max_level: u32) -> u32 { if reward_money_max_level == 0 { return 0; } - // `div_ceil(3)` IS `(n + 2) / 3` for unsigned n — same rounding, same value, no overflow - // difference at u64 width. Kept as the named operation so the "round up" intent is explicit. (((reward_money_max_level as u64) * 5).div_ceil(3)) as u32 } -/// (sharing): kinds carried in `game_group_event.kind` for `push_quest_to_party`'s -/// notifications. Reserved slots `10..=11`; kind 9 is retired party chat and never reused. See -/// `crate::group::event_kind` for the full reserved-range bookkeeping (loot-roll kinds `4..=8` live -/// in the sibling `crate::loot_roll` file). +// game_group_event kind 9 is retired; quest sharing uses 10 and 11. pub mod share_event_kind { - /// An eligible party member receives the shared quest → the gateway relay opens - /// `SMSG_QUESTGIVER_QUEST_DETAILS` for it (the sharer as "giver" — see module `GiverKind::Party`). - /// Payload: the quest entry (decimal string). pub const QUEST_SHARE: u8 = 10; - /// The SENDER's per-member feedback (one row per member, always) → `MSG_QUEST_PUSH_RESULT`. - /// Payload: the [`super::share_result`] code (decimal string) — mirrors gtker's - /// `wow_world_base::shared::quest_party_message_vanilla_tbc::QuestPartyMessage::as_int()` 1:1, so - /// the gateway's decode is a straight `try_into()`, never a translation table. pub const QUEST_PUSH_RESULT: u8 = 11; } @@ -88,12 +42,6 @@ pub mod share_result { pub const FINISH_QUEST: u8 = 8; } -/// The pure per-member decision for `push_quest_to_party` (module `quest::evaluate_share` gathers the -/// live booleans; this fn just picks the outcome — unit-tested here without a `ReducerContext`). -/// Priority order (first match wins, mirrors vanilla's own precedence): offline/wrong-map/out-of-range -/// → `TOO_FAR`; already actively on the quest → `HAVE_QUEST`; already turned in (non-repeatable) → -/// `FINISH_QUEST`; quest log full → `LOG_FULL`; level/race/class gates unmet → `CANT_TAKE_QUEST`; else -/// → `SHARING_QUEST` (the offer actually lands). #[allow(clippy::too_many_arguments)] pub fn share_result( member_online: bool, @@ -119,27 +67,12 @@ pub fn share_result( } } -/// The vanilla Raid Quest type (`quest_template.Type`, cm:QuestDef.h:121). A Raid Quest is the -/// only quest that keeps progressing kill credit, quest-item need and quest-object need for a -/// Raid member (cm:Player.cpp:13544-13559, cm:Player.cpp:13796-13799, cm:Player.cpp:18510-18511). -/// Shared between the Module (the credit and need authority) and the Gateway (the loot-window -/// display, which must never promise an item a Raid member's `take_loot` would then refuse). pub const QUEST_TYPE_RAID: u32 = 62; -/// Whether a quest of `quest_type` is allowed to progress for a Raid member. cmangos also checks -/// `Quests.IgnoreRaid`; LyraCore ships this at parity with the default (0, off), so the check is -/// `quest_type` alone (cm:QuestDef.cpp:219-225, cm:mangosd.conf.dist.in:852). pub fn allowed_in_raid(quest_type: u32) -> bool { quest_type == QUEST_TYPE_RAID } -/// Whether a character's progress toward a quest of `quest_type` should apply right now, given -/// whether that character is in a Raid. A Party member, and every quest outside a Raid, always -/// progresses; a Raid member takes no further kill credit, quest-item drop or quest-object use -/// for a quest that is not a Raid Quest (cm:Player.cpp:13544-13559). Pure, so the rule is -/// unit-testable without a live cache and identical on both sides. Cast credit and exploration -/// credit are deliberately not routed through this rule; only kill credit and quest-object use -/// are gated (cmangos leaves cast/exploration credit ungated). pub fn quest_progresses_for_raid(in_raid: bool, quest_type: u32) -> bool { !in_raid || allowed_in_raid(quest_type) } @@ -148,8 +81,6 @@ pub fn quest_progresses_for_raid(in_raid: bool, quest_type: u32) -> bool { mod tests { use super::*; - /// Only `QUEST_TYPE_RAID` (62) is allowed in a Raid. A normal quest (0) and an unrelated - /// non-zero type (a dungeon quest, cmangos type 81) are both refused. #[test] fn allowed_in_raid_only_accepts_the_raid_quest_type() { assert!(!allowed_in_raid(0)); @@ -157,8 +88,6 @@ mod tests { assert!(!allowed_in_raid(81)); } - /// Over the full (in raid, quest type) input: outside a Raid, every quest type progresses; - /// inside a Raid, only a Raid Quest does. #[test] fn quest_progresses_for_raid_gates_only_a_raid_members_non_raid_quest() { assert!(quest_progresses_for_raid(false, 0)); @@ -178,65 +107,47 @@ mod tests { #[test] fn quest_xp_is_authentic_and_exact() { - // Verified against cmangos RewMoneyMaxLevel/0.6 (parsed from the dump): these are the exact - // authentic values, ~2x the old xp_reward placeholder. - assert_eq!(quest_xp(102), 170); // Kobold Camp Cleanup (ql2) — was xp_reward(2)=130 - assert_eq!(quest_xp(150), 250); // Investigate Echo Ridge (ql3) - assert_eq!(quest_xp(270), 450); // Skirmish at Echo Ridge (ql5) — was xp_reward(5)=265 - assert_eq!(quest_xp(0), 0); // a no-XP quest awards nothing (falls through to xp_reward) - // integer ceil never mis-rounds where a float /0.6 would - assert_eq!(quest_xp(3), 5); // ceil(3*5/3)=5 - assert!(quest_xp(u32::MAX) > 0); // no overflow (u64 intermediate) + assert_eq!(quest_xp(102), 170); + assert_eq!(quest_xp(150), 250); + assert_eq!(quest_xp(270), 450); + assert_eq!(quest_xp(0), 0); + assert_eq!(quest_xp(3), 5); + assert!(quest_xp(u32::MAX) > 0); } #[test] fn race_class_gates_respect_bitmask_and_zero_means_all() { - // 0 = no restriction → anyone qualifies. assert!(race_allowed(0, 1)); assert!(class_allowed(0, 1)); - // Northshire human chain: RequiredRaces 77 = 0x4D = Human(1)|Dwarf(4)|NightElf(8)|Gnome(64). - assert!(race_allowed(77, 1)); // Human (bit 0) - assert!(race_allowed(77, 3)); // Dwarf (bit 2) - assert!(!race_allowed(77, 2)); // Orc (bit 1) — Horde, excluded - assert!(!race_allowed(77, 5)); // Undead (bit 4) — excluded - // Warrior-only quest (RequiredClasses 1 = bit 0). - assert!(class_allowed(1, 1)); // Warrior - assert!(!class_allowed(1, 2)); // Paladin - // A 0 race/class id never matches a restrictive mask (defensive). + assert!(race_allowed(77, 1)); + assert!(race_allowed(77, 3)); + assert!(!race_allowed(77, 2)); + assert!(!race_allowed(77, 5)); + assert!(class_allowed(1, 1)); + assert!(!class_allowed(1, 2)); assert!(!race_allowed(77, 0)); assert!(!class_allowed(1, 0)); } #[test] fn race_class_gates_pin_the_bit31_bit33_range_boundary() { - // race=32 is the LAST valid bit position (1 << 31, the top bit of the u32 mask) — still in - // range, and it's allowed when that top bit is set in the mask. let mask_bit31: u32 = 1 << 31; assert!(race_allowed(mask_bit31, 32)); assert!(class_allowed(mask_bit31, 32)); - // race=33 would need `1 << 32`, which overflows a u32 shift — the `(1..=32)` range guard - // must reject it BEFORE the shift executes, never panicking, always just "not allowed". assert!(!race_allowed(mask_bit31, 33)); assert!(!class_allowed(mask_bit31, 33)); - // Same guard applies to u8::MAX, the far end of the race/class byte's range. assert!(!race_allowed(u32::MAX, u8::MAX)); assert!(!class_allowed(u32::MAX, u8::MAX)); } - // ---- Level-cap payout ---- - #[test] fn max_level_money_reward_uses_the_documented_v_coefficient() { assert_eq!(MAX_LEVEL_XP_TO_COPPER, 6); assert_eq!(max_level_money_reward(0), 0); assert_eq!(max_level_money_reward(100), 600); - assert_eq!(max_level_money_reward(u32::MAX), u32::MAX); // saturates, never wraps/panics + assert_eq!(max_level_money_reward(u32::MAX), u32::MAX); } - // ---- Sharing: share_event_kind distinctness + share_result priority ---- - - /// `QUEST_SHARE`/`QUEST_PUSH_RESULT` (10/11) follow every loot-roll kind (4..=8) and skip kind - /// 9, retired party chat, which nothing may reuse. #[test] fn share_event_kinds_are_distinct_and_skip_retired_kind_9() { use crate::group::event_kind as g; @@ -264,8 +175,6 @@ mod tests { ); } - /// `share_result`'s wire codes mirror gtker's `QuestPartyMessage::as_int()` exactly — a mismatch - /// here would silently pick the wrong client-side dialog/toast. #[test] fn share_result_codes_mirror_the_gtker_quest_party_message_enum() { assert_eq!(share_result::SHARING_QUEST, 0); @@ -276,17 +185,12 @@ mod tests { assert_eq!(share_result::FINISH_QUEST, 8); } - /// `share_result`'s priority order: TOO_FAR beats every other reason (an offline/out-of-range - /// member's OTHER state doesn't matter), then HAVE_QUEST, then FINISH_QUEST, then LOG_FULL, then - /// CANT_TAKE_QUEST, and only every gate passing yields SHARING_QUEST. #[test] fn share_result_priority_order() { - // The all-pass baseline succeeds. assert_eq!( share_result(true, true, true, false, false, false, true), share_result::SHARING_QUEST ); - // Out-of-range/offline/wrong-map beats EVERY other condition, even a simultaneous HAVE_QUEST. assert_eq!( share_result(false, true, true, true, true, true, false), share_result::TOO_FAR @@ -299,22 +203,18 @@ mod tests { share_result(true, true, false, false, false, false, true), share_result::TOO_FAR ); - // Already active beats finished/log-full/gates. assert_eq!( share_result(true, true, true, true, true, true, false), share_result::HAVE_QUEST ); - // Finished (non-repeatable) beats log-full/gates. assert_eq!( share_result(true, true, true, false, true, true, false), share_result::FINISH_QUEST ); - // Log-full beats gates. assert_eq!( share_result(true, true, true, false, false, true, false), share_result::LOG_FULL ); - // Gates unmet, nothing else wrong. assert_eq!( share_result(true, true, true, false, false, false, false), share_result::CANT_TAKE_QUEST @@ -322,7 +222,6 @@ mod tests { } } -/// Durable objective-kind bytes. pub mod objective_kind { pub const KILL_CREATURE: u8 = 0; pub const COLLECT_ITEM: u8 = 1; @@ -330,7 +229,6 @@ pub mod objective_kind { pub const EXPLORE_AREATRIGGER: u8 = 3; } -/// Facts for one objective. Inventory quantities are supplied by the owning tier. #[derive(Clone, Copy)] pub struct Objective { pub kind: u8, @@ -339,7 +237,6 @@ pub struct Objective { pub required_count: u32, } -/// All objectives and any event requirement must be met. This does not check quest state. pub fn objectives_complete( objectives: impl IntoIterator, counts: &[u32], @@ -380,7 +277,6 @@ pub enum AcceptanceRefusal { AlreadyTaken, } -/// Apply acceptance Gates in the Module's order. A failed attempt may always be retried. pub fn acceptance( requirements: AcceptanceRequirements, level: u32, @@ -409,7 +305,6 @@ pub fn acceptance( Ok(()) } -/// Resolve the unscaled reward. The Module applies the realm XP rate after this selection. pub fn base_reward_xp(explicit_xp: u32, reward_money_max_level: u32, quest_level: u32) -> u32 { if explicit_xp > 0 { explicit_xp diff --git a/crates/lyracore-shared/src/region.rs b/crates/lyracore-shared/src/region.rs index 4c59abf2a..ea55ec85a 100644 --- a/crates/lyracore-shared/src/region.rs +++ b/crates/lyracore-shared/src/region.rs @@ -1,46 +1,10 @@ -//! Regions: the middle rung of the cell / region / shard hierarchy. -//! -//! Cells (50yd, [`crate::spatial`]) stay the fixed spatial primitive. A **region** is a contiguous -//! set of cells defined in DATA — never computed from gameplay state — and a **shard** owns one or -//! more regions through the epoch-versioned assignment table on realm-core. This module owns the -//! bottom half of that hierarchy: `region_of(map_id, cell)` as a BAKED LOOKUP, shared verbatim by -//! the module (which stores the definitions) and the gateway (which routes with them), so the two -//! tiers cannot disagree about which region a point is in. -//! -//! # A region is a cell RECTANGLE -//! -//! The spec's requirement is "contiguous cell sets, floor ~10×10 cells" — the floor comes from the -//! ~100yd interaction radius (`spatial::BOX_HALF_SPAN × GRID_CELL_SIZE`): a region smaller than the -//! interaction radius is *all seam*, so every interaction inside it would cross a shard boundary. -//! -//! # The format is content data -//! -//! [`RegionMap::parse`] defines the region format and geometry rules. - -// Deliberate simplification: a region is an axis-aligned cell RECTANGLE, not an arbitrary -// contiguous cell set. A rectangle is trivially contiguous, `region_of` is four integer -// comparisons instead of a hash-set probe, and a zone's seam menu stays legible as five lines of -// text an operator can eyeball. Ceiling: you cannot draw an L-shaped region around a lake — you -// draw two rectangles and assign both to the same shard, which costs nothing because a seam -// between same-shard regions is free (that is the whole point of the design). Upgrade path if a -// zone ever genuinely needs one: keep this type as the storage row and add a `region_id`-grouped -// multi-rect form; `region_of`'s signature does not change. - use crate::spatial::{grid_cell, GRID_CELL_SIZE}; -/// Cells outside the authored rectangles use the ordinary `(map, instance)` Shard map. -/// Region zero cannot receive an assignment. +// Region zero uses the ordinary map and instance Shard map. pub const DEFAULT_REGION: u32 = 0; -/// Minimum side of a region, in cells — the spec's ~10×10 floor. The ~100yd interaction radius is -/// `BOX_HALF_SPAN` (2) cells, so a 10-cell (500yd) side leaves a ~300yd seam-free interior after the -/// 2-cell interaction band on each edge; anything much smaller is *all seam*, and every interaction -/// inside it crosses a shard boundary. Enforced at DEFINITION time ([`RegionMap::build`]), which is -/// the only place it can be enforced cheaply. pub const MIN_REGION_SIDE_CELLS: i32 = 10; -/// One region definition: `region_id` on `map_id` covers the inclusive cell rectangle -/// `[gx_min..=gx_max] × [gy_min..=gy_max]`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Region { pub map_id: u32, @@ -52,7 +16,6 @@ pub struct Region { } impl Region { - /// Does this region cover cell `(gx, gy)` on `map_id`? pub fn contains_cell(&self, map_id: u32, gx: i32, gy: i32) -> bool { map_id == self.map_id && gx >= self.gx_min @@ -61,11 +24,6 @@ impl Region { && gy <= self.gy_max } - /// Width × height in cells (inclusive bounds). Saturating, because the bounds come from OPERATOR - /// TEXT: `0:1 = -2147483648..2147483647, 0..99` overflows a plain subtraction, which panics a - /// debug build (the import reducer, and the gateway's rebuild of the menu on every world entry) - /// instead of rejecting the row. A saturated side is still enormous, so it is accepted on its - /// merits rather than by accident. pub fn size_cells(&self) -> (i32, i32) { let side = |lo: i32, hi: i32| hi.saturating_sub(lo).saturating_add(1); ( @@ -74,15 +32,12 @@ impl Region { ) } - /// Side length in yards, for the docs/log line that tells an operator what they just drew. pub fn size_yards(&self) -> (f32, f32) { let (w, h) = self.size_cells(); (w as f32 * GRID_CELL_SIZE, h as f32 * GRID_CELL_SIZE) } - /// Do two definitions claim the same cell? Overlap is rejected at build time: with overlap, - /// `region_of` would depend on row order, and row order out of a database subscription is not - /// something a routing decision may depend on. + // Overlapping regions would make routing depend on subscription row order. pub fn overlaps(&self, other: &Region) -> bool { self.map_id == other.map_id && self.gx_min <= other.gx_max @@ -92,22 +47,12 @@ impl Region { } } -/// The baked cell→region lookup for the whole realm: every map's accepted region definitions. -/// -/// With no accepted definitions, every point resolves to the ordinary Shard map. #[derive(Clone, Debug, Default, PartialEq)] pub struct RegionMap { regions: Vec, } impl RegionMap { - /// Build from definition rows, dropping any that cannot be routed with and reporting why. - /// - /// Rejections (each one a `String` for the caller to log or fail on): a region id of - /// [`DEFAULT_REGION`], inverted bounds, a side under [`MIN_REGION_SIDE_CELLS`], a duplicate - /// `(map_id, region_id)`, or an overlap with an already-accepted region. **A rejected row is - /// dropped, never partially applied** — the cells it claimed fall back to `DEFAULT_REGION` and - /// therefore to the ordinary shard map. A malformed seam menu can only collapse toward today. pub fn build(rows: impl IntoIterator) -> (Self, Vec) { let mut regions: Vec = Vec::new(); let mut rejected = Vec::new(); @@ -142,17 +87,7 @@ impl RegionMap { (Self { regions }, rejected) } - /// Parse the seam-menu content format (`RegionMap::parse`). One region per line (or per - /// `;`), `#` starts a comment: - /// - /// ```text - /// # : = .., .. - /// 0:1 = 460..479, 300..319 # Elwynn — Goldshire basin - /// ``` - /// - /// Returns the map plus one rejection line per row that did not make it, from either the syntax - /// or the [`RegionMap::build`] rules. - /// Strip comments before splitting row separators. A semicolon in prose must not become a row. + // Format: map:region = gx_min..gx_max, gy_min..gy_max; # starts a line comment. pub fn parse(text: &str) -> (Self, Vec) { let mut rows = Vec::new(); let mut rejected = Vec::new(); @@ -176,9 +111,6 @@ impl RegionMap { (map, rejected) } - /// **The baked lookup.** Which region owns cell `(gx, gy)` on `map_id`? [`DEFAULT_REGION`] when - /// no definition covers it. Overlap is impossible by construction, so the answer does not depend - /// on definition order. pub fn region_of(&self, map_id: u32, gx: i32, gy: i32) -> u32 { self.regions .iter() @@ -186,31 +118,19 @@ impl RegionMap { .map_or(DEFAULT_REGION, |r| r.region_id) } - /// [`RegionMap::region_of`] for a world position — the form routing actually calls, so the - /// position→cell conversion happens in exactly one place for both tiers. pub fn region_at(&self, map_id: u32, x: f32, y: f32) -> u32 { let (gx, gy) = grid_cell(x, y); self.region_of(map_id, gx, gy) } - /// The accepted definitions (diagnostics, and the docs generator). pub fn regions(&self) -> &[Region] { &self.regions } - /// No definitions at all — the identity map, where every point is [`DEFAULT_REGION`]. pub fn is_empty(&self) -> bool { self.regions.is_empty() } - /// Bucket a set of open-world positions (already resolved to cells) into per-region player - /// counts for the region occupancy gauge shared by the Gateway - /// (which owns the impure half: reading live positions off its subscription cache) and its - /// tests. - /// - /// [`DEFAULT_REGION`] is never returned: it is "the rest of the map", not a candidate seam, so a - /// count against it tells an operator nothing they can act on — the gateway's sample would just - /// be spending a ring-buffer slot on a bucket nobody can activate. pub fn count_by_region( &self, positions: impl IntoIterator, @@ -231,8 +151,6 @@ impl RegionMap { } } -/// `:=..,..` → a row, or `None` if it doesn't -/// parse. Bounds validation is [`RegionMap::build`]'s job; this is syntax only. fn parse_region_line(line: &str) -> Option { let (id_part, rect) = line.split_once('=')?; let (map, region) = id_part.split_once(':')?; @@ -249,7 +167,6 @@ fn parse_region_line(line: &str) -> Option { }) } -/// `a..b` → `(a, b)`. fn parse_range(s: &str) -> Option<(i32, i32)> { let (a, b) = s.trim().split_once("..")?; Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) @@ -272,9 +189,6 @@ mod tests { #[test] fn an_empty_region_map_answers_the_default_region_everywhere() { - // THE no-op property: with no definitions imported, every cell on every map is - // DEFAULT_REGION, which routes through the plain (map, instance) shard map. An - // un-imported deployment is byte-identical to one with no region code at all. let m = RegionMap::default(); assert!(m.is_empty()); assert_eq!(m.region_of(0, 0, 0), DEFAULT_REGION); @@ -314,15 +228,10 @@ mod tests { #[test] fn region_at_agrees_with_the_cell_math_both_tiers_already_bake() { - // `region_at` must be `region_of` composed with `spatial::grid_cell` and nothing else — - // if it drifted, the module's baked grid_x/grid_y and the gateway's routing would disagree - // about which region a player is standing in, which is the one thing this lookup exists to - // prevent. let (gx, gy) = grid_cell(-8949.95, -132.493); let (m, _) = RegionMap::build([rect(0, 7, gx - 5, gy - 5)]); assert_eq!(m.region_at(0, -8949.95, -132.493), 7); assert_eq!(m.region_of(0, gx, gy), 7); - // A point a whole region away is back on the default. assert_eq!( m.region_at(0, -8949.95 + 40.0 * GRID_CELL_SIZE, -132.493), DEFAULT_REGION @@ -331,9 +240,6 @@ mod tests { #[test] fn a_region_under_the_interaction_radius_floor_is_rejected() { - // The spec's geometry rule: floor ~10×10 cells, because a region smaller than the ~100yd - // interaction radius is all seam. A rejected row is DROPPED, so its cells fall back to - // DEFAULT_REGION rather than being routed by a definition nobody can play inside. let small = Region { map_id: 0, region_id: 1, @@ -350,7 +256,6 @@ mod tests { DEFAULT_REGION, "the rejected row routes nothing" ); - // Exactly at the floor is ACCEPTED (the boundary belongs to the legal side). let at_floor = Region { map_id: 0, region_id: 1, @@ -371,8 +276,6 @@ mod tests { #[test] fn overlapping_definitions_are_rejected_so_lookup_never_depends_on_row_order() { - // Two regions claiming one cell would make `region_of` — and therefore the shard a player - // lands on — depend on the order rows arrive out of a subscription. Reject the second. let (m, rejected) = RegionMap::build([rect(0, 1, 100, 200), rect(0, 2, 110, 210)]); assert_eq!(rejected.len(), 1, "{rejected:?}"); assert!(rejected[0].contains("overlaps"), "{}", rejected[0]); @@ -382,11 +285,9 @@ mod tests { 1, "the FIRST definition keeps the contested cells" ); - // Touching-but-not-overlapping is fine — that is a seam, and seams are the point. let (m, rejected) = RegionMap::build([rect(0, 1, 100, 200), rect(0, 2, 120, 200)]); assert!(rejected.is_empty(), "{rejected:?}"); assert_eq!((m.region_of(0, 119, 205), m.region_of(0, 120, 205)), (1, 2)); - // Same rectangle on a DIFFERENT map does not overlap. let (m, rejected) = RegionMap::build([rect(0, 1, 100, 200), rect(1, 1, 100, 200)]); assert!(rejected.is_empty(), "{rejected:?}"); assert_eq!((m.region_of(0, 105, 205), m.region_of(1, 105, 205)), (1, 1)); @@ -444,15 +345,10 @@ mod tests { #[test] fn an_absurdly_large_rectangle_is_answered_not_panicked_at() { - // The bounds are OPERATOR TEXT, and `build` promises every bad row "degrades toward the - // identity". A whole-i32 span overflowed the inclusive-size subtraction, which panics a - // debug build — `import_map_regions` (a reducer) and `Coordinator::map_regions` (once per - // world entry) both run this on data an operator typed, so it must answer instead. let (m, rejected) = RegionMap::parse("0:1 = -2147483648..2147483647, 0..99\n"); - assert!(rejected.is_empty(), "{rejected:?}"); // a legal, if absurd, rectangle + assert!(rejected.is_empty(), "{rejected:?}"); assert_eq!(m.region_of(0, 0, 50), 1); assert_eq!(m.region_of(0, 0, 100), DEFAULT_REGION); - // The other extreme: an inverted whole-i32 span is REJECTED, not panicked at. let (m, rejected) = RegionMap::parse("0:1 = 2147483647..-2147483648, 0..99\n"); assert_eq!(rejected.len(), 1, "{rejected:?}"); assert!(m.is_empty()); @@ -463,11 +359,11 @@ mod tests { let (m, rejected) = RegionMap::build([rect(0, 1, 100, 200), rect(0, 2, 400, 400)]); assert!(rejected.is_empty(), "{rejected:?}"); let counts = m.count_by_region([ - (0, 105, 205), // region 1 - (0, 106, 206), // region 1 - (0, 405, 405), // region 2 - (0, 0, 0), // DEFAULT_REGION — must be dropped, not counted as a region - (1, 105, 205), // a different map has no definitions here — also DEFAULT_REGION + (0, 105, 205), + (0, 106, 206), + (0, 405, 405), + (0, 0, 0), + (1, 105, 205), ]); assert_eq!( counts, @@ -486,10 +382,6 @@ mod tests { #[test] fn a_semicolon_inside_a_comment_does_not_start_a_new_row() { - // A fixture menu with semicolon-separated rows: `split(['\n', ';'])` split BEFORE the `#` strip, so - // the tail of any prose comment containing a semicolon became a malformed row — and - // `import_map_regions` errors on ANY rejection, so a single semicolon in the header would - // have failed the whole import. Comments run to end of LINE, full stop. let (m, rejected) = RegionMap::parse( "# region 1 is Elwynn; region 2 is the capital; both are inert until assigned\n\ 0:1 = 460..479, 300..319 # touching, not overlapping; that is a seam\n", @@ -497,7 +389,6 @@ mod tests { assert!(rejected.is_empty(), "{rejected:?}"); assert_eq!(m.regions().len(), 1); assert_eq!(m.region_of(0, 465, 305), 1); - // A `;` in ROW position still separates rows — the documented format is unchanged. let (m, rejected) = RegionMap::parse("0:1=460..479,300..319; 0:2=480..499,300..319 # both"); assert!(rejected.is_empty(), "{rejected:?}"); assert_eq!(m.regions().len(), 2); @@ -505,8 +396,6 @@ mod tests { #[test] fn a_malformed_seam_menu_line_is_dropped_not_guessed_at() { - // Same rule the shard map uses: a typo can never silently reroute players. Every rejected - // line's cells stay on DEFAULT_REGION, and the VALID lines around it still apply. let (m, rejected) = RegionMap::parse( "nonsense\n0:=1..2,3..4\n0:1=1..2\n0:1=a..b,c..d\n0:1 = 460..479, 300..319\n", ); diff --git a/crates/lyracore-shared/src/resistance.rs b/crates/lyracore-shared/src/resistance.rs index 6f7d76109..2aff876dd 100644 --- a/crates/lyracore-shared/src/resistance.rs +++ b/crates/lyracore-shared/src/resistance.rs @@ -1,5 +1,3 @@ -//! Resistance calculation shared by combat and the character sheet. - use crate::spell::taxonomy::A_MOD_RESISTANCE; use crate::{ aura::{stacked_amount, AuraAmount}, @@ -40,8 +38,6 @@ pub fn aura_contribution(aura: AuraAmount, school_mask: u8) -> i32 { } } -/// A multi-school aura contributes once; equipment uses the lowest requested school bit. -/// Item and equipment sums saturate before the signed aura total is added, as in the Module. pub fn resistance_bonus( school_mask: u8, auras: impl IntoIterator, diff --git a/crates/lyracore-shared/src/social.rs b/crates/lyracore-shared/src/social.rs index a65336d9b..02006d0b8 100644 --- a/crates/lyracore-shared/src/social.rs +++ b/crates/lyracore-shared/src/social.rs @@ -1,22 +1,10 @@ -//! The Module↔Gateway CONTACT wire contract: why the Module refused a friends- or ignore-list -//! change. The party half of the social pane is [`crate::group`]. - -/// Why the Module refused a contact-list Durable Request. The tag is the whole reducer error text, -/// so neither tier matches on human prose. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ContactRefusal { - /// The acting Character is temporarily unable to change its contact lists. ActorUnavailable, - /// A Character may not befriend or ignore itself. AddSelf, - /// The target is already on that same list. AlreadyOnList, - /// That list is at its cap. ListFull, - /// The remove named a target that is not on that list. NotOnList, - /// A friend add named a Character on the other team (cm:MiscHandler.cpp:467-468). Ignore has - /// no such rule. Enemy, } diff --git a/crates/lyracore-shared/src/spatial.rs b/crates/lyracore-shared/src/spatial.rs index b856e8a77..6b3dc0c36 100644 --- a/crates/lyracore-shared/src/spatial.rs +++ b/crates/lyracore-shared/src/spatial.rs @@ -1,93 +1,33 @@ -//! Spatial helpers for visibility / interest management. -//! -//! The slice queries visibility with a bounding box over `x`/`y`, but the entity table also -//! carries pre-baked `grid_x`/`grid_y` cell columns so production can switch to grid-cell -//! interest management without a schema migration. These helpers compute those cells and the -//! box predicate consistently in both tiers. - -/// Vanilla map coordinate span constant: cells are measured from +COORD_MAX downwards, the -/// same convention the client/grids use, so cell math matches existing tooling. +// Cell indices count down from +MAP_COORD_MAX. pub const MAP_COORD_MAX: f32 = 17066.666; -/// Side length of an AOI interest cell in yards. The gateway subscribes a `(2·BOX_HALF_SPAN+1)`-square -/// `GridBox`; the guaranteed-visible radius (player → nearest box edge, worst case at a cell boundary) -/// is `BOX_HALF_SPAN × GRID_CELL_SIZE`. Tuned for VANILLA feel: 50yd cells × span 2 (5×5) → min-visible -/// **100yd** (≈ the ~90-100yd vanilla unit view, so no pop-in), loading to ~150yd on an axis / ~212yd at -/// a corner. Finer cells + a wider span give a tighter box for the same min-visible than a coarse 3×3 -/// (less cell-anchor slop). A box is a square approximation of vanilla's circle (the subscription SQL -/// can't do the `(x-px)²+(y-py)²` arithmetic), so a √2 corner over-reach is inherent; a true radius -/// would need an un-indexed `Box2` x/y filter. Both tiers bake `grid_x`/`grid_y` from this, so a change -/// needs a re-grid (`debug_regrid`). +// AOI cell side length in yards. pub const GRID_CELL_SIZE: f32 = 50.0; -/// AOI box half-span in cells: the box is the anchor cell ± this on each axis, i.e. -/// `(2·BOX_HALF_SPAN+1)²` cells. 2 → a 5×5 box. The gateway subscription AND the module's -/// movement-recipient selection both derive their range from this (via `GridBox::bounds`), so they -/// stay ALIGNED automatically (a peer is animated exactly when it's spawned). pub const BOX_HALF_SPAN: i32 = 2; -/// Default visibility radius in yards for the slice's box predicate. Roughly the vanilla -/// active-object range; tune during bring-up. pub const DEFAULT_VISIBILITY_RADIUS: f32 = 100.0; -/// Compute the grid cell (x, y) for a world position, matching the vanilla convention. pub fn grid_cell(x: f32, y: f32) -> (i32, i32) { let gx = ((MAP_COORD_MAX - x) / GRID_CELL_SIZE).floor() as i32; let gy = ((MAP_COORD_MAX - y) / GRID_CELL_SIZE).floor() as i32; (gx, gy) } -/// Pack a `(grid_x, grid_y)` cell address into ONE `i64` — the `cell` column every AOI-scoped table -/// carries alongside its `grid_x`/`grid_y` pair. -/// -/// # Why a packed column exists at all -/// -/// SpacetimeDB 2.7.1's subscription planner (`IxScanFromPredicates`) can only serve a query from an -/// index when **every column of that index is matched by an equality term**, and it skips any index -/// with more than 3 columns outright (`MAX_EXACT_INDEX_COLS = 3`). Range predicates are never -/// index-served at all — `IndexProbe::Range` is documented in that release as "we currently never -/// construct this variant" — and a disjunction (`OR`) is evaluated row-by-row, never turned into -/// multiple probes. So the 4-column `by_grid` index `(map_id, instance_id, grid_x, grid_y)` is -/// *unusable* from SQL, and a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box is a residual filter over -/// a full scan no matter how the columns are ordered. Folding the two grid columns into one turns the -/// only shape the planner CAN serve — a 3-column all-equality probe on -/// `(map_id, instance_id, cell)` — into an exact match for a single grid cell. -/// -/// # Why this encoding cannot collide -/// -/// It is a **bijection** on the whole `(i32, i32)` domain, not merely collision-free over the -/// coordinate range in use. `gx as i64` sign-extends and the `<< 32` leaves the low 32 bits zero; -/// `gy as u32 as i64` is `gy`'s exact 32 two's-complement bits zero-extended into those low 32 bits. -/// The two operands therefore occupy **disjoint bit ranges**, so the `|` loses nothing and the high -/// and low halves recover `gx` and `gy` exactly ([`grid_cell_of_id`]). Two distinct cells cannot -/// share an id because that would require two distinct 64-bit patterns to be equal. No assumption is -/// made about map extent, cell size, or sign — a negative cell index on either axis round-trips like -/// any other, which matters because [`grid_cell`] inverts the axis and out-of-bounds coordinates -/// legitimately produce negative indices. +// SpacetimeDB 2.7.1 needs one equality query per packed cell for indexed AOI reads. pub fn grid_cell_id(gx: i32, gy: i32) -> i64 { ((gx as i64) << 32) | (gy as u32 as i64) } -/// Inverse of [`grid_cell_id`] — recovers `(grid_x, grid_y)` from a packed cell id. Exists to make -/// the bijection testable (and to keep the encoding honest: an encoding you cannot invert is one you -/// cannot prove collision-free). pub fn grid_cell_of_id(id: i64) -> (i32, i32) { ((id >> 32) as i32, (id as u32) as i32) } -/// The packed [`grid_cell_id`] for a world position — the one-call form every row writer uses, so a -/// `cell` column can never disagree with the `grid_x`/`grid_y` pair written beside it. pub fn cell_id_at(x: f32, y: f32) -> i64 { let (gx, gy) = grid_cell(x, y); grid_cell_id(gx, gy) } -/// The inclusive grid-cell box `(gx0, gx1, gy0, gy1)` covering every point within `radius` yards -/// of `(x, y)` — the query shape for a `by_grid`-indexed neighborhood read. -/// `grid_cell` inverts the axis (`(MAP_COORD_MAX - v) / CELL`), so `+radius` yields the LOW cell -/// index and `-radius` the high one; monotonicity of `grid_cell` makes the two endpoints bracket -/// every intermediate cell. A non-positive (or NaN-tainted) radius degrades to the single anchor -/// cell rather than an inverted, silently-empty range. pub fn covering_cell_box(x: f32, y: f32, radius: f32) -> (i32, i32, i32, i32) { let r = if radius > 0.0 { radius } else { 0.0 }; let (gx0, gy0) = grid_cell(x + r, y + r); @@ -95,8 +35,6 @@ pub fn covering_cell_box(x: f32, y: f32, radius: f32) -> (i32, i32, i32, i32) { (gx0, gx1, gy0, gy1) } -/// Axis-aligned bounding box used by the slice's nearby-entity subscription predicate: -/// `map_id == m AND x BETWEEN x0..x1 AND y BETWEEN y0..y1`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Box2 { pub map_id: u32, @@ -107,7 +45,6 @@ pub struct Box2 { } impl Box2 { - /// The visibility box centered on `(x, y)` with the given radius on `map_id`. pub fn around(map_id: u32, x: f32, y: f32, radius: f32) -> Self { Self { map_id, @@ -118,38 +55,24 @@ impl Box2 { } } - /// True if `(x, y)` on `map_id` falls inside this box. pub fn contains(&self, map_id: u32, x: f32, y: f32) -> bool { map_id == self.map_id && x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1 } } -/// The Area-of-Interest footprint: a `(2·BOX_HALF_SPAN+1)`-square grid-cell box anchored on one cell. The -/// gateway subscribes to `game_world_entity` rows whose `(map_id, grid_x, grid_y)` fall in this box (an -/// index-friendly range on the `by_grid` btree), and RECENTERS it (unsubscribe+resubscribe) when the -/// player crosses out of the anchor cell. Pure so the cell/box math is unit-tested + shared between tiers. -/// At `GRID_CELL_SIZE` 50yd × `BOX_HALF_SPAN` 2 the 5×5 spans ~250yd with a **100yd** guaranteed-visible -/// radius (≈ the vanilla unit view → no pop-in), culling beyond ~150yd/axis (~212yd corner). Changing the -/// cell size needs a re-grid of stored `grid_x`/`grid_y` (the `debug_regrid` reducer re-stamps in place). #[derive(Clone, Copy, Debug, PartialEq)] pub struct GridBox { pub map_id: u32, - /// The anchor cell (the box is `gx-1..=gx+1`, `gy-1..=gy+1`). pub gx: i32, pub gy: i32, } impl GridBox { - /// The box (anchor cell ± [`BOX_HALF_SPAN`]) anchored on the cell containing `(x, y)` on `map_id`. pub fn around(map_id: u32, x: f32, y: f32) -> Self { let (gx, gy) = grid_cell(x, y); Self { map_id, gx, gy } } - /// Inclusive grid-cell bounds of the box (anchor ± [`BOX_HALF_SPAN`]): `(gx_min, gx_max, gy_min, - /// gy_max)` — the WHERE-clause range the AOI subscription uses (`grid_x BETWEEN gx_min AND gx_max AND - /// grid_y BETWEEN ...`). The SAME range drives the module's movement-recipient selection, so the - /// gateway's spawn set and the peer-movement relay stay aligned. pub fn bounds(&self) -> (i32, i32, i32, i32) { ( self.gx - BOX_HALF_SPAN, @@ -164,17 +87,10 @@ impl GridBox { (gx_min..=gx_max).flat_map(move |gx| (gy_min..=gy_max).map(move |gy| (gx, gy))) } - /// The box's cells as PACKED ids, [`GridBox::cells`] run through [`grid_cell_id`]. This is - /// what the AOI subscription enumerates: one equality query per cell is the only box-shaped read - /// SpacetimeDB 2.7.1's planner can serve entirely from an index (see [`grid_cell_id`]). pub fn cell_ids(self) -> impl Iterator { self.cells().map(|(gx, gy)| grid_cell_id(gx, gy)) } - /// Should the box RECENTER on `(map_id, x, y)`? True when the position is on a different map, or in a - /// different cell than the anchor (the player crossed a cell boundary). The cell-change trigger keeps - /// the player roughly centered; at 50yd a walking player crosses a boundary every ~7s, so the resubscribe - /// rate stays low. (A ½-cell hysteresis to damp exact-boundary thrash is a refinement.) pub fn needs_recenter(&self, map_id: u32, x: f32, y: f32) -> bool { if map_id != self.map_id { return true; @@ -184,15 +100,6 @@ impl GridBox { } } -/// An axis-aligned grid-cell RECTANGLE with no anchor — the per-shard split unit for cross-seam -/// view-merge. A [`GridBox`] is always a fixed-size square around one anchor cell; a -/// `GridRect` is any inclusive `(gx_min..=gx_max) × (gy_min..=gy_max)` span, which is what a straddling -/// box's per-shard share looks like after [`crate::region`]'s rectangle boundaries cut it. -/// -/// A non-straddling box's home share is exactly the whole box: [`GridRect::from_box`] and -/// [`GridBox::bounds`] emit the identical four integers, so a caller building the same 4 table -/// queries from either one produces byte-identical SQL — the property that makes "no seam in view" -/// cost nothing extra. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct GridRect { pub map_id: u32, @@ -203,8 +110,6 @@ pub struct GridRect { } impl GridRect { - /// The whole box, as a rect with no anchor — the identity conversion a non-straddling recenter - /// takes. pub fn from_box(b: &GridBox) -> Self { let (gx_min, gx_max, gy_min, gy_max) = b.bounds(); Self { @@ -216,16 +121,10 @@ impl GridRect { } } - /// Inclusive grid-cell bounds — the same 4-tuple shape [`GridBox::bounds`] returns, so both types - /// can feed one query-builder. pub fn bounds(&self) -> (i32, i32, i32, i32) { (self.gx_min, self.gx_max, self.gy_min, self.gy_max) } - /// Every cell in the rect as a PACKED id, the rect twin of [`GridBox::cell_ids`], and what - /// a per-shard AOI rect actually subscribes. A rect built from a whole box - /// ([`GridRect::from_box`]) emits the identical id sequence [`GridBox::cell_ids`] does, which is - /// what keeps a non-straddling recenter byte-identical to the login subscription. pub fn cell_ids(self) -> impl Iterator { (self.gx_min..=self.gx_max) .flat_map(move |gx| (self.gy_min..=self.gy_max).map(move |gy| grid_cell_id(gx, gy))) @@ -240,21 +139,14 @@ mod tests { fn grid_box_bounds_and_recenter() { let b = GridBox::around(0, -8949.95, -132.493); let (xmin, xmax, ymin, ymax) = b.bounds(); - assert_eq!(xmax - xmin, 2 * BOX_HALF_SPAN); // 5x5 → anchor ±2 + assert_eq!(xmax - xmin, 2 * BOX_HALF_SPAN); assert_eq!(ymax - ymin, 2 * BOX_HALF_SPAN); - assert_eq!((xmin + BOX_HALF_SPAN, ymin + BOX_HALF_SPAN), (b.gx, b.gy)); // anchor is the center - // A few yards away → same cell → no recenter. + assert_eq!((xmin + BOX_HALF_SPAN, ymin + BOX_HALF_SPAN), (b.gx, b.gy)); assert!(!b.needs_recenter(0, -8945.0, -130.0)); - // A different map → recenter. assert!(b.needs_recenter(1, -8949.95, -132.493)); - // Well over a cell away (600yd ≫ the 50yd cell) → different cell → recenter. assert!(b.needs_recenter(0, -8949.95 - 600.0, -132.493)); } - /// a non-straddling recenter's home share must be BYTE-IDENTICAL to today's whole-box - /// query, and this is the property that makes that true — `GridRect::from_box` emits the exact - /// same 4-tuple `GridBox::bounds` does, so a query builder driven by either type produces the - /// same SQL string for the same box. #[test] fn grid_rect_from_box_matches_bounds_exactly() { let b = GridBox::around(0, -8949.95, -132.493); @@ -266,10 +158,6 @@ mod tests { assert_eq!(r.bounds(), b.bounds()); } - /// `cells()` must emit EXACTLY the cell set `bounds()`'s four inequalities accept — the module's - /// movement relay swapped a full entity scan filtered by those inequalities for one indexed probe - /// per emitted cell, so the two forms are only interchangeable while this holds. A `..` for a - /// `..=`, a swapped axis or a repeated cell all show up here (and nowhere else in the suite). #[test] fn grid_box_cells_are_exactly_the_cells_bounds_accepts() { let b = GridBox::around(0, -8949.95, -132.493); @@ -283,17 +171,12 @@ mod tests { cells.len(), "a repeated cell would double a peer's movement rows" ); - // Sweep a margin around the box: membership must agree with the inequality predicate in BOTH - // directions (a dropped edge cell = a peer that stops seeing the mover; an extra one = a - // recipient outside the AOI box the gateway spawns from). for gx in (x0 - 2)..=(x1 + 2) { for gy in (y0 - 2)..=(y1 + 2) { let inside = gx >= x0 && gx <= x1 && gy >= y0 && gy <= y1; assert_eq!(unique.contains(&(gx, gy)), inside, "cell ({gx},{gy})"); } } - // Negative cell indices (the far quadrant of a map) enumerate identically — the anchor is - // always emitted, and the count never changes with the sign of the anchor. let neg = GridBox { map_id: 0, gx: -3, @@ -306,15 +189,8 @@ mod tests { ); } - /// The packed `cell` id must be a BIJECTION over `(i32, i32)`. Every writer of `grid_x`/ - /// `grid_y` also writes this value and the AOI subscription probes it with an equality, so a - /// collision would silently merge two cells — two players 500yd apart would see each other, and - /// the entities of one cell would be delivered to the wrong box. Round-tripping is the proof: - /// an invertible map cannot collide. #[test] fn grid_cell_id_round_trips_and_never_collides() { - // Round-trip the interesting corners of the i32 domain, not just the coordinate range in use: - // grid_cell inverts the axis, so out-of-bounds world coords legitimately go negative. let interesting = [ 0i32, 1, @@ -347,14 +223,9 @@ mod tests { ); } } - // The two halves are DISJOINT bit ranges: changing gy can never disturb the gx half and vice - // versa. This is the property the `|` relies on, and the one a `gx * K + gy` style pack - // (which overflows into its neighbour once gy exceeds K) would not have. assert_eq!(grid_cell_id(0, -1), 0x0000_0000_FFFF_FFFFu64 as i64); assert_eq!(grid_cell_id(-1, 0), -1i64 << 32); assert_ne!(grid_cell_id(1, 0), grid_cell_id(0, 1)); - // An exhaustive sweep over a realistic map's cell range (map extent / GRID_CELL_SIZE is ~683 - // cells per axis) — no duplicate across the whole plane, which is the live-data guarantee. let mut all = std::collections::HashSet::new(); for gx in -5..=690 { for gy in -5..=690 { @@ -364,9 +235,6 @@ mod tests { assert_eq!(all.len(), 696 * 696); } - /// `cell_id_at` must agree with `grid_cell` + `grid_cell_id` composed by hand — it is the ONE - /// call every row writer makes, so a divergence here would stamp a `cell` that disagrees with the - /// `grid_x`/`grid_y` written beside it on the same row. #[test] fn cell_id_at_matches_grid_cell_composed_with_the_packer() { for (x, y) in [ @@ -382,10 +250,6 @@ mod tests { } } - /// `cell_ids()` must be exactly `cells()` packed — the AOI subscription enumerates the former and - /// the module's movement-recipient probe enumerates the latter, so the two describing different - /// cell sets would spawn a peer the relay never animates (or vice versa), the exact class - /// `grid_box_cells_are_exactly_the_cells_bounds_accepts` guards for the unpacked form. #[test] fn box_and_rect_cell_ids_are_exactly_the_packed_cells() { let b = GridBox::around(0, -8949.95, -132.493); @@ -402,14 +266,11 @@ mod tests { got.len(), "a duplicate id inside one box would double-deliver a cell" ); - // A rect built from the whole box emits the IDENTICAL sequence — the property that keeps a - // non-straddling recenter byte-identical to the login subscription (see GridRect::from_box). assert_eq!( GridRect::from_box(&b).cell_ids().collect::>(), got, "rect-of-a-whole-box must equal the box's own id sequence" ); - // A negative-anchor box behaves identically (no sign-dependent shortcuts in the packer). let neg = GridBox { map_id: 0, gx: -3, @@ -425,17 +286,15 @@ mod tests { assert!(b.contains(0, 100.0, 200.0)); assert!(b.contains(0, 110.0, 210.0)); assert!(!b.contains(0, 111.0, 200.0)); - assert!(!b.contains(1, 100.0, 200.0)); // different map + assert!(!b.contains(1, 100.0, 200.0)); } #[test] fn covering_cell_box_brackets_the_radius() { - // 150yd radius over 50yd cells → 7 cells per axis, anchor cell strictly inside. let (ax, ay) = grid_cell(0.0, 0.0); let (gx0, gx1, gy0, gy1) = covering_cell_box(0.0, 0.0, 150.0); assert_eq!((gx1 - gx0, gy1 - gy0), (6, 6)); assert!(gx0 < ax && ax < gx1 && gy0 < ay && ay < gy1); - // Every point inside the radius maps into the box (monotonicity, spot-checked at the rim). for (px, py) in [ (150.0, 0.0), (-150.0, 0.0), @@ -449,7 +308,6 @@ mod tests { "({px},{py}) escaped the box" ); } - // Degenerate radii collapse to the single anchor cell — never an inverted/empty range. assert_eq!(covering_cell_box(0.0, 0.0, 0.0), (ax, ax, ay, ay)); assert_eq!(covering_cell_box(0.0, 0.0, -5.0), (ax, ax, ay, ay)); assert_eq!(covering_cell_box(0.0, 0.0, f32::NAN), (ax, ax, ay, ay)); @@ -459,19 +317,16 @@ mod tests { fn grid_cells_are_stable_for_nearby_points() { let a = grid_cell(-8949.95, -132.493); let b = grid_cell(-8945.0, -130.0); - assert_eq!(a, b); // points a few yards apart share a cell + assert_eq!(a, b); } #[test] fn grid_cell_pins_exact_indices_at_cell_edges() { - // At the coordinate origin, both axes sit in cell 0. assert_eq!(grid_cell(MAP_COORD_MAX, MAP_COORD_MAX), (0, 0)); - // Exactly one cell size in: the boundary itself belongs to the NEXT cell (floor semantics). assert_eq!( grid_cell(MAP_COORD_MAX - GRID_CELL_SIZE, MAP_COORD_MAX), (1, 0) ); - // A hair before the boundary is still cell 0; a hair past it is already cell 1. assert_eq!( grid_cell(MAP_COORD_MAX - GRID_CELL_SIZE + 0.002, MAP_COORD_MAX), (0, 0) @@ -480,7 +335,6 @@ mod tests { grid_cell(MAP_COORD_MAX - GRID_CELL_SIZE - 0.002, MAP_COORD_MAX), (1, 0) ); - // Two cells in on the y axis, independent of x. assert_eq!( grid_cell(MAP_COORD_MAX, MAP_COORD_MAX - 2.0 * GRID_CELL_SIZE), (0, 2) diff --git a/crates/lyracore-shared/src/spell.rs b/crates/lyracore-shared/src/spell.rs index 99d16241f..452f5c624 100644 --- a/crates/lyracore-shared/src/spell.rs +++ b/crates/lyracore-shared/src/spell.rs @@ -1,10 +1,8 @@ -//! Spell channel classification shared by gameplay and its protocol projection. - pub mod taxonomy; pub use taxonomy::SPELL_ATTR_CHANNELED; -/// Periodic triggers retain their legacy channel behavior when the header has no flag. +// Periodic triggers preserve legacy channel behavior when the header has no flag. pub fn is_channel_aura(effect_kind: u8, cast_flags: u32) -> bool { effect_kind == taxonomy::A_PERIODIC_TRIGGER || cast_flags & SPELL_ATTR_CHANNELED != 0 } diff --git a/crates/lyracore-shared/src/spell/taxonomy.rs b/crates/lyracore-shared/src/spell/taxonomy.rs index 2a6d1da4e..7944e6c71 100644 --- a/crates/lyracore-shared/src/spell/taxonomy.rs +++ b/crates/lyracore-shared/src/spell/taxonomy.rs @@ -1,8 +1,5 @@ -//! Durable spell effect codes and the parameter vocabulary shared by import and gameplay. - macro_rules! effect_kinds { ($($code:ident => $variant:ident = $value:literal),+ $(,)?) => { - /// A known effect code. Table columns retain their byte encoding. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum EffectKind { @@ -28,7 +25,7 @@ macro_rules! effect_kinds { } } - // Byte names are retained for authored rows and the Package API. + // Keep byte names for authored rows and Package API compatibility. $(pub const $code: u8 = EffectKind::$variant as u8;)+ }; } @@ -203,7 +200,6 @@ mod tests { assert_eq!(A_ABSORB, 0xA2); assert_eq!(A_MOD_COMBAT, 0xA3); assert_eq!(A_FLAG, 0xBE); - // every aura kind has the high bit; every instant kind does not. for k in [ E_DAMAGE, E_HEAL, E_ENERGIZE, E_DISPEL, E_TRIGGER, E_SCRIPTED, ] { @@ -279,7 +275,6 @@ mod tests { assert_eq!(M_ROOT, 2); assert_eq!(M_FEAR, 3); assert_eq!(M_POLY, 4); - // All distinct + non-zero (0 stays the inert "no mechanic" value). let ms = [M_STUN, M_ROOT, M_FEAR, M_POLY]; for (i, a) in ms.iter().enumerate() { assert_ne!(*a, 0, "mechanic must be non-zero (0 = inert sentinel)"); @@ -287,7 +282,6 @@ mod tests { assert_ne!(a, b, "mechanics must be distinct"); } } - // A_CONTROL is an aura kind (the gates read it off a frozen game_aura row). assert_eq!(A_CONTROL & KIND_AURA_BIT, KIND_AURA_BIT); } } diff --git a/crates/lyracore-shared/src/talent.rs b/crates/lyracore-shared/src/talent.rs index 38333dd47..d3f52c75b 100644 --- a/crates/lyracore-shared/src/talent.rs +++ b/crates/lyracore-shared/src/talent.rs @@ -1,5 +1,3 @@ -//! Talent reset pricing shared by the Module and the confirmation dialog. - pub fn respec_cost_copper(respec_count: u32) -> u32 { match respec_count.min(10) { 0 => 10_000, @@ -12,7 +10,6 @@ pub fn points_available(level: u32, spent: u32) -> u32 { level.saturating_sub(9).saturating_sub(spent) } -/// Rank zero is unlearned; absent higher ranks use the base spell. pub fn rank_spell(rank: u8, spells: [u32; 5]) -> u32 { if rank == 0 { return 0; diff --git a/crates/lyracore-shared/src/terrain.rs b/crates/lyracore-shared/src/terrain.rs index 89e7f7fc9..524d12bf2 100644 --- a/crates/lyracore-shared/src/terrain.rs +++ b/crates/lyracore-shared/src/terrain.rs @@ -1,17 +1,11 @@ use crate::spatial::MAP_COORD_MAX; -/// Side of one MCNK terrain cell in yards (533.33333 yd tile / 16). +// MCNK cell side length in yards. pub const CELL_SIZE: f32 = 533.333_3 / 16.0; -/// 9×9 corner-grid spacing inside a cell (8 sub-quads per axis). +// Nine outer corners per axis span eight terrain quads. pub const QUAD: f32 = CELL_SIZE / 8.0; -/// World coord → global terrain cell index on one axis, or None outside the map square. -/// -/// NOT `!(0.0..1024.0).contains(&c)` (what `clippy::manual_range_contains` asks for): the two differ -/// on NaN. `contains` is false for NaN, so the negation REJECTS it; the comparison chain below is -/// false for NaN, so it ACCEPTS it and `c as u16` saturates to cell 0. Keeping the comparison form -/// preserves the shipped behavior byte-for-byte, and NaN cannot reach here anyway — the movement -/// reducer fences non-finite coordinates before any terrain read (`creatures::ai::finite_point`). +// Comparison chains preserve legacy NaN acceptance; Range::contains would refuse it. #[allow(clippy::manual_range_contains)] pub fn cell_index(coord: f32) -> Option { let c = (MAP_COORD_MAX - coord) / CELL_SIZE; @@ -21,18 +15,11 @@ pub fn cell_index(coord: f32) -> Option { Some(c as u16) } -/// Pack a terrain cell address into a table PK: `(map << 32) | (cell_x << 16) | cell_y`. pub fn cell_key(map_id: u32, cell_x: u16, cell_y: u16) -> u64 { ((map_id as u64) << 32) | ((cell_x as u64) << 16) | cell_y as u64 } -/// Bilinear ground height at world (x, y) from one cell's 145-float MCVT height array -/// (absolute heights, raw interleaved order: 9 outer + 8 inner per row pair; outer corner -/// (i, j) lives at `heights[i*17 + j]`). Returns None if (x, y) isn't inside `(cell_x, cell_y)` -/// or the array is malformed. -// Deliberate simplification: corner bilinear, not mangos' V8 triangle scheme — worst-case error -// on a 4.17 yd sub-quad is centimeters, invisible at wander scale; the 8x8 centers are stored for -// the upgrade. +// MCVT has 145 floats: nine outer and eight inner heights per row pair. pub fn interpolate(heights: &[f32], cell_x: u16, cell_y: u16, x: f32, y: f32) -> Option { if heights.len() != 145 { return None; @@ -54,8 +41,6 @@ pub fn interpolate(heights: &[f32], cell_x: u16, cell_y: u16, x: f32, y: f32) -> mod tests { use super::*; - /// A cell whose outer corners encode a plane z = row index recovers the plane everywhere, - /// exercised through real world coordinates (not just local quad units). #[test] fn bilinear_recovers_a_sloped_plane() { let mut h = vec![0.0f32; 145]; @@ -64,13 +49,10 @@ mod tests { h[i * 17 + j] = i as f32; } } - // Use the cell containing the Northshire start. let (x0, y0) = (-8949.95f32, -132.493f32); let (cx, cy) = (cell_index(x0).unwrap(), cell_index(y0).unwrap()); - // Cell-corner world coords (cells count DOWN from MAP_COORD_MAX). let corner_x = MAP_COORD_MAX - cx as f32 * CELL_SIZE; let corner_y = MAP_COORD_MAX - cy as f32 * CELL_SIZE; - // Halfway down the cell in x → local row 4 → plane value 4. let z = interpolate( &h, cx, @@ -81,7 +63,6 @@ mod tests { assert!((z.unwrap() - 4.0).abs() < 1e-3); } - /// A flat cell at the known-good Northshire height reproduces exactly at any sample point. #[test] fn flat_cell_is_exact() { let h = vec![83.5312f32; 145]; @@ -91,12 +72,11 @@ mod tests { assert!((z - 83.5312).abs() < 1e-4); } - /// Out-of-cell coords and malformed arrays return None instead of interpolating garbage. #[test] fn rejects_wrong_cell_and_bad_array() { let h = vec![10.0f32; 145]; - assert!(interpolate(&h, 0, 0, -8949.95, -132.493).is_none()); // wrong cell - assert!(interpolate(&vec![10.0f32; 144], 779, 516, -8905.0, -153.0).is_none()); // short array - assert!(cell_index(20000.0).is_none()); // off the map square + assert!(interpolate(&h, 0, 0, -8949.95, -132.493).is_none()); + assert!(interpolate(&vec![10.0f32; 144], 779, 516, -8905.0, -153.0).is_none()); + assert!(cell_index(20000.0).is_none()); } } diff --git a/crates/lyracore-shared/src/trade.rs b/crates/lyracore-shared/src/trade.rs index 7c6d31251..37d3b004e 100644 --- a/crates/lyracore-shared/src/trade.rs +++ b/crates/lyracore-shared/src/trade.rs @@ -1,71 +1,30 @@ -//! The module↔gateway TRADE wire contract, event-kind codes for the Trade Session status -//! relay. Cross-boundary constants live HERE, both crates import: a module-side renumber becomes a -//! compile-visible edit on the gateway side instead of a runtime drift. Same precedent as -//! `group::event_kind`. - -/// Trade-event kinds (`game_trade_event.kind`) — which `SMSG_TRADE_STATUS` variant the gateway -/// relays. One byte per status the module actually emits; NOT the vanilla `TradeStatus` -/// discriminants (the gateway owns that wire mapping, and most vanilla variants are never -/// module-emitted). +// Durable event kinds differ from vanilla TradeStatus discriminants. pub mod event_kind { - /// You are proposed a trade (`other_guid` = the initiator) → `TradeStatus::BeginTrade`. - /// The client answers with `CMSG_BEGIN_TRADE`, which opens the window on both sides. pub const BEGIN_TRADE: u8 = 0; - /// The proposal was answered — both parties' windows open → `TradeStatus::OpenWindow`. pub const OPEN_WINDOW: u8 = 1; - /// The Trade Session ended without a Trade Commit (explicit cancel, logout) → - /// `TradeStatus::TradeCanceled`. pub const TRADE_CANCELED: u8 = 2; - /// Initiator refusal: you or the target are already in a Trade Session → `TradeStatus::Busy`. pub const BUSY: u8 = 3; - /// Initiator refusal: no such partner (absent, mid-transfer, not a player, or yourself) → - /// `TradeStatus::NoTarget`. pub const NO_TARGET: u8 = 4; - /// Initiator refusal: partner beyond the 10 yd interaction range (or another partition) → - /// `TradeStatus::TargetToFar` (the wire library's spelling). pub const TARGET_TO_FAR: u8 = 5; - /// Initiator refusal: cross-faction trade → `TradeStatus::WrongFaction`. pub const WRONG_FACTION: u8 = 6; - /// Initiator refusal: you are dead → `TradeStatus::YouDead`. pub const YOU_DEAD: u8 = 7; - /// Initiator refusal: the target is dead → `TradeStatus::TargetDead`. pub const TARGET_DEAD: u8 = 8; - /// Your OWN offer, echoed back after a mutation (payload = [`super::encode_offer`]) → - /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = true`. pub const OFFER_SELF: u8 = 9; - /// Your PARTNER's offer after their mutation (same payload grammar) → - /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = false`. pub const OFFER_PARTNER: u8 = 10; - /// Initiator notice: the target has you ignored — proposal declined → - /// `TradeStatus::IgnoreYou`. pub const IGNORE_YOU: u8 = 11; - /// Your partner accepted the current offer → `TradeStatus::TradeAccept`. pub const TRADE_ACCEPT: u8 = 12; - /// An accept was withdrawn — explicit unaccept, or the accept-reset rule firing on an offer - /// change → `TradeStatus::BackToTrade`. pub const BACK_TO_TRADE: u8 = 13; - /// The Trade Commit succeeded → `TradeStatus::TradeComplete`. pub const TRADE_COMPLETE: u8 = 14; - /// Commit refused, window closes: YOUR bags cannot fit the incoming items → - /// `TradeStatus::CloseWindow` with `InventoryResult::InventoryFull`, `target_error: false`. pub const INV_FULL_SELF: u8 = 15; - /// Commit refused, window closes: the PARTNER's bags cannot fit → same `CloseWindow`, - /// `target_error: true`. pub const INV_FULL_PARTNER: u8 = 16; - /// Commit refused, window closes: YOUR purse cannot honour the offer (short, or receipt would - /// pass the u32 cap) → `CloseWindow` with `InventoryResult::NotEnoughMoney`, - /// `target_error: false`. pub const GOLD_FAIL_SELF: u8 = 17; - /// Commit refused, window closes: the PARTNER's purse failed → same, `target_error: true`. pub const GOLD_FAIL_PARTNER: u8 = 18; } -/// One offered item as the trade window renders it — resolved by the MODULE at write time (the -/// roster payload-carry pattern: instance + template joined in the same transaction as the offer -/// change, so the gateway relay never races a read). Field order is the payload grammar below. +// Resolve item and template fields in the offer transaction to avoid relay read races. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfferSlot { - /// 0..=5 traded; 6 = the Will-Not-Be-Traded Slot (shown, never committed). + // Slots 0..=5 trade; slot 6 is shown but never committed. pub trade_slot: u8, pub entry: u32, pub display_id: u32, @@ -76,10 +35,6 @@ pub struct OfferSlot { pub random_property_id: u32, } -/// Encode one side's whole offer for an `OFFER_*` event payload: -/// `gold|slot,entry,display,stack,enchant,dur,maxdur;slot...`, occupied slots only, `gold` in -/// copper. All-numeric fields, so no delimiter stripping is needed (unlike -/// `group::RosterPayload::encode`). pub fn encode_offer(gold: u32, slots: &[OfferSlot]) -> String { let body: Vec = slots .iter() @@ -100,8 +55,6 @@ pub fn encode_offer(gold: u32, slots: &[OfferSlot]) -> String { format!("{gold}|{}", body.join(";")) } -/// Decode an `OFFER_*` payload back to `(gold, slots)`. Fails closed: any malformed field drops -/// the WHOLE payload (a half-decoded trade window is worse than a stale one). pub fn decode_offer(payload: &str) -> Option<(u32, Vec)> { let (gold, body) = payload.split_once('|')?; let gold: u32 = gold.parse().ok()?; @@ -129,9 +82,6 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec)> { mod tests { use super::*; - /// The offer payload round-trips with every window-visible field intact (the "real stack - /// count, durability, enchant" AC rides these fields), an empty offer is legal (gold-only or - /// cleared window), and malformed payloads fail closed as a whole. #[test] fn offer_payload_round_trips_and_fails_closed_on_malformed_input() { let slots = vec![ diff --git a/crates/lyracore-shared/src/trainer.rs b/crates/lyracore-shared/src/trainer.rs index 8f3fc80ba..e592297e6 100644 --- a/crates/lyracore-shared/src/trainer.rs +++ b/crates/lyracore-shared/src/trainer.rs @@ -1,44 +1,19 @@ -//! Which characters a trainer will serve. -//! -//! Shared because the rule has two producers that must not drift: the module enforces it on the buy -//! and the respec, and the gateway applies the same rule to decide whether to show the window and -//! the gossip option at all. A display copy that drifted permissive would advertise a service the -//! module then refuses; one that drifted restrictive would hide a trainer that works. - -/// `creature_template.TrainerType`. -/// -/// `CLASS` is 0, which is also the ingested column's default, so most creatures read `CLASS` without -/// meaning anything by it. This value alone can never identify a class trainer — see [`serves`]. +// CLASS is also the column default; require a populated trainer_class to restrict it. pub mod trainer_type { - /// Teaches a class's spells — the only type this gate restricts. pub const CLASS: u8 = 0; - /// Riding trainer. Serves every class. pub const MOUNTS: u8 = 1; - /// Profession trainer and the weapon masters. Serves every class. pub const TRADESKILLS: u8 = 2; - /// Hunter pet trainer. Serves every class here: vanilla also requires a Hunter, but some pet - /// trainers carry a class id and there is no pet system to gate, so restricting would be worse. pub const PETS: u8 = 3; } -/// `SkillLine.dbc` id of Riding — the skill a [`trainer_type::MOUNTS`] trainer teaches, and the line a -/// mount spell's `SkillLineAbility` row names. Shared for the same no-drift reason as [`serves`]: the -/// module grants the rank on the buy, and the gateway needs to know a riding offering teaches a SKILL so -/// it does not echo the offering's marker id back as a learned spell. pub const RIDING_SKILL_LINE: u32 = 762; -/// Why the Module refused a trainer Durable Request. The tag is the whole reducer error text, so -/// neither tier matches on human prose. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TrainerRefusal { - /// The trainer will not serve this interaction at all: missing, not a trainer, on another map, - /// out of range, refusing the buyer's class, or asked for riding while teaching something else. Unavailable, - /// The trainer's offering list does not carry that spell. NotOffered, AlreadyKnown, LevelTooLow, - /// A rank purchase whose `game_spell_chain` predecessor is not known yet. PreviousRankMissing, NotEnoughMoney, } @@ -71,15 +46,6 @@ impl TrainerRefusal { } } -/// Will this trainer serve a character of `player_class`? -/// -/// Refuses only when all three hold: the trainer is a `CLASS` trainer, its `trainer_class` is -/// populated, and it differs from the character's. The populated check is what keeps the gate -/// fail-open — `CLASS` is 0 and so is the column's default, so without it every trainer in a world -/// that has not been re-imported would refuse everyone. -/// -/// Both ids are class IDs (1 Warrior, 2 Paladin, 3 Hunter, 4 Rogue, 5 Priest, 7 Shaman, 8 Mage, -/// 9 Warlock, 11 Druid), never masks. pub const fn serves(player_class: u8, trainer_type: u8, trainer_class: u8) -> bool { trainer_type != self::trainer_type::CLASS || trainer_class == 0 || trainer_class == player_class } @@ -88,7 +54,6 @@ pub const fn serves(player_class: u8, trainer_type: u8, trainer_class: u8) -> bo mod tests { use super::*; - /// Every class the 1.12 client can create. 6 and 10 do not exist in vanilla. const CLASSES: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11]; #[test] @@ -114,12 +79,10 @@ mod tests { ); } } - // The reported case: a Warrior at a Paladin trainer. assert!(!serves(1, trainer_type::CLASS, 2)); assert!(serves(2, trainer_type::CLASS, 2)); } - /// Fail-open: if this breaks, an un-reimported world locks every player out of every trainer. #[test] fn an_unpopulated_trainer_class_serves_every_class() { for &player_class in &CLASSES { @@ -130,8 +93,6 @@ mod tests { } } - /// A populated class id on a non-CLASS trainer is real data (pet trainers ship one), not a - /// reason to gate. #[test] fn non_class_trainer_types_serve_every_class_even_with_a_populated_trainer_class() { for ty in [ @@ -154,22 +115,17 @@ mod tests { } } - /// The gate must read the columns the way the importer writes them. #[test] fn the_dump_verified_trainers_gate_as_ingested() { - // Brother Sammuel (925), Paladin trainer: type 0 / class 2. assert!(serves(2, 0, 2)); assert!(!serves(1, 0, 2)); - // Woo Ping (11867), weapon master: type 2 / class 0. for &c in &CLASSES { assert!(serves(c, 2, 0), "every class trains weapons at Woo Ping"); } - // Larimaine Purdue (2485), portal trainer: a genuine class trainer, so mages only. assert!(serves(8, 0, 8)); assert!(!serves(1, 0, 8)); } - /// Drift guard: the ingest stores the dump's raw value, so these names must match the source enum. #[test] fn trainer_type_constants_match_the_source_enum() { assert_eq!(trainer_type::CLASS, 0); @@ -203,7 +159,6 @@ pub fn learn_target_trigger(kind: u8, trigger_spell: u32) -> Option { .then_some(trigger_spell) } -/// Resolve a wrapper independently of table iteration order. pub fn resolve_learn_target(spell_id: u32, effects: impl IntoIterator) -> u32 { effects .into_iter() @@ -222,7 +177,6 @@ pub fn is_combat_skill_line(line: u32) -> bool { ) } -/// Combat skills are known by row presence; profession and riding tiers compare caps. pub fn offering_known( skill_line: u32, offered_cap: u32, diff --git a/crates/lyracore-shared/src/transfer.rs b/crates/lyracore-shared/src/transfer.rs index 3f5250fce..0dfe63b77 100644 --- a/crates/lyracore-shared/src/transfer.rs +++ b/crates/lyracore-shared/src/transfer.rs @@ -1,7 +1 @@ -//! Cross-crate Transfer bounds. - -/// Maximum durable session-less Transfer Intents on one World Shard. -/// -/// The Module refuses the next insert at this bound. The Gateway can therefore inspect the whole -/// pending set with one bounded read; `limit + 1` detects incompatible populated state. pub const BOT_TRANSFER_PENDING_LIMIT: usize = 64; diff --git a/crates/lyracore-shared/src/values_mask.rs b/crates/lyracore-shared/src/values_mask.rs index afdae270f..7dc8cdd99 100644 --- a/crates/lyracore-shared/src/values_mask.rs +++ b/crates/lyracore-shared/src/values_mask.rs @@ -1,32 +1,22 @@ -//! Raw decoder for `SMSG_UPDATE_OBJECT` VALUES blocks (testing-hardening §3.1). +// Only leading VALUES blocks are decoded; CREATE and MOVEMENT payloads cannot be skipped. -//! -//! Scope: VALUES blocks only, and only the LEADING run of them — a CREATE/MOVEMENT block has a -//! variable-length movement payload this deliberately does not decode (the typed reader handles -//! full CREATEs fine; VALUES is the gap). Frames that interleave a CREATE before a VALUES lose the -//! tail — acceptable for asserts, which watch many frames. - -/// One decoded VALUES block: which entity, and every `(field_index, raw_word)` the mask carried. #[derive(Debug, Clone, PartialEq)] pub struct ValuesUpdate { pub guid: u64, pub fields: Vec<(u16, u32)>, } -/// Parse the leading VALUES objects from a raw `SMSG_UPDATE_OBJECT` body. Malformed/truncated -/// input returns what was decoded so far (asserts treat "nothing matched" as the failure signal — -/// a parser panic would kill the whole probe on the first exotic frame). pub fn parse_values_updates(body: &[u8]) -> Vec { let mut out = Vec::new(); let mut r = Reader { b: body, i: 0 }; let Some(count) = r.u32() else { return out }; if r.u8().is_none() { - return out; // has_transport + return out; } for _ in 0..count { match r.u8() { - Some(0) => {} // UPDATE_TYPE_VALUES — the only block this decodes - _ => break, // CREATE/MOVEMENT/OOR: variable-length payloads we don't skip + Some(0) => {} + _ => break, } let Some(guid) = r.packed_guid() else { break }; let Some(n_blocks) = r.u8() else { break }; @@ -83,14 +73,11 @@ impl Reader<'_> { mod tests { use super::*; - /// Hand-assemble a VALUES frame: one object, guid 9, field 718 = 356 (a Fishing skill word) - /// and field 22 = 41 (UNIT_FIELD_HEALTH) — two mask blocks apart, exercising block indexing. fn frame(fields: &[(u16, u32)], guid: u64) -> Vec { let mut b = Vec::new(); - b.extend_from_slice(&1u32.to_le_bytes()); // count - b.push(0); // has_transport - b.push(0); // UPDATE_TYPE_VALUES - // packed guid: low bytes only + b.extend_from_slice(&1u32.to_le_bytes()); + b.push(0); + b.push(0); let gb = guid.to_le_bytes(); let mut mask = 0u8; let mut bytes = Vec::new(); @@ -144,11 +131,10 @@ mod tests { fn truncated_or_foreign_blocks_never_panic() { let body = frame(&[(22, 41)], 0x9); for cut in 0..body.len() { - let _ = parse_values_updates(&body[..cut]); // must not panic at any truncation + let _ = parse_values_updates(&body[..cut]); } - // A CREATE-typed first block yields nothing (we don't skip movement payloads). let mut create = body.clone(); - create[5] = 2; // UPDATE_TYPE_CREATE_OBJECT + create[5] = 2; assert!(parse_values_updates(&create).is_empty()); } } diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index 4e8a20a8f..73da865b2 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -1,31 +1,20 @@ -//! Per-cell collision triangles and exact segment-triangle ray queries. -//! Line of sight uses WMO triangles; collision also uses M2 doodads. A versioned blob carries source metadata for both ray flavors. -//! Queries walk only the terrain cells the segment crosses with a two-dimensional DDA. - -/// Blob format version. Bump on any layout change; `decode` rejects a mismatch rather than guess. pub const VMAP_VERSION: u8 = 1; -/// One packed triangle's source: which vanilla ray flavors it participates in. WMO carries its -/// group id + raw MOGP flags (indoor/outdoor bits live in there — decoded by the query surface, -/// not this codec); M2 (doodad) triangles have neither and never gate the WMO-only LoS ray. #[derive(Clone, Copy, Debug, PartialEq)] pub enum TriClass { Wmo { group_id: u32, mogp_flags: u32 }, M2, } -/// One world-space collision triangle plus its source class. #[derive(Clone, Copy, Debug, PartialEq)] pub struct VmapTri { pub verts: [[f32; 3]; 3], pub class: TriClass, } -/// Bytes per packed triangle: 1 (class tag) + 4 (group_id) + 4 (mogp_flags) + 36 (9 × f32 verts). -/// `pub` so the importer can size a per-blob shard cap. A dense cell's triangle count -/// isn't bounded, but a single `spacetime call` argument is (see `importer/src/vmap.rs`). +// Triangle layout: class u8, group id u32, MOGP flags u32, nine f32 coordinates. pub const TRI_BYTES: usize = 1 + 4 + 4 + 36; -/// Header: 1 version byte + 4-byte LE triangle count. `pub` for the same reason as `TRI_BYTES`. +// Header layout: version u8, triangle count u32 little-endian. pub const HEADER_BYTES: usize = 5; const CLASS_WMO: u8 = 0; @@ -33,16 +22,11 @@ const CLASS_M2: u8 = 1; #[derive(Debug, PartialEq, Eq)] pub enum DecodeError { - /// Blob shorter than the fixed header, or shorter than its declared triangle count implies. Truncated, - /// First byte isn't `VMAP_VERSION` — a schema mismatch, not a corrupt read. VersionMismatch(u8), - /// A triangle's class tag byte is neither `CLASS_WMO` nor `CLASS_M2`. BadClassTag(u8), } -/// Encode a batch of triangles (any order, any cell membership — the caller bins by cell before -/// calling this per-cell) into one versioned blob. pub fn encode(tris: &[VmapTri]) -> Vec { let mut buf = Vec::with_capacity(HEADER_BYTES + tris.len() * TRI_BYTES); buf.push(VMAP_VERSION); @@ -67,8 +51,6 @@ pub fn encode(tris: &[VmapTri]) -> Vec { buf } -/// Decode a blob produced by `encode`. Rejects a version mismatch or a short/truncated buffer -/// outright — never returns a partial result. pub fn decode(blob: &[u8]) -> Result, DecodeError> { if blob.len() < HEADER_BYTES { return Err(DecodeError::Truncated); @@ -110,31 +92,14 @@ pub fn decode(blob: &[u8]) -> Result, DecodeError> { Ok(out) } -// =========================================================================================== -// Ray queries, exact segment–triangle tests over a 2D DDA cell walk. -// =========================================================================================== - -/// Which vanilla ray flavor to cast: LoS ignores doodads (M2 never blocks sight); collision -/// blocks on both WMO and M2 triangles. +// M2 doodads block collision but never sight. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RayFlavor { Los, Collision, } -/// Cast an exact segment `a -> b` against the stored triangles, visiting only the terrain cells -/// the segment's XY projection crosses. `fetch` decodes and returns one cell's triangles, or -/// `None` for an unimported/empty cell — treated as "no obstruction known here", the same -/// missing-chunk contract `nav` uses. Returns the *nearest* hit world point, or `None` for a -/// clear segment. -/// -/// Cells are still walked in DDA order, but a cell can't stop the search early: the importer -/// (`importer::bin_by_cell`) duplicates a triangle into every cell its AABB touches, so a large -/// triangle can be found in an earlier cell than the true nearest obstruction that lives (only) -/// in a later one. Correctness requires scanning every crossed cell and taking the global min -/// `t`, not the first cell with any hit — the intersection point of any triangle that truly hits -/// the segment lies within that triangle's AABB, hence within a cell the same DDA walk visits, so -/// the global minimum found this way is exact. +// Triangles span cells, so choose the globally nearest hit across every crossed cell. pub fn cast_ray( fetch: &mut impl FnMut(u16, u16) -> Option>, a: [f32; 3], @@ -146,8 +111,6 @@ pub fn cast_ray( nearest_hit(fetch, a, b, want).map(|(t, _)| point_at(a, b, t)) } -/// Nearest model floor from `a` with a slope no greater than 50 degrees. The segment runs -/// downward from `a` to `b`; collision and area queries still include steep surfaces. pub fn cast_floor( fetch: &mut impl FnMut(u16, u16) -> Option>, a: [f32; 3], @@ -163,9 +126,6 @@ pub fn cast_floor( .map(|(t, _)| point_at(a, b, t)) } -/// Whether a plane supports walking within 50 degrees of horizontal, ignoring winding. -/// Both vectors use the same coordinate space and need not be normalized. Dynamic models -/// transform world up with the same rotation and scale as their collision probe. pub fn supports_walking(normal: [f32; 3], up: [f32; 3]) -> bool { let lengths_squared = dot3(normal, normal) * dot3(up, up); let alignment = dot3(normal, up); @@ -174,11 +134,6 @@ pub fn supports_walking(normal: [f32; 3], up: [f32; 3]) -> bool { && alignment * alignment >= 50.0f32.to_radians().cos().powi(2) * lengths_squared } -/// Nearest-hit walk for collision, sight, floor and area queries: same DDA cell walk + global-min -/// `t` correctness argument (see `cast_ray`'s doc comment), parameterized on which triangles -/// participate. Returns the winning hit parameter *and* that triangle's class, so a caller -/// that needs the source metadata (the WMO group/flags an area-info query reports) doesn't have to -/// re-walk the segment. fn nearest_hit( fetch: &mut impl FnMut(u16, u16) -> Option>, a: [f32; 3], @@ -212,29 +167,15 @@ fn point_at(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { ] } -/// Bit in `TriClass::Wmo`'s `mogp_flags` marking a WMO group as an OUTDOOR space (vanilla WMO -/// group flags, MOGP chunk — mangos' `MOGP_FLAG_OUTDOOR`). Mangos does not test an "indoor" bit: -/// a group the probe actually found is interior UNLESS it carries this one. +// A found WMO group is indoors unless its MOGP flags include 0x8000. pub const WMO_GROUP_OUTDOOR_FLAG: u32 = 0x8000; -/// Is a found WMO group an interior space? Mangos' rule, and the ONLY place the MOGP bit is -/// interpreted — the module's per-cell indoor-presence table classifies staged triangles through -/// this same function, so a query and its pre-reject can never disagree about a group. pub fn group_is_indoor(mogp_flags: u32) -> bool { mogp_flags & WMO_GROUP_OUTDOOR_FLAG == 0 } -/// How far BELOW the queried point an AREA probe segment reaches (yd). An area query asks which -/// group a point is *in*, so its budget is the height of a room, not the ray/floor probes' -/// 200 yd reach — that one finds a WMO two storeys down and reports the point as inside it. pub const AREA_PROBE_DOWN_YD: f32 = 10.0; -/// Point-in-WMO-group area-info: which WMO group (if any) contains `point`, and is it indoor — -/// mangos parity (`VMapManager2::getAreaInfo`). Implemented the same way mangos does: cast a -/// short vertical probe segment (`a` above `point`, `b` below it, no longer than -/// [`AREA_PROBE_DOWN_YD`]) and take the *nearest* WMO-class hit — the first model surface the -/// point sits under/on belongs to the containing group. `None` when no WMO triangle lies in the -/// probe range (outdoors, or vmap unimported for this cell). pub fn cast_ray_area( fetch: &mut impl FnMut(u16, u16) -> Option>, a: [f32; 3], @@ -255,7 +196,6 @@ pub fn cast_ray_area( }) } -/// Result of `cast_ray_area`: the containing WMO group and whether it's an indoor space. #[derive(Clone, Copy, Debug, PartialEq)] pub struct AreaInfo { pub group_id: u32, @@ -263,11 +203,6 @@ pub struct AreaInfo { pub point: [f32; 3], } -/// Terrain cells the segment's XY projection crosses, in traversal order from `a` to `b` — a 2D -/// DDA (Amanatides–Woo) over the same DOWNWARD-counting "cell index = floor(u / CELL_SIZE)" -/// convention as `terrain::cell_index` (`u = MAP_COORD_MAX - coord`, so higher world coord = lower -/// cell index). Cells outside the 1024×1024 map square are dropped, not clamped — a segment that -/// runs off the map square simply stops contributing hits there. fn crossed_cells(a: [f32; 3], b: [f32; 3]) -> Vec<(u16, u16)> { let ua = ( crate::spatial::MAP_COORD_MAX - a[0], @@ -336,9 +271,7 @@ fn crossed_cells(a: [f32; 3], b: [f32; 3]) -> Vec<(u16, u16)> { }; let mut out = Vec::new(); push(cx, cy, &mut out); - // Safety cap: a segment cannot legitimately cross more cells than the map's diagonal; guards - // a degenerate (NaN/inf) input from looping instead of terminating on the `cx==end && cy==end` - // condition below. + // A segment crosses at most 2048 map cells; cap traversal for NaN or infinite inputs. for _ in 0..2048 { if cx == end_cx && cy == end_cy { break; @@ -371,8 +304,7 @@ fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } -/// Möller–Trumbore, no backface culling (both winding orders block), segment-clamped (`t` in -/// `0..=1`, not an infinite ray). Returns the hit parameter along `a -> b`, or `None`. +// Moller-Trumbore with both windings and segment-clamped t in 0..=1. pub fn segment_tri_hit(a: [f32; 3], b: [f32; 3], tri: [[f32; 3]; 3]) -> Option { let dir = sub3(b, a); let e1 = sub3(tri[1], tri[0]); @@ -380,7 +312,7 @@ pub fn segment_tri_hit(a: [f32; 3], b: [f32; 3], tri: [[f32; 3]; 3]) -> Option (u16, u16, Vec) { use crate::spatial::MAP_COORD_MAX; use crate::terrain::CELL_SIZE; let (cx, cy): (u16, u16) = (500, 500); let x_hi = MAP_COORD_MAX - cx as f32 * CELL_SIZE; let x_lo = x_hi - CELL_SIZE; - let wall_y = MAP_COORD_MAX - (cy as f32 * CELL_SIZE + CELL_SIZE / 2.0); // cell center + let wall_y = MAP_COORD_MAX - (cy as f32 * CELL_SIZE + CELL_SIZE / 2.0); let tris = vec![ VmapTri { verts: [ @@ -572,7 +495,6 @@ mod tests { mogp_flags: 0, }); let mut fetch = |fx: u16, fy: u16| (fx == cx && fy == cy).then(|| tris.clone()); - // Straight segment crossing the wall's y at mid-height — must hit near the wall's y. let wall_y = tris[0].verts[0][1]; let mid_x = tris[0].verts[0][0].midpoint(tris[0].verts[1][0]); let start = [mid_x, wall_y - 20.0, 5.0]; @@ -597,7 +519,6 @@ mod tests { }); let mut fetch = |fx: u16, fy: u16| (fx == cx && fy == cy).then(|| tris.clone()); let wall_y = tris[0].verts[0][1]; - // Segment entirely on one side of the wall — never crosses its plane. let start = [tris[0].verts[0][0], wall_y - 20.0, 5.0]; let end = [tris[0].verts[0][0], wall_y - 10.0, 5.0]; assert_eq!(cast_ray(&mut fetch, start, end, RayFlavor::Collision), None); @@ -605,22 +526,14 @@ mod tests { #[test] fn a_nearer_hit_in_a_later_cell_beats_a_farther_hit_duplicated_into_an_earlier_cell() { - // Regression for the "first-cell-with-any-hit" bug: the importer (`bin_by_cell`) - // duplicates a triangle into every cell its AABB touches, so a large far wall can be - // found in an earlier-visited cell while a small near obstruction lives only in a - // later-visited cell. cast_ray must report the globally nearest hit, not the first cell's. use crate::spatial::MAP_COORD_MAX; use crate::terrain::CELL_SIZE; let (cx_a, cy): (u16, u16) = (500, 500); - let cx_b = cx_a + 1; // adjacent cell, visited after A by the DDA (see `crossed_cells`) + let cx_b = cx_a + 1; let x_hi_a = MAP_COORD_MAX - cx_a as f32 * CELL_SIZE; - // Ray's fixed world y, at the center of cell row `cy` (so `crossed_cells` reports the - // same `cy` for every visited cell — only `cx` advances). let y_world = MAP_COORD_MAX - (cy as f32 * CELL_SIZE + CELL_SIZE / 2.0); fn wall_at(x: f32, y_world: f32, class: TriClass) -> VmapTri { - // A plane perpendicular to the ray's travel axis (x), spanning y and z around the - // ray's fixed y, wide enough that the ray (at y=y_world, z=5) hits it. VmapTri { verts: [ [x, y_world - 50.0, 0.0], @@ -634,14 +547,11 @@ mod tests { group_id: id, mogp_flags: 0, }; - let x_far = x_hi_a - CELL_SIZE - 20.0; // deep in cell B — the true farther obstruction - let x_near = x_hi_a - CELL_SIZE - 5.0; // still inside cell B, nearer the A/B boundary + let x_far = x_hi_a - CELL_SIZE - 20.0; + let x_near = x_hi_a - CELL_SIZE - 5.0; let far_wall = wall_at(x_far, y_world, wmo(1)); let near_wall = wall_at(x_near, y_world, wmo(2)); - // Cell A's fetch carries a duplicate of the far wall (as the importer would bin a - // triangle whose AABB reaches back into a cell the ray crosses before the true hit). - // Cell B carries both, matching where they truly sit. let cell_a_tris = vec![far_wall]; let cell_b_tris = vec![far_wall, near_wall]; let mut fetch = |fx: u16, fy: u16| { @@ -653,7 +563,6 @@ mod tests { None } }; - // Ray runs along -x, from inside cell A, through both walls, at fixed y/z. let start = [x_hi_a - 1.0, y_world, 5.0]; let end = [x_far - 10.0, y_world, 5.0]; let hit = cast_ray(&mut fetch, start, end, RayFlavor::Collision) @@ -677,14 +586,6 @@ mod tests { assert_eq!(cast_ray(&mut fetch, start, end, RayFlavor::Los), None); } - // ------------------------------------------------------------------------------------- - // Area-info tests, a synthetic stand-in for live scenarios: a - // horizontal floor quad tagged indoor (abbey-interior analogue) under a probe reports the - // group + indoor=true; a probe with no WMO geometry in range reports "outdoors" (`None`). - // ------------------------------------------------------------------------------------- - - /// A single-cell world: cell (500, 500), one big quad (two triangles) lying flat at a fixed - /// z, spanning the full cell — a stand-in for a WMO group's interior floor. fn floor_cell(group_id: u32, mogp_flags: u32) -> (u16, u16, Vec) { use crate::spatial::MAP_COORD_MAX; use crate::terrain::CELL_SIZE; @@ -710,11 +611,6 @@ mod tests { (cx, cy, tris) } - /// The MOGP rule, at the bit level. Mangos tests ONE bit and tests it for OUTDOOR: a found - /// group is interior unless `0x8000` is set. The pre-fix code read `0x2000` as "indoor", - /// which was the wrong bit AND the wrong polarity — under it a plain interior group (flags 0, - /// the common case) read as outdoors and a group carrying the outdoor bit read as indoors, so - /// an indoor dismount built on it would have fired in the open world and nowhere else. #[test] fn only_the_outdoor_bit_decides_a_found_group() { assert!( @@ -732,17 +628,12 @@ mod tests { ); } - /// The area probe's budget is a room's height, not the 200 yd floor/ray budget: a probe that - /// long finds a WMO two storeys under an open-world point and reports the point as inside it. - /// Asserted against a floor deliberately placed BELOW the budget, so shortening the constant to - /// nothing fails here too. #[test] fn a_probe_no_longer_than_the_area_budget_misses_a_distant_floor() { let (cx, cy, tris) = floor_cell(3, 0); let mut fetch = |fx: u16, fy: u16| (fx == cx && fy == cy).then(|| tris.clone()); let x = tris[0].verts[0][0].midpoint(tris[0].verts[1][0]); let y = tris[0].verts[0][1].midpoint(tris[0].verts[2][1]); - // The floor sits at z = 10; stand far enough above it that the SHORT budget cannot reach. let stand_z = 10.0 + AREA_PROBE_DOWN_YD * 3.0; assert_eq!( cast_ray_area( @@ -796,8 +687,6 @@ mod tests { #[test] fn a_doodad_alone_never_produces_area_info() { - // M2 (doodad) triangles carry no group semantics — a probe through one must still find - // no WMO group, unlike `collision_ray` which would block on it. let (cx, cy, tris) = wall_cell(TriClass::M2); let mut fetch = |fx: u16, fy: u16| (fx == cx && fy == cy).then(|| tris.clone()); let wall_y = tris[0].verts[0][1]; diff --git a/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index 6ace1aaf4..8ddcaab07 100644 --- a/crates/lyracore-test-support/src/lib.rs +++ b/crates/lyracore-test-support/src/lib.rs @@ -14,8 +14,6 @@ use std::sync::{Arc, Mutex, Weak}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -/// The Core checkout whose Module and Gateway the private fixtures exercise. -/// Package runners set this explicitly; Core tests use this crate's own workspace. pub fn core_root() -> &'static Path { static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); ROOT.get_or_init(|| { @@ -33,26 +31,17 @@ pub fn core_root() -> &'static Path { }) } -/// How often every wait in this suite re-checks its condition. const POLL_INTERVAL: Duration = Duration::from_millis(50); -/// The deadline every wait uses unless it says otherwise. Deliberately generous: a wait that gives -/// up early is a flake, and a condition that is already true costs one poll either way. pub const POLL_TIMEOUT: Duration = Duration::from_secs(30); -/// How many times a publish may be retried past a node that died launching the module. const PUBLISH_ATTEMPTS: usize = 3; -/// Where each standalone's own stdout and stderr land. Outside the data directory on purpose — the -/// data directory goes away with the node, and a crash is exactly when the log is worth reading. -/// CI collects this directory as an artifact. pub fn log_dir() -> PathBuf { std::env::temp_dir().join("lyracore-standalone-logs") } -/// Poll `probe` every [`POLL_INTERVAL`] until it answers `true` within `timeout`. The timeout is the -/// full budget from this call until a successful probe returns. A probe runs synchronously and must -/// return on its own; this helper can refuse a late result but cannot interrupt a blocked probe. +// A synchronous probe must return on its own; the deadline cannot interrupt it. pub fn poll_until(timeout: Duration, mut probe: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + timeout; loop { @@ -159,8 +148,7 @@ impl Standalone { let spacetime = std::env::var_os("SPACETIME_BIN").unwrap_or_else(|| "spacetime".into()); let address = format!("127.0.0.1:{port}"); let server = format!("http://{address}"); - // Nodes in one fixture must accept the same Owner Token. Keep their signing keys private - // and wait for the first node to finish creating the pair before another node reads it. + // The first node must finish creating shared signing keys before the next reads. let signing_keys = signing_keys(); let startup = signing_keys .startup @@ -234,7 +222,6 @@ impl Standalone { self.publish_module_bytes(module_bytes()); } - /// Copy built Wasm bytes into this Standalone's private directory and publish that copy. pub fn publish_module_bytes(&mut self, wasm: &[u8]) { self.publish_bytes(wasm, &[]); } @@ -250,7 +237,6 @@ impl Standalone { self.publish(&database, &["--bin-path", path.to_str().unwrap()], extra); } - /// Publish the same private Wasm onto another database owned by this persistent node. pub fn publish_named_module_bytes(&mut self, database: &str, wasm: &[u8]) { assert!( self.storage == Storage::Disk, @@ -261,8 +247,6 @@ impl Standalone { self.publish(database, &["--bin-path", path.to_str().unwrap()], &[]); } - /// The published schema as `spacetime describe --json` prints it: tables, reducers and the - /// typespace their types refer into. pub fn describe(&self) -> serde_json::Value { let output = self .command() @@ -284,7 +268,6 @@ impl Standalone { command.output().expect("failed to call reducer") } - /// Capture committed transactions after the subscription's initial result is visible. pub fn capture_updates( &self, query: &str, @@ -319,8 +302,7 @@ impl Standalone { assert!(reader.read_line(&mut initial).unwrap() > 0); serde_json::from_str::(&initial).expect("initial subscription result"); action(); - // Keep the CLI alive until its output is read. Its update limit can exit before the - // final asynchronous stdout write reaches this pipe. + // Keep the CLI alive until its asynchronous stdout write reaches the pipe. let updates: Vec<_> = reader .lines() .take(count as usize) @@ -378,8 +360,6 @@ impl Standalone { parse_text_rows(&String::from_utf8(output.stdout).expect("SQL output was not UTF-8")) } - /// Wait for a reducer that refuses until the durable state it checks has settled. The reducer - /// itself is the condition, so the test waits exactly as long as the schedule behind it needs. pub fn wait_until_call_succeeds(&self, reducer: &str, args: &[&str]) { let mut last = None; let settled = poll_until(POLL_TIMEOUT, || { @@ -396,13 +376,7 @@ impl Standalone { ); } - /// Publish the module, restarting a fresh node when it dies mid-publish. - /// - /// `spacetimedb-standalone` 2.7.1 segfaults while launching this module roughly once in a dozen - /// publishes (SIGSEGV, no log line past `launching module`). Before the first successful publish - /// no fixture has staged gameplay in that database. A disk fixture may also restart while - /// publishing a new named database because previously published databases survive that owned - /// process replacement. Republishing any existing database remains fail-fast. + // SpacetimeDB 2.7.1 can segfault at launch; retry only for a new database. fn publish(&mut self, database: &str, source: &[&str], extra: &[&str]) { let workspace = core_root(); let mut attempts = Vec::new(); @@ -448,8 +422,6 @@ impl Standalone { self.child.id() } - /// Replace the owned process with the same storage, address and signing keys. The published - /// Module and its rows must survive without republishing or reseeding. pub fn restart_persistent(&mut self) { assert!(self.storage == Storage::Disk, "restart needs disk storage"); assert!(self.published, "restart needs a published Module"); @@ -457,7 +429,6 @@ impl Standalone { self.restart(); } - /// Start a replacement node on the same address, appending to the same log. fn restart(&mut self) { let _ = self.child.wait(); self.child = spawn_node( @@ -472,8 +443,6 @@ impl Standalone { self.wait_for_server(); } - /// Not `poll_until`: a node that has already exited never starts listening, so this one waits - /// on two outcomes and reports the exit rather than burning the whole deadline on it. fn wait_for_server(&mut self) { let deadline = Instant::now() + POLL_TIMEOUT; loop { @@ -545,8 +514,6 @@ impl Standalone { ); } - /// The end of this node's own log, appended to every failure. A publish that reports only - /// "connection closed" says nothing; the node's last lines say why it closed it. fn log_tail(&self) -> String { let Ok(log) = fs::read_to_string(&self.log_path) else { return String::new(); @@ -565,7 +532,6 @@ impl Drop for Standalone { } } -/// Start one standalone node, appending its stdout and stderr to `log_path`. fn spawn_node( spacetime: &OsString, cli_config: &Path, @@ -669,8 +635,6 @@ fn text_row_cells(line: &str) -> Vec<&str> { cells } -/// `spacetime sql --format text` prints a string column inside double quotes. Callers compare -/// against the value, not against its rendering, so the quotes come off here once. fn unquote(value: &str) -> String { value .strip_prefix('"') @@ -679,7 +643,6 @@ fn unquote(value: &str) -> String { .to_string() } -/// An Operator request for a fixture Character without a World Session. pub fn actor(guid: &str) -> String { let guid: u64 = guid.parse().expect("fixture Character guid"); format!(r#"{{"guid":{guid},"ownership":null}}"#) diff --git a/crates/lyracore-test-support/src/module_wasm.rs b/crates/lyracore-test-support/src/module_wasm.rs index 68a5196a7..88171f3d1 100644 --- a/crates/lyracore-test-support/src/module_wasm.rs +++ b/crates/lyracore-test-support/src/module_wasm.rs @@ -2,8 +2,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -/// Build once per test process and retain immutable bytes. Publishers copy these into their own -/// directories so concurrent fixtures never share the CLI optimizer's output file. +// Each publisher needs its own copy because the CLI optimizer rewrites its input. pub fn module_bytes() -> &'static [u8] { static WASM: OnceLock> = OnceLock::new(); WASM.get_or_init(build_module_bytes) @@ -25,7 +24,6 @@ fn build_module_bytes() -> Vec { bytes } -/// Build the managed Gateway source once per test process and return its exact artifact. pub fn gateway_binary() -> &'static Path { static BINARY: OnceLock = OnceLock::new(); BINARY.get_or_init(|| build_artifact("lyracore-gateway", "lyracore-gateway", &[])) diff --git a/crates/lyracore-test-support/src/source_scan.rs b/crates/lyracore-test-support/src/source_scan.rs index 756313aae..3677e3f57 100644 --- a/crates/lyracore-test-support/src/source_scan.rs +++ b/crates/lyracore-test-support/src/source_scan.rs @@ -1,5 +1,3 @@ -//! Lexical helpers for architecture tests. These scans do not prove runtime control flow. - use std::ops::Range; use rustc_lexer::{tokenize, TokenKind}; @@ -28,17 +26,14 @@ fn masked(src: &str, omit: impl Fn(TokenKind) -> bool) -> String { String::from_utf8(bytes).expect("masking whole tokens preserves UTF-8") } -/// Mask comments, literals, and test-only items or blocks, preserving byte offsets. -/// Handles outer `#[cfg(...)]` predicates. Inner `#![cfg(...)]` attributes remain in the scan. -/// This does not expand macros or `cfg_attr`. +// Inner cfg attributes, cfg_attr and macros are not expanded. pub fn code_only(src: &str) -> String { without_test_code(&masked(src, |kind| { is_comment(kind) || matches!(kind, TokenKind::Literal { .. }) })) } -// Unknown configuration predicates can hold in production. Only remove code when -// setting `test = false` proves its cfg false. +// Unknown cfg predicates may hold in production; remove only provably test-only code. fn cfg_without_test(tokens: &[(&str, Range)]) -> Option { if tokens.len() == 1 && tokens[0].0 == "test" { return Some(false); @@ -170,7 +165,6 @@ fn token_at(src: &str, byte_idx: usize) -> TokenKind { unreachable!("the lexer covers every source byte") } -/// Whether this byte belongs to a line or block comment. pub fn in_comment(src: &str, byte_idx: usize) -> bool { is_comment(token_at(src, byte_idx)) } @@ -202,19 +196,14 @@ fn body_range(src: &str, signature: &str) -> Range { panic!("unterminated body for `{signature}`"); } -/// Find a body without matching signatures or braces inside comments and literals. -/// Returns the original body, including comments. Use `code_of` for content checks. pub fn body_of(src: &str, signature: &str) -> String { src[body_range(src, signature)].to_string() } -/// Extract a body with comments removed, preserving literal values and test-only code. -/// Use `code_only` when a check must exclude non-production tokens. pub fn code_of(src: &str, signature: &str) -> String { compact(&masked(&body_of(src, signature), is_comment)) } -/// Collapse whitespace in `code_of` for the Package API's source comparisons. pub fn shape_of(src: &str, signature: &str) -> String { code_of(src, signature) .split_whitespace() diff --git a/datascripts/lib/authoring.ts b/datascripts/lib/authoring.ts index c1bc3d926..1bb8d2680 100644 --- a/datascripts/lib/authoring.ts +++ b/datascripts/lib/authoring.ts @@ -1,23 +1,3 @@ -// The typed surface a Datascript writes against: `data.spell(id)`, `.clone(newId)`, `.set(field, -// value)`, `.effect(0 | 1 | 2)`. -// -// # What an assignment means -// -// A `set` records ONE claim on ONE column, nothing else. A spell an author reads but never assigns -// produces no claim at all, so an unassigned field of an existing row keeps whatever the base import -// gives it — the Package states what it changes and stays silent about the rest. -// -// A CLONE is the other shape: the new row does not exist, so its claim carries the whole row, copied -// from the source and then overwritten by whatever the author assigns. Its child effects are cloned -// and claimed with it, at the same effect indexes, under the packed identifier rule. Cloning is the -// only way to invent a spell, which is why an insert can never be incomplete. -// -// # One handle per row -// -// `data.spell(id)` returns the SAME object every time it is called for one identifier, so two -// helpers tuning the same spell merge into one claim instead of colliding. Two clones onto one -// identifier do collide, and are refused. - import type { Spell, SpellEffect } from "../generated/types"; import type { FieldValue, PrimaryKey } from "./canonical.ts"; import { @@ -38,10 +18,8 @@ import { } from "./schema.ts"; import { describeKey, type Claim } from "./canonical.ts"; -/// The effect slots a spell has. export type EffectIndex = 0 | 1 | 2; -/// One row an author is building: the claim shape plus the base values a `get` falls back to. abstract class RowAuthor { protected readonly claimed = new Map(); @@ -52,13 +30,11 @@ abstract class RowAuthor { protected readonly table: string, ) {} - /// Record a claim on one column. protected assign(field: string, value: unknown): void { const column = resolveColumn(this.table, field); this.claimed.set(column.column, narrowValue(column, value)); } - /// The value this row will hold: what the author assigned, or the base value they did not. protected read(field: string): unknown { const column = resolveColumn(this.table, field); const value = this.claimed.get(column.column) ?? this.base.get(column.column); @@ -68,27 +44,21 @@ abstract class RowAuthor { return value.value; } - /// The row as it now stands: the base values with the author's assignments over them. This is - /// what a clone copies, so an author who tunes a spell and then clones it gets the tuned values — - /// which is what they are looking at. rowAsClaimed(): BaseRow { const row = new Map(this.base); for (const [name, value] of this.claimed) row.set(name, value); return row; } - /// The claim this row contributes, or nothing when an existing row was read but never assigned. claim(): Claim | undefined { if (this.operation === "update") { if (this.claimed.size === 0) return undefined; return buildClaim(this.key, "update", new Map(this.claimed)); } - // An insert carries the whole row: the cloned base values, then the author's assignments. return buildClaim(this.key, "insert", new Map(this.rowAsClaimed())); } } -/// One `game_spell_effect` row. export class SpellEffectAuthor extends RowAuthor { constructor(key: PrimaryKey, operation: "insert" | "update", base: BaseRow) { super(key, operation, base, SPELL_EFFECT_TABLE); @@ -104,7 +74,6 @@ export class SpellEffectAuthor extends RowAuthor { } } -/// One `game_spell` row and its effects. export class SpellAuthor extends RowAuthor { private readonly effects = new Map(); @@ -133,10 +102,8 @@ export class SpellAuthor extends RowAuthor { return this.read(field) as Spell[K]; } - /// One of this spell's effects. A slot the base row does not fill has no row to claim: an insert - /// there would have to invent every column, and an update would target a row that does not exist. effect(index: EffectIndex): SpellEffectAuthor { - spellEffectKey(this.key.spellId, index); // the index policy, before the base-data question + spellEffectKey(this.key.spellId, index); const effect = this.effects.get(index); if (!effect) { throw new BaseDataError( @@ -146,9 +113,6 @@ export class SpellAuthor extends RowAuthor { return effect; } - /// A new spell at `newId`, carrying this spell's whole row and every effect it has. The new - /// identifier must sit in the Package Spell Range; the copy is a starting point an author then - /// assigns over. clone(newId: number): SpellAuthor { const effects = new Map(); for (const [index, effect] of this.effects) { @@ -157,7 +121,6 @@ export class SpellAuthor extends RowAuthor { return this.registry.insert(newId, this.rowAsClaimed(), effects); } - /// Every claim this spell and its effects contribute. claims(): Claim[] { const claims: Claim[] = []; const own = this.claim(); @@ -170,15 +133,11 @@ export class SpellAuthor extends RowAuthor { } } -/// Every row one Datascript run claims. It hands out one author per identifier, which is what makes -/// two claims on one row impossible to write by accident and a second clone onto one identifier a -/// refusal. export class ClaimRegistry { private readonly spells = new Map(); constructor(private readonly snapshot: BaseSnapshot) {} - /// The author for an existing spell, reading the Base Snapshot. spell(spellId: number): SpellAuthor { const key = spellKey(spellId); const existing = this.spells.get(spellId); @@ -195,15 +154,12 @@ export class ClaimRegistry { return author; } - /// The author for a spell this Package invents. insert( spellId: number, base: BaseRow, baseEffects: Map, ): SpellAuthor { const key = spellKey(spellId); - // The same rule `buildClaim` applies to a finished insert, applied at the clone instead, so the - // refusal names the line that chose the identifier rather than the emit at the end of the run. if (!isPackageSpellId(spellId)) throw spellIdNotClientSafe(spellId); if (this.spells.has(spellId)) { throw duplicateClaim(SPELL_TABLE, describeKey(key)); @@ -213,7 +169,6 @@ export class ClaimRegistry { return author; } - /// Every claim the run produced. claims(): Claim[] { return [...this.spells.values()].flatMap((author) => author.claims()); } diff --git a/datascripts/lib/canonical.ts b/datascripts/lib/canonical.ts index bcca9efee..0fc604e81 100644 --- a/datascripts/lib/canonical.ts +++ b/datascripts/lib/canonical.ts @@ -1,33 +1,14 @@ -// The canonical form of a Package Delta, written to byte-match -// `crates/lyracore-package-delta/src/canonical.rs`. -// -// Byte equality is the interop contract, not a nicety: the importer re-canonicalizes every artifact -// it reads and the Module hashes what it applies, so an artifact this library writes and the same -// artifact round-tripped through Rust must be the same bytes. -// -// The rules, all of them, in the same order the Rust writer states them: -// -// * No whitespace anywhere, and no trailing newline. -// * Object members appear in a fixed declared order; `fields` members appear sorted by name. -// * Claims appear sorted by table, then spell, then effect index. -// * Integers are plain decimal. -// * An unsigned 64-bit value is a decimal string with no sign, no padding and no separators. -// * A float is the shortest decimal that reads back as the same `f32`, always with a decimal -// point. -// * A string escapes only what JSON requires, using the short escape where one exists. +// Must byte-match the Rust Package Delta writer. import { SPELL_EFFECT_TABLE, SPELL_TABLE, type FieldType } from "./schema.ts"; -/// The artifact version this library writes. export const DELTA_VERSION = 1; -/// One claimed column value, already narrowed to the column's type. export interface FieldValue { readonly type: FieldType; readonly value: number | bigint | boolean | string; } -/// The row a claim names, typed by its table. export type PrimaryKey = | { readonly kind: "spell"; readonly spellId: number } | { @@ -36,28 +17,22 @@ export type PrimaryKey = readonly effectIndex: number; }; -/// One Package's claim on one row. The table follows from the key, so a claim cannot name a table -/// its key does not describe. export interface Claim { readonly key: PrimaryKey; readonly operation: "insert" | "update"; readonly fields: Map; } -/// The durable table a key's row lives in. export function tableOf(key: PrimaryKey): string { return key.kind === "spell" ? SPELL_TABLE : SPELL_EFFECT_TABLE; } -/// The row as a conflict report writes it, which is also how a duplicate-claim refusal names it. export function describeKey(key: PrimaryKey): string { return key.kind === "spell" ? `{spell_id=${key.spellId}}` : `{spell_id=${key.spellId}, effect_index=${key.effectIndex}}`; } -/// The whole artifact in canonical form. `claims` is sorted here rather than by the caller, so the -/// order is a property of the writer and not of the order an author happened to write things in. export function writeDelta( packageId: string, sourceHash: string, @@ -68,8 +43,7 @@ export function writeDelta( return `{"version":${DELTA_VERSION},"package":${writeString(packageId)},"source_hash":${writeString(sourceHash)},"claims":[${parts}]}`; } -/// The canonical claim order: every `game_spell` row before every `game_spell_effect` row, then by -/// spell, then by effect index. Mirrors the derived `Ord` on the Rust `PrimaryKey`. +// Claim ordering must match the Rust PrimaryKey's derived Ord. export function compareKeys(a: Claim, b: Claim): number { const rank = (claim: Claim) => (claim.key.kind === "spell" ? 0 : 1); if (rank(a) !== rank(b)) return rank(a) - rank(b); @@ -98,11 +72,10 @@ function writeValue(value: FieldValue): string { return `{"type":${writeString(value.type)},"value":${scalarLiteral(value)}}`; } -/// One claimed value in its canonical JSON spelling, without the surrounding type tag. export function scalarLiteral(value: FieldValue): string { switch (value.type) { case "u64": - // A JSON number loses precision above 2^53, so an unsigned 64-bit value travels as a string. + // JSON numbers lose integer precision above 2^53, so u64 values travel as strings. return `"${value.value}"`; case "f32": return f32Literal(value.value as number); @@ -115,16 +88,13 @@ export function scalarLiteral(value: FieldValue): string { } } -/// A float as Rust's `f32` `Display` writes it, plus the decimal point Rust's writer appends: the -/// shortest decimal that reads back as the same `f32`, never in exponent form. +// Match Rust's f32 Display with a decimal point and no exponent. export function f32Literal(input: number): string { const text = shortestF32(Math.fround(input)); return text.includes(".") ? text : `${text}.0`; } -/// The shortest decimal that round-trips through `f32`. `toPrecision` is correctly rounded, so the -/// first precision that survives the round trip is the same digit string Rust's shortest-repr writer -/// produces. +// The first f32 round-trip must match Rust's shortest decimal representation. function shortestF32(value: number): string { if (!Number.isFinite(value)) { throw new RangeError(`${value} is not a finite f32`); @@ -137,8 +107,6 @@ function shortestF32(value: number): string { return plainDecimal(value.toPrecision(9)); } -/// The same value with the exponent multiplied out and no padding zeros. Rust's float `Display` -/// never uses exponent form, so neither may this. function plainDecimal(text: string): string { let sign = ""; let body = text; @@ -171,8 +139,6 @@ function plainDecimal(text: string): string { return sign + whole + (fraction ? `.${fraction}` : ""); } -/// A JSON string escaping only what JSON requires, with the short escape where one exists — the -/// exact set the Rust writer emits. function writeString(text: string): string { return JSON.stringify(text); } diff --git a/datascripts/lib/delta.ts b/datascripts/lib/delta.ts index 532ed3dd0..403aa3315 100644 --- a/datascripts/lib/delta.ts +++ b/datascripts/lib/delta.ts @@ -1,11 +1,3 @@ -// Building a Package Delta from claims, with the refusals `crates/lyracore-package-delta` applies -// to the same artifact. -// -// Every check here has a counterpart in `delta.rs`: `spellKey` / `spellEffectKey` mirror -// `PrimaryKey`'s constructors, `narrowValue` mirrors `parse_field_value`'s range narrowing, -// `buildClaim` mirrors `Claim::new`, and `buildDelta` mirrors `PackageDelta::new`. An artifact this -// module produces parses; an artifact it refuses would have been refused at import. - import { isFixtureReservedSpellId, isPackageSpellId, @@ -46,26 +38,22 @@ import { const U64_CEIL = (1n << 64n) - 1n; -/// Accepts 1 to 64 characters of `a-z`, `0-9`, `-`, `_` or `.`. Lowercase on purpose: two Packages -/// differing only in case would look like the same Package in a conflict report. +// Lowercase Package identities avoid case-only conflicts. export function validatePackageId(id: string): string { if (!/^[a-z0-9._-]{1,64}$/.test(id)) throw invalidPackageId(id); return id; } -/// Accepts exactly 64 lowercase hexadecimal characters. export function validateSourceHash(hex: string): string { if (!/^[0-9a-f]{64}$/.test(hex)) throw invalidSourceHash(hex); return hex; } -/// Names a spell header row. export function spellKey(spellId: number): PrimaryKey { checkClaimableSpellId(SPELL_TABLE, spellId); return { kind: "spell", spellId }; } -/// Names one effect of a spell. The packed durable key follows from the two components. export function spellEffectKey(spellId: number, effectIndex: number): PrimaryKey { checkClaimableSpellId(SPELL_EFFECT_TABLE, spellId); if ( @@ -78,7 +66,6 @@ export function spellEffectKey(spellId: number, effectIndex: number): PrimaryKey return { kind: "spellEffect", spellId, effectIndex }; } -/// A spell identifier is refused the same way under every operation when it is 0 or fixture-owned. function checkClaimableSpellId(table: string, spellId: number): void { if (!Number.isInteger(spellId) || spellId < 0 || spellId > 0xffff_ffff) { throw malformedKey( @@ -90,13 +77,10 @@ function checkClaimableSpellId(table: string, spellId: number): void { if (isFixtureReservedSpellId(spellId)) throw spellIdFixtureReserved(spellId); } -/// The claimable columns of a table, by their generated names. export function columnsOf(table: string): Record { return table === SPELL_TABLE ? SPELL_COLUMNS : SPELL_EFFECT_COLUMNS; } -/// Resolve a generated field name to its column, refusing a name the table does not have and a name -/// the primary key already carries. export function resolveColumn(table: string, field: string): Column { const column = columnsOf(table)[field]; if (column) return column; @@ -108,14 +92,10 @@ export function resolveColumn(table: string, field: string): Column { throw unknownField(table, field); } -/// The durable spelling of a generated name, used only to recognise a primary-key column so the -/// refusal can name it the way the schema does. function camelToSnake(field: string): string { return field.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); } -/// Narrow an author's value to the column's type, refusing anything that does not fit — the same -/// bounds the parser applies to the same claim. export function narrowValue(column: Column, value: unknown): FieldValue { const { type } = column; switch (type) { @@ -147,9 +127,7 @@ export function narrowValue(column: Column, value: unknown): FieldValue { } } -/// An unsigned column takes a whole number or a `bigint`. `u64` carries a `bigint` because a JSON -/// number loses precision above 2^53; the narrower widths carry a number, which is what the -/// generated types declare. +// u64 requires bigint because number loses integer precision above 2^53. function unsignedValue(column: Column, value: unknown, ceiling: bigint): FieldValue { let wide: bigint; if (typeof value === "bigint") { @@ -169,7 +147,6 @@ function outOfRange(column: Column, value: unknown) { return valueOutOfRange(column.column, column.type as FieldType, describe(value)); } -/// A value as a refusal quotes it. Mirrors the parser quoting the raw JSON it read. function describe(value: unknown): string { if (typeof value === "string") return JSON.stringify(value); if (typeof value === "bigint") return value.toString(); @@ -177,11 +154,6 @@ function describe(value: unknown): string { return JSON.stringify(value) ?? String(value); } -/// Build a claim, refusing everything the artifact promises it will refuse. -/// -/// The identifier policy is operation-dependent, and this is the only place it is applied: an -/// `insert` invents a row, so its spell must sit in the Package Spell Range; an `update` changes a -/// row someone else owns, so any identifier is allowed except a fixture-reserved one. export function buildClaim( key: PrimaryKey, operation: "insert" | "update", @@ -204,8 +176,6 @@ export function buildClaim( return { key, operation, fields }; } -/// The whole artifact, in canonical form. Refuses a Package that names one row twice: a Package -/// states each row once and lists every column it sets there. export function buildDelta( packageId: string, sourceHash: string, diff --git a/datascripts/lib/ids.ts b/datascripts/lib/ids.ts index a8aa814ea..3a713a2b3 100644 --- a/datascripts/lib/ids.ts +++ b/datascripts/lib/ids.ts @@ -1,37 +1,23 @@ -// Reserved spell identifier bands, and the rule that decides which of them a Package may claim. -// -// A strict mirror of `crates/lyracore-package-delta/src/ids.rs`. Every constant is a policy number, -// not a derived one; the reasoning behind each band lives in the Rust file and is not repeated here, -// because a second copy of the reasoning is a second thing to get out of date. - -/// Packed `game_spell_effect` primary key: `(spell_id << 2) | effect_index`. A Package Delta never -/// authors the packed value; it names the spell and the effect index, and this derives the key. export function packedSpellEffectId(spellId: number, effectIndex: number): bigint { return (BigInt(spellId) << 2n) | BigInt(effectIndex); } -/// Highest `effect_index` a claim may name. A vanilla spell carries three effect slots. +// Vanilla spells have three effect slots. export const MAX_SPELL_EFFECT_INDEX = 2; -/// The Package Spell Range: the identifiers a Package may INSERT. export const PACKAGE_SPELL_ID_FLOOR = 6_000_000; export const PACKAGE_SPELL_ID_CEIL = 6_999_999; -/// The seeded fixture cluster. export const FIXTURE_SPELL_ID_FLOOR = 50_000; export const FIXTURE_SPELL_ID_CEIL = 50_999; -/// The project-wide `509xxxx` fixture-reserved band. export const FIXTURE_RESERVED_ID_FLOOR = 5_090_000; export const FIXTURE_RESERVED_ID_CEIL = 5_099_999; -/// True when a Package may INSERT a spell at this identifier. export function isPackageSpellId(spellId: number): boolean { return spellId >= PACKAGE_SPELL_ID_FLOOR && spellId <= PACKAGE_SPELL_ID_CEIL; } -/// True when the identifier belongs to a seeded fixture. No Package may claim one, under any -/// operation. export function isFixtureReservedSpellId(spellId: number): boolean { return ( (spellId >= FIXTURE_SPELL_ID_FLOOR && spellId <= FIXTURE_SPELL_ID_CEIL) || diff --git a/datascripts/lib/index.ts b/datascripts/lib/index.ts index 813db0a7c..51e49c874 100644 --- a/datascripts/lib/index.ts +++ b/datascripts/lib/index.ts @@ -1,34 +1,3 @@ -// The Datascript authoring library: what a Datascript imports. -// -// A Datascript is one call: -// -// ```ts -// import { run } from "../../lib/index.ts"; -// -// await run("fire_nova", (data) => { -// const fireball = data.spell(133); -// const nova = fireball.clone(6_000_100); -// nova.set("name", "Fire Nova"); -// nova.effect(0).set("radiusYd", 8); -// }); -// ``` -// -// `run` reads the Base Snapshot, runs the script, and writes ONE canonical Package Delta artifact to -// `packages//data/.generated/spell.json`. On any refusal it throws and writes nothing, so a -// Package folder never holds a half-built artifact and the build exits non-zero. -// -// # Determinism -// -// Nothing here reads a clock, a random source or the environment beyond the two paths below, and the -// canonical writer fixes every byte. The same Datascript over the same Base Snapshot produces the -// same artifact, which is what lets an artifact be compared, hashed and diffed. -// -// # Where the paths come from -// -// Defaults are the repository's own layout, resolved from this file. `LYRACORE_BASE_SNAPSHOT` and -// `LYRACORE_PACKAGES_ROOT` override them; the build harness sets them when it runs a Datascript -// somewhere other than a checkout, and the library's own tests use them to work on a fixture. - import { dirname, join, resolve } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; @@ -40,23 +9,14 @@ import { BaseSnapshot } from "./snapshot.ts"; export { DeltaRefusal, BaseDataError } from "./refusals.ts"; export type { EffectIndex, SpellAuthor, SpellEffectAuthor } from "./authoring.ts"; -/// The base data a Datascript reads. `spell` is the whole surface on purpose: there is no reader for -/// another Package's generated Delta, so one Package cannot observe another's claims. export interface Data { spell(spellId: number): SpellAuthor; } -/// The artifact file one Datascript writes, named after the Import Family it claims rows in. const ARTIFACT_NAME = "spell.json"; -/// Where a Package's generated artifacts live, relative to the Package folder. The same path the -/// importer's Package Delta stage reads. const GENERATED_DIR = "data/.generated"; -/// Run a Datascript and write its Package Delta. -/// -/// `packageName` names the Package folder under the Packages root AND is the Package identity the -/// artifact carries, so there is no second name to keep in step. export async function run( packageName: string, script: (data: Data) => void, @@ -89,17 +49,12 @@ export async function run( return path; } -/// The digest of the Datascript that is running: the entry file's bytes, hashed whole. -/// -/// It records WHICH source produced an artifact, so a replay can tell that a Package's source moved -/// without diffing every claim. It covers the entry file rather than the whole import graph, which -/// is the honest promise: a change to a helper the Datascript imports does not move it. +// Hash only the entry file; imported helpers are outside this source hash. async function sourceHash(): Promise { const source = await readFile(entryScript()); return createHash("sha256").update(source).digest("hex"); } -/// The Datascript file the runtime was started with. function entryScript(): string { const main = (globalThis as { Bun?: { main?: string } }).Bun?.main; if (main) return main; @@ -110,7 +65,6 @@ function entryScript(): string { return script; } -/// The repository root, from this file's own location. function repoRoot(): string { return resolve(dirname(new URL(import.meta.url).pathname), "..", ".."); } diff --git a/datascripts/lib/refusals.ts b/datascripts/lib/refusals.ts index 504e61c4a..b77f0ef79 100644 --- a/datascripts/lib/refusals.ts +++ b/datascripts/lib/refusals.ts @@ -1,26 +1,3 @@ -// Why a Datascript's Package Delta was refused. -// -// A STRICT MIRROR of `DeltaError` in `crates/lyracore-package-delta/src/error.rs`. Every rule the -// Rust parser enforces is enforced here too, under the same name, so a Datascript that emits fails -// at author time rather than in the importer. Nothing here is a rule the parser does not have: an -// author who works around a refusal here would only meet it again at import. -// -// # The variants that cannot happen here -// -// `Malformed`, `UnsupportedVersion`, `MissingMember`, `UnexpectedMember` and `WrongJsonType` are the -// parser's answers to untrusted JSON; this library writes the JSON, so it cannot produce them. -// `UnknownTable` and `UnknownOperation` cannot happen because neither is authored — the table -// follows from the object an author holds, and the operation from whether it was cloned. -// `DeleteNotSupported` cannot happen because there is no delete surface to call. -// `FieldTypeMismatch` cannot happen because a claim always declares the column's OWN type: a value -// that does not fit it is `ValueOutOfRange`, exactly as the parser would rule on the same artifact. -// -// # What is NOT a refusal -// -// Missing base data is a different failure: it is about the snapshot an author is reading, not about -// the artifact they are writing, and the Rust parser has no opinion on it because it never sees base -// data. It raises `BaseDataError` instead, so the mirror stays exact. - import { FIXTURE_RESERVED_ID_CEIL, FIXTURE_RESERVED_ID_FLOOR, @@ -32,7 +9,6 @@ import { } from "./ids.ts"; import type { FieldType } from "./schema.ts"; -/// The `DeltaError` variant a refusal mirrors. export type RefusalKind = | "InvalidPackageId" | "InvalidSourceHash" @@ -47,8 +23,6 @@ export type RefusalKind = | "EmptyUpdate" | "DuplicateClaim"; -/// A Package Delta this Datascript may not emit. Thrown, never returned: a refused Datascript -/// writes nothing at all, so a half-built artifact can never reach a Package folder. export class DeltaRefusal extends Error { readonly kind: RefusalKind; @@ -59,8 +33,6 @@ export class DeltaRefusal extends Error { } } -/// The Base Snapshot does not hold what the Datascript asked for. Distinct from a `DeltaRefusal`: -/// the artifact would have been valid, the base data it was to be derived from is not there. export class BaseDataError extends Error { constructor(message: string) { super(message); diff --git a/datascripts/lib/schema.ts b/datascripts/lib/schema.ts index 425596902..7b618071f 100644 --- a/datascripts/lib/schema.ts +++ b/datascripts/lib/schema.ts @@ -1,21 +1,5 @@ -// The tables a Package Delta may claim, their claimable columns, and the wire type each column -// takes. Hand-written against the Rust catalogue in `crates/lyracore-package-delta/src/schema.rs`, -// which is itself hand-written against `module/src/spell/tables.rs`. -// -// Two names exist for every column, and both are load-bearing: -// -// * the KEY of each entry is the generated TypeScript name (`cooldownMs`), which is what an author -// writes and what the editor completes; -// * `column` is the DURABLE name (`cooldown_ms`), which is what the artifact and the Base Snapshot -// carry. -// -// The catalogue's keys are typed as exactly the generated `Spell` / `SpellEffect` columns minus the -// primary key, so a column added, removed or renamed in the Module fails `tsc --noEmit` HERE. That -// is the same standing check the Reference Datascript performs, applied to the authoring surface. - import type { Spell, SpellEffect } from "../generated/types"; -/// The wire type tag a claimed value carries. Mirrors `FieldType` in the Rust catalogue. export type FieldType = | "u8" | "u16" @@ -26,19 +10,14 @@ export type FieldType = | "bool" | "string"; -/// One claimable column: its durable name and the type a claim on it must declare. +// Keys use generated names; column values use durable names. export interface Column { readonly column: string; readonly type: FieldType; } -/// The `game_spell` columns an author may set, keyed by their generated names. `spellId` is absent: -/// a claim carries it in the key, so naming it in `fields` would let a Package state one identifier -/// twice and disagree with itself. export type SpellField = Exclude; -/// The `game_spell_effect` columns an author may set. `id` is absent for the key reason plus one -/// more: it is derived from the spell and the effect index, never authored. export type SpellEffectField = Exclude< keyof SpellEffect, "id" | "spellId" | "effectIndex" @@ -91,11 +70,8 @@ export const SPELL_EFFECT_COLUMNS: { entersCombat: { column: "enters_combat", type: "bool" }, }; -/// The durable table names, and the values an artifact's `table` member carries. export const SPELL_TABLE = "game_spell"; export const SPELL_EFFECT_TABLE = "game_spell_effect"; -/// The primary-key columns of each table, by their durable names. A claim never carries one in -/// `fields`; the key already names it. export const SPELL_KEY_COLUMNS = ["spell_id"]; export const SPELL_EFFECT_KEY_COLUMNS = ["id", "spell_id", "effect_index"]; diff --git a/datascripts/lib/snapshot.ts b/datascripts/lib/snapshot.ts index 1003d0f8c..470c3cdd7 100644 --- a/datascripts/lib/snapshot.ts +++ b/datascripts/lib/snapshot.ts @@ -1,14 +1,3 @@ -// Reading the Base Snapshot: the derived `game_spell` / `game_spell_effect` rows the importer wrote -// with `--spell-snapshot`, which are the only base data a Datascript can see. -// -// The snapshot is READ-ONLY in the strong sense: this module returns copies, and nothing in the -// library writes to it. It is also the ONLY source of rows a Datascript can read. A Package's -// generated artifacts are never opened here, so one Package cannot observe another's Delta — not by -// policy, but because there is no code path that would. -// -// Column names in the snapshot are the DURABLE ones, the same names the artifact carries. The -// generated names an author writes are resolved to them by the catalogue in `schema.ts`. - import { MAX_SPELL_EFFECT_INDEX } from "./ids.ts"; import { BaseDataError } from "./refusals.ts"; import { @@ -18,16 +7,12 @@ import { } from "./schema.ts"; import type { FieldValue } from "./canonical.ts"; -/// The snapshot format this library reads. const SNAPSHOT_VERSION = 1; -/// One base row: every claimable column, by its durable name. export type BaseRow = ReadonlyMap; -/// The base data one Datascript run reads. export class BaseSnapshot { private readonly spells: Map; - /// Keyed by spell, then by effect index, so a clone can copy exactly the slots the base fills. private readonly effects: Map>; private constructor( @@ -38,8 +23,6 @@ export class BaseSnapshot { this.effects = effects; } - /// Parse a snapshot. Refuses an unknown version and a row that does not carry every column, - /// because a partial base row would silently become a partial clone. static parse(json: string, origin: string): BaseSnapshot { let root: unknown; try { @@ -68,8 +51,6 @@ export class BaseSnapshot { for (const raw of expectArray(record.game_spell_effect, origin, "game_spell_effect")) { const spellId = expectNumber(raw, "spell_id", origin); const effectIndex = expectNumber(raw, "effect_index", origin); - // A row outside the three real effect slots is one no Package could ever claim, so the - // snapshot and the claim rules would disagree about what exists. Say so rather than hide it. if (effectIndex < 0 || effectIndex > MAX_SPELL_EFFECT_INDEX) { throw new BaseDataError( `${origin}: spell ${spellId} carries an effect at index ${effectIndex}; a spell has ` + @@ -87,8 +68,6 @@ export class BaseSnapshot { return new BaseSnapshot(spells, effects); } - /// The base header row, or a refusal naming the spell. A Datascript may only build on base data it - /// can actually see. spell(spellId: number): BaseRow { const row = this.spells.get(spellId); if (!row) { @@ -100,12 +79,10 @@ export class BaseSnapshot { return row; } - /// The base effect rows of a spell, by effect index. Empty when the spell has none. effectsOf(spellId: number): ReadonlyMap { return this.effects.get(spellId) ?? new Map(); } - /// How many rows the snapshot holds, for a build to report. get size(): { spells: number; effects: number } { let effects = 0; for (const slots of this.effects.values()) effects += slots.size; @@ -136,9 +113,6 @@ function expectNumber( return value; } -/// One row's claimable columns, narrowed to the types the catalogue declares. The snapshot writer is -/// the Package Delta canonical writer, so an unsigned 64-bit value arrives as a decimal string here -/// exactly as it would inside a claim. function readRow( raw: Record, columns: Record, diff --git a/datascripts/runtime-scripts/bindings.ts b/datascripts/runtime-scripts/bindings.ts index 1eadc81af..258083d61 100644 --- a/datascripts/runtime-scripts/bindings.ts +++ b/datascripts/runtime-scripts/bindings.ts @@ -63,7 +63,7 @@ function tsLiteral(file: string, expression: ts.Expression): unknown { function tsBinding(file: string, source: string, packageName: string): SourceBinding | undefined { const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); - // A legacy source can own a local variable with this name. + // Legacy scripts can declare their own events variable. if (tree.statements.some((node) => (ts.isVariableStatement(node) && node.declarationList.declarations.some((declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === "events")) @@ -142,7 +142,7 @@ function luaLiteral(file: string, expression: lua.Expression): unknown { function luaBinding(file: string, source: string, packageName: string): SourceBinding | undefined { let tree: lua.Chunk; try { - // Lua strings contain bytes. Parse those bytes without changing the emitted UTF-8 source. + // Lua strings contain bytes; parse them without changing the emitted UTF-8 source. tree = lua.parse(Buffer.from(source, "utf8").toString("latin1"), { luaVersion: "5.3", encodingMode: "pseudo-latin1", }); @@ -208,7 +208,6 @@ export function readBinding(file: string, source: string, packageName: string): return file.endsWith(".ts") ? tsBinding(file, source, packageName) : luaBinding(file, source, packageName); } -/** Capture the one declared handler inside the Invocation, then preserve its Script Answer. */ export function bindInvocation(source: string, binding: SourceBinding): string { let local = "____lyracore_handler"; while (source.includes(local)) local += "_"; diff --git a/datascripts/runtime-scripts/build-scripts.ts b/datascripts/runtime-scripts/build-scripts.ts index 12e1c7239..9af090d3f 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -1,7 +1,3 @@ -// Compile a Package's Runtime Scripts into one Script Artifact. Each file binds one named -// function to a typed event. Legacy Script Directives remain readable during migration. -// Stable numeric identities live in the Package's script-ids.json and must travel with its source. - import { createHash } from "node:crypto"; import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { mkdir, mkdtemp, open, readFile, rename, writeFile } from "node:fs/promises"; @@ -11,13 +7,10 @@ import { bindInvocation, readBinding, type SourceBinding } from "./bindings.ts"; import { readDirectives } from "./directives.ts"; import { allocateScriptId, renderScriptIds, scriptIds, SCRIPT_IDS_FILE } from "./script-ids.ts"; -/// Where a Package keeps its Runtime Script sources, relative to the Package folder. const SCRIPTS_DIR = "scripts"; -/// Where a Package's generated artifacts live, relative to the Package folder. const GENERATED_DIR = "data/.generated"; -/// One script, as its directives and its Lua describe it. interface RuntimeScript { readonly script_id: number; readonly name: string; @@ -27,15 +20,12 @@ interface RuntimeScript { readonly source: string; } -/// A refusal an author can act on: it always names the file. class ScriptRefusal extends Error {} function refuse(file: string, what: string): never { throw new ScriptRefusal(`${file}: ${what}`); } -// ---- directives ---- - function requiredDirective(file: string, directives: Map, key: string): string { const value = directives.get(key); if (value === undefined) { @@ -60,14 +50,12 @@ function enabled(file: string, directives: Map): boolean { return refuse(file, `\`@enabled ${raw}\` is neither \`true\` nor \`false\``); } -/// The catalogue the Module dispatches, or `undefined` when the caller did not supply one. function hookEvents(): Set | undefined { const raw = process.env.LYRACORE_HOOK_EVENTS; if (raw === undefined) return undefined; return new Set(raw.split("\n").map((name) => name.trim()).filter((name) => name.length > 0)); } -/// A core event from the catalogue, or a Package Event this Package fires itself. function checkEvent(file: string, event: string, packageName: string, catalogue: Set | undefined): void { if (catalogue === undefined || catalogue.has(event)) return; const own = event.startsWith(`${packageName}.`) && /^[a-z][a-z0-9_]*$/.test(event.slice(packageName.length + 1)); @@ -79,9 +67,6 @@ function checkEvent(file: string, event: string, packageName: string, catalogue: ); } -// ---- compilation ---- - -/// The directory this file sits in: the pinned Runtime Script toolchain. function toolchainDir(): string { return dirname(new URL(import.meta.url).pathname); } @@ -94,16 +79,7 @@ function packagesRoot(): string { return process.env.LYRACORE_PACKAGES_ROOT ?? join(repoRoot(), "packages"); } -/// Compile ONE `.ts` under `scriptsDir` into `outDir`, with the checked-in settings. -/// -/// One file per invocation, not one program per Package: every script declares `function script()` -/// at top level, and a shared program would make the second one a duplicate declaration. Separate -/// programs also match the Host, which runs each script as its own chunk with its own globals. -/// -/// The per-run config is the checked-in `tsconfig.json` with three members added and the plugin path -/// made absolute. It is NOT an `extends` of it: `typescript-to-lua` resolves a plugin path against -/// the config it was invoked with, so a relative one in a base config would resolve from the wrong -/// directory. Materialising the whole config keeps that resolution the toolchain's own business. +// Compile separately so legacy script() declarations cannot collide. async function compile(scriptsDir: string, source: string, outDir: string, bound: boolean): Promise { const toolchain = toolchainDir(); const config = JSON.parse(await readFile(join(toolchain, "tsconfig.json"), "utf8")) as { @@ -114,6 +90,7 @@ async function compile(scriptsDir: string, source: string, outDir: string, bound config.compilerOptions.rootDir = scriptsDir; config.compilerOptions.outDir = outDir; config.files = [join(toolchain, bound ? "runtime-script.d.ts" : "runtime-script.base.d.ts"), source]; + // TSTL resolves plugin paths relative to the temporary config. config.tstl.luaPlugins = config.tstl.luaPlugins.map((plugin) => ({ ...plugin, name: resolve(toolchain, plugin.name), @@ -137,13 +114,7 @@ async function compile(scriptsDir: string, source: string, outDir: string, bound } } -// ---- the artifact ---- - -/// The digest covers immediate `.ts` and `.lua` sources and the recorded script identities, -/// sorted by basename and length-prefixed to match the CLI Build Identity. -/// -/// The toolchain is deliberately NOT in it. `source_hash` answers "which revision of this Package's -/// sources is on the Shard"; whether the compiler moved is the Build Identity sidecar's question. +// Match the CLI Build Identity framing; source_hash excludes the toolchain. async function sourceHash(scriptsDir: string, files: string[], ids: string): Promise { const digest = createHash("sha256"); for (const file of [...files, SCRIPT_IDS_FILE].sort()) { @@ -157,12 +128,6 @@ async function sourceHash(scriptsDir: string, files: string[], ids: string): Pro return digest.digest("hex"); } -/// The artifact, as `crates/lyracore-package-delta/src/script.rs` parses it. -/// -/// Indented rather than canonical: the Official Package Collection COMMITS Script Artifacts, and a -/// reviewer reads this file. The Module canonicalises it again before hashing, so how it is spelled -/// changes nothing it means. Member order and script order are still fixed, so two runs over one -/// source tree write the same bytes. function renderArtifact(packageName: string, hash: string, scripts: RuntimeScript[]): string { const ordered = [...scripts].sort((a, b) => a.script_id - b.script_id); return `${JSON.stringify( @@ -185,7 +150,6 @@ function renderArtifact(packageName: string, hash: string, scripts: RuntimeScrip )}\n`; } -/// Every script source a Package ships, in file-name order. function scriptSources(scriptsDir: string): string[] { const listing = existsSync(scriptsDir) ? readdirSync(scriptsDir, { withFileTypes: true }) : []; return listing @@ -194,11 +158,7 @@ function scriptSources(scriptsDir: string): string[] { .sort(); } -/// Flush `artifact` to a sibling temporary file, then replace `path` in one rename. -/// -/// The old artifact stays whole if creating, writing, flushing, closing, or renaming the temporary -/// file fails. The temporary directory is on the destination filesystem, so the final rename is -/// atomic rather than a copy followed by deletion. +// The temporary file must share the destination filesystem for an atomic rename. async function writeArtifact(path: string, artifact: string): Promise { const parent = dirname(path); await mkdir(parent, { recursive: true }); @@ -218,7 +178,6 @@ async function writeArtifact(path: string, artifact: string): Promise { } } -/// Build one Package's Script Artifact and write it. Returns the path written. export async function buildPackageScripts(packageName: string): Promise { const packageDir = join(packagesRoot(), packageName); const scriptsDir = join(packageDir, SCRIPTS_DIR); diff --git a/datascripts/runtime-scripts/directives.ts b/datascripts/runtime-scripts/directives.ts index a58c282d7..50beb6c8e 100644 --- a/datascripts/runtime-scripts/directives.ts +++ b/datascripts/runtime-scripts/directives.ts @@ -1,4 +1,3 @@ -/** Read legacy headers before the first line that is neither blank nor a comment. */ export function readDirectives(file: string, source: string): Map { const directives = new Map(); for (const line of source.split("\n")) { diff --git a/datascripts/runtime-scripts/event-names.rs b/datascripts/runtime-scripts/event-names.rs index 4ba5832dc..818e27ddf 100644 --- a/datascripts/runtime-scripts/event-names.rs +++ b/datascripts/runtime-scripts/event-names.rs @@ -1,5 +1,4 @@ // GENERATED by generate-events.ts from events.json. DO NOT EDIT. -/// Core hook names accepted by the Script Artifact parser. pub const HOOK_EVENT_NAMES: &[&str] = &[ "on_damage_taken", "on_death_prevented", diff --git a/datascripts/runtime-scripts/fuel-workload.ts b/datascripts/runtime-scripts/fuel-workload.ts index e79cd4e3c..e152fc063 100644 --- a/datascripts/runtime-scripts/fuel-workload.ts +++ b/datascripts/runtime-scripts/fuel-workload.ts @@ -1,19 +1,5 @@ // @event on_login // @id 100999 -// -// The transpiler-shaped workload the Runtime Script Host's Fuel Budget is sized against, in the -// language a Package author writes it in. -// -// It is not installed anywhere. The directives above are there only so a test can copy this file -// straight into a scratch Package and get a real Runtime Script out; no Package ships it. Its ONLY job is to be compiled by the pinned -// toolchain and to have its Lua committed next to it, so two tests can meet on the same bytes: -// `datascripts/tests/runtime-scripts.test.ts` recompiles it and refuses a stale `.lua`, and the -// Module's `the_generated_representative_script_fits_the_fuel_budget` runs that same `.lua` on the -// Host. What the budget is sized against is therefore real transpiler output, not a hand-written -// impression of it. -// -// It reaches for the shapes the lua library has to supply: an array built by pushing, a -// higher-order map, string work, a join, and a Host Verb. function script(): number | void { const names: string[] = []; diff --git a/datascripts/runtime-scripts/generate-events.ts b/datascripts/runtime-scripts/generate-events.ts index 676f8e5a5..153a79e7b 100644 --- a/datascripts/runtime-scripts/generate-events.ts +++ b/datascripts/runtime-scripts/generate-events.ts @@ -164,7 +164,6 @@ export function luaDeclarations(): string { export function rustEventNames(): string { return [ "// GENERATED by generate-events.ts from events.json. DO NOT EDIT.", - "/// Core hook names accepted by the Script Artifact parser.", "pub const HOOK_EVENT_NAMES: &[&str] = &[", ...EVENTS.map(definition => ` ${JSON.stringify(definition.event)},`), "];", diff --git a/datascripts/runtime-scripts/lua-emit.cjs b/datascripts/runtime-scripts/lua-emit.cjs index 984deb979..52fd4cbf2 100644 --- a/datascripts/runtime-scripts/lua-emit.cjs +++ b/datascripts/runtime-scripts/lua-emit.cjs @@ -1,39 +1,12 @@ -// The two things this printer owns: the shape generated Lua must not take on the pinned -// interpreter, and the entry point every Runtime Script declares. -// -// # The table-constructor guard -// -// piccolo 0.3.3 passes an inline table constructor's ELEMENT COUNT as an extra argument when the -// constructor is the LAST argument of a call, so `f({7, 8, 9})` reaches a two-parameter `f` as -// `f(table, 3)`. `x = x or default` is the commonest shape in transpiler output, so generated Lua -// meets it constantly. Both facts are pinned in the Module by -// `piccolo_leaks_a_table_constructors_element_count_as_an_extra_argument`. -// -// Parentheses do not help: the leak is in how the call site counts arguments, not in value -// adjustment. Passing the table through a one-parameter function does, because that function drops -// the extra value and returns exactly one. So this printer rewrites `f(a, {…})` to -// `f(a, ____tbl({…}))` and prepends `____tbl` to every emitted file. -// -// A printer rather than a TypeScript visitor: it sees the whole Lua AST, including the calls tstl -// itself invents for classes, spreads and library helpers, which a visitor over TypeScript nodes -// never meets. -// -// Named Event Bindings get their invocation wrapper from build-scripts.ts. Legacy sources still -// declare script(); the appended call preserves its Script Answer. - const ts = require("typescript"); const tstl = require("typescript-to-lua"); const lua = require("typescript-to-lua/dist/LuaAST"); -/// The one-parameter passthrough. Named in the `____` space tstl reserves for its own locals. const GUARD = "____tbl"; -/// The function name every authored `.ts` Runtime Script declares. const ENTRY = "script"; -/// `params` with a trailing table constructor wrapped in the guard. Only the last argument is -/// rewritten: Lua adjusts only the last argument of a call to multiple values, and that is exactly -/// where the leak lands. +// Piccolo 0.3.3 leaks a trailing table constructor's element count as an extra argument. function guardTrailingTable(params) { if (!params || params.length === 0) return params; const last = params[params.length - 1]; @@ -42,9 +15,6 @@ function guardTrailingTable(params) { return [...params.slice(0, -1), wrapped]; } -/// `local function ____tbl(t) return t end`, prepended to every file whether or not a call needed -/// it. Emitting it unconditionally keeps the output a pure function of the source: whether the -/// guard is reachable never changes a byte anywhere else. function guardDeclaration() { const parameter = lua.createIdentifier("t"); return lua.createVariableDeclarationStatement( @@ -56,8 +26,6 @@ function guardDeclaration() { ); } -/// `return script()` — the last statement of every emitted file, so the chunk's return value is the -/// Script Answer. function entryCall() { return lua.createReturnStatement([ lua.createCallExpression(lua.createIdentifier(ENTRY), []), @@ -66,7 +34,7 @@ function entryCall() { class PiccoloPrinter extends tstl.LuaPrinter { printCallExpression(expression) { - // The guard's own call must not be wrapped again, or printing never terminates. + // Wrapping the passthrough itself would recurse forever. if (lua.isIdentifier(expression.expression) && expression.expression.text === GUARD) { return super.printCallExpression(expression); } @@ -93,8 +61,6 @@ function acceptsScriptAnswer(type) { return (type.flags & (ts.TypeFlags.NumberLike | ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0; } -/// Every non-declaration source file must declare `function script(): number | void` with no -/// parameters. The Host invokes it without arguments and reads only a numeric Script Answer. function requireEntryPoint(program) { const diagnostics = []; const checker = program.getTypeChecker(); diff --git a/datascripts/runtime-scripts/runtime-script.base.d.ts b/datascripts/runtime-scripts/runtime-script.base.d.ts index c7251cf00..14c027545 100644 --- a/datascripts/runtime-scripts/runtime-script.base.d.ts +++ b/datascripts/runtime-scripts/runtime-script.base.d.ts @@ -1,7 +1,4 @@ -// The Host supplies Entity Handles and gameplay operations for one Invocation. -// Scripts do not import modules. The builder lowers a static Event Binding to one Invocation. - -// Entity fields are snapshots. A level-up event carries the attained level in newLevel. +// Entity fields are snapshots; level-up events carry the attained level in newLevel. interface Entity { readonly name: string; readonly is_player: boolean; @@ -31,7 +28,6 @@ interface EventBindingOptions { readonly enabled?: boolean; } -// Kept for sources that declare the legacy script() entry point. declare const event: ScriptEvent; declare function heal(entity: Entity, amount: number): void; diff --git a/datascripts/runtime-scripts/runtime-script.d.ts b/datascripts/runtime-scripts/runtime-script.d.ts index 7eb250473..36189c164 100644 --- a/datascripts/runtime-scripts/runtime-script.d.ts +++ b/datascripts/runtime-scripts/runtime-script.d.ts @@ -1,8 +1,5 @@ // GENERATED by generate-events.ts from events.json and runtime-script.base.d.ts. DO NOT EDIT. -// The Host supplies Entity Handles and gameplay operations for one Invocation. -// Scripts do not import modules. The builder lowers a static Event Binding to one Invocation. - -// Entity fields are snapshots. A level-up event carries the attained level in newLevel. +// Entity fields are snapshots; level-up events carry the attained level in newLevel. interface Entity { readonly name: string; readonly is_player: boolean; @@ -32,7 +29,6 @@ interface EventBindingOptions { readonly enabled?: boolean; } -// Kept for sources that declare the legacy script() entry point. declare const event: ScriptEvent; declare function heal(entity: Entity, amount: number): void; diff --git a/datascripts/runtime-scripts/script-ids.ts b/datascripts/runtime-scripts/script-ids.ts index a8085b67d..d437733af 100644 --- a/datascripts/runtime-scripts/script-ids.ts +++ b/datascripts/runtime-scripts/script-ids.ts @@ -103,7 +103,6 @@ async function readPackageIds(packageDir: string, packageName: string): Promise< return ledger; } -/** New IDs avoid every installed Package's saved identities, artifacts and legacy headers. */ export async function scriptIds(packageDir: string, packageName: string): Promise { const ledger = await readPackageIds(packageDir, packageName); const packagesDir = dirname(packageDir); @@ -111,7 +110,7 @@ export async function scriptIds(packageDir: string, packageName: string): Promis for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { if (entry.name === packageName || entry.name.startsWith(".")) continue; const siblingDir = join(packagesDir, entry.name); - // Collection checks install Package directories as symlinks. Disabled Packages live elsewhere. + // Collection checks install Package directories as symlinks. const directory = entry.isDirectory() || (entry.isSymbolicLink() && await stat(siblingDir).then( (info) => info.isDirectory(), (error: unknown) => { diff --git a/datascripts/src/reference.ts b/datascripts/src/reference.ts index 4ab612f24..efc661927 100644 --- a/datascripts/src/reference.ts +++ b/datascripts/src/reference.ts @@ -1,25 +1,5 @@ -// The reference Datascript: the maintained, minimal example of author-time code written against -// the Module schema, and the standing check for schema drift. -// -// Every name below comes from `generated/`, which `lyracore packages build` regenerates from the -// Module wasm before it typechecks. That is the whole point of this file: rename or retype a field -// in the Module and `tsc --noEmit` fails HERE, at author time, instead of at apply time. Keep it -// referencing real columns for exactly that reason. -// -// This file references core tables only, because the bare checkout ships no Package with tables. -// An installed Package's tables land in `generated/` the same way; the `dungeons` Package's -// verification covers that half when it is installed. -// -// It deliberately writes nothing and applies nothing. Building a Package Delta from a Datascript is -// separate work; this file only proves that the generated schema binds. - import type { Spell } from "../generated/types"; -/// One derived rank of a Spell, in the shape the schema actually declares. -/// -/// Ranks in 1.12.1 share a name and differ by id, cost and effect magnitude, so a rank is the base -/// row with those columns replaced. Returning `Spell` rather than a loose object is what makes the -/// typecheck meaningful: a column this function forgets, misspells or mistypes is an error. export function spellRank(base: Spell, rank: number): Spell { return { ...base, @@ -32,8 +12,6 @@ export function spellRank(base: Spell, rank: number): Spell { }; } -/// The reading a rank ladder is built from, kept as a plain summary so the return type names the -/// generated columns it reads rather than restating them as loose strings. export function describeRank(spell: Spell): string { return `${spell.name} #${spell.spellId}: ${spell.cost} power, ${spell.cooldownMs}ms cooldown`; } diff --git a/datascripts/tests/authoring.test.ts b/datascripts/tests/authoring.test.ts index 00f0c198f..e705ab641 100644 --- a/datascripts/tests/authoring.test.ts +++ b/datascripts/tests/authoring.test.ts @@ -1,9 +1,3 @@ -// The authoring surface, tested against a SYNTHETIC Base Snapshot. -// -// `fixtures/base-snapshot.json` is hand-written and every value in it is invented. It carries the -// same column names and the same canonical spellings a real snapshot would, and no client-derived -// value at all, so these tests never touch the operator's game data. - import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -62,7 +56,7 @@ test("a clone carries the whole row and every effect the source has", () => { const emitted = claims.claims(); - expect(emitted).toHaveLength(3); // the header plus the source's two effects + expect(emitted).toHaveLength(3); const header = emitted.find((c) => c.key.kind === "spell")!; expect(header.operation).toBe("insert"); expect([...header.fields.keys()].sort()).toEqual( @@ -190,8 +184,6 @@ test("an update that states no change is refused", () => { ).toBe("EmptyUpdate"); }); -/// The AC in one test: a Package cannot see what another Package invents, because the only rows a -/// Datascript can read are the base ones, and a Package spell is never in the base data. test("a spell another Package would have invented is not readable", () => { expect(() => registry().spell(PACKAGE_SPELL)).toThrow(BaseDataError); }); diff --git a/datascripts/tests/canonical.test.ts b/datascripts/tests/canonical.test.ts index 6a8ed2b83..d11524a60 100644 --- a/datascripts/tests/canonical.test.ts +++ b/datascripts/tests/canonical.test.ts @@ -1,7 +1,3 @@ -// The canonical writer has to agree with the Rust one byte for byte, and the float spelling is the -// only part where two languages could plausibly disagree. `fixtures/f32-literals.json` pins that -// agreement: the literals in it were produced by Rust, and both sides assert against them. - import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/datascripts/tests/emit.test.ts b/datascripts/tests/emit.test.ts index ca5678165..02dc85701 100644 --- a/datascripts/tests/emit.test.ts +++ b/datascripts/tests/emit.test.ts @@ -1,18 +1,3 @@ -// The whole path, end to end: run the Fire Nova Datascript the way `lyracore packages build` runs -// it, and compare the bytes it writes with the frozen artifact. -// -// The frozen artifact is `fixtures/fire-nova.expected.json`. The Rust interop test in -// `crates/lyracore-package-delta/tests/datascript_interop.rs` parses the SAME file, so these bytes -// are the contract between the two languages. Regenerate it whenever the Datascript changes: -// -// LYRACORE_BASE_SNAPSHOT=datascripts/tests/fixtures/base-snapshot.json \ -// LYRACORE_PACKAGES_ROOT= \ -// bun run datascripts/tests/fixtures/fire_nova.ts -// -// The Datascript is run as a SUBPROCESS rather than imported, because the source hash it records is -// the entry script's own digest — which only means what it says when the entry script is the -// Datascript. - import { expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -23,7 +8,6 @@ const DATASCRIPT = join(import.meta.dir, "fixtures", "fire_nova.ts"); const SNAPSHOT = join(import.meta.dir, "fixtures", "base-snapshot.json"); const FROZEN = join(import.meta.dir, "fixtures", "fire-nova.expected.json"); -/// Run the Datascript against the synthetic snapshot, into a scratch Packages root. function emit(): { code: number; stderr: string; artifact?: string } { const root = mkdtempSync(join(tmpdir(), "lyracore-datascript-")); try { @@ -76,7 +60,7 @@ test("the artifact claims five ranks, each with its cloned effects", () => { ); expect(spells).toHaveLength(5); - expect(effects).toHaveLength(10); // the source's two effects, cloned onto each rank + expect(effects).toHaveLength(10); expect(spells.every((c: { operation: string }) => c.operation === "insert")).toBe(true); expect( spells.map((c: { key: { spell_id: number } }) => c.key.spell_id), diff --git a/datascripts/tests/fixtures/fire-nova.expected.json b/datascripts/tests/fixtures/fire-nova.expected.json index d13f8bf09..61c67989e 100644 --- a/datascripts/tests/fixtures/fire-nova.expected.json +++ b/datascripts/tests/fixtures/fire-nova.expected.json @@ -1 +1 @@ -{"version":1,"package":"fire_nova","source_hash":"e84139afd0f845c0c590983687564f8116889e7151fc1a52616192e11c806bd5","claims":[{"table":"game_spell","key":{"spell_id":6000100},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":40},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 1)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":12},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000101},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":80},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 2)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":22},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000102},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":145},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 3)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":32},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000103},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":240},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 4)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":42},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000104},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":355},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 5)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":52},"stances":{"type":"u8","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000100,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":33},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000100,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000101,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":71},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000101,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000102,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":129},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000102,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000103,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":224},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000103,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000104,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":340},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000104,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}}]} \ No newline at end of file +{"version":1,"package":"fire_nova","source_hash":"762f266974f72e8b0c3cd8e6e15fbc4fc5ebcdb5e0fcf2a348fccd48b15f9f91","claims":[{"table":"game_spell","key":{"spell_id":6000100},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":40},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 1)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":12},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000101},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":80},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 2)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":22},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000102},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":145},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 3)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":32},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000103},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":240},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 4)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":42},"stances":{"type":"u8","value":0}}},{"table":"game_spell","key":{"spell_id":6000104},"operation":"insert","fields":{"attributes":{"type":"u32","value":0},"aura_interrupt":{"type":"u16","value":1},"cast_flags":{"type":"u32","value":0},"cast_time_ms":{"type":"u32","value":0},"cooldown_ms":{"type":"u32","value":8000},"cost":{"type":"u32","value":355},"dispel_type":{"type":"u8","value":1},"duration_ms":{"type":"u32","value":6000},"family_flags":{"type":"u64","value":"1024"},"family_name":{"type":"u8","value":3},"gcd_ms":{"type":"u32","value":1500},"is_negative":{"type":"bool","value":true},"max_level":{"type":"u8","value":0},"max_stacks":{"type":"u8","value":0},"mechanic":{"type":"u8","value":0},"name":{"type":"string","value":"Fire Nova (Rank 5)"},"power_type":{"type":"u8","value":0},"proc_chance":{"type":"u8","value":0},"proc_charges":{"type":"u8","value":0},"proc_flags":{"type":"u32","value":0},"range_yd":{"type":"u32","value":0},"school_mask":{"type":"u8","value":4},"spell_level":{"type":"u8","value":52},"stances":{"type":"u8","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000100,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":33},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000100,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000101,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":71},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000101,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000102,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":129},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000102,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000103,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":224},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000103,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000104,"effect_index":0},"operation":"insert","fields":{"base_points":{"type":"i32","value":340},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":1},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.5},"period_ms":{"type":"u32","value":0},"radius_yd":{"type":"f32","value":8.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":4},"trigger_spell":{"type":"u32","value":0}}},{"table":"game_spell_effect","key":{"spell_id":6000104,"effect_index":1},"operation":"insert","fields":{"base_points":{"type":"i32","value":4},"chain_targets":{"type":"u8","value":0},"die_sides":{"type":"i32","value":0},"effect_mechanic":{"type":"u8","value":0},"enters_combat":{"type":"bool","value":false},"kind":{"type":"u8","value":129},"p0":{"type":"i32","value":0},"p0_kind":{"type":"u8","value":255},"p1":{"type":"i32","value":0},"per_level":{"type":"f32","value":0.0},"period_ms":{"type":"u32","value":3000},"radius_yd":{"type":"f32","value":0.0},"script_id":{"type":"u32","value":0},"target":{"type":"u8","value":1},"trigger_spell":{"type":"u32","value":0}}}]} \ No newline at end of file diff --git a/datascripts/tests/fixtures/fire_nova.ts b/datascripts/tests/fixtures/fire_nova.ts index 813d5cdbf..6a729b93b 100644 --- a/datascripts/tests/fixtures/fire_nova.ts +++ b/datascripts/tests/fixtures/fire_nova.ts @@ -1,28 +1,18 @@ -// Five spell ranks for the Datascript emission and Rust interop tests. Uses a synthetic Base Snapshot. - import { run } from "../../lib/index.ts"; -/// The Package this Datascript writes for: the folder under `packages/` and the Package identity the -/// artifact carries. const PACKAGE = "fire_nova"; -/// Fireball (Rank 1) — a direct fire nuke, which is the shape Fire Nova starts from. const FIREBALL = 133; -/// The first identifier in the Package Spell Range this Package claims. Five consecutive ranks -/// follow it. const FIRST_RANK_ID = 6_000_100; const RANKS = 5; -/// Our own TargetKind taxonomy (`module/src/spell/taxonomy.rs`): every enemy inside the radius. +// Matches T_AREA_ENEMY in the Module target taxonomy. const T_AREA_ENEMY = 4; -/// The nova's burst radius, in yards, at every rank. const RADIUS_YD = 8; -/// A rank's level requirement, mana cost and damage. Written as one table so the ladder reads as -/// data rather than as arithmetic scattered through a loop. const LADDER = [ { level: 12, cost: 40, damage: 33 }, { level: 22, cost: 80, damage: 71 }, @@ -39,20 +29,18 @@ await run(PACKAGE, (data) => { const nova = fireball.clone(FIRST_RANK_ID + rank); nova.set("name", `Fire Nova (Rank ${rank + 1})`); - nova.set("castTimeMs", 0); // instant, unlike the Fireball it was cloned from + nova.set("castTimeMs", 0); nova.set("cooldownMs", 8_000); - nova.set("rangeYd", 0); // centred on the caster, so there is no cast range + nova.set("rangeYd", 0); // Caster-centered spells have no cast range. nova.set("cost", tier.cost); nova.set("spellLevel", tier.level); nova.set("maxStacks", 0); - // The clone carries Fireball's own damage effect. Retarget it at everything nearby and set this - // rank's magnitude; the columns not named here — school, kind, mechanic — stay Fireball's. const burst = nova.effect(0); burst.set("target", T_AREA_ENEMY); burst.set("radiusYd", RADIUS_YD); burst.set("basePoints", tier.damage); - burst.set("dieSides", 0); // a flat burst, not Fireball's damage range + burst.set("dieSides", 0); burst.set("chainTargets", 0); } }); diff --git a/datascripts/tests/runtime-scripts.test.ts b/datascripts/tests/runtime-scripts.test.ts index e4213ac99..16bf141b9 100644 --- a/datascripts/tests/runtime-scripts.test.ts +++ b/datascripts/tests/runtime-scripts.test.ts @@ -1,11 +1,3 @@ -// The Runtime Script build, end to end: sources under a Package's `scripts/`, one Script Artifact -// out, and the same bytes every time. -// -// The committed `runtime-scripts/fuel-workload.lua` is where this suite meets the Module: -// `module/src/runtime_script.rs` runs those exact bytes on the Runtime Script Host and sizes the -// Fuel Budget against them. So the first test recompiles the workload and refuses a stale copy — -// otherwise the Module would be measuring Lua the toolchain no longer emits. - import { expect, test } from "bun:test"; import { chmodSync, @@ -30,10 +22,6 @@ interface Built { readonly scripts: Array<{ script_id: number; name: string; event: string; source: string }>; } -/// Build a scratch Package holding `files`, and return its artifact. -/// -/// A scratch root rather than `packages/`: the artifact is what is under test, and a build that -/// wrote into the checkout would make one test's output another test's input. async function build( files: Record, events?: string[], @@ -64,13 +52,10 @@ async function build( } } -/// The two required directives, for the tests that are about something else. function directives(event = EVENT, id = 100_200): string { return `// @event ${event}\n// @id ${id}\n`; } -// ---- the workload the Module's Fuel Budget is sized against ---- - test("the committed fuel workload Lua is what the pinned toolchain emits today", async () => { const source = readFileSync(join(TOOLCHAIN, "fuel-workload.ts"), "utf8"); const committed = readFileSync(join(TOOLCHAIN, "fuel-workload.lua"), "utf8"); @@ -80,8 +65,6 @@ test("the committed fuel workload Lua is what the pinned toolchain emits today", expect(built.scripts[0]?.source).toBe(committed); }); -// ---- determinism ---- - test("two builds of one source tree write the same bytes", async () => { const files = { "greet.ts": `${directives()}function script(): number { return 7; }\n` }; @@ -115,11 +98,8 @@ test("the scripts are ordered by identifier, whatever order the files sort in", expect(built.scripts.map((s) => s.name)).toEqual([`${PACKAGE}.zeta`, `${PACKAGE}.alpha`]); }); -// ---- the piccolo call shape ---- - test("a table constructor passed to a call is hoisted through the guard", async () => { - // piccolo 0.3.3 leaks a trailing constructor's element count as an extra argument. The Module - // pins both that fault and this fix; here we prove the emitter applies it. + // Piccolo 0.3.3 leaks a trailing constructor's element count as an extra argument. const built = await build({ "guard.ts": `${directives()}function take(o: { a: number }): number { return o.a; }\n` + @@ -139,8 +119,6 @@ test("every emitted file ends by calling its entry point, so the chunk returns t expect(built.scripts[0]?.source.trimEnd().endsWith("return script()")).toBe(true); }); -// ---- hand-written Lua ---- - test("a hand-written .lua script ships unchanged", async () => { const lua = "-- @event on_login\n-- @id 100400\ngrant_xp(event.actor, 5)\n"; @@ -150,8 +128,6 @@ test("a hand-written .lua script ships unchanged", async () => { expect(built.scripts[0]?.name).toBe(`${PACKAGE}.bonus`); }); -// ---- refusals, each naming the file ---- - test("a script declaring no entry point is refused", async () => { expect(build({ "bare.ts": `${directives()}const x = 1;\n` })).rejects.toThrow( /typescript-to-lua refused/, diff --git a/deploy/systemd/spacetimedb-standalone.service b/deploy/systemd/spacetimedb-standalone.service index 564552b28..c56b184d2 100644 --- a/deploy/systemd/spacetimedb-standalone.service +++ b/deploy/systemd/spacetimedb-standalone.service @@ -1,4 +1,3 @@ -# The pinned lyracore CLI reads this unit from this path for `lyracore service reconcile`. [Unit] Description=LyraCore SpacetimeDB standalone Wants=network-online.target diff --git a/docs/architecture.md b/docs/architecture.md index c23233bb0..98ef76017 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,40 @@ routing fact; module game logic never reads one, and an architecture test fails [`schema.md`](./schema.md) §6 lists them all. Nothing on a gateway timer decides gameplay. - **All authorization of writes** — a reducer derives its actor from `ctx.sender`. +The [creature cycle](../module/src/creatures/cycle/mod.rs) advances splines before behavior phases check range, +so those phases read the position the client renders. On sensing firings, aggro precedes assist and +pet engagement, then EventAI gets casting authority before compatibility casts and threat retargeting. +Casting and retargeting precede chase, so a ready caster can cast and a chaser follows its current +target in the same firing. Combat entry precedes regeneration and combat exit; regeneration precedes +exit, so a unit that leaves combat recovers from the next firing. Returning home takes priority over +idle wandering. Rout and fear movement follow regeneration. On other firings, EventAI runs after +rout and fear movement. Package passes run after the Core phases. + +The active-cell sweep limits idle and proximity work. Engaged phases use their live engagement rows +and firing scope, so a Character cannot freeze a fight by moving beyond every active cell. Global +due-time maintenance, regeneration, combat exit and Package passes run only on the catch-all firing; +dedicated Instance firings must not repeat them. The +[maintenance adapter](../module/src/creatures/cycle/ctx.rs) runs decay before respawn, and +[decay](../module/src/creatures/tick/lifecycle.rs) arms a future respawn deadline to prevent a corpse +from returning in the same firing. + +#### Navigation legs + +The [shared leg search](../crates/lyracore-shared/src/nav.rs) plans short movement legs. Callers +replan after partial progress. `find_leg_ex` returns waypoints, the number of expanded search nodes, +and whether the search reached the goal. `find_leg` discards the count and completeness, so `Some` +alone does not prove that the leg reaches its destination. `None` means the search found no usable +leg, including invalid coordinates or blocked movement. + +Waypoints omit the starting point and a complete exact-target leg ends at `to`. When the target +condition already holds, the result is `[from]`. A direct leg uses zero expansions. A search that +cannot reach the goal can return a partial leg to the explored node nearest it; callers must retain +the distinction between partial progress and reaching the goal. + +`find_leg_in_range_ex` uses `LegOutcome` to preserve completeness and retains the expansion count +even for a blocked search. Its target condition is reaching a walkable point within `stop_dist` +of `to`, with `[from]` returned immediately when already in range. + ### 2.2 The gateway owns - **Socket IO and protocol state only.** Per-connection: the SRP6 scratch, the header-cipher @@ -167,6 +201,12 @@ Petitions and Signatures. A Guild Invite it sent stays, as in mangos. A deleted earliest join first, and a Guild with nobody left disbands. The op finds nothing on a second call, so a pass that a restart interrupts is repeated whole. +Each durable presence snapshot uses a temporary filtered subscription. The ordinary Coordinator +cache can lag a commit made through another Gateway. The temporary subscription's `on_applied` +callback samples presence after the server snapshot reaches the cache. Removing the temporary +subscription leaves the main subscription owning the Character rows. Replacing this step with +ordinary cache reads could make a live Character appear absent and remove its Guild membership. + The mail Delivery Delay is the same shape. The Gateway reads the Realm Account of the sender and of the recipient from the World Shards: the Account Character Owner name, else the name of a local Account that is not a shadow Account. It conveys one `same_account` flag in `realm_mail_send`, @@ -427,7 +467,7 @@ sharded tables. |---|---|---| | Cell size | 50 yd | `GRID_CELL_SIZE`, `crates/lyracore-shared/src/spatial.rs` | | Box | 5×5 cells (`BOX_HALF_SPAN = 2`) | `BOX_HALF_SPAN`, `spatial.rs` | -| Guaranteed-visible radius | 100 yd (box spans ~250 yd; culls past ~150 yd on an axis) | `spatial.rs` doc comment on `GRID_CELL_SIZE` | +| Guaranteed-visible radius | 100 yd (box spans ~250 yd; culls past ~150 yd on an axis) | `GRID_CELL_SIZE * BOX_HALF_SPAN` in `spatial.rs` | | Recenter | on map change or leaving the anchor cell — roughly every 7 s of walking | `gateway/src/stdb/aoi.rs` (`GridBox`/recenter logic) | | Kill switch | `LYRACORE_AOI=0`; **default on since 2026-07-10** | `gateway/src/config.rs` | @@ -774,6 +814,12 @@ warning banner states plainly, not a contradiction to resolve in this document. | Recipes | Step lists for common changes: [add an opcode](./recipes/add-an-opcode.md), [add a spell effect](./recipes/add-a-spell-effect.md). | | [`schema.md`](./schema.md) | The table-level data model. | | [`package-api.md`](./package-api.md) | The Package API, version 2: what a Package's Rust half may call, and what core promises about it. | +| [`actor-verbs.md`](./actor-verbs.md) | Before adding Actor verbs or changing their Gates. | +| [`package-delta-format.md`](./package-delta-format.md) | Before changing artifact parsing, canonicalization or claim conflicts. | +| [`accept-policy.md`](./accept-policy.md) | Before changing listener retries or blocking-task capacity. | +| [`realm-loot-routing.md`](./realm-loot-routing.md) | Before changing Loot Roll promotion, voting or settlement across Shards. | +| [`instance-lifecycle.md`](./instance-lifecycle.md) | Before changing Instance allocation, Group bindings or pool routing. | +| [`runtime-script-host.md`](./runtime-script-host.md) | Before changing Runtime Script execution or compiler compatibility. | ### Operating and building diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index 772130aad..94bd1adb6 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -100,6 +100,23 @@ root, hover, feather-fall, water-walk, knockback, teleport and spline completion acknowledgements keep the World Session usable. This does not establish boat simulation, transport membership or acknowledgement counter enforcement. +### Root receipts + +The client moves independently of the Module. The Gateway sends a root packet for a root, stun or +polymorph and sends an unroot packet only after the last hold ends. The Module drops steering +samples while held, but accepts stop receipts, turns and landings so fall damage still applies. + +`gateway/src/world/mod.rs::root_receipt_stop` relays the current Character's root or unroot receipt +as a stop. Peers otherwise retain the last run. A root receipt clears steering flags so the Module +accepts the reported stop position. The pinned library's `clear_*` methods mask with +`reverse_bits`, so the Gateway clears the bits in the encoded bytes instead. The typed reader skips +the build-5875 transport tail; the encoder refuses that incomplete value, so receipts on a +transport are not relayed. + +Movement interruption uses client flags, including jumping, rather than a change in stored +position. A delayed stop can update the stored position after Shadowmeld begins without ending +the aura. Turning in place also preserves it. + ## Gameplay scenarios and remaining gaps The Headless Client scenarios cover these exchanges through the same WorldStore Seam: @@ -207,9 +224,9 @@ feature once per World Session. Repeated requests do not add chat lines. | Summon acceptance, remote cameras and opening cinematics | System Messages. | Summon offers, camera control and cinematic start state. | | LFG preferences | System Message. | Durable LFG slots and comments. Meeting Stone requests use their existing path. | -Movement-control acknowledgements, skipped time, inactive-mover movement, -cinematic camera advances and cinematic completion are receipt-only. They do -not move the Character or establish acknowledgement counter enforcement. Setting +Apart from the [root receipts](#root-receipts) above, movement-control acknowledgements, skipped +time, inactive-mover movement, cinematic camera advances and cinematic completion are receipt-only. +They do not move the Character. No receipt establishes acknowledgement counter enforcement. Setting the active mover to the current Character is accepted; another mover receives a System Message. Possession and remote movement authority remain unavailable. diff --git a/docs/danger-zones.md b/docs/danger-zones.md index 38c49ae38..0539c6cdd 100644 --- a/docs/danger-zones.md +++ b/docs/danger-zones.md @@ -165,16 +165,12 @@ confirms *"the server sent the right packet"* and nothing more. It shares gtker' **cannot** reproduce a client crash or lock-up. That class of bug needs a real 5875 client and a human watching it. -### Reading `work-item N` in code comments - -Comments written before 2026-07-30 sometimes cite `work-item N` (occasionally just a bare 3-digit -number) — an id from a retired internal task queue, not a GitHub issue or PR number. That queue's -index is maintainer-side and not included in this repository, so these ids don't resolve to anything -you can open here; treat them as an opaque historical label ("this behavior was decided while working -task N") rather than a link. **Do not assume `work-item N` and issue `#N` are the same thing** — the -two id ranges overlap, so for a number under a few hundred it is easy to land on an unrelated GitHub -issue by guessing. A comment's own `#N` references (this repository's actual issues/PRs) are a -separate, resolvable convention and are unaffected by this. +### Reading `work-item N` in older revisions + +Source comments in older revisions sometimes cite `work-item N` or a bare three-digit number. +These refer to a retired internal task queue whose index is maintainer-side. They do not identify +GitHub issues or PRs. The number ranges overlap, so do not turn a work-item number into an issue +link. Explicit `#N` references still identify this repository's issues and PRs. ## 3. Deploy + verify procedure (use exactly this) diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 5bc2a51b2..2d3e240ec 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -447,6 +447,14 @@ correction now addresses source slot 0. The authored seal marker and Power Word: Shield's linked Weakened Soul rule remain because their Module behavior is not an equivalent DBC effect. +The binding summon correction follows the +[pinned ClassicDB snapshot](https://github.com/cmangos/classic-db/blob/cd0c426a3b2ff56dd518bf009025299468e60fdb/Full_DB/ClassicDB_1_12_1_z2815.sql.gz). +Spell 7728 sends event 1131 for creature 5676; spell 8674 sends event 1134 for creature 5677. +Both require spell focus 83, and their event scripts set a 360000 ms despawn delay. +[Their EventAI rows](https://github.com/cmangos/classic-db/blob/cd0c426a3b2ff56dd518bf009025299468e60fdb/ACID/acid_classic.sql#L18699-L18705) +set faction 14 and attack the summoner. The importer maps these source events to `E_SUMMON_HOSTILE` +and preserves the focus requirement. + Every World Import Profile loads the same full Spell catalogue. After this importer change, each World Shard and Instance Pool needs the normal Spell reimport and curated class-spell pass. Rebuild Base Snapshots and review Package Deltas that name affected effect indices before replaying them. diff --git a/docs/instance-lifecycle.md b/docs/instance-lifecycle.md index 6498f3331..4ca72a5e6 100644 --- a/docs/instance-lifecycle.md +++ b/docs/instance-lifecycle.md @@ -40,3 +40,9 @@ Group projection. `expire_instance_removal` checks the rule again before moving All tables are private. The Coordinator subscribes to leases for Transfer admission and removal rows for countdown messages. Character bindings have no subscription. + +Grow an Instance Pool only while it is idle. The Gateway keeps a Character already inside a live +Instance on its holder Shard if that Shard remains in the pool. A Group member who has not entered +still follows the current bucket assignment. Reassigning that bucket during a run can send the +member to a separate copy of the Instance. There is no realm-wide Instance-to-Shard index to join +those routing decisions. diff --git a/docs/package-api.md b/docs/package-api.md index ff2509225..1ad987e9e 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -38,7 +38,7 @@ and Actor operations from version 3. ### Marker macros The sanctioned extension points. Each is a text-scanned marker `module/build.rs` turns into a -registry entry; the macro documentation in `module/src/lib.rs` is authoritative for the exact shape, +registry entry; the macro patterns in `module/src/lib.rs` are authoritative for the exact shape, which must be written literally. | marker | registers | @@ -56,8 +56,8 @@ them a despawned character leaves rows behind, and a character that crosses a sh ### Hook catalogue Handlers are notify-only: they observe the payload and may act through the operations below. There is -no veto and no fold. Payload fields are documented at the struct in `module/src/hooks.rs`, which is -authoritative; this list is the set of event names and their payload types. +no veto and no fold. The structs in `module/src/hooks.rs` define the payload fields and their types. +This list pairs each event name with its payload type. | event | payload | |---|---| diff --git a/docs/realm-loot-routing.md b/docs/realm-loot-routing.md index 1868d9218..7e718ae3c 100644 --- a/docs/realm-loot-routing.md +++ b/docs/realm-loot-routing.md @@ -11,6 +11,15 @@ another database. That local roll is a staging copy. `relay_tick` promotes it to clears the staging copy so only one database votes and resolves it. The initial `ROLL_START` message reaches the nearby participants through their existing local relay. +Realm-core keeps a Loot Roll Promotion Receipt for each source Module identity, corpse and slot. +Its source roll ID is a high-water mark. Replays at or below that mark succeed without recreating +the roll, even after resolution. A newer promotion waits until the current roll in that slot has +resolved. Receipts have no expiry because delayed requests from a retired source can still arrive. + +The source `game_loot_roll` auto-increment sequence orders those generations. Content imports must +preserve that sequence and must not insert explicit roll IDs. Restoring an older copy under the +same Module identity can reuse IDs, so recovery must account for the receipts already on Realm-core. + `run_vote` flushes pending promotions before issuing the vote on Realm-core. `party::run` also flushes them before leave and uninvite requests. Those requests can disband a Group, whose removal transaction must see every existing roll to resolve it immediately. A periodic poll alone leaves diff --git a/docs/runtime-script-host.md b/docs/runtime-script-host.md index 671979f38..034e233b7 100644 --- a/docs/runtime-script-host.md +++ b/docs/runtime-script-host.md @@ -77,3 +77,15 @@ and a write to an allowed library table is invisible to the next Invocation. The Host provides no durable script storage, event bindings, damage, items, spawning, scheduling, or database queries. Lua state ends with its Invocation. + +## Compiler compatibility + +Piccolo 0.3.3 passes a trailing table constructor's element count as an extra call argument. +The Lua printer wraps that argument in a one-parameter function, which returns only the table. +Parentheses do not fix the call's argument count. Applying this in the Lua printer also covers +calls emitted by the transpiler and its library helpers. + +The builder compiles each Runtime Script separately so legacy `script()` declarations cannot +collide. It writes an independent compiler configuration with absolute plugin paths because TSTL +resolves plugins relative to that temporary configuration. The committed fuel workload is compiled +by the same toolchain and executed by the Module tests. diff --git a/docs/schema.md b/docs/schema.md index 88ec4a8af..266bef7df 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -35,7 +35,7 @@ Start at [`architecture.md`](./architecture.md) for how these tables fit into th 1. **A default must be END-appended and explicitly typed.** A bare `#[default(0)]` on a `u64` column encodes as a 4-byte i32 and the publish migration rejects it (`data too short for u64`). Write - `#[default(0u64)]`. `module/src/character.rs` carries several worked examples in its comments. + `#[default(0u64)]`. See the typed field defaults in `module/src/character.rs`. 2. **`#[default(...)]` cannot default a `String`.** The macro type-checks the expression in a const block, and dropping a `String` in const-evaluated Rust is `error[E0493]` on stable. This is a Rust limitation, not a repo convention. Put new string data in a **new table** instead — the same @@ -592,10 +592,10 @@ until the generation is verified again or the vmap data is imported again. **The module declares no `#[client_visibility_filter]` filters.** Recount at any time: ```bash -grep -rn 'client_visibility_filter' module/src --include='*.rs' +rg -n '#\[client_visibility_filter' module/src --glob '*.rs' ``` -Only two comment lines match. This document used to list sixteen owner-scoped filters, all of the +No attributes match. This document used to list sixteen owner-scoped filters, all of the shape `SELECT * FROM WHERE = :sender`. Commit `7fda35a` (2026-08-11) deleted every one of them, in the same change that deleted per-player client connections. An RLS filter only ever applied to a client-identity subscription, and no client-identity subscription is @@ -641,7 +641,7 @@ Two constraints survive the removal and bind any filter added later. timer decides gameplay. Recount and re-list them with: ```bash -grep -rn 'scheduled(' module/src --include='*.rs' # 27 tables plus 4 comment lines, 2026-10-03 +rg -n 'scheduled\(' module/src --glob '*.rs' ``` | Scheduled table | Reducer | Cadence | Where | diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 77589747d..c72949abc 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -9,9 +9,7 @@ homepage.workspace = true publish.workspace = true description = "LyraCore edge/protocol tier: terminates the WoW client protocol, translates to/from SpacetimeDB. Holds NO game state." -# ⚠ 16 characters. `pkill -x` / `pgrep -x` match /proc//comm, which the kernel truncates to -# TASK_COMM_LEN-1 = 15 — so `pkill -x lyracore-gateway` matches NOTHING and `pkill -x -# lyracore-gatewa` (no trailing `y`) is the correct incantation. See docs/danger-zones.md §3. +# Linux truncates the process name to lyracore-gatewa in /proc//comm. [[bin]] name = "lyracore-gateway" path = "src/main.rs" @@ -23,30 +21,15 @@ test = true [dependencies] lyracore-shared.workspace = true -# Protocol/auth/runtime crates the gateway is built around. Pinned to the versions verified -# available; the slice wires these in incrementally (see src/ module docs). wow_srp = "0.7" wow_login_messages = { version = "0.5", features = ["sync"] } wow_world_messages = { version = "0.3", features = ["vanilla", "sync"] } -# Only needed to name `FriendResult` (SMSG_FRIEND_STATUS's result field) — wow_world_messages -# doesn't re-export it, but it's already a transitive dep (pinned to match via Cargo.lock). wow_world_base = "0.3" -# Pinned exactly to match the module's spacetimedb pin and the `spacetime` CLI version this repo is -# verified against (see module/Cargo.toml; `lyracore preflight` check 0 asserts the CLI version). +# Match the Module and CLI SpacetimeDB version. spacetimedb-sdk = "=2.7.1" tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "io-util", "sync", "time"] } anyhow = "1" getrandom = "0.3" -# #451: `getrlimit`/`setrlimit` (raise RLIMIT_NOFILE soft to hard at startup, `src/fd_limit.rs`) and -# the `E*` errno constants the accept-loop policy classifies against (`src/accept.rs`). std exposes -# neither — `io::ErrorKind` flattens EMFILE/ENFILE to `Uncategorized`, which is exactly the -# distinction that policy turns on. -# -# NOT a new crate in the tree: libc 0.2.189 already resolves here transitively through tokio (and -# through spacetimedb-sdk), so this promotes an existing, already-vetted dependency to a direct one: -# zero new nodes in the graph, and no new license to clear (MIT OR Apache-2.0, the same pair the rest -# of the workspace already carries). There is no `deny.toml` in this repository; the maintainers run -# cargo-deny from their own working repository. libc = "0.2" zeroize = "=1.9.0" log = "0.4" @@ -60,15 +43,6 @@ benilla-srp = { git = "https://github.com/samwhosung/benilla.git", rev = "c2fc3c blake3 = "1" serde_json = "1" syn = { version = "2", features = ["full", "visit"] } -# Work-item 220 (binding schema-parity test, gateway/tests/schema_parity.rs): the module crate -# compiles natively (its own test suite proves it — no wasm-only dep), so a dev-only dependency -# lets the test compare module `#[table]` structs against the generated Gateway bindings -# structurally instead of only at live-decode time. `spacetimedb-lib` gives the same -# `SpacetimeType`/`AlgebraicType`/`RawModuleDefV9Builder` machinery the real `spacetime` CLI's -# schema extraction uses; pinned exactly so the workspace lockfile resolves it to the SAME -# `spacetimedb-sats`/`spacetimedb-lib` 2.7.1 that `spacetimedb-sdk` (the gateway's normal, non-dev -# dependency) and `spacetimedb` (the module's dependency) already unify on. Pinned exactly for the -# same reason those two are. spacetimedb-lib = "=2.7.1" [build-dependencies] @@ -78,12 +52,7 @@ serde_json = "1" [features] dhat-heap = ["dep:dhat"] -# The module crate is wasm32-only in production. Its NATIVE build references SpacetimeDB wasm-host -# intrinsics (bytes_sink_write, table_id_from_name, ...) that no native library provides; ELF -# linkers dead-strip the unreferenced paths so `cargo test` links on Linux, but Mach-O ld refuses -# outright. The armor mirror tests that need the real module curves are therefore Linux-only -# (matching #[cfg(target_os = "linux")] gates in src/stdb/armor.rs) — one platform is enough for a -# drift tripwire. +# Mach-O cannot link the native Module host intrinsics; these tests require Linux. [target.'cfg(target_os = "linux")'.dev-dependencies] lyracore-module = { path = "../module" } diff --git a/gateway/build.rs b/gateway/build.rs index a7dab3552..aa06be2da 100644 --- a/gateway/build.rs +++ b/gateway/build.rs @@ -20,8 +20,7 @@ fn prepare() -> Result<(), Box> { if pin.len() != 40 || !pin.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("invalid CLI revision".into()); } - // Call the installed binary directly. The shim may install it with Cargo, which would wait - // for this build's lock. Preparation installs it before any Gateway build starts. + // The shim may invoke Cargo and deadlock on this build's lock; call the installed binary. let cli = root.join(".lyracore/cli").join(pin).join("bin/lyracore"); println!("cargo:rerun-if-changed={}", cli.display()); let output = Command::new(cli) diff --git a/gateway/examples/benilla_inventory.rs b/gateway/examples/benilla_inventory.rs index 515c2f0b5..d82f2373a 100644 --- a/gateway/examples/benilla_inventory.rs +++ b/gateway/examples/benilla_inventory.rs @@ -1,5 +1,3 @@ -//! Inventory source evidence by numeric world opcode and direction. This does not certify behavior. - use anyhow::{bail, Context, Result}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -74,7 +72,6 @@ impl<'ast> Visit<'ast> for SourceScan { if !item.path.is_ident("matches") { return; } - // Benilla lists movement relay opcodes in a matches! pattern. let parse = |input: syn::parse::ParseStream<'_>| { let value: syn::Expr = input.parse()?; input.parse::()?; @@ -303,7 +300,6 @@ fn inventory() -> Result { for path in rust_files(&benilla_root.join("src/world"))? { sends.scan(&path, benilla_repo)?; } - // Movement sends select their opcodes outside WorldWriter::send_movement. let mut movement = SourceScan::default(); movement.scan(&benilla_root.join("src/messages/movement.rs"), benilla_repo)?; movement.scan( diff --git a/gateway/src/accept.rs b/gateway/src/accept.rs index d65e60298..04157631d 100644 --- a/gateway/src/accept.rs +++ b/gateway/src/accept.rs @@ -1,15 +1,10 @@ -//! Accept-loop resource policy. See `docs/accept-policy.md` for the contract and rationale. +//! Before changing listener retries or capacity limits, read docs/accept-policy.md. use std::io; use std::sync::Arc; use std::time::Duration; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; -/// Non-waiting capacity shared by both listeners before they submit blocking session tasks. -/// -/// A permit stays with the task for its whole lifetime. This keeps submitted plus running session -/// tasks at or below the blocking-pool ceiling instead of moving excess accepted sockets into -/// Tokio's unbounded blocking-task queue. #[derive(Debug, Clone)] pub(crate) struct BlockingTaskCapacity { permits: Arc, @@ -23,7 +18,6 @@ impl BlockingTaskCapacity { } } - /// Take a task seat immediately. `None` refuses this connection; it never waits in a queue. pub(crate) fn try_admit(&self) -> Option { self.permits .clone() @@ -38,30 +32,18 @@ impl BlockingTaskCapacity { } } -/// One submitted or running blocking session task. Dropping it returns the task seat, including -/// during unwinding. pub(crate) struct BlockingTaskPermit { _permit: OwnedSemaphorePermit, } -/// What the accept loop should do about an `accept(2)` (or per-socket setup) failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AcceptOutcome { - /// Log it and take the next connection. Costs one connection, never the realm. Retry, - /// The listener itself is unusable — end the task. Retrying would spin forever. Fatal, } -/// The errnos that mean the *listening socket* is broken rather than one connection. -/// -/// `docs/accept-policy.md` explains the allowlist and the transient resource errors. const FATAL_ACCEPT_ERRNOS: &[i32] = &[libc::EBADF, libc::ENOTSOCK, libc::EINVAL, libc::EFAULT]; -/// Decide whether an accept-path error ends the listener task or one connection. -/// -/// Fatal is a short explicit allowlist ([`FATAL_ACCEPT_ERRNOS`]); everything else — known transient -/// errnos, errnos we have never seen, and errors with no raw errno at all — is [`AcceptOutcome::Retry`]. pub fn classify_accept_error(e: &io::Error) -> AcceptOutcome { match e.raw_os_error() { Some(errno) if FATAL_ACCEPT_ERRNOS.contains(&errno) => AcceptOutcome::Fatal, @@ -69,26 +51,15 @@ pub fn classify_accept_error(e: &io::Error) -> AcceptOutcome { } } -/// First failure retries immediately: a lone `ECONNABORTED` is the common case and should not cost -/// the next player any latency. const BACKOFF_BASE_MS: u64 = 10; -/// Ceiling on the sleep. Under sustained `EMFILE` this is what the loop settles at — one attempt and -/// one log line per second, rather than a spun core and a flooded log. const BACKOFF_CAP_MS: u64 = 1_000; -/// How long to wait after the `consecutive`-th back-to-back transient accept failure. -/// -/// `1` is zero (retry at once), then 10ms doubling to a 1s cap. Under a saturated fd table -/// `accept` returns `EMFILE` *immediately*, so without this the loop is a busy-spin that burns a -/// core and writes a log line per iteration — which makes a recoverable shortage look like a hang. +/// EMFILE returns immediately, so repeated accept failures need backoff to avoid busy-spinning. pub fn backoff_delay(consecutive: u32) -> Duration { if consecutive <= 1 { return Duration::ZERO; } - // `checked_shl` is NOT the tool here and the test below caught it being used: it validates only - // the shift AMOUNT, so `10u64.checked_shl(63)` is a cheerful `Some(0)` — every set bit shifted - // off the top — and `0.min(cap)` restores the exact busy-spin this function exists to prevent. - // Clamp the shift, then saturate the multiply, so overshoot lands on the cap instead of zero. + // checked_shl checks the shift count, not lost high bits; clamp before multiplying. let steps = (consecutive - 2).min(u64::BITS - 1); let ms = BACKOFF_BASE_MS .saturating_mul(1u64 << steps) @@ -96,8 +67,6 @@ pub fn backoff_delay(consecutive: u32) -> Duration { Duration::from_millis(ms) } -/// Consecutive-transient-failure counter for one accept loop. Reset by every accepted connection, -/// so a healthy gateway that sees one bad connection an hour never sleeps at all. #[derive(Debug, Default, Clone, Copy)] pub struct AcceptBackoff { consecutive: u32, @@ -108,19 +77,15 @@ impl AcceptBackoff { Self { consecutive: 0 } } - /// A connection came through — the shortage, whatever it was, is over. pub fn record_success(&mut self) { self.consecutive = 0; } - /// Record one transient failure; returns how long to sleep before the next `accept`. pub fn record_failure(&mut self) -> Duration { self.consecutive = self.consecutive.saturating_add(1); backoff_delay(self.consecutive) } - /// How many failures in a row we are into — for the log line, so an operator can tell one bad - /// connection from a gateway that has been out of file descriptors for the last minute. pub fn consecutive(&self) -> u32 { self.consecutive } @@ -134,7 +99,6 @@ mod tests { io::Error::from_raw_os_error(errno) } - /// Resource exhaustion and interrupted connections leave the listener available. #[test] fn the_errnos_that_killed_the_realm_are_transient() { for (errno, name) in [ @@ -156,9 +120,6 @@ mod tests { } } - /// The Linux "already-pending network error" family, which `accept(2)` says to retry like - /// `EAGAIN`. `EOPNOTSUPP` is in here on purpose — see the module docs for why its other, - /// permanent meaning cannot apply to a `TcpListener::bind`. #[test] fn pending_network_errors_and_per_connection_verdicts_are_transient() { for errno in [ @@ -181,8 +142,6 @@ mod tests { } } - /// The fatal set, pinned in full. If this test has to change, someone is changing the - /// availability policy of the whole realm and should have to say so in a diff. #[test] fn only_a_broken_listener_is_fatal() { for errno in [libc::EBADF, libc::ENOTSOCK, libc::EINVAL, libc::EFAULT] { @@ -199,15 +158,11 @@ mod tests { ); } - /// An errno nobody has enumerated must not end the realm. This is the direction the allowlist - /// is shaped for, so pin it against a value that is not in any list above. #[test] fn an_unknown_errno_retries_rather_than_ending_the_realm() { assert_eq!(classify_accept_error(&err(4095)), AcceptOutcome::Retry); } - /// `accept` has never handed us an errno-less error, but if it did, "we cannot prove it is - /// permanent" resolves to retry — the backoff bounds the cost of being wrong. #[test] fn an_error_with_no_raw_errno_retries() { let synthetic = io::Error::other("no errno here"); @@ -225,9 +180,6 @@ mod tests { assert_eq!(backoff_delay(8), Duration::from_millis(640)); } - /// A sustained shortage settles at one attempt per second and stays there — including for - /// shift counts past `u64`'s width, which is where a naive `<<` would panic or wrap to zero and - /// silently restore the busy-spin. #[test] fn the_backoff_saturates_at_one_second_and_never_wraps() { assert_eq!(backoff_delay(9), Duration::from_millis(1_000)); @@ -252,8 +204,6 @@ mod tests { assert_eq!(b.record_failure(), Duration::ZERO); } - /// The counter runs for the life of the process; it must not wrap back into "first failure, - /// retry immediately" after 4 billion consecutive errors. #[test] fn the_counter_saturates_rather_than_wrapping() { let mut b = AcceptBackoff { diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index 0b3f090d0..40cc21f60 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -1,22 +1,3 @@ -//! Addon-bridge codec: the bridge's own private reply, and the raw routing that keeps addon -//! traffic off players who never asked for it. -//! -//! `wow_world_base`'s `Language` enum carries `Addon` (0xFFFFFFFF), so a typed `CMSG_MESSAGECHAT` -//! on the addon language decodes and dispatches exactly like any other chat line. -//! `gateway/src/world/mod.rs` still peeks the raw frame before typed decode, only to route it: a -//! bridge-prefixed frame goes to `handle_addon_message`; a PARTY, RAID, GUILD or OFFICER frame -//! with any other prefix falls through to the ordinary chat path, since `SendAddonMessage` -//! legitimately shares those four channels with other addons; every other chat type drops, since -//! no addon traffic reaches it. The bridge's own reply has no `chat_kind` to ride — it is a -//! private command channel between the server and one client's addon, not a broadcast chat line — -//! so it is hand-built here against the byte layout pinned from gtker's own serializer -//! (smsg_messagechat.rs). -//! -//! Envelope (the bridge's framing protocol, versioned): the chat TEXT is `"STC\t" + "v1||| -//! /|"`. v1 implements the single-part fast path only — a multi-part -//! message logs and drops (payloads that don't fit ~230 bytes are a design smell at this layer). - -// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. #![cfg_attr( not(test), deny( @@ -29,15 +10,11 @@ ) )] -/// The addon language value `SendAddonMessage` stamps (`LANG_ADDON`). pub const LANG_ADDON: u32 = 0xFFFF_FFFF; -/// Our bridge prefix — the addon sends/filters on exactly this. pub const BRIDGE_PREFIX: &str = "STC"; -/// `SMSG_MESSAGECHAT` / `CMSG_MESSAGECHAT` opcodes. pub const SMSG_MESSAGECHAT_OPCODE: u16 = 0x0096; pub const CMSG_MESSAGECHAT_OPCODE: u32 = 0x0095; -/// Client `ChatType` discriminants (u32 on the CMSG wire) this parser understands. const CHAT_TYPE_PARTY: u32 = 1; const CHAT_TYPE_RAID: u32 = 2; const CHAT_TYPE_GUILD: u32 = 3; @@ -45,11 +22,6 @@ const CHAT_TYPE_OFFICER: u32 = 4; const CHAT_TYPE_WHISPER: u32 = 6; const CHAT_TYPE_CHANNEL: u32 = 14; -/// Parse a raw `CMSG_MESSAGECHAT` body IF it is an addon-language message; `None` for every -/// normal-language frame (the caller re-parses those through gtker as before). Returns the chat -/// TYPE and the chat TEXT (`"\t"`). Layout (gtker cmsg_messagechat.rs): -/// `chat_type: u32 LE`, `language: u32 LE`, `[Whisper: target CString | Channel: channel CString]`, -/// `message CString`. pub fn parse_addon_client_chat(body: &[u8]) -> Option<(u32, String)> { if body.len() < 9 { return None; @@ -61,7 +33,6 @@ pub fn parse_addon_client_chat(body: &[u8]) -> Option<(u32, String)> { } let mut rest = &body[8..]; if chat_type == CHAT_TYPE_WHISPER || chat_type == CHAT_TYPE_CHANNEL { - // Skip the target/channel CString — the addon channel argument is cosmetic; we own the pipe. let nul = rest.iter().position(|b| *b == 0)?; rest = &rest[nul + 1..]; } @@ -72,9 +43,6 @@ pub fn parse_addon_client_chat(body: &[u8]) -> Option<(u32, String)> { )) } -/// Whether `SendAddonMessage` traffic on `chat_type` must still reach real players through the -/// ordinary chat path instead of dropping. The client's own Lua API distributes AddonMessage over -/// exactly these four channels; RAID_LEADER, RAID_WARNING, WHISPER and CHANNEL never carry it. pub const fn typed_addon_chat_type(chat_type: u32) -> bool { matches!( chat_type, @@ -82,17 +50,12 @@ pub const fn typed_addon_chat_type(chat_type: u32) -> bool { ) } -/// Whether `text` (the parsed `"\t"`) carries our own bridge prefix. A typed -/// chat type can carry a FOREIGN addon's traffic sharing the same wire; only our own prefix takes -/// the bridge. pub fn is_bridge_prefixed(text: &str) -> bool { text.split_once('\t') .is_some_and(|(prefix, _)| prefix == BRIDGE_PREFIX) } -/// Parse the v1 envelope for [`BRIDGE_PREFIX`]. Build 5875 requires escaped `||` separators in -/// `SendAddonMessage` and retains them on the wire. Unescaped envelopes remain valid for existing -/// Headless Clients. Multipart and foreign-prefix messages are declined. +/// Build 5875 preserves escaped `||` separators sent by `SendAddonMessage`. pub fn parse_bridge_envelope(text: &str) -> Option<(String, String)> { let (prefix, envelope) = text.split_once('\t')?; if prefix != BRIDGE_PREFIX { @@ -119,15 +82,10 @@ pub fn parse_bridge_envelope(text: &str) -> Option<(String, String)> { Some((cmd.to_string(), payload)) } -/// Build the v1 single-part envelope text for a server→client message. pub fn build_bridge_envelope(cmd: &str, payload: &str) -> String { format!("{BRIDGE_PREFIX}\tv1|{cmd}|0|1/1|{payload}") } -/// Raw-build the addon-language `SMSG_MESSAGECHAT` (Whisper shape — the client surfaces it to -/// addons as `CHAT_MSG_ADDON` and does NOT print addon-language lines in the chat frame). -/// Layout (gtker smsg_messagechat.rs write_into_vec): `chat_type u8 (6)`, `language u32 LE`, -/// `sender2 u64 LE`, `msg_len u32 LE (len+1)`, msg bytes, NUL, `tag u8 (0)`. pub fn build_addon_smsg_raw(sender_guid: u64, text: &str) -> (u16, Vec) { let bytes = text.as_bytes(); let mut body = Vec::with_capacity(1 + 4 + 8 + 4 + bytes.len() + 1 + 1); @@ -145,7 +103,6 @@ pub fn build_addon_smsg_raw(sender_guid: u64, text: &str) -> (u16, Vec) { mod tests { use super::*; - /// A hand-constructed `SendAddonMessage("STC", "v1|ping|0|1/1|hi", "WHISPER", "Self")` body. fn addon_whisper_body(text: &str) -> Vec { let mut b = Vec::new(); b.extend_from_slice(&CHAT_TYPE_WHISPER.to_le_bytes()); @@ -163,7 +120,6 @@ mod tests { parse_addon_client_chat(&addon_whisper_body(text)), Some((CHAT_TYPE_WHISPER, text.to_string())) ); - // Same frame with Universal language → not ours; the typed path handles it. let mut normal = addon_whisper_body(text); normal[4..8].copy_from_slice(&0u32.to_le_bytes()); assert_eq!(parse_addon_client_chat(&normal), None); @@ -171,10 +127,9 @@ mod tests { #[test] fn say_shape_addon_message_parses_without_target_skip() { - // Party/say shapes carry no leading CString — the message is first. let text = "STC\tv1|ping|0|1/1|x"; let mut b = Vec::new(); - b.extend_from_slice(&1u32.to_le_bytes()); // Party + b.extend_from_slice(&1u32.to_le_bytes()); b.extend_from_slice(&LANG_ADDON.to_le_bytes()); b.extend_from_slice(text.as_bytes()); b.push(0); @@ -197,9 +152,6 @@ mod tests { ); } - /// The client's `SendAddonMessage` API distributes over PARTY, RAID, GUILD and - /// OFFICER only. RAID_LEADER and RAID_WARNING chat lines cannot carry addon traffic at all, - /// and WHISPER/CHANNEL addon frames are this bridge's own private channels, not shared wire. #[test] fn typed_addon_chat_types_are_exactly_party_raid_guild_and_officer() { for chat_type in [ @@ -239,7 +191,6 @@ mod tests { ); assert!(typed_addon_chat_type(chat_type), "RAID carries addon mail"); - // The same body, framed the way `gateway/src/world/mod.rs` frames it for the typed reader. let mut framed = Vec::new(); framed.extend_from_slice(&((body.len() + 4) as u16).to_be_bytes()); framed.extend_from_slice(&CMSG_MESSAGECHAT_OPCODE.to_le_bytes()); @@ -256,12 +207,10 @@ mod tests { } } - /// a SAY addon frame is not a typed chat type, so the caller keeps dropping it, - /// SAY has no chat-kind route to a real player, addon-language or not. #[test] fn a_say_addon_frame_is_still_dropped() { let mut body = Vec::new(); - body.extend_from_slice(&0u32.to_le_bytes()); // Say + body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&LANG_ADDON.to_le_bytes()); body.extend_from_slice(b"BigWigs\thello"); body.push(0); @@ -296,7 +245,6 @@ mod tests { parse_bridge_envelope(&text), Some(("event.progress".to_string(), "e1|55|100".to_string())) ); - // Payloads keep their own pipes intact (splitn(5)). let t2 = build_bridge_envelope("c", "a|b|c"); assert_eq!(parse_bridge_envelope(&t2).unwrap().1, "a|b|c"); assert_eq!(parse_bridge_envelope("OTHER\tv1|x|0|1/1|y"), None); @@ -308,14 +256,14 @@ mod tests { fn smsg_raw_layout_matches_the_pinned_gtker_shape() { let (op, body) = build_addon_smsg_raw(9, "STC\tv1|pong|0|1/1|hi"); assert_eq!(op, SMSG_MESSAGECHAT_OPCODE); - assert_eq!(body[0], 6); // Whisper + assert_eq!(body[0], 6); assert_eq!(&body[1..5], &LANG_ADDON.to_le_bytes()); assert_eq!(&body[5..13], &9u64.to_le_bytes()); let text = b"STC\tv1|pong|0|1/1|hi"; assert_eq!(&body[13..17], &((text.len() + 1) as u32).to_le_bytes()); assert_eq!(&body[17..17 + text.len()], text); assert_eq!(body[17 + text.len()], 0); - assert_eq!(body[18 + text.len()], 0); // tag None + assert_eq!(body[18 + text.len()], 0); assert_eq!(body.len(), 19 + text.len()); } } diff --git a/gateway/src/codec/addon/subscriptions.rs b/gateway/src/codec/addon/subscriptions.rs index 328abd226..1db51ad24 100644 --- a/gateway/src/codec/addon/subscriptions.rs +++ b/gateway/src/codec/addon/subscriptions.rs @@ -1,19 +1,15 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; use crate::stdb::world_view::*; use crate::world::Outbound; -/// Build one addon-language whisper using the addressed viewer as its sender. pub(crate) fn addon_message_outbound(self_guid: u64, row: &AddonMessage) -> Vec { let text = codec::addon::build_bridge_envelope(&row.cmd, &row.payload); let (opcode, body) = codec::addon::build_addon_smsg_raw(self_guid, &text); vec![Outbound::Raw { opcode, body }] } -/// Addon messages are identity-addressed, with the addressed viewer as the wire sender. pub(crate) fn addon_message_appeared(view: &WorldView, shard: ShardId, row: &AddonMessage) { let Some(viewer) = view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) diff --git a/gateway/src/codec/auction.rs b/gateway/src/codec/auction.rs index ef5630eea..fa5bd3dc4 100644 --- a/gateway/src/codec/auction.rs +++ b/gateway/src/codec/auction.rs @@ -91,9 +91,7 @@ pub fn build_auction_bidder_list_result( } } -/// Outbid or Won, to the bidder (`cm:AuctionHouseHandler.cpp:93-107,157-158`, -/// `cm:AuctionHouseMgr.cpp:146-147`). `won` is the displaced bid on Outbid, 0 on Won: the client -/// reads a nonzero value as "you were outbid" and zero as "you won". +/// The client reads nonzero `won` as outbid and zero as won. pub fn build_auction_bidder_notification( auction_house: AuctionHouse, auction_id: u32, @@ -114,9 +112,6 @@ pub fn build_auction_bidder_notification( } } -/// Sold, Expired or New bid, to the owner (`cm:AuctionHouseMgr.cpp:195-199,231-232,802-806`, -/// `cm:AuctionHouseHandler.cpp:110-128`). `bidder` is 0 on Sold and Expired, and the new bidder on -/// New bid, which the client answers only by refreshing its list. pub fn build_auction_owner_notification( auction_id: u32, bid: u32, @@ -135,9 +130,7 @@ pub fn build_auction_owner_notification( } } -/// Removed, to the bidder a Cancellation displaced (`cm:AuctionHouseHandler.cpp:131-139`). The -/// client prints `ERR_AUCTION_REMOVED_S` with the item's name. The wire's first field is the -/// auction id, which `wow_world_messages` names `item`. +/// The binding's `item` field carries the auction id. pub fn build_auction_removed_notification( auction_id: u32, item_entry: u32, @@ -154,8 +147,6 @@ pub fn build_auction_removed_notification( mod tests { use super::*; - /// `cm:AuctionHouseHandler.cpp:131-139`: auction id, item entry, random property id, 12 bytes, - /// written out by hand. #[test] fn the_removed_notification_encodes_to_the_exact_cmangos_wire_layout() { use wow_world_messages::Message; @@ -277,8 +268,6 @@ mod tests { assert_eq!(packet.bidder.guid(), 9); } - /// `cm:AuctionHouseHandler.cpp:93-107`: house, auction id, bidder guid (full 8 bytes, not - /// packed), won, out_bid, item entry, random property id — 32 bytes, written out by hand. #[test] fn the_bidder_notification_encodes_to_the_exact_cmangos_wire_layout() { use wow_world_messages::Message; @@ -288,7 +277,7 @@ mod tests { packet.write_into_vec(&mut bytes).unwrap(); let mut expected = Vec::new(); - expected.extend_from_slice(&1u32.to_le_bytes()); // AuctionHouse::Stormwind == 0x1 + expected.extend_from_slice(&1u32.to_le_bytes()); expected.extend_from_slice(&41u32.to_le_bytes()); expected.extend_from_slice(&9u64.to_le_bytes()); expected.extend_from_slice(&201u32.to_le_bytes()); @@ -299,8 +288,6 @@ mod tests { assert_eq!(bytes, expected); } - /// `cm:AuctionHouseHandler.cpp:110-128`: auction id, bid, auction_out_bid, bidder guid (full 8 - /// bytes), item entry, random property id — 28 bytes, written out by hand. #[test] fn the_owner_notification_encodes_to_the_exact_cmangos_wire_layout() { use wow_world_messages::Message; diff --git a/gateway/src/codec/auction/subscriptions.rs b/gateway/src/codec/auction/subscriptions.rs index f15385a91..4fafe8c09 100644 --- a/gateway/src/codec/auction/subscriptions.rs +++ b/gateway/src/codec/auction/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_view::*; @@ -7,12 +5,6 @@ use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::AuctionHouse; -/// Auction Notice: the packet body both legs run. Audience resolved by the caller. Outbid and Won -/// go to the bidder on `SMSG_AUCTION_BIDDER_NOTIFICATION`; Sold, Expired and New bid go to the -/// owner on `SMSG_AUCTION_OWNER_NOTIFICATION`; Removed goes to the bidder a Cancellation displaced -/// on `SMSG_AUCTION_REMOVED_NOTIFICATION` (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`). -/// `house` outside the imported 1-7 range and any other `kind` both drop the notice and log — -/// the accompanying Auction Mail still reaches the recipient's inbox either way. pub(crate) fn auction_notice_outbound(row: &AuctionNotice) -> Vec { use lyracore_shared::auction::auction_notice::{EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON}; @@ -66,9 +58,6 @@ pub(crate) fn auction_notice_outbound(row: &AuctionNotice) -> Vec { } } -/// An Auction Notice landed → SMSG_AUCTION_BIDDER_NOTIFICATION or SMSG_AUCTION_OWNER_NOTIFICATION -/// to the row's RECIPIENT and nobody else (same shape as [`group_event_appeared`]). An offline -/// recipient gets the Auction Mail the same transaction wrote and no packet — the vanilla rule. pub(crate) fn auction_notice_appeared(view: &WorldView, row: &AuctionNotice) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; diff --git a/gateway/src/codec/auction/subscriptions/packet_tests.rs b/gateway/src/codec/auction/subscriptions/packet_tests.rs index da6d068f8..d3f808967 100644 --- a/gateway/src/codec/auction/subscriptions/packet_tests.rs +++ b/gateway/src/codec/auction/subscriptions/packet_tests.rs @@ -3,11 +3,6 @@ use crate::stdb::bindings::AuctionNotice; use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Auction Notice kind decoding (`cm:AuctionHouseHandler.cpp`/`AuctionHouseMgr.cpp`): Outbid -/// and Won go out on `SMSG_AUCTION_BIDDER_NOTIFICATION`, Sold, Expired and New bid go out on -/// `SMSG_AUCTION_OWNER_NOTIFICATION`, Removed goes out on `SMSG_AUCTION_REMOVED_NOTIFICATION`, -/// and an unrecognized kind or an unimported house both drop the packet. The Auction Mail the -/// same transaction wrote still reaches the inbox. #[test] fn auction_notice_kinds_decode_to_their_own_packet_and_an_unknown_one_drops() { use lyracore_shared::auction::auction_notice::{EXPIRED, NEW_BID, OUTBID, REMOVED, SOLD, WON}; diff --git a/gateway/src/codec/auth_session.rs b/gateway/src/codec/auth_session.rs index 8864ab9e4..260b9942f 100644 --- a/gateway/src/codec/auth_session.rs +++ b/gateway/src/codec/auth_session.rs @@ -1,13 +1,5 @@ -//! The plaintext `CMSG_AUTH_SESSION` frame, decoded by hand. -//! -//! `wow_world_messages` sizes a buffer from the client's `addon_info_decompressed_size` and unwraps -//! the zlib decode, so an unauthenticated peer could abort the process or unwind the session with -//! one packet. The gateway never reads the addon list, so this decoder stops before the zlib blob. -//! Layout (gtker cmsg_auth_session.wowm): `u16 BE size` (opcode + body), `u32 LE opcode`, -//! `build: u32`, `server_id: u32`, `username: CString`, `client_seed: u32`, `client_proof: u8[20]`, -//! then the compressed addon list. - -// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. +//! The typed decoder trusts the addon size and unwraps zlib; skip the unused blob here. + #![cfg_attr( not(test), deny( @@ -22,10 +14,8 @@ use std::io::{self, Read}; -/// The opcode of the one plaintext client frame in the world handshake. pub const CMSG_AUTH_SESSION_OPCODE: u32 = 0x01ED; -/// What the world handshake proves against: the fields before the addon blob. #[derive(Debug, PartialEq, Eq)] pub struct AuthSessionClaim { pub username: String, @@ -33,8 +23,6 @@ pub struct AuthSessionClaim { pub client_proof: [u8; 20], } -/// Read one plaintext frame and decode it as `CMSG_AUTH_SESSION`. A wrong opcode or a short body -/// is `InvalidData`; a closed or silent socket surfaces as the read's own error kind. pub fn read_auth_session(r: &mut R) -> io::Result { let mut header = [0u8; 6]; r.read_exact(&mut header)?; @@ -45,14 +33,13 @@ pub fn read_auth_session(r: &mut R) -> io::Result { "expected CMSG_AUTH_SESSION, got opcode {opcode:#06x}" ))); } - // `size` counts the u32 opcode. The u16 header bounds the body; no client field sizes it. + // `size` includes the u32 opcode; the u16 header bounds the body. let mut body = vec![0u8; size.saturating_sub(4)]; r.read_exact(&mut body)?; parse_auth_session_body(&body) } fn parse_auth_session_body(body: &[u8]) -> io::Result { - // build + server_id: the gateway reads neither. let rest = body.get(8..).ok_or_else(short)?; let nul = rest.iter().position(|b| *b == 0).ok_or_else(short)?; let username = String::from_utf8(rest[..nul].to_vec()) @@ -117,11 +104,9 @@ mod tests { ); } - /// The whole point of the hand decoder: the addon blob is never sized or inflated. #[test] fn an_absurd_addon_size_with_garbage_zlib_is_ignored() { let mut bytes = frame(&tester()); - // Replace the decompressed-size field and everything after it. let fixed = 6 + 4 + 4 + "TESTER".len() + 1 + 4 + 20; bytes.truncate(fixed); bytes.extend_from_slice(&u32::MAX.to_le_bytes()); @@ -136,7 +121,7 @@ mod tests { #[test] fn a_frame_with_another_opcode_is_invalid_data() { let mut bytes = frame(&tester()); - bytes[2..6].copy_from_slice(&0x0037u32.to_le_bytes()); // CMSG_CHAR_ENUM + bytes[2..6].copy_from_slice(&0x0037u32.to_le_bytes()); let err = read_auth_session(&mut bytes.as_slice()).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert!(err.to_string().contains("0x0037"), "{err}"); diff --git a/gateway/src/codec/breath.rs b/gateway/src/codec/breath.rs index 645989e8c..c8e02e039 100644 --- a/gateway/src/codec/breath.rs +++ b/gateway/src/codec/breath.rs @@ -1,16 +1,11 @@ -//! Breath mirror-timer and drowning combat-log packets. - use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::{ EnvironmentalDamageType, TimerType, SMSG_ENVIRONMENTAL_DAMAGE_LOG, SMSG_START_MIRROR_TIMER, SMSG_STOP_MIRROR_TIMER, }; -/// The only timer this relay currently mirrors: underwater breath. const BREATH_TIMER: TimerType = TimerType::Breath; -/// Client-side countdown start. The client owns all intervening countdown frames, which is why -/// this is sent only on the submerge edge. pub fn build_breath_timer_start(time_remaining_ms: u32, duration_ms: u32) -> ServerOpcodeMessage { ServerOpcodeMessage::SMSG_START_MIRROR_TIMER(Box::new(SMSG_START_MIRROR_TIMER { timer: BREATH_TIMER, @@ -23,14 +18,12 @@ pub fn build_breath_timer_start(time_remaining_ms: u32, duration_ms: u32) -> Ser })) } -/// Client-side countdown stop on the surface edge. pub fn build_breath_timer_stop() -> ServerOpcodeMessage { ServerOpcodeMessage::SMSG_STOP_MIRROR_TIMER(SMSG_STOP_MIRROR_TIMER { timer: BREATH_TIMER, }) } -/// Combat-log line for exactly the health amount the server's drowning resolver removed. pub fn build_drowning_damage_log(character_guid: u64, damage: u32) -> ServerOpcodeMessage { ServerOpcodeMessage::SMSG_ENVIRONMENTAL_DAMAGE_LOG(Box::new(SMSG_ENVIRONMENTAL_DAMAGE_LOG { guid: wow_world_messages::Guid::new(character_guid), diff --git a/gateway/src/codec/breath/subscriptions.rs b/gateway/src/codec/breath/subscriptions.rs index 94fdbf419..bcf154793 100644 --- a/gateway/src/codec/breath/subscriptions.rs +++ b/gateway/src/codec/breath/subscriptions.rs @@ -1,13 +1,9 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; use crate::stdb::world_view::*; use crate::world::Outbound; -/// Shared-dispatch recipient leg for a breath relay row. The owner-session lookup in -/// `world_view::breath_relay_appeared` has already established the self-only audience. pub(crate) fn relay_breath_event( character_guid: u64, kind: u8, @@ -27,7 +23,6 @@ pub(crate) fn relay_breath_event( vec![Outbound::One(msg)] } -/// Send breath events to their owner on the callback's Shard. pub(crate) fn breath_relay_appeared(view: &WorldView, shard: ShardId, row: &BreathRelayEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { return; diff --git a/gateway/src/codec/channel.rs b/gateway/src/codec/channel.rs index 0baa1c75e..6951f863c 100644 --- a/gateway/src/codec/channel.rs +++ b/gateway/src/codec/channel.rs @@ -1,8 +1,4 @@ -//! Chat Channel packets: every Channel Notice (`SMSG_CHANNEL_NOTIFY`) and the member list -//! (`SMSG_CHANNEL_LIST`). -//! -//! Channel Notices are hand-encoded, because gtker 0.3's `SMSG_CHANNEL_NOTIFY` carries only the -//! notice and the channel name, never the payload that follows (cm:Channel.cpp:748-946). +//! gtker 0.3 omits Channel Notice payloads after the name, so encode them here. use lyracore_shared::channel::notice; use wow_world_messages::vanilla::{ @@ -10,11 +6,8 @@ use wow_world_messages::vanilla::{ }; use wow_world_messages::Guid; -/// `SMSG_CHANNEL_NOTIFY` (vanilla 5875). pub const SMSG_CHANNEL_NOTIFY_OPCODE: u16 = 0x0099; -/// One Channel Notice: a Module row, or a Refusal the Gateway answers. Which fields a notice reads -/// is [`notify`]'s business; the others stay zero. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ChannelNoticeView { pub notice: u8, @@ -27,17 +20,6 @@ pub struct ChannelNoticeView { pub text: String, } -/// Encode one Channel Notice. The body is the notice byte and the channel name as a CString, then -/// the notice's payload (all little-endian, guids as the full u64): -/// - one guid: `subject_guid` for JOINED, LEFT, OWNER_CHANGED, PLAYER_ALREADY_MEMBER and INVITE; -/// `actor_guid` for PASSWORD_CHANGED and the announcement and moderation toggles; -/// - YOU_JOINED: the channel flags u32, then a channel index u32 that is always 0; -/// - MODE_CHANGE: `subject_guid`, old flags u8, new flags u8; -/// - PLAYER_KICKED, PLAYER_BANNED, PLAYER_UNBANNED: `subject_guid` (the target) then -/// `actor_guid` (the source); -/// - a CString `text` for PLAYER_NOT_FOUND, CHANNEL_OWNER, PLAYER_NOT_BANNED, PLAYER_INVITED and -/// PLAYER_INVITE_BANNED; -/// - nothing for the rest. pub fn notify(view: &ChannelNoticeView) -> (u16, Vec) { let mut body = Vec::with_capacity(2 + view.channel_name.len() + 18); body.push(view.notice); @@ -76,9 +58,7 @@ pub fn notify(view: &ChannelNoticeView) -> (u16, Vec) { (SMSG_CHANNEL_NOTIFY_OPCODE, body) } -/// `SMSG_CHANNEL_LIST` (cm:Channel.cpp:479-521): the stored name, the wire channel flags, and each -/// member's guid with its raw member flags. gtker's `ChannelMemberFlags` constants use other bit -/// names than the cores, so the flags pass through as raw bits. +/// gtker's member-flag names differ from the client flags; preserve the raw bits. pub fn build_channel_list( channel_name: String, channel_flags: u8, @@ -157,7 +137,6 @@ mod tests { } } - /// cm:Channel.cpp:767-772: the flags, then a channel index of 0. #[test] fn you_joined_carries_the_channel_flags_and_a_zero_index() { assert_eq!(body(0x02), [0x02, b'T', b'x', 0, 0x3C, 0, 0, 0, 0, 0, 0, 0]); @@ -199,7 +178,6 @@ mod tests { } } - /// cm:Channel.cpp:490-515: name, flags u8, count u32, then guid u64 and flags u8 per member. #[test] fn the_channel_list_writes_raw_member_flags() { let message = ServerOpcodeMessage::SMSG_CHANNEL_LIST(Box::new(build_channel_list( diff --git a/gateway/src/codec/channel/subscriptions.rs b/gateway/src/codec/channel/subscriptions.rs index fea423cf5..5fceca43d 100644 --- a/gateway/src/codec/channel/subscriptions.rs +++ b/gateway/src/codec/channel/subscriptions.rs @@ -1,13 +1,9 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_view::*; use crate::world::Outbound; use std::sync::Arc; -/// A Channel Notice: one raw `SMSG_CHANNEL_NOTIFY` for one recipient. The Relay already chose the -/// audience. pub(crate) fn channel_notice_outbound(row: &ChatChannelNoticeEvent) -> Vec { let (opcode, body) = codec::channel::notify(&codec::channel::ChannelNoticeView { notice: row.notice, @@ -22,8 +18,6 @@ pub(crate) fn channel_notice_outbound(row: &ChatChannelNoticeEvent) -> Vec) -> Vec<(u16, Vec)> { let mut packets = Vec::new(); while let Ok(outbound) = rx.try_recv() { @@ -24,7 +23,6 @@ fn received_raw(rx: &Receiver) -> Vec<(u16, Vec)> { packets } -/// JOINED naming `SPEAKER` in "Tx": notice 0x00, the name, then the guid (cm:Channel.cpp:760-764). #[test] fn a_channel_notice_reaches_each_recipient_on_any_shard_and_nobody_else() { let view = WorldView::new(true); diff --git a/gateway/src/codec/char.rs b/gateway/src/codec/char.rs index b71fdd6bc..2de24da9d 100644 --- a/gateway/src/codec/char.rs +++ b/gateway/src/codec/char.rs @@ -1,11 +1,5 @@ -//! Character-select wire mapping: `CharacterView` + the char-enum / char-create / name-query -//! builders and the logout ack. Pure code-motion out of `mod.rs` (behind the `pub use` facade). - use super::*; -/// A character row as the gateway reads it from the coordinator cache (`game_character`), -/// flattened to the plain ints/strings the codec needs to build `SMSG_CHAR_ENUM`. Decoupled -/// from the module's table type; the typed enum conversions happen in [`build_char_enum`]. #[derive(Clone, Debug, Default)] pub struct CharacterView { pub guid: u64, @@ -25,23 +19,14 @@ pub struct CharacterView { pub y: f32, pub z: f32, pub first_login: bool, - /// Equipment display models for the character-select screen. Indexed by equipment slot - /// (0..=18, matching `game_item_instance.slot`). Default is all-zero (naked/grey). - /// Populated by `Coordinator::characters` from `game_item_instance` + `game_item_template`. pub equipment: [CharacterGear; 19], - /// Accrued played-time total in whole seconds, NOT counting the current live session (`/played`). - /// Folded with `session_start_micros` at the `CMSG_PLAYED_TIME` reply site so an - /// online player's total keeps ticking without a periodic write. + /// Whole seconds excluding the current World Session. pub played_total_secs: u32, - /// Unix-epoch micros the current live session began (0 = offline / no live session). + /// Unix-epoch microseconds; zero means offline. pub session_start_micros: u64, - /// The Character's Guild, read from Realm-core membership. 0 = no Guild. pub guild_id: u32, } -/// The five appearance bytes a player picks at character creation, bundled so `create_character` -/// (dispatch → trait → coordinator) passes one value instead of five positional `u8`s that are -/// trivial to transpose. (race/class/gender are validated separately via typed `try_from`.) #[derive(Clone, Copy, Debug, Default)] pub struct Appearance { pub skin: u8, @@ -51,18 +36,14 @@ pub struct Appearance { pub facial_hair: u8, } -/// Outcome of a `CMSG_CHAR_CREATE` from the server's perspective (mapped to a `WorldResult`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CharCreateOutcome { Success, NameInUse, - /// Account already holds the per-realm character cap (10) — the 11th is refused. ServerLimit, Failed, } -/// Build the `SMSG_CHAR_CREATE` reply. `CharCreateSuccess` makes the client re-send -/// `CMSG_CHAR_ENUM` (so the new character appears); the others raise a popup. pub fn build_char_create_response(outcome: CharCreateOutcome) -> SMSG_CHAR_CREATE { SMSG_CHAR_CREATE { result: match outcome { @@ -74,15 +55,12 @@ pub fn build_char_create_response(outcome: CharCreateOutcome) -> SMSG_CHAR_CREAT } } -/// Outcome of a `CMSG_CHAR_DELETE` from the server's perspective (mapped to a `WorldResult`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CharDeleteOutcome { Success, Failed, } -/// Build the `SMSG_CHAR_DELETE` reply. Per the wire doc this updates the character-select screen -/// directly — the client does NOT need to re-send `CMSG_CHAR_ENUM` (unlike char-create). pub fn build_char_delete_response(outcome: CharDeleteOutcome) -> SMSG_CHAR_DELETE { SMSG_CHAR_DELETE { result: match outcome { @@ -92,15 +70,6 @@ pub fn build_char_delete_response(outcome: CharDeleteOutcome) -> SMSG_CHAR_DELET } } -/// Build the `SMSG_CHAR_ENUM` reply for the character-select screen (gateway -/// translation §4). Each [`CharacterView`] becomes a `wow_world_messages` `Character` block; -/// the count and the `first_bag_*` trailers are written by the codec, not set here. -/// -/// The list is ordered by ascending guid, as CMaNGOS enumerates Characters: the Store's -/// subscription-cache and cross-Shard probe order is not stable between logins. Equipment comes -/// from the view. `race`/`class`/`gender`/`map` are load-bearing for what the client renders, so -/// an out-of-range raw value is a hard error; `area`/`zone` is cosmetic on this screen, so an -/// unknown zone degrades to `Area::None` rather than failing the whole enumeration. pub fn build_char_enum(chars: &[CharacterView]) -> Result { let mut ordered: Vec<&CharacterView> = chars.iter().collect(); ordered.sort_unstable_by_key(|c| c.guid); @@ -138,10 +107,6 @@ pub fn build_char_enum(chars: &[CharacterView]) -> Result { Ok(SMSG_CHAR_ENUM { characters }) } -/// Build the `SMSG_NAME_QUERY_RESPONSE` the client needs to render a (peer or self) character's -/// name + race/class/gender, so it shows the real name instead of "Unknown". `realm_name` is empty -/// (same-realm). race/class/gender are load-bearing for the name plate, so an out-of-range raw -/// value is a hard error. pub fn build_name_query_response(c: &CharacterView) -> Result { Ok(SMSG_NAME_QUERY_RESPONSE { guid: Guid::new(c.guid), @@ -153,20 +118,13 @@ pub fn build_name_query_response(c: &CharacterView) -> Result SMSG_INSPECT { SMSG_INSPECT { guid: Guid::new(target_guid), } } -/// The reply to `CMSG_LOGOUT_REQUEST` (the client's Logout/Exit button): acknowledge an -/// **instant** successful logout, then immediately complete it. Without this the client hangs on -/// the logout/exit screen waiting for `SMSG_LOGOUT_COMPLETE`. Only called when the player is NOT -/// in combat. pub fn logout_sequence() -> Vec { vec![ ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(SMSG_LOGOUT_RESPONSE { @@ -177,9 +135,6 @@ pub fn logout_sequence() -> Vec { ] } -/// Denial reply to `CMSG_LOGOUT_REQUEST` when the player is in combat. Vanilla uses -/// `FAILURE_IN_COMBAT` (result=1); the client shows an error message and does NOT begin a -/// logout countdown. `speed` is irrelevant for non-Success results but must be set. pub fn logout_denied_in_combat() -> SMSG_LOGOUT_RESPONSE { SMSG_LOGOUT_RESPONSE { result: LogoutResult::FailureInCombat, @@ -262,21 +217,17 @@ mod tests { #[test] fn played_time_folds_the_live_session_span_into_the_durable_total() { - // Offline (no live session): total is exactly the durable value, no fold. let offline = build_played_time(3600, 0, 999_999); assert_eq!(offline.total_played_time, 3600); assert_eq!(offline.level_played_time, 3600); - // Online: total = durable + elapsed seconds since session_start_micros. - let now = 10_000_000_000u64; // an arbitrary "current" wall-clock reading, in micros - let session_start = now - 5_000_000; // session began 5 real seconds ago + let now = 10_000_000_000u64; + let session_start = now - 5_000_000; let online = build_played_time(3600, session_start, now); assert_eq!(online.total_played_time, 3605); } #[test] fn played_time_never_underflows_a_clock_that_moved_backward() { - // A `now_micros` at or before `session_start_micros` (a clock adjustment / bad caller) must - // saturate the elapsed span at 0 rather than wrap a u64 subtraction into a huge total. let msg = build_played_time(100, 5_000_000, 1_000_000); assert_eq!( msg.total_played_time, 100, diff --git a/gateway/src/codec/combat.rs b/gateway/src/codec/combat.rs index 3cbf27015..9aefc3219 100644 --- a/gateway/src/codec/combat.rs +++ b/gateway/src/codec/combat.rs @@ -1,15 +1,6 @@ -//! Combat wire mapping: the spell-cast visual (`SMSG_SPELL_GO`), the attack start/stop stance -//! messages, and the per-swing `SMSG_ATTACKERSTATEUPDATE` (with its VictimState constants). -//! Pure code-motion out of `mod.rs`. - use super::*; -/// `SMSG_SPELL_COOLDOWN` — start the client's cooldown swipe on `spell_id` for `cooldown_ms`. Sent ONLY for -/// spells that actually HAVE a cooldown (Mortal Strike 6s, Judgement 10s, …). mangos does NOT send a cooldown -/// packet per cast: the 1.5s GCD is client-managed from Spell.dbc (StartRecoveryTime), and the SMSG_SPELL_GO -/// is what releases the client's pending-cast state. A prior theory had the gateway send this (cooldown 0) -/// after EVERY cast to "close the pending lock" — that instead STUCK the action button ("Another action is in -/// progress" on every recast). The relay now gates it on cooldown_ms > 0. `guid` is a plain 8-byte guid. [combat] +/// The client manages the GCD from Spell.dbc; send this only for spell cooldowns. pub fn build_spell_cooldown( caster_guid: u64, spell_id: u32, @@ -24,17 +15,7 @@ pub fn build_spell_cooldown( } } -// The argument list IS the packet's field list (SMSG_SPELLNONMELEEDAMAGELOG); a struct here would only move the same fields one level down. #[allow(clippy::too_many_arguments)] -/// `SMSG_SPELLNONMELEEDAMAGELOG` — the floating spell damage number over the target (vanilla "Shadow Bolt -/// hits X for N Shadow damage"). Sent on the cast-GO when the cast dealt damage. `school_index` is the -/// SpellSchool INDEX (0=normal/physical..6=arcane) the module already derived from the spell header's -/// school bitmask, so this builder stays dumb (no mask math) — an out-of-range index falls back to Normal. -/// `is_crit`/`resisted`/`absorbed` now ride from the cast-event row: a crit sets hit_info=CriticalHit -/// (the 5875 client renders the number as a crit), and resisted/absorbed populate the "(N resisted)"/ -/// "(N absorbed)" suffixes. `blocked` stays 0 (spells aren't blocked in vanilla). Single-target — -/// multi-target / DoT per-tick logging is a follow-up. `target`/`attacker` are PackedGuid (matches the -/// other combat builders). pub fn build_spell_non_melee_damage_log( target_guid: u64, caster_guid: u64, @@ -56,11 +37,7 @@ pub fn build_spell_non_melee_damage_log( periodic_log: false, unused: 0, blocked: 0, - // GTKER FIELD NAMES LIE (3rd confirmed instance): gtker typed this field with the MELEE - // HitInfo enum, but the 5875 client reads it as the SPELL hit-type flags (cmangos - // SendSpellNonMeleeDamageLog: SPELL_HIT_TYPE_CRIT = 0x2). HitInfo::CriticalHit encodes 0x80 — - // the melee crit bit — which the client ignores here (live find: a 22-damage Heroic Strike - // crit rendered plain). The variant that happens to encode the needed 0x2 is AffectsVictim. + // Spell crits use 0x2; the binding's melee CriticalHit bit, 0x80, is wrong here. hit_info: if is_crit { HitInfo::AffectsVictim } else { @@ -70,17 +47,7 @@ pub fn build_spell_non_melee_damage_log( } } -/// The cast *begin* (`SMSG_SPELL_START`), paired with `build_spell_go` and sent FIRST. The 5875 client -/// opens its cast state on CMSG_CAST_SPELL and only fully closes it when it sees the START→GO pair the -/// real cores always send — a lone `SMSG_SPELL_GO` leaves `m_currentSpell` set, so the action button -/// stays lit and every further ability fails with "Another action is in progress." `timer = 0` (the -/// gateway relays this when the cast RESOLVES, so for both instant and just-finished timed casts the -/// remaining cast time is 0). Self-cast: `cast_item == caster`, empty targets/flags. -/// -/// `target`/`ammo` exist for the RANGED auto-repeat activation: vanilla's SendSpellStart writes -/// the real unit target (not SELF) and, for a ranged spell, CAST_FLAG_AMMO (0x20) + the ammo block — -/// that block is what nocks the arrow/bullet on the 5875 client. All other casts pass `0, None` -/// (byte-identical to before). [combat] +/// START must precede GO, including instant casts, to release the client's cast state. pub fn build_spell_start( caster_guid: u64, spell_id: u32, @@ -88,8 +55,7 @@ pub fn build_spell_start( target: u64, ammo: Option<(u32, u32)>, ) -> SMSG_SPELL_START { - // CAST_FLAG_UNKNOWN2 (0x02) — base flag every mangos/vmangos SendSpellStart sets; a ranged - // auto-repeat ORs in CAST_FLAG_AMMO (0x20) + the ammo block (mirrors build_spell_go's ammo path). + // CAST_FLAG_UNKNOWN2 is the base START flag; ranged casts also carry AMMO. let flags = match ammo { Some((ammo_display_id, ammo_inventory_type)) => SMSG_SPELL_START_CastFlags::new( 0x0002 | 0x0020, @@ -116,15 +82,11 @@ pub fn build_spell_start( caster: Guid::new(caster_guid), spell: spell_id, flags, - timer, // cast-bar duration (ms): >0 on a timed cast-START, 0 on an instant START + timer, targets, } } -/// The cast-INTERRUPT signal (`SMSG_SPELL_FAILURE`, opcode 0x0133, 13-byte body). Relayed to the caster -/// when its in-progress timed cast is cancelled by direct damage (or CC) mid-cast — no `SMSG_SPELL_GO` -/// follows (the cast never resolves), so the client must be told explicitly to tear down its filling cast -/// bar. `result = Interrupted` (byte 0x23) is the vanilla "your cast was interrupted" reason. [combat] pub fn build_spell_failure(caster_guid: u64, spell_id: u32) -> SMSG_SPELL_FAILURE { SMSG_SPELL_FAILURE { guid: Guid::new(caster_guid), @@ -133,14 +95,6 @@ pub fn build_spell_failure(caster_guid: u64, spell_id: u32) -> SMSG_SPELL_FAILUR } } -/// `SMSG_SPELL_DELAYED` (opcode 0x01E2, 12-byte body: guid + u32 delay) — vanilla 1.12's cast-bar -/// PUSHBACK signal. Sent when a landed DIRECT hit slides `caster_guid`'s in-progress -/// timed cast's fire time by `delay_ms` (the module caps this at `CAST_PUSHBACK_MAX` pushbacks per -/// cast — see `module/src/spell/cast.rs::pushback_cast`); the client visibly slides its cast bar by the -/// same amount rather than tearing it down (unlike `SMSG_SPELL_FAILURE`/`build_spell_failure`, which -/// tears the bar down on an actual cancel/Kick). Broadcast like `build_spell_start`/`build_spell_go` -/// (the cast bar is caster-visible unit state, not a private failure message) — `guid` is a plain -/// 8-byte guid. [combat] pub fn build_spell_delayed(caster_guid: u64, delay_ms: u32) -> SMSG_SPELL_DELAYED { SMSG_SPELL_DELAYED { guid: Guid::new(caster_guid), @@ -148,16 +102,7 @@ pub fn build_spell_delayed(caster_guid: u64, delay_ms: u32) -> SMSG_SPELL_DELAYE } } -/// Raw body for `SMSG_CAST_RESULT` (0x0130) with result = OKAY (0x00). vmangos sends this -/// after `SMSG_SPELL_COOLDOWN` and BEFORE `SMSG_SPELL_GO` for every successful non-triggered -/// player cast; without it the 5875 client's m_currentSpells is never cleared, leaving the action -/// button locked ("Another action is in progress"). Body = spell_id(u32 LE) + 0x00(u8) = 5 bytes. -/// Note: the gtker SMSG_CAST_RESULT wowm has the Success/Failure condition inverted (Success would -/// add a reason byte), so this is emitted as `Outbound::Raw { opcode: 0x0130, body }`. [combat] -/// Raw body for `SMSG_CAST_RESULT` (0x0130) with result = FAILED: spell_id(u32 LE) + 0x02 -/// (CAST_FAILED) + the 1.12 `CastFailureReason` byte — the client prints the red error line -/// ("Not enough rage", "You must be behind your target", ...). Raw for the same reason as -/// `build_cast_result_ok` below (gtker's typed SMSG_CAST_RESULT inverts the status semantics). +/// Send OK after COOLDOWN and before GO to release the client's current spell. pub fn build_cast_result_failed(spell_id: u32, reason: u8) -> Vec { let mut v = spell_id.to_le_bytes().to_vec(); v.push(0x02); @@ -165,23 +110,14 @@ pub fn build_cast_result_failed(spell_id: u32, reason: u8) -> Vec { v } -/// The 1.12 `CastFailureReason` for a durable cast-failure code -/// (`game_spell_cast_event.failure_reason`), or `None` when the row names no concrete reason and the -/// cast-bar teardown stands alone. Codes are LOCKSTEP with the module's `CAST_FAIL_*` taxonomy -/// (`module/src/spell/tables.rs`); the vanilla byte lives here, with the rest of the protocol. pub fn cast_failure_reason_for_code(code: u8) -> Option { const CAST_FAIL_NO_POWER: u8 = 1; match code { - CAST_FAIL_NO_POWER => Some(0x4D), // SPELL_FAILED_NO_POWER — "Not enough rage" + CAST_FAIL_NO_POWER => Some(0x4D), _ => None, } } -/// Map a module cast-rejection message to the closest 1.12 `CastFailureReason`. The module's -/// rejections are human strings (not tagged codes), so this is a SUBSTRING map over the stable -/// phrases in `resolve_cast_at`'s gates — if a gate's wording changes, the worst case is the -/// generic fallback (0x17 DontReport: red-line-free silent fail, what mangos uses for -/// "don't confuse the player"). Values from gtker's vanilla CastFailureReason (client-verified ids). pub fn cast_failure_reason_for(es: &str) -> u8 { const NO_POWER: u8 = 0x4D; const NOT_READY: u8 = 0x3C; @@ -194,7 +130,7 @@ pub fn cast_failure_reason_for(es: &str) -> u8 { const NOT_BEHIND: u8 = 0x33; const ONLY_STEALTHED: u8 = 0x57; const INTERRUPTED: u8 = 0x23; - const CASTER_AURASTATE: u8 = 0x12; // "You can't do that yet" — the react-window (Overpower/Revenge) fit + const CASTER_AURASTATE: u8 = 0x12; const STUNNED: u8 = 0x64; const LEVEL_REQUIREMENT: u8 = 0x29; const TARGETS_DEAD: u8 = 0x65; @@ -244,20 +180,6 @@ pub fn build_cast_result_ok(spell_id: u32) -> Vec { v } -/// The cast visual (`SMSG_SPELL_GO`). `hit_target == 0` → a self-cast (hits = [caster]): the cast-clear -/// after a `CMSG_CAST_SPELL`, and the aura-tracer relay. `hit_target != 0` → a shot/bolt landing on that -/// target (hits = [target]): the per-cast projectile the client renders. `ammo` = `Some((display_id, -/// inv_type))` sets the AMMO flag so an Auto Shot fires the ARROW graphic; `None` → no flag (wand -/// bolt / generic spell missile). `cast_item` == `caster` (cmangos convention; 0 is wrong), no misses. -/// -/// PROJECTILE FIX: the 5875 client flies a spell's travelling missile (SpellVisual→SpellMissile, e.g. -/// Shadow Bolt) from `caster → m_targets.unit_target` — it reads the trajectory from the `targets` -/// (`SpellCastTargets`) block, NOT from `hits[]` (which only drives the combat log / impact resolution). -/// An empty `SpellCastTargets::default()` is TARGET_FLAG_SELF with no unit target, so a generic spell -/// bolt has no flight destination and the client renders the impact in place ("instant damage, no bolt"). -/// When `hit_target != 0` we therefore set TARGET_FLAG_UNIT + the packed target guid so the missile -/// flies at the mob. (Auto Shot/wand looked right only because the AMMO path renders from the caster's -/// ranged target/facing; a no-ammo spell bolt needs this unit target.) Self-cast → default unchanged. pub fn build_spell_go( caster_guid: u64, spell_id: u32, @@ -279,8 +201,6 @@ pub fn build_spell_go_area(caster_guid: u64, spell_id: u32) -> SMSG_SPELL_GO { } } -/// The target block for a cast at a clicked ground point (`TARGET_FLAG_DEST_LOCATION`), echoing the -/// point as the client sent it, as vmangos does. fn ground_point_targets(dest: (f32, f32, f32)) -> SpellCastTargets { SpellCastTargets { target_flags: SpellCastTargets_SpellCastTargetFlags::new_dest_location( @@ -295,7 +215,6 @@ fn ground_point_targets(dest: (f32, f32, f32)) -> SpellCastTargets { } } -/// The instant START for a cast at a clicked ground point, with the point in the target block. pub fn build_spell_start_dest( caster_guid: u64, spell_id: u32, @@ -307,9 +226,6 @@ pub fn build_spell_start_dest( } } -/// The GO for any instant cast at a clicked ground point (Distract, Blizzard, Rain of Fire, Flare and -/// the other ground-targeted spells): no hits, and the point in the target block, so the client plays -/// the cast at the point and not on the caster. pub fn build_spell_go_dest( caster_guid: u64, spell_id: u32, @@ -321,10 +237,6 @@ pub fn build_spell_go_dest( } } -/// `build_spell_go` with an explicit MISS outcome: a MISSED ranged auto-shot puts the target in -/// the GO's `misses` list (SpellMissInfo::Miss) instead of `hits` — the 5875 client renders the white -/// "Miss" over the target from this list (vanilla shape; a missed shot sends NO damage log). Every -/// other call site keeps the 4-arg `build_spell_go` (miss = false, byte-identical). pub fn build_spell_go_outcome( caster_guid: u64, spell_id: u32, @@ -332,16 +244,14 @@ pub fn build_spell_go_outcome( ammo: Option<(u32, u32)>, miss: bool, ) -> SMSG_SPELL_GO { - // A self-cast arrives as hit_target 0 OR the caster's own guid (cast_spell resolves a self-cast - // to target_guid = caster). Both write TARGET_FLAG_SELF (the default), NOT TARGET_FLAG_UNIT → caster. + // Self-casts use TARGET_FLAG_SELF even when the target guid equals the caster. let is_self_cast = hit_target == 0 || hit_target == caster_guid; let hit = if is_self_cast { caster_guid } else { hit_target }; - // CAST_FLAG_UNKNOWN9 (0x0100) base — every mangos/vmangos SendSpellGo sets it; a ranged shot - // ORs in CAST_FLAG_AMMO (0x20) + the ammo block. + // CAST_FLAG_UNKNOWN9 is the base GO flag; ranged casts also carry AMMO. let flags = match ammo { Some((ammo_display_id, ammo_inventory_type)) => SMSG_SPELL_GO_CastFlags::new( 0x0100 | 0x0020, @@ -352,8 +262,6 @@ pub fn build_spell_go_outcome( ), None => SMSG_SPELL_GO_CastFlags::new_unknown9(), }; - // A real OTHER target rides as TARGET_FLAG_UNIT (caster → unit_target so the missile flies at it); a - // self-cast keeps the default SELF target block. let targets = if is_self_cast { SpellCastTargets::default() } else { @@ -387,10 +295,6 @@ pub fn build_spell_go_outcome( } } -/// Build `SMSG_ATTACKSTART` (Tier 3 combat C1) — tells observers `attacker` is now auto-attacking -/// `victim`, which puts the attacker into combat stance. Both guids are the values the client -/// already knows the units by (player = raw low guid, high-part 0; creature = full unit guid), so -/// `Guid::new` mirrors `build_create_object`. pub fn build_attack_start(attacker_guid: u64, victim_guid: u64) -> SMSG_ATTACKSTART { SMSG_ATTACKSTART { attacker: Guid::new(attacker_guid), @@ -398,8 +302,6 @@ pub fn build_attack_start(attacker_guid: u64, victim_guid: u64) -> SMSG_ATTACKST } } -/// Build `SMSG_ATTACKSTOP` (Tier 3 combat C1) — leaves combat stance. `unknown1 = 0` per the -/// mangos cores. pub fn build_attack_stop(attacker_guid: u64, victim_guid: u64) -> SMSG_ATTACKSTOP { SMSG_ATTACKSTOP { player: Guid::new(attacker_guid), @@ -408,10 +310,6 @@ pub fn build_attack_stop(attacker_guid: u64, victim_guid: u64) -> SMSG_ATTACKSTO } } -/// Build `SMSG_ATTACKERSTATEUPDATE` (Tier 3 combat C1) for one physical normal melee swing of -/// `damage`. This drives the swing animation + floating damage text; the victim's health bar is -/// moved separately by the `game_world_entity` `on_update` → VALUES relay. Single physical -/// `DamageInfo` (school mask 0; `damage_float == damage_uint`), no block/absorb/resist. pub fn build_attacker_state_update( attacker_guid: u64, target_guid: u64, @@ -420,40 +318,13 @@ pub fn build_attacker_state_update( blocked_amount: u32, spell_id: u32, ) -> SMSG_ATTACKERSTATEUPDATE { - // The 5875 client picks the floating combat text from `damage_state` (the VictimState) and the - // HitInfo flags TOGETHER — NOT from the damage value. mangos-zero/vmangos - // (Unit::CalculateMeleeDamage) set, for a player/creature BASE_ATTACK auto-swing: - // landed NORMAL: HitInfo = AFFECTS_VICTIM (0x02), VictimState = NORMAL (1) - // landed CRIT: HitInfo = AFFECTS_VICTIM|CRITICAL_HIT (0x82), VictimState = NORMAL (1) - // MISS: HitInfo = MISS (0x10) [AFFECTS_VICTIM cleared],VictimState = UNAFFECTED (0) - // Our old packet sent VictimState=0 (UNAFFECTED) for landed hits — UNAFFECTED is precisely the - // value mangos sends on a MISS, so the client renders "MISS" even though DamageInfo carried real - // damage. The load-bearing field for the text is `damage_state`; AFFECTS_VICTIM (0x02) is what - // plays the victim's being-hit animation (vmangos: "No animation on victim" clears it on miss). - // - // gtker's `HitInfo` is a CLOSED single-value enum written via `as_int()`, so it cannot carry the - // OR'd 0x82 / 0x12 mangos uses — there is no raw/From write path. We therefore send the - // dominant single bit through the enum and keep the (now-correct) VictimState in `damage_state`: - // normal -> HitInfo::AffectsVictim (0x02), VictimState 1 (hit + animation, no degradation) - // crit -> HitInfo::CriticalHit (0x80), VictimState 1 (loses 0x02 anim bit; crit text + - // VictimState=1 still render as a hit) - // miss -> HitInfo::Miss (0x10), VictimState 0 (matches mangos exactly) - // Dodge/parry are zero-damage avoidance outcomes. In vanilla they ride the VictimState - // (`damage_state`), NOT a HitInfo flag (gtker's HitInfo has no Dodge/Parry variant — the client - // renders the "Dodge"/"Parry" text from VictimState 2/3). HitInfo stays NormalSwing (0x0) so the - // AFFECTS_VICTIM hit-animation bit is cleared — no being-hit animation plays on an avoided swing. let (hit_info, damage_state) = match hit_info { 1 => (HitInfo::CriticalHit, u32::from(VICTIMSTATE_NORMAL)), 2 => (HitInfo::Miss, u32::from(VICTIMSTATE_UNAFFECTED)), 3 => (HitInfo::NormalSwing, u32::from(VICTIMSTATE_DODGE)), 4 => (HitInfo::NormalSwing, u32::from(VICTIMSTATE_PARRY)), - // Glancing: a landed hit (VictimState NORMAL) carrying reduced damage; HitInfo::Glancing - // (0x4000) drives the "Glancing" text. Affects-victim animation still plays (it connected). 5 => (HitInfo::Glancing, u32::from(VICTIMSTATE_NORMAL)), - // Block: a LANDED hit a shield reduced by `blocked_amount`. Like dodge/parry, vanilla renders the - // "Block" floating text from the VictimState (BLOCK=4) + the `blocked_amount` field, NOT a HitInfo - // flag (gtker's HitInfo has no Block variant). It still affects the victim (the swing connected), - // so it carries AffectsVictim — the being-hit animation plays, unlike avoidance. + // Block text uses VictimState::Block and blocked_amount, not a HitInfo flag. 7 => (HitInfo::AffectsVictim, u32::from(VICTIMSTATE_BLOCK)), _ => (HitInfo::AffectsVictim, u32::from(VICTIMSTATE_NORMAL)), }; @@ -463,7 +334,7 @@ pub fn build_attacker_state_update( target: Guid::new(target_guid), total_damage: damage, damages: vec![DamageInfo { - spell_school_mask: 0, // physical + spell_school_mask: 0, damage_float: damage as f32, damage_uint: damage, absorb: 0, @@ -471,23 +342,16 @@ pub fn build_attacker_state_update( }], damage_state, unknown1: 0, - // 0 for a melee swing; the ranged spell (75 Auto Shot / 5019 Shoot) for a ranged shot, so - // the client attributes the shot to the ability. HitInfo is identical to melee — vanilla has no - // ranged-specific HitInfo flag. spell_id, blocked_amount, } } -/// VictimState (the `damage_state` u32 in vanilla SMSG_ATTACKERSTATEUPDATE). The 5875 client reads -/// this to decide whether the swing affected the victim; `UNAFFECTED` makes it render "MISS". -/// (mangos: `VICTIMSTATE_UNAFFECTED = 0`, `VICTIMSTATE_NORMAL = 1`, `DODGE = 2`, `PARRY = 3`.) The -/// 5875 client renders the "Dodge"/"Parry" floating text from these (HitInfo carries no such flag). const VICTIMSTATE_UNAFFECTED: u8 = 0; const VICTIMSTATE_NORMAL: u8 = 1; const VICTIMSTATE_DODGE: u8 = 2; const VICTIMSTATE_PARRY: u8 = 3; -const VICTIMSTATE_BLOCK: u8 = 4; // a shielded defender — drives the "Block" text with `blocked_amount` +const VICTIMSTATE_BLOCK: u8 = 4; #[cfg(test)] mod tests { @@ -495,11 +359,8 @@ mod tests { #[test] fn spell_start_carries_cast_bar_timer() { - // Fix 2 (peer cast bar): a timed cast-START relays its duration so observers see the bar fill; - // an instant cast / cast-clear sends 0. assert_eq!(build_spell_start(0xF1, 133, 3500, 0, None).timer, 3500); assert_eq!(build_spell_start(0xF1, 133, 0, 0, None).timer, 0); - // The ranged-activation START carries the AMMO flag + block and the real unit target. let s = build_spell_start(0xF1, 75, 0, 0xBEEF, Some((5996, 24))); assert_eq!( s.flags @@ -508,7 +369,6 @@ mod tests { Some((5996, 24)) ); assert!(s.targets.target_flags.get_unit().is_some()); - // A MISSED shot's GO carries the target in `misses`, not `hits`. let go = build_spell_go_outcome(0xF1, 75, 0xBEEF, Some((5996, 24)), true); assert!(go.hits.is_empty()); assert_eq!(go.misses.len(), 1); @@ -516,14 +376,12 @@ mod tests { #[test] fn spell_go_aims_at_the_target_not_self() { - // Fix 1 (peer cast direction): the missile flies at the primary target (hits = [target]). let caster = 0xF130_0000_0000_0001u64; let target = 0xF130_0000_0000_00AAu64; assert_eq!( build_spell_go(caster, 133, target, None).hits, vec![Guid::new(target)] ); - // hit_target 0 → self-cast falls back to the caster (unchanged). assert_eq!( build_spell_go(caster, 133, 0, None).hits, vec![Guid::new(caster)] @@ -532,14 +390,12 @@ mod tests { #[test] fn spell_go_targets_carry_unit_for_the_missile_trajectory() { - // PROJECTILE FIX: a real target must ride SMSG_SPELL_GO.targets as TARGET_FLAG_UNIT so the - // 5875 client flies the spell missile (caster → unit_target). hits[] alone is not enough. + // Missile animation requires TARGET_FLAG_UNIT; hits[] alone is insufficient. let caster = 0xF130_0000_0000_0001u64; let target = 0xF130_0000_0000_00AAu64; let go = build_spell_go(caster, 686, target, None); let unit = go.targets.target_flags.get_unit().expect("UNIT flag set"); assert_eq!(unit.unit_target, Guid::new(target)); - // Self-cast (hit_target 0) keeps the default SELF target block — no unit target. let self_go = build_spell_go(caster, 686, 0, None); assert!(self_go.targets.target_flags.get_unit().is_none()); } @@ -551,12 +407,11 @@ mod tests { build_spell_go_dest(5, 1725, (1.5, -2.0, 30.25)) .write_into_vec(&mut bytes) .unwrap(); - // Packed guid 5 for the cast item and the caster: mask 0x01, then the one nonzero byte. let mut want = vec![0x01, 0x05, 0x01, 0x05]; want.extend_from_slice(&1725u32.to_le_bytes()); - want.extend_from_slice(&0x0100u16.to_le_bytes()); // CAST_FLAG_UNKNOWN9 - want.extend_from_slice(&[0, 0]); // hit count, miss count - want.extend_from_slice(&0x0040u16.to_le_bytes()); // TARGET_FLAG_DEST_LOCATION + want.extend_from_slice(&0x0100u16.to_le_bytes()); + want.extend_from_slice(&[0, 0]); + want.extend_from_slice(&0x0040u16.to_le_bytes()); for coordinate in [1.5f32, -2.0, 30.25] { want.extend_from_slice(&coordinate.to_le_bytes()); } @@ -565,7 +420,6 @@ mod tests { #[test] fn ground_destination_refusals_map_to_bad_targets_out_of_range_and_line_of_sight() { - // gtker vanilla CastFailureReason: BAD_TARGETS 0x0A, OUT_OF_RANGE 0x59, LINE_OF_SIGHT 0x2A. assert_eq!( cast_failure_reason_for("spell can only target a ground point"), 0x0A @@ -582,8 +436,6 @@ mod tests { #[test] fn spell_failure_carries_interrupted_for_the_caster() { - // Cast-interrupt signal: SMSG_SPELL_FAILURE addressed to the caster's guid, naming the cancelled - // spell, with result=Interrupted (the cast-bar teardown). guid is the plain 8-byte caster guid. let caster = 0xF130_0000_0000_0005u64; let f = build_spell_failure(caster, 686); assert_eq!(f.guid, Guid::new(caster)); @@ -593,8 +445,6 @@ mod tests { #[test] fn spell_delayed_carries_the_caster_guid_and_delay_ms() { - // Cast-pushback signal: SMSG_SPELL_DELAYED addressed to the caster's guid, with - // the delay in ms so the client's cast bar visibly slides. guid is the plain 8-byte caster guid. let caster = 0xF130_0000_0000_0007u64; let d = build_spell_delayed(caster, 500); assert_eq!(d.guid, Guid::new(caster)); @@ -626,15 +476,9 @@ mod tests { } } -/// The client-side SPELL-MODIFIER mirror: aggregate raw modifier rows `(family_mask, op, -/// amount, is_pct)` into one `SMSG_SET_FLAT/PCT_SPELL_MODIFIER` per SET BIT of each op's combined -/// mask — `eff` is the mask BIT INDEX (0..31) and `value` the TOTAL for that (op, bit), the mangos -/// SendSpellMod convention. The 5875 client then shortens its own cast bars/tooltips for the -/// affected spells; the server stays authoritative regardless (display parity only). pub fn build_spell_modifier_msgs(rows: &[(u32, u8, i32, bool)]) -> Vec { use std::collections::BTreeMap; use wow_world_messages::vanilla::{SMSG_SET_FLAT_SPELL_MODIFIER, SMSG_SET_PCT_SPELL_MODIFIER}; - // (is_pct, op, bit) -> summed value. BTreeMap for a deterministic packet order. let mut totals: BTreeMap<(bool, u8, u8), i64> = BTreeMap::new(); for &(mask, op, amount, pct) in rows { for bit in 0..32u8 { @@ -646,7 +490,8 @@ pub fn build_spell_modifier_msgs(rows: &[(u32, u8, i32, bool)]) -> Vec { assert_eq!((m.eff, m.op, m.value as i32), (0, 10, -200)); diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs index bcd2c685a..7810604ad 100644 --- a/gateway/src/codec/combat/subscriptions.rs +++ b/gateway/src/codec/combat/subscriptions.rs @@ -1,4 +1,3 @@ -//! Table Relays for this Protocol Family. use lyracore_shared::spell::taxonomy::{A_MOD_RESISTANCE, A_MOD_SPEED, SPEED_SWIM}; use crate::codec::entity::subscriptions::build_peer_create; @@ -15,16 +14,9 @@ use std::sync::{Arc, Mutex}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::{SMSG_FORCE_MOVE_ROOT, SMSG_FORCE_MOVE_UNROOT}; -/// Stealth presence marker (taxonomy `A_STEALTH`): a unit carrying ≥1 such aura is stealthed and must -/// be hidden from OTHER players' clients. Mirrors `module::spell::taxonomy::A_STEALTH = 0xA8`. pub(crate) use lyracore_shared::spell::taxonomy::A_STEALTH; -/// The visibility action a single A_STEALTH aura insert/delete implies, decided from the POST-change -/// count of A_STEALTH auras on the stealther's guid. A Stealth cast applies TWO A_STEALTH auras (and a -/// break removes both); since the SDK applies the whole row-delta before firing callbacks, both inserts -/// fire seeing count 2 and both deletes seeing count 0 — so we HIDE on ANY insert while stealthed -/// (count >= 1) and REVEAL when the count reaches 0. Pure + unit-tested; the exactly-once guarantee -/// against an already-hidden/visible peer is the per-viewer `created` set at the call site, not a boundary. +/// SDK callbacks see the whole row delta, so multiple stealth changes share one count. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum StealthAction { Hide, @@ -32,8 +24,6 @@ pub(crate) enum StealthAction { None, } -/// Hide on a stealth insert and reveal when the last marker is removed. The aura index supplies -/// the count after this callback's change; the viewer's `created` set makes repeated actions harmless. pub(crate) fn stealth_action(is_insert: bool, post_change_count: usize) -> StealthAction { match (is_insert, post_change_count) { (true, n) if n >= 1 => StealthAction::Hide, @@ -42,8 +32,6 @@ pub(crate) fn stealth_action(is_insert: bool, post_change_count: usize) -> Steal } } -/// Relay a visible melee swing or ranged launch. The Module emits ranged damage at impact -/// through `SpellCastEvent`, which `cast_event_outbound` relays separately. pub(crate) fn combat_event_outbound( created: &Mutex>, row: &CombatEvent, @@ -52,17 +40,15 @@ pub(crate) fn combat_event_outbound( return Vec::new(); } let mut out = Vec::new(); - // A ranged launch uses SPELL_GO. ATTACKERSTATEUPDATE would animate a melee swing. + // A ranged launch uses SPELL_GO; ATTACKERSTATEUPDATE would animate a melee swing. if row.ranged_spell_id != 0 { - // Auto Shot stamps an ammo display id → the AMMO flag fires the arrow graphic (24 = - // INVTYPE_AMMO). Wand Shoot (ammo_display_id 0) → no flag → the bolt. Deliberate - // simplification: inv-type 24 is hardcoded. + // 24 is INVTYPE_AMMO; a zero ammo display leaves wand bolts without the AMMO flag. let ammo = if row.ammo_display_id != 0 { Some((row.ammo_display_id, 24)) } else { None }; - let miss = row.hit_info == 2; // module HIT_MISS + let miss = row.hit_info == 2; let go = codec::build_spell_go_outcome( row.attacker_guid, row.ranged_spell_id, @@ -74,9 +60,6 @@ pub(crate) fn combat_event_outbound( go, )))); } else if !row.spell_swing { - // A fired on-next-swing spell (Heroic Strike/Cleave) REPLACES the white hit — the - // whole swing rides the spell's cast-event row (GO + yellow named damage log), so this - // event sends NO white ATTACKERSTATEUPDATE. killing_blow/ATTACKSTOP below still applies. let m = codec::build_attacker_state_update( row.attacker_guid, row.target_guid, @@ -89,11 +72,6 @@ pub(crate) fn combat_event_outbound( ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(m)), )); } - // C2: on a MELEE killing blow, tell the attacker to leave combat stance. The target itself - // vanishes via the game_world_entity on_delete → SMSG_DESTROY_OBJECT relay. A RANGED kill - // sends no ATTACKSTOP (the client was never in melee-attack state; vanilla sends none) — - // its stop signal is the SMSG_CANCEL_AUTO_REPEAT from the engagement-row delete relay - // (kill_creature's disengage frees the attacker's row). if row.killing_blow && row.ranged_spell_id == 0 { let stop = codec::build_attack_stop(row.attacker_guid, row.target_guid); out.push(Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP( @@ -103,10 +81,6 @@ pub(crate) fn combat_event_outbound( out } -/// Combat-stance engage leg: SMSG_ATTACKSTART for a melee engagement — -/// the one body both legs run. Ranged rows are NOT melee combat (Auto Shot: ATTACKSTART would animate a -/// melee swing between shots); the `created` gate skips an out-of-scope attacker like the swing -/// relay. pub(crate) fn melee_engage_outbound( created: &Mutex>, row: &MeleeAttack, @@ -123,10 +97,6 @@ pub(crate) fn melee_engage_outbound( ))] } -/// Combat-stance disengage leg. A RANGED row's delete is the ONE -/// server-initiated auto-repeat teardown choke point: the OWNING player (and only them) gets the -/// 0-byte SMSG_CANCEL_AUTO_REPEAT so its toggle drops in lockstep (Auto Shot); a melee row's delete is -/// SMSG_ATTACKSTOP for everyone (a non-kill disengage leaves stance too). pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec { if row.ranged_spell_id != 0 { if row.attacker_guid == self_guid { @@ -140,26 +110,13 @@ pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec ))] } -/// Spell cast visuals: the one body both paths run. It handles START and GO sequencing, interrupt -/// teardown, pushback, Proc logs, damage and heal logs, and cooldown. Every caster-private branch -/// keys on `self_guid`. pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec { let mut out = Vec::new(); let Some(kind) = SpellCastEventKind::decode(row) else { return out; }; - // INTERRUPT signal: the victim's mid-cast timed spell was cancelled. - // Relay SMSG_SPELL_FAILURE{spell, Interrupted} to the caster so the client tears down its cast - // bar. No SMSG_SPELL_GO follows because the cast never resolved. if kind == SpellCastEventKind::Interrupt { - // SELF-ONLY for a PLAYER caster: game_spell_cast_event is a global public subscription, - // so this closure fires for EVERY player. SMSG_SPELL_FAILURE is a caster-private cast-bar - // teardown (unlike the START/GO broadcast visuals), so relay it ONLY to the caster — else - // bystanders get stray "Interrupted" feedback and a bystander mid-casting the same spell - // risks a spurious teardown. - // A creature or pet caster (0xF130/0xF140 high-guid) has no self — broadcast so every observer's - // mob cast bar tears down on a Kick/Counterspell (the packet carries the mob's guid, so - // a bystander's own bar is untouched; mirrors the START broadcast that drew the bar). + // SPELL_FAILURE must be caster-private or bystanders can lose their own cast state. let is_creature = matches!(row.caster_guid >> 48, 0xF130 | 0xF140); if is_creature || row.caster_guid == self_guid { let m = codec::build_spell_failure(row.caster_guid, row.spell_id); @@ -167,12 +124,7 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec Vec Vec 0 { let log = codec::build_spell_non_melee_damage_log( @@ -212,8 +161,6 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec Vec Vec Vec Vec 0 { use wow_world_messages::vanilla::SMSG_SPELLHEALLOG; use wow_world_messages::Guid; @@ -327,9 +244,6 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec, target_guid: u64, @@ -348,8 +262,6 @@ pub(crate) fn aura_sync( Outbound::Raw { opcode, body } } -// A self resistance aura applies or expires after the entity CREATE. Recompute all seven effective -// resistances from the coordinator's post-change cache and push the complete paperdoll group. pub(crate) fn armor_packet( coord: &Coordinator, changed: &Aura, @@ -362,22 +274,15 @@ pub(crate) fn armor_packet( { return None; } - // The coordinator owns the entity, aura, item-instance and item-catalogue caches needed by this fold. let guard = coord.0.coord(); let db = &guard.conn.db; let eff = crate::stdb::armor::effective_resistances(db, self_guid); - // Carry the positive AURA portion alongside the total so the paperdoll renders the - // green "(+N)" (Devotion Aura showed as plain white armor). Raw path — the positive - // field has no gtker setter. Login self-corrects through this same relay (the SDK - // replays aura rows after subscription-apply). let pos = crate::stdb::armor::aura_armor_positive(db, self_guid); Some(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_armor_values(self_guid, eff, pos)), ))) } -// The Module has already persisted sheet stats in the aura transaction. Re-push that row -// only for aura kinds that affect it; the paperdoll is private to its owner. pub(crate) fn sheet_packet( coord: &Coordinator, changed: &Aura, @@ -398,8 +303,6 @@ pub(crate) fn sheet_packet( }) } -/// Hide a peer on stealth gain; reveal it with current equipment and auras on stealth loss. -/// The AOI and `created` checks prevent distant or duplicate CREATEs. Self never hides from itself. #[allow(clippy::too_many_arguments)] pub(crate) fn stealth_visibility( post_change_stealth_count: usize, @@ -417,8 +320,6 @@ pub(crate) fn stealth_visibility( } match stealth_action(is_insert, post_change_stealth_count) { StealthAction::Hide => { - // Evict so a later REVEAL (and ordinary AOI re-entry) re-CREATEs; only DESTROY if the - // viewer actually had it (idempotent — a never-created peer needs no DESTROY). if created.lock().unwrap().remove(&changed.target_guid) { vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( codec::build_destroy_object(changed.target_guid), @@ -428,9 +329,6 @@ pub(crate) fn stealth_visibility( } } StealthAction::Reveal => { - // Re-CREATE only if not currently shown (idempotent) AND the peer is in this viewer's - // scope (its row is in the cache). Insert into `created` BEFORE building so the dedup - // is consistent; if the row is gone (out of scope), undo the insert and emit nothing. if !created.lock().unwrap().insert(changed.target_guid) { return Vec::new(); } @@ -454,10 +352,7 @@ pub(crate) fn stealth_visibility( Some(row) => match build_peer_create(coord, view, shard, self_guid, &row) { Some(out) => out, None => { - // Encode failure: roll the dedup entry back like `offer_peer_create` - // does, else this guid is permanently suppressed (marked created with - // no CREATE ever sent) — the same latent re-entry bug fixed on the - // insert/update path. + // Undo dedup on failure so CREATE can retry. created.lock().unwrap().remove(&changed.target_guid); Vec::new() } @@ -472,7 +367,6 @@ pub(crate) fn stealth_visibility( } } -/// Channel timers belong to the caster, including channels whose auras sit on an enemy. pub(crate) fn spell_channel_outbound( coord: &Coordinator, self_guid: u64, @@ -522,9 +416,6 @@ pub(crate) fn spell_channel_outbound( Some(Outbound::One(packet)) } -/// Aura insert leg: array sync, self-only duration/run-speed/armor/sheet packets, and the -/// stealth HIDE transition. The target's current aura set comes from the gateway's aura index, -/// post-change like the cache it mirrors; `stealth_count` was taken on the pump. #[allow(clippy::too_many_arguments)] pub(crate) fn aura_insert_outbound( coord: &Coordinator, @@ -538,8 +429,7 @@ pub(crate) fn aura_insert_outbound( ) -> Vec { let mut out = Vec::new(); out.extend(spell_channel_outbound(coord, self_guid, row, false)); - // A stealth-hidden peer (not in `created`) must get NO per-peer relay: a partial VALUES on - // a DESTROYed object is a client crash/desync vector. + // VALUES for a stealth-hidden, destroyed object can crash or desynchronize the client. let visible = row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); let current = visible.then(|| view.auras.on_target(shard, row.target_guid)); @@ -580,7 +470,6 @@ pub(crate) fn aura_insert_outbound( out } -/// Aura update leg (no stealth transition on an update). pub(crate) fn aura_update_outbound( coord: &Coordinator, view: &WorldView, @@ -597,7 +486,6 @@ pub(crate) fn aura_update_outbound( if let Some(current) = ¤t { out.push(aura_sync(current.iter().cloned(), row.target_guid)); } - // Re-send the timer only when the duration window actually changed (a refresh). if expires_changed { if let Some(o) = aura_duration_packet(row, self_guid) { out.push(o); @@ -623,8 +511,6 @@ pub(crate) fn aura_update_outbound( out } -/// Aura delete leg (the REVEAL half of stealth). The aura index is post-delete, so the -/// sync/speed folds read the remaining set. #[allow(clippy::too_many_arguments)] pub(crate) fn aura_delete_outbound( coord: &Coordinator, @@ -673,8 +559,6 @@ pub(crate) fn aura_delete_outbound( out } -/// Reconnect sends one current aura array per target and observer. Owner timers remain per aura, -/// while speed, armor, and sheet values refresh once even when several auras changed offline. pub(crate) fn aura_snapshot_outbound( coord: &Coordinator, view: &WorldView, @@ -755,11 +639,9 @@ pub(crate) fn aura_snapshot_outbound( out } -/// Projectile impact: the floating damage number for a projectile that -/// finished its travel — never a START/GO. Pure over the row; broadcast. pub(crate) fn impact_event_outbound(row: &SpellImpactEvent) -> Vec { if row.damage == 0 { - return Vec::new(); // a fully-absorbed impact (or a vanished target) logs nothing + return Vec::new(); } let log = codec::build_spell_non_melee_damage_log( row.target_guid, @@ -776,8 +658,6 @@ pub(crate) fn impact_event_outbound(row: &SpellImpactEvent) -> Vec { )] } -/// Dynamic objects: the shared-dispatch twin of the per-player -/// `on_dynobj_insert` relay. The instance gate already ran in `world_view::dynobj_appeared`. pub(crate) fn relay_dynobj_create(row: &DynamicObject) -> Vec { let m = codec::build_dynamicobject_create_object( row.guid, @@ -793,19 +673,13 @@ pub(crate) fn relay_dynobj_create(row: &DynamicObject) -> Vec { ))] } -/// A bare SMSG_DESTROY_OBJECT — the delete leg of the dynobj/corpse families, shared because the -/// packet is nothing but the guid. pub(crate) fn relay_destroy_object(guid: u64) -> Vec { vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( codec::build_destroy_object(guid), ))] } -/// SMSG_UPDATE_AURA_DURATION (buff timer): the 1.12 UpdateMask aura array carries NO duration, -/// so without this the client shows "0 seconds"/flashing icon. Only for THIS player's own auras -/// (the slot indexes the player's aura array) with a finite window; the duration is the full -/// applied span (expires - applied), which is what the client shows on apply/refresh. Pure → -/// unit-tested (hoisted verbatim out of `subscribe_player_events`, no logic change). +/// Aura-array fields omit duration; only the owner's aura slots accept timer packets. pub(crate) fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option { if row.target_guid != self_guid { return None; @@ -827,14 +701,12 @@ pub(crate) fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option, changed: &Aura, self_guid: u64, ) -> Option { - const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s + const BASE_RUN_SPEED: f32 = 7.0; // Vanilla base run speed, yards per second. if changed.target_guid != self_guid || !lyracore_shared::aura::moves_run_speed(changed.eff_kind, changed.eff_p_0) { @@ -858,9 +730,7 @@ pub(crate) fn run_speed_packet( )) } -/// A root, stun or polymorph change tells the owning client whether it may still move. The client -/// moves on its own authority, so the Module's movement gate alone leaves it running in place. -/// Overlapping holds keep the client rooted until the last one ends. +/// The client moves independently; overlapping holds must not send an early unroot. pub(crate) fn root_packet( mut auras: impl Iterator, changed: &Aura, @@ -889,7 +759,6 @@ pub(crate) fn root_packet( })) } -/// A swimming aura change refreshes the owner from the remaining aura set. pub(crate) fn swim_speed_packet( auras: impl Iterator, changed: &Aura, @@ -919,10 +788,6 @@ pub(crate) fn swim_speed_packet( )) } -/// A ground-area dynamic object appeared (Consecration's swirl etc.) → CREATE for every -/// same-instance viewer on the row's shard — the same `instance_relay_gate` the per-player leg -/// runs. Short-lived, so no login/AOI resident sweep (a mid-area login misses the visual), -/// exactly as before. pub(crate) fn dynobj_appeared(view: &WorldView, shard: ShardId, row: &DynamicObject) { let row = Arc::new(row.clone()); for viewer in viewers_on_shard(view, shard) { @@ -939,9 +804,6 @@ pub(crate) fn dynobj_appeared(view: &WorldView, shard: ShardId, row: &DynamicObj } } -/// A dynamic object's area ended → DESTROY to every viewer on the shard. Deliberately NOT -/// instance-gated: the per-player delete leg never was (a DESTROY for a guid the client doesn't -/// know is a no-op), and the A/B contract is byte-for-byte equality. pub(crate) fn dynobj_vanished(view: &WorldView, shard: ShardId, row: &DynamicObject) { let guid = row.guid; for viewer in viewers_on_shard(view, shard) { @@ -965,7 +827,6 @@ pub(crate) fn combat_audience( ) } -/// Queue a ranged launch or melee swing for each viewer. The job checks its created set. pub(crate) fn combat_event_appeared(view: &WorldView, shard: ShardId, row: &CombatEvent) { let row = Arc::new(row.clone()); for viewer in combat_audience(view, shard, &row) { @@ -976,7 +837,6 @@ pub(crate) fn combat_event_appeared(view: &WorldView, shard: ShardId, row: &Comb } } -/// An engagement row names no cell; the attacker's indexed cell anchors it. pub(crate) fn melee_audience( view: &WorldView, shard: ShardId, @@ -993,8 +853,6 @@ pub(crate) fn melee_audience( ) } -/// A melee/ranged engagement began → SMSG_ATTACKSTART per viewer (`melee_engage_outbound` runs -/// the ranged skip + created gate in the job). pub(crate) fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { let row = Arc::new(row.clone()); for viewer in melee_audience(view, shard, &row) { @@ -1006,8 +864,6 @@ pub(crate) fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) } } -/// An engagement row was removed → ATTACKSTOP / the owner-only CANCEL_AUTO_REPEAT -/// (`melee_disengage_outbound`, keyed on each viewer's own guid). pub(crate) fn melee_disengaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { let row = Arc::new(row.clone()); for viewer in melee_audience(view, shard, &row) { @@ -1019,7 +875,6 @@ pub(crate) fn melee_disengaged(view: &WorldView, shard: ShardId, row: &MeleeAtta } } -/// Aura descriptors follow the target's AOI; channel timers also reach the caster. pub(crate) fn aura_audience(view: &WorldView, shard: ShardId, row: &Aura) -> Vec> { let key = view .spatial @@ -1032,8 +887,6 @@ pub(crate) fn aura_audience(view: &WorldView, shard: ShardId, row: &Aura) -> Vec ) } -/// An aura appeared → per viewer: array sync + self-only packets + the stealth HIDE transition -/// (`aura_insert_outbound`, all gates inside the job against that viewer's own state). pub(crate) fn aura_applied( view: &Arc, coord: &Coordinator, @@ -1059,7 +912,6 @@ pub(crate) fn aura_applied( } } -/// An aura changed → per viewer: array sync + refresh-only duration + self-only packets. pub(crate) fn aura_updated( view: &Arc, coord: &Coordinator, @@ -1084,8 +936,6 @@ pub(crate) fn aura_updated( } } -/// An aura expired/was removed → per viewer: array sync + self-only packets + the stealth REVEAL -/// transition. pub(crate) fn aura_removed( view: &Arc, coord: &Coordinator, @@ -1125,8 +975,6 @@ pub(crate) fn cast_audience( ) } -/// A cast event landed → the full cast-lock sequence per viewer (`cast_event_outbound`: the -/// caster-private branches key on each viewer's own guid inside the job). pub(crate) fn cast_event_appeared(view: &WorldView, shard: ShardId, row: &SpellCastEvent) { let row = Arc::new(row.clone()); for viewer in cast_audience(view, shard, &row) { @@ -1152,7 +1000,6 @@ pub(crate) fn impact_audience( ) } -/// A projectile impact landed → the floating damage number per viewer that can see the caster. pub(crate) fn impact_appeared(view: &WorldView, shard: ShardId, row: &SpellImpactEvent) { let row = Arc::new(row.clone()); for viewer in impact_audience(view, shard, &row) { @@ -1198,9 +1045,7 @@ pub(crate) enum SpellCastEventKind { } impl SpellCastEventKind { - /// Decode the append-only Module code. Kind zero is a row from the old schema and keeps the - /// former field-based order. An unknown nonzero kind is newer or damaged data, so guessing a - /// client packet would be unsafe. + // Kind zero uses the old schema's field-based ordering; unknown nonzero kinds must drop. pub(crate) fn decode(row: &SpellCastEvent) -> Option { match row.kind { 0 if row.is_interrupted => Some(Self::Interrupt), @@ -1230,9 +1075,6 @@ impl SpellCastEventKind { pub(crate) mod tests { use super::*; use lyracore_shared::spell::taxonomy::SPEED_MOVE; - /// A minimal `Aura` row for the packet-builder tests: `target_guid`/`eff_kind`/`eff_p_0`/`amount` - /// are the fields those builders branch on; `applied_at`/`expires_at` (raw micros) drive the - /// duration math. Everything else is a fixed, inert value. pub(crate) fn aura( target_guid: u64, eff_kind: u8, @@ -1279,7 +1121,6 @@ pub(crate) mod tests { #[test] fn aura_duration_packet_pins_the_remaining_window_for_self_only() { let self_guid = 1u64; - // A 5-second remaining window on the player's OWN aura, in slot 3. let mine = aura(self_guid, 0xA1, 1, 10, 0, 5_000_000); let out = aura_duration_packet(&mine, self_guid); let m = match out { @@ -1287,13 +1128,9 @@ pub(crate) mod tests { _ => panic!("expected Some(Outbound::One(SMSG_UPDATE_AURA_DURATION))"), }; assert_eq!(m, codec::build_aura_duration(3, 5000)); - // A peer's aura (not self) never gets a duration packet — the slot only indexes the - // viewer's OWN aura array. assert!(aura_duration_packet(&mine, self_guid + 1).is_none()); - // The infinite-duration sentinel (a toggle aura) never gets a timer packet. let toggle = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); assert!(aura_duration_packet(&toggle, self_guid).is_none()); - // A non-positive remaining window (expires_at <= applied_at) emits nothing. let expired = aura(self_guid, 0xA1, 1, 10, 5_000_000, 5_000_000); assert!(aura_duration_packet(&expired, self_guid).is_none()); } @@ -1328,7 +1165,6 @@ pub(crate) mod tests { #[test] fn run_speed_packet_uses_the_strongest_move_bonus_on_self_only() { let self_guid = 1u64; - // Ghost and Wisp Spirit coexist, but only the stronger run bonus applies. let a1 = aura(self_guid, 0xA4, 0, 25, 0, i64::MAX); let a2 = aura(self_guid, 0xA4, 0, 50, 0, i64::MAX); let current = vec![a1.clone(), a2.clone()]; @@ -1338,10 +1174,8 @@ pub(crate) mod tests { _ => panic!("expected Some(Outbound::One(SMSG_FORCE_RUN_SPEED_CHANGE))"), }; assert_eq!(*m, codec::build_force_run_speed(self_guid, 10.5)); - // A non-move-speed change on self spams nothing (ordinary buffs/debuffs don't trigger this). let unrelated = aura(self_guid, 0xA1, 1, 10, 0, i64::MAX); assert!(run_speed_packet(current.clone().into_iter(), &unrelated, self_guid).is_none()); - // A move-speed change on a DIFFERENT guid (a peer) never triggers self's packet. let peer_changed = aura(self_guid + 1, 0xA4, 0, 25, 0, i64::MAX); assert!(run_speed_packet(current.into_iter(), &peer_changed, self_guid).is_none()); } diff --git a/gateway/src/codec/combat/subscriptions/packet_tests.rs b/gateway/src/codec/combat/subscriptions/packet_tests.rs index 1d8aa0441..6ebe2c683 100644 --- a/gateway/src/codec/combat/subscriptions/packet_tests.rs +++ b/gateway/src/codec/combat/subscriptions/packet_tests.rs @@ -118,7 +118,6 @@ fn melee_swing_relays_damage_then_stops_on_a_killing_blow() { const HEROIC_STRIKE_CASTER: u64 = 7; -/// The module's `CAST_FAIL_NO_POWER`. const NO_POWER_CODE: u8 = 1; fn spell_cast_event(kind: u8) -> SpellCastEvent { @@ -151,9 +150,6 @@ fn spell_cast_event(kind: u8) -> SpellCastEvent { } } -/// An interruption signal row for `spell_id` cast by `HEROIC_STRIKE_CASTER`, carrying -/// `failure_reason`. Every other field stays at the module baseline's zero, which is what such a -/// row really looks like on the wire. fn interrupted_cast(spell_id: u32, failure_reason: u8) -> SpellCastEvent { SpellCastEvent { spell_id, @@ -296,9 +292,6 @@ fn unknown_spell_cast_event_kind_emits_no_packet() { assert!(cast_event_outbound(HEROIC_STRIKE_CASTER, &row).is_empty()); } -/// A queued strike that could not pay its cost at the swing: the caster gets the cast-bar -/// teardown and then the failed cast result naming the queued spell with the vanilla Not Enough -/// Power reason (0x4D). No SMSG_SPELL_GO — the strike never fired. #[test] fn a_deferred_power_failure_relays_the_teardown_then_the_failed_cast_result() { let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(78, NO_POWER_CODE)); @@ -314,14 +307,12 @@ fn a_deferred_power_failure_relays_the_teardown_then_the_failed_cast_result() { match &out[1] { Outbound::Raw { opcode, body } => { assert_eq!(*opcode, 0x0130); - // spell(u32 LE) + CAST_FAILED(0x02) + SPELL_FAILED_NO_POWER(0x4D). assert_eq!(body, &vec![78, 0, 0, 0, 0x02, 0x4D]); } _ => panic!("expected the raw failed cast result second"), } } -/// The failed cast result is caster-private: a bystander watching the same row gets nothing. #[test] fn a_bystander_sees_neither_half_of_a_deferred_power_failure() { let out = cast_event_outbound( @@ -331,8 +322,6 @@ fn a_bystander_sees_neither_half_of_a_deferred_power_failure() { assert!(out.is_empty(), "both messages are caster-private"); } -/// An interruption with no concrete reason (a damaged mid-cast timed spell) still sends the -/// teardown alone — the cast result belongs to a failure the server can name. #[test] fn an_ordinary_interruption_still_sends_the_teardown_alone() { let out = cast_event_outbound(HEROIC_STRIKE_CASTER, &interrupted_cast(133, 0)); @@ -345,31 +334,20 @@ fn an_ordinary_interruption_still_sends_the_teardown_alone() { #[test] fn stealth_both_inserts_hide_both_deletes_reveal_created_dedupes() { - // A Stealth cast applies TWO A_STEALTH auras and the SDK fires both inserts AFTER applying the - // delta, so both see post-count 2 -> both HIDE (the per-viewer `created` set makes the 2nd a - // no-op at the call site). A break removes both -> two deletes seeing post-count 0 -> both REVEAL - // (created-deduped to one). The decision is purely "stealthed -> Hide, not stealthed -> Reveal". assert_eq!(stealth_action(true, 1), StealthAction::Hide); assert_eq!(stealth_action(true, 2), StealthAction::Hide); assert_eq!(stealth_action(true, 3), StealthAction::Hide); - assert_eq!(stealth_action(false, 1), StealthAction::None); // still stealthed after one of two deletes + assert_eq!(stealth_action(false, 1), StealthAction::None); assert_eq!(stealth_action(false, 0), StealthAction::Reveal); - assert_eq!(stealth_action(false, 2), StealthAction::None); // over-count delete does not reveal - // An insert can never leave count 0 (it adds one) -> defensively a no-op. + assert_eq!(stealth_action(false, 2), StealthAction::None); assert_eq!(stealth_action(true, 0), StealthAction::None); } #[test] fn stealth_dedup_composes_with_a_real_hashset_exactly_once() { - // `stealth_visibility` pairs `stealth_action`'s Hide/Reveal decision with `created`'s - // remove()/insert() return value (only act if the set's membership actually changed) — this - // composes that SAME discipline over a real `std::collections::HashSet` (not a mock) to - // pin the "exactly once" guarantee a Stealth cast's two A_STEALTH inserts/deletes rely on. let target = 99u64; - let mut created: HashSet = HashSet::from([target]); // starts visible + let mut created: HashSet = HashSet::from([target]); let mut destroys = 0; - // A Stealth cast applies TWO A_STEALTH auras; the SDK fires both inserts AFTER applying the - // whole delta, so both see post-count 2 -> both decide Hide. for count in [2, 2] { if stealth_action(true, count) == StealthAction::Hide && created.remove(&target) { destroys += 1; @@ -377,7 +355,6 @@ fn stealth_dedup_composes_with_a_real_hashset_exactly_once() { } assert_eq!(destroys, 1, "two HIDE decisions must destroy exactly once"); assert!(!created.contains(&target)); - // A break removes both auras; both deletes see post-count 0 -> both decide Reveal. let mut creates = 0; for count in [0, 0] { if stealth_action(false, count) == StealthAction::Reveal && created.insert(target) { diff --git a/gateway/src/codec/corpse.rs b/gateway/src/codec/corpse.rs index 51e4c228c..ebe7d6b89 100644 --- a/gateway/src/codec/corpse.rs +++ b/gateway/src/codec/corpse.rs @@ -1,9 +1,5 @@ -//! Corpse wire mapping: the `MSG_CORPSE_QUERY` reply + the corpse CREATE_OBJECT, plus the -//! Resurrection accept-prompt. - use super::*; -/// A corpse row as the gateway reads it (`game_corpse`), flattened for the CORPSE CREATE_OBJECT. #[derive(Clone, Debug, Default)] pub struct CorpseView { pub guid: u64, @@ -15,14 +11,9 @@ pub struct CorpseView { pub display_id: u32, pub bytes_1: u32, pub bytes_2: u32, - /// Body-decayed-to-bones state (`game_corpse.is_bones`). Drives the - /// `CORPSE_FLAG_BONE` (0x01) vs the normal body flag (0x04) in `build_corpse_create_object`. pub is_bones: bool, } -/// Build the `MSG_CORPSE_QUERY` reply: tells the client where the player's corpse is so it -/// draws the map marker and offers "Reclaim Corpse" when the ghost is near it. `None` → `NotFound`. -/// Same map for `map` (ghost map) and `corpse_map` — we have no cross-map corpses (single zone). pub fn build_corpse_query_response( loc: Option<(u32, f32, f32, f32)>, ) -> Result { @@ -39,16 +30,10 @@ pub fn build_corpse_query_response( } } -/// Build the CREATE_OBJECT for a player corpse: a CORPSE-type object with a stationary -/// position (`HasPosition`, UPDATEFLAG 0x40) and the `UpdateCorpse` descriptors (owner, position, -/// display, bytes) so the client renders the dead body. Relayed on `game_corpse` insert; the matching -/// `SMSG_DESTROY_OBJECT` on delete (reclaim/decay) reuses `build_destroy_object`. pub fn build_corpse_create_object(corpse: &CorpseView) -> SMSG_UPDATE_OBJECT { let (b1a, b1b, b1c, b1d) = unpack4(corpse.bytes_1); let (b2a, b2b, b2c, b2d) = unpack4(corpse.bytes_2); - // Defensive floor against a null-model crash: CORPSE_FIELD_DISPLAY_ID must be a valid - // display or the 5875 client dereferences a null model. The owner's native display drives it (49 - // for the current Human-Male fixture; race-derived once login is DBC-sourced), but never send 0. + // A zero display id makes build 5875 dereference a null corpse model. let display_id = if corpse.display_id == 0 { 49 } else { @@ -65,11 +50,7 @@ pub fn build_corpse_create_object(corpse: &CorpseView) -> SMSG_UPDATE_OBJECT { .set_corpse_facing(corpse.orientation) .set_corpse_bytes_1(b1a, b1b, b1c, b1d) .set_corpse_bytes_2(b2a, b2b, b2c, b2d) - // CORPSE_FLAG_BONE (0x01) once the body has decayed to bones (no longer a - // reclaim target — see `Corpse::is_bones`/`reclaim_corpse`); otherwise CORPSE_FLAG_UNK2 - // (0x04), what every real vanilla body carries. UNVERIFIED-until-observed: no live client has - // confirmed the bones render actually reads this bit (flag semantics inferred from mangos' - // `CORPSE_FLAG_BONE` naming, not from watching a real client render decayed bones). + // CORPSE_FLAG_BONE is 0x01; an ordinary body carries 0x04. .set_corpse_flags(if corpse.is_bones { 0x01 } else { 0x04 }) .finalize(); let update_flag = MovementBlock_UpdateFlag::empty() @@ -93,11 +74,6 @@ pub fn build_corpse_create_object(corpse: &CorpseView) -> SMSG_UPDATE_OBJECT { } } -/// Build the `SMSG_RESURRECT_REQUEST` offer sent to a dead ally: names the CASTER (so the -/// client's " requests to resurrect you" prompt reads correctly) and the guid the client echoes -/// back in `CMSG_RESURRECT_RESPONSE`. `player = true` unconditionally — E_RESURRECT only ever offers this -/// to a player target (the module gates non-player targets out before the row is even inserted), so the -/// gateway never has to special-case an NPC-cast revive here. pub fn build_resurrect_request(caster_guid: u64, caster_name: String) -> SMSG_RESURRECT_REQUEST { SMSG_RESURRECT_REQUEST { guid: Guid::new(caster_guid), diff --git a/gateway/src/codec/corpse/subscriptions.rs b/gateway/src/codec/corpse/subscriptions.rs index 18eecad46..b2801df32 100644 --- a/gateway/src/codec/corpse/subscriptions.rs +++ b/gateway/src/codec/corpse/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::views::corpse_view; @@ -16,9 +14,6 @@ pub(crate) fn relay_corpse_create(tx: &SessionTx, self_guid: u64, row: &Corpse) } } -/// The corpse CREATE body as outbound packets — shared by the per-player relay, the login -/// resident sweep, and the shared-dispatch twin. The owner's reclaim-delay -/// packet rides inside, keyed on the VIEWER's guid. pub(crate) fn corpse_create_outbound(self_guid: u64, row: &Corpse) -> Vec { let m = codec::build_corpse_create_object(&corpse_view(row.clone())); let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( @@ -36,8 +31,6 @@ pub(crate) fn corpse_create_outbound(self_guid: u64, row: &Corpse) -> Vec Vec { let m = codec::build_corpse_create_object(&corpse_view(row.clone())); vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( @@ -45,8 +38,6 @@ pub(crate) fn relay_corpse_update(row: &Corpse) -> Vec { ))] } -/// Resurrect prompt: the packet body both legs run. Audience: the -/// offer's target, resolved by the caller. pub(crate) fn resurrect_request_outbound(row: &ResurrectRequest) -> Vec { let m = codec::build_resurrect_request(row.caster_guid, row.caster_name.clone()); vec![Outbound::One(ServerOpcodeMessage::SMSG_RESURRECT_REQUEST( @@ -54,8 +45,6 @@ pub(crate) fn resurrect_request_outbound(row: &ResurrectRequest) -> Vec Vec { let m = codec::build_self_res_spell_values(character_guid, spell_id); vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( @@ -63,9 +52,6 @@ pub(crate) fn self_res_option_outbound(character_guid: u64, spell_id: u32) -> Ve ))] } -/// A player corpse appeared → CREATE for every same-instance viewer on the row's shard, with the -/// owner's reclaim-delay packet riding inside (`corpse_create_outbound`, the same body the -/// per-player relay and the login resident sweep use). pub(crate) fn corpse_appeared(view: &WorldView, shard: ShardId, row: &Corpse) { let row = Arc::new(row.clone()); for viewer in viewers_on_shard(view, shard) { @@ -83,8 +69,6 @@ pub(crate) fn corpse_appeared(view: &WorldView, shard: ShardId, row: &Corpse) { } } -/// A corpse's state changed (body → bones decay) → re-emit the CREATE so the client re-renders it, -/// same instance gate as the insert leg (the re-emit must not leak across instances either). pub(crate) fn corpse_changed(view: &WorldView, shard: ShardId, row: &Corpse) { let row = Arc::new(row.clone()); for viewer in viewers_on_shard(view, shard) { @@ -101,8 +85,6 @@ pub(crate) fn corpse_changed(view: &WorldView, shard: ShardId, row: &Corpse) { } } -/// A corpse was reclaimed/decayed → DESTROY to every viewer on the shard. Ungated like the -/// per-player delete leg (a DESTROY for an unknown guid is a client no-op). pub(crate) fn corpse_vanished(view: &WorldView, shard: ShardId, row: &Corpse) { let guid = row.guid; for viewer in viewers_on_shard(view, shard) { @@ -112,7 +94,6 @@ pub(crate) fn corpse_vanished(view: &WorldView, shard: ShardId, row: &Corpse) { } } -/// A resurrect offer landed → SMSG_RESURRECT_REQUEST to the offer's TARGET and nobody else. pub(crate) fn resurrect_offered(view: &WorldView, shard: ShardId, row: &ResurrectRequest) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.target_guid)) else { return; @@ -129,8 +110,6 @@ pub(crate) fn resurrect_offered(view: &WorldView, shard: ShardId, row: &Resurrec }); } -/// A Self-Resurrection Option changed → the owner's `PLAYER_SELF_RES_SPELL` (0 clears it), and -/// nobody else's. pub(crate) fn self_res_option_changed( view: &WorldView, shard: ShardId, diff --git a/gateway/src/codec/corpse/subscriptions/audience_tests.rs b/gateway/src/codec/corpse/subscriptions/audience_tests.rs index 8783e2d1c..d541879a5 100644 --- a/gateway/src/codec/corpse/subscriptions/audience_tests.rs +++ b/gateway/src/codec/corpse/subscriptions/audience_tests.rs @@ -5,7 +5,6 @@ use crate::stdb::world_view::WorldView; use crate::world::{Outbound, SessionTx}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// The owner's `PLAYER_SELF_RES_SPELL` from one queued VALUES packet. fn self_res_spell_of(outbound: Vec) -> Option { use wow_world_messages::vanilla::{Object, UpdateMask}; let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(packet))] = outbound.as_slice() diff --git a/gateway/src/codec/duel.rs b/gateway/src/codec/duel.rs index 7e27d79e5..604f4e476 100644 --- a/gateway/src/codec/duel.rs +++ b/gateway/src/codec/duel.rs @@ -1,9 +1,5 @@ -//! Duel-specific sparse player descriptor updates. - use super::*; -/// Set only the Duel descriptor fields that changed. `arbiter` occupies two u32 slots; `team` is -/// one. Neither update carries `OBJECT_FIELD_TYPE`, preserving the vanilla partial-VALUES rule. pub fn build_duel_player_values( guid: u64, arbiter: Option, diff --git a/gateway/src/codec/duel/subscriptions.rs b/gateway/src/codec/duel/subscriptions.rs index 0cbb1a279..de402058c 100644 --- a/gateway/src/codec/duel/subscriptions.rs +++ b/gateway/src/codec/duel/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::{CellKey, ShardId}; @@ -11,8 +9,6 @@ use std::sync::Arc; use std::time::Duration; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Project one Duel lifecycle edge for one recipient. The module inserts one row per participant, -/// so this function deliberately builds one copy of each typed protocol packet per row. pub(crate) fn duel_event_outbound( row: &DuelEvent, template: Option<&codec::GameObjectTemplateView>, @@ -75,9 +71,7 @@ pub(crate) fn duel_event_outbound( } event_kind::COUNTDOWN => vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COUNTDOWN( SMSG_DUEL_COUNTDOWN { - // The library labels this Duration as seconds, but writes `as_secs()` directly. - // Vanilla's field is milliseconds and CMaNGOS writes 3000, so 3000 seconds here - // is the typed API spelling that produces the correct u32 wire value. + // The binding writes as_secs(), but vanilla reads the value as milliseconds. time: Duration::from_secs(3_000), }, ))], @@ -112,8 +106,6 @@ pub(crate) fn duel_event_outbound( } } -/// The one winner announcement for a completed Duel. Participant relays include it after -/// `SMSG_DUEL_COMPLETE`; the shared view also fans it to nearby non-participants. pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { use lyracore_shared::duel::completion_kind; use wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason; @@ -131,8 +123,7 @@ pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { completion_kind::FLED => DuelWinnerReason::Fled, _ => return Vec::new(), }; - // `opponent_name` is the first CString on the wire and carries the WINNER: mangos sends - // this packet from the loser, announcing its opponent as victor. + // The first CString, named opponent_name by the binding, carries the winner. vec![Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER( Box::new(SMSG_DUEL_WINNER { reason, @@ -142,8 +133,6 @@ pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { ))] } -/// A Duel lifecycle edge landed for one participant. Private packets go only to that participant; -/// the initiator-addressed completion row also drives the one nearby winner announcement. pub(crate) fn duel_event_appeared( view: &WorldView, coord: &Coordinator, @@ -169,8 +158,6 @@ pub(crate) fn duel_event_appeared( }); } - // The module writes two participant rows. Use the initiator-addressed copy as the one AOI fan - // trigger, and exclude both private recipients so each client receives one winner packet. if row.kind != lyracore_shared::duel::event_kind::COMPLETE || row.recipient_guid != row.initiator_guid || row.winner_guid == 0 @@ -190,8 +177,6 @@ pub(crate) fn duel_event_appeared( } } -/// The AOI bystander leg of the winner announcement: both participants get their own copy, so the -/// fan-out must skip them (and the unregistered zero guid). pub(crate) fn duel_winner_audience( viewer_guid: u64, initiator_guid: u64, diff --git a/gateway/src/codec/duel/subscriptions/packet_tests.rs b/gateway/src/codec/duel/subscriptions/packet_tests.rs index 4995e47f5..2029d52c0 100644 --- a/gateway/src/codec/duel/subscriptions/packet_tests.rs +++ b/gateway/src/codec/duel/subscriptions/packet_tests.rs @@ -102,7 +102,6 @@ fn duel_winner_projects_names_and_the_terminal_reason() { Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(_)), Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)), .. - // `opponent_name` is written first on the wire and must carry the winner. ] if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Won && message.opponent_name == "Winner" && message.initiator_name == "Loser" diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index 5a5f5758d..1ce2b1d3b 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -1,35 +1,19 @@ -//! Entity (player/creature) wire mapping: `EntityView`, the CREATE_OBJECT2 self/peer spawn, the -//! post-login SMSG sequence, and destroy. Pure code-motion out of `mod.rs`. - use super::*; use std::time::{SystemTime, UNIX_EPOCH}; -/// A minimal mirror of the entity-row fields the encoder needs. The gateway fills this from -/// the `game_world_entity` subscription cache; it is decoupled from the module's table type. -/// -/// Character fields supply the saved watched faction, hearth point and fallback zone at login. #[derive(Clone, Debug, Default)] pub struct EntityView { pub guid: u64, pub map_id: u32, - /// The partition half of the `(map_id, instance_id)` address: 0 for the open world, the - /// live instance id inside a dungeon copy. Carried here because `enter_world` needs it to scope - /// the AOI subscription, and the raw `game_world_entity` row is not otherwise in scope there — - /// `player_login` hands back a view, not the row. pub instance_id: u64, pub zone_id: u32, - /// Saved Character selection, sent only to its own client. None clears the watched bar. pub watched_faction_index: Option, pub x: f32, pub y: f32, pub z: f32, pub orientation: f32, pub last_move_ms: u32, - /// Live MovementFlags (u32) — stamped into the CREATE so a peer spawns in its current move state. pub movement_flags: u32, - /// GM playtest run-speed multiplier in basis points (10_000 = 1.0×). The CREATE movement block - /// carries the derived speed so an entity rebuild (for example, a cross-map teleport) preserves - /// the client's movement prediction without waiting for an UPDATE-only speed relay. pub run_speed_mult_bp: u32, pub type_mask: u32, pub entry: u32, @@ -40,14 +24,11 @@ pub struct EntityView { pub max_power: u32, pub level: u32, pub faction_template: u32, - /// UNIT_FIELD_TARGET — who this unit has selected (relayed so peers see the target ring / assist). pub target_guid: u64, pub unit_bytes_0: u32, pub display_id: u32, pub native_display_id: u32, pub unit_flags: u32, - /// `UNIT_FIELD_MOUNTDISPLAYID`; the shared presentation for a taxi flight and a land mount. The - /// module decides which one owns it and projects the winner onto this field. pub mount_display_id: u32, pub base_attack_time_ms: u32, pub dynamic_flags: u32, @@ -57,13 +38,10 @@ pub struct EntityView { pub player_flags: u32, pub xp: u32, pub next_level_xp: u32, - pub money: u32, // PLAYER_FIELD_COINAGE for a player; corpse loot for a creature - pub unit_bytes_1: u32, // UNIT_FIELD_BYTES_1; byte 3 carries UNIT_VIS_FLAG_GHOST while a ghost - /// `UNIT_FIELD_BYTES_2`; byte 0 is the sheath state (0 stowed / 1 melee drawn / 2 ranged drawn). - /// Without it in the CREATE, everyone entering AOI range renders this unit permanently unarmed - /// no matter what it is holding. + pub money: u32, + pub unit_bytes_1: u32, // UNIT_FIELD_BYTES_1 byte 3 carries UNIT_VIS_FLAG_GHOST. + /// UNIT_FIELD_BYTES_2 byte 0 is sheath state: 0 stowed, 1 melee, 2 ranged. pub unit_bytes_2: u32, - // The five base attributes (UNIT_FIELD_STAT0..4) for the character sheet; 0 for a creature. pub strength: u32, pub agility: u32, pub stamina: u32, @@ -76,44 +54,25 @@ pub struct EntityView { pub sheet_ranged_ap: u32, pub sheet_ranged_dmg_min: u32, pub sheet_ranged_dmg_max: u32, - pub npc_flags: u32, // UNIT_NPC_FLAGS — gossip/vendor/etc. for a creature; 0 for a player - /// Owning player for a SUMMONED unit (warlock pet) — drives UNIT_FIELD_SUMMONEDBY/CREATEDBY on - /// the pet CREATE so the 5875 client can bind it to the owner's pet frame. 0 = wild. + pub npc_flags: u32, pub owner_guid: u64, - /// UNIT_FIELD_RESISTANCES[0] — the EFFECTIVE armor shown on the character sheet (base + armor auras + - /// gear; see `stdb::armor::effective_armor`). Defaults to the server-authoritative BASE armor (the - /// module's agility*2, also what combat mitigates with) for peers/creatures, where the extra fold isn't - /// worth it (your sheet shows only your own armor); the self-login CREATE overrides it with the - /// gear-folded value so the operator sees real worn armor on relog (auras self-correct via the on_aura - /// relay). Matches the module's combat `effective_armor`, so the sheet equals the mitigation value. pub effective_armor: u32, - /// Holy, Fire, Nature, Frost, Shadow and Arcane resistance in descriptor order. pub magic_resistances: [u32; 6], - /// Persisted hearthstone / bind-point from `game_character.home_*`. Zero-initialised for - /// creature rows (which never build a `SMSG_BINDPOINTUPDATE`). pub home_map: u32, pub home_zone: u32, pub home_x: f32, pub home_y: f32, pub home_z: f32, - /// The Guild Projection: PLAYER_GUILDID and PLAYER_GUILDRANK, read from Realm-core membership - /// when the Gateway encodes a player. 0 and 0 for a Character in no Guild and for a creature. pub guild_id: u32, pub guild_rank: u32, } -/// Whether a create block is for the player's own character (SELF flag set) or a peer the -/// observer is seeing. Both use `CREATE_OBJECT2`; SELF-ness lives in the movement flags. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CreateKind { SelfPlayer, Peer, } -/// Map an equipment-slot ordinal (0..=18) to its `VisibleItemIndex` — the `PLAYER_VISIBLE_ITEM[slot]` -/// descriptor that renders gear on the 3D character model. `None` for a non-equipment slot -/// (equipped bags 19..22, backpack 23..38, bank, …): those carry an inventory-slot guid but nothing -/// model-visible. The index is 0-based and matches the equipment-slot ordinal 1:1 (15 = MAINHAND). pub(crate) fn visible_item_index(slot: u8) -> Option { use VisibleItemIndex::*; Some(match slot { @@ -140,9 +99,6 @@ pub(crate) fn visible_item_index(slot: u8) -> Option { }) } -/// The `PLAYER_VISIBLE_ITEM` value of an equipped item: entry, Random Property and the permanent -/// enchantment in the first enchant word. The second word holds a temporary enchantment, which no -/// item carries. A default view yields the empty value that clears the slot. pub(crate) fn visible_item_of(item: &ItemInstanceView) -> VisibleItem { VisibleItem { item: item.entry, @@ -152,9 +108,7 @@ pub(crate) fn visible_item_of(item: &ItemInstanceView) -> VisibleItem { } } -/// The per-class STATIC skill base (spec schools + weapons + language) — the login CREATE's slot -/// layout starts from this. Shared with the live skill relay so a line's PLAYER_SKILL_INFO -/// slot NEVER moves mid-session (the client keys the pane rows on the slot index). +/// Skill descriptor slots must stay stable throughout the World Session. pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { match class_b { 1 => &[ @@ -169,7 +123,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 8 => &[ - // Mage (Skill::Fire, 1, 1), (Skill::Frost, 1, 1), (Skill::Arcane, 1, 1), @@ -181,7 +134,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 5 => &[ - // Priest (Skill::Holy, 1, 1), (Skill::Discipline, 1, 1), (Skill::Shadow, 1, 1), @@ -193,7 +145,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 9 => &[ - // Warlock (Skill::Affliction, 1, 1), (Skill::Demonology, 1, 1), (Skill::Destruction, 1, 1), @@ -205,7 +156,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 11 => &[ - // Druid (Skill::Balance, 1, 1), (Skill::FeralCombat, 1, 1), (Skill::Restoration, 1, 1), @@ -217,7 +167,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 2 => &[ - // Paladin (all 3 spec schools so an aura/spell finds its tab) (Skill::Holy, 1, 1), (Skill::Protection, 1, 1), (Skill::Retribution, 1, 1), @@ -228,7 +177,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 3 => &[ - // Hunter (Skill::BeastMastery, 1, 1), (Skill::Marksmanship, 1, 1), (Skill::Survival, 1, 1), @@ -239,7 +187,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 4 => &[ - // Rogue (Skill::Assassination, 1, 1), (Skill::Combat, 1, 1), (Skill::Subtlety, 1, 1), @@ -250,7 +197,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], 7 => &[ - // Shaman (Skill::ElementalCombat, 1, 1), (Skill::Enhancement, 1, 1), (Skill::Restoration, 1, 1), @@ -261,7 +207,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { (Skill::LanguageCommon, 300, 300), ], _ => &[ - // unknown class: martial fallback (Attack + weapons; no spell tab) (Skill::Unarmed, 1, 5), (Skill::Swords, 1, 5), (Skill::Maces, 1, 5), @@ -274,9 +219,6 @@ pub fn class_static_skills(class_b: u8) -> &'static [(Skill, u16, u16)] { } } -/// The deterministic PLAYER_SKILL_INFO slot layout: the class static base, overridden by any -/// matching learned row, then every remaining learned line APPENDED — index i IS the descriptor -/// slot. Used by the login CREATE and the live relay's slot map; both MUST derive from this one fn. pub fn skill_slot_layout(class_b: u8, learned: &[(u32, u16, u16)]) -> Vec<(Skill, u16, u16)> { let mut lines: Vec<(Skill, u16, u16)> = class_static_skills(class_b).to_vec(); for &(line, cur, max) in learned { @@ -291,39 +233,26 @@ pub fn skill_slot_layout(class_b: u8, learned: &[(u32, u16, u16)]) -> Vec<(Skill lines } -/// Build the `SMSG_UPDATE_OBJECT` CREATE_OBJECT2 block for an entity's self-spawn or first -/// sighting. The `wow_world_messages` update-mask builder owns the descriptor bit indices, the -/// packed GUID and the movement block, so this only maps the row's values to typed setters. -/// -/// Each `inventory` view sets the player's `PLAYER_FIELD_INV_SLOT_*` pointer for its slot. An -/// equipment slot (0..=18) also sets `PLAYER_VISIBLE_ITEM`, so the character model renders the gear -/// and the paperdoll tooltip shows its enchant. Pass `&[]` for a creature. pub fn build_create_object( entity: &EntityView, kind: CreateKind, inventory: &[ItemInstanceView], learned_skills: &[(u32, u16, u16)], ) -> Result { - // race/class/gender/power are packed in unit_bytes_0 for both players and creatures (a creature - // seeds dummy-but-valid Human/Warrior/Male/Mana — the bytes are non-rendering for a Unit). let (race_b, class_b, gender_b, power_b) = unpack4(entity.unit_bytes_0); let race = Race::try_from(race_b).map_err(|_| anyhow!("invalid race {race_b}"))?; let class = Class::try_from(class_b).map_err(|_| anyhow!("invalid class {class_b}"))?; let gender = Gender::try_from(gender_b).map_err(|_| anyhow!("invalid gender {gender_b}"))?; let power = Power::try_from(power_b).map_err(|_| anyhow!("invalid power {power_b}"))?; - // Branch on the PLAYER bit: a creature (type Unit) gets a Unit update mask + ObjectType::Unit; - // a player gets the full player descriptor block. The game_world_entity row carries both shapes. let is_player = entity.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0; let (object_type, mask2) = if is_player { let (skin, face, hair_style, hair_color) = unpack4(entity.player_bytes); let (facial_hair, pb2_b, pb2_c, pb2_d) = unpack4(entity.player_bytes_2); let (pb3_a, pb3_b, pb3_c, pb3_d) = unpack4(entity.player_bytes_3); - let (ub1_a, ub1_b, ub1_c, ub1_d) = unpack4(entity.unit_bytes_1); // ghost vis bit in byte 3 - let (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 + let (ub1_a, ub1_b, ub1_c, ub1_d) = unpack4(entity.unit_bytes_1); + let (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); - // Melee and ranged paperdoll values are stored Module projections. CREATE reads them - // unchanged so it agrees with later partial sheet updates. let mut builder = UpdatePlayer::builder() .set_object_guid(Guid::new(entity.guid)) .set_object_scale_x(entity.scale_x) @@ -337,9 +266,8 @@ pub fn build_create_object( .set_unit_nativedisplayid(entity.native_display_id as i32) .set_unit_flags(entity.unit_flags as i32) .set_unit_baseattacktime(entity.base_attack_time_ms as i32) - .set_unit_bytes_1(ub1_a, ub1_b, ub1_c, ub1_d) // ghost render bit - // gtker NAMES this setter's params after PLAYER_BYTES_2 (facial hair/rest state) — wrong - // field, right index (164). Byte 0 is the sheath state; the names are noise. + .set_unit_bytes_1(ub1_a, ub1_b, ub1_c, ub1_d) + // gtker misnames these parameters; field 164 is UNIT_FIELD_BYTES_2, sheath in byte 0. .set_unit_bytes_2(ub2_a, ub2_b, ub2_c, ub2_d) .set_player_features(skin, face, hair_style, hair_color) .set_player_bytes_2(facial_hair, pb2_b, pb2_c, pb2_d) @@ -347,33 +275,15 @@ pub fn build_create_object( .set_player_flags(entity.player_flags as i32) .set_player_xp(entity.xp as i32) .set_player_next_level_xp(entity.next_level_xp as i32) - .set_player_field_coinage(entity.money as i32) // purse in copper - // Free talent points (PLAYER_CHARACTER_POINTS1) so the talent pane shows a non-zero count - // Shared talent calculation: one point per level from level 10 - // (= level - 9), clamped at 0 below 10. NOTE: this does NOT yet subtract points already spent - // (the gateway doesn't read game_character_talent here) — it's a display of points EARNED, exact - // for a fresh character and approximate after spending. Learning is reducer-gated server-side; - // the in-client *clickable* pane additionally needs talent data aligned to the client's - // Talent.dbc (real ids + rank-spells) — a separate follow-up, not wired here. + .set_player_field_coinage(entity.money as i32) .set_player_character_points1( lyracore_shared::talent::points_available(entity.level, 0) as i32, ) - // The five base attributes (UNIT_FIELD_STAT0..4) so the character sheet shows real - // numbers (set at login from the cmangos curve; 0 until that data is loaded). Part of the - // full CREATE mask — not a partial VALUES update — so no dirty_reset concern. .set_unit_strength(entity.strength as i32) .set_unit_agility(entity.agility as i32) .set_unit_stamina(entity.stamina as i32) .set_unit_intellect(entity.intellect as i32) .set_unit_spirit(entity.spirit as i32) - // EFFECTIVE armor for the character sheet (UNIT_FIELD_RESISTANCES[0], the "normal"/physical - // resistance slot, read by the paperdoll via `UnitArmor`). `effective_armor` = base (the - // module's agility*2, used for combat mitigation) + worn gear armor (folded by - // `stdb::armor::effective_armor` at the self-login call site); it equals the module's combat - // `effective_armor`, so the displayed Armor matches what mitigation uses. Login-present armor - // AURAS aren't folded here (the coordinator cache has no game_aura) — the on_aura relay pushes - // them the instant they insert. Part of the full CREATE mask (OBJECT_FIELD_TYPE belongs here), - // so no dirty_reset concern. .set_unit_normal_resistance(entity.effective_armor as i32) .set_unit_holy_resistance(entity.magic_resistances[0] as i32) .set_unit_fire_resistance(entity.magic_resistances[1] as i32) @@ -381,37 +291,23 @@ pub fn build_create_object( .set_unit_frost_resistance(entity.magic_resistances[3] as i32) .set_unit_shadow_resistance(entity.magic_resistances[4] as i32) .set_unit_arcane_resistance(entity.magic_resistances[5] as i32) - // Weapon damage for the character sheet's Main-Hand panel. gtker's UpdatePlayer has no - // default for these float/int fields, so leaving them unset makes the 5875 client read - // uninitialized floats → "-1.#IND" (NaN) in the damage/DPS tooltip. Send the derived values. + // Unset damage fields become uninitialized floats in the build 5875 paperdoll. .set_unit_attack_power(entity.sheet_ap_base as i32) .set_unit_attack_power_mods( entity.sheet_ap_mods.max(0) as u16, (-entity.sheet_ap_mods).max(0) as u16, ) - // NOTE: do NOT set UNIT_FIELD_ATTACK_POWER_MULTIPLIER — the client treats it as `1 + value`, - // so sending 1.0 DOUBLES the displayed attack power (134 → 268) and does NOT fix the paperdoll - // tooltip's "x0%"/1.#INF (that `percent` comes from a different field — still TODO). Leaving it - // unset (default 0.0 ⇒ ×1.0) keeps the attack-power readout correct. The melee-damage STAT line - // (UNIT_FIELD_MINDAMAGE/MAXDAMAGE) already displays correctly (20–22); the slot hover tooltip's - // INF is a separate cosmetic issue and combat is server-authoritative regardless. + // Attack power uses 1 + multiplier; setting 1.0 doubles displayed AP. .set_unit_mindamage(entity.sheet_dmg_min as f32) .set_unit_maxdamage(entity.sheet_dmg_max as f32) .set_unit_ranged_attack_power(entity.sheet_ranged_ap as i32) .set_unit_ranged_attack_power_mods(0, 0) .set_unit_minrangeddamage(entity.sheet_ranged_dmg_min as f32) .set_unit_maxrangeddamage(entity.sheet_ranged_dmg_max as f32) - // PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[physical] = 1.0 — THE fix for the Main-Hand tooltip's - // "1.#INF - 1.#INF x0%" / "-1.$" DPS. It's the `percent` the 5875 paperdoll DIVIDES weapon - // damage by (UnitDamage's 7th return: `displayDamage = MINDAMAGE / percent`); unset it arrives - // as 0.0 → divide-by-zero → +Inf. It's a FLOAT descriptor (a ×multiplier, default 1.0) but - // gtker's setter is typed i32, so we pass the bit pattern of 1.0f32 (0x3F800000). The typed - // setter reaches school index 0 = physical, which is exactly the melee school. (The melee - // *stat line* already read correctly from MINDAMAGE/MAXDAMAGE; this fixes the hover tooltip.) + // The paperdoll divides damage by this multiplier; zero produces infinities. .set_player_field_mod_damage_done_pct(1.0_f32.to_bits() as i32); - // Always written, 0 included: a re-CREATE of a Character the client already holds must - // clear a guild id from a removal whose relay it missed. + // Include zero to clear a removed Guild after a missed Relay. builder = builder .set_player_guildid(entity.guild_id as i32) .set_player_guildrank(entity.guild_rank as i32); @@ -423,8 +319,6 @@ pub fn build_create_object( builder = builder.set_unit_mountdisplayid(entity.mount_display_id as i32); } - // Current/max power live at the descriptor index matching the unit's power type - // (Mana=1, Rage=2, Focus=3, Energy=4, Happiness=5). let (power_cur, power_max) = (entity.power as i32, entity.max_power as i32); builder = match power_b { 1 => builder @@ -444,30 +338,6 @@ pub fn build_create_object( .set_unit_maxpower1(power_max), }; - // Skill lines — without them the spellbook has no tab to hold a class ability and the client - // throws SpellBookFrame.lua:342 "Invalid spell slot in GetSpellTexture". Vanilla routes each - // known spell to a tab via SkillLineAbility.skillLine, so the player must POSSESS that line. - // Battle Shout (6673) lives in Fury (256) — NOT Arms — so Fury is the load-bearing one; we - // send all three Warrior class lines so any class ability resolves, plus the weapon/defense - // skills (1/5 at L1) and Common (300/300). Warrior fixture only (class 1); other classes get - // none until per-character skill data exists (importer follow-up). Creatures get none. - // Additive to the full CREATE mask (where OBJECT_FIELD_TYPE correctly belongs) — not a partial - // VALUES update, so the dirty_reset discipline does not apply here. - // Per-class skill lines: a class's spell-school tabs (so its spells render a spellbook - // tab instead of the SpellBookFrame Lua crash) + its weapon/armor/defense skills + Common. We send - // ALL of a class's spec schools so any ability resolves a tab. A class with no ported spells gets a - // martial set (it can melee; no spells means no spell tab is needed). Warrior is byte-identical to - // the prior hardcoded set. Per-RACE language (Orcish for Horde) is a follow-up — Common for now. - // LIVE skills (replacing the old static Cooking-1/75 fixture): the block - // starts from the per-class STATIC base above (the spec-school/weapon/language rows the - // client needs pre-import), then the character's `game_player_skill` rows OVERRIDE any - // matching line (a trained weapon skill shows its real climb at relog) and every remaining - // learned line (Mining, Herbalism, First Aid, Cooking, …) is APPENDED at its real - // current/max — so a profession appears iff learned, and never as a phantom fixture. An - // unrecognized line id is skipped (Skill::try_from guards the enum), and the for-loop's - // `SkillInfoIndex::try_from` still guards the slot count (128 descriptor slots). This is the - // login-create snapshot; the LIVE mid-session climb relay is still deferred (the skill - // block is descriptor-wall territory — the pane refreshes on relog). let skill_lines = skill_slot_layout(class_b, learned_skills); for (i, &(skill, min, max)) in skill_lines.iter().enumerate() { if let Ok(idx) = SkillInfoIndex::try_from(i as u8) { @@ -475,8 +345,6 @@ pub fn build_create_object( builder.set_player_skill_info(SkillInfo::new(skill, 0, min, max, 0, 0), idx); } } - // An unknown slot ordinal is skipped. Both descriptors sit in the full CREATE mask, so no - // dirty_reset applies. for item in inventory { if let Ok(s) = ItemSlot::try_from(item.slot) { builder = builder.set_player_field_inv(s, Guid::new(item.guid)); @@ -487,9 +355,7 @@ pub fn build_create_object( } (ObjectType::Player, UpdateMask::Player(builder.finalize())) } else { - // Creature (Unit): no player descriptor fields. `entry` is REQUIRED so the client can issue - // CMSG_CREATURE_QUERY; without it the creature renders nameless. - let (cb2_a, cb2_b, cb2_c, cb2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 + let (cb2_a, cb2_b, cb2_c, cb2_d) = unpack4(entity.unit_bytes_2); let mut unit_builder = UpdateUnit::builder() .set_object_guid(Guid::new(entity.guid)) .set_object_entry(entity.entry as i32) @@ -504,14 +370,9 @@ pub fn build_create_object( .set_unit_nativedisplayid(entity.native_display_id as i32) .set_unit_flags(entity.unit_flags as i32) .set_unit_baseattacktime(entity.base_attack_time_ms as i32) - // Sheath state, a creature draws its weapon on engage exactly like a player, so - // this belongs on the Unit branch too, not just the player one. .set_unit_bytes_2(cb2_a, cb2_b, cb2_c, cb2_d) - .set_unit_dynamic_flags(entity.dynamic_flags as i32) // corpse/lootable bits - .set_unit_npc_flags(entity.npc_flags as i32); // gossip/vendor/questgiver/trainer icons (item 6) - // A SUMMONED unit (warlock pet) carries its owner in SUMMONEDBY/CREATEDBY — what the - // client needs to treat the unit as this player's pet (frame binding). Wild creatures skip - // both fields (byte-identical CREATE). + .set_unit_dynamic_flags(entity.dynamic_flags as i32) + .set_unit_npc_flags(entity.npc_flags as i32); if entity.owner_guid != 0 { unit_builder = unit_builder .set_unit_summonedby(Guid::new(entity.owner_guid)) @@ -523,13 +384,7 @@ pub fn build_create_object( (ObjectType::Unit, UpdateMask::Unit(unit_builder.finalize())) }; - // A Living movement block carrying the entity's CURRENT movement flags, so a peer entering AOI - // range mid-move spawns in its real state (running/strafing) rather than idle-floating (it missed - // the MSG_MOVE_START sent while out of range). Only the directional + walk bits are stamped: - // FORWARD|BACKWARD|STRAFE_LEFT|STRAFE_RIGHT|TURN_LEFT|TURN_RIGHT|WALK_MODE. The conditional-data - // bits require their optional bodies. Peer snapshots replace this simple pose with the - // stored vanilla MovementInfo when its timestamp and position still match the entity. - const SIMPLE_MOVE_MASK: u32 = 0x01 | 0x02 | 0x04 | 0x08 | 0x10 | 0x20 | 0x100; // = 0x13F + const SIMPLE_MOVE_MASK: u32 = 0x01 | 0x02 | 0x04 | 0x08 | 0x10 | 0x20 | 0x100; let running_speed = speeds::RUN * (entity.run_speed_mult_bp as f32 / 10_000.0); let mut update_flag = MovementBlock_UpdateFlag::empty() .set_all(MovementBlock_UpdateFlag_All { unknown1: 1 }) @@ -572,8 +427,6 @@ pub fn build_create_object( }) } -/// Create the Character with its existing quest log already populated, so map changes do not -/// introduce an empty-to-populated quest transition. pub fn build_self_create_object( entity: &EntityView, inventory: &[ItemInstanceView], @@ -596,9 +449,7 @@ pub fn build_self_create_object( let Object::CreateObject2 { mask2, .. } = &mut create.objects[0] else { unreachable!("build_create_object returns one CREATE_OBJECT2"); }; - // The typed codec has no quest timer setters or public field-index setter. Its message - // reader does preserve those fields. Use a local VALUES envelope to extend the mask, then - // put it back in CREATE. This envelope carries OBJECT_FIELD_TYPE and must never be sent. + // This local envelope carries OBJECT_FIELD_TYPE and must never reach the client. let envelope = SMSG_UPDATE_OBJECT { has_transport: 0, objects: vec![Object::Values { @@ -623,7 +474,6 @@ pub fn build_self_create_object( { mask.set_u32(index, quest_mask.get(index).unwrap_or(0)); } - // Header, object count, transport flag, VALUES tag, and the one-byte packed zero guid. bytes.truncate(4 + 4 + 1 + 1 + 1); mask.write_to(&mut bytes); let size = u16::try_from(bytes.len() - 2)?; @@ -640,24 +490,12 @@ pub fn build_self_create_object( Ok(create) } -/// Which world entry a login sequence serves — `SMSG_LOGIN_VERIFY_WORLD` is a command to load the -/// named map, so only a fresh login may carry it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WorldEntry { - /// `CMSG_PLAYER_LOGIN`: the client has loaded nothing and needs verify-world to know which map. FreshLogin, - /// `MSG_MOVE_WORLDPORT_ACK`: the client already loaded the destination, so a verify-world - /// resend runs a second loading screen. WorldPort, } -/// Packs a Unix timestamp into the `SMSG_LOGIN_SETTIMESPEED` wire clock. The realm clock is -/// always UTC — there is no realm-timezone setting, so this deliberately never reads host-local -/// time. -/// -/// The wire constructor takes zero-based `month` and `month_day` and a `Weekday` that must agree -/// with the date; this function derives all three from `secs_since_unix_epoch` alone so the -/// packed value is always internally consistent. pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { let days = (secs_since_unix_epoch / 86_400) as i64; let secs_of_day = (secs_since_unix_epoch % 86_400) as u32; @@ -665,8 +503,7 @@ pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { let minutes = ((secs_of_day % 3_600) / 60) as u8; let (year, month, month_day) = lyracore_shared::calendar::civil_from_days(days); - // 1970-01-01 (days = 0) was a Thursday, and `Weekday::as_int()` numbers Sunday 0.. - // Saturday 6 — exactly `(days + 4) % 7` for a non-negative day count. + // Unix day zero was Thursday; vanilla numbers weekdays from Sunday, zero. let weekday = Weekday::try_from(((days + 4) % 7) as u32) .expect("(days + 4) % 7 is always 0..=6, a valid Weekday"); @@ -680,12 +517,7 @@ pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { ) } -/// Emit the post-login SMSG sequence (gateway translation §5) to the owner, in the -/// order the client expects — `SMSG_LOGIN_VERIFY_WORLD` must precede `SMSG_TUTORIAL_FLAGS`, -/// and is sent ONLY for a [`WorldEntry::FreshLogin`] (see the enum's doc for why a world-port -/// re-entry must omit it). -/// Slice values: zeroed account-data/factions/action-bar, real-time clock scale, no starting -/// spells, bind = the start position. All headers encrypted. +/// Only fresh login sends LOGIN_VERIFY_WORLD, and it must precede TUTORIAL_FLAGS. pub fn login_sequence_messages( entity: &EntityView, learned: &[u32], @@ -701,18 +533,7 @@ pub fn login_sequence_messages( z: entity.z, }; - // Known spells + a default action bar. CRITICAL for combat: the baseline melee toggle is the - // "Attack" ability (spell 6603). Without it known AND on the action bar, the 5875 client has no way - // to start auto-attack — there's no default keybind, and right-click does NOT engage a NEUTRAL - // (yellow) target — so it never emits CMSG_ATTACKSWING (confirmed via the inbound opcode trace: - // CMSG_SET_SELECTION fires on target, CMSG_ATTACKSWING never does). So we grant Attack + lay out a - // default bar (Attack in slot 0, then each known castable ability). A vanilla action button for a - // spell is just the spell id (button type 0 = spell); the bar has 120 slots. - const ATTACK_SPELL: u32 = 6603; // "Attack" — toggles melee auto-attack on the current target - // Spellbook + action bar come from the character's `game_player_spell` rows — the same rows the - // module's cast gate reads, so the client book can never show a spell the server would reject. - // Creation granted the createinfo kit (class abilities + racials) as rows; trainers/talents/quests - // append. Deduped so a spell isn't listed twice. + const ATTACK_SPELL: u32 = 6603; let mut known: Vec = vec![ATTACK_SPELL]; for &id in learned { if !known.contains(&id) { @@ -726,16 +547,6 @@ pub fn login_sequence_messages( unknown1: 0, }) .collect(); - // Action bar: if the character has IMPORTED `game_player_action` rows (creation - // copied `game_createinfo_action` — the real vanilla default bar for this race/class), build the - // bar from THOSE rows and trust them completely — no merge with the synth below, so a - // real-but-Attack-less imported bar is never silently patched (the dump is expected to carry its - // own auto-attack button if vanilla's real layout has one; double-injecting our own would put a - // second, possibly redundant Attack somewhere the imported layout didn't intend). Each button - // packs as `action | (action_type << 24)` — the vanilla client's own action-button encoding - // (`action` in the low 24 bits, `action_type` the high byte; type 0 = spell, matching the synth - // below's implicit type-0 raw-spell-id packing). Absent (pre-import, the common case today) → - // fall back to the byte-identical synth that shipped before imported gossip existed. let mut action_buttons = [0u32; 120]; if !player_actions.is_empty() { for &(button, action, action_type) in player_actions { @@ -749,9 +560,6 @@ pub fn login_sequence_messages( } } - // What this Character may wield and wear. The class byte is `UNIT_FIELD_BYTES_0` byte 1, and the - // armor half reads the trained passives out of `learned` — the same spells the Module equip Gate - // reads, so the client's red tint and the server's Refusal agree. let [weapon_proficiency, armor_proficiency] = build_set_proficiency_msgs(((entity.unit_bytes_0 >> 8) & 0xFF) as u8, learned); @@ -770,8 +578,6 @@ pub fn login_sequence_messages( data: [0u32; 32], })), ServerOpcodeMessage::SMSG_LOGIN_SETTIMESPEED(SMSG_LOGIN_SETTIMESPEED { - // Current realm date/time (UTC). The client free-runs from here at the timescale - // below; no periodic clock resend follows. datetime: realm_datetime( SystemTime::now() .duration_since(UNIX_EPOCH) @@ -785,27 +591,16 @@ pub fn login_sequence_messages( })), ServerOpcodeMessage::SMSG_INITIAL_SPELLS(Box::new(SMSG_INITIAL_SPELLS { unknown1: 0, - // Known spells = "Attack" (6603) + the SHARED player-castable set (Battle Shout, Rend, …), - // so the spellbook and the module's cast gate never drift. The Warrior abilities live in skill - // lines sent at CREATE (Fury/Arms), so the client resolves their tabs. (Slice: the warrior set - // is sent to the lone test character; a per-class known-spell list is an importer follow-up.) initial_spells, cooldowns: vec![], })), - // Straight after the spellbook the armor mask is derived from, so the client learns the - // passives and what they unlock in one initial-state batch. weapon_proficiency, armor_proficiency, ServerOpcodeMessage::SMSG_ACTION_BUTTONS(Box::new(SMSG_ACTION_BUTTONS { data: action_buttons, })), ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(Box::new(SMSG_INITIALIZE_FACTIONS { - // Fold each persisted `game_player_reputation` row's standing into its stored - // `reputation_index` slot (0..63) — the SAME Faction.dbc ReputationListID the live - // SET_FACTION_STANDING relay addresses (see build_set_faction_standing_raw's crash note). - // HARD GUARDRAIL: index by reputation_index, NEVER faction_id — faction_id indexed past the - // client's 64-slot array caused the McBride ERROR crash. Slots with no persisted row stay - // Neutral/0 (the prior stub value), matching a faction the player has never gained rep with. + // Reputation slots use Faction.dbc ReputationListID, never the faction id. factions: { let mut slots: Vec = (0..64) .map(|_| FactionInitializer { @@ -815,11 +610,7 @@ pub fn login_sequence_messages( .collect(); for &(index, standing, at_war) in reputations { if let Some(slot) = usize::try_from(index).ok().and_then(|i| slots.get_mut(i)) { - slot.standing = standing as u32; // signed standing, bit-cast (client reads it i32) — same as build_set_faction_standing_raw - // The persisted At-War checkbox survives relog via this flag - // byte (AT_WAR = 0x02). VISIBLE (0x01) rides along — a slot with a stored - // row is a faction the player has met, and the client needs VISIBLE to - // render the pane row the checkbox lives on. + slot.standing = standing as u32; if at_war { slot.flag = FactionFlag::new_visible().set_at_war(); } @@ -830,11 +621,7 @@ pub fn login_sequence_messages( })), ServerOpcodeMessage::SMSG_SET_REST_START(SMSG_SET_REST_START { unknown1: 0 }), ServerOpcodeMessage::SMSG_BINDPOINTUPDATE(Box::new(SMSG_BINDPOINTUPDATE { - // Always send the persisted hearthstone bind point (home_*). `create_character` seeds - // home_* to the race start-position and `bind_home` updates them to the innkeeper; - // both paths produce real coordinates. A `home_map != 0` sentinel is wrong because - // Eastern Kingdoms is map_id 0 — every human/dwarf/gnome character would fail that - // check and receive the login position instead of their actual bind point. + // Map 0 is Eastern Kingdoms, so home_map cannot use zero as an absent sentinel. position: Vector3d { x: entity.home_x, y: entity.home_y, @@ -847,24 +634,16 @@ pub fn login_sequence_messages( Ok(msgs) } -/// Build `SMSG_DESTROY_OBJECT` for a guid leaving view. Vanilla carries just the -/// guid (no `is_on_death`). pub fn build_destroy_object(guid: u64) -> SMSG_DESTROY_OBJECT { SMSG_DESTROY_OBJECT { guid: Guid::new(guid), } } -/// Build `SMSG_PET_SPELLS` for a freshly summoned pet: the default vanilla pet action bar — -/// Attack/Follow/Stay commands in slots 0–2, the pet's castable spells (if any) in slots 3–6, and -/// the Aggressive/Defensive/Passive react states in slots 7–9, each slot packed as -/// `action | (active_state << 24)` — the packing the 5875 client decodes. The pet starts Enabled, -/// Follow, Defensive — the 1.12 summon defaults. pub fn build_pet_spells(pet_guid: u64, spells: &[u32]) -> SMSG_PET_SPELLS { - // mangos ActiveStates: the high byte tagging what kind of button a slot holds. - const ACT_COMMAND: u32 = 0x07; // attack/follow/stay - const ACT_REACT: u32 = 0x06; // aggressive/defensive/passive - const ACT_DISABLED: u32 = 0x81; // an autocastable spell, autocast OFF (the summon default) + const ACT_COMMAND: u32 = 0x07; + const ACT_REACT: u32 = 0x06; + const ACT_DISABLED: u32 = 0x81; let pack = |action: u32, state: u32| (action & 0x00FF_FFFF) | (state << 24); let mut bars = [0u32; 10]; bars[0] = pack(PetCommandState::Attack.as_int() as u32, ACT_COMMAND); @@ -879,7 +658,7 @@ pub fn build_pet_spells(pet_guid: u64, spells: &[u32]) -> SMSG_PET_SPELLS { SMSG_PET_SPELLS { pet: Guid::new(pet_guid), action_bars: Some(SMSG_PET_SPELLS_action_bars { - duration: 0, // permanent (a warlock pet has no despawn timer) + duration: 0, react: PetReactState::Defensive, command: PetCommandState::Follow, unknown: 0, @@ -891,8 +670,6 @@ pub fn build_pet_spells(pet_guid: u64, spells: &[u32]) -> SMSG_PET_SPELLS { } } -/// The empty `SMSG_PET_SPELLS` (guid 0, no bar block) — the vanilla "remove the pet bar" form, -/// sent when the pet despawns/dies. pub fn build_pet_spells_clear() -> SMSG_PET_SPELLS { SMSG_PET_SPELLS { pet: Guid::new(0), @@ -900,29 +677,18 @@ pub fn build_pet_spells_clear() -> SMSG_PET_SPELLS { } } -/// `SMSG_SET_FACTION_STANDING` opcode (0x0124) — RAW-encoded (a vanilla wire gap; gtker writes the entry -/// faction as a u16, the 5875 client reads u32, hence the hand-rolled 12-byte body). +// gtker writes a u16 faction slot, but build 5875 requires u32. const SMSG_SET_FACTION_STANDING_OPCODE: u16 = 0x0124; -/// Build the live reputation-bar update relayed when a `game_player_reputation` row is inserted/updated, -/// in the exact 5875 layout: `count(u32)` then per faction `reputation_index(u32)` + `standing(u32)`. -/// -/// CRASH ROOT-CAUSE (3rd McBride attempt — the real one): the entry field is the Faction.dbc **ReputationListID** -/// (a SMALL 0..63 index into the client's fixed rep array), NOT the faction id. SMSG_INITIALIZE_FACTIONS sets up -/// 64 slots; SET_FACTION_STANDING addresses ONE by that index. We were sending `faction.as_int()` = the faction -/// id (Stormwind 72), so the client indexed slot 72 — past the 64-slot array → null faction record → `mov -/// esi,[ebx]` with ebx=0 → ERROR ACCESS_VIOLATION (the client crash dump's args carried opcode 0x124 + the -/// standing 3175, confirming this packet). The earlier u16→u32 width fix was a real-but-insufficient symptom fix; -/// the VALUE was always wrong. `reputation_index` now comes straight off the row (the module stamps it from -/// game_faction at grant time); callers pass `< 0` rows nowhere (the relay skips them — no rep bar to address). +/// The client indexes a 64-slot array by reputation_index, not faction id. pub fn build_set_faction_standing_raw( reputation_index: u32, standing: i32, ) -> Option<(u16, Vec)> { let mut body = Vec::with_capacity(12); - body.extend_from_slice(&1u32.to_le_bytes()); // count = 1 - body.extend_from_slice(&reputation_index.to_le_bytes()); // the Faction.dbc ReputationListID (0..63), as u32 - body.extend_from_slice(&(standing as u32).to_le_bytes()); // signed standing, bit-cast (client reads it i32) + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&reputation_index.to_le_bytes()); + body.extend_from_slice(&(standing as u32).to_le_bytes()); Some((SMSG_SET_FACTION_STANDING_OPCODE, body)) } diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs index 730bd21f4..ba017ad59 100644 --- a/gateway/src/codec/entity/subscriptions.rs +++ b/gateway/src/codec/entity/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::combat::subscriptions::aura_sync; use crate::codec::guild::subscriptions::guild_projection_of; use crate::codec::movement::subscriptions::{ @@ -21,8 +19,6 @@ use std::collections::HashSet; use std::sync::Arc; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Read a peer's equipment and current aura state for AOI entry or stealth reveal. -/// An encoding failure leaves the caller responsible for removing its `created` entry. pub(crate) fn build_peer_create( coord: &Coordinator, world: &WorldView, @@ -81,8 +77,6 @@ pub(crate) fn build_peer_create( } } -/// The view a peer CREATE encodes: the stored row, the viewer-relative dynamic flags, and for a -/// player the Guild Projection `guild_of` reads. pub(crate) fn peer_entity_view( row: &WorldEntity, dynamic_flags: u32, @@ -96,8 +90,7 @@ pub(crate) fn peer_entity_view( view } -/// A peer's current auras follow its CREATE. Their rows may have arrived before the viewer -/// could see it, so another aura callback cannot be relied on to supply them. +// Replay auras after CREATE; their callbacks may have fired before the entity was visible. pub(crate) fn peer_create_outbound( view: &codec::EntityView, inventory: &[codec::ItemInstanceView], @@ -160,7 +153,6 @@ struct LootTagProjectionRows<'a> { corpse_eligible: &'a [CorpseLootEligible], } -/// Render the stored entity flags for one viewer without changing the durable row. fn viewer_relative_dynamic_flags( viewer_guid: u64, entity: &WorldEntity, @@ -244,15 +236,6 @@ pub(crate) fn projected_dynamic_flags( ) } -/// May an instance-tagged CORPSE/GAMEOBJECT row be CREATE-relayed to this -/// viewer? Instance equality against a KNOWN viewer instance — `None` (viewer entity not resident, -/// see `viewer_instance`) relays nothing: suppress-then-sweep is strictly safer than the old -/// default-to-0 (which leaked instance-0 rows to a viewer whose real instance wasn't known yet). -/// Kept map-agnostic deliberately: instance-0 rows keep today's relay behavior byte-identical for -/// every resident open-world viewer, while a per-instance copy (or an in-instance corpse) reaches -/// ONLY viewers inside that instance. DELETE relays stay ungated (the `on_melee_delete` precedent — -/// SMSG_DESTROY_OBJECT for a never-created guid is a client no-op, and gating deletes risks -/// leaking a stale object on an instance transition). Pure — unit-tested below. pub(crate) fn instance_relay_gate(row_instance_id: u64, viewer_instance_id: Option) -> bool { viewer_instance_id == Some(row_instance_id) } @@ -266,7 +249,7 @@ pub(crate) fn peer_create_gate( viewer_is_ghost: bool, row_is_stealthed: bool, ) -> bool { - const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 + const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; if row_guid == self_guid { return false; } @@ -290,12 +273,11 @@ pub(crate) fn offer_peer_create_for( viewer: &Viewer, row: &WorldEntity, ) -> Vec { - const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // 0x20 + const SPIRITHEALER_NPC_FLAG: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; if row.guid == viewer.self_guid || row.instance_id != viewer.instance_id { return Vec::new(); } let viewer_is_ghost = viewer.gates.is_ghost(); - // A spirit healer viewed by a living player is refused regardless of stealth. if row.npc_flags & SPIRITHEALER_NPC_FLAG != 0 && !viewer_is_ghost { return Vec::new(); } @@ -323,8 +305,6 @@ pub(crate) fn offer_peer_create_for( if let Some(equipment) = db.game_encounter_equip().creature_guid().find(&row.guid) { append_encounter_equip_after_create(&mut out, viewer, &equipment); } - // Pet action bar: this viewer's OWN summoned pet just appeared — bind it client-side - // (UNIT_FIELD_SUMMON + the pet action bar). if row.owner_guid == viewer.self_guid { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_owner_summon_values(viewer.self_guid, row.guid)), @@ -368,8 +348,6 @@ pub(crate) fn offer_peer_create_for( out } -/// Relay one durable creature virtual-item projection to a viewer that already holds the creature. -/// A delete clears all three display slots. The raw builder is the crash-safe sparse VALUES path. pub(crate) fn encounter_equip_outbound( viewer: &Viewer, row: &EncounterEquip, @@ -430,10 +408,6 @@ pub(crate) fn relay_entity_update( .gates .is_ghost .store(is_ghost, std::sync::atomic::Ordering::Relaxed); - // Every spirit healer currently IN VIEW (the index answers that; before the - // shared-connection model this scanned the AOI-scoped connection cache, which was the - // same set). Idempotent through - // `created`, exactly like the stealth Reveal/Hide arms. let guard = coord.0.coord(); let healers: Vec = guard .conn @@ -459,9 +433,6 @@ pub(crate) fn relay_entity_update( out } -/// Re-send the projected flags for visible live Loot Tags after the shard membership mirror -/// changes. Membership does not rewrite a creature's `WorldEntity`, so its normal VALUES relay has -/// nothing to carry. pub(crate) fn loot_tag_flags_after_membership_change( coord: &Coordinator, viewer: &Viewer, @@ -496,8 +467,6 @@ pub(crate) fn relay_peer_destroy(viewer: &Viewer, guid: u64, owner_guid: u64) -> let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_DESTROY_OBJECT( codec::build_destroy_object(guid), ))]; - // Pet action bar: this viewer's own pet despawned — clear UNIT_FIELD_SUMMON and send the empty - // SMSG_PET_SPELLS (the vanilla "remove pet bar" form). if owner_guid == viewer.self_guid { out.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_owner_summon_values(viewer.self_guid, 0)), @@ -509,21 +478,10 @@ pub(crate) fn relay_peer_destroy(viewer: &Viewer, guid: u64, owner_guid: u64) -> out } -/// The `on_update` dispatch decision: does this update need to be treated as a -/// re-entry (offer a fresh CREATE via `offer_peer_create`) instead of a field diff? True exactly when -/// the guid isn't self AND isn't already in the viewer's `created` set — i.e. either this is the first -/// time this viewer has seen the row, or it left scope (evicted by `on_delete`) and is now returning as -/// an UPDATE of the SDK's still-cached row instead of a fresh `on_insert`. Pure over the dedup-set -/// membership so the branch is unit-testable without a live SDK connection. pub(crate) fn is_update_reentry(guid: u64, self_guid: u64, currently_shown: bool) -> bool { guid != self_guid && !currently_shown } -/// Edge-detect for the spirit-healer reveal: did the GHOST player-flag (mask) cross 0↔1 between `old` -/// and `new`? Returns `Some(true)` on an alive→ghost crossing (REVEAL the in-scope spirit healers), -/// `Some(false)` on ghost→alive (DESTROY them), `None` when the bit didn't change (the common heartbeat / -/// any other field update — no reveal work). Pure so the on_update closure stays a thin relay and this -/// stays unit-testable (the closure itself needs a live EventContext to enumerate healers). pub(crate) fn ghost_transition(old_flags: u32, new_flags: u32, ghost_mask: u32) -> Option { let was = old_flags & ghost_mask != 0; let is = new_flags & ghost_mask != 0; @@ -534,13 +492,6 @@ pub(crate) fn ghost_transition(old_flags: u32, new_flags: u32, ghost_mask: u32) } } -/// Diff an observed `game_world_entity` row (`old` → `new`) into the partial-VALUES `SMSG_UPDATE_OBJECT` -/// packets that replicate the change to this player's client. Pure (no I/O) so it is unit-testable — the -/// `on_update` relay callback just sends whatever this returns, in order. The level-up branch is -/// MUTUALLY EXCLUSIVE with the individual field diffs: a ding pushes ONE combined VALUES block (level + -/// vitals + xp together) instead of the separate health/xp packets, so the panel + bars flip in lockstep. -/// Player-only fields (xp / coinage / ghost / power) are gated on the PLAYER bit; health and dynamic-flags -/// apply to any unit (creatures' health bars + loot sparkle). Order is preserved from the original relay. #[cfg(test)] pub(crate) fn entity_update_to_outbound( old: &WorldEntity, @@ -557,8 +508,6 @@ pub(crate) fn entity_update_to_outbound_with_dynamic_flags( let mut out = Vec::new(); let is_player = new.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0; if is_player && old.level != new.level { - // Level-up ding: level + maxhealth + health + xp in ONE VALUES block (the panel + health/XP bars - // flip together). SMSG_LEVELUP_INFO (relayed separately) is the cosmetic popup only. let m = codec::build_levelup_values( new.guid, new.level, @@ -575,34 +524,22 @@ pub(crate) fn entity_update_to_outbound_with_dynamic_flags( let m = codec::build_health_values(new.guid, new.health); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Dynamic-flags VALUES relay: the killing blow sets UNIT_DYNFLAG_LOOTABLE (0x1) on a corpse that - // rolled money; the loot reducer clears it. NOTE: UNIT_DYNFLAG_DEAD (0x20) is NEVER set - // (it is feign-death in vanilla — see combat + lyracore-shared constants). + // UNIT_DYNFLAG_DEAD, 0x20, means feign death in vanilla. if old.dynamic_flags != new.dynamic_flags { let m = codec::build_dynamic_flags_values(new.guid, dynamic_flags); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Target relay (UNIT_FIELD_TARGET): observers see who this unit selected — the target ring / - // target-of-target / assist. Any unit (a creature's aggro target relays too), not player-gated. if old.target_guid != new.target_guid { let m = codec::build_target_values(new.guid, new.target_guid); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Max-vitals relay: a non-level-up max_health/max_power change (+Stamina/+Intellect gear or a - // Fortitude/Mark aura) moves the bar DENOMINATOR live for observers. Any unit. if old.max_health != new.max_health || old.max_power != new.max_power { let power_b = (new.unit_bytes_0 >> 24) as u8; let m = codec::build_max_vitals_values(new.guid, new.max_health, power_b, new.max_power); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Taxi presentation changes mount display + the TAXI_FLIGHT unit flag in one mask (activation - // and landing set both atomically), so the client never sees an intermediate - // mounted-but-controllable or unmounted-but-in-flight state — detected by the flight bit itself - // toggling, not just "unit_flags changed", so an unrelated flag change alongside a land mount - // doesn't get mistaken for a taxi transition. A LAND mount/dismount changes `mount_display_id` - // ALONE (the flight bit is untouched) and relays through the standalone single-field builder - // instead, never alongside the coupled one for the same change. + // Mount display and TAXI_FLIGHT must reach the client in one mask. let taxi_flight_toggled = (old.unit_flags ^ new.unit_flags) & lyracore_shared::constants::unit_flags::TAXI_FLIGHT != 0; @@ -623,27 +560,19 @@ pub(crate) fn entity_update_to_outbound_with_dynamic_flags( out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } } - // Sheath relay: UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. - // Any unit, not player-gated — a creature drawing on engage is the same wire field. Without - // this arm the server knows the state and no observer ever hears about it. if old.unit_bytes_2 != new.unit_bytes_2 { let m = codec::build_sheath_values(new.guid, new.unit_bytes_2); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live XP-bar update: players only (creatures never change xp; player-only fields). let xp_changed = old.xp != new.xp || old.next_level_xp != new.next_level_xp; if xp_changed && is_player { let m = codec::build_player_xp_values(new.guid, new.xp, new.next_level_xp); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live purse update: PLAYER_FIELD_COINAGE on money change (looting). Players only. if old.money != new.money && is_player { let m = codec::build_coinage_values(new.guid, new.money); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live bank bag slot count: PLAYER_BYTES_2 byte 2 on a purchase, so the bank window grows a - // slot without a relog. Scoped to byte 2 only — byte 3 (rest state) already relays through - // its own dedicated `game_rest_state_event` path and must not double-fire here. const BANK_SLOT_BYTE: u32 = 0x00FF_0000; if is_player && (old.player_bytes_2 & BANK_SLOT_BYTE) != (new.player_bytes_2 & BANK_SLOT_BYTE) @@ -651,29 +580,19 @@ pub(crate) fn entity_update_to_outbound_with_dynamic_flags( let m = codec::build_bank_bag_slots_values(new.guid, new.player_bytes_2); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // PLAYER_FLAGS and the UNIT_FIELD_BYTES_1 vis bit: the ghost transition on Release Spirit - // and reclaim, and the AFK and DND bits of an Away Status. Players only. if is_player && (old.player_flags != new.player_flags || old.unit_bytes_1 != new.unit_bytes_1) { let m = codec::build_player_flags_values(new.guid, new.player_flags, new.unit_bytes_1); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live power update: rage/energy/mana changing in combat. The power-type byte of unit_bytes_0 picks - // the right UNIT_FIELD_POWER_N. Players only — else the rage bar stays at 0 and rage spells grey out. if is_player && old.power != new.power { let power_b = (new.unit_bytes_0 >> 24) as u8; let m = codec::build_power_values(new.guid, power_b, new.power); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // GM playtest `.speed`: a `run_speed_mult_bp` change relays - // SMSG_FORCE_RUN_SPEED_CHANGE — player movement is client-authoritative, so the server-side - // field alone never speeds the client up. Mirrors the existing `A_MOD_SPEED(MOVE)` aura relay - // (`run_speed_packet`, same `BASE_RUN_SPEED` constant) but keyed off THIS field, any unit (not - // player-gated — a GM could in principle god/speed-tag a creature down the line, and the relay - // is inert for anyone not watching this guid). if old.run_speed_mult_bp != new.run_speed_mult_bp { - const BASE_RUN_SPEED: f32 = 7.0; // vanilla base run yd/s — same constant as `run_speed_packet` + const BASE_RUN_SPEED: f32 = 7.0; let speed = BASE_RUN_SPEED * (new.run_speed_mult_bp as f32 / 10_000.0); out.push(ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( codec::build_force_run_speed(new.guid, speed), @@ -688,17 +607,10 @@ pub(crate) fn entity_update_to_outbound_with_dynamic_flags( out } -/// A row's cell, taken from the grid columns the WRITER stamped — not recomputed from `x`/`y`. -/// -/// The subscription predicate this replaces filtered on `grid_x`/`grid_y`, so reading the same -/// columns is what makes the index and the deleted SQL agree by construction rather than by both -/// happening to call `grid_cell` the same way. pub(crate) fn entity_key(row: &WorldEntity) -> CellKey { CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y) } -/// A row entered the world (or this shard). Index it, then offer a CREATE to everyone who can see -/// its cell. pub(crate) fn entity_appeared(view: &Arc, shard: ShardId, row: &WorldEntity) { let key = entity_key(row); view.spatial.upsert_entity( @@ -714,8 +626,6 @@ pub(crate) fn entity_appeared(view: &Arc, shard: ShardId, row: &World } } -/// Create or update for the new cell's viewers, destroy for viewers left behind, -/// and leave all other viewers untouched. pub(crate) fn entity_changed( view: &Arc, shard: ShardId, @@ -748,7 +658,6 @@ pub(crate) fn entity_changed( } } -/// The row is gone from the database. Everyone who could see it gets a DESTROY. pub(crate) fn entity_vanished(view: &WorldView, shard: ShardId, guid: u64, owner_guid: u64) { let Some((key, _)) = view .spatial @@ -844,8 +753,6 @@ pub(crate) fn destroy_job(view: &WorldView, session: SessionId, guid: u64, owner }); } -/// A standing row is delivered only to its owner. The wire packet uses the Faction.dbc -/// ReputationListID, never the game's faction id. pub(crate) fn reputation_appeared(view: &WorldView, shard: ShardId, row: &PlayerReputation) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { return; @@ -892,8 +799,6 @@ pub(crate) mod tests { peer_create_outbound(&entity_view(creature, 0), &[], &[], None).unwrap() } - /// A live-entity row with no pending change; tests clone it and mutate one field at a time. - /// `type_mask` carries the PLAYER bit by default (most relayed fields are player-only). pub(crate) fn player_entity() -> WorldEntity { WorldEntity { guid: 1, diff --git a/gateway/src/codec/entity/subscriptions/packet_tests.rs b/gateway/src/codec/entity/subscriptions/packet_tests.rs index b06125c22..634f71fe9 100644 --- a/gateway/src/codec/entity/subscriptions/packet_tests.rs +++ b/gateway/src/codec/entity/subscriptions/packet_tests.rs @@ -117,7 +117,6 @@ fn created_player(out: &[Outbound]) -> wow_world_messages::vanilla::UpdatePlayer player.clone() } -/// A Human Warrior: the CREATE encoder refuses race 0. fn human_warrior() -> WorldEntity { WorldEntity { unit_bytes_0: 1 | (1 << 8) | (1 << 24), @@ -165,7 +164,6 @@ fn single_health_change_emits_one() { let mut new = old.clone(); new.health = 50; let out = entity_update_to_outbound(&old, &new); - // Decode + pin: guid and the exact health value ride the health-only VALUES packet. assert_eq!( out, vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( @@ -174,15 +172,11 @@ fn single_health_change_emits_one() { ); } -/// a sheath change must relay, or drawing a weapon is invisible to everyone but the player -/// who did it. The state lives in BYTE 0 of `unit_bytes_2` — pin the decoded packet, and pin that -/// an unchanged row stays silent (the client re-sends its current state on every weapon swap, and -/// a relay per no-op is a broadcast amplifier on a busy cell). #[test] fn sheath_change_relays_and_a_no_op_stays_silent() { let old = player_entity(); let mut new = old.clone(); - new.unit_bytes_2 = 1; // SHEATH_STATE_MELEE — weapons drawn + new.unit_bytes_2 = 1; assert_eq!( entity_update_to_outbound(&old, &new), vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( @@ -197,8 +191,6 @@ fn sheath_change_relays_and_a_no_op_stays_silent() { #[test] fn independent_field_changes_each_emit() { - // health (any unit) + dynamic_flags (any unit) + power (player) = three separate packets, - // in the relay's fixed emission order — decoded and pinned by guid + value, not just count. let old = player_entity(); let mut new = old.clone(); new.health = 50; @@ -240,8 +232,6 @@ fn dynamic_flag_values_uses_the_viewer_projection() { #[test] fn levelup_is_exclusive_with_field_diffs() { - // A ding collapses level + vitals + xp into ONE combined VALUES block, NOT separate health/xp - // packets — even though health and xp also changed this tick. let old = player_entity(); let mut new = old.clone(); new.level = 2; @@ -251,8 +241,6 @@ fn levelup_is_exclusive_with_field_diffs() { assert_eq!(entity_update_to_outbound(&old, &new).len(), 1); } -/// `/afk` sets PLAYER_FLAGS_AFK (0x02, descriptor index 190). Self and every observer run this -/// same diff, so each client holding the object gets one VALUES update carrying the new word. #[test] fn an_afk_toggle_relays_player_flags_to_every_viewer() { let old = player_entity(); @@ -290,17 +278,15 @@ fn an_afk_toggle_relays_player_flags_to_every_viewer() { #[test] fn player_only_fields_are_gated_for_creatures() { - // A creature (no PLAYER bit) relays health + dynamic_flags but NOT xp/coinage/ghost/power — - // decoded and pinned as exactly those two packets, not just a count of two. let mut old = player_entity(); old.type_mask = 0; let mut new = old.clone(); - new.health = 50; // emits (any unit) - new.dynamic_flags = 1; // emits (any unit) - new.xp = 10; // gated out (player-only) - new.money = 10; // gated out - new.power = 30; // gated out - new.player_flags = 0x10; // gated out + new.health = 50; + new.dynamic_flags = 1; + new.xp = 10; + new.money = 10; + new.power = 30; + new.player_flags = 0x10; let out = entity_update_to_outbound(&old, &new); assert_eq!( out, @@ -317,11 +303,6 @@ fn player_only_fields_are_gated_for_creatures() { #[test] fn creature_levelup_does_not_take_the_exclusive_branch() { - // The level-up branch is player-only; a creature whose level changed still diffs health - // normally. A bare length check can't tell the two branches apart here — the player-only - // ding ALSO emits exactly one packet — so decode the one packet and pin it as the plain - // Unit-mask health update (build_health_values), not the Player-mask combined ding - // (build_levelup_values) a regressed player-gate would otherwise produce with the same count. let mut old = player_entity(); old.type_mask = 0; let mut new = old.clone(); @@ -338,7 +319,6 @@ fn creature_levelup_does_not_take_the_exclusive_branch() { #[test] fn target_change_emits_one() { - // UNIT_FIELD_TARGET relay — any unit, one packet, guid + target value pinned. let old = player_entity(); let mut new = old.clone(); new.target_guid = 0xDEAD_BEEF; @@ -353,11 +333,9 @@ fn target_change_emits_one() { #[test] fn bank_bag_slot_purchase_relays_and_the_rest_byte_survives() { - // A bought bank bag slot lands in byte 2 of PLAYER_BYTES_2; the relay must carry the FULL - // field (a partial VALUES overwrites the whole u32) so byte 0 (facial hair) and byte 3 (rest - // state — HAZARD: a zero byte 3 crashes the 5875 client's XP bar) ride along unchanged. + // Preserve all PLAYER_BYTES_2 bytes; a zero rest-state byte crashes build 5875's XP bar. let mut old = player_entity(); - old.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(7, 0, false); // facial hair 7, 0 slots, NORMAL + old.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(7, 0, false); let mut new = old.clone(); new.player_bytes_2 = lyracore_shared::packing::with_bank_bag_slots(old.player_bytes_2, 1); let out = entity_update_to_outbound(&old, &new); @@ -382,18 +360,14 @@ fn bank_bag_slot_purchase_relays_and_the_rest_byte_survives() { #[test] fn a_rest_state_flip_alone_does_not_relay_the_bank_slot_packet() { - // Byte 3 changing (rest flip) must NOT trip the byte-2-scoped bank slot relay — that byte - // already relays through its own dedicated `game_rest_state_event` path. let old = player_entity(); let mut new = old.clone(); - new.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(0, 0, true); // RESTED + new.player_bytes_2 = lyracore_shared::packing::player_bytes_2_with_rest(0, 0, true); assert!(entity_update_to_outbound(&old, &new).is_empty()); } #[test] fn max_vitals_change_emits_one() { - // A non-level-up max_health change (gear/aura) — any unit, one packet, guid + max_health/ - // power values pinned (max_power carries through unchanged from `player_entity()`). let old = player_entity(); let mut new = old.clone(); new.max_health = 150; @@ -409,23 +383,17 @@ fn max_vitals_change_emits_one() { #[test] fn ghost_transition_edge_detects_the_spirit_healer_reveal() { - const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; // 0x10 - // alive→ghost (Release Spirit): REVEAL the in-scope spirit healers (Some(true)). + const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; assert_eq!(ghost_transition(0, GHOST, GHOST), Some(true)); - // ghost→alive (reclaim corpse / spirit-healer res): DESTROY them (Some(false)). assert_eq!(ghost_transition(GHOST, 0, GHOST), Some(false)); - // No GHOST-bit change → no reveal work, even when OTHER player-flags toggle this update - // (the common heartbeat / unrelated flag write must NOT re-fire the reveal). assert_eq!(ghost_transition(0, 0, GHOST), None); assert_eq!(ghost_transition(GHOST, GHOST, GHOST), None); - assert_eq!(ghost_transition(0x4 | GHOST, 0x8 | GHOST, GHOST), None); // ghost stays set, others flip - assert_eq!(ghost_transition(0x4, 0x8, GHOST), None); // ghost stays clear, others flip + assert_eq!(ghost_transition(0x4 | GHOST, 0x8 | GHOST, GHOST), None); + assert_eq!(ghost_transition(0x4, 0x8, GHOST), None); } #[test] fn in_combat_flag_change_emits_one() { - // UNIT_FLAG_IN_COMBAT toggling on unit_flags — any unit, one VALUES packet, guid + the - // exact post-toggle flags word pinned. let old = player_entity(); let mut new = old.clone(); new.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; @@ -470,8 +438,6 @@ fn taxi_landing_emits_one_atomic_presentation_update_not_a_land_mount_duplicate( #[test] fn land_mount_display_change_emits_the_standalone_builder_not_taxi_presentation() { - // A land mount changes ONLY `mount_display_id` — TAXI_FLIGHT is untouched — so it must relay - // through the decoupled single-field builder, not the taxi-coupled one. let old = player_entity(); let mut new = old.clone(); new.mount_display_id = 1147; @@ -488,7 +454,7 @@ fn land_dismount_display_change_emits_the_standalone_builder_with_zero() { let mut old = player_entity(); old.mount_display_id = 1147; let mut new = old.clone(); - new.mount_display_id = 0; // dismount — TAXI_FLIGHT was never set for a land mount + new.mount_display_id = 0; assert_eq!( entity_update_to_outbound(&old, &new), vec![ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( @@ -499,8 +465,6 @@ fn land_dismount_display_change_emits_the_standalone_builder_with_zero() { #[test] fn a_land_mount_display_change_alongside_an_unrelated_flag_change_emits_both_packets() { - // The flag change is real but NOT the taxi flight bit — both relay, separately, neither - // coupled through the taxi builder. let old = player_entity(); let mut new = old.clone(); new.mount_display_id = 1147; @@ -522,12 +486,9 @@ fn a_land_mount_display_change_alongside_an_unrelated_flag_change_emits_both_pac #[test] fn run_speed_mult_bp_change_emits_force_run_speed_change_with_the_derived_speed() { - // GM playtest `.speed`: a run_speed_mult_bp change relays - // SMSG_FORCE_RUN_SPEED_CHANGE with speed = BASE_RUN_SPEED (7.0) * bp/10000 — pin the derived - // value end-to-end (this is the "codec pin for the speed message" this feature calls for). let old = player_entity(); let mut new = old.clone(); - new.run_speed_mult_bp = 30_000; // .speed 3 -> 3x + new.run_speed_mult_bp = 30_000; let out = entity_update_to_outbound(&old, &new); assert_eq!( out, @@ -557,7 +518,7 @@ fn appearance_change_relays_the_wisp_and_the_restored_native_display() { fn run_speed_mult_bp_unchanged_emits_nothing_extra() { let old = player_entity(); let mut new = old.clone(); - new.health = 50; // an unrelated change must not spuriously trigger the speed relay + new.health = 50; let out = entity_update_to_outbound(&old, &new); assert!(!out .iter() @@ -566,8 +527,6 @@ fn run_speed_mult_bp_unchanged_emits_nothing_extra() { #[test] fn creature_relays_target_and_max_vitals() { - // The target + max-vitals arms are NOT player-gated: a creature relays both, decoded and - // pinned in emission order (target before max-vitals) with their exact values. let mut old = player_entity(); old.type_mask = 0; let mut new = old.clone(); @@ -848,31 +807,15 @@ fn peer_create_applies_always_and_not_stack_run_bonuses() { } } -// ---- AOI re-entry, initial-apply, instance isolation -------------- -// -// `offer_peer_create`/`is_update_reentry` themselves need a live SDK connection (`RemoteTables` -// is only constructible off a real subscription) to exercise end-to-end, so — per this repo's -// established convention (`ghost_transition`, `stealth_action`, `entity_update_to_outbound`) — -// the DECISION logic they're built from is extracted pure (`peer_create_gate`, -// `is_update_reentry`) and tested directly here; both are the ACTUAL functions -// `offer_peer_create`/the `on_update` callback call, not a parallel re-implementation. - #[test] fn peer_create_gate_excludes_self_regardless_of_other_fields() { - // Self is never offered a CREATE of its own row (the dedup set is pre-seeded with - // self_guid instead) — true even if every other gate would otherwise pass. assert!(!peer_create_gate(1, 1, 0, 0, 0, true, false)); } #[test] fn peer_create_gate_excludes_a_different_instance_work_item_190() { - // Same instance (both open-world 0, or both some future dungeon instance N) -> visible. assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); assert!(peer_create_gate(2, 1, 7, 7, 0, false, false)); - // Different instance (e.g. a dungeon party's row vs. an open-world viewer, or two - // different parties' instances of the same dungeon) -> excluded. This is the gate the - // The login sweep and the re-entry path BOTH sit behind — a cross-instance row must - // never be marked `created`, even transiently, by either path. assert!(!peer_create_gate(2, 1, 7, 0, 0, false, false)); assert!(!peer_create_gate(2, 1, 3, 7, 0, false, false)); } @@ -880,62 +823,41 @@ fn peer_create_gate_excludes_a_different_instance_work_item_190() { #[test] fn peer_create_gate_spirithealer_and_stealth_gates_unaffected_by_the_refactor() { const SPIRITHEALER: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; - // A spirit healer is invisible to a living viewer... assert!(!peer_create_gate(2, 1, 0, 0, SPIRITHEALER, false, false)); assert!(peer_create_gate(2, 1, 0, 0, SPIRITHEALER, true, false)); - // A non-spirit-healer entity is unaffected by the viewer's ghost state. assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); - // A currently-stealthed peer is excluded regardless of everything else. assert!(!peer_create_gate(2, 1, 0, 0, 0, false, true)); } #[test] fn reentry_after_leaving_aoi_recreates_exactly_once_work_item_144() { - // The exact re-entry scenario: observer sees peer (CREATE) -> peer leaves the AOI box - // (on_delete evicts it from `created`, sends DESTROY) -> peer returns, delivered as an - // UPDATE of the SDK's still-cached row (not a fresh on_insert) -> the observer must - // re-CREATE it, not silently drop it forever. let self_guid = 1u64; let peer = 42u64; - let mut created: HashSet = HashSet::from([self_guid, peer]); // currently visible + let mut created: HashSet = HashSet::from([self_guid, peer]); - // Leave: on_delete's `if del_created.lock().unwrap().remove(&row.guid)` — must actually - // evict, or the later re-entry check below sees a false "already shown". assert!( created.remove(&peer), "on_delete must evict the leaving peer from `created`" ); assert!(!created.contains(&peer)); - // Re-entry arrives as an on_update, not on_insert (the SDK's refcounted cache still holds - // the row). The callback's actual dispatch predicate must say "treat as re-entry". assert!(is_update_reentry(peer, self_guid, created.contains(&peer))); - // `offer_peer_create`'s gate passes (same instance, not stealthed, not a spirit healer) -> - // the dedup insert succeeds -> exactly one CREATE goes out. assert!(peer_create_gate(peer, self_guid, 0, 0, 0, false, false)); assert!( created.insert(peer), "the re-entry offer must (re)insert into `created`" ); - // A THIRD sighting of the same still-visible peer (an ordinary field update, e.g. a - // heartbeat) must NOT be treated as another re-entry — it's already shown, so it diffs - // normally instead of re-CREATEing (which would desync the client). assert!(!is_update_reentry(peer, self_guid, created.contains(&peer))); - // Self never triggers the re-entry branch, even if (hypothetically) it were ever evicted. assert!(!is_update_reentry(self_guid, self_guid, false)); } #[test] fn login_resident_sweep_creates_every_pre_existing_peer_exactly_once_work_item_145() { - // The login-sweep scenario: a fresh login's AOI box already contains peers/creatures (rows - // resident in the connection's cache from the just-applied subscription) whose - // `on_insert` may or may not have fired. The sweep in `subscribe_player_events` offers - // every resident row through this SAME gate + dedup — model it directly. let self_guid = 1u64; - let residents = [2u64, 3, 4]; // pre-existing peers already in the login AOI box + let residents = [2u64, 3, 4]; let mut created: HashSet = HashSet::from([self_guid]); let mut creates_sent = 0; @@ -951,8 +873,6 @@ fn login_resident_sweep_creates_every_pre_existing_peer_exactly_once_work_item_1 ); assert!(residents.iter().all(|g| created.contains(g))); - // Idempotency (the sweep's own correctness claim): if the SDK ALSO fired an apply-time - // on_insert for one resident before the sweep runs, the sweep must not double-create it. creates_sent = 0; for guid in residents { if peer_create_gate(guid, self_guid, 0, 0, 0, false, false) && created.insert(guid) { @@ -967,9 +887,6 @@ fn login_resident_sweep_creates_every_pre_existing_peer_exactly_once_work_item_1 #[test] fn login_resident_sweep_excludes_cross_instance_residents_work_item_190() { - // A row resident in the connection's cache from a DIFFERENT instance than the viewer - // (always a no-op today since every entity is instance 0, but the - // gate must already hold the line) must never be swept into `created`. let self_guid = 1u64; let viewer_instance_id = 0u64; let same_instance_peer = 2u64; @@ -998,9 +915,7 @@ fn login_resident_sweep_excludes_cross_instance_residents_work_item_190() { #[test] fn corpse_and_gameobject_creates_relay_only_within_the_viewers_instance_work_item_190_slice_2() { - // Open-world rows to an open-world viewer: byte-identical to the previous behavior (always relayed). assert!(instance_relay_gate(0, Some(0))); - // A per-instance GO copy / in-instance corpse reaches ONLY viewers inside that instance… assert!(instance_relay_gate(7, Some(7))); assert!( !instance_relay_gate(7, Some(0)), @@ -1010,11 +925,7 @@ fn corpse_and_gameobject_creates_relay_only_within_the_viewers_instance_work_ite !instance_relay_gate(7, Some(9)), "another party's instance must not see them either" ); - // …and an instanced viewer no longer receives the static instance-0 originals (they are the - // COPY SOURCES on the dungeon map — the copies are what render inside the run). assert!(!instance_relay_gate(0, Some(7))); - // Unknown viewer instance (entity not resident — the AOI-on login window) relays NOTHING, - // not even instance 0: suppress-then-sweep, never guess. assert!(!instance_relay_gate(0, None)); assert!(!instance_relay_gate(7, None)); } diff --git a/gateway/src/codec/gameobject.rs b/gateway/src/codec/gameobject.rs index c7d943b32..7a8a4b138 100644 --- a/gateway/src/codec/gameobject.rs +++ b/gateway/src/codec/gameobject.rs @@ -1,11 +1,5 @@ -//! Gameobject wire mapping: the GameObject CREATE_OBJECT (a stationary world prop) + the -//! `SMSG_GAMEOBJECT_QUERY_RESPONSE` template reply. The CREATE_OBJECT mirrors `corpse.rs` exactly — a -//! stationary `HasPosition` object — but with the `UpdateGameObject` descriptor. The descriptor is a -//! FULL build via `finalize()` (not a partial VALUES), so there is no OBJECT_FIELD_TYPE crash risk. - use super::*; -/// A `game_gameobject` row joined with its template, flattened for the CREATE_OBJECT. #[derive(Clone, Debug, Default)] pub struct GameObjectView { pub guid: u64, @@ -17,20 +11,13 @@ pub struct GameObjectView { pub state: u8, pub type_id: u8, pub display_id: u32, - /// The cmangos spawn quaternion, `GAMEOBJECT_ROTATION`, the field the 5875 client - /// actually renders a static prop's orientation from. All-zero means "no quaternion stored" - /// (every row without rotation data and every hand-seeded fixture): `build_gameobject_rotation_values` derives - /// a yaw-only fallback from `orientation` in that case rather than sending a degenerate rotation. pub rotation_0: f32, pub rotation_1: f32, pub rotation_2: f32, pub rotation_3: f32, - /// The template's render scale. Non-positive means no size stored — see `object_scale_x`. pub size: f32, } -/// The `OBJECT_FIELD_SCALE_X` to send for a gameobject. Guards `> 0.0` rather than `!= 0.0` so a -/// corrupt negative or a NaN falls back too — a 0 on the wire renders the prop invisible. fn object_scale_x(size: f32) -> f32 { if size > 0.0 { size @@ -39,7 +26,6 @@ fn object_scale_x(size: f32) -> f32 { } } -/// A `game_gameobject_template` row, for the `CMSG_GAMEOBJECT_QUERY` reply. #[derive(Clone, Debug, Default)] pub struct GameObjectTemplateView { pub type_id: u8, @@ -47,14 +33,9 @@ pub struct GameObjectTemplateView { pub name: String, pub data0: u32, pub data1: u32, - /// A Meeting Stone's dungeon area. 0 for every other type. pub data2: u32, } -/// Build the CREATE_OBJECT for a gameobject: a GAMEOBJECT-type object with a stationary position -/// (`HasPosition`, like a corpse) carrying the `UpdateGameObject` descriptors (display, state, type, -/// position) so the client renders the prop. Relayed on `game_gameobject` insert; the matching -/// `SMSG_DESTROY_OBJECT` on delete reuses `build_destroy_object`. Full descriptor → no VALUES crash risk. pub fn build_gameobject_create_object(go: &GameObjectView) -> SMSG_UPDATE_OBJECT { let mask = UpdateGameObject::builder() .set_object_guid(Guid::new(go.guid)) @@ -92,13 +73,7 @@ pub fn build_gameobject_create_object(go: &GameObjectView) -> SMSG_UPDATE_OBJECT } } -/// Build the raw `GAMEOBJECT_ROTATION` VALUES update, the 4-float spawn quaternion the -/// client actually orients a static prop's model from. gtker's typed builder only exposes -/// `set_gameobject_rotation(f32)`, which reaches slot 0 alone (the same descriptor-setter wall as the -/// multi-aura array), so all 4 slots (rot0..3, wire index `GAMEOBJECT_ROTATION`..+3) ride the -/// hand-rolled raw encoder instead. Not a partial UNIT/PLAYER update — `GAMEOBJECT_ROTATION` (10) never -/// collides with `OBJECT_FIELD_TYPE` (2), so `build_values_update_raw`'s debug_assert is inert here by -/// construction, same as every other GAMEOBJECT field. +/// gtker's rotation setter reaches only slot 0 of the four-float quaternion. pub fn build_gameobject_rotation_values(go: &GameObjectView) -> (u16, Vec) { let (rot0, rot1, rot2, rot3) = if go.rotation_0 == 0.0 && go.rotation_1 == 0.0 @@ -118,8 +93,6 @@ pub fn build_gameobject_rotation_values(go: &GameObjectView) -> (u16, Vec) { build_values_update_raw(go.guid, &mask) } -/// Build `SMSG_GAMEOBJECT_QUERY_RESPONSE` (the client asks for a GO template's name/type/display before -/// it can render/interact). `None` → the not-found form (`entry_id` with the high bit set, per protocol). pub fn build_gameobject_query_response( entry: u32, template: Option<&GameObjectTemplateView>, @@ -145,11 +118,6 @@ pub fn build_gameobject_query_response( } } -/// A ground-area spell's DYNAMICOBJECT CREATE (Consecration's swirl): the 5875 client renders -/// the persistent ground effect from `DYNAMICOBJECT_SPELLID`'s SpellVisual, sized by RADIUS at -/// POS — it never draws it from the cast packets alone (live find). Same stationary -/// `CreateObject2` + `HasPosition` shape as the gameobject CREATE above; entry = the spell id and -/// bytes = (1,0,0,0) = DYNAMIC_OBJECT_AREA_SPELL — the value the 5875 client expects here. pub fn build_dynamicobject_create_object( guid: u64, caster_guid: u64, @@ -164,7 +132,7 @@ pub fn build_dynamicobject_create_object( .set_object_entry(spell_id as i32) .set_object_scale_x(1.0) .set_dynamicobject_caster(Guid::new(caster_guid)) - .set_dynamicobject_bytes(1, 0, 0, 0) // DYNAMIC_OBJECT_AREA_SPELL + .set_dynamicobject_bytes(1, 0, 0, 0) .set_dynamicobject_spellid(spell_id as i32) .set_dynamicobject_radius(radius) .set_dynamicobject_pos_x(x) @@ -197,8 +165,6 @@ mod tests { #[test] fn gameobject_create_object_carries_descriptor_fields_and_serializes() { - // Was WEAK (only asserted non-empty serialization); now pins the actual descriptor fields - // the client renders the prop from, plus the object-type/mask shape, before serializing. let go = GameObjectView { guid: (0xF110u64 << 48) | 1, template_entry: 50100, @@ -206,8 +172,8 @@ mod tests { y: -139.0, z: 83.5, orientation: 0.0, - state: 1, // open — distinct from the enum's 0 default, so a dropped setter call is caught - type_id: 3, // CHEST + state: 1, + type_id: 3, display_id: 259, rotation_0: 0.0, rotation_1: 0.0, @@ -233,8 +199,6 @@ mod tests { } other => panic!("expected a GameObject CreateObject2, got {other:?}"), } - // The full GAMEOBJECT CREATE_OBJECT must still serialize without panic (stationary - // descriptor like a corpse). let msg = ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(built)); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); @@ -268,14 +232,10 @@ mod tests { } } - /// Decode a `build_gameobject_rotation_values` body: strips the fixed envelope (amount_of_objects, - /// has_transport, update_type, packed guid — byte-identical to `build_values_update_raw`'s other - /// callers, pinned separately by `raw_values_body_matches_gtker_envelope`) and reads the 4 - /// consecutive `GAMEOBJECT_ROTATION` float slots in ascending index order. Test-only. fn decode_rotation_floats(guid: u64, body: &[u8]) -> [f32; 4] { let mut packed_guid = Vec::new(); super::write_packed_guid_u64(&mut packed_guid, guid); - let mask_start = 4 + 1 + 1 + packed_guid.len(); // amount_of_objects + has_transport + update_type + guid + let mask_start = 4 + 1 + 1 + packed_guid.len(); let block_count = body[mask_start] as usize; let values_start = mask_start + 1 + block_count * 4; let mut out = [0.0f32; 4]; @@ -290,9 +250,6 @@ mod tests { #[test] fn gameobject_rotation_values_passes_through_a_stored_nonzero_quaternion() { - // A non-degenerate stored quaternion (e.g. a bench flush against a sloped wall, rot0/rot1 - // nonzero) must ride the wire UNCHANGED — no derive-from-orientation fallback applies once - // ANY of the 4 stored slots is nonzero. let go = GameObjectView { guid: 5, template_entry: 446, @@ -301,7 +258,7 @@ mod tests { z: 55.0, orientation: 1.57, state: 0, - type_id: 0, // DOOR + type_id: 0, display_id: 259, rotation_0: 0.1, rotation_1: 0.2, @@ -317,17 +274,13 @@ mod tests { #[test] fn gameobject_rotation_values_derives_yaw_only_from_orientation_when_quaternion_is_all_zero() { - // Every row without rotation data and every hand-seeded fixture stores an all-zero quaternion, that must - // NOT ride verbatim (a real spawn's quaternion is never exactly (0,0,0,0); sending it as-is - // is the exact tilted-bench bug this issue reports). Derive rot2/rot3 = sin(o/2)/cos(o/2) - // (Z-axis-only yaw) instead, leaving rot0/rot1 (terrain pitch) at 0. let go = GameObjectView { guid: 5, template_entry: 446, x: -9450.0, y: -90.0, z: 55.0, - orientation: std::f32::consts::FRAC_PI_2, // 90° + orientation: std::f32::consts::FRAC_PI_2, state: 0, type_id: 0, display_id: 259, @@ -373,8 +326,6 @@ mod tests { assert!(missing.found.is_none()); } - /// A Meeting Stone's tooltip reads its level range from `data0`/`data1` and its dungeon from - /// `data2` (cm:Entities/GameObject.h:266-272). #[test] fn a_meeting_stone_query_response_carries_its_level_range_and_area() { let stone = GameObjectTemplateView { diff --git a/gateway/src/codec/gameobject/subscriptions.rs b/gateway/src/codec/gameobject/subscriptions.rs index e15c4a942..6a5614c59 100644 --- a/gateway/src/codec/gameobject/subscriptions.rs +++ b/gateway/src/codec/gameobject/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::entity::subscriptions::instance_relay_gate; use crate::codec::{self}; use crate::stdb::bindings::*; @@ -32,11 +30,7 @@ pub(crate) fn relay_gameobject_create( return Vec::new(); }; let view = go_view(row.clone(), &tmpl); - // The CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn - // quaternion (gtker's slot-0-only wall), so the other 3 slots — and the yaw-derive fallback for - // an all-zero stored quaternion — ride a second raw VALUES frame right behind CREATE. The client - // has already materialized the object by the time this second frame arrives (same ordering the - // aura/quest-log raw-VALUES relays rely on elsewhere in this file). + // CREATE carries only rot0; send all four quaternion slots in the following VALUES frame. let (rot_opcode, rot_body) = codec::build_gameobject_rotation_values(&view); vec![ Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new( @@ -96,8 +90,7 @@ pub(crate) fn gameobject_vanished(view: &WorldView, shard: ShardId, guid: u64) { return; }; for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - // DESTROY stays ungated (the `on_melee_delete` precedent): SMSG_DESTROY_OBJECT for a guid - // the client never created is a client no-op, and gating it risks a stale object. + // DESTROY for an unknown guid is a client no-op; gating it can leave stale objects. enqueue(viewer.clone(), move |viewer| { crate::codec::gameobject::subscriptions::relay_gameobject_destroy(&viewer, guid) }); diff --git a/gateway/src/codec/guild.rs b/gateway/src/codec/guild.rs index 06d06af9d..7659082ca 100644 --- a/gateway/src/codec/guild.rs +++ b/gateway/src/codec/guild.rs @@ -1,6 +1,3 @@ -//! Guild wire mapping: the query response, roster, guild info, command result, the raw -//! SMSG_GUILD_EVENT, the Guild Projection values update and the petition messages. - use super::*; use wow_world_messages::vanilla::{ GuildCommand, GuildCommandResult, GuildMember, GuildMember_GuildMemberStatus, PetitionResult, @@ -9,14 +6,11 @@ use wow_world_messages::vanilla::{ SMSG_PETITION_SHOWLIST, SMSG_PETITION_SHOW_SIGNATURES, SMSG_PETITION_SIGN_RESULTS, }; -/// SMSG_GUILD_EVENT. gtker's vanilla type has no trailing guid, so the event is encoded raw. +// SMSG_GUILD_EVENT needs a trailing guid that gtker's vanilla type omits. pub const SMSG_GUILD_EVENT_OPCODE: u16 = 0x0092; -/// The largest SMSG_GUILD_ROSTER body the 1.12 client accepts: the packet limit minus the header -/// (`vm:src/game/Guild/Guild.h:41`). pub const GUILD_ROSTER_MAX_BODY: usize = 0x8000 - 4; -/// One Guild Rank as the Gateway reads it. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GuildRankView { pub rank_id: u32, @@ -24,13 +18,11 @@ pub struct GuildRankView { pub rights: u32, } -/// One Guild as the Gateway reads it from Realm-core. `ranks` is ordered by `rank_id`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GuildView { pub guild_id: u32, pub name: String, pub leader_guid: u64, - /// `lyracore_shared::faction::TEAM_*`, fixed at founding. pub team: u32, pub motd: String, pub info: String, @@ -44,7 +36,6 @@ pub struct GuildView { } impl GuildView { - /// The Rank Rights of `rank_id`. An unknown rank grants nothing (`cm:Guild.cpp:660-666`). pub fn rank_rights(&self, rank_id: u32) -> u32 { self.ranks .iter() @@ -53,20 +44,17 @@ impl GuildView { } } -/// One membership row as the Gateway reads it from Realm-core. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GuildMemberView { pub character_guid: u64, pub guild_id: u32, pub rank_id: u32, - /// The name snapshot Realm-core keeps for the member. pub name: String, pub public_note: String, pub officer_note: String, pub realm_account_id: u64, } -/// One SMSG_GUILD_ROSTER line, already resolved for one viewer. #[derive(Clone, Debug, Default, PartialEq)] pub struct GuildRosterLine { pub guid: u64, @@ -75,15 +63,11 @@ pub struct GuildRosterLine { pub level: u8, pub class: u8, pub zone_id: u32, - /// `None` when online; otherwise days since the member's last logout. pub days_offline: Option, pub public_note: String, - /// Empty unless the viewer's Guild Rank holds VIEWOFFNOTE. pub officer_note: String, } -/// SMSG_GUILD_QUERY_RESPONSE: the name, ten rank names (empty past the last rank) and the emblem -/// (`cm:Guild.cpp:760-783`). pub fn build_guild_query_response(guild: &GuildView) -> SMSG_GUILD_QUERY_RESPONSE { let mut rank_names: [String; 10] = Default::default(); for (slot, rank) in rank_names.iter_mut().zip(&guild.ranks) { @@ -101,7 +85,6 @@ pub fn build_guild_query_response(guild: &GuildView) -> SMSG_GUILD_QUERY_RESPONS } } -/// SMSG_GUILD_COMMAND_RESULT for `command`, naming `name` where the client line has a `%s`. pub fn build_guild_command_result( command: GuildCommand, name: String, @@ -114,8 +97,6 @@ pub fn build_guild_command_result( } } -/// SMSG_GUILD_INFO: the name, the founding day, month (1..=12) and year on the Realm Clock, the -/// member count and the Account count (`cm:GuildHandler.cpp:240-259`). pub fn build_guild_info( guild: &GuildView, member_count: u32, @@ -133,8 +114,6 @@ pub fn build_guild_info( } } -/// SMSG_GUILD_ROSTER. Lines are added in order until the next one would push the body past -/// [`GUILD_ROSTER_MAX_BODY`] (`vm:src/game/Guild/Guild.cpp:803-860`); the rest are left out. pub fn build_guild_roster( guild: &GuildView, lines: impl IntoIterator, @@ -172,8 +151,6 @@ pub fn build_guild_roster( } } -/// Bytes one roster line takes on the wire: guid, status, name, rank, level, class, zone, the -/// offline time when offline, and both notes. fn roster_line_size(line: &GuildRosterLine) -> usize { const FIXED: usize = 8 + 1 + 4 + 1 + 1 + 4; let offline = if line.days_offline.is_some() { 4 } else { 0 }; @@ -181,8 +158,6 @@ fn roster_line_size(line: &GuildRosterLine) -> usize { FIXED + offline + strings.iter().map(|s| s.len() + 1).sum::() } -/// SMSG_GUILD_INVITE (`gtker:shared/smsg_guild_invite_vanilla_tbc_wrath.rs`): the inviter's name and -/// the Guild's name (`cm:GuildHandler.cpp:124-131`). pub fn build_guild_invite(player_name: String, guild_name: String) -> SMSG_GUILD_INVITE { SMSG_GUILD_INVITE { player_name, @@ -190,9 +165,7 @@ pub fn build_guild_invite(player_name: String, guild_name: String) -> SMSG_GUILD } } -/// SMSG_GUILD_DECLINE (opcode 0x086). No vanilla type in gtker (TBC and Wrath only), so encoded -/// raw: one CString, the declining Character's name (`cm:GuildHandler.cpp:229-231`, -/// `cm:Opcodes.cpp:162`). +/// SMSG_GUILD_DECLINE is 0x086 with one CString; gtker has no vanilla type. pub const SMSG_GUILD_DECLINE_OPCODE: u16 = 0x0086; pub fn build_guild_decline_raw(player_name: &str) -> (u16, Vec) { @@ -201,8 +174,6 @@ pub fn build_guild_decline_raw(player_name: &str) -> (u16, Vec) { (SMSG_GUILD_DECLINE_OPCODE, body) } -/// SMSG_GUILD_EVENT as `(opcode, body)`: `u8 event`, `u8 count`, the strings, then the subject guid -/// when nonzero (`cm:Guild.cpp:886-911`). pub fn build_guild_event_raw(kind: u8, strings: &[String], subject_guid: u64) -> (u16, Vec) { let strings = &strings[..strings.len().min(usize::from(u8::MAX))]; let mut body = vec![kind, strings.len() as u8]; @@ -216,19 +187,15 @@ pub fn build_guild_event_raw(kind: u8, strings: &[String], subject_guid: u64) -> (SMSG_GUILD_EVENT_OPCODE, body) } -/// One open Petition as the Gateway reads it from Realm-core. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PetitionView { pub petition_id: u32, pub charter_item_guid: u64, pub owner_guid: u64, pub name: String, - /// The signers, in slot order. A lost Signature frees its slot for the next signer. pub signers: Vec, } -/// SMSG_PETITION_SHOWLIST: the one Guild Charter a Petitioner sells. The entry flag is 1, which the -/// client needs to show the entry (`vm:src/game/Handlers/PetitionsHandler.cpp:495-500`). pub fn build_petition_showlist(npc_guid: u64) -> SMSG_PETITION_SHOWLIST { use lyracore_shared::guild::{CHARTER_COST_COPPER, CHARTER_DISPLAY_ID, GUILD_CHARTER_ENTRY}; SMSG_PETITION_SHOWLIST { @@ -243,8 +210,6 @@ pub fn build_petition_showlist(npc_guid: u64) -> SMSG_PETITION_SHOWLIST { } } -/// SMSG_PETITION_SHOW_SIGNATURES: the Charter, its owner, the Petition id and each signer with a -/// zero word (`cm:PetitionsHandler.cpp:201-217`). pub fn build_petition_show_signatures(petition: &PetitionView) -> SMSG_PETITION_SHOW_SIGNATURES { SMSG_PETITION_SHOW_SIGNATURES { item: Guid::new(petition.charter_item_guid), @@ -261,8 +226,6 @@ pub fn build_petition_show_signatures(petition: &PetitionView) -> SMSG_PETITION_ } } -/// SMSG_PETITION_QUERY_RESPONSE: the id, owner and name, an empty body, flags 1, nine signatures -/// minimum and maximum, and zero for every restriction (`cm:PetitionsHandler.cpp:260-279`). pub fn build_petition_query_response(petition: &PetitionView) -> SMSG_PETITION_QUERY_RESPONSE { use lyracore_shared::guild::{MAX_PETITION_SIGNATURES, MIN_PETITION_SIGNATURES}; SMSG_PETITION_QUERY_RESPONSE { @@ -276,8 +239,7 @@ pub fn build_petition_query_response(petition: &PetitionView) -> SMSG_PETITION_Q } } -/// SMSG_PETITION_SIGN_RESULTS: the Charter, the signer and the result -/// (`cm:PetitionsHandler.cpp:384-387`). gtker names the signer field `owner`. +/// gtker's `owner` field carries the signer guid. pub fn build_petition_sign_results( charter_item_guid: u64, signer_guid: u64, @@ -416,7 +378,6 @@ mod tests { let entity = EntityView { guid: 1, type_mask: lyracore_shared::constants::type_mask::PLAYER_BIT, - // Human Warrior: the CREATE encoder refuses race 0. unit_bytes_0: 1 | (1 << 8) | (1 << 24), guild_id, guild_rank, @@ -474,7 +435,6 @@ mod tests { #[test] fn guild_info_reads_the_founding_date_in_whole_utc_days() { let mut guild = guild(); - // 2026-09-23T23:59:59Z guild.created_micros = 1_790_207_999_000_000; let info = build_guild_info(&guild, 3, 2); assert_eq!( @@ -503,14 +463,10 @@ mod tests { let message = ServerOpcodeMessage::SMSG_GUILD_ROSTER(Box::new(roster.clone())); let mut wire = Vec::new(); message.write_unencrypted_server(&mut wire).unwrap(); - // Header: count 4 + MOTD 16 + info 1 + rank count 4 + five rank words 20 = 45 bytes. - // Each line: 8 + 1 + 11 + 4 + 1 + 1 + 4 + 4 + 32 + 32 = 98 bytes. - // (32764 - 45) / 98 = 333 lines, 45 + 333 * 98 = 32679 bytes. assert_eq!(roster.members.len(), 333); assert_eq!(wire.len() - 4, 32_679); } - /// Frame a client body the way the 1.12 client sends it: u16 big-endian size, u32 opcode. fn client_packet(opcode: u32, body: &[u8]) -> ClientOpcodeMessage { let mut framed = ((body.len() + 4) as u16).to_be_bytes().to_vec(); framed.extend_from_slice(&opcode.to_le_bytes()); @@ -524,7 +480,6 @@ mod tests { (u16::from_le_bytes([wire[2], wire[3]]), wire[4..].to_vec()) } - /// `cm:GuildHandler.cpp:722-723`: the vendor guid, then five u32 design values. #[test] fn save_guild_emblem_reads_the_vendor_then_five_design_values() { let mut body = 0xF130_0000_0000_0042u64.to_le_bytes().to_vec(); @@ -547,7 +502,6 @@ mod tests { ); } - /// `cm:GuildHandler.cpp:766-771` writes one u32; `cm:Guild.h:146-151` numbers the results. #[test] fn save_guild_emblem_answers_one_result_word() { use wow_world_messages::vanilla::{GuildEmblemResult, MSG_SAVE_GUILD_EMBLEM_Server}; @@ -564,8 +518,6 @@ mod tests { } } - /// `cm:NPCHandler.cpp:49-50,62-67`: the client sends the NPC's full guid and the server echoes - /// it unpacked. #[test] fn tabard_vendor_activate_carries_the_full_npc_guid_both_ways() { use wow_world_messages::vanilla::MSG_TABARDVENDOR_ACTIVATE; @@ -582,7 +534,6 @@ mod tests { assert_eq!(server_body(window), (0x01F2, guid.to_le_bytes().to_vec())); } - /// GE_TABARDCHANGE is event 9 with no strings and no guid (`cm:Guild.h:111`). #[test] fn tabard_changed_is_event_nine_with_nothing_after_it() { let (opcode, body) = @@ -600,8 +551,6 @@ mod tests { } } - /// `vm:src/game/Server/Packets/Petition.cpp:175-187`: the NPC guid, a u8 count, then index 1, - /// entry 5863, display 16161, cost 1000 and entry flag 1. #[test] fn petition_showlist_lists_the_guild_charter_at_ten_silver() { let mut expected = 0xF130_0000_0000_0042u64.to_le_bytes().to_vec(); @@ -615,8 +564,6 @@ mod tests { assert_eq!(server_body(message), (0x01BC, expected)); } - /// `cm:PetitionsHandler.cpp:201-217`: Charter guid, owner guid, u32 petition id, u8 count, - /// then each signer guid with a zero word. #[test] fn petition_show_signatures_lists_each_signer_with_a_zero_word() { let mut expected = vec![0x01, 0x01, 0, 0, 0, 0, 0, 0x40]; @@ -631,22 +578,17 @@ mod tests { assert_eq!(server_body(message), (0x01BF, expected)); } - /// `cm:PetitionsHandler.cpp:260-279`. #[test] fn petition_query_response_matches_the_mangos_layout() { let mut expected = vec![0x2A, 0, 0, 0]; expected.extend_from_slice(&[0x02, 0x01, 0, 0, 0, 0, 0, 0]); expected.extend_from_slice(b"Night Watch\0"); - // Empty body text, then flags 1, minimum 9 and maximum 9 signatures. expected.push(0); for word in [1u32, 9, 9] { expected.extend_from_slice(&word.to_le_bytes()); } - // Deadline, issue date, guild id, classes and races: five zero words. expected.extend_from_slice(&[0; 20]); - // Genders, a u16. expected.extend_from_slice(&[0; 2]); - // Minimum and maximum level, choice text, number of choices: four zero words. expected.extend_from_slice(&[0; 16]); let message = ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(Box::new( build_petition_query_response(&night_watch()), @@ -654,8 +596,6 @@ mod tests { assert_eq!(server_body(message), (0x01C7, expected)); } - /// `cm:PetitionsHandler.cpp:384-387`: Charter guid, signer guid, u32 result. ALREADY_SIGNED - /// is 1 (`cm:Guild.h:118-132`). #[test] fn petition_sign_results_carry_the_charter_the_signer_and_the_result() { let mut expected = vec![0x01, 0x01, 0, 0, 0, 0, 0, 0x40]; @@ -670,7 +610,6 @@ mod tests { assert_eq!(server_body(message), (0x01C1, expected)); } - /// `cm:PetitionsHandler.cpp:631-633`: one u32 result. NEED_MORE is 4 (`cm:Guild.h:118-132`). #[test] fn turn_in_petition_results_are_one_word() { use wow_world_messages::vanilla::SMSG_TURN_IN_PETITION_RESULTS; @@ -681,8 +620,6 @@ mod tests { assert_eq!(server_body(message), (0x01C5, vec![4, 0, 0, 0])); } - /// `cm:PetitionsHandler.cpp:49-73`: CMSG_PETITION_BUY carries the NPC guid, skips a u32 and a - /// u64, then the name, ten u32, a u16, a u8, the index and a last u32. #[test] fn petition_buy_reads_the_npc_and_the_name() { let mut body = 0xF130_0000_0000_0042u64.to_le_bytes().to_vec(); diff --git a/gateway/src/codec/guild/subscriptions.rs b/gateway/src/codec/guild/subscriptions.rs index 7e787fff5..82c4a15d2 100644 --- a/gateway/src/codec/guild/subscriptions.rs +++ b/gateway/src/codec/guild/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::EntityLayer; @@ -9,17 +7,12 @@ use crate::world::Outbound; use std::sync::{Arc, OnceLock}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// The Guild Projection of `character_guid`, read from the Realm-core cache. `(0, 0)` when -/// Realm-core cannot answer: the next membership relay corrects it. pub(crate) fn guild_projection_of(coord: &Coordinator, character_guid: u64) -> (u32, u32) { coord .realm_core() .map_or((0, 0), |realm| realm.guild_projection(character_guid)) } -/// Relay the current Guild Projection of `character_guid` to a viewer that holds its entity: the -/// member itself or a viewer whose `created` set has the guid. `projection` is read when the job -/// runs, so a delayed job never writes a value older than the cache. pub(crate) fn guild_values_outbound( viewer: &Viewer, character_guid: u64, @@ -35,9 +28,6 @@ pub(crate) fn guild_values_outbound( vec![Outbound::Raw { opcode, body }] } -/// Render one Guild Event row. The row carries its final strings. Kinds 0x40 (INVITE) and 0x41 -/// (DECLINE) are membership offers with their own wire shape; every other kind not built from a -/// shared snapshot (see `GuildEventSnapshot`) rides the generic SMSG_GUILD_EVENT builder. pub(crate) fn guild_event_outbound(row: &GuildEvent) -> Vec { match row.kind { lyracore_shared::guild::event_kind::INVITE => { @@ -60,10 +50,6 @@ pub(crate) fn guild_event_outbound(row: &GuildEvent) -> Vec { } } -/// Render one petition Guild Event (`0x70..=0x7F`). The Charter is in `other_guid` and the -/// signer or decliner in `subject_guid`. `petition` is the Petition of that Charter, read when the -/// job runs; only PETITION_OFFERED and PETITION_CHANGED need it, and a Petition that is gone by -/// then renders nothing. pub(crate) fn petition_event_outbound( row: &GuildEvent, petition: Option<&codec::PetitionView>, @@ -142,10 +128,6 @@ pub(crate) fn petition_read(realm: &Coordinator) -> PetitionRead { Arc::new(move |charter_item_guid| realm.guild_petition_of_charter(charter_item_guid)) } -/// A membership row changed: re-send the member's Guild Projection to the member and to every -/// viewer that holds its entity. The member is always addressed, as `cell_audience` keeps its owner -/// leg, so an entity the cell index has not seen yet cannot hide a removal from the member's own -/// client. The job reads membership when it runs, so after a delete it sends 0 and 0. pub(crate) fn guild_membership_changed( view: &WorldView, membership: &GuildMembershipRead, @@ -181,14 +163,6 @@ pub(crate) fn guild_membership_changed( } } -/// A Guild Event landed. An addressed row reaches its recipient; a broadcast row reaches every -/// online member on this Gateway. A member signing on does not hear its own SIGNED_ON: mangos -/// broadcasts it before the Character is in the world (`cm:CharacterHandler.cpp:787`). -/// -/// `TABARD_CHANGED`, `ROSTER_TO_ACTOR` and `ROSTER_REFRESH` also carry a query response, a roster, -/// or both; the first recipient's job reads the Guild (and, for the two roster kinds, its roster) -/// once through `snapshot_read` and every other recipient's job reuses that one snapshot, so a -/// broadcast to a large Guild costs one Realm-core read, not one per online member. pub(crate) fn guild_event_appeared( view: &WorldView, membership: &GuildMembershipRead, @@ -271,8 +245,6 @@ pub(crate) fn guild_event_appeared( } } -/// A petition Guild Event landed: it reaches its one addressed recipient. The two kinds that show -/// a Petition read it when the job runs, once per event. pub(crate) fn petition_event_appeared( view: &WorldView, petitions: &PetitionRead, @@ -303,8 +275,6 @@ pub(crate) fn petition_event_appeared( }); } -/// A Petition opened: its owner's Guild Charter shows the Petition id in ITEM_FIELD_ENCHANTMENT, -/// where the client reads it back for its petition queries (`cm:PetitionsHandler.cpp:141`). pub(crate) fn charter_petition_opened(view: &WorldView, row: &GuildPetition) { let Some(viewer) = view .session_of_owner(row.owner_guid) diff --git a/gateway/src/codec/guild/subscriptions/audience_tests.rs b/gateway/src/codec/guild/subscriptions/audience_tests.rs index af0f13bf4..f96de6631 100644 --- a/gateway/src/codec/guild/subscriptions/audience_tests.rs +++ b/gateway/src/codec/guild/subscriptions/audience_tests.rs @@ -30,15 +30,10 @@ fn membership(rows: &[(u64, u32, u32)]) -> GuildMembershipRead { }) } -/// The broadcast (`0..=13`) and addressed-membership (`0x40..`) kinds these audience tests use -/// never read a Guild snapshot, so a closure that panics if called proves it. fn unreachable_snapshot_read() -> GuildRosterSnapshotRead { Arc::new(|_guild_id, _kind| panic!("this Guild Event kind must not read a snapshot")) } -/// A snapshot read that always answers `snapshot` and counts its own calls, so a test can -/// assert a settings-change event reads the Realm-core cache once and shares the result with -/// every recipient. fn counting_snapshot_read( snapshot: GuildEventSnapshot, ) -> (GuildRosterSnapshotRead, Arc) { @@ -51,8 +46,6 @@ fn counting_snapshot_read( (read, reads) } -/// SMSG_GUILD_ROSTER for one member whose officer note is `officer_note`, so two calls with -/// different notes give two packets a test can tell apart by content. fn roster_variant(officer_note: &str) -> ServerOpcodeMessage { let guild = crate::codec::GuildView { guild_id: 7, @@ -139,7 +132,6 @@ fn a_broadcast_guild_event_reaches_online_members_and_nobody_else() { let mut receivers = Vec::new(); for (session, guid) in [(1, 9001), (2, 9002), (3, 9003), (4, 9004)] { let (tx, rx) = SessionTx::with_depth(0); - // Members stand on different Shards: the audience is the Guild, not the Shard. view.add_viewer_on_shard( viewer_with_tx(session, guid, identity(session as u8), tx), anchor, @@ -191,8 +183,6 @@ fn an_addressed_guild_event_reaches_only_its_recipient() { ); view.add_viewer_on_shard(viewer_with_tx(2, 9001, identity(2), member_tx), anchor, 0); - // DISBANDED (0x08) is addressed per former member, so it exercises the generic addressed - // path through the raw SMSG_GUILD_EVENT builder. guild_event_appeared( &view, &membership(&[(9001, 7, 0)]), @@ -258,9 +248,6 @@ fn tabard_changed_reaches_every_online_member_with_the_new_emblem() { ); } -/// `0x50` (`event_kind::ROSTER_TO_ACTOR`) is addressed, so it takes the same recipient-only -/// path SIGNED_ON's twin above already proves. This test pins that the actor's job reads the -/// shared snapshot and picks the roster its own Guild Rank sees. #[test] fn roster_to_actor_reaches_only_the_actor_with_the_roster_its_rank_sees() { use lyracore_shared::guild::event_kind; @@ -315,10 +302,6 @@ fn roster_to_actor_with_no_membership_row_sends_nothing() { assert!(queued_job(&actor_rx).is_empty()); } -/// The same guard also has to check which Guild the membership row names: `map_or(0, ...)` -/// ignored the returned guild_id, so a member who moved to another Guild between this job's -/// enqueue and its run would still get a roster, rendered under its new Guild's own Rank -/// Rights. #[test] fn roster_to_actor_with_membership_in_another_guild_sends_nothing() { use lyracore_shared::guild::event_kind; @@ -337,10 +320,6 @@ fn roster_to_actor_with_membership_in_another_guild_sends_nothing() { assert!(queued_job(&actor_rx).is_empty()); } -/// `0x80` (`event_kind::ROSTER_REFRESH`) is a broadcast kind, so it takes the same audience -/// path the SIGNED_OFF broadcast test above already proves. This test pins that every online -/// member's own job reads one shared snapshot and picks the roster its own rank sees, so a -/// large Guild costs one Realm-core read for the whole broadcast, not one per member. #[test] fn roster_refresh_gives_each_online_member_the_roster_its_own_rank_sees() { use lyracore_shared::guild::event_kind; @@ -456,7 +435,6 @@ fn night_watch() -> crate::codec::PetitionView { } } -/// A Petition read that answers `petition` for the Charter and counts its calls. fn counting_petition_read( petition: Option, ) -> (PetitionRead, Arc) { @@ -484,7 +462,6 @@ fn petition_event(recipient_guid: u64, kind: u8, subject_guid: u64) -> GuildEven } } -/// Registers the recipient 9003 and a bystander 9004 on one shard. fn recipient_and_bystander(view: &WorldView) -> (Receiver, Receiver) { let anchor = CellKey::at(0, 0, 0, 0); let (recipient_tx, recipient_rx) = SessionTx::with_depth(0); @@ -690,9 +667,6 @@ fn a_disband_reaches_each_former_member_exactly_once() { receivers.push(rx); } let [alice_rx, bob_rx] = <[_; 2]>::try_from(receivers).unwrap(); - // A disbanded Guild's members are already gone by the time the relay job runs, so - // `membership` answers `None` for both. DISBANDED is addressed, not read from current - // membership. let no_members = membership(&[]); guild_event_appeared( @@ -724,11 +698,8 @@ fn a_removed_event_does_not_reach_the_removed_member() { anchor, 0, ); - // The removed Character's viewer is still on this Gateway (mid-logout, or simply still - // connected); its membership is already gone, so `online_audience` no longer includes it. view.add_viewer_on_shard(viewer_with_tx(2, 9002, identity(2), removed_tx), anchor, 0); - // Only 9001 remains a member of guild 7; 9002 was just removed. let members = membership(&[(9001, 7, 0)]); guild_event_appeared( &view, diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index db85c6f5a..78a9e1bb3 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -1,9 +1,6 @@ -//! Item wire mapping: the `CMSG_ITEM_QUERY_SINGLE` reply and the item CREATE_OBJECT, plus the flattened template/instance row views. Pure code-motion out of `mod.rs`. - use super::*; use lyracore_shared::item::{item_class, weapon_subclass, ItemRefusal, Proficiency}; -/// Map vanilla bag coordinates to the Module's slot address. Capacity stays a Module Gate. pub fn inventory_slot(bag: u8, slot: u8) -> Option { match (bag, slot) { (255, 0..=62) => Some(slot), @@ -12,16 +9,12 @@ pub fn inventory_slot(bag: u8, slot: u8) -> Option { } } -/// Recover the bag equipment slot and position for a Module bag-content address. pub fn bag_content_parts(slot: u8) -> Option<(u8, u8)> { (120..192) .contains(&slot) .then(|| (19 + (slot - 120) / 18, (slot - 120) % 18)) } -/// An item-template row as the gateway reads it from `game_item_template`, flattened for the -/// `CMSG_ITEM_QUERY_SINGLE` reply + the item CREATE. Decoupled from the SDK row -/// type. `class`/`subclass` together pick the typed `ItemClassAndSubClass`; the rest map 1:1. #[derive(Clone, Debug, Default)] pub struct ItemTemplateView { pub entry: u32, @@ -40,36 +33,20 @@ pub struct ItemTemplateView { pub damage_min: f32, pub damage_max: f32, pub delay_ms: u32, - // equip stat bonuses (mirrored from module ItemTemplate) pub stat_strength: i32, pub stat_agility: i32, pub stat_stamina: i32, pub stat_intellect: i32, pub stat_spirit: i32, - pub stat_armor: i32, // bonus armor for the tooltip - pub block_value: i32, // shield block value (0 for non-shields) - // On-use/on-equip spell slots 1-2 (id + ItemSpellTriggerType) — drive the client green "Use:" text. + pub stat_armor: i32, + pub block_value: i32, pub spellid_1: u32, pub spelltrigger_1: u8, pub spellid_2: u32, pub spelltrigger_2: u8, - // NOTE: no `container_slots` here. The bag CREATE reads it off `ItemInstanceView` (below), which - // gets it from its own `game_item_template` lookup at read/subscription time — a template mirror - // on this struct would be a second, never-read copy of the same column. - /// Item binding: the cmangos `bonding` value (0=NoBind,1=BoP,2=BoE,3=BoU, - /// 4/5=QuestItem), sent as-is into `SMSG_ITEM_QUERY_SINGLE_RESPONSE.bonding` so the client renders - /// "Binds when picked up/equipped". Trade/mail enforcement lives in the trade-window/mailbox - /// systems — this is query-only. pub bonding: u8, - /// Sheathe posture: the raw cmangos `item_template.sheath` byte — where the weapon stows - /// visually (Worn Shortsword=3 → hip; shields/2H on the back). Passed THROUGH to the wire (cmangos - /// sends `pProto->Sheath` verbatim; gtker's SheatheType variant NAMES don't track the client's - /// actual postures — values rule). + /// Preserve raw sheath values; gtker's SheatheType names differ from cmangos. pub sheath: u8, - // The dropped item_template columns, mirrored straight from the module's - // `ItemTemplate` (no consumer system reads these server-side yet — this view carries them ONLY - // so `build_item_query_response` can fill in the wire fields it already writes hardcoded 0/default - // for — the free gateway win). pub holy_res: i32, pub fire_res: i32, pub nature_res: i32, @@ -96,56 +73,37 @@ pub struct ItemTemplateView { pub random_property: u32, } -/// An owned-item instance as the gateway reads it from `game_item_instance`, -/// joined with its template for the CREATE descriptors. Decoupled from the SDK row type. #[derive(Clone, Debug, Default)] pub struct ItemInstanceView { pub guid: u64, pub entry: u32, - pub owner_guid: u64, // the owning character — ITEM_FIELD_OWNER / ITEM_FIELD_CONTAINED - pub slot: u8, // the inventory slot it occupies (ItemSlot ordinal) + pub owner_guid: u64, + pub slot: u8, pub stack_count: u32, pub durability: u32, pub max_durability: u32, - /// Number of container slots (0 = regular item, > 0 = bag). Drives the CREATE_OBJECT choice: - /// bags are sent as `ObjectType::Container` with `CONTAINER_FIELD_NUM_SLOTS` set so the client - /// opens the bag window. Non-bags use `ObjectType::Item` as before (baseline-safe). pub container_slots: u8, pub random_property_id: u32, - /// The Random Property's three enchant ids, zero where it names none. An owned item's tooltip - /// takes its property stat lines from these; `random_property_id` only names the Suffix. pub random_property_enchant_ids: [u32; 3], - /// `ITEM_FIELD_ITEM_TEXT_ID` — nonzero on a Letter Copy's Plain Letter - /// (`CMSG_MAIL_CREATE_TEXT_ITEM`). Zero means the item carries no readable text. pub item_text_id: u32, - /// ITEM_FIELD_ENCHANTMENT. An item with a permanent enchant carries the enchantment ID the - /// client resolves (`client_enchantment_id`), not the stored ID. A Guild Charter carries its - /// Petition id here, projected from Realm-core (`cm:PetitionsHandler.cpp:141`). Every other - /// item leaves it 0 and unwritten. pub enchantment: u32, } -/// ITEM_FIELD_ENCHANTMENT, the first enchantment word of an item (`cm:UpdateFields.h:48`). pub const ITEM_FIELD_ENCHANTMENT: u16 = 22; -/// A Guild Charter's Petition id as a partial VALUES update of the Charter item. Raw, so it never -/// carries `OBJECT_FIELD_TYPE`. pub fn build_charter_petition_values(charter_item_guid: u64, petition_id: u32) -> (u16, Vec) { let mut mask = update_mask::UpdateMaskValues::new(); mask.set_u32(ITEM_FIELD_ENCHANTMENT, petition_id); build_values_update_raw(charter_item_guid, &mask) } -/// The id words of enchantment slots 3, 4 and 5, where cmangos `Item::SetItemRandomProperties` -/// writes a Random Property's enchant ids. Each slot is three words: id, duration, charges. const RANDOM_PROPERTY_ENCHANT_ID_FIELDS: [u16; 3] = [ ITEM_FIELD_ENCHANTMENT + 3 * 3, ITEM_FIELD_ENCHANTMENT + 4 * 3, ITEM_FIELD_ENCHANTMENT + 5 * 3, ]; -/// A Random Property's enchant ids as a partial VALUES update that follows the item's CREATE. The -/// typed CREATE reaches only enchantment slot 0. `None` when the item has no property enchant ids. +/// The typed CREATE reaches only enchantment slot 0; property slots follow in VALUES. pub fn build_random_property_values(inst: &ItemInstanceView) -> Option<(u16, Vec)> { if inst.random_property_enchant_ids == [0; 3] { return None; @@ -161,32 +119,17 @@ pub fn build_random_property_values(inst: &ItemInstanceView) -> Option<(u16, Vec Some(build_values_update_raw(inst.guid, &mask)) } -/// Build `SMSG_ITEM_QUERY_SINGLE_RESPONSE` so the client caches the item's name/tooltip/icon (the -/// item analogue of the creature query). The 1.12 client only queries an item it has *encountered* -/// (i.e. has an object for), so this is what makes `GetItemInfo`/the bag tooltip resolve a real name -/// instead of staying blank. The response struct derives `Default`, so we set only the load-bearing -/// fields (class/subclass, name, icon display, quality, prices, equip type, level, the weapon's -/// damage + speed, sheathe, durability) and leave the ~40 cosmetic fields at their zero defaults. pub fn build_item_query_response( item: u32, t: Option<&ItemTemplateView>, ) -> SMSG_ITEM_QUERY_SINGLE_RESPONSE { - // Unknown entry → reply `found: None` (NotFound) so the client stops re-asking, matching a real - // server's miss (the creature query just logs + drops, but an item miss would re-query forever). let Some(t) = t else { return SMSG_ITEM_QUERY_SINGLE_RESPONSE { item, found: None }; }; - // ItemClassAndSubClass packs subclass in the high u32, class in the low u32 (e.g. a one-hand - // sword = (7 << 32) | 2 = 0x7_0000_0002). An unknown pairing degrades to the enum's default - // rather than failing the query (the worst case is a wrong category line on the tooltip). + // ItemClassAndSubClass packs subclass in the high u32 and class in the low u32. let class_and_sub_class = ItemClassAndSubClass::try_from((u64::from(t.subclass) << 32) | u64::from(t.class)) .unwrap_or_default(); - // Build a compact stats array: pack non-zero stats into the first N slots (max 10), pad the - // rest with (Mana, 0) which the 1.12 client skips when rendering the tooltip. The 7 stat columns - // the module stores map directly to 5 attribute stats + crit/hit (which don't show as "+N Stat" - // lines in vanilla — skip them to avoid confusing the tooltip). stat_armor feeds the `armor` - // field instead. let stats: [ItemStat; 10] = { let pairs: [(ItemStatType, i32); 5] = [ (ItemStatType::Strength, t.stat_strength), @@ -211,10 +154,6 @@ pub fn build_item_query_response( } out }; - // On-use/on-equip spell slots → the client renders the green "Use:/Equip:/Chance on hit:" text from - // its OWN Spell.dbc. All 5 slots are populated from the template (slots 3-5 were wired - // in later — previously hardcoded empty). spell_trigger maps the raw ItemSpellTriggerType byte - // (0=on-use, 1=on-equip, 2=chance-on-hit). let spell_slot = |id: u32, trig: u8| ItemSpells { spell: id, spell_trigger: SpellTriggerType::try_from(u32::from(trig)).unwrap_or_default(), @@ -233,8 +172,6 @@ pub fn build_item_query_response( name1: t.name.clone(), display_id: t.display_id, quality: ItemQuality::try_from(u32::from(t.quality)).unwrap_or_default(), - // The raw item_template.Flags bitmask (unique/conjured/etc) — was - // hardcoded to the wire default (empty) before the column existed server-side. flags: ItemFlag::new(t.item_flags), buy_price: Gold::new(t.buy_price), sell_price: Gold::new(t.sell_price), @@ -244,25 +181,15 @@ pub fn build_item_query_response( required_level: Level::new(t.required_level), allowed_class: AllowedClass::new(t.allowed_class), allowed_race: AllowedRace::new(t.allowed_race), - // Weapon-skill / mail-plate proficiency gate. An out-of-range skill id - // degrades to Skill::None rather than failing the query (matches the other try_from - // degrades in this function). required_skill: Skill::try_from(t.required_skill).unwrap_or_default(), required_skill_rank: t.required_skill_rank, - // The reputation-gated-item half of faction reaction gating. required_faction: Faction::try_from(t.required_reputation_faction).unwrap_or_default(), required_faction_rank: t.required_reputation_rank, stackable: t.max_stack.max(1), - // Unique-item stack cap (was hardcoded 0 before the column existed - // server-side). max_count: t.max_count, stats, armor: t.stat_armor, - // block carries the shield's base block value (0 for every non-shield); u32 cast is - // safe because every seeded block_value is ≥ 0 (i32 future-proofs enchant negatives). block: t.block_value.max(0) as u32, - // The weapon's white-damage range + swing speed (drives the tooltip "N - M Damage" and - // "Speed X.XX"); slot 0 physical, the rest left empty. damages: { let mut d = [ItemDamageType::default(); 5]; d[0] = ItemDamageType { @@ -272,8 +199,6 @@ pub fn build_item_query_response( }; d }, - // The 6 resistance schools — resist gear tooltips were dead (always 0) - // until these columns existed server-side. holy_resistance: t.holy_res, fire_resistance: t.fire_res, nature_resistance: t.nature_res, @@ -281,28 +206,14 @@ pub fn build_item_query_response( shadow_resistance: t.shadow_res, arcane_resistance: t.arcane_res, delay: t.delay_ms, - // The per-item sheathe posture (was a blanket MainHand → every weapon/shield stowed - // in the same spot). An out-of-range byte degrades to None rather than failing the query. sheathe_type: SheatheType::try_from(t.sheath).unwrap_or_default(), spells, - // Item binding: the raw cmangos byte maps directly onto the wire enum - // (NoBind=0, PickUp=1 "Binds when picked up", Equip=2 "Binds when equipped", Use=3, - // QuestItem=4/5) — an out-of-range byte degrades to NoBind rather than failing the query. bonding: Bonding::try_from(u32::from(t.bonding)).unwrap_or_default(), - // Readable-item page id (needs its own page_text dump table + reader - // packet before a player can actually read one — data plumbing only for now) and the - // quest-starter link (consumed by the quest system). page_text: t.page_text, start_quest: t.start_quest, max_durability: t.max_durability, - // Bag-type restriction bitmask (Soul Bag/Quiver/etc). An out-of-range - // value degrades to BagFamily::None rather than failing the query. bag_family: BagFamily::try_from(t.bag_family).unwrap_or_default(), - // Ranged weapon RANGE multiplier (mangos `RangedModRange`, % of base). The client scales a - // ranged attack's spell range (Auto Shot = 35yd) by THIS — a `Default::default()` of 0.0 - // collapsed the effective range to 0, so Auto Shot read "out of range" at EVERY distance. The - // mangos column defaults to 100.0 for ALL items (only ranged weapons consult it); we don't - // import per-item overrides, so send the universal vanilla default. [hunter Auto Shot fix] + // RangedModRange is percent of base range; zero makes every ranged target out of range. ranged_range_modification: 100.0, random_property: t.random_property, ..Default::default() @@ -310,12 +221,6 @@ pub fn build_item_query_response( } } -/// Build a generic `SMSG_INVENTORY_CHANGE_FAILURE` — the error popup for a rejected inventory -/// action (equip, move, store, or use). The gateway doesn't have the specific reason at this -/// level (the module surfaced only an opaque `Err`), so we use `ItemCantBeEquipped` with zero -/// item guids. Any non-OK result triggers the client's error sound and prevents the snap-back -/// from looking like success. Specific codes (with the actual item guids and cause) require -/// the module to surface them via a structured error; this is the gateway-only baseline. pub fn build_inventory_change_failure() -> SMSG_INVENTORY_CHANGE_FAILURE { SMSG_INVENTORY_CHANGE_FAILURE::ItemCantBeEquipped { item1: Guid::new(0), @@ -324,11 +229,6 @@ pub fn build_inventory_change_failure() -> SMSG_INVENTORY_CHANGE_FAILURE { } } -/// Build `SMSG_INVENTORY_CHANGE_FAILURE` for one typed item Refusal — the vanilla result code the -/// 1.12 client turns into its own error line. The item guids stay zero: the module names the reason, -/// not the rows. `Internal` has no gameplay meaning, so it reads as the client's internal bag error. -/// A required level has no payload-free code (vanilla's level result carries the level itself), so it -/// falls back to the generic equip and use failures. pub fn build_inventory_refusal(refusal: ItemRefusal) -> SMSG_INVENTORY_CHANGE_FAILURE { let (item1, item2, bag_type_subclass) = (Guid::new(0), Guid::new(0), 0); match refusal { @@ -405,9 +305,6 @@ pub fn build_inventory_refusal(refusal: ItemRefusal) -> SMSG_INVENTORY_CHANGE_FA } } -/// `SMSG_INVENTORY_CHANGE_FAILURE` CANT_CARRY_MORE_OF_THIS for an item whose unique count is -/// reached, with no item guids, as mangos answers a second Guild Charter -/// (`cm:PetitionsHandler.cpp:128-134`). pub fn build_cant_carry_more_of_this() -> SMSG_INVENTORY_CHANGE_FAILURE { SMSG_INVENTORY_CHANGE_FAILURE::CantCarryMoreOfThis { item1: Guid::new(0), @@ -416,9 +313,6 @@ pub fn build_cant_carry_more_of_this() -> SMSG_INVENTORY_CHANGE_FAILURE { } } -/// Build `SMSG_BUY_FAILED` — the red on-screen error for a rejected vendor purchase. -/// Maps the module's Err string to the closest `BuyResult` code (displayed as a toast by the -/// 1.12 client). `vendor_guid` is the NPC, `item_entry` is the `c.item` from the client packet. pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BUY_FAILED { let result = if err.contains("not enough money") { BuyResult::NotEnoughMoney @@ -429,7 +323,6 @@ pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BU } else if err.contains("sold out") || err.contains("no stock") { BuyResult::ItemSoldOut } else { - // "vendor does not sell that item", "item cannot be bought", "dead", etc. — generic. BuyResult::CantFindItem }; SMSG_BUY_FAILED { @@ -439,24 +332,16 @@ pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BU } } -/// Build `SMSG_SHOW_BANK` — opens the bank window, carrying the banker's guid. pub fn build_show_bank(banker_guid: u64) -> SMSG_SHOW_BANK { SMSG_SHOW_BANK { guid: Guid::new(banker_guid), } } -/// Build `SMSG_BUY_BANK_SLOT_RESULT`. pub fn build_buy_bank_slot_result(result: BuyBankSlotResult) -> SMSG_BUY_BANK_SLOT_RESULT { SMSG_BUY_BANK_SLOT_RESULT { result } } -/// Map `apply_buy_bank_slot`'s `Err` string onto `BuyBankSlotResult` by its leading `[N]` code -/// (the trainer `[N]` precedent) — never by matching the prose, which can drift out from under a -/// substring match. `lyracore_shared::bank::result`'s constants ARE the wire numbering, so `[N]` -/// decodes straight through `BuyBankSlotResult::from_int`. An unparseable or out-of-range code -/// falls back to `NotBanker`: a refusal, and the one outcome that asserts nothing about money or -/// slot count, so a fallback here can never mislead the client into thinking a charge went through. pub fn parse_buy_bank_slot_result(err: &str) -> BuyBankSlotResult { err.strip_prefix('[') .and_then(|rest| rest.split(']').next()) @@ -465,9 +350,6 @@ pub fn parse_buy_bank_slot_result(err: &str) -> BuyBankSlotResult { .unwrap_or(BuyBankSlotResult::NotBanker) } -/// Build `SMSG_BUY_BANK_SLOT_RESULT` straight from the store call's outcome, so the dispatch -/// handler never needs to name the gtker enum itself (the trainer/vendor handlers keep the same -/// distance from their wire enums). pub fn build_buy_bank_slot_reply(outcome: Result<(), &str>) -> SMSG_BUY_BANK_SLOT_RESULT { let result = match outcome { Ok(()) => BuyBankSlotResult::Ok, @@ -476,21 +358,12 @@ pub fn build_buy_bank_slot_reply(outcome: Result<(), &str>) -> SMSG_BUY_BANK_SLO build_buy_bank_slot_result(result) } -/// Build the item or container CREATE_OBJECT. A non-spatial object: -/// NO living/position movement block — just `UPDATEFLAG_ALL`. Branches on `inst.container_slots`: -/// - Regular items (0): `ObjectType::Item` + `UpdateMask::Item` (baseline-safe, byte-identical). -/// - Bags (> 0): `ObjectType::Container` + `UpdateMask::Container` with `CONTAINER_FIELD_NUM_SLOTS` -/// set so the client shows a bag window with the correct slot count. `ITEM_FIELD_OWNER` and -/// `ITEM_FIELD_CONTAINED` both point at the owning player. Sent before the player self-spawn. -/// -/// `ITEM_FIELD_ENCHANTMENT` is written only when nonzero, so an item without one keeps its bytes. pub fn build_item_create_object(inst: &ItemInstanceView) -> SMSG_UPDATE_OBJECT { let owner = Guid::new(inst.owner_guid); let update_flag = MovementBlock_UpdateFlag::empty().set_all(MovementBlock_UpdateFlag_All { unknown1: 1 }); let guid3 = Guid::new(inst.guid); if inst.container_slots > 0 { - // This item is a bag — send a CONTAINER CREATE so the client shows the bag window. let mut container = UpdateContainer::builder() .set_object_guid(guid3) .set_object_entry(inst.entry as i32) @@ -519,7 +392,6 @@ pub fn build_item_create_object(inst: &ItemInstanceView) -> SMSG_UPDATE_OBJECT { }], } } else { - // Regular item — `ObjectType::Item`, byte-identical to the pre-bag path. let mut item = UpdateItem::builder() .set_object_guid(guid3) .set_object_entry(inst.entry as i32) @@ -531,8 +403,6 @@ pub fn build_item_create_object(inst: &ItemInstanceView) -> SMSG_UPDATE_OBJECT { .set_item_durability(inst.durability as i32) .set_item_maxdurability(inst.max_durability as i32) .finalize(); - // A copied letter's item text — the field a plain item leaves unset (byte-identical to the - // pre-Letter-Copy wire shape). if inst.item_text_id != 0 { item.set_item_item_text_id(inst.item_text_id as i32); } @@ -555,13 +425,6 @@ use wow_world_messages::vanilla::{ NewItemChatAlert, NewItemCreationType, NewItemSource, SMSG_ITEM_PUSH_RESULT, }; -/// `SMSG_ITEM_PUSH_RESULT` — the "You receive item: [X]." chat/toast for a -/// windowless gain, and the client's trigger to re-check watched-quest item objectives (the -/// tracker + "Wolf Meat: 3/8" floaty — 1.12 recomputes those from its bags when this arrives). -/// `stack_add` sends item_slot 0xFFFFFFFF (the mangoszero added-to-stack convention). Source is -/// always Looted/Received — the relay can't see WHERE the row came from (loot/buy/conjure); -/// "You receive item" reads fine for all three. Deliberate simplification: thread a source hint -/// through when this grates. pub fn build_item_push_result( player_guid: u64, bag_slot: u8, @@ -585,17 +448,10 @@ pub fn build_item_push_result( } } -/// The Character's armor Proficiency, read off its spellbook. The two level-40 upgrades are the -/// passive spells a class trainer teaches, so knowing the passive IS the knowledge — the same -/// question the Module equip Gate asks before it allows the item move. pub fn armor_proficiency(player_class: u8, learned: &[u32]) -> Proficiency { Proficiency::from_spellbook(player_class, |spell_id| learned.contains(&spell_id)) } -/// The pair of `SMSG_SET_PROFICIENCY` messages that tell the client what this Character may wield -/// and wear: the static class weapon table, then the armor tiers it has actually trained. Both -/// masks are `1 << subclass`, and both come from the shared [`Proficiency`] rule, so the client's -/// red tint and the Module's Refusal cannot drift apart. pub fn build_set_proficiency_msgs(player_class: u8, learned: &[u32]) -> [ServerOpcodeMessage; 2] { [ ServerOpcodeMessage::SMSG_SET_PROFICIENCY(SMSG_SET_PROFICIENCY { @@ -606,8 +462,6 @@ pub fn build_set_proficiency_msgs(player_class: u8, learned: &[u32]) -> [ServerO ] } -/// The ARMOR message alone — what a proficiency purchase refreshes. A weapon table never moves, so -/// resending it after a trainer buy would say nothing. pub fn build_armor_proficiency_msg(player_class: u8, learned: &[u32]) -> ServerOpcodeMessage { ServerOpcodeMessage::SMSG_SET_PROFICIENCY(SMSG_SET_PROFICIENCY { class: ItemClass::Armor, @@ -615,8 +469,6 @@ pub fn build_armor_proficiency_msg(player_class: u8, learned: &[u32]) -> ServerO }) } -/// Weapon subclasses the class table admits, folded into a `1 << subclass` mask. Armor training -/// never touches weapons, so the ceiling answers this for every Character of the class. fn weapon_subclass_mask(player_class: u8) -> u32 { let table = Proficiency::class_ceiling(player_class); (0..=weapon_subclass::FISHING_POLE) @@ -694,8 +546,6 @@ mod tests { assert_eq!(item_mask(&charter(0)).item_enchantment(), None); } - /// `cm:PetitionsHandler.cpp:128-134` sends `SendEquipError(msg, nullptr, nullptr, 5863)`: a u8 - /// result, CANT_CARRY_MORE_OF_THIS (17), then two zero item guids and a zero bag subclass. #[test] fn a_second_charter_answers_cant_carry_more_of_this() { let message = ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(Box::new( @@ -742,8 +592,7 @@ mod tests { updates[0].fields.clone() } - /// Slot k's id word is 22 + 3k (`cm:UpdateFields.h`, `cm:Item.h`), so slots 3, 4 and 5 are - /// words 31, 34 and 37. + /// Enchantment slot k uses word 22 + 3k in the vanilla item descriptor. #[test] fn a_random_property_fills_enchantment_slots_three_to_five() { assert_eq!( diff --git a/gateway/src/codec/item/subscriptions.rs b/gateway/src/codec/item/subscriptions.rs index b034da7ad..47e430cd8 100644 --- a/gateway/src/codec/item/subscriptions.rs +++ b/gateway/src/codec/item/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::reads::view_of_item_row; @@ -13,20 +11,11 @@ use spacetimedb_sdk::Table; use std::sync::{Arc, OnceLock}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Trade-status relay: `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the -/// row's recipient and nobody else (audience resolved by the caller, the `auction_notice_outbound` -/// shape). The kind byte is `lyracore_shared::trade::event_kind` — NOT the vanilla discriminant; -/// this match IS the wire mapping. An unknown kind (a newer module mid-rollout) drops with a warn -/// rather than desyncing the window. pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { use lyracore_shared::trade::event_kind as kind; use wow_world_messages::vanilla::SMSG_TRADE_STATUS; - // The OFFER_* kinds carry a whole-side snapshot and decode to the fixed-444-byte extended - // status instead of a plain status ; `self_player` is the kind, not an inference. if row.kind == kind::OFFER_SELF || row.kind == kind::OFFER_PARTNER { - // WIRE POLARITY (mangoszero `SendUpdateTrade`): the byte is `1 means traders data, - // 0 means own` — so the field is SET when the packet describes the PARTNER's side, - // despite the binding's `self_player` name. The protocol's polarity controls this value. + // The misnamed self_player byte is 1 for the partner's data and 0 for one's own. return match trade_offer_extended(row.kind == kind::OFFER_PARTNER, &row.payload) { Some(msg) => vec![Outbound::One( ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(Box::new(msg)), @@ -57,8 +46,6 @@ pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { kind::TRADE_ACCEPT => Some(SMSG_TRADE_STATUS::TradeAccept), kind::BACK_TO_TRADE => Some(SMSG_TRADE_STATUS::BackToTrade), kind::TRADE_COMPLETE => Some(SMSG_TRADE_STATUS::TradeComplete), - // Commit refused on bag space: the window closes with the inventory error; - // `target_error` says WHOSE bags — false = yours, true = the partner's. kind::INV_FULL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, item_limit_category_id: 0, @@ -92,11 +79,6 @@ pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { } } -/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED`: -/// counts are 7/7 (the cmangos constant), unused slots stay zeroed (`TradeSlot::default`), and -/// every filled slot carries the module-resolved stack/durability/enchant fields. Fails closed -/// with the payload decoder. `describes_partner` sets the wire's misnamed `self_player` byte — -/// mangoszero: "1 means traders data, 0 means own". pub(crate) fn trade_offer_extended( describes_partner: bool, payload: &str, @@ -119,7 +101,6 @@ pub(crate) fn trade_offer_extended( }; } Some(SMSG_TRADE_STATUS_EXTENDED { - // `1 means traders data` — see the call-site polarity note. self_player: describes_partner, trade_slot_count1: 7, trade_slot_count2: 7, @@ -173,7 +154,6 @@ pub(crate) fn item_gain_feedback( out } -/// Build the complete live-inventory insert result for one owner. pub(crate) fn item_instance_insert_outbound( db: &RemoteTables, self_guid: u64, @@ -224,9 +204,7 @@ pub(crate) fn item_instance_insert_outbound( out.push(Outbound::Raw { opcode, body }); } } - // The item object and the descriptor that makes it reachable must precede gain feedback. The - // shared owner-view enqueues this complete vector as one writer job, so another producer cannot - // splice quest completion between CREATE and its inventory/container pointer. + // Queue CREATE and inventory pointers together before gain or quest-completion feedback. out.extend(item_gain_feedback( db, self_guid, @@ -242,7 +220,6 @@ pub(crate) fn item_instance_insert_outbound( out } -/// Build the complete live-inventory delete result for one owner. pub(crate) fn item_instance_delete_outbound( db: &RemoteTables, self_guid: u64, @@ -280,7 +257,6 @@ pub(crate) fn item_instance_delete_outbound( out } -/// Build the complete live-inventory update result for one owner. pub(crate) fn item_instance_update_outbound( db: &RemoteTables, self_guid: u64, @@ -335,9 +311,6 @@ pub(crate) fn item_instance_update_outbound( out } -/// An in-place permanent enchant change: the item's enchantment word and, for worn gear that did -/// not change slot, its visible item. The slot projection covers a change of slot. Empty when the -/// enchant did not change, and for a Guild Charter, whose word holds its Petition id. pub(crate) fn enchant_change_outbound( self_guid: u64, old: &ItemInstance, @@ -419,8 +392,6 @@ pub(crate) fn append_item_armor_and_sheet( } } -/// Select one item owner registered on the firing shard; cache reads and packet work run on its -/// writer queue. pub(crate) fn item_owner_job( view: &WorldView, shard: ShardId, @@ -453,10 +424,7 @@ pub(crate) fn item_inserted( }); } -/// The value of ITEM_FIELD_ENCHANTMENT for a newly inserted item. A Guild Charter shows its Petition -/// id, read from the Realm-core cache before the Home Shard's cache guard is taken; it shows 0 when -/// its Petition is not open yet, and the Petition insert relay sends it later. Every other item -/// shows the enchantment ID the client resolves for its permanent enchant. +/// Read the Guild Charter's Petition before taking the Home Shard cache lock. pub(crate) fn item_enchantment_word(coord: &Coordinator, row: &ItemInstance) -> u32 { if row.entry != lyracore_shared::guild::GUILD_CHARTER_ENTRY { return client_enchantment_id(row.enchant_id); @@ -524,8 +492,6 @@ pub(crate) fn item_slot_read(coord: &Coordinator) -> ItemSlotRead { }) } -/// Peers see worn gear only. The first eligible writer reads the current slot occupant; -/// all recipients share it so the item table is scanned once per changed slot. pub(crate) fn item_visible_changed( view: &WorldView, shard: ShardId, @@ -580,8 +546,6 @@ pub(crate) fn item_visible_changed( } } -/// A trade status landed → the `SMSG_TRADE_STATUS` packet to the row's RECIPIENT and nobody -/// else (same shape as [`resurrect_offered`]). pub(crate) fn trade_event_appeared(view: &WorldView, shard: ShardId, row: &TradeEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) else { return; diff --git a/gateway/src/codec/item/subscriptions/packet_tests.rs b/gateway/src/codec/item/subscriptions/packet_tests.rs index 09d3870f8..3ea3326b4 100644 --- a/gateway/src/codec/item/subscriptions/packet_tests.rs +++ b/gateway/src/codec/item/subscriptions/packet_tests.rs @@ -5,10 +5,6 @@ use crate::stdb::reads::view_of_item_row; use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// The trade-status wire mapping: every `lyracore_shared::trade::event_kind` the module -/// emits decodes to its `SMSG_TRADE_STATUS` variant — `BeginTrade` carrying the counterparty -/// guid the client needs to open the window — and an unknown kind (newer module mid-rollout) -/// drops rather than desyncing the trade window. #[test] fn trade_event_kinds_decode_to_their_trade_status_variants() { use lyracore_shared::trade::event_kind as kind; @@ -70,7 +66,6 @@ fn trade_event_kinds_decode_to_their_trade_status_variants() { status_of(kind::TRADE_COMPLETE), SMSG_TRADE_STATUS::TradeComplete ); - // The bag-space refusal closes the window naming WHOSE bags overflowed. let full_self = status_of(kind::INV_FULL_SELF); let full_partner = status_of(kind::INV_FULL_PARTNER); match (&full_self, &full_partner) { @@ -97,7 +92,6 @@ fn trade_event_kinds_decode_to_their_trade_status_variants() { } other => panic!("expected CloseWindow pair with flipped target_error, got {other:?}"), } - // The purse-failure pair mirrors it with NotEnoughMoney. match ( &status_of(kind::GOLD_FAIL_SELF), &status_of(kind::GOLD_FAIL_PARTNER), @@ -132,10 +126,6 @@ fn trade_event_kinds_decode_to_their_trade_status_variants() { ); } -/// The OFFER_* kinds decode to the fixed-444-byte extended status: the polarity byte -/// comes from the KIND (never inferred), the window-visible item fields survive the payload -/// round-trip into the right wire slots, unused slots stay zeroed, and a malformed payload -/// drops the packet entirely. #[test] fn offer_events_decode_to_the_extended_status_with_the_kind_deciding_self_player() { use lyracore_shared::trade::{encode_offer, event_kind as kind, OfferSlot}; @@ -171,8 +161,6 @@ fn offer_events_decode_to_the_extended_status_with_the_kind_deciding_self_player } }; - // Wire polarity (mangoszero): the `self_player` BYTE is "1 means traders data, 0 means - // own", so your OWN echo carries 0 and the partner's side carries 1. let own = extended(kind::OFFER_SELF); assert!( !own.self_player, @@ -242,11 +230,11 @@ fn a_compatibility_enchant_in_an_offer_goes_out_as_its_client_enchantment() { #[test] fn bag_content_parts_boundaries() { - assert_eq!(codec::bag_content_parts(119), None); // just below the bag-content range - assert_eq!(codec::bag_content_parts(120), Some((19, 0))); // first bag, first slot - assert_eq!(codec::bag_content_parts(137), Some((19, 17))); // first bag, last slot (0..MAX_BAG_SIZE) - assert_eq!(codec::bag_content_parts(138), Some((20, 0))); // second bag, first slot - assert_eq!(codec::bag_content_parts(191), Some((22, 17))); // fourth (last) bag, last slot + assert_eq!(codec::bag_content_parts(119), None); + assert_eq!(codec::bag_content_parts(120), Some((19, 0))); + assert_eq!(codec::bag_content_parts(137), Some((19, 17))); + assert_eq!(codec::bag_content_parts(138), Some((20, 0))); + assert_eq!(codec::bag_content_parts(191), Some((22, 17))); assert_eq!(codec::bag_content_parts(192), None); } @@ -275,8 +263,6 @@ fn item_row(slot: u8, enchant_id: u32) -> ItemInstance { } } -/// The enchantment word of the item update and the first enchant word of the visible item in an -/// in-place enchant outbound, with the raw field indexes of every VALUES block. fn enchant_words(out: &[Outbound]) -> (Option, Option, Vec>) { use wow_world_messages::vanilla::{Object, ServerMessage, UpdateMask, VisibleItemIndex}; let (mut item_word, mut visible_word) = (None, None); diff --git a/gateway/src/codec/leveling.rs b/gateway/src/codec/leveling.rs index c30594a2c..6208ef2f4 100644 --- a/gateway/src/codec/leveling.rs +++ b/gateway/src/codec/leveling.rs @@ -1,15 +1,5 @@ -//! XP / level-up wire mapping: the `SMSG_LOG_XPGAIN` "+N experience" text and the -//! `SMSG_LEVELUP_INFO` ding popup (Tier 5 / XP). The matching descriptor VALUES updates -//! (`build_player_xp_values` / `build_levelup_values`) live in `values.rs`. Pure code-motion. - use super::*; -/// Build `SMSG_LOG_XPGAIN` (Tier 5 / XP) — the "+N experience" combat-log text for a kill. -/// `killed_guid` is the slain creature. -/// SMSG_EXPLORATION_EXPERIENCE (opcode 0x01F8) — the "Discovered: " popup, sent once per fresh -/// subzone. Body is `area_id: u32 LE` + `experience: u32 LE` (8 bytes). Hand-rolled raw rather than the -/// gtker typed message so an `Area` enum that lacks an imported area id can't reject the send (the fog + -/// XP still ship; only the text would be skipped otherwise). Returns `(opcode, body)` for `Outbound::Raw`. pub const SMSG_EXPLORATION_EXPERIENCE_OPCODE: u16 = 0x01F8; pub fn build_exploration_experience_raw(area_id: u32, experience: u32) -> (u16, Vec) { let mut body = Vec::with_capacity(8); @@ -18,8 +8,7 @@ pub fn build_exploration_experience_raw(area_id: u32, experience: u32) -> (u16, (SMSG_EXPLORATION_EXPERIENCE_OPCODE, body) } -/// Build 5875 XP feedback. Only a kill carries base XP and the group multiplier. -/// The typed dependency attaches that tail to the opposite award type. +/// gtker puts the base-XP and group-multiplier tail on the wrong award type. pub const SMSG_LOG_XPGAIN: u16 = 0x01d0; pub fn build_log_xpgain_raw( @@ -39,9 +28,6 @@ pub fn build_log_xpgain_raw( (SMSG_LOG_XPGAIN, body) } -/// Build `SMSG_LEVELUP_INFO` (Tier 5 / XP) — the "You have reached level N" ding popup, carrying the -/// health/mana gains and the five per-stat deltas the module's ding loop computed. rage/focus/ -/// energy/happiness stay 0 — those pools never grow per level in vanilla. #[allow(clippy::too_many_arguments)] pub fn build_levelup_info( new_level: u32, @@ -108,7 +94,6 @@ mod tests { assert_eq!(msg.stamina, 2); assert_eq!(msg.intellect, 0); assert_eq!(msg.spirit, 1); - // rage/focus/energy/happiness never grow per level in vanilla — always 0. assert_eq!(msg.rage, 0); assert_eq!(msg.focus, 0); assert_eq!(msg.energy, 0); diff --git a/gateway/src/codec/leveling/subscriptions.rs b/gateway/src/codec/leveling/subscriptions.rs index ad247c6a4..b3c843f62 100644 --- a/gateway/src/codec/leveling/subscriptions.rs +++ b/gateway/src/codec/leveling/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; @@ -9,7 +7,6 @@ use crate::world::Outbound; use spacetimedb_sdk::Table; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Build the XP-gain result for one identity-addressed row. pub(crate) fn xp_event_outbound(row: &XpEvent) -> Vec { let (opcode, body) = codec::build_log_xpgain_raw( row.killed_guid, @@ -20,7 +17,6 @@ pub(crate) fn xp_event_outbound(row: &XpEvent) -> Vec { vec![Outbound::Raw { opcode, body }] } -/// Build the level-up result for one identity-addressed row. pub(crate) fn levelup_event_outbound(row: &LevelupEvent) -> Vec { let message = codec::build_levelup_info( row.new_level, @@ -37,15 +33,11 @@ pub(crate) fn levelup_event_outbound(row: &LevelupEvent) -> Vec { ))] } -/// The shared-dispatch twin of the per-player -/// `on_rest_insert` relay (rest state). Same raw PLAYER_BYTES_2 VALUES packet; the audience (owner -/// only) was already resolved by the owner-session lookup in `world_view::rest_state_appeared`. pub(crate) fn relay_rest_state(self_guid: u64, player_bytes_2: u32) -> Vec { let (opcode, body) = codec::build_rest_state_values(self_guid, player_bytes_2); vec![Outbound::Raw { opcode, body }] } -/// XP rows are delivered to the one viewer bound to their recipient identity. pub(crate) fn xp_appeared(view: &WorldView, shard: ShardId, row: &XpEvent) { let Some(viewer) = view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) @@ -58,7 +50,6 @@ pub(crate) fn xp_appeared(view: &WorldView, shard: ShardId, row: &XpEvent) { }); } -/// Level-up rows are delivered to the one viewer bound to their recipient identity. pub(crate) fn levelup_appeared(view: &WorldView, shard: ShardId, row: &LevelupEvent) { let Some(viewer) = view.viewer_of_identity_on_shard(shard, BoundIdentity(row.recipient_identity)) @@ -71,8 +62,6 @@ pub(crate) fn levelup_appeared(view: &WorldView, shard: ShardId, row: &LevelupEv }); } -/// An explored-area row restores the complete fog word first, then reports a new discovery. -/// Cache reads and replay-state mutation stay on the addressed session's writer queue. pub(crate) fn exploration_appeared( view: &WorldView, coord: &Coordinator, @@ -134,7 +123,6 @@ pub(crate) fn exploration_outbound_for_word( outbound } -/// Send a rest-state change to its owner on the callback's Shard. pub(crate) fn rest_state_appeared(view: &WorldView, shard: ShardId, row: &RestStateEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { return; diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 09309c1a8..47884ba1f 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -1,50 +1,33 @@ -//! Loot wire mapping: the RAW `SMSG_LOOT_RESPONSE` (gtker's typed `LootItem` is incomplete for -//! 1.12), the money-notify, and the loot-release ack. Pure code-motion out of `mod.rs`. - use super::*; use lyracore_shared::loot_roll::vote_kind; use wow_world_messages::vanilla::{ LootMethodError, SMSG_LOOT_RESPONSE_LootMethod, SMSG_LOOT_RESPONSE, }; -/// `SMSG_LOOT_RESPONSE` opcode (vanilla 5875). Pinned against gtker in the byte-match test below. const SMSG_LOOT_RESPONSE_OPCODE: u16 = 0x0160; -/// One lootable item for the loot window: `(slot, item_id, count, display_id, random_property_id)`. `display_id` is the -/// item's `ItemDisplayInfo` id (the gateway joins it from `game_item_template`); the client also -/// queries the item (`CMSG_ITEM_QUERY_SINGLE`) for the name/tooltip (item-query path). pub type LootItemView = (u8, u32, u32, u32, u32); -/// Build a RAW `SMSG_LOOT_RESPONSE` (money and items). RAW because gtker's typed -/// `LootItem` is INCOMPLETE for vanilla 1.12 — it encodes only `index:u8 + item:u32 + ty:u8` (6 -/// bytes), omitting the `count`/`display_id`/`random_suffix`/`random_property_id` (u32×4) the 5875 -/// client reads per item (mangos sends 22 bytes/item). Sending items through the typed builder would -/// desync the client's parse. The ENVELOPE (guid u64, loot_method u8, gold u32, count u8) is -/// byte-identical to gtker's working money-only message — pinned by `loot_response_envelope_matches_gtker` -/// — so we reuse it verbatim and only hand-roll the per-item bytes in the correct vanilla layout. -/// Returns `(opcode, body)` for [`Outbound::Raw`](crate::world::Outbound::Raw). +/// gtker emits 6 bytes per loot item; build 5875 reads 22, including four extra u32 fields. pub fn build_loot_response_raw(guid: u64, money: u32, items: &[LootItemView]) -> (u16, Vec) { - // The wire count is a u8; cap the slice so the count and the item bytes can never disagree (a corpse - // holds far fewer than 255 — this is a guard against a silent `as u8` wrap, not a real limit). let items = &items[..items.len().min(255)]; let mut body = Vec::with_capacity(14 + items.len() * 22); - body.extend_from_slice(&guid.to_le_bytes()); // guid: u64 (full, not packed) - body.push(1u8); // loot_method = Corpse (SMSG_LOOT_RESPONSE_LootMethod::Corpse as_int) - body.extend_from_slice(&money.to_le_bytes()); // gold: u32 - body.push(items.len() as u8); // amount_of_items: u8 + body.extend_from_slice(&guid.to_le_bytes()); // Full guid, not packed. + body.push(1u8); // LootMethod::Corpse + body.extend_from_slice(&money.to_le_bytes()); + body.push(items.len() as u8); for &(slot, item_id, count, display_id, random_property_id) in items { - body.push(slot); // loot slot index: u8 - body.extend_from_slice(&item_id.to_le_bytes()); // item id (entry): u32 - body.extend_from_slice(&count.to_le_bytes()); // stack count: u32 - body.extend_from_slice(&display_id.to_le_bytes()); // ItemDisplayInfo id: u32 - body.extend_from_slice(&0u32.to_le_bytes()); // random_suffix: u32 (none) + body.push(slot); + body.extend_from_slice(&item_id.to_le_bytes()); + body.extend_from_slice(&count.to_le_bytes()); + body.extend_from_slice(&display_id.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // No random suffix. body.extend_from_slice(&random_property_id.to_le_bytes()); - body.push(0u8); // slot_type = TypeAllowLoot (as_int 0) + body.push(0u8); // TypeAllowLoot } (SMSG_LOOT_RESPONSE_OPCODE, body) } -/// Build the vanilla response that answers a loot Refusal on the named Loot Source. pub fn build_loot_error_response(guid: u64, loot_error: LootMethodError) -> SMSG_LOOT_RESPONSE { SMSG_LOOT_RESPONSE { guid: Guid::new(guid), @@ -54,19 +37,14 @@ pub fn build_loot_error_response(guid: u64, loot_error: LootMethodError) -> SMSG } } -/// Build `SMSG_LOOT_REMOVED` — clears one looted slot from the open loot window after its item is -/// taken (`CMSG_AUTOSTORE_LOOT_ITEM`), so the slot disappears without re-opening the window. pub fn build_loot_removed(slot: u8) -> SMSG_LOOT_REMOVED { SMSG_LOOT_REMOVED { slot } } -/// Build `SMSG_LOOT_MONEY_NOTIFY` — tells the client how much coin was just looted. pub fn build_loot_money_notify(amount: u32) -> SMSG_LOOT_MONEY_NOTIFY { SMSG_LOOT_MONEY_NOTIFY { amount } } -/// Build `SMSG_LOOT_RELEASE_RESPONSE` — acks closing the loot window. `unknown1 = 1` per -/// the reference emulators (mangos/cmangos/vmangos). pub fn build_loot_release_response(guid: u64) -> SMSG_LOOT_RELEASE_RESPONSE { SMSG_LOOT_RELEASE_RESPONSE { guid: Guid::new(guid), @@ -74,20 +52,10 @@ pub fn build_loot_release_response(guid: u64) -> SMSG_LOOT_RELEASE_RESPONSE { } } -// =========================================================================================== -// GROUP LOOT METHODS — need/greed rolls + master looter. All four -// messages below are gtker-TYPED vanilla-complete builders (verified present in the vendored -// `wow_world_messages` — no raw-build needed, unlike the money/item `SMSG_LOOT_RESPONSE` above). -// =========================================================================================== - -/// Vote-kind on the wire (`RollVote`) from the module's `vote_kind::*` byte — the two orderings are -/// IDENTICAL by design (`lyracore_shared::loot_roll` doc), so this is a direct `try_from`, never a -/// hand-written match table that could silently drift. fn wire_vote(vote: u8) -> RollVote { RollVote::try_from(vote).unwrap_or(RollVote::Pass) } -/// Build `SMSG_LOOT_START_ROLL` — the 60s need/greed/pass countdown window opening for one item. pub fn build_loot_start_roll( corpse_guid: u64, loot_slot: u8, @@ -113,8 +81,6 @@ pub(crate) fn wire_roll_number(vote: u8, rolled: u8) -> u8 { } } -/// Project a member's vote to every eligible member. Vanilla represents both manual and -/// automatic passes with the same `roll_number` sentinel. pub fn build_loot_roll( corpse_guid: u64, loot_slot: u8, @@ -136,7 +102,6 @@ pub fn build_loot_roll( } } -/// Build `SMSG_LOOT_ROLL_WON` — the roll resolved to a winner. pub fn build_loot_roll_won( corpse_guid: u64, loot_slot: u8, @@ -154,13 +119,10 @@ pub fn build_loot_roll_won( item_random_property_id: random_property_id, winning_player: Guid::new(winning_player), winning_roll, - // The tier that actually won — a greed-only contest reports Greed, not Need - // (the module sends vote_kind::*, already wire-order-matching). vote: wire_vote(winning_vote), } } -/// Build `SMSG_LOOT_MASTER_LIST` — the master looter's eligible-recipient list. pub fn build_loot_master_list(eligible: &[u64]) -> SMSG_LOOT_MASTER_LIST { SMSG_LOOT_MASTER_LIST { guids: eligible.iter().map(|&g| Guid::new(g)).collect(), diff --git a/gateway/src/codec/mail.rs b/gateway/src/codec/mail.rs index 07b7a1e6b..98a1a5e84 100644 --- a/gateway/src/codec/mail.rs +++ b/gateway/src/codec/mail.rs @@ -1,5 +1,3 @@ -//! Vanilla mailbox packet mapping. - use lyracore_shared::item_property::client_enchantment_id; use lyracore_shared::mail::{self as mail_rules, MailSender}; use wow_world_messages::vanilla::{ @@ -27,10 +25,9 @@ pub struct MailView { pub random_property_id: u32, pub sender_kind: u8, pub sender_entry: u32, - /// `CHECK_MASK_*` bits other than READ, which is `was_read`. pub check_flags: u32, pub mail_template_id: u32, - /// When the recipient can first see the mail, rounded up to the second. 0 means from creation. + /// Rounded up to seconds; zero means visible from creation. pub deliver_secs: i64, } @@ -42,9 +39,6 @@ impl MailView { self.deliver_secs <= now_secs } } -/// The inbox the client shows: newest first, at most [`mail_rules::INBOX_CAPACITY`] mails -/// (cmangos `Player.cpp:15062`, `MailHandler.cpp:557`). A returned or delayed mail counts as new -/// from the moment it arrives. pub fn build_mail_list(mails: &[MailView], now_secs: i64) -> SMSG_MAIL_LIST_RESULT { let mut newest_first: Vec<&MailView> = mails.iter().collect(); newest_first.sort_by_key(|m| { @@ -86,8 +80,6 @@ pub fn build_mail_list(mails: &[MailView], now_secs: i64) -> SMSG_MAIL_LIST_RESU .collect(), } } -/// cmangos writes the sender guid for a Character and the entry or house id for the rest -/// (`MailHandler.cpp:574-586`). fn message_type(sender: MailSender) -> Mail_MailType { match sender { MailSender::Character(guid) => Mail_MailType::Normal { @@ -98,9 +90,6 @@ fn message_type(sender: MailSender) -> Mail_MailType { MailSender::Gameobject(entry) => Mail_MailType::Gameobject { sender_id: entry }, } } -/// The `checked` field the client reads read, returned, copied, COD-payment and has-body state -/// from (cmangos `MailHandler.cpp:610`). Server-only bits such as `CHECK_FLAG_LETTER_GRANTED` ride -/// the same column and name no vanilla state, so only `CLIENT_CHECK_MASK` reaches the client. fn check_mask(m: &MailView) -> u32 { let read = if m.was_read { mail_rules::CHECK_MASK_READ @@ -114,7 +103,6 @@ pub fn build_next_mail_time(has_unread: bool) -> MSG_QUERY_NEXT_MAIL_TIME_Server unread_mails: mail_rules::unread_mail_signal(has_unread), } } -/// A Mail Arrival. The packet carries one `u32` 0 (cmangos `Player.cpp:3071-3077`). pub fn build_received_mail() -> SMSG_RECEIVED_MAIL { SMSG_RECEIVED_MAIL { unknown1: 0 } } @@ -193,7 +181,6 @@ pub enum MailTakeItemError { NotEnoughMoney, Other, } -/// `CMSG_MAIL_CREATE_TEXT_ITEM`'s ack: the Plain Letter was made, a full bag, or anything else. pub fn build_mail_made_permanent_result( mail_id: u32, made: Result<(), MailMadePermanentError>, diff --git a/gateway/src/codec/mail/subscriptions.rs b/gateway/src/codec/mail/subscriptions.rs index cf81c73c9..eece4f0c3 100644 --- a/gateway/src/codec/mail/subscriptions.rs +++ b/gateway/src/codec/mail/subscriptions.rs @@ -1,21 +1,15 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_view::*; use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Build a Mail Arrival after the caller validates the recipient. pub(crate) fn mail_arrival_outbound() -> Vec { vec![Outbound::One(ServerOpcodeMessage::SMSG_RECEIVED_MAIL( codec::build_received_mail(), ))] } -/// A Mail became visible to its recipient → SMSG_RECEIVED_MAIL to that recipient's World Session on -/// any Shard. An offline recipient gets nothing and learns of the Mail from the unread poll at the -/// next login (cmangos `Player.cpp:3098-3110`, `MailHandler.cpp:712-720`). pub(crate) fn mail_arrived(view: &WorldView, row: &MailArrival) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; diff --git a/gateway/src/codec/meeting_stone.rs b/gateway/src/codec/meeting_stone.rs index f53541d44..9a63bc02b 100644 --- a/gateway/src/codec/meeting_stone.rs +++ b/gateway/src/codec/meeting_stone.rs @@ -1,13 +1,9 @@ -//! Meeting Stone Queue messages. `SMSG_MEETINGSTONE_COMPLETE` and `_IN_PROGRESS` are empty, so -//! they need no builder. - use wow_world_messages::vanilla::{ Area, MSG_LOOKING_FOR_GROUP_Server, MeetingStoneFailure, MeetingStoneStatus, SMSG_MEETINGSTONE_JOINFAILED, SMSG_MEETINGSTONE_MEMBER_ADDED, SMSG_MEETINGSTONE_SETQUEUE, }; use wow_world_messages::Guid; -/// `None` when `wow_world_messages` has no `Area` for the id or knows no such status. pub fn build_meetingstone_setqueue(area_id: u32, status: u8) -> Option { Some(SMSG_MEETINGSTONE_SETQUEUE { area: Area::try_from(area_id).ok()?, @@ -15,22 +11,19 @@ pub fn build_meetingstone_setqueue(area_id: u32, status: u8) -> Option Option { Some(SMSG_MEETINGSTONE_JOINFAILED { reason: MeetingStoneFailure::try_from(reason).ok()?, }) } -/// A Stone Add, told to the members already in the Party. pub fn build_meetingstone_member_added(guid: u64) -> SMSG_MEETINGSTONE_MEMBER_ADDED { SMSG_MEETINGSTONE_MEMBER_ADDED { guid: Guid::new(guid), } } -/// The answer to `MSG_LOOKING_FOR_GROUP`. vmangos sends 0 (vm:Handlers/MiscHandler.cpp:277-282), -/// and the stock UI never reads it. +/// Vanilla sends zero here; the stock UI never reads it. pub fn build_looking_for_group() -> MSG_LOOKING_FOR_GROUP_Server { MSG_LOOKING_FOR_GROUP_Server { unknown1: 0 } } @@ -47,7 +40,6 @@ mod tests { framed.split_off(4) } - /// cm:LFG/LFGHandler.cpp:146-152 writes the area as a u32 and the status as a u8. #[test] fn setqueue_is_the_area_then_the_status_byte() { let packet = build_meetingstone_setqueue(1581, queue_status::JOINED_QUEUE).unwrap(); @@ -57,7 +49,6 @@ mod tests { assert!(build_meetingstone_setqueue(1581, 6).is_none()); } - /// The shared status and failure bytes are the `wow_world_messages` values. #[test] fn the_shared_bytes_match_the_wire_enums() { use queue_status::*; diff --git a/gateway/src/codec/member_stats.rs b/gateway/src/codec/member_stats.rs index f2583b663..ae1d92f2e 100644 --- a/gateway/src/codec/member_stats.rs +++ b/gateway/src/codec/member_stats.rs @@ -1,45 +1,28 @@ -//! Member Stats wire mapping: `SMSG_PARTY_MEMBER_STATS` and `SMSG_PARTY_MEMBER_STATS_FULL`. -//! -//! Both packets are hand-rolled. gtker 0.3 reads the zone as a `u32` where cmangos and vmangos -//! write a `u16` (cm:GroupHandler.cpp:626-627, vm:GroupHandler.cpp:630-631). It also writes the -//! negative-aura mask as a `u32` where the client reads a `u16`, and its FULL struct has no -//! negative-aura field. The pet block carries the same gap one level down: gtker's -//! `GroupUpdateFlags` names a `PET_AURAS_2` bit (cmangos's `GROUP_UPDATE_FLAG_PET_AURAS_NEGATIVE`), -//! but neither `SMSG_PARTY_MEMBER_STATS` nor its FULL twin ever reads or writes it. +//! gtker uses u32 for the u16 zone and negative-aura mask fields; encode these packets raw. use super::values::write_packed_guid_u64; use lyracore_shared::constants::player_flags; use std::ops::{BitOr, BitOrAssign}; -/// `SMSG_PARTY_MEMBER_STATS`: the fields that changed, pushed by the Member Stats Relay. pub const SMSG_PARTY_MEMBER_STATS_OPCODE: u16 = 0x007E; -/// `SMSG_PARTY_MEMBER_STATS_FULL`: the answer to `CMSG_REQUEST_PARTY_MEMBER_STATS`. pub const SMSG_PARTY_MEMBER_STATS_FULL_OPCODE: u16 = 0x02F2; -/// The member status byte (cm:Group.h:45-56). Only the states the Gateway can tell are named. PvP, -/// AFK and DND have no entity field yet. pub mod member_status { pub const OFFLINE: u8 = 0x00; pub const ONLINE: u8 = 0x01; pub const DEAD: u8 = 0x04; pub const GHOST: u8 = 0x08; - /// Online but not in the world, as during a loading screen (cm:Group.cpp:54-55). pub const ZONE_OUT: u8 = 0x20; } -/// `UNIT_FIELD_AURA` slots 0-31 are positive; the client reads a `u32` mask over them -/// (cm:GroupHandler.cpp:637, cm:GroupHandler.cpp:825). `Aura.slot` is this same index, confirmed -/// against `codec/update_mask.rs`'s `idx::UNIT_AURA` layout, which writes a slot's spell id at -/// `UNIT_AURA + slot`. +// Positive aura slots 0..31 use a u32 mask. pub const MAX_POSITIVE_AURAS: u8 = 32; -/// Slots 32-47 are negative; the client reads a `u16` mask over them, bit `n` meaning slot `32 + n` -/// (cm:GroupHandler.cpp:649-651). +// Negative aura slots 32..47 use a u16 mask, with bit n selecting slot 32 + n. pub const MAX_AURAS: u8 = 48; const POSITIVE_AURA_SLOTS: usize = MAX_POSITIVE_AURAS as usize; const NEGATIVE_AURA_SLOTS: usize = (MAX_AURAS - MAX_POSITIVE_AURAS) as usize; -/// Which fields a Member Stats body carries, as `GROUP_UPDATE_FLAG_*` bits (cm:Group.h:66-89). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct GroupUpdateMask(u32); @@ -67,14 +50,8 @@ impl GroupUpdateMask { pub const PET_AURAS: Self = Self(0x0008_0000); pub const PET_AURAS_NEGATIVE: Self = Self(0x0010_0000); - /// Every base field a live member always carries: status through position (cm:Group.h:69-77). pub const MEMBER: Self = Self(0x0000_01FF); - /// Every pet field (cm:Group.h:91, `GROUP_UPDATE_PET`). [`full_update_mask`] ORs this in for a - /// live pet only; [`FULL`](Self::FULL) carries it unconditionally. pub const PET: Self = Self(0x001F_F800); - /// Every known field (cm:Group.h:92, `GROUP_UPDATE_FULL`). A join or an online transition - /// marks this whole set dirty (cm:Group.cpp:306-307, 746-747), pet fields included even with - /// no pet, so [`stats_delta`]'s first send uses it as-is. pub const FULL: Self = Self(0x001F_FFFF); pub const fn bits(self) -> u32 { @@ -104,17 +81,12 @@ impl BitOrAssign for GroupUpdateMask { } } -/// One occupied `UNIT_FIELD_AURA` slot, as `AuraIndex::on_target` reports it: `Aura.slot` and -/// `Aura.spell_id`, before narrowing the id to the wire's `u16`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MemberAuraSlot { pub slot: u8, pub spell_id: u32, } -/// The member's live pet, before narrowing to wire widths. Resolved the way the pet bar resolves -/// it: `game_hunter_pet_protocol` names a Hunter's pet, the creature template names a summoned -/// pet, and the pet's own `game_world_entity` row carries the rest (cm:Group.h:80-89). #[derive(Clone, Debug, PartialEq)] pub struct MemberPetEntity { pub guid: u64, @@ -124,13 +96,11 @@ pub struct MemberPetEntity { pub max_health: u32, pub power: u32, pub max_power: u32, - /// Byte 3 is the power type, the same layout as [`MemberEntity::unit_bytes_0`]. + /// Byte 3 is the power type. pub unit_bytes_0: u32, - /// Every occupied `UNIT_FIELD_AURA` slot on the pet, in any order. pub auras: Vec, } -/// The `game_world_entity` columns Member Stats read, before they narrow to wire widths. #[derive(Clone, Debug, Default, PartialEq)] pub struct MemberEntity { pub health: u32, @@ -145,16 +115,11 @@ pub struct MemberEntity { pub y: f32, pub dead: bool, pub player_flags: u32, - /// Every occupied `UNIT_FIELD_AURA` slot, in any order. pub auras: Vec, - /// The member's live pet. `None` when it has none. pub pet: Option, } -/// The pet block of one member's Member Stats in wire values (cm:Group.h:80-89). A pet-less -/// member carries the all-zero/empty value, which is also what a dismissed pet's fields become -/// (cm:GroupHandler.cpp:848-890): the wire has no separate "no pet" tag for a delta packet, only -/// this sentinel. +/// The wire has no absent-pet tag; zero every pet field when a pet disappears. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PetStats { pub guid: u64, @@ -165,9 +130,7 @@ pub struct PetStats { pub power_type: u8, pub current_power: u16, pub max_power: u16, - /// Slots 0-31, positive. pub auras: [u16; POSITIVE_AURA_SLOTS], - /// Slots 32-47, index `n` holding slot `32 + n`. pub auras_negative: [u16; NEGATIVE_AURA_SLOTS], } @@ -189,8 +152,6 @@ impl PetStats { } } -/// One group member's Member Stats in wire values. The relay compares these, so a value that -/// narrows to the same wire value is not a change. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MemberStats { pub status: u8, @@ -203,15 +164,11 @@ pub struct MemberStats { pub zone: u16, pub position_x: u16, pub position_y: u16, - /// Slots 0-31, positive. pub auras: [u16; POSITIVE_AURA_SLOTS], - /// Slots 32-47, index `n` holding slot `32 + n`. pub auras_negative: [u16; NEGATIVE_AURA_SLOTS], pub pet: PetStats, } -/// Narrow occupied slots 0-31 to their wire spell id, 0 elsewhere. A slot outside this range (the -/// negative half) falls out of bounds and is skipped here by construction. fn positive_aura_slots(auras: &[MemberAuraSlot]) -> [u16; POSITIVE_AURA_SLOTS] { let mut slots = [0u16; POSITIVE_AURA_SLOTS]; for aura in auras { @@ -222,7 +179,6 @@ fn positive_aura_slots(auras: &[MemberAuraSlot]) -> [u16; POSITIVE_AURA_SLOTS] { slots } -/// Narrow occupied slots 32-47 to their wire spell id, 0 elsewhere. Index `n` holds slot `32 + n`. fn negative_aura_slots(auras: &[MemberAuraSlot]) -> [u16; NEGATIVE_AURA_SLOTS] { let mut slots = [0u16; NEGATIVE_AURA_SLOTS]; for aura in auras { @@ -237,8 +193,6 @@ fn negative_aura_slots(auras: &[MemberAuraSlot]) -> [u16; NEGATIVE_AURA_SLOTS] { } impl MemberStats { - /// Project a live entity. Health, power, level and zone saturate at `u16::MAX`. A coordinate - /// keeps the bits of cmangos's `uint16(float)`, which is `x as i32 as u16` for a map coordinate. pub fn from_entity(entity: &MemberEntity) -> Self { let narrow = |value: u32| u16::try_from(value).unwrap_or(u16::MAX); let mut status = member_status::ONLINE; @@ -311,8 +265,6 @@ pub fn stats_delta(previous: Option<&MemberStats>, current: &MemberStats) -> Gro mask | pet_delta(&previous.pet, ¤t.pet) } -/// The pet-field half of [`stats_delta`], compared the same way: one bit per field that differs, -/// a power-type change resending both current and max power (cm:GroupHandler.cpp:592-593). fn pet_delta(previous: &PetStats, current: &PetStats) -> GroupUpdateMask { let mut mask = GroupUpdateMask::NONE; if previous.guid != current.guid { @@ -350,18 +302,6 @@ fn pet_delta(previous: &PetStats, current: &PetStats) -> GroupUpdateMask { mask } -/// The mask the explicit `SMSG_PARTY_MEMBER_STATS_FULL` answer carries: every base and aura -/// field, plus every pet field when the member has a live pet. No pet strips `GROUP_UPDATE_PET` -/// whole (cm:GroupHandler.cpp:781-786), the way `HandleRequestPartyMemberStatsOpcode` builds -/// `mask1`. Unlike this, a first send through the Relay's `SMSG_PARTY_MEMBER_STATS` carries every -/// pet field unconditionally; see [`stats_delta`]'s `None` case. Which slots inside the aura and -/// pet-aura blocks actually carry a value is a separate, encode-time question; see -/// [`build_member_stats`]. -/// -/// cmangos's own no-pet branch there still appends a phantom `uint8(0)` and `uint32(0)` -/// afterwards (cm:GroupHandler.cpp:885-889), 5 bytes past every field its own mask claims. A -/// reader parses strictly from the mask, gtker's included, so those bytes go unread on a real -/// client; this encoder skips writing them. pub fn full_update_mask(stats: &MemberStats) -> GroupUpdateMask { let mask = GroupUpdateMask::MEMBER | GroupUpdateMask::AURAS | GroupUpdateMask::AURAS_NEGATIVE; if stats.pet.guid == 0 { @@ -371,7 +311,6 @@ pub fn full_update_mask(stats: &MemberStats) -> GroupUpdateMask { } } -/// Which of the two packets carries a Member Stats body. Both share one body grammar. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MemberStatsPacket { Changed, @@ -387,16 +326,11 @@ impl MemberStatsPacket { } } -/// Push a NUL-terminated CString. fn push_cstr(body: &mut Vec, s: &str) { body.extend_from_slice(s.as_bytes()); body.push(0); } -/// The positive-slot dirty mask: every slot for a first send (`previous` is `None`, cmangos marks -/// the whole update flag set on join or online, cm:Group.cpp:306-307, 746-747, so every aura slot -/// rides the next packet), or the slots that changed since `previous` for a delta -/// (cm:GroupHandler.cpp:632-644). fn positive_dirty_mask( previous: Option<&[u16; POSITIVE_AURA_SLOTS]>, current: &[u16; POSITIVE_AURA_SLOTS], @@ -415,7 +349,6 @@ fn positive_dirty_mask( } } -/// [`positive_dirty_mask`] for the negative slots. fn negative_dirty_mask( previous: Option<&[u16; NEGATIVE_AURA_SLOTS]>, current: &[u16; NEGATIVE_AURA_SLOTS], @@ -434,14 +367,6 @@ fn negative_dirty_mask( } } -/// `AURAS`/`PET_AURAS`: a `u32` mask of `dirty` slots, then each dirty slot's current spell id (0 -/// for an empty one) in slot order (cm:GroupHandler.cpp:632-644, 720-737). Every slot rides a -/// `None`-`previous` packet (`dirty = u32::MAX`, [`positive_dirty_mask`]), so an empty slot is -/// written explicitly rather than skipped: cmangos's own FULL scan skips an empty slot instead -/// (cm:GroupHandler.cpp:822-833), which cannot clear a slot the requester's client already shows -/// from before a mate's offline round-trip. A pet's own block covers only its positive slots: -/// cmangos loops the pet block to `MAX_AURAS` there (cm:GroupHandler.cpp:863), which overflows the -/// `u32` mask it just wrote. This encoder does not copy that bug. fn write_positive_aura_block(body: &mut Vec, dirty: u32, current: &[u16; POSITIVE_AURA_SLOTS]) { body.extend_from_slice(&dirty.to_le_bytes()); for (i, &value) in current.iter().enumerate() { @@ -451,8 +376,6 @@ fn write_positive_aura_block(body: &mut Vec, dirty: u32, current: &[u16; POS } } -/// `AURAS_NEGATIVE`/`PET_AURAS_NEGATIVE`: a `u16` mask of `dirty` slots, bit `n` meaning slot -/// `32 + n` (cm:GroupHandler.cpp:646-658, 739-754). fn write_negative_aura_block(body: &mut Vec, dirty: u16, current: &[u16; NEGATIVE_AURA_SLOTS]) { body.extend_from_slice(&dirty.to_le_bytes()); for (i, &value) in current.iter().enumerate() { @@ -462,14 +385,6 @@ fn write_negative_aura_block(body: &mut Vec, dirty: u16, current: &[u16; NEG } } -/// Build one Member Stats packet: packed guid, `u32` mask, then each masked field in bit order -/// with the widths cmangos writes (cm:Group.h:66-89, cm:GroupHandler.cpp:585-756). -/// -/// `previous` is the snapshot the aura and pet-aura blocks diff against to decide which of their -/// slots carry a value: `None` for a first send, which marks every slot dirty (the FULL answer's -/// own aura scan behaves the same way, since it has no snapshot either). It plays no part in -/// `mask`, which the caller already decided ([`stats_delta`] or [`full_update_mask`]). -/// Returns `(opcode, body)` for [`Outbound::Raw`](crate::world::Outbound::Raw). pub fn build_member_stats( packet: MemberStatsPacket, guid: u64, @@ -555,8 +470,6 @@ pub fn build_member_stats( (packet.opcode(), body) } -/// A status-only body: mask `STATUS` and the one byte. With status 0 it is the offline answer -/// (cm:GroupHandler.cpp:764-771). pub fn build_member_status(packet: MemberStatsPacket, guid: u64, status: u8) -> (u16, Vec) { let stats = MemberStats { status, @@ -571,10 +484,8 @@ mod tests { use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::Power; - /// A player-shaped guid whose packed form is `03 02 01`. const GUID: u64 = 0x0102; - /// cm:Group.h:92's `GROUP_UPDATE_FULL` (0x1FFFFF): every base, aura and pet field. fn full_group_update_mask() -> GroupUpdateMask { GroupUpdateMask::MEMBER | GroupUpdateMask::AURAS @@ -582,7 +493,6 @@ mod tests { | GroupUpdateMask::PET } - /// Wire values for a level 20 caster standing in Elwynn Forest, near Northshire. fn caster() -> MemberStats { MemberStats { status: member_status::ONLINE, @@ -599,8 +509,6 @@ mod tests { } } - /// The caster with two positive auras (slots 0 and 5) and one negative aura (slot 35, bit 3 - /// of the negative mask). fn caster_with_auras() -> MemberStats { let mut stats = caster(); stats.auras[0] = 100; @@ -609,8 +517,6 @@ mod tests { stats } - /// A Hunter's pet: alive, with one positive aura (slot 2) and one negative aura (slot 40, bit - /// 8 of the pet's negative mask). fn pet() -> PetStats { let mut pet = PetStats { guid: 85, @@ -618,7 +524,7 @@ mod tests { model_id: 618, current_health: 50, max_health: 60, - power_type: 2, // focus, a Hunter pet's power (0 mana, 1 rage, 2 focus, 3 energy) + power_type: 2, current_power: 40, max_power: 100, ..PetStats::default() @@ -628,9 +534,6 @@ mod tests { pet } - /// The wire bytes for a positive `AURAS`/`PET_AURAS` block where every slot rides the packet - /// (a `None` previous, the sent-once-since-offline shape): mask `0xFFFFFFFF`, then a spell id - /// per slot in order, `occupied` overlaying the `(slot, spell_id)` pairs that are not empty. fn full_positive_aura_bytes(occupied: &[(usize, u16)]) -> Vec { let mut slots = [0u16; POSITIVE_AURA_SLOTS]; for &(slot, spell_id) in occupied { @@ -643,8 +546,6 @@ mod tests { bytes } - /// [`full_positive_aura_bytes`] for the negative block: mask `0xFFFF`, then a spell id per - /// slot, `occupied` indexed the same way as [`PetStats::auras_negative`] (slot `32 + n`). fn full_negative_aura_bytes(occupied: &[(usize, u16)]) -> Vec { let mut slots = [0u16; NEGATIVE_AURA_SLOTS]; for &(slot, spell_id) in occupied { @@ -657,7 +558,6 @@ mod tests { bytes } - /// Frame a body the way `Outbound::Raw` does, then decode it with gtker. fn gtker_decode(opcode: u16, body: &[u8]) -> ServerOpcodeMessage { let mut framed = ((2 + body.len()) as u16).to_be_bytes().to_vec(); framed.extend_from_slice(&opcode.to_le_bytes()); @@ -682,7 +582,6 @@ mod tests { #[test] fn field_bits_are_the_cmangos_group_update_flags() { use wow_world_messages::vanilla::GroupUpdateFlags as Gtker; - // cm:Group.h:68-89 typed from the header, then gtker's constants as a second source. let pinned = [ (GroupUpdateMask::STATUS, 0x0000_0001, Gtker::STATUS), (GroupUpdateMask::CUR_HP, 0x0000_0002, Gtker::CUR_HP), @@ -737,7 +636,6 @@ mod tests { #[test] fn status_bits_are_the_cmangos_member_status() { use wow_world_messages::vanilla::GroupMemberOnlineStatus as Gtker; - // cm:Group.h:47-53, then gtker's constants. let pinned = [ (member_status::OFFLINE, 0x00, Gtker::OFFLINE), (member_status::ONLINE, 0x01, Gtker::ONLINE), @@ -752,7 +650,6 @@ mod tests { #[test] fn each_field_has_the_cmangos_width() { - // `GroupUpdateLength`, cm:Group.h:97 and vm:Group.h:153, entries 0-8. const GROUP_UPDATE_LENGTH: [usize; 9] = [1, 2, 2, 1, 2, 2, 2, 2, 4]; let header = 3 + 4; for (bit, width) in GROUP_UPDATE_LENGTH.into_iter().enumerate() { @@ -765,8 +662,6 @@ mod tests { #[test] fn each_pet_scalar_field_has_the_cmangos_width() { - // The same `GroupUpdateLength` table, entries 11 and 13-18, the pet fields with a fixed - // width. 12 (PET_NAME), 19 and 20 (the pet aura blocks) are variable and excluded. let stats = MemberStats { pet: pet(), ..caster() @@ -800,25 +695,22 @@ mod tests { assert_eq!(opcode, 0x007E); #[rustfmt::skip] let expected = [ - 0x03, 0x02, 0x01, // packed guid 0x0102 - 0xFF, 0x01, 0x00, 0x00, // mask 0x1FF - 0x01, // status ONLINE - 0xD2, 0x04, // current health 1234 - 0xDC, 0x05, // max health 1500 - 0x00, // power type mana - 0x20, 0x03, // current power 800 - 0xE8, 0x03, // max power 1000 - 0x14, 0x00, // level 20 - 0x0C, 0x00, // zone 12, u16 - 0x0B, 0xDD, // x -8949 - 0x7C, 0xFF, // y -132 + 0x03, 0x02, 0x01, + 0xFF, 0x01, 0x00, 0x00, + 0x01, + 0xD2, 0x04, + 0xDC, 0x05, + 0x00, + 0x20, 0x03, + 0xE8, 0x03, + 0x14, 0x00, + 0x0C, 0x00, + 0x0B, 0xDD, + 0x7C, 0xFF, ]; assert_eq!(body, expected); } - /// A FULL packet with positive auras, negative auras and a pet, built by hand from - /// cm:GroupHandler.cpp:585-756. `None` marks every aura slot dirty (cm:Group.cpp:306-307, - /// 746-747), so both blocks carry all 32 or 16 slots, zero for an empty one. #[test] fn a_full_body_with_auras_and_a_pet_matches_hand_derived_bytes() { let stats = MemberStats { @@ -831,30 +723,14 @@ mod tests { assert_eq!(opcode, 0x02F2); let mut expected = vec![ - 0x03, 0x02, 0x01, // packed guid 0x0102 - 0xFF, 0xFF, 0x1F, 0x00, // mask 0x1FFFFF (GROUP_UPDATE_FULL) - 0x01, // status ONLINE - 0xD2, 0x04, // current health 1234 - 0xDC, 0x05, // max health 1500 - 0x00, // power type mana - 0x20, 0x03, // current power 800 - 0xE8, 0x03, // max power 1000 - 0x14, 0x00, // level 20 - 0x0C, 0x00, // zone 12 - 0x0B, 0xDD, // x -8949 - 0x7C, 0xFF, // y -132 + 0x03, 0x02, 0x01, 0xFF, 0xFF, 0x1F, 0x00, 0x01, 0xD2, 0x04, 0xDC, 0x05, 0x00, 0x20, + 0x03, 0xE8, 0x03, 0x14, 0x00, 0x0C, 0x00, 0x0B, 0xDD, 0x7C, 0xFF, ]; expected.extend(full_positive_aura_bytes(&[(0, 100), (5, 200)])); expected.extend(full_negative_aura_bytes(&[(3, 300)])); expected.extend([ - 0x55, 0, 0, 0, 0, 0, 0, 0, // PET_GUID 85 - b'F', b'l', b'u', b'f', b'f', b'y', 0x00, // PET_NAME "Fluffy" - 0x6A, 0x02, // PET_MODEL_ID 618 - 0x32, 0x00, // PET_CUR_HP 50 - 0x3C, 0x00, // PET_MAX_HP 60 - 0x02, // PET_POWER_TYPE 2 (focus) - 0x28, 0x00, // PET_CUR_POWER 40 - 0x64, 0x00, // PET_MAX_POWER 100 + 0x55, 0, 0, 0, 0, 0, 0, 0, b'F', b'l', b'u', b'f', b'f', b'y', 0x00, 0x6A, 0x02, 0x32, + 0x00, 0x3C, 0x00, 0x02, 0x28, 0x00, 0x64, 0x00, ]); expected.extend(full_positive_aura_bytes(&[(2, 400)])); expected.extend(full_negative_aura_bytes(&[(8, 500)])); @@ -877,9 +753,6 @@ mod tests { assert_eq!(body, [0x03, 0x02, 0x01, 0x02, 0x00, 0x00, 0x00, 0xE8, 0x03]); } - /// AC1: two buffs and one debuff appearing on top of a previously aura-less snapshot send - /// `AURAS` with two ids and `AURAS_NEGATIVE` with one id, at the right bit. The body length - /// matches the mask (2 masks + 3 ids = 12 bytes, no padding for the 29 untouched slots). #[test] fn positive_and_negative_auras_send_their_ids_at_the_right_bits() { let before = caster(); @@ -900,11 +773,11 @@ mod tests { let header = 3 + 4; #[rustfmt::skip] let expected = [ - 0x21, 0x00, 0x00, 0x00, // AURAS mask: slots 0 and 5 - 0x64, 0x00, // spell 100 - 0xC8, 0x00, // spell 200 - 0x08, 0x00, // AURAS_NEGATIVE mask: slot 35 (bit 3) - 0x2C, 0x01, // spell 300 + 0x21, 0x00, 0x00, 0x00, + 0x64, 0x00, + 0xC8, 0x00, + 0x08, 0x00, + 0x2C, 0x01, ]; assert_eq!(&body[header..], expected); } @@ -929,13 +802,11 @@ mod tests { assert_eq!(&body[header..], [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]); } - /// Replacing a buff in an already-occupied slot still sends only that one bit, now carrying - /// the new id. #[test] fn replacing_an_aura_in_one_slot_sends_only_that_bit_with_the_new_id() { - let before = caster_with_auras(); // slot 0 carries spell 100 + let before = caster_with_auras(); let mut after = before.clone(); - after.auras[0] = 150; // a different buff lands in the same slot + after.auras[0] = 150; let mask = stats_delta(Some(&before), &after); assert_eq!(mask, GroupUpdateMask::AURAS); @@ -951,7 +822,6 @@ mod tests { assert_eq!(&body[header..], [0x01, 0x00, 0x00, 0x00, 0x96, 0x00]); } - /// A new debuff sends only the negative bit, at slot `32 + 10` (bit 10 of the negative mask). #[test] fn adding_a_debuff_sends_only_the_negative_bit() { let before = caster(); @@ -972,11 +842,9 @@ mod tests { assert_eq!(&body[header..], [0x00, 0x04, 0xBC, 0x02]); } - /// A cleared debuff sends only its bit, with id 0, the negative twin of - /// `removing_a_buff_sends_only_its_bit_with_id_zero`. #[test] fn removing_a_debuff_sends_only_its_bit_with_id_zero() { - let before = caster_with_auras(); // negative slot 3 carries spell 300 + let before = caster_with_auras(); let mut after = before.clone(); after.auras_negative[3] = 0; @@ -994,11 +862,10 @@ mod tests { assert_eq!(&body[header..], [0x08, 0x00, 0x00, 0x00]); } - /// A pet's own aura changing sends only its bit, never the member's own `AURAS` bit. #[test] fn a_pet_aura_delta_sends_only_the_pet_bit() { let before = MemberStats { - pet: pet(), // pet's slot 2 carries spell 400 + pet: pet(), ..caster() }; let mut after = before.clone(); @@ -1026,9 +893,6 @@ mod tests { }; assert!(stats_delta(None, &stats).contains(GroupUpdateMask::PET)); - // The pet bits alone: gtker misreads ZONE as a u32 (cm and vmangos write a u16, per this - // file's own module doc), so a round trip through its decoder leaves that field out, the - // same way `gtker_reads_every_field_except_the_zone_the_same_way` does. let (_, body) = build_member_stats( MemberStatsPacket::Changed, GUID, @@ -1063,7 +927,7 @@ mod tests { pet: pet(), ..caster() }; - let after = caster(); // `PetStats::default()`: no pet. + let after = caster(); let mask = stats_delta(Some(&before), &after); assert!(mask.contains(GroupUpdateMask::PET_GUID)); @@ -1085,22 +949,21 @@ mod tests { let header = 3 + 4; #[rustfmt::skip] let expected = [ - 0, 0, 0, 0, 0, 0, 0, 0, // PET_GUID zeroed - 0x00, // PET_NAME: an empty CString - 0x00, 0x00, // PET_MODEL_ID zeroed - 0x00, 0x00, // PET_CUR_HP zeroed - 0x00, 0x00, // PET_MAX_HP zeroed - 0x00, // PET_POWER_TYPE zeroed - 0x00, 0x00, // PET_CUR_POWER zeroed - 0x00, 0x00, // PET_MAX_POWER zeroed - 0x04, 0x00, 0x00, 0x00, // PET_AURAS: slot 2 cleared, still the one differing bit - 0x00, 0x00, // spell id 0 - 0x00, 0x01, // PET_AURAS_NEGATIVE: slot 40 (bit 8) cleared - 0x00, 0x00, // spell id 0 + 0, 0, 0, 0, 0, 0, 0, 0, + 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x01, + 0x00, 0x00, ]; assert_eq!(&body[header..], expected); - // Idempotent: an unchanged pet-less member sends nothing more about its pet. assert!(stats_delta(Some(&after), &after).is_empty()); } @@ -1115,7 +978,6 @@ mod tests { assert!(!mask.contains(GroupUpdateMask::PET)); let (_, body) = build_member_stats(MemberStatsPacket::Full, GUID, mask, None, &stats); - // Header + 9 base fields + AURAS (mask + 32 zeroed slots) + AURAS_NEGATIVE (mask + 16). assert_eq!( body.len(), (3 + 4) + 18 + (4 + POSITIVE_AURA_SLOTS * 2) + (2 + NEGATIVE_AURA_SLOTS * 2) @@ -1163,10 +1025,6 @@ mod tests { assert_eq!(decoded.mask.get_status().unwrap().status.as_int(), 0); } - /// The reason both packets are hand-rolled rather than built with gtker: its `AuraMask` - /// always reads a `u32` mask (32 slots) for `AURAS` and `AURAS_2` alike. Fed cmangos's `u16` - /// negative-aura block, it reads the id that should follow the mask as more mask bytes, - /// expects far more trailing spell ids than we wrote, and runs off the end of the buffer. #[test] fn gtkers_own_reader_treats_our_negative_aura_block_as_a_different_shape() { use std::io::Cursor; @@ -1204,7 +1062,7 @@ mod tests { max_health: 1500, power: 800, max_power: 1000, - unit_bytes_0: 0x0100_0101, // human warrior with rage in byte 3 + unit_bytes_0: 0x0100_0101, level: 20, zone_id: 12, x: -8949.95, @@ -1247,7 +1105,7 @@ mod tests { max_health: 60, power: 40, max_power: 100, - unit_bytes_0: 0x0200_0000, // power type 2, focus + unit_bytes_0: 0x0200_0000, auras: vec![ MemberAuraSlot { slot: 2, @@ -1279,7 +1137,6 @@ mod tests { ); } - /// [`caster`] as a [`MemberEntity`], for the projection tests above. fn caster_entity() -> MemberEntity { MemberEntity { health: 1234, @@ -1331,7 +1188,6 @@ mod tests { #[test] fn a_viewer_holding_nothing_gets_every_field_pet_included() { - // No pet, no auras: still every field, unconditionally (cm:Group.cpp:306-307, 746-747). assert_eq!(stats_delta(None, &caster()), full_group_update_mask()); let stats = MemberStats { pet: pet(), diff --git a/gateway/src/codec/mod.rs b/gateway/src/codec/mod.rs index c64994479..35e3a90b8 100644 --- a/gateway/src/codec/mod.rs +++ b/gateway/src/codec/mod.rs @@ -1,26 +1,9 @@ -//! Codec: map gateway-side row views to `wow_world_messages` (vanilla) messages. -//! -//! Most byte layouts come from `wow_world_messages`. The build-5875 movement codec corrects -//! its transport flag and layout. This module is the thin mapping from the gateway's flattened -//! row views, such as `CharacterView`, `EntityView`, and `AuctionView`, to the crate's typed -//! messages: char enum, the -//! `CREATE_OBJECT2` self/peer spawn (built via the crate's `UpdatePlayer::builder()`, which -//! owns the descriptor bit indices), the post-login -//! sequence, the `MSG_MOVE_*` relay, and destroy. Header framing/cipher is delegated to -//! `wow_srp` by the message write methods. -//! -//! The production body is split into packet-family submodules behind this thin facade, which -//! re-exports their public symbols as `crate::codec::`. The shared `use` imports below stay -//! here and the older submodules pull them in via `use super::*`. The crash-critical partial-VALUES -//! builders live in `values`. - pub mod addon; pub mod auth_session; pub mod channel; pub mod social; pub mod update_mask; -// Packet-family submodules. pub(crate) mod auction; pub(crate) mod breath; mod char; @@ -56,7 +39,6 @@ pub use social::{ WhoPlayerView, }; -// Re-export packet-family surfaces through `crate::codec::`. pub use auction::*; pub use breath::*; pub use char::*; @@ -82,246 +64,61 @@ pub use trainer::*; pub use values::*; pub use weather::*; -// Shared imports for the family submodules. Kept at this (private) module level so each submodule -// glob-imports them via `use super::*`; the type aliases / row-view structs they build all live in -// the submodules themselves. use anyhow::{anyhow, Result}; use lyracore_shared::constants::speeds; use lyracore_shared::opcodes::movement as movement_opcodes; use lyracore_shared::packing::unpack4; use wow_world_messages::vanilla::opcodes::{ClientOpcodeMessage, ServerOpcodeMessage}; use wow_world_messages::vanilla::{ - AllowedClass, - AllowedRace, - Area, - BagFamily, - // Item binding: "Binds when picked up/equipped" tooltip line. - Bonding, - // Bank bag slot purchase result — the enum's own numbering IS the wire value; see - // `lyracore_shared::bank::result`, which must never drift from `as_int()` below. - BuyBankSlotResult, - BuyResult, - Character, - CharacterFlags, - CharacterGear, - Class, - CreatureFamily, - DamageInfo, - DateTime, - Faction, - FactionFlag, - FactionInitializer, - Gender, - Gold, - GossipItem, - HitInfo, - InitialSpell, - InventoryType, - // `SMSG_SET_PROFICIENCY` names the item class its subclass mask applies to (Weapon / Armor). - ItemClass, - ItemClassAndSubClass, - ItemDamageType, - // Item flags bitmask + bag-type restriction on the query response. - ItemFlag, - ItemQuality, - ItemSlot, - ItemSpells, - ItemStat, - ItemStatType, - Level, - LogoutResult, - LogoutSpeed, - MSG_CORPSE_QUERY_Server, - MSG_MOVE_FALL_LAND_Server, - MSG_MOVE_HEARTBEAT_Server, - MSG_MOVE_JUMP_Server, - MSG_MOVE_SET_FACING_Server, - MSG_MOVE_SET_RUN_MODE_Server, - MSG_MOVE_SET_WALK_MODE_Server, - MSG_MOVE_START_BACKWARD_Server, - MSG_MOVE_START_FORWARD_Server, - MSG_MOVE_START_STRAFE_LEFT_Server, - MSG_MOVE_START_STRAFE_RIGHT_Server, - MSG_MOVE_START_SWIM_Server, - MSG_MOVE_START_TURN_LEFT_Server, - MSG_MOVE_START_TURN_RIGHT_Server, - MSG_MOVE_STOP_STRAFE_Server, - MSG_MOVE_STOP_SWIM_Server, - MSG_MOVE_STOP_Server, - MSG_MOVE_STOP_TURN_Server, - MSG_MOVE_TELEPORT_ACK_Server, - Map, - Month, - MovementBlock, - MovementBlock_MovementFlags, - MovementBlock_UpdateFlag, - MovementBlock_UpdateFlag_All, - MovementBlock_UpdateFlag_Living, - MovementInfo, - MovementInfo_MovementFlags, - NpcTextUpdate, - NpcTextUpdateEmote, - Object, - ObjectType, - PetCommandState, - PetEnabled, - PetReactState, - Power, - QuestCompletable, - QuestGiverStatus, - QuestItem, - QuestItemRequirement, - QuestItemReward, - Race, - // Group loot methods: the need/greed vote enum shared by CMSG_LOOT_ROLL / - // SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON. - RollVote, - SMSG_CREATURE_QUERY_RESPONSE_found, - SMSG_GAMEOBJECT_QUERY_RESPONSE_found, - SMSG_ITEM_QUERY_SINGLE_RESPONSE_found, - SMSG_MONSTER_MOVE_MonsterMoveType, - SMSG_PET_SPELLS_action_bars, - SMSG_SPELL_GO_CastFlags, - SMSG_SPELL_GO_CastFlags_Ammo, - SMSG_SPELL_START_CastFlags, - SMSG_SPELL_START_CastFlags_Ammo, - SheatheType, - Skill, - SkillInfo, - SkillInfoIndex, - SpellCastResult, - SpellCastTargets, - SpellCastTargets_SpellCastTargetFlags, - SpellCastTargets_SpellCastTargetFlags_DestLocation, - SpellCastTargets_SpellCastTargetFlags_Unit, - SpellCooldownStatus, - SpellMiss, - SpellMissInfo, - SpellSchool, - SpellTriggerType, - SplineFlag, - TrainerSpell, - TrainerSpellState, - TrainingFailureReason, - TransferAbortReason, - UpdateContainer, - UpdateCorpse, - UpdateDynamicObject, - UpdateGameObject, - UpdateItem, - UpdateMask, - UpdatePlayer, - UpdateUnit, - Vector3d, - // Items: equip → render on the 3D model via PLAYER_VISIBLE_ITEM. - VisibleItem, - VisibleItemIndex, - Weekday, - WorldResult, - MSG_QUEST_PUSH_RESULT, - SMSG_ACCOUNT_DATA_TIMES, - SMSG_ACTION_BUTTONS, - SMSG_ATTACKERSTATEUPDATE, - SMSG_ATTACKSTART, - SMSG_ATTACKSTOP, - SMSG_BINDPOINTUPDATE, - // Bank bag slot purchase result feedback. - SMSG_BUY_BANK_SLOT_RESULT, - // Vendor buy failure feedback. - SMSG_BUY_FAILED, - SMSG_CHAR_CREATE, - SMSG_CHAR_DELETE, - SMSG_CHAR_ENUM, - SMSG_CREATURE_QUERY_RESPONSE, - SMSG_DESTROY_OBJECT, - SMSG_FORCE_RUN_SPEED_CHANGE, - SMSG_GAMEOBJECT_QUERY_RESPONSE, - // Gossip (rank 12): the gossip-window message + its menu items + the NPC-text title round-trip. - SMSG_GOSSIP_MESSAGE, - SMSG_INITIALIZE_FACTIONS, - SMSG_INITIAL_SPELLS, - // Inspect: ack the target guid so the client opens the paperdoll window. - SMSG_INSPECT, - // Inventory-action failure feedback (equip / move / store / use rejection). - SMSG_INVENTORY_CHANGE_FAILURE, - // Items: the item-query response + the typed item CREATE_OBJECT descriptors. - // UpdateContainer is the container (bag) analogue of UpdateItem — same ITEM+CONTAINER mask. - SMSG_ITEM_QUERY_SINGLE_RESPONSE, - SMSG_LEARNED_SPELL, - SMSG_LEVELUP_INFO, - SMSG_LOGIN_SETTIMESPEED, - SMSG_LOGIN_VERIFY_WORLD, - SMSG_LOGOUT_RESPONSE, - SMSG_LOOT_MASTER_LIST, - SMSG_LOOT_MONEY_NOTIFY, - SMSG_LOOT_RELEASE_RESPONSE, - SMSG_LOOT_REMOVED, - SMSG_LOOT_ROLL, - SMSG_LOOT_ROLL_WON, - // Group loot methods: need/greed rolls + master looter. - SMSG_LOOT_START_ROLL, - SMSG_MONSTER_MOVE, - SMSG_NAME_QUERY_RESPONSE, - SMSG_NEW_WORLD, - SMSG_NPC_TEXT_UPDATE, - // Warlock pet UI: the pet action bar + enable state pushed at summon (guid-only form clears). - SMSG_PET_SPELLS, - // /played: total/level played-time reply. - SMSG_PLAYED_TIME, - SMSG_QUESTGIVER_OFFER_REWARD, - SMSG_QUESTGIVER_QUEST_COMPLETE, - SMSG_QUESTGIVER_QUEST_LIST, - SMSG_QUESTGIVER_REQUEST_ITEMS, - // Quests (gateway wire): the questgiver-dialog packets + their list/reward sub-structs (all - // gtker-typed — vanilla-complete, unlike the raw-encoded loot/vendor paths). - SMSG_QUESTGIVER_STATUS, - SMSG_QUESTUPDATE_ADD_KILL, - // Timed quests + sharing: expiry feedback + the party-share push-result round trip. - SMSG_QUESTUPDATE_FAILEDTIMER, - // Resurrection accept-prompt handshake: the offer sent to a dead ally awaiting their - // CMSG_RESURRECT_RESPONSE accept/decline. - SMSG_RESURRECT_REQUEST, - // NOTE: `SMSG_SET_FACTION_STANDING`/`FactionStanding` are deliberately absent — gtker writes the - // faction ENTRY where 1.12 wants the rep INDEX, so that packet is RAW-encoded in `entity.rs` - // (`build_faction_standing_raw`). Re-importing the typed pair would resurrect the McBride - // client-crash (see `update_mask.rs`'s note). - // What the client may equip, per item class — drives the red "unusable" tint on a tooltip. - SMSG_SET_PROFICIENCY, - SMSG_SET_REST_START, - // Banker activate + the BANKER gossip option: opens the bank window. - SMSG_SHOW_BANK, - // Floating spell damage number (the per-cast non-melee damage log). - SMSG_SPELLNONMELEEDAMAGELOG, - SMSG_SPELL_COOLDOWN, - // Cast pushback: the client cast-bar slide signal on a direct-damage pushback hit. - SMSG_SPELL_DELAYED, - // Cast-interrupt signal: the cast-bar teardown relayed to the caster when its timed cast is cancelled. - SMSG_SPELL_FAILURE, - SMSG_SPELL_GO, - SMSG_SPELL_START, - SMSG_TRAINER_BUY_FAILED, - SMSG_TRAINER_BUY_SUCCEEDED, - // Class trainers: the trainer-window list + buy result + the live learned-spell push (all gtker-typed - // vanilla — SMSG_TRAINER_LIST IS vanilla-complete in the crate, unlike the raw vendor list). - SMSG_TRAINER_LIST, - // The loud failure: a transfer that cannot proceed must abort the client's loading - // screen, never leave it spinning. - SMSG_TRANSFER_ABORTED, - // Cross-map teleport arrival: loading-screen kickoff + the new-map position. - SMSG_TRANSFER_PENDING, - SMSG_TUTORIAL_FLAGS, - // Buff/debuff timer (the UpdateMask aura array has no duration in 1.12 — sent separately). - SMSG_UPDATE_AURA_DURATION, - SMSG_UPDATE_OBJECT, + AllowedClass, AllowedRace, Area, BagFamily, Bonding, BuyBankSlotResult, BuyResult, Character, + CharacterFlags, CharacterGear, Class, CreatureFamily, DamageInfo, DateTime, Faction, + FactionFlag, FactionInitializer, Gender, Gold, GossipItem, HitInfo, InitialSpell, + InventoryType, ItemClass, ItemClassAndSubClass, ItemDamageType, ItemFlag, ItemQuality, + ItemSlot, ItemSpells, ItemStat, ItemStatType, Level, LogoutResult, LogoutSpeed, + MSG_CORPSE_QUERY_Server, MSG_MOVE_FALL_LAND_Server, MSG_MOVE_HEARTBEAT_Server, + MSG_MOVE_JUMP_Server, MSG_MOVE_SET_FACING_Server, MSG_MOVE_SET_RUN_MODE_Server, + MSG_MOVE_SET_WALK_MODE_Server, MSG_MOVE_START_BACKWARD_Server, MSG_MOVE_START_FORWARD_Server, + MSG_MOVE_START_STRAFE_LEFT_Server, MSG_MOVE_START_STRAFE_RIGHT_Server, + MSG_MOVE_START_SWIM_Server, MSG_MOVE_START_TURN_LEFT_Server, MSG_MOVE_START_TURN_RIGHT_Server, + MSG_MOVE_STOP_STRAFE_Server, MSG_MOVE_STOP_SWIM_Server, MSG_MOVE_STOP_Server, + MSG_MOVE_STOP_TURN_Server, MSG_MOVE_TELEPORT_ACK_Server, Map, Month, MovementBlock, + MovementBlock_MovementFlags, MovementBlock_UpdateFlag, MovementBlock_UpdateFlag_All, + MovementBlock_UpdateFlag_Living, MovementInfo, MovementInfo_MovementFlags, NpcTextUpdate, + NpcTextUpdateEmote, Object, ObjectType, PetCommandState, PetEnabled, PetReactState, Power, + QuestCompletable, QuestGiverStatus, QuestItem, QuestItemRequirement, QuestItemReward, Race, + RollVote, SMSG_CREATURE_QUERY_RESPONSE_found, SMSG_GAMEOBJECT_QUERY_RESPONSE_found, + SMSG_ITEM_QUERY_SINGLE_RESPONSE_found, SMSG_MONSTER_MOVE_MonsterMoveType, + SMSG_PET_SPELLS_action_bars, SMSG_SPELL_GO_CastFlags, SMSG_SPELL_GO_CastFlags_Ammo, + SMSG_SPELL_START_CastFlags, SMSG_SPELL_START_CastFlags_Ammo, SheatheType, Skill, SkillInfo, + SkillInfoIndex, SpellCastResult, SpellCastTargets, SpellCastTargets_SpellCastTargetFlags, + SpellCastTargets_SpellCastTargetFlags_DestLocation, SpellCastTargets_SpellCastTargetFlags_Unit, + SpellCooldownStatus, SpellMiss, SpellMissInfo, SpellSchool, SpellTriggerType, SplineFlag, + TrainerSpell, TrainerSpellState, TrainingFailureReason, TransferAbortReason, UpdateContainer, + UpdateCorpse, UpdateDynamicObject, UpdateGameObject, UpdateItem, UpdateMask, UpdatePlayer, + UpdateUnit, Vector3d, VisibleItem, VisibleItemIndex, Weekday, WorldResult, + MSG_QUEST_PUSH_RESULT, SMSG_ACCOUNT_DATA_TIMES, SMSG_ACTION_BUTTONS, SMSG_ATTACKERSTATEUPDATE, + SMSG_ATTACKSTART, SMSG_ATTACKSTOP, SMSG_BINDPOINTUPDATE, SMSG_BUY_BANK_SLOT_RESULT, + SMSG_BUY_FAILED, SMSG_CHAR_CREATE, SMSG_CHAR_DELETE, SMSG_CHAR_ENUM, + SMSG_CREATURE_QUERY_RESPONSE, SMSG_DESTROY_OBJECT, SMSG_FORCE_RUN_SPEED_CHANGE, + SMSG_GAMEOBJECT_QUERY_RESPONSE, SMSG_GOSSIP_MESSAGE, SMSG_INITIALIZE_FACTIONS, + SMSG_INITIAL_SPELLS, SMSG_INSPECT, SMSG_INVENTORY_CHANGE_FAILURE, + SMSG_ITEM_QUERY_SINGLE_RESPONSE, SMSG_LEARNED_SPELL, SMSG_LEVELUP_INFO, + SMSG_LOGIN_SETTIMESPEED, SMSG_LOGIN_VERIFY_WORLD, SMSG_LOGOUT_RESPONSE, SMSG_LOOT_MASTER_LIST, + SMSG_LOOT_MONEY_NOTIFY, SMSG_LOOT_RELEASE_RESPONSE, SMSG_LOOT_REMOVED, SMSG_LOOT_ROLL, + SMSG_LOOT_ROLL_WON, SMSG_LOOT_START_ROLL, SMSG_MONSTER_MOVE, SMSG_NAME_QUERY_RESPONSE, + SMSG_NEW_WORLD, SMSG_NPC_TEXT_UPDATE, SMSG_PET_SPELLS, SMSG_PLAYED_TIME, + SMSG_QUESTGIVER_OFFER_REWARD, SMSG_QUESTGIVER_QUEST_COMPLETE, SMSG_QUESTGIVER_QUEST_LIST, + SMSG_QUESTGIVER_REQUEST_ITEMS, SMSG_QUESTGIVER_STATUS, SMSG_QUESTUPDATE_ADD_KILL, + SMSG_QUESTUPDATE_FAILEDTIMER, SMSG_RESURRECT_REQUEST, SMSG_SET_PROFICIENCY, + SMSG_SET_REST_START, SMSG_SHOW_BANK, SMSG_SPELLNONMELEEDAMAGELOG, SMSG_SPELL_COOLDOWN, + SMSG_SPELL_DELAYED, SMSG_SPELL_FAILURE, SMSG_SPELL_GO, SMSG_SPELL_START, + SMSG_TRAINER_BUY_FAILED, SMSG_TRAINER_BUY_SUCCEEDED, SMSG_TRAINER_LIST, SMSG_TRANSFER_ABORTED, + SMSG_TRANSFER_PENDING, SMSG_TUTORIAL_FLAGS, SMSG_UPDATE_AURA_DURATION, SMSG_UPDATE_OBJECT, }; use wow_world_messages::Guid; #[cfg(test)] mod tests; -/// Generated-input (property) tests for the hand-rolled decoders — the client-byte parsers -/// and the update-mask encode/decode round trip. Kept apart from `tests` because it is a different -/// kind of assertion (a generated input space, over a fixed seed) and because it also hosts the -/// seeded generator the logon-framing property test reuses. #[cfg(test)] pub(crate) mod property_tests; diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index 55d678aa2..0fa261448 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -1,8 +1,6 @@ use super::*; use wow_world_messages::vanilla::SMSG_FORCE_SWIM_SPEED_CHANGE; -/// The opcode of an inbound movement message, if it's one of the slice's relayed `MSG_MOVE_*` -/// Records and relays movement under the same opcode. pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { use movement_opcodes as m; Some(match msg { @@ -27,8 +25,7 @@ pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { }) } -/// Typed reference for movement without transport tails. Transport uses the build-5875 -/// codec and the raw relay, since the library's transport layout belongs to later clients. +/// The typed transport layout belongs to later clients; use the build-5875 codec. #[cfg_attr(not(test), allow(dead_code))] pub fn build_movement_relay( opcode: u32, @@ -126,16 +123,9 @@ pub fn build_movement_relay( }) } -/// The shortest legal `MovementInfo` body: `flags u32 + timestamp u32 + position 12 + -/// orientation f32 + fall_time u32`. Every optional block (transport / pitch / jump / spline -/// elevation) only makes it longer, so this is a floor, not an exact size — it exists so a truncated -/// row can never be framed as a movement packet (the client would parse past the end of the body and -/// desync its stream). +// Fixed MovementInfo fields require 28 bytes before any optional tails. const MIN_MOVEMENT_INFO_LEN: usize = 4 + 4 + 12 + 4 + 4; -/// Relay the stored build-5875 movement body under its original opcode, prefixed by the -/// mover's packed guid. Each observer's writer supplies the frame header and encryption. -/// Returns None for an unsupported opcode or a body shorter than the fixed movement fields. pub fn build_movement_relay_raw( opcode: u32, mover_guid: u64, @@ -144,20 +134,13 @@ pub fn build_movement_relay_raw( if !movement_opcodes::is_slice_move(opcode) || info_bytes.len() < MIN_MOVEMENT_INFO_LEN { return None; } - // 9 = the largest packed guid (pattern byte + 8 non-zero bytes). + // 9 = the largest packed guid, including its mask byte. let mut body = Vec::with_capacity(9 + info_bytes.len()); super::values::write_packed_guid_u64(&mut body, mover_guid); body.extend_from_slice(info_bytes); - // `MSG_MOVE_*` opcodes are bidirectional: the server re-emits under the SAME number the client - // sent (the `_Server` message's `OPCODE` equals the `_Client` one), so no mapping is needed. Some((opcode as u16, body)) } -/// Build `MSG_MOVE_TELEPORT_ACK_Server` (same-map teleport, opcode 0x00C7) moving `mover_guid` to -/// `(x, y, z)` facing `o`. The 5875 client applies the position on receipt and echoes a CMSG -/// `MSG_MOVE_TELEPORT_ACK` we accept-and-ignore, so `counter` (the movement counter it echoes) can be 0 — -/// we don't gate on its reply. The `MovementInfo` carries no movement flags (a clean stand-still arrival). -/// This is the reusable teleport leg: graveyard release, `debug_teleport`, and future `.tele`/hearthstone. pub fn build_teleport_ack( mover_guid: u64, counter: u32, @@ -179,13 +162,7 @@ pub fn build_teleport_ack( } } -/// Build the cross-map teleport-ARRIVAL pair: `SMSG_TRANSFER_PENDING(map)` (opcode -/// 0x003F — starts the client's loading screen) immediately followed by `SMSG_NEW_WORLD(map, x, y, z, o)` -/// (opcode 0x003E — the position on the new map). Sent back-to-back as ONE batch: both go out BEFORE the client even starts -/// loading — the client only replies `MSG_MOVE_WORLDPORT_ACK` (opcode 0x00DC, empty body — [V]ed against -/// the vendored gtker crate, `wow_world_messages` 0.3.0) once ITS OWN load completes, at which point the -/// gateway drives the entity rebuild + full subscription reset (`world::mod.rs`'s `enter_world`). -/// `map_id` must resolve to a client-known `Map` variant — `Err` in the pathological case (an unmapped id). +/// Queue TRANSFER_PENDING and NEW_WORLD together to keep loading and arrival ordered. pub fn build_cross_map_teleport( map_id: u32, x: f32, @@ -207,20 +184,6 @@ pub fn build_cross_map_teleport( ]) } -/// Build `SMSG_TRANSFER_ABORTED` (opcode 0x0040) — "the transfer you are loading for is not going to -/// happen". The ONLY thing worse than failing a dungeon entry is not telling the client, -/// because a client that has already been sent `SMSG_TRANSFER_PENDING` sits on its loading screen -/// forever with no error and no way out. This is the packet vanilla servers send on exactly this -/// path (mangos `SendTransferAborted`, e.g. "instance not found" / "instance is full"), so the -/// client's own handler surfaces it as a dialog and stops loading. -/// -/// `[V]`: the wire shape is pinned against the vendored gtker crate (`Map` u32 + `TransferAbortReason` -/// u8 + a trailing `argument` u8, body 6 bytes); the client-side DIALOG has not been eyeballed on a -/// live 1.12.1 client yet — the server-side property (the client is TOLD, and the socket is not -/// silently dropped mid-load) is what this fixes and what the tests pin. -/// -/// `map_id` must resolve to a client-known `Map` variant — `Err` for an unmapped id, which the -/// caller treats as "no abort packet", never as a reason to skip the rest of its failure handling. pub fn build_transfer_aborted( map_id: u32, reason: TransferAbortReason, @@ -232,11 +195,6 @@ pub fn build_transfer_aborted( }) } -/// Build `SMSG_FORCE_RUN_SPEED_CHANGE` (opcode 0x00E2): tell the client `guid` now runs at `speed` -/// yards/sec. Sprint (and any future move-speed aura) pushes this so the client actually moves faster — -/// player movement is client-authoritative, so the server-side `A_MOD_SPEED(MOVE)` aura alone never -/// changes the client's speed. `move_event` is 0 (cmangos/vmangos convention). The client echoes -/// `CMSG_FORCE_RUN_SPEED_CHANGE_ACK`, which we accept-and-ignore (we don't gate on its reply). pub fn build_force_run_speed(guid: u64, speed: f32) -> SMSG_FORCE_RUN_SPEED_CHANGE { SMSG_FORCE_RUN_SPEED_CHANGE { guid: Guid::new(guid), @@ -245,7 +203,6 @@ pub fn build_force_run_speed(guid: u64, speed: f32) -> SMSG_FORCE_RUN_SPEED_CHAN } } -/// Tell the owning client its current swimming speed in yards per second. pub fn build_force_swim_speed(guid: u64, speed: f32) -> SMSG_FORCE_SWIM_SPEED_CHANGE { SMSG_FORCE_SWIM_SPEED_CHANGE { guid: Guid::new(guid), @@ -254,10 +211,6 @@ pub fn build_force_swim_speed(guid: u64, speed: f32) -> SMSG_FORCE_SWIM_SPEED_CH } } -/// Build `SMSG_MONSTER_MOVE` for one server-driven creature leg: a straight walk from -/// `start` to `dest` over `duration_ms`. EXACTLY ONE destination point — the crate writes a single -/// spline point absolute, avoiding its multi-point packed-offset encoding (not 1.12-compatible). -/// `spline_id` must strictly increase per creature or the client ignores the new spline. pub fn build_monster_move( mover_guid: u64, start: Vector3d, @@ -271,16 +224,13 @@ pub fn build_monster_move( spline_point: start, spline_id, move_type: SMSG_MONSTER_MOVE_MonsterMoveType::Normal, - // RUN_MODE (0x100) = run animation; 0 = walk. Set from the leg's `run` flag so a RUN-speed - // chase/return/flee/fear leg run-animates (without it every leg walk-animated → moonwalk). spline_flags: SplineFlag::new(if run { 0x100 } else { 0 }), duration: duration_ms, splines: vec![dest], } } -/// Vanilla linear paths put the absolute destination first, then signed destination-relative -/// intermediate points. The typed writer uses a different multi-point representation. +// Linear paths send the destination first, then signed destination-relative points. pub fn build_ground_path_raw( mover_guid: u64, start: Vector3d, @@ -321,10 +271,7 @@ pub fn build_ground_path_raw( Some((0x00DD, body)) } -/// Build the dedicated multi-point passenger spline in the build-5875 Catmull-Rom form. The -/// vendored typed writer always applies linear-path PackXYZ after the destination, even when the -/// FLYING/Catmull-Rom flag is set. Taxi paths require full absolute points (and commonly have -/// negative world coordinates), so this packet is intentionally encoded raw. +/// Taxi Catmull-Rom paths need absolute points; gtker always applies linear PackXYZ. pub fn build_taxi_move_raw( passenger_guid: u64, start: Vector3d, @@ -346,8 +293,8 @@ pub fn build_taxi_move_raw( body.extend_from_slice(&value.to_le_bytes()); } body.extend_from_slice(&spline_id.to_le_bytes()); - body.push(0); // MonsterMoveNormal - body.extend_from_slice(&0x0000_0300u32.to_le_bytes()); // RUN_MODE | FLYING (Catmull-Rom) + body.push(0); + body.extend_from_slice(&0x0000_0300u32.to_le_bytes()); body.extend_from_slice(&duration_ms.to_le_bytes()); body.extend_from_slice(&(points.len() as u32).to_le_bytes()); for point in points { @@ -358,13 +305,6 @@ pub fn build_taxi_move_raw( Some((0x00DD, body)) } -/// Build `SMSG_MONSTER_MOVE` as a FACING-ONLY packet: `mover_guid` does not move, `pos` is -/// both the spline point and its sole (degenerate) destination, `duration` 0 — but its heading -/// changes to `angle_rad`. This is the wire tool the issue calls out as "already exists, unwired": -/// gtker 0.3 models 1.12's `FacingAngle` `MonsterMoveType` (`spline_point`/`splines` framing -/// unchanged from [`build_monster_move`], only `move_type` differs), but nothing built it until a -/// stationary stand-and-swing creature had no OTHER way to tell the client its facing changed — a -/// `Normal`/`Stop` leg with `dest == start` carries a destination point, never a heading. pub fn build_monster_move_facing( mover_guid: u64, pos: Vector3d, @@ -402,7 +342,6 @@ mod ground_path_tests { ) .unwrap(); assert_eq!(opcode, 0xdd); - // Packed GUID, start, id, normal, RUN_MODE, duration, count, destination, packed point. assert_eq!( body, vec![ diff --git a/gateway/src/codec/movement/subscriptions.rs b/gateway/src/codec/movement/subscriptions.rs index 9e228b90b..7e169f8e9 100644 --- a/gateway/src/codec/movement/subscriptions.rs +++ b/gateway/src/codec/movement/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::subscriptions::{MOTION_CALLS, MOTION_SENT}; @@ -15,17 +13,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::Vector3d; -/// Build the wire relay for one `game_teleport_event` row. Pure: takes the -/// already-derived `still_here` boolean (whether the live entity survived the transaction — the -/// module/gateway-mirrored same-map/cross-map signal, see the `on_teleport` callback's doc comment) -/// instead of a live `ReducerContext`/connection, so it's testable without a DB (module crate -/// convention: extract pure functions and test those). -/// -/// `still_here` → same-map: `MSG_MOVE_TELEPORT_ACK` (byte-identical to the previous behavior — the ONLY -/// caller-visible difference from before is that this is now reached via a branch rather than -/// unconditionally). NOT `still_here` → cross-map: `SMSG_TRANSFER_PENDING`+`SMSG_NEW_WORLD` as one batch -/// (`codec::build_cross_map_teleport`). `Err` only for a `map_id` the client's `Map` enum doesn't know -/// (propagated so the caller can log it — never silently drops the relay). pub(crate) fn build_teleport_relay( still_here: bool, mover_guid: u64, @@ -46,7 +33,6 @@ pub(crate) fn build_teleport_relay( } } -/// Build the complete teleport result on the addressed session's writer queue. pub(crate) fn teleport_event_outbound(row: &TeleportEvent) -> Vec { match build_teleport_relay( !row.cross_map, @@ -103,9 +89,6 @@ pub(crate) fn creature_leg_outbound( y: row.sy, z: row.sz, }; - // a facing-only leg (the mover doesn't move, `emit_facing_spline`'s carrier) gets the - // `FacingAngle` variant — the only 1.12 `SMSG_MONSTER_MOVE` shape that turns a STATIONARY - // creature without a spline the client would otherwise have nothing to interpolate from. if row.facing { return vec![Outbound::One(ServerOpcodeMessage::SMSG_MONSTER_MOVE( Box::new(codec::build_monster_move_facing( @@ -231,17 +214,11 @@ pub(crate) fn motion( delivery.received(); let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); let row = Arc::new(row.clone()); - // The MOVER is deliberately not a recipient (`world_entity_recipients`' self leg is skipped - // here): a - // player must never receive their own movement echoed back, or they fight the server for - // authority over their position. `relay_entity_motion` re-checks it anyway. + // Never echo a Character's movement to its own client; it fights position authority. for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { if viewer.self_guid == row.guid { continue; } - // SUPERSEDE, don't append — the viewer needs only this mover's latest position, so - // the upsert replaces any not-yet-flushed heartbeat from the same mover and the queue - // carries at most one flush job regardless of crowd size. viewer .motion_pending .entity @@ -256,7 +233,6 @@ pub(crate) fn creature_leg(view: &WorldView, shard: ShardId, row: &CreatureSplin let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); let row = Arc::new(row.clone()); for viewer in view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[]) { - // Same supersession as player motion — latest leg per creature wins. viewer .motion_pending .creature @@ -267,14 +243,10 @@ pub(crate) fn creature_leg(view: &WorldView, shard: ShardId, row: &CreatureSplin } } -/// Queue this viewer's ONE motion-flush job, unless one is already queued. The egress shed -/// decision keeps its pump-side, one-atomic-load shape (task B2's contract): a shed drops the -/// FLUSH — the pending map keeps superseding meanwhile, the flag resets so a later heartbeat -/// retries, and nothing is stranded. pub(crate) fn maybe_queue_motion_flush(_view: &WorldView, viewer: &Arc) { use std::sync::atomic::Ordering as AtOrd; if viewer.motion_pending.flush_queued.swap(true, AtOrd::AcqRel) { - return; // a queued flush will carry the upsert that just landed + return; } if crate::stdb::subscriptions::shed_motion_at_depth(viewer.tx.depth()) { crate::stdb::subscriptions::MOTION_DROPPED.fetch_add(1, Ordering::Relaxed); @@ -286,8 +258,7 @@ pub(crate) fn maybe_queue_motion_flush(_view: &WorldView, viewer: &Arc) } let viewer = viewer.clone(); enqueue(viewer.clone(), move |viewer| { - // Clear BEFORE draining: an upsert racing the drain re-queues a follow-up flush (possibly - // empty — harmless) instead of being stranded behind a still-set flag. + // Clear before draining so a racing upsert can queue another flush. viewer .motion_pending .flush_queued @@ -317,8 +288,6 @@ pub(crate) fn maybe_queue_motion_flush(_view: &WorldView, viewer: &Arc) }); } -/// A teleport row names one character owner on this shard. Packet construction stays on that -/// viewer's writer. pub(crate) fn teleport_appeared(view: &WorldView, shard: ShardId, row: &TeleportEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.mover_guid)) else { return; diff --git a/gateway/src/codec/movement/subscriptions/packet_tests.rs b/gateway/src/codec/movement/subscriptions/packet_tests.rs index 952c6542d..8c49360a6 100644 --- a/gateway/src/codec/movement/subscriptions/packet_tests.rs +++ b/gateway/src/codec/movement/subscriptions/packet_tests.rs @@ -7,8 +7,6 @@ use std::sync::{Arc, Mutex}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::Vector3d; -// ---- Cross-map teleport relay -------------------------------------------- - #[test] fn teleport_relay_same_map_is_byte_identical_to_the_pre_224_ack() { let out = build_teleport_relay(true, 7, 0, 1.0, 2.0, 3.0, 0.5).unwrap(); @@ -66,8 +64,6 @@ fn teleport_relay_cross_map_sends_transfer_pending_then_new_world() { #[test] fn teleport_relay_rejects_a_map_id_the_client_enum_does_not_know() { - // `Map` is a closed vanilla enum; an id past its known range must surface as an Err (never - // silently drop the whole relay or panic). assert!(build_teleport_relay(false, 7, u32::MAX, 0.0, 0.0, 0.0, 0.0).is_err()); } @@ -261,9 +257,6 @@ fn resident_facing_replay_follows_create_even_after_its_timestamp() { ); } -/// a `facing`-flagged `game_creature_spline` row relays as `SMSG_MONSTER_MOVE`'s -/// `FacingAngle` variant, not the `Normal` one every other leg uses — the whole point being that -/// a stationary creature otherwise has no wire-level way to tell the client its heading changed. #[test] fn a_facing_leg_relays_as_the_facing_angle_variant() { let created = Arc::new(Mutex::new(HashSet::from([42u64]))); @@ -300,7 +293,6 @@ fn a_facing_leg_relays_as_the_facing_angle_variant() { ); assert_eq!(msg.duration, 0); - // A non-facing leg keeps the Normal variant unchanged. let mut normal_row = row.clone(); normal_row.facing = false; normal_row.dur_ms = 500; diff --git a/gateway/src/codec/movement_info.rs b/gateway/src/codec/movement_info.rs index 45156a158..714f70716 100644 --- a/gateway/src/codec/movement_info.rs +++ b/gateway/src/codec/movement_info.rs @@ -1,4 +1,4 @@ -//! Build-5875 MovementInfo bodies. The typed codec uses the later transport flag and layout. +//! Build 5875 uses a different transport flag and layout from the typed codec. use anyhow::{ensure, Result}; use lyracore_shared::env::{MOVEMENT_FLAG_ON_TRANSPORT, MOVEMENT_FLAG_SWIMMING}; @@ -44,7 +44,7 @@ pub fn bytes_to_movement_info(mut body: &[u8]) -> Result { guid: Guid::new(u64::from_le_bytes(guid)), position: position(&mut body)?, orientation: float(&mut body)?, - timestamp: 0, // The library requires this field; vanilla has no transport time. + timestamp: 0, }, }) } else { @@ -57,7 +57,6 @@ pub fn bytes_to_movement_info(mut body: &[u8]) -> Result { } else { None }; - // Keep the u32 millisecond bits in the library's f32 carrier. let fall_time = f32::from_bits(u32::from_le_bytes(word(&mut body)?)); let jumping = if flags & JUMPING != 0 { Some(MovementInfo_MovementFlags_Jumping { @@ -108,7 +107,6 @@ pub fn movement_info_to_bytes(info: &MovementInfo) -> Result> { ); } if let Some(tail) = flags.get_on_transport() { - // Replace the library's packed guid and timestamp with vanilla's full guid and pose. let guid = tail.transport.guid.guid(); let packed_len = 1 + guid.to_le_bytes().iter().filter(|&&byte| byte != 0).count(); let mut transport = Vec::with_capacity(24); @@ -126,8 +124,6 @@ pub fn movement_info_to_bytes(info: &MovementInfo) -> Result> { Ok(body) } -/// Replace the initial, tail-free living pose with the Module's current vanilla movement body. -/// CREATE descriptors and speeds still come from the typed builder. pub fn create_with_movement( create: &wow_world_messages::vanilla::SMSG_UPDATE_OBJECT, movement: &[u8], @@ -155,7 +151,6 @@ pub fn create_with_movement( Ok(body) } -/// Decode relayed movement before the general typed packet reader sees its transport tail. pub fn read_movement_client( opcode: u32, body: &[u8], diff --git a/gateway/src/codec/npc.rs b/gateway/src/codec/npc.rs index 6fcdd14c7..f7369ab5d 100644 --- a/gateway/src/codec/npc.rs +++ b/gateway/src/codec/npc.rs @@ -1,10 +1,5 @@ -//! NPC wire mapping: the `CMSG_CREATURE_QUERY` reply and the minimal generic gossip window -//! (gossip message + NPC-text round-trip). Pure code-motion out of `mod.rs`. - use super::*; -/// A creature template as the gateway reads it from `game_creature_template`, flattened for the -/// `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). Decoupled from the module's table type. #[derive(Clone, Debug, Default)] pub struct CreatureView { pub entry: u32, @@ -17,8 +12,6 @@ pub struct CreatureView { pub rank: u32, } -/// Build `SMSG_CREATURE_QUERY_RESPONSE` so the client renders the creature's name/subname instead -/// of "Unknown" (the creature analogue of the player name query). pub fn build_creature_query_response(c: &CreatureView) -> SMSG_CREATURE_QUERY_RESPONSE { SMSG_CREATURE_QUERY_RESPONSE { creature_entry: c.entry, @@ -41,53 +34,31 @@ pub fn build_creature_query_response(c: &CreatureView) -> SMSG_CREATURE_QUERY_RE } } -// =========================================================================================== -// Gossip (rank 12): the gateway answers CMSG_GOSSIP_HELLO with a greeting -// window (title resolved via the CMSG_NPC_TEXT_QUERY round-trip) + the NPC's IMPORTED menu options -// (`game_gossip_option`) MERGED with the flag-derived vendor/innkeeper synthesis, so an NPC whose -// dump menu omits the browse-goods or make-home row still reaches its stock and its bind. -// =========================================================================================== - -/// One imported gossip menu option (`game_gossip_option`), as the gateway reads it — -/// already sorted by `option_index` (the render/select order). Carries the RAW condition so the -/// dispatcher can filter with [`option_condition_holds`] (the codec stays pure; the store call for -/// quest status lives in `gateway::world`'s dispatch). #[derive(Clone, Debug, Default, PartialEq)] pub struct GossipOptionView { - pub row_id: u32, // game_gossip_option PK — the STABLE option identity + pub row_id: u32, pub icon: u32, pub text: String, - pub action: u32, // see `lyracore_shared::constants::gossip_option` - pub action_menu_id: u32, // submenu target — stored, never navigated - pub cond_type: u32, // see `lyracore_shared::constants::gossip_condition` + pub action: u32, + pub action_menu_id: u32, + pub cond_type: u32, pub cond_value1: u32, pub cond_value2: u32, } -/// Evaluate one option's visibility condition against the player's quest state — PURE (no store -/// access), so the dispatcher can call it identically at `CMSG_GOSSIP_HELLO` (render) and -/// `CMSG_GOSSIP_SELECT_OPTION` (re-derive the click) without risking the two disagreeing. -/// `quest_taken`/`quest_rewarded` are `stdb::quest_status(guid, cond_value1)` — a quest never seen by -/// the player is `(false, false)`. An unrecognized `cond_type` fails OPEN (shown), matching the -/// importer's own fail-open convention for a condition it can't classify. pub fn option_condition_holds(cond_type: u32, quest_taken: bool, quest_rewarded: bool) -> bool { use lyracore_shared::constants::gossip_condition; match cond_type { gossip_condition::NONE => true, gossip_condition::QUEST_TAKEN => quest_taken, gossip_condition::QUEST_REWARDED => quest_rewarded, - gossip_condition::NEVER => false, // fail-closed placeholder (seasonal events don't exist here) - _ => true, // unrecognized → fail open, never silently hide an option + gossip_condition::NEVER => false, + _ => true, } } -/// The `row_id` of a gossip option the gateway synthesized rather than imported. No -/// `game_gossip_option` row backs it, so a package keying on `row_id` can never match one. pub const SYNTHESIZED_ROW_ID: u32 = u32::MAX; -/// The menu the NPC shows: the condition-filtered imported options, then the flag-derived lines the -/// dump left out — a vendor whose imported menu has no `action=VENDOR` row has unreachable stock -/// otherwise. The trailing "Farewell." is appended later, by `build_gossip_message`. pub fn gossip_menu_options( imported: Vec, is_vendor: bool, @@ -108,7 +79,6 @@ pub fn gossip_menu_options( }); }; if is_vendor { - // GOSSIP_ICON_VENDOR (the bag icon); everything else uses GOSSIP_ICON_CHAT (0). synthesize(gossip_option::VENDOR, 1, "I'd like to browse your goods."); } if is_innkeeper { @@ -117,41 +87,17 @@ pub fn gossip_menu_options( opts } -/// The generic title text id sent in `SMSG_GOSSIP_MESSAGE`; the client round-trips it via -/// `CMSG_NPC_TEXT_QUERY`, which `build_npc_text_update` answers with the generic greeting. -/// -/// Kept a LOW, in-range npc_text id. The first `u32` after the guid in `SMSG_GOSSIP_MESSAGE` is an -/// npc_text (gossip-menu text) id, not a free-form title field. SUSPECTED CAUSE (not yet client- -/// confirmed) of the McBride gossip-window bug: a synthetic high id (the old `0xFFFF_FF01`, top byte -/// `0xFF`) appears to be rejected as out-of-range by the 1.12.1 (5875) client, which then drops the -/// gossip packet (no `GOSSIP_SHOW`, window never opens). The headless wire-probe shares the gtker codec -/// and does NO DBC/text validation, so it decoded the packet fine — it can't reproduce the real -/// client's reject, so this stays UNVERIFIED until the render is observed (right-click McBride → -/// GOSSIP_SHOW). `build_npc_text_update` answers ANY id generically, so a small id (`1`) the client -/// accepts + queries is the candidate fix; the wire BYTES are unchanged, only this value. If the window -/// still won't open, the next suspect is the questgiver/gossip menu-path race, not this id. pub const GOSSIP_GREETING_TEXT_ID: u32 = 1; -/// The generic greeting shown in the gossip window (until per-NPC text is imported). const GOSSIP_GREETING: &str = "Greetings, traveler. I have nothing for you at this time."; -/// Build the `SMSG_GOSSIP_MESSAGE` reply to `CMSG_GOSSIP_HELLO`: echo the NPC `guid`, the per-NPC -/// title text id (from `game_gossip_menu` → fallback `GOSSIP_GREETING_TEXT_ID = 1`; the client -/// resolves it via `CMSG_NPC_TEXT_QUERY` → `build_npc_text_update`), and the `quests` section. A -/// gossip-FLAGGED questgiver (e.g. Marshal McBride) delivers its quests HERE. `quests` is empty for a -/// plain gossip NPC → byte-identical to before for those. -/// -/// `options` is the final menu from [`gossip_menu_options`] — condition-filtered imports plus the -/// synthesized vendor/innkeeper lines, in click order. A trailing "Farewell." is appended so every -/// menu has a close option, and the dispatcher's snapshot indexes THIS list. pub fn build_gossip_message( npc_guid: u64, title_text_id: u32, quests: Vec, options: &[GossipOptionView], ) -> SMSG_GOSSIP_MESSAGE { - // The `gossip_list_id` the client echoes in CMSG_GOSSIP_SELECT_OPTION is the option's POSITION in - // this list; the dispatcher resolves it against the snapshot it took here, never a re-derivation. + // The client echoes the option's list position; resolve it against this snapshot. let mut gossips: Vec = options .iter() .enumerate() @@ -176,21 +122,11 @@ pub fn build_gossip_message( } } -/// The full 8-slot weighted `npc_text` row, as the gateway reads it: each slot is -/// `(male, female, probability)`. `SMSG_NPC_TEXT_UPDATE` ships all 8 to the client, which does its OWN -/// weighted random pick — there is no server-side RNG here (see `build_npc_text_update`). An unused -/// slot is `("", "", 0.0)`. `stdb::npc_text_for_id` normalizes the pre-import "legacy single-slot" case -/// (a row imported before the multi-slot table existed) so slot 0's probability reads `1.0` rather -/// than the raw `0.0` default — see that method's doc comment. #[derive(Clone, Debug, Default, PartialEq)] pub struct NpcTextView { pub slots: [(String, String, f32); 8], } -/// Build the `SMSG_NPC_TEXT_UPDATE` reply to `CMSG_NPC_TEXT_QUERY`. `Some(view)` (an imported row — -/// `stdb::npc_text_for_id`) ships its 8 real weighted slots verbatim; `None` (no row for this -/// `text_id`) falls back to the generic greeting string in slot 0 at probability 1.0, every other slot -/// silent — byte-identical to the pre-import behavior. pub fn build_npc_text_update(text_id: u32, view: Option<&NpcTextView>) -> SMSG_NPC_TEXT_UPDATE { let texts: [NpcTextUpdate; 8] = match view { Some(v) => core::array::from_fn(|i| { @@ -214,19 +150,8 @@ pub fn build_npc_text_update(text_id: u32, view: Option<&NpcTextView>) -> SMSG_N SMSG_NPC_TEXT_UPDATE { text_id, texts } } -// =========================================================================================== -// Vendor (Tier 2) — the vendor inventory window. `SMSG_LIST_INVENTORY` is built RAW because the -// vanilla (5875) layout is NOT in the gtker crate: the typed `SMSG_LIST_INVENTORY` is tbc/wrath -// and carries an extra `ExtendedCost` u32 per item the 1.12 client doesn't read. The BUY/SELL -// client opcodes ARE in the vanilla crate (CMSG_BUY_ITEM / CMSG_SELL_ITEM), handled in dispatch. -// =========================================================================================== - -/// `SMSG_LIST_INVENTORY` opcode (vanilla 5875). RAW-encoded — see [`build_list_inventory_raw`]. pub const SMSG_LIST_INVENTORY_OPCODE: u16 = 0x019F; -/// One vendor stock line as the gateway reads it (the `game_npc_vendor` row joined with its -/// `game_item_template`), flattened for the RAW `SMSG_LIST_INVENTORY` body. `max_count` is the -/// per-slot stock limit (0 → unlimited, written as 0xFFFF_FFFF on the wire). #[derive(Clone, Debug, Default, PartialEq)] pub struct VendorItemView { pub item_entry: u32, @@ -234,48 +159,34 @@ pub struct VendorItemView { pub buy_price: u32, pub max_durability: u32, pub max_count: u32, - /// The stack sold per purchase (cmangos BuyCount): water/food ×5, ammo ×200. ≥1. pub buy_count: u32, } -/// Build a RAW `SMSG_LIST_INVENTORY` (vanilla 5875). RAW because gtker's typed message is the -/// tbc/wrath shape (an extra `ExtendedCost` u32 per item) the 1.12 client doesn't parse. The -/// vanilla body is (all little-endian): -/// - `vendor_guid: u64` (full, not packed) -/// - `count: u8` (= `items.len()`) -/// - per item, in `slot` order: `muid: u32` (1-based index), `item_entry: u32`, -/// `display_id: u32`, `max_count: u32` (0 stored → 0xFFFF_FFFF unlimited), `buy_price: u32`, -/// `max_durability: u32`, `buy_count: u32` (the template's per-purchase stack). -/// -/// An empty stock is just the guid + a zero count. Returns `(opcode, body)` for -/// [`Outbound::Raw`](crate::world::Outbound::Raw). +/// gtker's vendor records include ExtendedCost, a u32 absent from build 5875. pub fn build_list_inventory_raw(vendor_guid: u64, items: &[VendorItemView]) -> (u16, Vec) { - // The wire count is a u8; cap the slice so the count and the item bytes can never disagree (a vanilla - // vendor is well under 255 — this is a guard against a silent `as u8` wrap, not a real limit). let items = &items[..items.len().min(255)]; let mut body = Vec::with_capacity(9 + items.len() * 28); - body.extend_from_slice(&vendor_guid.to_le_bytes()); // vendor guid: u64 (full, not packed) - body.push(items.len() as u8); // count: u8 + body.extend_from_slice(&vendor_guid.to_le_bytes()); + body.push(items.len() as u8); if items.is_empty() { - // count == 0 is followed by an error byte the client reads to pick its message; without it the - // 5875 client parses past the end of the body and shows nothing at all. 0 = "no items". + // An empty vendor list still needs an error byte; zero means no items. body.push(0); return (SMSG_LIST_INVENTORY_OPCODE, body); } for (i, it) in items.iter().enumerate() { - body.extend_from_slice(&((i as u32) + 1).to_le_bytes()); // muid: 1-based slot index - body.extend_from_slice(&it.item_entry.to_le_bytes()); // item entry: u32 - body.extend_from_slice(&it.display_id.to_le_bytes()); // ItemDisplayInfo id: u32 - // max_count: 0 (no stored limit) → 0xFFFF_FFFF = unlimited, matching mangos/cmangos. + body.extend_from_slice(&((i as u32) + 1).to_le_bytes()); + body.extend_from_slice(&it.item_entry.to_le_bytes()); + body.extend_from_slice(&it.display_id.to_le_bytes()); + // The wire uses 0xFFFF_FFFF for unlimited stock. let max_count = if it.max_count == 0 { 0xFFFF_FFFF } else { it.max_count }; - body.extend_from_slice(&max_count.to_le_bytes()); // max stock: u32 - body.extend_from_slice(&it.buy_price.to_le_bytes()); // buy price (copper): u32 - body.extend_from_slice(&it.max_durability.to_le_bytes()); // max durability: u32 - body.extend_from_slice(&it.buy_count.max(1).to_le_bytes()); // buy_count: u32 + body.extend_from_slice(&max_count.to_le_bytes()); + body.extend_from_slice(&it.buy_price.to_le_bytes()); + body.extend_from_slice(&it.max_durability.to_le_bytes()); + body.extend_from_slice(&it.buy_count.max(1).to_le_bytes()); } (SMSG_LIST_INVENTORY_OPCODE, body) } diff --git a/gateway/src/codec/pet.rs b/gateway/src/codec/pet.rs index 6b12bd027..1f690486a 100644 --- a/gateway/src/codec/pet.rs +++ b/gateway/src/codec/pet.rs @@ -1,5 +1,3 @@ -//! Hunter-pet identity packets and descriptor updates. - use super::*; use wow_world_messages::vanilla::SMSG_PET_NAME_QUERY_RESPONSE; @@ -27,8 +25,7 @@ pub struct PetNameView { pub name_timestamp: u32, } -/// Publish only Hunter-specific fields after the live creature exists. Loyalty occupies byte 1 of -/// UNIT_FIELD_BYTES_1; happiness is the otherwise hidden POWER5 pair in build 5875. +/// Loyalty uses UNIT_FIELD_BYTES_1 byte 1; happiness uses the hidden POWER5 pair. pub fn build_hunter_pet_values(pet: &HunterPetProtocolView) -> SMSG_UPDATE_OBJECT { values::unit_values(pet.live_pet_guid, |unit| { unit.set_unit_level(pet.level as i32); @@ -95,11 +92,8 @@ mod tests { let mut bytes = Vec::new(); msg.write_unencrypted_server(&mut bytes).unwrap(); let mut tail = vec![ - 5, // five update-mask words through UNIT_FIELD_PETNEXTLEVELEXP - 0x00, 0x00, 0x00, 0x08, // POWER5, descriptor 27 - 0x06, 0x00, 0x00, 0x00, // MAXPOWER5 + LEVEL, descriptors 33–34 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x00, - 0x00, // BYTES_1 through PETNEXTLEVELEXP, 138–142 + 5, 0x00, 0x00, 0x00, 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x7c, 0x00, 0x00, ]; for value in [ 166_500u32, diff --git a/gateway/src/codec/pet/subscriptions.rs b/gateway/src/codec/pet/subscriptions.rs index cb5f97c12..bd281fc0c 100644 --- a/gateway/src/codec/pet/subscriptions.rs +++ b/gateway/src/codec/pet/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::views::hunter_pet_protocol_view; @@ -8,7 +6,6 @@ use crate::stdb::world_view::*; use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Build the owner's Hunter pet descriptor VALUES for one owner-addressed row. pub(crate) fn hunter_pet_outbound(row: &HunterPetProtocol) -> Vec { vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_hunter_pet_values(&hunter_pet_protocol_view( @@ -17,7 +14,6 @@ pub(crate) fn hunter_pet_outbound(row: &HunterPetProtocol) -> Vec { ))] } -/// Hunter pet descriptors are idempotent VALUES, so replay re-sends are state-sync, not toasts. pub(crate) fn hunter_pet_appeared(view: &WorldView, shard: ShardId, row: &HunterPetProtocol) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { return; diff --git a/gateway/src/codec/property_tests.rs b/gateway/src/codec/property_tests.rs index cc65743f0..02b18134e 100644 --- a/gateway/src/codec/property_tests.rs +++ b/gateway/src/codec/property_tests.rs @@ -1,36 +1,10 @@ -//! Property tests for the hand-rolled decoders: the client-byte parsers and the -//! update-mask codec. -//! -//! WHY NOT `proptest`. These are property tests in the sense that matters (a generated input space -//! rather than a hand-picked vector), but they are driven by the fixed-seed generator below instead -//! of a proptest/quickcheck dependency. Three reasons, in order: -//! -//! * DETERMINISM IS THE POINT. A shrinking fuzzer finds a new counterexample on some later run, -//! which is a flaky test by construction — exactly what this workspace's test policy forbids. -//! Every run here explores the same inputs and either always passes or always fails, and a -//! failure names the seed that produced it. -//! * The workspace has no `[dev-dependencies]` beyond the two `schema_parity.rs` needs, and no -//! proptest anywhere in `Cargo.lock`. Adding one to assert "does not panic" would buy shrinking -//! and cost a dependency on the release-critical crate. -//! * The properties here are total ("never panics", "round-trips", "decodes no more than was -//! encoded"), so shrinking has little to offer: any failing input is already 8-200 bytes. -//! -//! WHAT IS ACTUALLY BEING ASSERTED. Every parser in this file is reachable from bytes the server -//! does not control, and each returns `Option`/`Result`/an empty `Vec` rather than panicking. That -//! is a deliberate design property (`main.rs`'s `boundary_panic_tripwire` pins its lexical half — -//! that no `.unwrap()` is WRITTEN there). This file pins the behavioural half the scan cannot see: -//! a panic reached through indexing, slicing or arithmetic, which no source scan detects. - use super::*; -/// A fixed-seed xorshift64* — the whole generator, so a "property" run is reproducible byte for -/// byte. Not cryptographic and not meant to be; it only has to spread bits well enough to reach the -/// branch structure of a byte parser. pub(crate) struct Rng(u64); impl Rng { pub(crate) fn new(seed: u64) -> Self { - // Any non-zero state; xorshift64* is degenerate at 0. + // xorshift64* degenerates at zero. Self(seed | 1) } @@ -47,33 +21,17 @@ impl Rng { (self.next_u64() >> 32) as u32 } - /// Uniform-enough in `0..n`. `n == 0` is a caller bug and would divide by zero, so it saturates - /// to 1 rather than panicking inside a generator. pub(crate) fn below(&mut self, n: usize) -> usize { (self.next_u64() % (n.max(1)) as u64) as usize } - /// `len` pseudo-random bytes. pub(crate) fn bytes(&mut self, len: usize) -> Vec { (0..len).map(|_| (self.next_u64() >> 24) as u8).collect() } } -/// How many generated inputs each property runs. Small enough that the whole file is well under a -/// second (these are all pure functions over ≤256-byte buffers), large enough to cover the branch -/// structure many times over. const CASES: usize = 2_000; -// =========================================================================================== -// The addon chat decoder — the only hand-rolled parser of CLIENT bytes in `codec/` -// =========================================================================================== - -/// `parse_addon_client_chat` walks a client-supplied buffer looking for NUL terminators and skips a -/// leading CString for whisper/channel frames. All of that is slice math driven by remote input. -/// -/// The property: for ANY byte string, it returns `Option` — it never panics, and it never reads -/// past the buffer. Random bytes reach the `Some` arm rarely, so the generator deliberately biases -/// toward well-shaped headers (see `addon_shaped`) as well as pure noise. #[test] fn the_addon_chat_decoder_never_panics_on_any_byte_string() { let mut rng = Rng::new(0x2023_0223); @@ -84,18 +42,12 @@ fn the_addon_chat_decoder_never_panics_on_any_byte_string() { } else { addon_shaped(&mut rng, len) }; - // The assertion is that this returns at all. A returned `Some` must additionally be a - // string the caller can use, which `String` already guarantees. let _ = addon::parse_addon_client_chat(&body); } } -/// A body with a plausible addon header (chat type + `LANG_ADDON`) and a random tail, so the -/// generator reaches the NUL-scanning and CString-skipping branches instead of bailing at the -/// language check on nearly every case. fn addon_shaped(rng: &mut Rng, tail_len: usize) -> Vec { let mut body = Vec::with_capacity(9 + tail_len); - // Chat types 6 (WHISPER) and 14 (CHANNEL) take the extra CString-skip branch. let chat_type = [0u32, 1, 6, 14][rng.below(4)]; body.extend_from_slice(&chat_type.to_le_bytes()); body.extend_from_slice(&addon::LANG_ADDON.to_le_bytes()); @@ -103,17 +55,14 @@ fn addon_shaped(rng: &mut Rng, tail_len: usize) -> Vec { body } -/// Truncation is the shape a severed or mis-sized frame actually takes, and it is the one this -/// parser's slice math is most exposed to: every prefix of a VALID frame is tried. #[test] fn every_truncation_of_a_well_formed_addon_frame_decodes_or_declines_but_never_panics() { let mut full = Vec::new(); - full.extend_from_slice(&6u32.to_le_bytes()); // CHAT_TYPE_WHISPER — takes the CString skip + full.extend_from_slice(&6u32.to_le_bytes()); full.extend_from_slice(&addon::LANG_ADDON.to_le_bytes()); full.extend_from_slice(b"TargetName\0"); full.extend_from_slice(b"STC|1|1|PING|payload\0"); - // Sanity floor: the untruncated frame must actually parse, or this sweep is vacuous. assert_eq!( addon::parse_addon_client_chat(&full), Some((6, "STC|1|1|PING|payload".to_string())), @@ -126,9 +75,6 @@ fn every_truncation_of_a_well_formed_addon_frame_decodes_or_declines_but_never_p } } -/// The `STC` envelope splitter runs on an already-decoded String, i.e. still on remote content. It -/// splits on `|` and indexes the resulting fields; a short envelope must decline, not index out of -/// bounds. #[test] fn the_bridge_envelope_splitter_never_panics_and_only_accepts_the_stc_shape() { let mut rng = Rng::new(0x5743_0050); @@ -139,9 +85,6 @@ fn the_bridge_envelope_splitter_never_panics_and_only_accepts_the_stc_shape() { .map(|_| alphabet[rng.below(alphabet.len())] as char) .collect(); if let Some((cmd, _payload)) = addon::parse_bridge_envelope(&text) { - // The one content invariant: an accepted envelope really was an `STC` one. If this ever - // fires, the bridge would be forwarding another server's addon traffic into our - // `client_command` reducer. assert!( text.starts_with(addon::BRIDGE_PREFIX), "accepted a non-{} envelope {text:?} (cmd {cmd:?})", @@ -151,11 +94,6 @@ fn the_bridge_envelope_splitter_never_panics_and_only_accepts_the_stc_shape() { } } -// =========================================================================================== -// The movement-info carrier -// =========================================================================================== - -/// Movement bodies come from clients and must fail cleanly on arbitrary bytes. #[test] fn the_movement_carrier_decoder_never_panics_on_any_byte_string() { let mut rng = Rng::new(0x4D4F_5645); @@ -165,7 +103,6 @@ fn the_movement_carrier_decoder_never_panics_on_any_byte_string() { } } -/// A movement body has a bounded set of optional fields. Excess bytes must not be ignored. #[test] fn oversized_movement_bodies_are_refused() { for len in [65_531usize, 65_532, 65_535, 70_000] { @@ -179,23 +116,6 @@ fn oversized_movement_bodies_are_refused() { } } -// =========================================================================================== -// The update-mask codec — encode/decode round trip -// =========================================================================================== - -/// The core property of the hand-rolled mask: whatever `UpdateMaskValues` encodes, -/// `lyracore_shared::values_mask::parse_values_updates` decodes back EXACTLY — same guid, same -/// `(field_index, word)` set, in ascending index order. -/// -/// This matters because the two halves are written independently and neither can be checked against -/// gtker: gtker's typed reader REJECTS the TYPE-less partial mask a correct 5875 server sends, which -/// is the whole reason both of these exist. The round trip is the only oracle available offline, and -/// it is a real one — an off-by-one in the block count, the bit order, or the ascending-value order -/// breaks it immediately. -/// -/// `OBJECT_FIELD_TYPE` (index 2) is excluded by the generator, because setting it on a partial -/// VALUES update is the 5875 null+0x110 client crash and `build_values_update_raw` `debug_assert!`s -/// against it. #[test] fn every_generated_update_mask_round_trips_through_the_raw_values_decoder() { let mut rng = Rng::new(0x5641_4C55); @@ -205,15 +125,13 @@ fn every_generated_update_mask_round_trips_through_the_raw_values_decoder() { let mut mask = update_mask::UpdateMaskValues::new(); let mut expected: std::collections::BTreeMap = Default::default(); for _ in 0..n_fields { - // 0..1200 spans the real descriptor range (PLAYER_FIELD_COINAGE = 1176) and keeps the - // block count inside the u8 the wire format allots it. let mut idx = rng.below(1200) as u16; if idx == update_mask::idx::OBJECT_TYPE { idx += 1; } let value = rng.next_u32(); mask.set_u32(idx, value); - expected.insert(idx, value); // last write wins, exactly like the map inside the mask + expected.insert(idx, value); } let (opcode, body) = values::build_values_update_raw(guid, &mask); @@ -240,10 +158,6 @@ fn every_generated_update_mask_round_trips_through_the_raw_values_decoder() { } } -/// A decoder for frames off a socket has to survive a frame that stops early. The property is -/// stronger than "does not panic": a truncated frame may decode FEWER fields than were encoded, but -/// it must never invent one — a phantom `(index, value)` pair would make a packet-lint assertion -/// fire on a field the server never sent. #[test] fn every_truncation_of_an_encoded_mask_decodes_a_subset_and_never_invents_a_field() { let mut rng = Rng::new(0x5452_554E); @@ -282,17 +196,9 @@ fn every_truncation_of_an_encoded_mask_decodes_a_subset_and_never_invents_a_fiel } } -// =========================================================================================== -// The outbound frame linter — it parses our OWN bytes, but with the same exposure -// =========================================================================================== - -/// `lint_raw` re-parses an already-built frame to catch the two crash-class mistakes. It runs on -/// every raw send in debug builds, so a panic in it would take down a session over a diagnostic. -/// The property: any `(opcode, body)` pair returns a `Vec` of violations, never an unwind. #[test] fn the_outbound_frame_linter_never_panics_on_any_body() { let mut rng = Rng::new(0x4C49_4E54); - // The two opcodes it actually inspects, plus one it must pass through untouched. let opcodes = [0x00A9u16, 0x0124, 0x0037]; for _ in 0..CASES { let opcode = opcodes[rng.below(opcodes.len())]; @@ -301,9 +207,6 @@ fn the_outbound_frame_linter_never_panics_on_any_body() { } } -/// The generator itself has to be trustworthy, or every "never panics" above could be passing on a -/// stream of identical bytes. Two properties: a fixed seed reproduces exactly (that is what makes -/// these tests deterministic), and different seeds diverge (that is what makes them a search). #[test] fn the_seeded_generator_is_reproducible_and_seed_dependent() { let a: Vec = Rng::new(7).bytes(64); @@ -313,7 +216,6 @@ fn the_seeded_generator_is_reproducible_and_seed_dependent() { let c: Vec = Rng::new(8).bytes(64); assert_ne!(a, c, "different seeds must explore different inputs"); - // Not a stuck value: 64 draws from a working generator are not all equal. assert!( a.windows(2).any(|w| w[0] != w[1]), "the generator emitted a constant stream: {a:02X?}" diff --git a/gateway/src/codec/quest.rs b/gateway/src/codec/quest.rs index 4a5821b4c..44caf6574 100644 --- a/gateway/src/codec/quest.rs +++ b/gateway/src/codec/quest.rs @@ -1,26 +1,9 @@ -//! Quest-giver dialog codec (gateway wire). Maps the gateway's quest row-views to the gtker-typed -//! `SMSG_QUESTGIVER_*` messages — the `!`/`?` overhead status, the right-click quest menu, the quest -//! details + accept screen, and the turn-in offer/complete screens. Unlike loot/vendor (raw-encoded -//! past gtker version gaps), every quest packet here is a vanilla-complete gtker typed message, so -//! these are thin view→message mappings except for raw DETAILS reward triples, which gtker omits. -//! -//! The gameplay-meaningful piece is the PURE status logic ([`quest_giver_status`] / [`eval_status`]): -//! given the giver's quests evaluated against the player's log, pick the vanilla overhead icon -//! (turn-in > available > incomplete > none). It's unit-tested here; the coordinator read just gathers -//! the [`GiverQuestEval`]s and the SMSG send is mechanical. -//! -//! NOT here: the quest-LOG window (`PLAYER_QUEST_LOG_*` descriptor fields) + `CMSG_QUEST_QUERY` text — -//! those ride the UpdateMask wall (the hand-rolled `update_mask` encoder lacks the quest-log indices) - use super::*; use wow_world_base::shared::quest_party_message_vanilla_tbc::QuestPartyMessage; -/// `CreatureQuest.role` mirror (module `quest::quest_role`): which end of the quest a giver is. pub const ROLE_START: u8 = 0; pub const ROLE_END: u8 = 1; -/// A reward item line for a quest, joined with its template `display_id` (needed by the offer-reward -/// screen, which carries the icon). `display_id` 0 if the template isn't loaded. #[derive(Clone, Debug, PartialEq)] pub struct QuestRewardView { pub item_entry: u32, @@ -28,16 +11,10 @@ pub struct QuestRewardView { pub display_id: u32, } -/// Everything the gateway needs to render a quest's details / offer-reward / completion dialogs. The -/// text bodies come from `game_quest_text` (cmangos Details/Objectives/OfferReward/RequestItems); -/// `objectives_text` falls back to a synthesized "Creature slain: 0/N" line when the imported objectives -/// text is empty. `reward_xp` is the RESOLVED award (explicit or computed via the shared formula) so the -/// completion popup matches the turn-in grant. #[derive(Clone, Debug, PartialEq)] pub struct QuestDetailView { pub quest_id: u32, pub quest_level: u32, - /// cmangos ZoneOrSort — the quest-log category header (>0 zone, <0 sort; 0 → "Missing header!"). pub zone_or_sort: i32, pub title: String, pub details: String, @@ -46,43 +23,25 @@ pub struct QuestDetailView { pub request_items_text: String, pub money_reward: u32, pub reward_xp: u32, - /// cmangos NextQuestId/NextQuestInChain: the successor quest auto-offered on - /// turn-in (0 = no successor). Threaded into `SMSG_QUEST_QUERY_RESPONSE`'s `next_quest_in_chain` - /// field (`build_quest_query_response_raw`) — previously a hardcoded 0 stub. pub next_quest_id: u32, - /// The level-cap (60) money conversion of this quest's XP reward - /// (`lyracore_shared::quest::max_level_money_reward`) — the SAME number a level-60 turn-in actually - /// pays. Threaded into `SMSG_QUEST_QUERY_RESPONSE`'s `max_level_money_reward` field (previously a - /// hardcoded 0 stub); the client only surfaces it in the log preview when the VIEWER is capped, so - /// it's always populated regardless of who's asking. pub max_level_money_reward: u32, pub rewards: Vec, - /// CHOICE reward items (pick-1-of-N), ordered by `choice_index` so the wire index == the index the - /// client sends back as `CMSG_QUESTGIVER_CHOOSE_REWARD.reward`. Empty for a guaranteed-only quest. pub choice_rewards: Vec, - /// Objectives in wire order for `SMSG_QUEST_QUERY_RESPONSE`'s objectives[4] array: each slot is - /// `(creature_or_go, count, req_item, req_item_count)`. Kill = `(entry, n, 0, 0)`, collect = - /// `(0, 0, item, n)`, GO-use = `((-(go_entry as i32)) as u32, n, 0, 0)`. + /// Gameobject objectives encode the negated entry id as a u32. pub objectives: Vec<(u32, u32, u32, u32)>, } -/// One giver↔quest relation evaluated against the player — the input to the status icon + the menu. -/// The coordinator computes the booleans (it has the player's log + the objectives); the codec only -/// maps them to vanilla statuses. #[derive(Clone, Debug, PartialEq)] pub struct GiverQuestEval { pub quest_id: u32, pub title: String, pub level: u32, - pub role: u8, // ROLE_START / ROLE_END - pub startable: bool, // role START && player qualifies (level) && not already in the log - pub active: bool, // role END && player has it && !rewarded - pub complete: bool, // active && every objective met + pub role: u8, + pub startable: bool, + pub active: bool, + pub complete: bool, } -/// The vanilla overhead status for ONE evaluated giver↔quest relation. An END relation the player is -/// on shows turn-in-ready (`Reward2`, yellow `?`) when complete else `Incomplete`; a startable START -/// relation shows `Available` (the `!`); anything else contributes nothing (`None`). Pure. pub fn eval_status(e: &GiverQuestEval) -> QuestGiverStatus { if e.role == ROLE_END && e.active { if e.complete { @@ -97,8 +56,6 @@ pub fn eval_status(e: &GiverQuestEval) -> QuestGiverStatus { } } -/// Priority rank for picking the single overhead icon when a giver has several quests (vanilla shows -/// the most actionable: turn-in > available > incomplete > none). fn status_rank(s: QuestGiverStatus) -> u8 { match s { QuestGiverStatus::Reward2 | QuestGiverStatus::RewardRep | QuestGiverStatus::RewardOld => 3, @@ -108,7 +65,6 @@ fn status_rank(s: QuestGiverStatus) -> u8 { } } -/// The single overhead status for a giver, folding every evaluated relation by priority. Pure/testable. pub fn quest_giver_status(evals: &[GiverQuestEval]) -> QuestGiverStatus { evals.iter().fold(QuestGiverStatus::None, |best, e| { let s = eval_status(e); @@ -120,7 +76,6 @@ pub fn quest_giver_status(evals: &[GiverQuestEval]) -> QuestGiverStatus { }) } -/// `SMSG_QUESTGIVER_STATUS` — the overhead `!`/`?` icon for `giver_guid`. pub fn build_questgiver_status( giver_guid: u64, status: QuestGiverStatus, @@ -131,10 +86,6 @@ pub fn build_questgiver_status( } } -/// `SMSG_QUESTUPDATE_ADD_KILL` — the "Creature slain: n/N" kill-progress toast. `creature_id` is the -/// objective's target creature entry (the client looks up its name); `guid` is sent as 0 — the quest-update -/// relay knows the entry + counts but not the specific corpse, and the client renders the toast from -/// `creature_id` regardless. pub fn build_questupdate_add_kill( quest_id: u32, creature_id: u32, @@ -150,10 +101,6 @@ pub fn build_questupdate_add_kill( } } -/// Pure kill-progress diff: given a quest's objectives and its old→new progress counts, return one -/// `SMSG_QUESTUPDATE_ADD_KILL` per KILL objective (kind 0) whose count INCREASED. The gateway's -/// `on_quest_update` relay is just this + the send — extracted so the diff is unit-testable without a live -/// client. `objectives` are `(kind, obj_index, target_entry, required_count)`. pub fn kill_progress_add_kills( quest_entry: u32, old_counts: &[u32], @@ -164,7 +111,7 @@ pub fn kill_progress_add_kills( .iter() .filter_map(|&(kind, obj_index, target_entry, required)| { if kind != 0 { - return None; // KILL_CREATURE only (collect/explore have no per-kill counter) + return None; } let i = obj_index as usize; let new_c = new_counts.get(i).copied().unwrap_or(0); @@ -180,34 +127,21 @@ mod add_kill_tests { #[test] fn emits_one_per_incremented_kill_objective() { - // obj0 kills creature 257 (×10); obj1 is a COLLECT (kind 1) that must be ignored. let objs = [(0u8, 0u8, 257u32, 10u32), (1, 1, 999, 5)]; - // obj0 went 2→3; obj1's count also changed but it's not a KILL objective. let out = kill_progress_add_kills(15, &[2, 4], &[3, 5], &objs); assert_eq!(out.len(), 1); assert_eq!(out[0].quest_id, 15); assert_eq!(out[0].creature_id, 257); assert_eq!(out[0].kill_count, 3); assert_eq!(out[0].required_kill_count, 10); - // No count change → no message. assert!(kill_progress_add_kills(15, &[3, 5], &[3, 5], &objs).is_empty()); } } -/// `SMSG_QUESTUPDATE_FAILEDTIMER` (0x0197) — a timed quest's log entry flips to FAILED -/// the instant `quest_timer_pass` fails it (`gateway/src/stdb/subscriptions.rs`'s `on_quest_update` -/// diffs `!old.failed && row.failed`). Pure builder: not boxed (unlike `SMSG_QUESTUPDATE_ADD_KILL`) — -/// gtker's enum wraps this variant directly. pub fn build_questupdate_failedtimer(quest_id: u32) -> SMSG_QUESTUPDATE_FAILEDTIMER { SMSG_QUESTUPDATE_FAILEDTIMER { quest_id } } -/// `MSG_QUEST_PUSH_RESULT` (0x0276) — the SENDER's per-member feedback line -/// after `push_quest_to_party` ("So-and-so already has that quest", "is too far away", etc). `code` is -/// the module's `lyracore_shared::quest::share_result` wire byte, which mirrors gtker's -/// `QuestPartyMessage::as_int()` 1:1 by construction — decode is a straight `try_into()`; an -/// out-of-range byte (never actually produced by the module) falls back to `SharingQuest` rather than -/// dropping the packet. pub fn build_quest_push_result(member_guid: u64, code: u8) -> MSG_QUEST_PUSH_RESULT { MSG_QUEST_PUSH_RESULT { guid: Guid::new(member_guid), @@ -215,11 +149,6 @@ pub fn build_quest_push_result(member_guid: u64, code: u8) -> MSG_QUEST_PUSH_RES } } -/// The actionable quests of a giver as gtker `QuestItem`s (one menu line each, tagged with the -/// per-quest icon `!`/`?`/grey). Quests contributing no action (`None`) are filtered out. Shared by -/// the pure-questgiver menu ([`build_quest_list`]) AND the gossip window's quest section -/// (`build_gossip_message`), since a gossip-flagged questgiver delivers its quests through -/// `SMSG_GOSSIP_MESSAGE` rather than `SMSG_QUESTGIVER_QUEST_LIST`. pub fn quest_menu_items(evals: &[GiverQuestEval]) -> Vec { evals .iter() @@ -235,10 +164,7 @@ pub fn quest_menu_items(evals: &[GiverQuestEval]) -> Vec { .collect() } -/// The MENU/quest-list icon for a giver↔quest line — distinct from [`eval_status`] (the overhead `!`/`?` -/// icon) in ONE case: a COMPLETE turn-in shows as `RewardRep` (4), the value the 1.12 client treats as -/// turn-in-able in a gossip/quest list; the overhead icon uses `Reward2` (7, the yellow minimap dot). -/// Reusing the overhead value here left the gossip line un-clickable for turn-in. +/// Completed quests use RewardRep, 4, in menus and Reward, 6, above the giver. fn menu_icon(e: &GiverQuestEval) -> QuestGiverStatus { if e.role == ROLE_END && e.active { if e.complete { @@ -253,8 +179,6 @@ fn menu_icon(e: &GiverQuestEval) -> QuestGiverStatus { } } -/// The guaranteed reward items of a quest as gtker `QuestItemReward`s (item + count). Used by the quest -/// details + completion popups (which don't show the item icon). fn reward_items(rewards: &[QuestRewardView]) -> Vec { rewards .iter() @@ -265,8 +189,6 @@ fn reward_items(rewards: &[QuestRewardView]) -> Vec { .collect() } -/// The reward items as gtker `QuestItemRequirement`s (item + count + display_id) — the offer-reward -/// screen variant, which DOES show the item icon (`item_display_id`). fn reward_items_with_icon(rewards: &[QuestRewardView]) -> Vec { rewards .iter() @@ -278,8 +200,6 @@ fn reward_items_with_icon(rewards: &[QuestRewardView]) -> Vec (u16, Vec) { const SMSG_QUESTGIVER_QUEST_DETAILS: u16 = 0x0188; const DETAILS_EMOTE_COUNT: u32 = 4; @@ -324,7 +240,7 @@ pub fn build_quest_details_raw(giver_guid: u64, d: &QuestDetailView) -> (u16, Ve body.extend_from_slice(text.as_bytes()); body.push(0); } - body.extend_from_slice(&1u32.to_le_bytes()); // ActivateAccept + body.extend_from_slice(&1u32.to_le_bytes()); for rewards in [&d.choice_rewards, &d.rewards] { body.extend_from_slice(&(rewards.len() as u32).to_le_bytes()); for reward in rewards.iter() { @@ -334,22 +250,12 @@ pub fn build_quest_details_raw(giver_guid: u64, d: &QuestDetailView) -> (u16, Ve } } body.extend_from_slice(&d.money_reward.to_le_bytes()); - body.extend_from_slice(&0u32.to_le_bytes()); // reward spell + body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&DETAILS_EMOTE_COUNT.to_le_bytes()); body.resize(body.len() + DETAILS_EMOTE_COUNT as usize * 8, 0); (SMSG_QUESTGIVER_QUEST_DETAILS, body) } -/// `SMSG_QUESTGIVER_OFFER_REWARD` — the turn-in reward screen (shown when the player clicks a complete -/// `?`). Reward items carry their display id here (the screen shows icons). -/// -/// `auto_finish` is the `Bool32` mangos-zero names `EnableNext` — the real 5875 server hardcodes it -/// `true` when it sends this packet (`SendQuestGiverOfferReward(pQuest, npcGUID, true)`, -/// `mz_gossip.cpp:922` → `:811`), NOT `false`. It enables the "Complete Quest" button + automatic -/// progression to the next quest in a chain (783→7→15→21→54, exactly McBride's quests). Sending `0` left -/// the turn-in button in a non-armed state for a gossip-questgiver chain; this was the only field on the -/// turn-in packets that diverged from the authoritative server (the two trailing `reward_spell`/ -/// `reward_spell_cast` u32s and every other field are byte-identical to mangos-zero). Match it: `true`. pub fn build_offer_reward(giver_guid: u64, d: &QuestDetailView) -> SMSG_QUESTGIVER_OFFER_REWARD { SMSG_QUESTGIVER_OFFER_REWARD { npc: Guid::new(giver_guid), @@ -366,8 +272,6 @@ pub fn build_offer_reward(giver_guid: u64, d: &QuestDetailView) -> SMSG_QUESTGIV } } -/// `SMSG_QUESTGIVER_REQUEST_ITEMS` — shown when the player opens a turn-in that ISN'T finished yet -/// (the "you don't have everything" screen). `completable` drives the Complete button's enabled state. pub fn build_request_items( giver_guid: u64, d: &QuestDetailView, @@ -383,7 +287,7 @@ pub fn build_request_items( auto_finish: false, required_money: Gold::new(0), required_items: Vec::new(), - unknown1: 0x02, // all emulators set this constant + unknown1: 0x02, completable: if completable { QuestCompletable::Completeable } else { @@ -394,44 +298,32 @@ pub fn build_request_items( } } -/// `SMSG_QUEST_QUERY_RESPONSE` opcode (0x005D) — RAW-encoded (the 3rd vanilla wire gap after the -/// update-mask wall + the loot-item codec). gtker's typed `SMSG_QUEST_QUERY_RESPONSE` writes the two -/// rep-objective `Faction` fields as u16, which is 4 bytes short before `title` → the 5875 client reads -/// the title 4 bytes late ("A Threat Within" → "reat Within"). We hand-roll the exact 5875 layout (every -/// numeric field a u32, the two position values f32, the rest CStrings) to fix it. +// gtker writes u16 reputation-faction fields; build 5875 reads u32. const SMSG_QUEST_QUERY_RESPONSE_OPCODE: u16 = 0x005D; -/// Push a NUL-terminated CString. fn push_cstr(body: &mut Vec, s: &str) { body.extend_from_slice(s.as_bytes()); body.push(0); } -/// Build the RAW `SMSG_QUEST_QUERY_RESPONSE`: the full quest definition the client needs to DISPLAY a -/// quest in its log. The client sends `CMSG_QUEST_QUERY` the moment it sees a quest id in a -/// `PLAYER_QUEST_LOG` slot it has no data for; until this reply lands it won't show/count the quest. The -/// load-bearing fields are zone_or_sort (the log category header), title, objective text, details, money, -/// reward items, and the kill objectives; the rest are 0 for our slice. See the field table in -/// docs (the u16→u32 Faction fix is the 4-byte shift that corrupted the title). pub fn build_quest_query_response_raw(d: &QuestDetailView) -> (u16, Vec) { let mut body = Vec::with_capacity(256); let u32le = |body: &mut Vec, v: u32| body.extend_from_slice(&v.to_le_bytes()); u32le(&mut body, d.quest_id); - u32le(&mut body, 2); // quest_method + u32le(&mut body, 2); u32le(&mut body, d.quest_level); - body.extend_from_slice(&d.zone_or_sort.to_le_bytes()); // i32 bits (header category) - u32le(&mut body, 0); // quest_type - u32le(&mut body, 0); // reputation_objective_faction (u32 in 5875 — gtker wrote u16) - u32le(&mut body, 0); // reputation_objective_value - u32le(&mut body, 0); // required_opposite_faction (u32 in 5875 — gtker wrote u16) - u32le(&mut body, 0); // required_opposite_reputation_value - u32le(&mut body, d.next_quest_id); // next_quest_in_chain (was a hardcoded 0 stub) + body.extend_from_slice(&d.zone_or_sort.to_le_bytes()); + u32le(&mut body, 0); + u32le(&mut body, 0); + u32le(&mut body, 0); + u32le(&mut body, 0); + u32le(&mut body, 0); + u32le(&mut body, d.next_quest_id); u32le(&mut body, d.money_reward); - u32le(&mut body, d.max_level_money_reward); // was a hardcoded 0 stub - u32le(&mut body, 0); // reward_spell - u32le(&mut body, 0); // source_item_id - u32le(&mut body, 0); // quest_flags - // rewards[4]: (item, count), padded with zeros. + u32le(&mut body, d.max_level_money_reward); + u32le(&mut body, 0); + u32le(&mut body, 0); + u32le(&mut body, 0); for i in 0..4 { let (item, count) = d .rewards @@ -441,7 +333,6 @@ pub fn build_quest_query_response_raw(d: &QuestDetailView) -> (u16, Vec) { u32le(&mut body, item); u32le(&mut body, count); } - // choice_rewards[6]: (item, count), padded with zeros — the pick-1-of-N choices for the quest log. for i in 0..6 { let (item, count) = d .choice_rewards @@ -451,16 +342,14 @@ pub fn build_quest_query_response_raw(d: &QuestDetailView) -> (u16, Vec) { u32le(&mut body, item); u32le(&mut body, count); } - u32le(&mut body, 0); // point_map_id - body.extend_from_slice(&0f32.to_le_bytes()); // point_x - body.extend_from_slice(&0f32.to_le_bytes()); // point_y - u32le(&mut body, 0); // point_opt + u32le(&mut body, 0); + body.extend_from_slice(&0f32.to_le_bytes()); + body.extend_from_slice(&0f32.to_le_bytes()); + u32le(&mut body, 0); push_cstr(&mut body, &d.title); push_cstr(&mut body, &d.objectives_text); push_cstr(&mut body, &d.details); - push_cstr(&mut body, ""); // end_text - // objectives[4]: (creature_or_go, count, req_item, req_item_count). Kill = positive creature entry; - // GO-use = negative go_entry cast to u32; collect = creature_or_go/count=0, req_item/count filled. + push_cstr(&mut body, ""); for i in 0..4 { let (creature_or_go, count, req_item, req_item_count) = d.objectives.get(i).copied().unwrap_or((0, 0, 0, 0)); @@ -470,17 +359,15 @@ pub fn build_quest_query_response_raw(d: &QuestDetailView) -> (u16, Vec) { u32le(&mut body, req_item_count); } for _ in 0..4 { - push_cstr(&mut body, ""); // objective_texts[4] + push_cstr(&mut body, ""); } (SMSG_QUEST_QUERY_RESPONSE_OPCODE, body) } -/// `SMSG_QUESTGIVER_QUEST_COMPLETE` — the "Quest Complete" popup after choosing the reward, showing -/// the XP + money + items actually granted (the XP matches the module's turn-in via the shared formula). pub fn build_quest_complete(d: &QuestDetailView) -> SMSG_QUESTGIVER_QUEST_COMPLETE { SMSG_QUESTGIVER_QUEST_COMPLETE { quest_id: d.quest_id, - unknown: 0x03, // all emulators set this constant + unknown: 0x03, experience_reward: d.reward_xp, money_reward: Gold::new(d.money_reward), item_rewards: reward_items(&d.rewards), @@ -505,12 +392,10 @@ mod tests { #[test] fn status_picks_most_actionable() { - // A startable START quest → the `!` (Available). assert_eq!( quest_giver_status(&[ev(ROLE_START, true, false, false)]), QuestGiverStatus::Available ); - // An active+complete END quest → the yellow `?` (Reward2), even alongside an available one. assert_eq!( quest_giver_status(&[ ev(ROLE_START, true, false, false), @@ -518,7 +403,6 @@ mod tests { ]), QuestGiverStatus::Reward2 ); - // Active but NOT complete → Incomplete (a grey `?`), ranked below an available `!`. assert_eq!( quest_giver_status(&[ ev(ROLE_END, false, true, false), @@ -530,7 +414,6 @@ mod tests { quest_giver_status(&[ev(ROLE_END, false, true, false)]), QuestGiverStatus::Incomplete ); - // Nothing actionable (already done / under-level) → None. assert_eq!( quest_giver_status(&[ev(ROLE_START, false, false, false)]), QuestGiverStatus::None @@ -548,9 +431,9 @@ mod tests { #[test] fn quest_list_filters_to_actionable_and_tags_icons() { let evals = [ - ev(ROLE_START, true, false, false), // available → in the list as `!` - ev(ROLE_END, false, true, true), // complete → in the list as `?` - ev(ROLE_START, false, false, false), // none → filtered out + ev(ROLE_START, true, false, false), + ev(ROLE_END, false, true, true), + ev(ROLE_START, false, false, false), ]; let list = build_quest_list(100, "Greetings", &evals); assert_eq!(list.quest_items.len(), 2); @@ -558,8 +441,6 @@ mod tests { list.quest_items[0].quest_icon, QuestGiverStatus::Available.as_int() as u32 ); - // Complete turn-in line uses the MENU icon RewardRep(4) — the value the client treats as - // turn-in-able in a quest list (the overhead `?` uses Reward2(7); see menu_icon vs eval_status). assert_eq!( list.quest_items[1].quest_icon, QuestGiverStatus::RewardRep.as_int() as u32 @@ -568,7 +449,6 @@ mod tests { #[test] fn quest_query_response_raw_is_well_formed() { - // The title must be FIRST byte of the title CString (no 4-byte shift) — the bug this fixes. let d = QuestDetailView { quest_id: 783, quest_level: 1, @@ -588,27 +468,17 @@ mod tests { }; let (opcode, body) = build_quest_query_response_raw(&d); assert_eq!(opcode, 0x005D); - // quest_id at byte 0, zone_or_sort at byte 12 (3 u32s in), full title present uncut. assert_eq!(&body[0..4], &783u32.to_le_bytes()); assert_eq!(&body[12..16], &12i32.to_le_bytes()); - // PIN THE FIX: the title CString starts at EXACTLY byte 156 — the offset produced when the two - // rep-objective Faction fields are u32 (the 5875 layout). gtker's u16 bug makes the fixed header - // 4 bytes shorter, shifting the title to byte 152 ("reat Within"). This absolute-offset assert - // fails loudly if anyone reverts those fields to u16; the windows()-search below would NOT (it - // would still find the title at the wrong offset and pass). assert_eq!(&body[156..156 + 15], b"A Threat Within"); let title_pos = body .windows(15) .position(|w| w == b"A Threat Within") .expect("full title present"); assert_eq!(title_pos, 156); - // The title's NUL terminator follows immediately (the title isn't truncated mid-string). assert_eq!(body[title_pos + 15], 0); } - /// `next_quest_in_chain` and `max_level_money_reward` are THREADED from the view, - /// not the old hardcoded-0 stubs — pinned at their exact byte offsets (9 leading u32s, then - /// `next_quest_in_chain` at byte 36; `money_reward` at 40, then `max_level_money_reward` at 44). #[test] fn quest_query_response_raw_threads_next_quest_id_and_max_level_money_reward() { let d = QuestDetailView { @@ -633,16 +503,10 @@ mod tests { #[test] fn offer_reward_and_details_arm_auto_finish() { - // PIN the 5875 turn-in fix: `auto_finish` (Bool32) must serialize as 1 — mangos-zero hardcodes - // it `true` on both screens (SendQuestGiverOfferReward(.., true) / SendQuestGiverQuestDetails(.., - // true)). It is the FIRST u32 after the two leading CStrings, so its byte offset is computable - // from the title + body-text lengths. We use a known title ("T") + empty body text so the offset - // is fixed: a regression to `false` (0) fails here loudly. let mut d = detail_view(); - d.title = "T".into(); // 1 byte + NUL + d.title = "T".into(); - // OFFER_REWARD body: npc(8) + quest_id(4) + title("T\0"=2) + offer_reward_text("\0"=1) = 15, then - // auto_finish (Bool32) at byte 15. + // Guid, quest id and two CStrings put auto_finish at byte 15. let mut framed = Vec::new(); ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(Box::new(build_offer_reward(100, &d))) .write_unencrypted_server(&mut framed) @@ -659,8 +523,7 @@ mod tests { "OFFER_REWARD auto_finish (EnableNext) must be 1" ); - // DETAILS body: npc(8) + quest_id(4) + title("T\0"=2) + details("\0"=1) + objectives("\0"=1) = 16, - // then auto_finish (Bool32) at byte 16. + // Guid, quest id and three CStrings put auto_finish at byte 16. let (opcode, body) = build_quest_details_raw(100, &d); assert_eq!(opcode, 0x0188, "opcode == SMSG_QUESTGIVER_QUEST_DETAILS"); assert_eq!( @@ -691,8 +554,6 @@ mod tests { let (opcode, body) = build_quest_details_raw(100, &d); assert_eq!(opcode, 0x0188); - // Guid + id + three CStrings take 16 bytes. DETAILS has display triples, unlike - // gtker's incomplete pair-only definition. assert_eq!(&body[16..20], &1u32.to_le_bytes()); assert_eq!(&body[20..24], &1u32.to_le_bytes()); assert_eq!(&body[24..36], &[210, 4, 0, 0, 2, 0, 0, 0, 55, 0, 0, 0]); @@ -706,7 +567,6 @@ mod tests { ); } - /// A minimal detail view (all zero/empty) for the build_* codecs — the title is overwritten per-test. fn detail_view() -> QuestDetailView { QuestDetailView { quest_id: 7, @@ -727,9 +587,6 @@ mod tests { } } - /// `SMSG_QUESTUPDATE_FAILEDTIMER` (0x0197) carries the quest id at byte 0 of its - /// body — pinned by encoding the real gtker message (not just constructing the struct), so a - /// gtker version bump that changes the layout fails here loudly. #[test] fn failedtimer_wire_pin() { let msg = build_questupdate_failedtimer(783); @@ -743,9 +600,6 @@ mod tests { assert_eq!(&body[0..4], &783u32.to_le_bytes()); } - /// `MSG_QUEST_PUSH_RESULT` (0x0276) carries the member guid then the `QuestPartyMessage` - /// byte — pinned via a real gtker encode. `share_result::HAVE_QUEST` (7) round-trips to - /// `QuestPartyMessage::HaveQuest` (also 7 per gtker's own `as_int()`), proving the 1:1 mirror. #[test] fn quest_push_result_wire_pin() { use lyracore_shared::quest::share_result; @@ -762,8 +616,6 @@ mod tests { assert_eq!(body[8], share_result::HAVE_QUEST); } - /// An out-of-range code (never produced by the module — `share_result` only ever returns 0/1/4/6/7/8) - /// fails closed to `SharingQuest` rather than panicking. #[test] fn quest_push_result_out_of_range_code_falls_back_to_sharing() { let msg = build_quest_push_result(1, 255); @@ -809,14 +661,10 @@ mod tests { assert_eq!(c.money_reward, Gold::new(25)); assert_eq!(c.item_rewards.len(), 1); assert_eq!(c.item_rewards[0].item, 6078); - // The complete popup carries ONLY the guaranteed items (no choices), matching mangos. - // The offer-reward screen carries the item display id (icon). let o = build_offer_reward(100, &d); assert_eq!(o.item_rewards[0].item_display_id, 42); } - /// Both turn-in screens pack the CHOICE list (pick-1-of-N) in `choice_index` order, alongside the - /// unchanged guaranteed `item_rewards`: DETAILS without icons, OFFER with the display id (icon). #[test] fn turn_in_screens_pack_choice_rewards() { let d = QuestDetailView { @@ -840,9 +688,7 @@ mod tests { ..detail_view() }; - // DETAILS: choice_item_rewards carries item+count (no icon field), ordered as given. let (_, det) = build_quest_details_raw(100, &d); - // DETAILS uses 12-byte item/display triples and preserves choice order. let choice_start = 12 + d.title.len() + d.details.len() + d.objectives_text.len() + 3 + 8; assert_eq!(&det[choice_start..choice_start + 4], &1234u32.to_le_bytes()); assert_eq!( @@ -858,7 +704,6 @@ mod tests { &6078u32.to_le_bytes() ); - // OFFER: choice_item_rewards carries the display id (icon) too. let off = build_offer_reward(100, &d); assert_eq!(off.choice_item_rewards.len(), 2); assert_eq!(off.choice_item_rewards[0].item, 1234); @@ -866,7 +711,6 @@ mod tests { assert_eq!(off.choice_item_rewards[1].item_display_id, 22); assert_eq!(off.item_rewards.len(), 1); - // The "Quest Complete" popup is guaranteed-only — no choices. let comp = build_quest_complete(&d); assert_eq!(comp.item_rewards.len(), 1); } diff --git a/gateway/src/codec/quest/subscriptions.rs b/gateway/src/codec/quest/subscriptions.rs index 6a22db57e..78770cb8d 100644 --- a/gateway/src/codec/quest/subscriptions.rs +++ b/gateway/src/codec/quest/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; @@ -9,8 +7,6 @@ use crate::world::Outbound; use spacetimedb_sdk::Table; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Build the full quest-log update from the coordinator cache. The empty mask deliberately clears -/// every quest-log field when the final quest leaves the log. pub(crate) fn quest_log_outbound( db: &crate::stdb::bindings::RemoteTables, self_guid: u64, @@ -25,7 +21,6 @@ pub(crate) fn quest_log_outbound( Some(Outbound::Raw { opcode, body }) } -/// Build all packets for one quest update, in the order the client expects. pub(crate) fn quest_update_outbound( db: &crate::stdb::bindings::RemoteTables, self_guid: u64, @@ -48,7 +43,6 @@ pub(crate) fn quest_update_outbound( quest_update_packets(old, row, &objectives, quest_log_outbound(db, self_guid)) } -/// Build the feedback that precedes a full quest-log sync for one update. pub(crate) fn quest_update_feedback( old: &CharacterQuest, row: &CharacterQuest, @@ -73,7 +67,6 @@ pub(crate) fn quest_update_feedback( outbound } -/// Append a post-update full-log sync after kill and timer feedback. pub(crate) fn quest_update_packets( old: &CharacterQuest, row: &CharacterQuest, @@ -87,7 +80,6 @@ pub(crate) fn quest_update_packets( outbound } -/// A quest-log insert names one owner. The full log is read after the delta on that owner's writer. pub(crate) fn quest_inserted( view: &WorldView, coord: &Coordinator, @@ -106,7 +98,6 @@ pub(crate) fn quest_inserted( }); } -/// A quest update keeps kill feedback, timer failure, then the full quest-log sync in one writer job. pub(crate) fn quest_updated( view: &WorldView, coord: &Coordinator, @@ -129,7 +120,6 @@ pub(crate) fn quest_updated( }); } -/// A quest deletion re-sends the full log, including the all-zero final-log clear. pub(crate) fn quest_deleted( view: &WorldView, coord: &Coordinator, diff --git a/gateway/src/codec/social.rs b/gateway/src/codec/social.rs index 2aa944e26..4cbe28bea 100644 --- a/gateway/src/codec/social.rs +++ b/gateway/src/codec/social.rs @@ -1,7 +1,3 @@ -//! Social-tier wire builders — `SMSG_MESSAGECHAT` (say/yell/whisper) + `SMSG_TEXT_EMOTE` / -//! `SMSG_EMOTE` (emotes) + `SMSG_WHO` (/who panel). Split out of `codec` (a 1.7k-line module) so -//! the say/yell/whisper/emote/who group lives in one focused place; re-exported from `codec`. - use lyracore_shared::chat::broadcast_chat; use wow_world_base::shared::friend_result_vanilla_tbc::FriendResult; use wow_world_messages::vanilla::{ @@ -11,13 +7,8 @@ use wow_world_messages::vanilla::{ }; use wow_world_messages::Guid; -// ─── /who panel ────────────────────────────────────────────────────────────── - -/// One matched character row as `/who`'s filter (`world::who`) produces it — flat ints the codec -/// converts. pub struct WhoPlayerView { pub name: String, - /// The Guild name, empty outside a Guild. pub guild: String, pub level: u8, pub class: u8, @@ -25,28 +16,14 @@ pub struct WhoPlayerView { pub zone_id: u32, } -/// `SMSG_WHO` opcode (vanilla 5875). RAW-encoded — see [`build_who_response_raw`]. pub const SMSG_WHO_OPCODE: u16 = 0x0063; -/// Push a NUL-terminated CString. fn push_cstr(body: &mut Vec, s: &str) { body.extend_from_slice(s.as_bytes()); body.push(0); } -/// Build a RAW `SMSG_WHO` for `CMSG_WHO`. `players` is every match, already filtered to the -/// requester's team (`world::who::respond`); listing caps at 49, the vanilla client's display -/// limit, but `online_players` reports the full, uncapped match count. -/// -/// RAW because gtker 0.3's typed `WhoPlayer` writes `class: u8`, `race: u8`, `area: u32`, then a -/// trailing `party_status: u32` — cm:MiscHandler.cpp:252-257 writes class, race AND zone as `u32` -/// each, with no `party_status` field. vm:MiscHandler.cpp:212 sends `party_status` only to clients -/// at build 1.8.4 or older, which 5875 is not. The gtker shape is 2 bytes short per entry, so a -/// real client reads every row after the first out of alignment. The body is (all little-endian): -/// - `listed_players: u32` (`players.len()`, capped at 49) -/// - `online_players: u32` (`players.len()`, the full match count, uncapped) -/// - per listed player: `name` (CString), `guild` (CString, empty outside a Guild), -/// `level: u32`, `class: u32`, `race: u32`, `zone: u32` +/// Build 5875 uses u32 class/race/zone and no party_status; gtker's row is two bytes short. pub fn build_who_response_raw(players: &[WhoPlayerView]) -> (u16, Vec) { let listed = &players[..players.len().min(49)]; let mut body = Vec::with_capacity(8 + listed.len() * 20); @@ -63,12 +40,6 @@ pub fn build_who_response_raw(players: &[WhoPlayerView]) -> (u16, Vec) { (SMSG_WHO_OPCODE, body) } -// ─── Friends / ignore list ───────────────────────────────────────────────── - -/// One friend row as needed by `SMSG_FRIEND_LIST` — flat ints the codec converts. `online` false -/// means the row degrades to `Friend_FriendStatus::Offline` (no area/level/class carried, matching -/// the wire format — those fields are ONLY present for a non-offline status); `away` is read only -/// when `online` is true. pub struct FriendView { pub guid: u64, pub online: bool, @@ -78,11 +49,6 @@ pub struct FriendView { pub zone_id: u32, } -/// Build `SMSG_FRIEND_LIST` for `CMSG_FRIEND_LIST`. An online friend carries area/level/class (zone -/// ids outside the gtker `Area` enum degrade to `Area::None`, an unknown class degrades the WHOLE -/// row to `Offline` — better an inert row than a malformed one) and its status splits Online/Afk/ -/// Dnd off `away`; an offline friend carries only the guid. Capped at 255 (the wire's `u8` count -/// prefix). pub fn build_friend_list_response(friends: &[FriendView]) -> SMSG_FRIEND_LIST { let list = friends .iter() @@ -116,19 +82,14 @@ pub fn build_friend_list_response(friends: &[FriendView]) -> SMSG_FRIEND_LIST { SMSG_FRIEND_LIST { friends: list } } -/// Build `SMSG_IGNORE_LIST` for `CMSG_FRIEND_LIST` (sent alongside `SMSG_FRIEND_LIST` — vanilla -/// replies to the one request with both lists). Capped at 255 (the wire's `u8` count prefix). pub fn build_ignore_list_response(ignored: &[u64]) -> SMSG_IGNORE_LIST { SMSG_IGNORE_LIST { ignored: ignored.iter().take(255).copied().collect(), } } -/// `SMSG_FRIEND_STATUS` opcode (vanilla 5875). pub const SMSG_FRIEND_STATUS_OPCODE: u16 = 0x0068; -/// The trailing fields `SMSG_FRIEND_STATUS` carries for `FriendResult::Online` and -/// `FriendResult::AddedOnline` only (cm:SocialMgr.cpp:227-261, vm:SocialMgr.cpp:248-259). #[derive(Clone)] pub struct FriendOnline { pub away: crate::world::presence::AwayStatus, @@ -138,7 +99,6 @@ pub struct FriendOnline { } impl FriendOnline { - /// The status byte: 1 online, 2 afk, 4 dnd (cm:SocialMgr.h:31-38). fn status_byte(&self) -> u8 { match self.away { crate::world::presence::AwayStatus::None => 1, @@ -148,17 +108,7 @@ impl FriendOnline { } } -/// Build the RAW `SMSG_FRIEND_STATUS` — the add/remove confirmation (or rejection) for one -/// contact-list action. `result` is the vanilla `FriendResult` discriminant (`ADDED_ONLINE`/ -/// `ADDED_OFFLINE`/`REMOVED`/`IGNORE_ADDED`/`IGNORE_REMOVED`/`NOT_FOUND`/`ENEMY`/`ALREADY`/… — the -/// client renders the right system message off it); `guid` is the OTHER party (0 when the action -/// failed to resolve a name). -/// -/// RAW because gtker 0.3's typed `SMSG_FRIEND_STATUS` writes only `result` and `guid`: cm's -/// `ONLINE`/`ADDED_ONLINE` also append status u8, area u32, level u32, class u32 -/// (cm:SocialMgr.cpp:227-261), which gtker 0.3 cannot encode. `online` carries those trailing -/// fields and must be `Some` for exactly `FriendResult::Online`/`FriendResult::AddedOnline`, `None` -/// otherwise — the caller decides which, from the same Realm Presence read either way. +/// gtker omits the status/area/level/class tail required for ONLINE and ADDED_ONLINE. pub fn build_friend_status_raw( result: FriendResult, guid: u64, @@ -175,10 +125,6 @@ pub fn build_friend_status_raw( (SMSG_FRIEND_STATUS_OPCODE, body) } -/// Build `SMSG_MESSAGECHAT` for nearby player or creature speech. -/// -/// Creature packets carry the speaker name and use the matching monster variant. Unknown source -/// modes retain the existing player-say fallback. #[cfg(test)] fn build_chat_message( sender_guid: u64, @@ -190,10 +136,6 @@ fn build_chat_message( build_chat_message_to(sender_guid, sender_name, 0, chat_type, language, message, 0) } -/// Build creature speech while retaining the addressed target chosen by EventAI. A player EMOTE -/// row (`chat_type` 3, never a creature — EventAI has no such source) has no name, so it always -/// hits the `(EMOTE, None)` arm and names the speaker via `sender2`. `chat_tag` is the speaker's -/// `lyracore_shared::chat::chat_tag`; an unknown value goes out as none. pub fn build_chat_message_to( sender_guid: u64, sender_name: Option, @@ -217,9 +159,7 @@ pub fn build_chat_message_to( target: Guid::new(target_guid), } } - // `MonsterEmote` carries one guid and it names the SPEAKER, paired with the speaker's own - // name. The variant has no addressee field on the wire, so an emote's addressed target is - // not expressible here and is dropped rather than misfiled into the speaker slot. + // MonsterEmote's sole guid names the speaker; it has no addressee field. (broadcast_chat::CREATURE_WHISPER, Some(monster_name)) => { SMSG_MESSAGECHAT_ChatType::MonsterWhisper { monster: sender, @@ -247,7 +187,6 @@ pub fn build_chat_message_to( monster: sender, monster_name: String::new(), }, - // `/e` (cm:Chat.cpp:3660-3662): a Character-only broadcast type, always Universal below. (broadcast_chat::EMOTE, None) => SMSG_MESSAGECHAT_ChatType::Emote { sender2: sender }, _ => SMSG_MESSAGECHAT_ChatType::Say { chat_credit: sender, @@ -272,10 +211,6 @@ pub fn build_chat_message_to( } } -/// Build the `SMSG_MESSAGECHAT` for one Realm Chat Line. Covers every Chat Kind in -/// `lyracore_shared::chat::chat_kind`, so a new audience family never edits the codec. PARTY names -/// the speaker in both credit fields, CHANNEL carries the channel name with rank 0, and the other -/// kinds carry one guid. An unknown language goes out as Universal. `None` for an unknown kind. pub fn build_realm_chat_line( kind: u8, speaker_guid: u64, @@ -316,11 +251,6 @@ pub fn build_realm_chat_line( }) } -/// Build `SMSG_MESSAGECHAT` System — a self-only server line (the GM dot-command -/// reducer's `Err` relayed back to the SENDER, e.g. "permission denied", "unknown command: .foo"). -/// `System`'s `sender2` guid is always 0 (no meaningful sender for a server-originated line, mirroring -/// how vanilla's own system messages carry no unit credit). Always Universal + no tag, like whispers/ -/// party (this line is never proximity/language-filtered — it never leaves this one connection). pub fn build_gm_system_message(message: String) -> SMSG_MESSAGECHAT { SMSG_MESSAGECHAT { chat_type: SMSG_MESSAGECHAT_ChatType::System { @@ -332,11 +262,6 @@ pub fn build_gm_system_message(message: String) -> SMSG_MESSAGECHAT { } } -/// Build `SMSG_TEXT_EMOTE` — the "X dances." chat-log line observers see for an emote. `text_emote` -/// is the social-emote id (drives the wording); `emote_anim` echoes the animation id; `target_name` -/// is the resolved name of the CMSG-supplied target (`None`/empty → the client renders the untargeted -/// "X dances." line; `Some(name)` → "X waves at name."). Returns `None` if `text_emote` isn't a known -/// emote (the client always sends a valid one; this just degrades safely). pub fn build_text_emote( sender_guid: u64, text_emote: u32, @@ -352,9 +277,6 @@ pub fn build_text_emote( }) } -/// Build `MSG_RANDOM_ROLL_Server`: the `/roll` result for the roller's group, or for the roller -/// alone. `minimum` / `maximum` are the clamped range from the roller's request; `actual_roll` is -/// the server-computed result; `guid` is the roller. All values echo what the module committed. pub fn build_random_roll( roller_guid: u64, minimum: u32, @@ -369,10 +291,6 @@ pub fn build_random_roll( } } -/// Build `SMSG_EMOTE` — the animation observers play for the performer. `emote_anim` is the -/// client-provided `Emote` id; `None` (text-only) when it isn't a known animation, so a missing / -/// zero id never breaks the emote (the chat line still goes out). The text line is the primary -/// social signal; the animation is an additive nice-to-have, so they relay independently by design. pub fn build_emote_anim(sender_guid: u64, emote_anim: u32) -> Option { let emote = Emote::try_from(emote_anim).ok()?; Some(SMSG_EMOTE { @@ -388,8 +306,6 @@ mod tests { #[test] fn chat_message_carries_sender_type_and_serializes() { - // Say: sender guid rides in the Say variant (both credit fields); message + language survive - // the writer. Yell: the variant flips. Serialize each so the writer runs over the new path. let say = build_chat_message(7, None, 0, 0, "hello world".into()); match &say.chat_type { SMSG_MESSAGECHAT_ChatType::Say { @@ -411,7 +327,6 @@ mod tests { yell.chat_type, SMSG_MESSAGECHAT_ChatType::Yell { .. } )); - // An unknown language discriminant falls back to Universal rather than panicking. let creature_say = build_chat_message( 0xF130_0000_0000_000B, Some("Defias Thug".into()), @@ -484,7 +399,6 @@ mod tests { monster, monster_name, } => { - // The guid and the name must describe the same unit: the speaking creature. assert_eq!(monster.guid(), 0xF130_0000_0000_000B); assert_eq!(monster_name, "Defias Thug"); } @@ -536,9 +450,6 @@ mod tests { assert_eq!(emote.language, Language::Common); } - /// A player `/e` row (no creature name — EventAI never emits EMOTE) builds `Emote { sender2 }` - /// naming the speaker, in Universal whatever language byte the row carries - /// (cm:Chat.cpp:3660-3662, cm:Player.cpp:16594). #[test] fn player_emote_row_builds_emote_in_universal_and_round_trips() { let emote = build_chat_message_to( @@ -546,7 +457,7 @@ mod tests { None, 0, broadcast_chat::EMOTE, - 1, // a non-Universal byte on the row must not leak onto the wire + 1, "waves wildly.".into(), 0, ); @@ -559,20 +470,15 @@ mod tests { assert_eq!(read_back(&emote), emote); } - /// cm:Chat.cpp:3616-3669 with cm:Player.cpp:16574: a Human's say in Common from guid 7 while - /// AFK. The chat tag is the last byte, after the sized message. #[test] fn an_afk_players_say_ends_with_the_afk_tag() { let say = build_chat_message_to(7, None, 0, 0, 7, "hi".into(), 1); let mut wire = Vec::new(); say.write_unencrypted_server(&mut wire).unwrap(); let body = [ - 0x00, // CHAT_MSG_SAY - 0x07, 0x00, 0x00, 0x00, // LANG_COMMON - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // speaker guid - 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // speaker guid again - 0x03, 0x00, 0x00, 0x00, b'h', b'i', 0x00, // sized message - 0x01, // CHAT_TAG_AFK + 0x00, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, b'h', b'i', 0x00, + 0x01, ]; assert!(wire.ends_with(&body), "wire {wire:02x?}"); assert_eq!(say.tag, PlayerChatTag::Afk); @@ -592,8 +498,6 @@ mod tests { } } - /// Every Chat Kind, written out from the 1.12 `SMSG_MESSAGECHAT` layout - /// (cm:Chat.cpp:3616-3669): the wire value, then the variant the vanilla reader must decode. #[test] fn every_realm_chat_kind_round_trips_through_the_vanilla_reader() { let speaker = Guid::new(42); @@ -687,8 +591,6 @@ mod tests { #[test] fn emote_builders_carry_performer_and_degrade_safely() { - // A known text-emote id (1 = a real TextEmote) yields the "X dances." line with the performer - // guid; serialize so the writer runs over it. An unknown id yields None (text-only degrade). let te = build_text_emote(5, 1, 10, None).expect("text_emote id 1 is known"); assert_eq!(te.guid.guid(), 5); assert!(te.name.is_empty()); @@ -697,12 +599,10 @@ mod tests { assert!(!buf.is_empty()); assert!(build_text_emote(5, u32::MAX, 0, None).is_none()); - // A resolved target name flows through untouched (the "waves at " case). let targeted = build_text_emote(5, 1, 10, Some("Bob".to_string())).expect("text_emote id 1 is known"); assert_eq!(targeted.name, "Bob"); - // The animation: a valid Emote id → Some carrying the performer; an unknown id → None. let anim = build_emote_anim(5, 10).expect("Emote id 10 is known"); assert_eq!(anim.guid.guid(), 5); assert!(build_emote_anim(5, u32::MAX).is_none()); @@ -752,7 +652,6 @@ mod tests { assert!(!buf.is_empty()); } - /// Away Status splits the wire status code: Online 1, Afk 2, Dnd 4 (cm:SocialMgr.h:31-38). #[test] fn friend_list_online_status_follows_away_status() { use crate::world::presence::AwayStatus; @@ -779,8 +678,6 @@ mod tests { #[test] fn friend_list_unknown_class_degrades_whole_row_to_offline() { - // An online row with an unmapped class discriminant can't build a valid Online variant — - // degrade the WHOLE row to Offline rather than send a malformed one. let friends = [FriendView { guid: 5, online: true, @@ -805,9 +702,6 @@ mod tests { assert!(!buf.is_empty()); } - /// Bytes hand-written from the protocol table (README, cm:SocialMgr.cpp:227-261): result u8, - /// guid u64 LE, then for `ADDED_ONLINE` only status u8 (ONLINE=1), area u32, level u32, class - /// u32, all little-endian. Never recomputed with the builder's own field order. #[test] fn friend_status_raw_is_byte_exact_for_added_online() { let (opcode, body) = build_friend_status_raw( @@ -821,17 +715,15 @@ mod tests { }), ); assert_eq!(opcode, SMSG_FRIEND_STATUS_OPCODE); - let mut expected = vec![0x06]; // ADDED_ONLINE + let mut expected = vec![0x06]; expected.extend(77u64.to_le_bytes()); - expected.push(1); // status ONLINE - expected.extend(12u32.to_le_bytes()); // area - expected.extend(30u32.to_le_bytes()); // level - expected.extend(1u32.to_le_bytes()); // class + expected.push(1); + expected.extend(12u32.to_le_bytes()); + expected.extend(30u32.to_le_bytes()); + expected.extend(1u32.to_le_bytes()); assert_eq!(body, expected); } - /// An Afk friend's `ADDED_ONLINE`/`ONLINE` status byte is 2, Dnd is 4 (cm:SocialMgr.h:31-38) — - /// distinct from the chat tag's own 1/2 numbering. #[test] fn friend_status_raw_status_byte_follows_away_status() { for (away, status) in [ @@ -853,19 +745,16 @@ mod tests { } } - /// A rejection or an offline add/remove carries no trailing fields: result u8 then guid u64 LE, - /// nothing after. #[test] fn friend_status_raw_carries_no_trailing_fields_without_online() { let (_, body) = build_friend_status_raw(FriendResult::Enemy, 5, None); - let mut expected = vec![0x0a]; // ENEMY + let mut expected = vec![0x0a]; expected.extend(5u64.to_le_bytes()); assert_eq!(body, expected); } #[test] fn friend_list_response_truncates_to_255_entries() { - // The wire count is a u8; feeding more than that must cap rather than silently wrap. let friends: Vec = (0..300) .map(|i| FriendView { guid: i as u64, @@ -888,10 +777,6 @@ mod tests { } } -// =========================================================================================== -// Party/group -// =========================================================================================== - use lyracore_shared::group::{GroupKind, RosterPayload}; use wow_world_messages::vanilla::{ GroupListMember, GroupLootSetting, GroupType, ItemQuality, PartyOperation, PartyResult, @@ -899,25 +784,20 @@ use wow_world_messages::vanilla::{ SMSG_GROUP_SET_LEADER, SMSG_PARTY_COMMAND_RESULT, }; -/// `SMSG_GROUP_INVITE` — the target's "X invites you to a group" dialog. pub fn build_group_invite(inviter_name: String) -> SMSG_GROUP_INVITE { SMSG_GROUP_INVITE { name: inviter_name } } -/// `SMSG_GROUP_DECLINE` — the inviter's "X declines your group invitation" line. pub fn build_group_decline(decliner_name: String) -> SMSG_GROUP_DECLINE { SMSG_GROUP_DECLINE { name: decliner_name, } } -/// `SMSG_GROUP_SET_LEADER`: every member's "X is now the group leader" line -/// (cm:Group.cpp:498-500). pub fn build_group_set_leader(leader_name: String) -> SMSG_GROUP_SET_LEADER { SMSG_GROUP_SET_LEADER { name: leader_name } } -/// `SMSG_PARTY_COMMAND_RESULT` — the invite/leave outcome line ("X is already in a group" etc.). pub fn build_party_command_result( operation: PartyOperation, member: String, @@ -930,17 +810,6 @@ pub fn build_party_command_result( } } -/// `SMSG_GROUP_LIST` for `self_guid` (cm:Group.cpp:680-708): the group type, the viewer's own Raid -/// Slot as `flags`, then the OTHER members (the recipient is implied) with their own Raid Slots, the -/// leader, and the loot block. `roster` is the FULL roster with names already resolved; the -/// recipient is filtered out here so every member can share one roster snapshot. `online` answers -/// presence per member guid. -/// -/// `loot_method`/`loot_threshold` are the module's wire-matching `group::loot_method::*`/ -/// `ItemQuality` byte values, read with a direct `try_from`, never a hand-written match table. An -/// out-of-range byte (the module validates both before storing, so this should not happen) falls back to -/// FreeForAll/Uncommon rather than failing the whole packet. As in cmangos, the loot block goes out -/// only when the list names another member. pub fn build_group_list( self_guid: u64, roster: &RosterPayload, @@ -981,17 +850,12 @@ pub fn build_group_list( } } -/// `SMSG_GROUP_LIST` as the 1.12 servers send it: `(opcode, body)` for `Outbound::Raw`. The body -/// is gtker's encoding of `list`, then, after the loot threshold, one more byte 0 that gtker has no -/// field for. cmangos writes it as "Heroic Mod Group - unused in vanilla" (cm:Group.cpp:705), and -/// vmangos as the dungeon difficulty for client builds after 1.10.2 -/// (vm:Server/Packets/Group.cpp:258-259). Both send it only with the loot block. +/// The loot block needs a trailing zero difficulty byte that gtker omits. pub fn build_group_list_raw(list: &SMSG_GROUP_LIST) -> (u16, Vec) { use wow_world_messages::vanilla::ServerMessage; let mut framed = Vec::new(); list.write_unencrypted_server(&mut framed) .expect("writing to a Vec cannot fail"); - // The frame is `[size:u16 BE][opcode:u16 LE]` and then the body. let opcode = u16::from_le_bytes([framed[2], framed[3]]); let mut body = framed.split_off(4); if list.group_not_empty.is_some() { @@ -1000,23 +864,16 @@ pub fn build_group_list_raw(list: &SMSG_GROUP_LIST) -> (u16, Vec) { (opcode, body) } -// =========================================================================================== -// Group Broadcasts -// =========================================================================================== - use lyracore_shared::group::TargetIcon; use wow_world_messages::vanilla::{ MSG_MINIMAP_PING_Server, MSG_RAID_READY_CHECK_Server, MSG_RAID_READY_CHECK_Server_state_check, MSG_RAID_TARGET_UPDATE_Server, RaidTargetIndex, RaidTargetUpdate, }; -/// `MSG_RAID_READY_CHECK` with no body: a Ready Check started (cm:GroupHandler.cpp:562-563). pub fn build_ready_check() -> MSG_RAID_READY_CHECK_Server { MSG_RAID_READY_CHECK_Server { state_check: None } } -/// `MSG_RAID_READY_CHECK` for the leader: one member's guid and answer state -/// (cm:GroupHandler.cpp:576-579). pub fn build_ready_check_answer(member_guid: u64, state: u8) -> MSG_RAID_READY_CHECK_Server { MSG_RAID_READY_CHECK_Server { state_check: Some(MSG_RAID_READY_CHECK_Server_state_check { @@ -1026,8 +883,6 @@ pub fn build_ready_check_answer(member_guid: u64, state: u8) -> MSG_RAID_READY_C } } -/// The partial `MSG_RAID_TARGET_UPDATE`: update type 0, the icon, the unit (cm:Group.cpp:595-600). -/// Guid 0 clears the icon. `None` for an icon outside 0 to 7. pub fn build_target_icon_update(icon: TargetIcon) -> Option { Some(MSG_RAID_TARGET_UPDATE_Server::Partial { raid_target: RaidTargetUpdate { @@ -1037,9 +892,7 @@ pub fn build_target_icon_update(icon: TargetIcon) -> Option (u16, Vec) { use wow_world_messages::Message; let opcode = u16::try_from(MSG_RAID_TARGET_UPDATE_Server::OPCODE) @@ -1054,14 +907,12 @@ pub fn build_target_icon_list_raw(icons: &[TargetIcon]) -> (u16, Vec) { (opcode, body) } -/// gtker names the 8 icons `Unknown0` to `Unknown7`. Its `Unknown8` is not an icon. fn target_icon_index(icon: u8) -> Option { (icon < lyracore_shared::group::TARGET_ICON_COUNT) .then(|| RaidTargetIndex::try_from(icon).ok()) .flatten() } -/// `MSG_MINIMAP_PING`: the pinger and the point on the minimap (cm:GroupHandler.cpp:410-413). pub fn build_minimap_ping(pinger_guid: u64, x: f32, y: f32) -> MSG_MINIMAP_PING_Server { MSG_MINIMAP_PING_Server { guid: Guid::new(pinger_guid), @@ -1072,9 +923,6 @@ pub fn build_minimap_ping(pinger_guid: u64, x: f32, y: f32) -> MSG_MINIMAP_PING_ use wow_world_messages::vanilla::{RaidGroupError, SMSG_RAID_GROUP_ONLY}; -/// `SMSG_RAID_GROUP_ONLY`: the Instance Removal countdown in milliseconds, or 0 to hide it -/// (cm:Player.cpp:17697-17728). The error is `Required` (1) as vmangos sends -/// (vm:Player.cpp:18561-18588). cmangos sends 0, which gtker's `RaidGroupError` cannot encode. pub fn build_raid_group_only(timer_ms: u32) -> SMSG_RAID_GROUP_ONLY { SMSG_RAID_GROUP_ONLY { homebind_timer: timer_ms, @@ -1088,9 +936,6 @@ mod party_tests { #[test] fn who_response_raw_caps_listed_rows_at_49_but_reports_the_full_match_count() { - // The 1.12 client won't render more than 49 rows, so the listing caps there — but - // `online_players` must still report the FULL match count (before any cap), matching a real - // server's "N players online" count even when the panel itself only shows 49. let players: Vec = (0..51) .map(|i| WhoPlayerView { name: format!("P{i}"), @@ -1113,8 +958,6 @@ mod party_tests { online_players, 51, "the online count must be the full 51-player match, uncapped" ); - // 8 header bytes, then one row per listed player: name CString, empty guild CString, 4 - // u32 fields. Sums the actual (varying) name lengths rather than assuming one width. let expected_len: usize = 8 + players[..49] .iter() .map(|p| p.name.len() + 1 + 1 + 16) @@ -1124,9 +967,6 @@ mod party_tests { #[test] fn who_response_raw_matches_the_cmangos_byte_layout() { - // Hand-written from cm:MiscHandler.cpp:252-257: name, guild, level/class/race/zone each a - // u32, no party_status. Two rows, so a real client reading past the first proves the - // layout, not just the first row's offsets. let players = [ WhoPlayerView { name: "Ginger".into(), @@ -1148,20 +988,20 @@ mod party_tests { let (opcode, body) = build_who_response_raw(&players); assert_eq!(opcode, SMSG_WHO_OPCODE); let mut expected = Vec::new(); - expected.extend_from_slice(&2u32.to_le_bytes()); // listed_players - expected.extend_from_slice(&2u32.to_le_bytes()); // online_players + expected.extend_from_slice(&2u32.to_le_bytes()); + expected.extend_from_slice(&2u32.to_le_bytes()); expected.extend_from_slice(b"Ginger\0"); - expected.extend_from_slice(b"Boundary Test\0"); // guild - expected.extend_from_slice(&10u32.to_le_bytes()); // level - expected.extend_from_slice(&1u32.to_le_bytes()); // class - expected.extend_from_slice(&1u32.to_le_bytes()); // race - expected.extend_from_slice(&12u32.to_le_bytes()); // zone + expected.extend_from_slice(b"Boundary Test\0"); + expected.extend_from_slice(&10u32.to_le_bytes()); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&12u32.to_le_bytes()); expected.extend_from_slice(b"Vim\0"); - expected.extend_from_slice(b"\0"); // guild - expected.extend_from_slice(&60u32.to_le_bytes()); // level - expected.extend_from_slice(&8u32.to_le_bytes()); // class - expected.extend_from_slice(&2u32.to_le_bytes()); // race - expected.extend_from_slice(&1u32.to_le_bytes()); // zone + expected.extend_from_slice(b"\0"); + expected.extend_from_slice(&60u32.to_le_bytes()); + expected.extend_from_slice(&8u32.to_le_bytes()); + expected.extend_from_slice(&2u32.to_le_bytes()); + expected.extend_from_slice(&1u32.to_le_bytes()); assert_eq!(body, expected); } @@ -1198,26 +1038,16 @@ mod party_tests { .iter() .map(|(guid, name)| (*guid, *name, RaidSlot::default())) .collect(); - roster( - GroupKind::Party, - 1, - 3, /* GROUP */ - 2, /* Uncommon */ - 0, - &members, - ) + roster(GroupKind::Party, 1, 3, 2, 0, &members) } - /// The sent body bytes, pinned field by field against cm:Group.cpp:680-708 and - /// vm:Server/Packets/Group.cpp:237-261 for a viewer in Subgroup 1 with an online leader in - /// Subgroup 0 and an offline Assistant in Subgroup 1. #[test] fn a_raid_group_list_carries_the_raid_type_and_every_members_raid_slot() { let raid = roster( GroupKind::Raid, 10, - 3, // GROUP_LOOT - 2, // Uncommon + 3, + 2, 0, &[ (10, "Ab", RaidSlot::new(0, false).unwrap()), @@ -1228,37 +1058,36 @@ mod party_tests { let (opcode, body) = build_group_list_raw(&build_group_list(11, &raid, |guid| guid == 10)); assert_eq!(opcode, 0x007D); let expected: Vec = [ - &[0x01][..], // group type: GROUP_FLAG_RAID - &[0x01], // own flags: Subgroup 1, no Assistant - &2u32.to_le_bytes(), // members other than the viewer - b"Ab\0", // name - &10u64.to_le_bytes(), // guid - &[0x01], // status: online - &[0x00], // flags: Subgroup 0 - b"Cd\0", // name - &12u64.to_le_bytes(), // guid - &[0x00], // status: offline - &[0x81], // flags: Subgroup 1 | 0x80 Assistant - &10u64.to_le_bytes(), // leader - &[0x03], // loot method - &0u64.to_le_bytes(), // master looter - &[0x02], // loot threshold - &[0x00], // unused in 1.x: cm "Heroic Mod Group", vm "dungeonDifficulty" + &[0x01][..], + &[0x01], + &2u32.to_le_bytes(), + b"Ab\0", + &10u64.to_le_bytes(), + &[0x01], + &[0x00], + b"Cd\0", + &12u64.to_le_bytes(), + &[0x00], + &[0x81], + &10u64.to_le_bytes(), + &[0x03], + &0u64.to_le_bytes(), + &[0x02], + &[0x00], ] .concat(); assert_eq!(body, expected); } - /// Without another member there is no loot block, and so no trailing byte either. #[test] fn a_sent_list_naming_only_the_viewer_ends_at_the_leader() { let (_, body) = build_group_list_raw(&build_group_list(1, &party(&[(1, "Self")]), |_| true)); let expected: Vec = [ - &[0x00][..], // group type: GROUP_FLAG_NORMAL - &[0x00], // own flags - &0u32.to_le_bytes(), // no other member - &1u64.to_le_bytes(), // leader + &[0x00][..], + &[0x00], + &0u32.to_le_bytes(), + &1u64.to_le_bytes(), ] .concat(); assert_eq!(body, expected); @@ -1272,7 +1101,6 @@ mod party_tests { assert!(list.members.iter().all(|member| member.flags == 0)); } - /// cm:Group.cpp:700: the loot block follows the leader only when another member is listed. #[test] fn a_list_naming_only_the_viewer_carries_no_loot_block() { let list = build_group_list(1, &party(&[(1, "Self")]), |_| true); @@ -1313,21 +1141,11 @@ mod party_tests { assert!(!carol.is_online); } - /// The loot block now carries the group's REAL current method/threshold/master - /// (was hardcoded FreeForAll/Uncommon) — a direct `try_from` off the module's wire-matching byte - /// values, with a safe fallback for an (unreachable, module-validated) out-of-range byte. #[test] fn group_list_loot_block_carries_the_real_method_threshold_and_master() { let slot = RaidSlot::default(); let members = [(1, "Self", slot), (2, "Bob", slot)]; - let master = roster( - GroupKind::Party, - 1, - 2, /* MASTER */ - 4, /* Epic */ - 2, - &members, - ); + let master = roster(GroupKind::Party, 1, 2, 4, 2, &members); let resp = build_group_list(1, &master, |_| true); let block = resp .group_not_empty @@ -1335,8 +1153,6 @@ mod party_tests { assert_eq!(block.loot_setting, GroupLootSetting::MasterLoot); assert_eq!(block.loot_threshold, ItemQuality::Epic); assert_eq!(block.master_loot.guid(), 2); - // An out-of-range byte (shouldn't happen post module-validation) degrades safely rather than - // failing the whole packet. let fallback = build_group_list( 1, &roster(GroupKind::Party, 1, 255, 255, 0, &members), @@ -1347,8 +1163,6 @@ mod party_tests { assert_eq!(fallback_block.loot_threshold, ItemQuality::Uncommon); } - /// cm:Group.cpp:498-499 writes the leader's name as one CString: opcode 0x0079, then the name - /// bytes and a 0. #[test] fn the_set_leader_packet_is_the_leader_name_as_one_cstring() { use wow_world_messages::vanilla::ServerMessage; @@ -1358,15 +1172,14 @@ mod party_tests { .unwrap(); let body: &[u8] = b"Ginger\0"; let expected: Vec = [ - &((body.len() + 2) as u16).to_be_bytes()[..], // size: opcode + body - &0x0079u16.to_le_bytes(), // SMSG_GROUP_SET_LEADER + &((body.len() + 2) as u16).to_be_bytes()[..], + &0x0079u16.to_le_bytes(), body, ] .concat(); assert_eq!(framed, expected); } - /// cm:Player.h:309-314 and gtker: opcode 0x0286, then `u32 homebind_timer` and `u32 error`. #[test] fn the_raid_group_only_packet_is_the_countdown_then_the_error() { use wow_world_messages::vanilla::ServerMessage; diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index 38930ac11..84b69dee5 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::{CellKey, EntityLayer, ShardId}; @@ -11,10 +9,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// SAY proximity range (vanilla ~25yd). See [`chat_in_range`]. pub(crate) const SAY_RANGE_YD: f32 = 25.0; -/// YELL proximity range (vanilla ~300yd). Same rationale as `SAY_RANGE_YD`. pub(crate) const YELL_RANGE_YD: f32 = 300.0; #[cfg(test)] @@ -23,7 +19,6 @@ pub(crate) const SAY_RANGE_SQ: f32 = SAY_RANGE_YD * SAY_RANGE_YD; #[cfg(test)] pub(crate) const YELL_RANGE_SQ: f32 = YELL_RANGE_YD * YELL_RANGE_YD; -/// The distance used for local speech. Zone speech and creature whispers use their own audience. pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { if matches!( chat_type, @@ -36,12 +31,7 @@ pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { } } -/// Group / loot-roll / quest-share: the ONE kind-decode body both legs -/// run. PRIVATE data — the audience (the row's recipient, and nobody else) is resolved by the -/// caller (RLS on the per-player leg; the owner-session lookup + `private_recipient_audience` on -/// the shared leg). `store` is the connection the row came from: Realm-core on a sharded Realm. -/// The LIST render reads every World Shard through it, and QUEST_SHARE reads the quest detail. -#[allow(clippy::too_many_lines)] // One arm per group event kind. +#[allow(clippy::too_many_lines)] pub(crate) fn group_event_outbound< St: CharacterStore + QuestActionStore + SessionStore + ShardRoutingStore + ?Sized, >( @@ -54,18 +44,11 @@ pub(crate) fn group_event_outbound< use lyracore_shared::meeting_stone::event_kind as stone_kind; use lyracore_shared::quest::share_event_kind as quest_share_kind; let msg = match row.kind { - // Sent even when no shard can name the inviter (falls back to an empty name, like - // `render_list` does for an unresolved roster member): the popup is the target's only way - // to learn of the invite, and the pending `game_group_invite` row already committed. - // Skipping the packet would strand it for the whole 2-minute invite GC. group_kind::INVITE => Some(ServerOpcodeMessage::SMSG_GROUP_INVITE(Box::new( codec::build_group_invite( other_character_name(store, row, "INVITE").unwrap_or_default(), ), ))), - // The same renderer world entry uses: presence and blank names come from the shard caches, - // because a roster written on realm-core can know neither. A Party's Target Icons follow - // in the same job, because the Party client clears its marks on the list. group_kind::LIST => match lyracore_shared::group::RosterPayload::decode(&row.payload) { Some(roster) => { let mut packets = vec![crate::world::party::render_list(store, self_guid, &roster)]; @@ -84,8 +67,6 @@ pub(crate) fn group_event_outbound< None } }, - // Same fallback as INVITE, for the same reason: consistent behavior for the two kinds - // that answer an invite dialog. group_kind::DECLINE => Some(ServerOpcodeMessage::SMSG_GROUP_DECLINE(Box::new( codec::build_group_decline( other_character_name(store, row, "DECLINE").unwrap_or_default(), @@ -189,11 +170,6 @@ pub(crate) fn group_event_outbound< } } } - // Quest sharing: an eligible party member receives the shared quest — - // `row.other_guid` is the SHARER (resolved/pushed by `group::push_event`), `row.payload` - // is the quest entry. Opens the DETAILS screen with the SHARER as "giver" (the - // recipient's own `CMSG_QUESTGIVER_ACCEPT_QUEST` then re-validates fresh via the - // module's `GiverKind::Party` — this relay never authorizes anything by itself). quest_share_kind::QUEST_SHARE => match row.payload.parse::() { Ok(quest_id) => match store.quest_detail_view(quest_id) { Ok(Some(detail)) => { @@ -224,9 +200,6 @@ pub(crate) fn group_event_outbound< None } }, - // Quest sharing: the SENDER's per-member feedback line, one row per member, - // always (whether or not the share actually landed) — `row.other_guid` is that member, - // `row.payload` is the `share_result` wire byte (mirrors gtker's `QuestPartyMessage` 1:1). quest_share_kind::QUEST_PUSH_RESULT => match row.payload.parse::() { Ok(code) => Some(ServerOpcodeMessage::MSG_QUEST_PUSH_RESULT(Box::new( codec::build_quest_push_result(row.other_guid, code), @@ -321,17 +294,6 @@ pub(crate) fn group_event_outbound< } } -/// The Character an INVITE, DECLINE or SET_LEADER row names by `other_guid`: the inviter, the -/// decliner, or the new leader. The Gateway always looks it up from the World Shard caches, -/// because Realm-core holds no `game_character` rows to resolve one from. `None` means no -/// connected shard can name it, or the lookup itself failed. -/// -/// INVITE and DECLINE fall back to an empty name and send the packet anyway, the way -/// [`crate::world::party::render_list`] fills an unresolved roster member's name with -/// `.unwrap_or_default()`: the popup is the target's only way to learn of an invite that already -/// committed durably, so dropping the packet would strand it for the whole GC window. SET_LEADER -/// skips the packet instead: the roster mirror the Gateway already pushed carries the new leader, -/// so the line is redundant rather than load-bearing. pub(crate) fn other_character_name( store: &St, row: &GroupEvent, @@ -358,8 +320,6 @@ pub(crate) fn other_character_name( row: &GroupEvent, decoded: Option, @@ -381,13 +341,9 @@ pub(crate) fn log_unparseable_broadcast(row: &GroupEvent) { } pub(crate) fn private_recipient_audience(row_recipient_guid: u64, viewer_guid: u64) -> bool { - // 0 is "unaddressed"/"uninitialized", never a real character — an equality alone would let an - // unaddressed row match a half-initialized viewer (0 == 0), so zero denies on either side. row_recipient_guid != 0 && row_recipient_guid == viewer_guid } -/// A Realm Chat Line: one `SMSG_MESSAGECHAT` for one recipient. The Relay already chose the -/// audience; an unknown Chat Kind sends nothing. pub(crate) fn realm_chat_outbound(row: &RealmChatEvent) -> Vec { codec::build_realm_chat_line( row.kind, @@ -402,7 +358,6 @@ pub(crate) fn realm_chat_outbound(row: &RealmChatEvent) -> Vec { .collect() } -/// Build a Package System Message after the caller validates the recipient. pub(crate) fn system_message_event_outbound(row: &SystemMessageEvent) -> Vec { let message = codec::build_gm_system_message(row.message.clone()); vec![Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT( @@ -410,14 +365,6 @@ pub(crate) fn system_message_event_outbound(row: &SystemMessageEvent) -> Vec) -> u8 { match speaker { Some(entity) @@ -601,12 +546,6 @@ pub(crate) fn chat_event_message( Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new(message))) } -/// Text emotes (`/wave`, `/dance`, …): SMSG_TEXT_EMOTE + SMSG_EMOTE, `chat_in_range`-gated at -/// [`lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD`] like say (cm:ChatHandler.cpp:789- -/// 794 sends to both teams, so there is no team gate here — contrast `chat_event_outbound`'s EMOTE -/// arm). The performer always sees their own emote. The target name resolves through the -/// coordinator cache (player, else creature template); unknown ids degrade gracefully so the rest -/// still relays. pub(crate) fn emote_event_outbound( coord: &Coordinator, self_guid: u64, @@ -643,7 +582,6 @@ pub(crate) fn emote_event_outbound( return Vec::new(); } } - // target_guid == 0 → untargeted emote (the client sends 0 when nothing is selected). let target_name = if row.target_guid != 0 { let guard = coord.0.coord(); let db = &guard.conn.db; @@ -679,12 +617,6 @@ pub(crate) fn emote_event_outbound( out } -/// The SAY/YELL proximity gate: does a speaker at `(speaker_map, speaker_instance, -/// speaker_x, speaker_y)` reach a listener at `(listener_map, listener_instance, listener_x, -/// listener_y)` within `range_sq` (already squared — pass `SAY_RANGE_SQ`/`YELL_RANGE_SQ`)? Different -/// maps or different instances never hear each other (also guards the distance arithmetic against -/// comparing positions on unrelated maps). Pure — no I/O — so it is the ONE tested source of truth -/// for `on_chat`'s range gate. #[allow(clippy::too_many_arguments)] pub(crate) fn chat_in_range( speaker_map: u32, @@ -705,7 +637,6 @@ pub(crate) fn chat_in_range( dx * dx + dy * dy <= range_sq } -/// Show an Instance Removal countdown to its owner on the callback's Shard. pub(crate) fn instance_removal_started( view: &Arc, shard: ShardId, @@ -724,8 +655,6 @@ pub(crate) fn instance_removal_started( }); } -/// Hide an ended Instance Removal countdown, canceled or expired, from its owner on the callback's -/// Shard. pub(crate) fn instance_removal_ended(view: &Arc, shard: ShardId, row: &InstanceRemoval) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { return; @@ -736,8 +665,6 @@ pub(crate) fn instance_removal_ended(view: &Arc, shard: ShardId, row: }); } -/// `SMSG_RAID_GROUP_ONLY` with `timer_ms` left, 0 to hide the countdown, noting what the client -/// now shows. pub(crate) fn show_countdown(view: &WorldView, character_guid: u64, timer_ms: u32) -> Outbound { view.note_countdown(character_guid, timer_ms > 0); Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY( @@ -745,10 +672,6 @@ pub(crate) fn show_countdown(view: &WorldView, character_guid: u64, timer_ms: u3 )) } -/// The timer a world entry sends: the time left of the countdown `due` on the Home Shard, or 0 to -/// hide a countdown the client was last shown when the Home Shard holds none. A Character that -/// left the Instance Pool through the portal reaches its new Home Shard this way (vmangos hides -/// with the same zero timer, vm:Player.cpp:18561-18569). `None` sends nothing. Pure. pub(crate) fn resident_countdown_ms( due: Option<&spacetimedb_sdk::ScheduleAt>, shown: bool, @@ -760,8 +683,6 @@ pub(crate) fn resident_countdown_ms( } } -/// Milliseconds left before `due` at `now_micros`, rounded up and never more than the full -/// countdown, so a Gateway clock behind the Module's never shows extra time. `None` once due. Pure. pub(crate) fn instance_removal_time_left_ms( due: &spacetimedb_sdk::ScheduleAt, now_micros: u64, @@ -781,9 +702,6 @@ pub(crate) fn instance_removal_time_left_ms( ) } -/// The world-entry Instance Removal packet for `character_guid`, read from its Home Shard `coord`: -/// a reconnect inside the countdown shows the time left, and an arrival without one hides a -/// countdown still on screen. See [`resident_countdown_ms`]. pub(crate) fn resident_instance_removal_outbound( view: &WorldView, coord: &Coordinator, @@ -805,9 +723,6 @@ pub(crate) fn resident_instance_removal_outbound( .collect() } -/// A Realm Chat Line landed → one `SMSG_MESSAGECHAT` per recipient with a World Session on this -/// Gateway, whatever Shard it plays on. The Module chose the audience. The only per-listener -/// filter here is the listener's own ignore list, and only for lines the Module marked ignorable. pub(crate) fn realm_chat_appeared(view: &WorldView, row: &RealmChatEvent) { let row = Arc::new(row.clone()); for &recipient in &row.recipients { @@ -833,8 +748,6 @@ pub(crate) fn realm_chat_appeared(view: &WorldView, row: &RealmChatEvent) { } } -/// A contact row changed on `shard` → the owner's ignore set or friend set, whichever list the row -/// names, if the owner plays on that Shard. pub(crate) fn contact_changed(view: &WorldView, shard: ShardId, row: &ContactEntry, present: bool) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.owner_guid)) else { return; @@ -846,10 +759,7 @@ pub(crate) fn contact_changed(view: &WorldView, shard: ShardId, row: &ContactEnt } } -/// Rebuild the ignore set AND the friend set of every viewer on `shard` from that Shard's contact -/// rows. A resubscribe replays inserts but never the deletes it missed, so the live callbacks alone -/// could keep a removed contact in force until relog. Runs on the Shard's pump, like its contact -/// callbacks. +// Resubscribe replays inserts but not missed deletes; rebuild both contact sets. pub(crate) fn replace_contact_sets(view: &WorldView, shard: ShardId, contacts: &[ContactEntry]) { let mut ignored: HashMap> = HashMap::new(); let mut friends: HashMap> = HashMap::new(); @@ -872,10 +782,6 @@ pub(crate) fn replace_contact_sets(view: &WorldView, shard: ShardId, contacts: & } } -/// The World Sessions that could possibly hear a friend online/offline notice about -/// `subject_guid`, on any Shard: every viewer that lists it as a friend, minus its own viewer. -/// Team is checked separately by each caller — one already knows it for free (the just-committed -/// `game_character` row), the other only after a read. pub(crate) fn friend_notice_candidates(view: &WorldView, subject_guid: u64) -> Vec> { view.all_viewers() .into_iter() @@ -924,27 +830,6 @@ pub(crate) fn character_home_changed( }); } -/// A Character logged in on this Shard → `FRIEND_ONLINE` to every same-team viewer, on any Shard, -/// who lists it as a friend (cm:CharacterHandler.cpp:856, cm:WorldSession.cpp:754, -/// cm:SocialMgr.cpp:263-292). -/// -/// Hooked to `game_character` (shard-local, inside `player_login`'s own transaction), never the -/// realm-wide Account Claim: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` BEFORE -/// `enter_world`/`player_login`, so a Claim-driven edge could fire for a Character that has not -/// entered the world yet, or never will if `enter_world` then refuses — carrying its stale -/// pre-login level and zone either way. This row IS the just-committed login state, so no further -/// read is needed: race/class/level/zone come straight off it, and Away Status is always `None` -/// (login clears it — nobody crosses a loading screen AFK). -/// -/// The edge is landing on a fresh, nonzero `session_start_micros`, not `online` flipping false → -/// true. `player_login` is the only writer that ever stamps `session_start_micros` to a real value; -/// every `persist_entity` call — a real logout AND a same-database transfer's `freeze_live_entity` -/// alike — zeroes it back out, but `freeze_live_entity` passes `set_offline: false` and so never -/// touches `online`. An abandoned, rolled-back transfer therefore leaves a Character `online: true` -/// with no live entity; a plain `old.online` check would never fire for that Character's real next -/// login, because `online` reads true on both sides of it. Marks the Character in this Gateway's -/// own `online_characters` set, the dedup [`account_claim_changed`] reads before it answers -/// FRIEND_OFFLINE for the same Character's later claim close. pub(crate) fn character_online_changed(view: &WorldView, old: &Character, new: &Character) { if new.session_start_micros == 0 || old.session_start_micros == new.session_start_micros { return; @@ -978,9 +863,6 @@ pub(crate) fn character_online_changed(view: &WorldView, old: &Character, new: & } } -/// Does an Account Claim row's old and new state close the OLD row's Character? `old` is `None` -/// for the account's first-ever claim, never a close. Reopening after a close, and a fresh open, -/// both answer `None` — that edge is [`character_online_changed`]'s, not this file's. pub(crate) fn claim_closed(old: Option<&AccountClaim>, new: &AccountClaim) -> Option { let old = old?; let replaced = old.generation != new.generation || old.character_guid != new.character_guid; @@ -988,14 +870,6 @@ pub(crate) fn claim_closed(old: Option<&AccountClaim>, new: &AccountClaim) -> Op (was_live && (replaced || new.closed)).then_some(old.character_guid) } -/// The departing Character's team for one Account Claim close — read with the local-cache-only -/// [`presence::character_identity_anywhere`], not the health-checked [`presence::of`]: the -/// Character has just stopped being reachable, so a Whereabouts read has nothing left to prove. -/// Computed ONCE per transition, by whichever candidate's job runs first (cached in the caller's -/// `OnceLock` for every other candidate), on that job's own writer thread, NEVER the shared pump: -/// this read can cross the network to another Shard, and the pump is the one thread every World -/// Session on this Shard depends on. `None` when no connected Shard currently knows the Character -/// at all: never guess a team, which could notify the wrong side. pub(crate) fn claim_close_team(coord: &Coordinator, subject_guid: u64) -> Option { let identity = crate::world::presence::character_identity_anywhere(coord, subject_guid) .ok() @@ -1003,15 +877,6 @@ pub(crate) fn claim_close_team(coord: &Coordinator, subject_guid: u64) -> Option Some(lyracore_shared::faction::team_for_race(identity.race)) } -/// An Account Claim closed on Realm-core → `FRIEND_OFFLINE` to every same-team viewer, on any -/// Shard, who lists the Character as a friend (cm:CharacterHandler.cpp:856, -/// cm:WorldSession.cpp:754, cm:SocialMgr.cpp:263-292) — but ONLY for a Character this Gateway -/// itself watched come online ([`WorldView::take_character_online`]). A claim can close without -/// `player_login` ever having run for it: `CMSG_PLAYER_LOGIN`'s handler calls `claim_session` -/// BEFORE `enter_world`, so a login that fails after the claim opens releases it on session -/// teardown with no Character having ever entered the world, and this must answer nothing for it. -/// `coord` is the world handle [`claim_close_team`] reads through — never the Realm-core handle, -/// which holds no Characters. pub(crate) fn account_claim_changed( view: &WorldView, coord: &Coordinator, @@ -1050,7 +915,6 @@ pub(crate) fn account_claim_changed( } } -/// Queue a Package System Message for its addressed World Session. pub(crate) fn system_message_appeared(view: &WorldView, row: &SystemMessageEvent) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; @@ -1070,9 +934,6 @@ pub(crate) fn system_message_appeared(view: &WorldView, row: &SystemMessageEvent }); } -/// A group/loot-roll/quest-share event landed → the kind-decoded packets to the row's RECIPIENT -/// and nobody else (`coord` feeds the QUEST_SHARE detail JOIN inside the job). The recipient gets -/// its rows of one transaction in the order the Module inserted them ([`GroupEventCursor::take`]). pub(crate) fn group_event_appeared( view: &WorldView, coord: &Coordinator, @@ -1105,7 +966,6 @@ pub(crate) fn group_event_appeared( packets.extend(crate::codec::social::subscriptions::group_event_outbound( &coord, self_guid, row, )); - // A party frame sets every member's online flag, so Member Stats start over behind it. if row.kind == lyracore_shared::group::event_kind::LIST { viewer.member_stats.forget_all(); } @@ -1114,8 +974,6 @@ pub(crate) fn group_event_appeared( }); } -/// A shard mirror delta is the point at which viewer-relative Loot Tag flags can be projected from -/// current membership without racing the realm event that requested the mirror write. pub(crate) fn group_member_mirror_changed( view: &WorldView, coord: &Coordinator, @@ -1131,9 +989,6 @@ pub(crate) fn group_member_mirror_changed( }); } -/// A text emote names no cell of its own; the row carries the actor's cell (stamped at insert -/// time), widened to the 25 yd text-emote range rather than the AOI box — the job's `chat_in_range` -/// gate is the exact filter, this only bounds the candidate set. pub(crate) fn emote_audience( view: &WorldView, shard: ShardId, @@ -1148,9 +1003,6 @@ pub(crate) fn emote_audience( ) } -/// A text emote landed → SMSG_TEXT_EMOTE + SMSG_EMOTE per viewer within 25 yd of the sender -/// (`emote_event_outbound`'s `chat_in_range` gate). Name resolve runs in the job against `coord`'s -/// cache. pub(crate) fn emote_appeared( view: &WorldView, coord: &Coordinator, @@ -1167,8 +1019,6 @@ pub(crate) fn emote_appeared( } } -/// Creature whispers select their recipient; zone speech reaches the shard's viewers before the -/// zone gate. Local speech uses the speaker's cell and the chat kind's distance. pub(crate) fn chat_audience(view: &WorldView, shard: ShardId, row: &ChatEvent) -> Vec> { if row.chat_type == lyracore_shared::chat::broadcast_chat::CREATURE_WHISPER { return view @@ -1189,9 +1039,6 @@ pub(crate) fn chat_audience(view: &WorldView, shard: ShardId, row: &ChatEvent) - view.cell_audience(shard, key, span, &[row.sender_guid, row.target_guid]) } -/// A say/yell line landed → SMSG_MESSAGECHAT per viewer, `chat_in_range`-gated inside the job -/// (`chat_event_outbound`: speaker echo, SAY/YELL radii, map+instance fence, coordinator-cache -/// endpoint lookups). pub(crate) fn chat_appeared( view: &WorldView, coord: &Coordinator, diff --git a/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs b/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs index f18026d12..bc3def49f 100644 --- a/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs +++ b/gateway/src/codec/social/subscriptions/account_claim_relay_tests.rs @@ -14,7 +14,6 @@ const HORDE: u32 = lyracore_shared::faction::TEAM_HORDE; const FRIEND: u64 = 10; -/// A registered viewer for `self_guid` on `shard`, already listing [`FRIEND`]. fn listener( view: &WorldView, shard: usize, @@ -57,9 +56,6 @@ fn claim(generation: u64, character_guid: u64, closed: bool) -> AccountClaim { } } -/// A `game_character` row with only the columns this relay reads set to something other than -/// zero, the rest at whatever a fresh row would have. `session_start_micros` is `player_login`'s -/// own stamp — 0 outside a session, a real timestamp inside one. fn character_row( guid: u64, race: u8, @@ -119,43 +115,32 @@ fn character_row( } } -/// One case per bullet in `claim_closed`'s doc comment, rows written by hand. #[test] fn claim_closed_covers_every_bullet() { - // Inserted open (the account's first-ever claim): not a close. assert_eq!(claim_closed(None, &claim(1, FRIEND, false)), None); - // A renewal — only `expires_micros` moves: not a close. let old = claim(1, FRIEND, false); let mut renewed = old.clone(); renewed.expires_micros += 1; assert_eq!(claim_closed(Some(&old), &renewed), None); - // Closed (logout): the Character stops being live. let old = claim(1, FRIEND, false); let closed = claim(1, FRIEND, true); assert_eq!(claim_closed(Some(&old), &closed), Some(FRIEND)); - // Reopened after being closed (a fresh login, a new generation): not a close. let old = claim(1, FRIEND, true); let reopened = claim(2, FRIEND, false); assert_eq!(claim_closed(Some(&old), &reopened), None); - // Replaced while live, by a DIFFERENT Character (a relogin as someone else on the same - // Account without an intervening close): the OLD Character stops being live. let old = claim(1, FRIEND, false); let replaced = claim(2, 99, false); assert_eq!(claim_closed(Some(&old), &replaced), Some(FRIEND)); - // Reaped while already closed (the claim reaper closes an already-closed row, or closes it - // twice across a race): not a (new) close. let old = claim(1, FRIEND, true); let still_closed = claim(1, FRIEND, true); assert_eq!(claim_closed(Some(&old), &still_closed), None); } -/// `friend_notice_candidates` collects every friend-listing viewer on any Shard, and never the -/// subject's own viewer, whatever it lists. #[test] fn friend_notice_candidates_lists_every_friend_listing_viewer_but_never_the_subject() { let view = WorldView::new(true); @@ -165,7 +150,7 @@ fn friend_notice_candidates_lists_every_friend_listing_viewer_but_never_the_subj let (non_friend, _non_friend_rx) = listener(&view, 0, 23, ALLIANCE); non_friend.friends.lock().unwrap().clear(); let (subject, _subject_rx) = listener(&view, 0, FRIEND, ALLIANCE); - subject.friends.lock().unwrap().insert(FRIEND); // hypothetically lists itself + subject.friends.lock().unwrap().insert(FRIEND); let mut candidates: Vec = friend_notice_candidates(&view, FRIEND) .iter() @@ -189,8 +174,6 @@ fn character_online_changed_reaches_a_same_team_friend_with_the_just_committed_r let (non_friend, non_friend_rx) = listener(&view, 0, 22, ALLIANCE); non_friend.friends.lock().unwrap().clear(); - // Last logged out at level 21 in zone 12; this login already leveled up and landed the - // Character in zone 33 — the row's OWN fields, not a stale earlier read. let old = character_row(FRIEND, 1, 4, 21, 12, false, 0); let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); @@ -231,30 +214,24 @@ fn character_online_changed_reaches_a_same_team_friend_with_the_just_committed_r ); } -/// An UPDATE that is not a login — an already-online Character's level-up, a logout, or a -/// same-database transfer's freeze/rebuild with no real login in between — fires nothing and -/// marks nothing. #[test] fn character_online_changed_ignores_every_update_that_is_not_a_login() { let view = WorldView::new(true); let (_friend, friend_rx) = listener(&view, 0, 20, ALLIANCE); let old = character_row(FRIEND, 1, 4, 21, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); // a level-up, same session + let new = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); character_online_changed(&view, &old, &new); assert!(friend_rx.try_recv().is_err()); assert!(!view.take_character_online(FRIEND)); let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, false, 0); // a real logout + let new = character_row(FRIEND, 1, 4, 22, 33, false, 0); character_online_changed(&view, &old, &new); assert!(friend_rx.try_recv().is_err()); - // A same-database transfer's freeze zeroes session_start_micros without touching `online` - // (`freeze_live_entity` calls `persist_entity` with `set_offline: false`) — no login - // happened, so this must not fire either, even though `online` reads true on both sides. let old = character_row(FRIEND, 1, 4, 22, 33, true, 500_000); - let new = character_row(FRIEND, 1, 4, 22, 33, true, 0); // transfer freeze, still online + let new = character_row(FRIEND, 1, 4, 22, 33, true, 0); character_online_changed(&view, &old, &new); assert!(friend_rx.try_recv().is_err()); assert!(!view.take_character_online(FRIEND)); diff --git a/gateway/src/codec/social/subscriptions/audience_tests.rs b/gateway/src/codec/social/subscriptions/audience_tests.rs index e2d6deeb0..eb88667c0 100644 --- a/gateway/src/codec/social/subscriptions/audience_tests.rs +++ b/gateway/src/codec/social/subscriptions/audience_tests.rs @@ -55,7 +55,6 @@ fn at_micros(micros: i64) -> spacetimedb_sdk::ScheduleAt { )) } -/// The one `SMSG_RAID_GROUP_ONLY` timer a writer job produced. fn raid_group_only_timer(out: &[Outbound]) -> u32 { match out { [Outbound::One(ServerOpcodeMessage::SMSG_RAID_GROUP_ONLY(packet))] => { @@ -79,7 +78,6 @@ fn an_instance_removal_shows_and_hides_the_countdown_for_its_owner_only() { let anchor = CellKey::at(36, 7, 0, 0); view.add_viewer_on_shard(owner.clone(), anchor, 1); view.add_viewer_on_shard(other, anchor, 1); - // Due in 2100: far enough ahead that the time left is always the full countdown. let row = InstanceRemoval { scheduled_id: 3, scheduled_at: at_micros(4_102_444_800_000_000), @@ -103,8 +101,6 @@ fn an_instance_removal_shows_and_hides_the_countdown_for_its_owner_only() { ); } -/// A world-port drops the source viewer before the source Shard deletes the row, so the delete -/// reaches nobody. The countdown the client still shows is hidden at the next world entry. #[test] fn a_world_entry_hides_a_countdown_whose_row_is_gone() { let view = Arc::new(WorldView::new(true)); @@ -136,7 +132,6 @@ fn a_world_entry_hides_a_countdown_whose_row_is_gone() { ); } -/// The world-entry sweep and a replayed insert both show the time left, not a fresh 60 s. #[test] fn the_countdown_shows_the_time_left_rounded_up_and_nothing_once_due() { let due = 1_900_000_000_000_000; diff --git a/gateway/src/codec/social/subscriptions/packet_tests.rs b/gateway/src/codec/social/subscriptions/packet_tests.rs index 76a8f28c1..a104ef1eb 100644 --- a/gateway/src/codec/social/subscriptions/packet_tests.rs +++ b/gateway/src/codec/social/subscriptions/packet_tests.rs @@ -39,8 +39,6 @@ fn chat_event_target_reaches_every_addressable_creature_packet() { | wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterYell { target, .. } => assert_eq!(target.guid(), 77), - // `MonsterEmote` has no addressee field on the wire; its single guid names the - // speaker, so the addressed target is legitimately absent here. wow_world_messages::vanilla::SMSG_MESSAGECHAT_ChatType::MonsterEmote { monster, .. @@ -70,8 +68,6 @@ fn system_message_outbound_reuses_the_existing_system_chat_builder() { ); } -/// cm:Player.cpp:16571-16599: a player's say, yell and `/e` carry `GetChatTag()`, AFK 1 and -/// DND 2. A creature's line carries none, whatever its flags word holds. #[test] fn a_nearby_line_carries_the_player_speakers_away_tag() { let mut speaker = player_entity(); @@ -85,22 +81,15 @@ fn a_nearby_line_carries_the_player_speakers_away_tag() { assert_eq!(nearby_chat_tag(None), 0, "a speaker already gone"); } -// ---- the SAY/YELL range math ----------------------------------------------------------------- - #[test] fn chat_in_range_gates_by_distance() { - // `chat_in_range` only ever sees plain map/x/y — distance in WORLD SPACE. - let range_sq = 25.0f32 * 25.0; // SAY_RANGE_SQ, spelled out so the test pins the shape not the const - // 10 yards apart -> in range. + let range_sq = 25.0f32 * 25.0; assert!(chat_in_range( 0, 0, 100.0, 100.0, 0, 0, 105.0, 108.0, range_sq )); - // Exactly on the boundary (25yd) -> in range (inclusive, `<=`). assert!(chat_in_range( 0, 0, 100.0, 100.0, 0, 0, 125.0, 100.0, range_sq )); - // Just past the boundary -> out of range: this is the assertion the wire script's "beyond - // /say range but within view -> NO chat delivery" case pins end-to-end. assert!(!chat_in_range( 0, 0, 100.0, 100.0, 0, 0, 125.01, 100.0, range_sq )); @@ -108,14 +97,9 @@ fn chat_in_range_gates_by_distance() { #[test] fn chat_in_range_never_crosses_a_map_or_instance_boundary_regardless_of_distance() { - // Same coordinates, different map -> never in range (also guards the arithmetic: comparing - // x/y across two unrelated maps would otherwise produce a meaningless "distance"). assert!(!chat_in_range( 0, 0, 100.0, 100.0, 1, 0, 100.0, 100.0, 90_000.0 )); - // Same map, different instance (e.g. an open-world speaker vs. a dungeon-instance listener - // whose rows happen to share a database) -> never in range either (mirrors `peer_create_gate`'s - // instance isolation). assert!(!chat_in_range( 0, 0, 100.0, 100.0, 0, 1, 100.0, 100.0, 90_000.0 )); @@ -123,8 +107,6 @@ fn chat_in_range_never_crosses_a_map_or_instance_boundary_regardless_of_distance #[test] fn chat_in_range_yell_reaches_further_than_say_at_the_same_distance() { - // A speaker 200yd away: outside SAY range, inside YELL range — yell's wider radius must - // not be silently clipped to say range. assert!(!chat_in_range( 0, 0, @@ -149,8 +131,6 @@ fn chat_in_range_yell_reaches_further_than_say_at_the_same_distance() { )); } -/// The text-emote range shares SAY's 25yd radius (`ListenRange.TextEmote` = `ListenRange.Say` -/// = 25 in cm:mangosd.conf.dist.in:1092): the same boundary math, over the shared constant. #[test] fn chat_in_range_gates_a_text_emote_at_the_same_25yd_boundary_as_say() { let range_sq = lyracore_shared::chat::broadcast_chat::TEXT_EMOTE_RANGE_YD diff --git a/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs b/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs index 5537aced7..5383ca02d 100644 --- a/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs +++ b/gateway/src/codec/social/subscriptions/realm_chat_relay_tests.rs @@ -24,9 +24,6 @@ fn party_line(recipients: &[u64], ignorable: bool) -> RealmChatEvent { } } -/// Every queued packet for one viewer, jobs run in order. -/// The body of every packet queued for one viewer, jobs run in order. Each packet must be one -/// framed `SMSG_MESSAGECHAT`: size u16 big-endian, then opcode 0x0096 little-endian. fn received(rx: &Receiver) -> Vec> { let mut bodies = Vec::new(); while let Ok(outbound) = rx.try_recv() { @@ -48,17 +45,14 @@ fn received(rx: &Receiver) -> Vec> { bodies } -/// A PARTY line in Common from `SPEAKER` saying "form up", written out from cm:Chat.cpp:3637-3641 -/// rather than built with the codec the Relay calls: type, language, the sender guid twice, the -/// sized message with its terminator, then the chat tag. fn expected_line() -> Vec { - let mut body = vec![0x01]; // CHAT_MSG_PARTY - body.extend(7u32.to_le_bytes()); // LANG_COMMON + let mut body = vec![0x01]; + body.extend(7u32.to_le_bytes()); body.extend(SPEAKER.to_le_bytes()); body.extend(SPEAKER.to_le_bytes()); - body.extend(8u32.to_le_bytes()); // "form up" and its terminator + body.extend(8u32.to_le_bytes()); body.extend(b"form up\0"); - body.push(0); // CHAT_TAG_NONE + body.push(0); body } @@ -120,8 +114,6 @@ fn the_ignore_set_follows_its_owners_ignore_rows_on_the_owners_shard() { assert!(!viewer.ignores(SPEAKER)); } -/// The friend set follows the same rule as the ignore set, off the SAME contact rows and -/// callbacks, filtered by `is_ignore` the other way. #[test] fn the_friend_set_follows_its_owners_friend_rows_on_the_owners_shard() { let view = WorldView::new(true); diff --git a/gateway/src/codec/taxi.rs b/gateway/src/codec/taxi.rs index b85f1a544..7382a9754 100644 --- a/gateway/src/codec/taxi.rs +++ b/gateway/src/codec/taxi.rs @@ -1,6 +1,3 @@ -//! Vanilla taxi query replies. Gameplay has already selected the source and available client node -//! ids; this layer only maps those facts to the fixed 256-bit build-5875 wire representation. - use super::*; use wow_world_messages::vanilla::{ ActivateTaxiReply, SMSG_ACTIVATETAXIREPLY, SMSG_SHOWTAXINODES, SMSG_TAXINODE_STATUS, @@ -36,8 +33,6 @@ impl Default for TaxiActivationResult { } } -/// Map the module's stable primitive result onto the closest vanilla 5875 reply. Unknown future -/// values fail closed as a server error; refusal prose is diagnostic only and never enters policy. pub fn activate_taxi_reply(result_code: u8) -> ActivateTaxiReply { use lyracore_shared::constants::taxi_protocol as result; match result_code { @@ -63,8 +58,7 @@ pub fn build_activate_taxi_reply(result: TaxiActivationResult) -> SMSG_ACTIVATET } } -/// Pack one-based client node ids into exactly eight little-endian u32 words. Invalid ids are -/// ignored defensively; imported data and module replies already enforce 1..=256. +/// Node ids are one-based; the wire holds eight little-endian u32 words. pub fn build_taxi_mask(ids: impl IntoIterator) -> [u32; TAXI_MASK_WORDS] { let mut words = [0u32; TAXI_MASK_WORDS]; for id in ids { diff --git a/gateway/src/codec/taxi/subscriptions.rs b/gateway/src/codec/taxi/subscriptions.rs index 84f1dfb3a..3ddef6dce 100644 --- a/gateway/src/codec/taxi/subscriptions.rs +++ b/gateway/src/codec/taxi/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::{CellKey, ShardId}; @@ -11,8 +9,7 @@ use std::collections::HashSet; use std::sync::Mutex; use wow_world_messages::vanilla::Vector3d; -/// One module-owned passenger route to self and observers. Ordinary player motion deliberately -/// excludes self; taxi presentation must include it because the client is not driving the mover. +/// Taxi splines must include the owner because the client is not driving the mover. pub(crate) fn taxi_spline_outbound( created: &Mutex>, self_guid: u64, @@ -64,8 +61,7 @@ pub(crate) fn append_resident_taxi_after_create( created_outbound.extend(taxi_spline_outbound(created, self_guid, row)); } -/// Replay a resident passenger spline after the owner's self CREATE/login batch. The same helper -/// is used by reconnect recovery, keeping the CREATE-before-MONSTER_MOVE ordering explicit. +/// The owner's CREATE must precede its resident MONSTER_MOVE replay. pub(crate) fn resident_taxi_spline_outbound( coord: &Coordinator, viewer: &Viewer, @@ -84,8 +80,6 @@ pub(crate) fn resident_taxi_spline_outbound( taxi_spline_outbound(&viewer.created, viewer.self_guid, &row) } -/// A passenger spline is always sent to its owner. Nearby sessions receive it only after the -/// outbound visibility check confirms that the passenger already exists in their client world. pub(crate) fn taxi_spline(view: &WorldView, shard: ShardId, row: &TaxiPassengerSpline) { let key = CellKey::at(row.map_id, row.instance_id, row.grid_x, row.grid_y); let row = row.clone(); diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 64d3b1ee3..72ee5e285 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -1,14 +1,12 @@ -//! Unit and golden-vector tests for the wire codec. - use super::*; use std::time::{SystemTime, UNIX_EPOCH}; -// Only the loot byte-match test needs gtker's typed loot response (the runtime path is raw). + use wow_world_messages::vanilla::ServerMessage; use wow_world_messages::vanilla::{ EnvironmentalDamageType, MSG_MOVE_HEARTBEAT_Client, SMSG_LOOT_RESPONSE_LootMethod, TimerType, SMSG_LOOT_RESPONSE, }; -// Group loot methods: the vote-kind byte constants + wire RollVote enum. + use lyracore_shared::loot_roll::vote_kind; use wow_world_messages::vanilla::RollVote; @@ -29,7 +27,7 @@ fn warrior_entity() -> EntityView { level: 1, run_speed_mult_bp: 10_000, faction_template: 1, - unit_bytes_0: 1 | (1 << 8) | (1 << 24), // human warrior male rage + unit_bytes_0: 1 | (1 << 8) | (1 << 24), display_id: 49, native_display_id: 49, ..Default::default() @@ -73,14 +71,8 @@ fn character_create_restores_the_private_watched_faction() { } } -/// Reputation relay: a known faction id maps to its `Faction` enum value and the signed standing -/// survives the wire `u32` bit-cast (the client reads it back as `i32`); an unknown faction id yields -/// `None` so the relay skips silently instead of sending garbage. #[test] fn set_faction_standing_raw_sends_the_rep_index_not_the_faction_id() { - // 5875 layout: count(u32) + ReputationListID(u32) + standing(u32) = 12 bytes. The CRASH FIX: the middle - // field is the rep-list INDEX (Stormwind = 19), NOT the faction id (72). Sending 72 indexed past the - // client's 64-slot rep array → null deref → ERROR. The caller passes game_faction.reputation_index. let (opcode, body) = build_set_faction_standing_raw(19, 500).expect("a valid rep-index always builds"); assert_eq!(opcode, 0x0124); @@ -104,7 +96,6 @@ fn set_faction_standing_raw_sends_the_rep_index_not_the_faction_id() { 500, "standing survives" ); - // Negative standing (e.g. Hostile) bit-casts through the u32 unchanged. let (_, neg) = build_set_faction_standing_raw(19, -42000).unwrap(); assert_eq!( u32::from_le_bytes(neg[8..12].try_into().unwrap()) as i32, @@ -112,24 +103,16 @@ fn set_faction_standing_raw_sends_the_rep_index_not_the_faction_id() { ); } -/// The raw-send envelope is byte-identical to gtker's: build the SAME slot-0 aura VALUES update -/// both ways (gtker's typed `UpdatePlayer` slot-0 aura builder vs our `build_values_update_raw` -/// fed the equivalent mask) and assert the opcode + the whole body match. Pins the block-count / -/// has_transport / update-type / packed-guid envelope + the SMSG_UPDATE_OBJECT opcode so the raw -/// path can carry a multi-slot mask the typed builder can't express, without ever drifting from the -/// real wire shape. #[test] fn raw_values_body_matches_gtker_envelope() { use super::update_mask::{self, idx, AuraSlot}; let (guid, spell_id, level, flags) = (0x42u64, 6673u32, 1u8, 0x09u8); - // gtker's typed slot-0 aura VALUES update, with the same finalize→dirty_reset→re-set-only-changed - // discipline the live builders use so OBJECT_FIELD_TYPE never leaks (stripped by dirty_reset). let mut player = UpdatePlayer::builder() .set_unit_aura(spell_id as i32) .set_unit_auraflags(flags, 0, 0, 0) .set_unit_auralevels(level, 0, 0, 0) - .set_unit_auraapplications(0, 0, 0, 0) // stack count 1 is stored as 0 + .set_unit_auraapplications(0, 0, 0, 0) .finalize(); player.dirty_reset(); player.set_unit_aura(spell_id as i32); @@ -147,7 +130,7 @@ fn raw_values_body_matches_gtker_envelope() { .write_unencrypted_server(&mut gtker) .unwrap(); let gtker_opcode = u16::from_le_bytes([gtker[2], gtker[3]]); - let gtker_body = >ker[4..]; // strip [size:2 BE][opcode:2 LE] + let gtker_body = >ker[4..]; let mut mask = update_mask::UpdateMaskValues::new(); update_mask::write_auras( @@ -159,7 +142,7 @@ fn raw_values_body_matches_gtker_envelope() { level, }], ); - mask.set_u32(idx::UNIT_AURAAPPLICATIONS, 0); // gtker's slot-0 aura builder emits this present-zero word + mask.set_u32(idx::UNIT_AURAAPPLICATIONS, 0); let (our_opcode, our_body) = build_values_update_raw(guid, &mask); assert_eq!( @@ -205,8 +188,6 @@ fn movement_info_bytes_roundtrip() { fall_time: 0.0, }; let bytes = movement_info_to_bytes(&info).unwrap(); - // A vanilla MovementInfo with no flags is a fixed 28-byte body (flags u32 + time u32 + - // xyz 3×f32 + orientation f32 + fall_time f32). assert_eq!(bytes.len(), 28); let back = bytes_to_movement_info(&bytes).unwrap(); assert_eq!(back, info); @@ -217,9 +198,9 @@ fn name_query_response_maps_seeded_warrior() { let c = CharacterView { guid: 1, name: "Tester".into(), - race: 1, // Human - class: 1, // Warrior - gender: 0, // Male + race: 1, + class: 1, + gender: 0, ..Default::default() }; let r = build_name_query_response(&c).unwrap(); @@ -233,11 +214,6 @@ fn name_query_response_maps_seeded_warrior() { #[test] fn health_values_update_is_health_only_no_object_type() { - // Combat-crash regression (2026-06-16): a partial VALUES update must carry ONLY - // UNIT_FIELD_HEALTH (descriptor bit 22). The gtker builder seeds OBJECT_FIELD_TYPE (bit 2) - // dirty; `build_health_values` strips it via dirty_reset, because re-sending TYPE in a - // partial update crashes the 1.12 client (it overwrites the cached object type, stripping the - // PLAYER bit on the player's own object → null deref at 0x0060655A / null+0x110). let guid = (0xF130u64 << 48) | (620u64 << 24) | 1; let msg = build_health_values(guid, 20); match &msg.objects[0] { @@ -250,12 +226,6 @@ fn health_values_update_is_health_only_no_object_type() { } other => panic!("expected Object::Values, got {other:?}"), } - // The update mask is the LAST thing written, so the body tail is exactly: - // block_count(u8)=1 | mask word 0x00400000 LE (bit 22 only, NO bit 2) | value = health LE. - // A re-sent OBJECT_FIELD_TYPE would make the mask word 0x00400004 with a 0x09 value word - // ahead of the health word — this assertion fails in that case, guarding the regression. - // (We deliberately do NOT round-trip: gtker's strict reader rejects a TYPE-less mask, but the - // real 5875 client — the only consumer — requires TYPE's absence, like cmangos/vmangos.) let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); assert!( @@ -267,10 +237,6 @@ fn health_values_update_is_health_only_no_object_type() { #[test] fn resistance_values_is_unit_only_no_object_type() { - // Live armor and magic resistance must use a Unit VALUES mask carrying only - // UNIT_FIELD_RESISTANCES[0..=6], never - // re-sending OBJECT_FIELD_TYPE (bit 2, value 0x09 = OBJECT|UNIT) whose re-send strips the PLAYER bit - // → 5875 null+0x110 crash. Same dirty_reset discipline as build_health_values. let guid = (0xF130u64 << 48) | (620u64 << 24) | 1; let resistances = [0x1234u32, 1, 2, 3, 4, 5, 6]; let msg = build_resistance_values(guid, resistances); @@ -286,30 +252,15 @@ fn resistance_values_is_unit_only_no_object_type() { } let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); - // The update mask is the last thing written: descriptors 155..=161 span blocks 4 and 5. A leaked - // OBJECT_FIELD_TYPE would set bit 2 in block 0 and add a 0x09 value word ahead of the armor word, so - // this exact-tail assertion fails in that case (guarding the crash regression). assert!( buf.ends_with(&[ - 0x06, // block_count = 6 (the mask spans blocks 0..=5) - 0, 0, 0, 0, // block 0 (NO OBJECT_FIELD_TYPE bit 2) - 0, 0, 0, 0, // block 1 - 0, 0, 0, 0, // block 2 - 0, 0, 0, 0, // block 3 - 0x00, 0x00, 0x00, 0xf8, // block 4 = descriptors 155..=159 - 0x03, 0x00, 0x00, 0x00, // block 5 = descriptors 160..=161 - 0x34, 0x12, 0x00, 0x00, // armor value 0x1234 LE - 1, 0, 0, 0, // holy - 2, 0, 0, 0, // fire - 3, 0, 0, 0, // nature - 4, 0, 0, 0, // frost - 5, 0, 0, 0, // shadow - 6, 0, 0, 0, // arcane + 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x00, 0x00, 0x00, 0xf8, 0x03, + 0x00, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, + 0, 5, 0, 0, 0, 6, 0, 0, 0, ]), "resistance VALUES must carry only indices 155..=161; tail was {:02x?}", &buf[buf.len().saturating_sub(56)..] ); - // Belt-and-suspenders: the OBJECT_FIELD_TYPE value word (0x09 = OBJECT|UNIT) must be absent anywhere. let contains = |needle: &[u8]| buf.windows(needle.len()).any(|w| w == needle); assert!( !contains(&[0x09, 0x00, 0x00, 0x00]), @@ -320,12 +271,8 @@ fn resistance_values_is_unit_only_no_object_type() { #[test] fn dynamic_flags_values_is_unit_only_no_object_type() { - // Corpse render bit: the killing-blow dynamic_flags update must be a Unit VALUES - // mask carrying ONLY UNIT_DYNAMIC_FLAGS — never re-sending OBJECT_FIELD_TYPE (bit 2, value - // 0x09 = OBJECT|UNIT), which crashes the 1.12 client. Same dirty_reset discipline as - // build_health_values; this guards that the new builder didn't regress it. let guid = (0xF130u64 << 48) | (620u64 << 24) | 1; - let msg = build_dynamic_flags_values(guid, 0x20); // UNIT_DYNFLAG_DEAD + let msg = build_dynamic_flags_values(guid, 0x20); match &msg.objects[0] { Object::Values { guid1, mask1 } => { assert_eq!(guid1.guid(), guid); @@ -338,8 +285,6 @@ fn dynamic_flags_values_is_unit_only_no_object_type() { } let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); - // The dynamic_flags value word (0x20 LE) must be present; the OBJECT_FIELD_TYPE value word - // (0x09 LE) must be absent — its presence would mean TYPE leaked into the partial mask. let contains = |needle: &[u8]| buf.windows(needle.len()).any(|w| w == needle); assert!( contains(&[0x20, 0x00, 0x00, 0x00]), @@ -355,9 +300,7 @@ fn dynamic_flags_values_is_unit_only_no_object_type() { #[test] fn coinage_values_is_player_mask_without_object_type() { - // The live purse update must be a Player VALUES mask carrying ONLY - // PLAYER_FIELD_COINAGE — never re-sending OBJECT_FIELD_TYPE (0x09). Same dirty_reset guard. - let msg = build_coinage_values(1, 0x111); // 273 copper, a recognizable LE pattern + let msg = build_coinage_values(1, 0x111); match &msg.objects[0] { Object::Values { guid1, mask1 } => { assert_eq!(guid1.guid(), 1); @@ -385,12 +328,6 @@ fn coinage_values_is_player_mask_without_object_type() { #[test] fn aura_values_is_player_mask_without_object_type() { - // The aura tracer's buff-icon update goes out on the LIVE relay path (stdb → full_aura_mask + - // build_values_update_raw), the multi-slot encoder that replaced the slot-0-only typed builder. - // The mask must carry the aura fields but NEVER OBJECT_FIELD_TYPE (0x09), whose re-send crashes - // the 5875 client at null+0x110 — the make-or-break guard for the whole spell tier. Here the - // trap is structural (full_aura_mask never inserts index 2), and build_values_update_raw also - // debug-asserts its absence; this pins it on the wire bytes regardless. use super::update_mask::{self, idx, AuraSlot}; let win = |b: &[u8], needle: &[u8]| b.windows(needle.len()).any(|w| w == needle); @@ -411,9 +348,6 @@ fn aura_values_is_player_mask_without_object_type() { body ); - // OBJECT_FIELD_TYPE-leak check via the CLEAR path: with no auras present every aura field is 0, - // so any `09 00 00 00` word would be a leaked OBJECT_FIELD_TYPE (0x09 = OBJECT|UNIT). (We can't - // search the apply path for it — auraflags=0x09 legitimately produces the same value word.) let clear = update_mask::full_aura_mask(&[]); assert!( clear.get(idx::OBJECT_TYPE).is_none(), @@ -429,10 +363,6 @@ fn aura_values_is_player_mask_without_object_type() { #[test] fn loot_response_envelope_matches_gtker() { - // The RAW loot response must reuse gtker's exact money-only ENVELOPE (guid u64, loot_method - // u8, gold u32, count u8) — only the per-item bytes are ours (gtker's item codec is short). - // Pin it: our raw body (no items) must equal gtker's SMSG_LOOT_RESPONSE body byte-for-byte, - // and the opcode must match. (Same discipline as the iter-14 raw-aura envelope test.) let guid = (0xF130u64 << 48) | (620u64 << 24) | 1; let (opcode, body) = build_loot_response_raw(guid, 13, &[]); let gtker = ServerOpcodeMessage::SMSG_LOOT_RESPONSE(Box::new(SMSG_LOOT_RESPONSE { @@ -443,7 +373,6 @@ fn loot_response_envelope_matches_gtker() { })); let mut framed = Vec::new(); gtker.write_unencrypted_server(&mut framed).unwrap(); - // Strip gtker's 4-byte server header ([size:u16 BE][opcode:u16 LE]) to get the body. let gtker_opcode = u16::from_le_bytes([framed[2], framed[3]]); assert_eq!( opcode, gtker_opcode, @@ -458,11 +387,8 @@ fn loot_response_envelope_matches_gtker() { #[test] fn loot_response_item_layout_is_full_vanilla() { - // One item must serialize the FULL vanilla 22-byte layout (slot, id, count, display, 0,0, ty), - // NOT gtker's short 6-byte form. Spot-check the offsets + total length. let guid = 0xF130_0000_0000_0001; let (_op, body) = build_loot_response_raw(guid, 5, &[(0, 25, 2, 1542, 117)]); - // envelope = 8 (guid) + 1 (method) + 4 (gold) + 1 (count) = 14; one item = 22 → 36 total. assert_eq!( body.len(), 14 + 22, @@ -490,8 +416,6 @@ fn loot_response_item_layout_is_full_vanilla() { assert_eq!(it[21], 0, "slot_type = TypeAllowLoot"); } -// ---- Group loot methods — codec pins for the roll messages ---- - #[test] fn loot_start_roll_carries_the_countdown_and_item() { let m = build_loot_start_roll(0xF130_0000_0000_0007, 3, 1234, 60_000, 117); @@ -500,7 +424,6 @@ fn loot_start_roll_carries_the_countdown_and_item() { assert_eq!(m.item, 1234); assert_eq!(m.item_random_property_id, 117); assert_eq!(m.countdown_time, std::time::Duration::from_millis(60_000)); - // Round-trips through the real vanilla wire encode without error (opcode 0x02A1). let msg = ServerOpcodeMessage::SMSG_LOOT_START_ROLL(Box::new(m)); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); @@ -509,10 +432,6 @@ fn loot_start_roll_carries_the_countdown_and_item() { #[test] fn wire_roll_number_signals_pass_as_128_and_passes_a_real_roll_through() { - // vanilla's SMSG_LOOT_ROLL has NO separate auto_pass field (confirmed against the vendored - // crate — a TBC/WRATH-only addition); a PASS is instead `roll_number > 127` per the field's own - // doc comment. `[V]`: the exact sentinel byte (128 vs some other >127 value) is unverified - // against a real client — 128 matches cmangos/vmangos. assert_eq!(wire_roll_number(vote_kind::PASS, 0), 128); assert_eq!( wire_roll_number(vote_kind::NEED, 87), @@ -532,7 +451,6 @@ fn loot_roll_carries_the_voter_item_and_vote() { assert_eq!(m.roll_number, 87); assert_eq!(m.item_random_property_id, 117); assert_eq!(m.vote, RollVote::Need); - // A PASS vote's roll_number folds to the >127 sentinel regardless of the stored `rolled` (0). let pass = build_loot_roll(0xF130_0000_0000_0007, 3, 55, 1234, 0, vote_kind::PASS, 117); assert_eq!(pass.roll_number, 128); assert_eq!(pass.vote, RollVote::Pass); @@ -547,7 +465,6 @@ fn loot_roll_won_carries_the_winner_roll_and_winning_tier() { assert_eq!(m.winning_player.guid(), 55); assert_eq!(m.winning_roll, 96); assert_eq!(m.vote, RollVote::Need); - // A greed-only contest reports Greed — the tier is threaded, not hardcoded. let g = build_loot_roll_won(0xF130_0000_0000_0007, 3, 1234, 55, 42, vote_kind::GREED, 0); assert_eq!(g.vote, RollVote::Greed); } @@ -559,28 +476,20 @@ fn loot_master_list_carries_every_eligible_guid() { assert_eq!(m.guids[0].guid(), 10); assert_eq!(m.guids[1].guid(), 20); assert_eq!(m.guids[2].guid(), 30); - // Round-trips through the real vanilla wire encode without error (opcode 0x02A4). let msg = ServerOpcodeMessage::SMSG_LOOT_MASTER_LIST(Box::new(m)); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); assert!(!buf.is_empty()); } -// ---- Money-loot split — codec pin for the MONEY_SHARE relay ---- - #[test] fn loot_money_notify_carries_the_share_not_a_hardcoded_total() { - // The gateway's grouped-split relay decodes a `MONEY_SHARE` event payload and rebuilds - // `SMSG_LOOT_MONEY_NOTIFY` straight from it (`stdb/subscriptions.rs`) — pin that the wire - // builder's `amount` field is exactly the decoded share, for a value that would be wrong if the - // relay ever passed the corpse's TOTAL instead of the per-recipient share. let share = lyracore_shared::loot_roll::decode_money_share( &lyracore_shared::loot_roll::encode_money_share(17), ) .unwrap(); let m = build_loot_money_notify(share); assert_eq!(m.amount, 17); - // Round-trips through the real vanilla wire encode without error (opcode 0x0154). let msg = ServerOpcodeMessage::SMSG_LOOT_MONEY_NOTIFY(m); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); @@ -589,11 +498,8 @@ fn loot_money_notify_carries_the_share_not_a_hardcoded_total() { #[test] fn ghost_values_is_player_mask_without_object_type() { - // The ghost transition relay carries PLAYER_FLAGS (GHOST) + UNIT_FIELD_BYTES_1 only, - // and must NOT re-send OBJECT_FIELD_TYPE (0x09) — same dirty_reset discipline as the other - // partial-VALUES builders (re-sending TYPE crashes the 5875 client). let guid = 1; - let msg = build_player_flags_values(guid, 0x10, 0x0100_0000); // PLAYER_FLAGS_GHOST + vis-ghost byte 3 + let msg = build_player_flags_values(guid, 0x10, 0x0100_0000); match &msg.objects[0] { Object::Values { guid1, mask1 } => { assert_eq!(guid1.guid(), guid); @@ -626,9 +532,6 @@ fn ghost_values_is_player_mask_without_object_type() { #[test] fn corpse_create_is_a_corpse_object_with_race_in_bytes1() { - // The crash regression: the client null-derefs if CORPSE_FIELD_BYTES_1 doesn't carry - // the RACE in byte 1. The module packs bytes_1 = 0 | race<<8 | gender<<16 | skin<<24; this - // confirms the codec emits a CORPSE object and passes that word through with race in byte 1. let corpse = CorpseView { guid: (0xF101u64 << 48) | 1, owner_guid: 1, @@ -637,7 +540,7 @@ fn corpse_create_is_a_corpse_object_with_race_in_bytes1() { z: 83.0, orientation: 0.0, display_id: 49, - bytes_1: (1u32 << 8) | (1u32 << 16) | (5u32 << 24), // race=1, gender=1, skin=5 + bytes_1: (1u32 << 8) | (1u32 << 16) | (5u32 << 24), bytes_2: 0, is_bones: false, }; @@ -659,7 +562,6 @@ fn corpse_create_is_a_corpse_object_with_race_in_bytes1() { } let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); - // CORPSE_FIELD_BYTES_1 value word = 0x05010100 → LE [00, 01, 01, 05]: byte1=race(1), byte2=gender(1). assert!( buf.windows(4).any(|w| w == [0x00, 0x01, 0x01, 0x05]), "corpse BYTES_1 must carry race in byte 1 (not the player_bytes layout); buf {:02x?}", @@ -669,11 +571,6 @@ fn corpse_create_is_a_corpse_object_with_race_in_bytes1() { #[test] fn corpse_create_object_flags_bones_state_work_item_201() { - // A live body carries CORPSE_FLAG_UNK2 (0x04, what every real vanilla body sends); once the body - // has decayed to bones (`Corpse::is_bones`), the object should instead carry - // CORPSE_FLAG_BONE (0x01) so a real client renders bare bones instead of the dead body. - // UNVERIFIED-until-observed — no live client has confirmed this bit actually drives the bones - // render; the flag mapping is inferred from mangos' `CORPSE_FLAG_BONE` naming. let live = CorpseView { guid: 1, owner_guid: 1, @@ -732,9 +629,6 @@ fn corpse_query_found_carries_position_else_not_found() { #[test] fn resurrect_request_names_the_caster_and_flags_player() { - // The offer must name the CASTER (the client's " requests to resurrect you" prompt) - // and echo the caster's guid — the dead target answers CMSG_RESURRECT_RESPONSE against THIS guid, - // not their own. `player` is always true (the module only ever offers this to a player target). let msg = build_resurrect_request(42, "Priestly".to_string()); assert_eq!(msg.guid.guid(), 42); assert_eq!(msg.name, "Priestly"); @@ -743,9 +637,6 @@ fn resurrect_request_names_the_caster_and_flags_player() { #[test] fn levelup_values_is_player_mask_without_object_type() { - // The ding descriptor update (UNIT_FIELD_LEVEL + maxhealth + health + xp) must be a Player - // mask and must NOT re-send OBJECT_FIELD_TYPE (bit 2 / value 0x09) — same dirty_reset - // discipline as build_health_values (see that test for the byte-level guard on the pattern). let msg = build_levelup_values(1, 2, 75, 75, 0, 900, 0, 1000); match &msg.objects[0] { Object::Values { guid1, mask1 } => { @@ -757,9 +648,6 @@ fn levelup_values_is_player_mask_without_object_type() { } other => panic!("expected Object::Values, got {other:?}"), } - // OBJECT_FIELD_TYPE (bit 2) lives in mask block 0; its value word is 0x09 (OBJECT|UNIT) — none - // of our fields are 9, so its absence in the body proves TYPE wasn't re-sent (level=2, - // health/maxhealth=75, xp=0, next=900 never serialize as 0x09_00_00_00). let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); let type_word = [0x09u8, 0x00, 0x00, 0x00]; @@ -772,12 +660,6 @@ fn levelup_values_is_player_mask_without_object_type() { #[test] fn levelup_values_sets_character_points1_at_l10() { - // A mid-session ding to L10 must include PLAYER_CHARACTER_POINTS1=1 in the VALUES packet so the - // talent pane shows 1 free point immediately (without relog). Verify by looking for the value - // word [0x01, 0x00, 0x00, 0x00] in the wire body — at L10 it MUST be present. - // NOTE: we use level=10 (not 9) to avoid the collision where the level value itself serializes - // as [0x09, 0x00, 0x00, 0x00] (the same pattern as OBJECT_FIELD_TYPE). The OBJECT_FIELD_TYPE - // guard is already proven by levelup_values_is_player_mask_without_object_type at level=2. let msg_l10 = build_levelup_values(1, 10, 100, 100, 0, 1500, 0, 1500); let mut buf = Vec::new(); msg_l10.write_unencrypted_server(&mut buf).unwrap(); @@ -796,7 +678,6 @@ fn attack_start_carries_attacker_and_victim() { let msg = build_attack_start(player, chicken); assert_eq!(msg.attacker.guid(), player); assert_eq!(msg.victim.guid(), chicken); - // Round-trip through the wire codec. let server: ServerOpcodeMessage = ServerOpcodeMessage::SMSG_ATTACKSTART(Box::new(msg)); let mut buf = Vec::new(); server.write_unencrypted_server(&mut buf).unwrap(); @@ -856,8 +737,6 @@ fn attacker_state_update_is_one_physical_normal_swing() { let player = 1u64; let chicken = (0xF130u64 << 48) | (620u64 << 24) | 1; let msg = build_attacker_state_update(player, chicken, 7, 0, 0, 0); - // A landed normal swing must carry AffectsVictim (0x02) + VictimState NORMAL (1) so the - // 5875 client renders damage, not "MISS" (the symptom of VictimState=UNAFFECTED). assert_eq!(msg.hit_info, HitInfo::AffectsVictim); assert_eq!(msg.damage_state, 1); assert_eq!(msg.attacker.guid(), player); @@ -867,7 +746,6 @@ fn attacker_state_update_is_one_physical_normal_swing() { assert_eq!(msg.damages[0].spell_school_mask, 0); assert_eq!(msg.damages[0].damage_uint, 7); assert_eq!(msg.damages[0].damage_float, 7.0); - // Round-trip through the wire codec (the Vec + HitInfo must survive). let server = ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(msg)); let mut buf = Vec::new(); server.write_unencrypted_server(&mut buf).unwrap(); @@ -882,9 +760,6 @@ fn attacker_state_update_is_one_physical_normal_swing() { #[test] fn attacker_state_update_maps_hit_info_discriminant() { - // module hit_info: 0 normal, 1 crit, 2 miss → SMSG HitInfo enum + VictimState (damage_state). - // Landed hits get VictimState NORMAL (1); a miss gets UNAFFECTED (0) — matching mangos so the - // client shows the swing as a hit (normal/crit) vs. "MISS". let normal = build_attacker_state_update(1, 2, 7, 0, 0, 0); assert_eq!(normal.hit_info, HitInfo::AffectsVictim); assert_eq!(normal.damage_state, 1); @@ -896,8 +771,6 @@ fn attacker_state_update_maps_hit_info_discriminant() { assert_eq!(miss.hit_info, HitInfo::Miss); assert_eq!(miss.damage_state, 0); assert_eq!(miss.total_damage, 0); - // Dodge/parry (3/4): zero damage, "Dodge"/"Parry" from VictimState (2/3), no HitInfo flag - // (NormalSwing clears AFFECTS_VICTIM so no being-hit animation plays on the avoided swing). let dodge = build_attacker_state_update(1, 2, 0, 3, 0, 0); assert_eq!(dodge.hit_info, HitInfo::NormalSwing); assert_eq!(dodge.damage_state, 2); @@ -905,25 +778,19 @@ fn attacker_state_update_maps_hit_info_discriminant() { let parry = build_attacker_state_update(1, 2, 0, 4, 0, 0); assert_eq!(parry.hit_info, HitInfo::NormalSwing); assert_eq!(parry.damage_state, 3); - // Glancing (5): a landed hit with reduced damage — HitInfo::Glancing, VictimState NORMAL (1). let glancing = build_attacker_state_update(1, 2, 4, 5, 0, 0); assert_eq!(glancing.hit_info, HitInfo::Glancing); assert_eq!(glancing.damage_state, 1); assert_eq!(glancing.total_damage, 4); - // Block (7): a landed hit whose damage a shield reduced — AffectsVictim (the swing connected, so the - // being-hit animation plays) + VictimState BLOCK (4) + blocked_amount, the vanilla "Block N" path. let block = build_attacker_state_update(1, 2, 5, 7, 25, 0); assert_eq!(block.hit_info, HitInfo::AffectsVictim); assert_eq!(block.damage_state, 4); assert_eq!(block.total_damage, 5); assert_eq!(block.blocked_amount, 25); - // Ranged: a normal shot carries its spell id (75 Auto Shot) in spell_id so the client attributes - // it to the ability; HitInfo is identical to a melee normal hit (vanilla has no ranged HitInfo flag). let ranged = build_attacker_state_update(1, 2, 9, 0, 0, 75); assert_eq!(ranged.spell_id, 75); assert_eq!(ranged.hit_info, HitInfo::AffectsVictim); assert_eq!(ranged.total_damage, 9); - // The blocked_amount must survive the wire round-trip (a message-level u32, after damage_state). let server = ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(block)); let mut buf = Vec::new(); server.write_unencrypted_server(&mut buf).unwrap(); @@ -938,8 +805,6 @@ fn attacker_state_update_maps_hit_info_discriminant() { #[test] fn create_object_builds_unit_for_a_creature() { - // A creature row (type Unit, no PLAYER bit) must yield ObjectType::Unit + UpdateMask::Unit, - // not the player descriptor block. let chicken = EntityView { guid: (0xF130u64 << 48) | (620u64 << 24) | 1, map_id: 0, @@ -951,7 +816,7 @@ fn create_object_builds_unit_for_a_creature() { level: 1, run_speed_mult_bp: 10_000, faction_template: 31, - unit_bytes_0: 0x0101, // dummy-but-valid Human/Warrior/Male/Mana + unit_bytes_0: 0x0101, display_id: 304, native_display_id: 304, ..Default::default() @@ -978,9 +843,6 @@ fn create_object_builds_unit_for_a_creature() { #[test] fn creature_create_carries_npc_flags() { - // A gossip+vendor NPC (UNIT_NPC_FLAGS 0x1 gossip | 0x4 vendor — vanilla 1.12 numbering, where - // VENDOR is 0x4, not the 0x80 of later cores) must surface those flags in the Unit mask so the - // client shows the gossip-eye / vendor cursor (item 6). The packet must also serialize end-to-end. let vendor = EntityView { guid: (0xF130u64 << 48) | (6741u64 << 24) | 1, map_id: 0, @@ -995,7 +857,7 @@ fn creature_create_carries_npc_flags() { unit_bytes_0: 0x0101, display_id: 2068, native_display_id: 2068, - npc_flags: 0x1 | 0x4, // gossip + vendor + npc_flags: 0x1 | 0x4, ..Default::default() }; let msg = build_create_object(&vendor, CreateKind::Peer, &[], &[]).unwrap(); @@ -1011,7 +873,6 @@ fn creature_create_carries_npc_flags() { } other => panic!("expected a Unit CreateObject2, got {other:?}"), } - // A player carries no NPC flags (npc_flags 0; the player arm never sets the field). let player = build_create_object(&warrior_entity(), CreateKind::SelfPlayer, &[], &[]).unwrap(); match &player.objects[0] { Object::CreateObject2 { @@ -1088,7 +949,7 @@ fn self_player_create_object_carries_player_controlled_unit_flag_on_the_wire() { #[test] fn create_object_uses_entity_run_speed_multiplier() { let mut e = warrior_entity(); - e.run_speed_mult_bp = 30_000; // `.speed 3` survives a destination-shard entity rebuild + e.run_speed_mult_bp = 30_000; let msg = build_create_object(&e, CreateKind::SelfPlayer, &[], &[]).unwrap(); match &msg.objects[0] { Object::CreateObject2 { movement2, .. } => { @@ -1111,9 +972,6 @@ fn create_object_uses_entity_run_speed_multiplier() { #[test] fn self_and_peer_create_carry_the_self_flag_on_only_the_self_side() { - // Was WEAK (only asserted `me != peer`, never which side carries SELF). The movement SELF flag - // is what tells the client "this is MY character" (drives the local-player camera/controls); a - // peer create must NOT carry it, or the client would fight over which object is itself. let e = warrior_entity(); let me = build_create_object(&e, CreateKind::SelfPlayer, &[], &[]).unwrap(); let peer = build_create_object(&e, CreateKind::Peer, &[], &[]).unwrap(); @@ -1129,16 +987,11 @@ fn self_and_peer_create_carry_the_self_flag_on_only_the_self_side() { !self_flag(&peer), "a Peer create must NOT carry the SELF flag" ); - // Only the movement SELF flag differs; the descriptor mask itself is unaffected. assert_ne!(me, peer); } #[test] fn warrior_create_carries_skills_and_serializes() { - // Skill lines give the spellbook its class tabs; Battle Shout (6673) lives in Fury (256), so - // without Fury the client throws SpellBookFrame.lua:342 "invalid spell slot". The existing - // CREATE tests only pattern-match, so SERIALIZE here to actually run the writer over the new - // PLAYER_SKILL_INFO words (a malformed skill mask would otherwise slip past the suite). let mut entity = warrior_entity(); entity.magic_resistances = [1, 2, 3, 4, 5, 6]; let msg = build_create_object(&entity, CreateKind::SelfPlayer, &[], &[]).unwrap(); @@ -1155,16 +1008,12 @@ fn warrior_create_carries_skills_and_serializes() { Some(SkillInfo::new(Skill::Fury, 0, 1, 1, 0, 0)), "Fury (256) must anchor the Battle Shout spellbook tab" ); - // All five base attributes (UNIT_FIELD_STAT0..4) must survive the serializer so the - // character sheet reads the cmangos curve values, not zero. let e = warrior_entity(); assert_eq!(p.unit_strength(), Some(e.strength as i32)); assert_eq!(p.unit_agility(), Some(e.agility as i32)); assert_eq!(p.unit_stamina(), Some(e.stamina as i32)); assert_eq!(p.unit_intellect(), Some(e.intellect as i32)); assert_eq!(p.unit_spirit(), Some(e.spirit as i32)); - // EFFECTIVE armor (UNIT_FIELD_RESISTANCES[0]) is read from `entity.effective_armor` (base + - // worn gear, folded at the self-login call site; the fixture has no gear → effective == base). assert_eq!(p.unit_normal_resistance(), Some(e.effective_armor as i32)); assert_eq!(p.unit_holy_resistance(), Some(1)); assert_eq!(p.unit_fire_resistance(), Some(2)); @@ -1196,9 +1045,6 @@ fn char_enum_maps_seeded_warrior() { assert_eq!(msg.characters[0].guid.guid(), 1); } -/// CMaNGOS enumerates Characters by ascending guid, and the client keeps the order the list -/// arrives in. The Store may return subscription-cache or cross-Shard probe order, which changes -/// between logins, so the message must order the list itself. #[test] fn char_enum_orders_the_characters_by_ascending_guid() { let view = |guid: u64| CharacterView { @@ -1246,20 +1092,16 @@ fn movement_relay_roundtrips_under_same_opcode() { match ServerOpcodeMessage::read_unencrypted(&mut buf.as_slice()).unwrap() { ServerOpcodeMessage::MSG_MOVE_START_FORWARD(m) => { - assert_eq!(m.guid.guid(), 7); // mover guid, packed on the wire + assert_eq!(m.guid.guid(), 7); assert_eq!(m.info.position.x, 1.0); assert_eq!(m.info.timestamp, 99); } other => panic!("expected relayed MSG_MOVE_START_FORWARD, got {other}"), } - // A non-relayed opcode yields nothing. assert!(build_movement_relay(0x9999, 7, info).is_none()); } -/// Every `MovementInfo` shape the wire can carry: the plain 28-byte block, plus each optional block -/// the flags can switch on (they change the body LENGTH and ORDER, which is exactly what a -/// memcpy-based relay would get wrong if the layout assumption were false). #[cfg(test)] fn movement_info_cases() -> Vec<(&'static str, MovementInfo)> { use wow_world_messages::vanilla::{ @@ -1342,16 +1184,11 @@ fn movement_info_cases() -> Vec<(&'static str, MovementInfo)> { ] } -/// Non-transport movement agrees with the typed codec across all supported opcodes and -/// packed mover guids. Benilla checks transport movement independently. #[test] fn raw_movement_relay_is_byte_identical_to_the_typed_path() { - // 1 = one low byte; 0x0100 = one HIGH byte (a zero low byte must be skipped, not written); - // u64::MAX = all eight; 0 = the empty bit pattern; the last is a realistic player guid. const GUIDS: &[u64] = &[1, 0x0100, u64::MAX, 0, 0x0000_00F3_0000_0042]; let mut compared = 0usize; for (label, info) in movement_info_cases() { - // The bytes the module stores are produced by exactly this call (`SessionStore::movement_update`). let stored = movement_info_to_bytes(&info).unwrap(); for &opcode in movement_opcodes::SLICE_MOVE_OPCODES { for &guid in GUIDS { @@ -1359,8 +1196,6 @@ fn raw_movement_relay_is_byte_identical_to_the_typed_path() { .unwrap_or_else(|| panic!("typed relay missing for opcode 0x{opcode:03X}")); let mut framed = Vec::new(); typed.write_unencrypted_server(&mut framed).unwrap(); - // gtker's server framing: [size u16 BE][opcode u16 LE][body...] — the same 4 bytes - // `Outbound::Raw`'s writer re-creates from `(opcode, body.len())`. let typed_opcode = u16::from_le_bytes([framed[2], framed[3]]); let typed_body = &framed[4..]; assert_eq!( @@ -1390,11 +1225,6 @@ fn raw_movement_relay_is_byte_identical_to_the_typed_path() { ); } -/// The other half of the equivalence: the two builders must accept and REJECT the same opcodes. -/// Exhaustive over the whole u16 opcode space rather than a sample, because the raw path re-derives -/// the accepted set from `is_slice_move` while the typed path spells it out as match arms — two -/// lists that can drift, and a drift means a movement opcode silently stops relaying (peers freeze -/// mid-strafe) or a non-movement one gets framed as movement. #[test] fn raw_and_typed_movement_relays_accept_exactly_the_same_opcodes() { let info = movement_info_cases()[0].1.clone(); @@ -1408,10 +1238,6 @@ fn raw_and_typed_movement_relays_accept_exactly_the_same_opcodes() { } } -/// A row whose `movement_info` is too short to be a `MovementInfo` must produce NO packet. The typed -/// path got this for free (the decode failed and logged); the raw path memcpys, so without the -/// length floor a truncated block would be framed with a `size` the client trusts — it would read -/// past the body and desync the header stream for every subsequent packet. #[test] fn a_truncated_movement_block_is_never_framed() { let info = movement_info_cases()[0].1.clone(); @@ -1443,20 +1269,16 @@ fn destroy_object_carries_guid() { } } -// ---- Items --------------------------------------------------------------------------- - -/// The hand-authored "Worn Shortsword" template the gateway maps to the wire (mirrors the -/// module seed: a Poor one-hand sword, mainhand-equip, durability 20). fn worn_shortsword() -> ItemTemplateView { ItemTemplateView { entry: 25, - class: 2, // Weapon - subclass: 7, // Sword (one-hand) - sheath: 3, // the REAL dump value for entry 25 (hip posture on the live client) + class: 2, + subclass: 7, + sheath: 3, name: "Worn Shortsword".to_string(), display_id: 1542, - quality: 0, // Poor - inventory_type: 21, // WeaponMainHand + quality: 0, + inventory_type: 21, item_level: 2, required_level: 1, max_durability: 20, @@ -1466,7 +1288,6 @@ fn worn_shortsword() -> ItemTemplateView { damage_min: 1.0, damage_max: 3.0, delay_ms: 1900, - // No stat bonuses on a plain weapon — all stat fields remain zero. ..Default::default() } } @@ -1474,12 +1295,12 @@ fn worn_shortsword() -> ItemTemplateView { fn blackrock_gauntlets() -> ItemTemplateView { ItemTemplateView { entry: 1448, - class: 4, // Armor - subclass: 1, // Leather + class: 4, + subclass: 1, name: "Blackrock Gauntlets".to_string(), display_id: 7836, - quality: 1, // Common - inventory_type: 10, // INVTYPE_HAND + quality: 1, + inventory_type: 10, item_level: 20, required_level: 15, max_durability: 30, @@ -1488,17 +1309,15 @@ fn blackrock_gauntlets() -> ItemTemplateView { max_stack: 1, stat_armor: 105, stat_strength: 3, - bonding: 2, // Bind on Equip (BoE) — greens/blues at 1-10 bind when worn + bonding: 2, ..Default::default() } } -/// A hand-authored BoP (Bind on Pickup) template — a quest reward / starter-kit analogue — so the -/// `bonding=1` wire value is pinned independently of the BoE fixture above. fn bound_on_pickup_relic() -> ItemTemplateView { ItemTemplateView { entry: 90001, - class: 12, // Quest + class: 12, subclass: 0, name: "Ancient Relic".to_string(), display_id: 1542, @@ -1510,16 +1329,13 @@ fn bound_on_pickup_relic() -> ItemTemplateView { buy_price: 0, sell_price: 0, max_stack: 1, - bonding: 1, // Bind on Pickup (BoP) + bonding: 1, ..Default::default() } } #[test] fn item_query_response_maps_worn_shortsword_and_serializes() { - // The item query is NOT independently live-verifiable (the 1.12 client only queries an item - // it already holds an object for), so the typed mapping is pinned here: serialize + read back - // through the opcode so a malformed field (a bad enum cast, a wrong array len) is caught. let t = worn_shortsword(); let msg = build_item_query_response(t.entry, Some(&t)); let mut buf = Vec::new(); @@ -1537,15 +1353,11 @@ fn item_query_response_maps_worn_shortsword_and_serializes() { assert_eq!(found.inventory_type, InventoryType::WeaponMainHand); assert_eq!(found.max_durability, 20); assert_eq!(found.item_level, Level::new(2)); - // The per-item dump value rides through (wire 3) instead of the old blanket - // MainHand — gtker NAMES it LargeWeaponLeft, but the value is what the client reads. assert_eq!(found.sheathe_type, SheatheType::LargeWeaponLeft); assert_eq!(found.sheathe_type.as_int(), 3); assert_eq!(found.damages[0].damage_minimum, 1.0); assert_eq!(found.damages[0].damage_maximum, 3.0); assert_eq!(found.delay, 1900); - // RangedModRange: the vanilla-default 100% (NOT the struct Default of 0.0, which zeroed a - // ranged weapon's effective range → Auto Shot "out of range" everywhere). assert_eq!(found.ranged_range_modification, 100.0); } other => panic!("expected SMSG_ITEM_QUERY_SINGLE_RESPONSE, got {other}"), @@ -1554,9 +1366,6 @@ fn item_query_response_maps_worn_shortsword_and_serializes() { #[test] fn item_query_response_armor_and_stats_reach_wire() { - // Pins that stat_armor and equip stats are NOT zeroed out by the codec path — the fix for - // ItemTemplateView having dropped the stat/armor/block columns. Serialize + read back - // through the opcode so the full round-trip is verified. use wow_world_messages::vanilla::ItemStatType; let t = blackrock_gauntlets(); let msg = build_item_query_response(t.entry, Some(&t)); @@ -1566,7 +1375,6 @@ fn item_query_response_armor_and_stats_reach_wire() { ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(m) => { let found = m.found.expect("Blackrock Gauntlets must be found"); assert_eq!(found.armor, 105, "stat_armor must reach the wire"); - // First stat slot must carry Strength=3; remaining slots padded with Mana=0. assert_eq!( found.stats[0].stat_type, ItemStatType::Strength, @@ -1588,9 +1396,6 @@ fn item_query_response_armor_and_stats_reach_wire() { } } -/// Item binding: a BoP template's item query carries `bonding=1` (Bonding::PickUp) -/// over the wire — the "Done when" criterion for the feature, pinned as a serialize+read round-trip -/// like the other item-query tests, independent of the BoE fixture above. #[test] fn item_query_response_bop_template_carries_bonding_pickup() { let t = bound_on_pickup_relic(); @@ -1610,18 +1415,14 @@ fn item_query_response_bop_template_carries_bonding_pickup() { } } -/// Sheathe posture is a RAW passthrough, not a remap: a shield's dump byte (Dented Buckler, -/// entry 1166, `sheath=4`) must reach the wire as 4. gtker names that variant `LargeWeaponRight`, -/// which is why the assertion pins `as_int()` too — the name is noise, the byte is the contract. -/// Pairs with the entry-25 (`sheath=3`) leg in `item_query_response_maps_worn_shortsword_and_serializes`. #[test] fn item_query_response_shield_carries_its_own_sheathe_byte() { let t = ItemTemplateView { entry: 1166, - class: 4, // Armor - subclass: 6, // Shield - inventory_type: 14, // Shield - sheath: 4, // the REAL dump value for entry 1166 + class: 4, + subclass: 6, + inventory_type: 14, + sheath: 4, name: "Dented Buckler".to_string(), ..worn_shortsword() }; @@ -1642,11 +1443,9 @@ fn item_query_response_shield_carries_its_own_sheathe_byte() { } } -/// The default (unbound) template — every item that predates these columns — must still carry -/// `bonding=0` (NoBind), baseline-safe: an unbound item's tooltip renders no binding line. #[test] fn item_query_response_default_bonding_is_no_bind() { - let t = worn_shortsword(); // bonding left at its ..Default::default() value (0) + let t = worn_shortsword(); assert_eq!(t.bonding, 0); let msg = build_item_query_response(t.entry, Some(&t)); let mut buf = Vec::new(); @@ -1660,18 +1459,15 @@ fn item_query_response_default_bonding_is_no_bind() { } } -/// A hand-authored resist ring exercising every one of these item-query columns at once: 6 resistances, spell -/// slots 3-5, the skill/reputation gates, maxcount/flags/page_text/startquest/bag_family. Mirrors -/// a real vanilla "... of Fire Resistance" ring. fn ring_of_fire_resistance() -> ItemTemplateView { ItemTemplateView { entry: 90002, - class: 4, // Armor - subclass: 0, // Miscellaneous (rings/trinkets/necks) + class: 4, + subclass: 0, name: "Ring of Fire Resistance".to_string(), display_id: 5001, - quality: 2, // Uncommon (green) - inventory_type: 11, // INVTYPE_FINGER + quality: 2, + inventory_type: 11, item_level: 15, required_level: 10, max_durability: 0, @@ -1685,20 +1481,20 @@ fn ring_of_fire_resistance() -> ItemTemplateView { shadow_res: 0, arcane_res: 0, spellid_3: 12345, - spelltrigger_3: 1, // on-equip + spelltrigger_3: 1, spellid_4: 12346, - spelltrigger_4: 2, // chance-on-hit + spelltrigger_4: 2, spellid_5: 12347, - spelltrigger_5: 0, // on-use - required_skill: 165, // Leatherworking (arbitrary nonzero marker) + spelltrigger_5: 0, + required_skill: 165, required_skill_rank: 150, required_reputation_faction: 69, required_reputation_rank: 3, max_count: 1, - item_flags: 0x200, // ItemFlag::WRAPPER bit — any nonzero marker + item_flags: 0x200, page_text: 999, start_quest: 777, - bag_family: 6, // Herbs + bag_family: 6, allowed_class: 0x8000_0002, allowed_race: 0x8000_0004, random_property: 509_0100, @@ -1706,10 +1502,6 @@ fn ring_of_fire_resistance() -> ItemTemplateView { } } -/// The "free gateway win" columns: the resistances, spells 3-5, skill/rep gates, and -/// maxcount/flags/page_text/startquest/bag_family the item-query response already had wire slots -/// for (previously hardcoded 0/default) now carry the template's real values end-to-end through a -/// serialize+read round-trip. #[test] fn item_query_response_carries_the_work_item_213_columns() { let t = ring_of_fire_resistance(); @@ -1768,12 +1560,9 @@ fn item_query_response_carries_the_work_item_213_columns() { } } -/// The default (all-zero) template — every item that predates these columns — keeps every new -/// item-query wire field at its default/empty value: baseline-safe, byte-identical to the -/// response shape before these columns existed. #[test] fn item_query_response_default_template_keeps_the_work_item_213_columns_zero() { - let t = worn_shortsword(); // every new field left at its ..Default::default() value (0) + let t = worn_shortsword(); let msg = build_item_query_response(t.entry, Some(&t)); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); @@ -1809,7 +1598,6 @@ fn item_query_response_unknown_entry_is_not_found() { msg.found.is_none(), "an unknown item must reply NotFound (found: None)" ); - // Must still serialize (a NotFound is a valid one-field reply). let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); assert!(!buf.is_empty()); @@ -1818,7 +1606,7 @@ fn item_query_response_unknown_entry_is_not_found() { #[test] fn item_create_object_is_item_typed_and_serializes() { let inst = ItemInstanceView { - guid: (0x4000u64 << 48) | (1 << 8) | 23, // HIGHGUID_ITEM | char-derived low | slot 23 + guid: (0x4000u64 << 48) | (1 << 8) | 23, entry: 25, owner_guid: 1, slot: 23, @@ -1894,8 +1682,6 @@ fn a_copied_letters_create_block_carries_its_item_text_id() { #[test] fn player_create_seeds_inventory_slot_descriptor() { - // A backpack item's guid must land in its inventory slot (ItemSlot::Inventory0 == 23) of the - // player's PLAYER_FIELD_INV_SLOT descriptors, and a NON-equipment slot carries NO visible item. let item_guid = (0x4000u64 << 48) | (1 << 8) | 23; let msg = build_create_object( &warrior_entity(), @@ -1929,9 +1715,6 @@ fn player_create_seeds_inventory_slot_descriptor() { #[test] fn skill_block_reads_learned_rows_override_and_append() { - // A learned row for a line already in the static class base (Defense 95) OVERRIDES it, a - // learned profession (Mining 186) APPENDS at its real current/max, an unknown line id is - // skipped, and the old Cooking fixture is GONE unless actually learned. let msg = build_create_object( &warrior_entity(), CreateKind::SelfPlayer, @@ -1968,11 +1751,8 @@ fn skill_block_reads_learned_rows_override_and_append() { #[test] fn create_object_carries_the_sheath_state_for_player_and_creature() { - // The CREATE is how a peer entering AOI range learns a unit's sheath state. Omit - // UNIT_FIELD_BYTES_2 and everyone who walks up to a player with a drawn sword sees them - // empty-handed until the next toggle. Byte 0 is the state; bytes 1-3 ride along untouched. let mut e = warrior_entity(); - e.unit_bytes_2 = 0xAB_CD_EF_01; // MELEE in byte 0, neighbours occupied + e.unit_bytes_2 = 0xAB_CD_EF_01; let msg = build_create_object(&e, CreateKind::SelfPlayer, &[], &[]).unwrap(); match &msg.objects[0] { Object::CreateObject2 { @@ -1986,7 +1766,6 @@ fn create_object_carries_the_sheath_state_for_player_and_creature() { other => panic!("expected a Player CreateObject2, got {other:?}"), } - // Same field on the Unit branch — a creature draws its weapon on engage too. let mut c = warrior_entity(); c.type_mask = lyracore_shared::constants::type_mask::CREATURE; c.unit_bytes_2 = 1; @@ -2006,8 +1785,6 @@ fn create_object_carries_the_sheath_state_for_player_and_creature() { #[test] fn player_create_equips_mainhand_renders_visible_item() { - // a weapon in the main-hand equipment slot (15) must set BOTH the inv-slot guid AND - // PLAYER_VISIBLE_ITEM[15] to the item ENTRY, so the 3D model renders the weapon. let item_guid = (0x4000u64 << 48) | (1 << 8) | 15; let msg = build_create_object( &warrior_entity(), @@ -2050,20 +1827,17 @@ fn gossip_message_echoes_guid_with_a_greeting_option() { Vec::new(), &menu(false, false), ); - assert_eq!(m.guid.guid(), 0xF130_0000_0000_0001); // echoes the NPC the player talked to - assert_eq!(m.title_text_id, GOSSIP_GREETING_TEXT_ID); // resolved via CMSG_NPC_TEXT_QUERY - // Guard the McBride fix: the npc_text id MUST be low/in-range. A high synthetic id (top byte 0xFF, - // the old 0xFFFF_FF01) makes the 5875 client silently drop the gossip packet (no GOSSIP_SHOW). + assert_eq!(m.guid.guid(), 0xF130_0000_0000_0001); + assert_eq!(m.title_text_id, GOSSIP_GREETING_TEXT_ID); const { assert!( GOSSIP_GREETING_TEXT_ID <= 0xFF_FFFF, "title_text_id must be a low, in-range npc_text id" ) }; - assert_eq!(m.gossips.len(), 1); // non-vendor: one clickable line so the window isn't empty + assert_eq!(m.gossips.len(), 1); assert_eq!(m.gossips[0].message, "Farewell."); assert!(!m.gossips[0].coded); - // A vendor NPC gets a "browse goods" option FIRST (index 0), then Farewell. let v = build_gossip_message( 0xF130_0000_0000_0001, GOSSIP_GREETING_TEXT_ID, @@ -2072,13 +1846,10 @@ fn gossip_message_echoes_guid_with_a_greeting_option() { ); assert_eq!(v.gossips.len(), 2); assert_eq!(v.gossips[0].id, 0); - assert_eq!(v.gossips[0].item_icon, 1); // vendor bag icon + assert_eq!(v.gossips[0].item_icon, 1); assert_eq!(v.gossips[1].message, "Farewell."); - assert!(m.quests.is_empty()); // a plain gossip NPC (no quests passed) is unchanged + assert!(m.quests.is_empty()); - // INNKEEPER bit numbering (cmangos 1.12): the gate reads `npc_flags & 0x80`. Innkeeper Farley(295) - // ships npc_flags=135; assert the corrected constant gates true while VENDOR (0x4) still passes — the - // old WRONG literal (0x10000) made `135 & 0x10000 == 0`, silently dropping the "make home" option. use lyracore_shared::constants::npc_flags; assert_eq!( npc_flags::INNKEEPER, @@ -2097,8 +1868,6 @@ fn gossip_message_echoes_guid_with_a_greeting_option() { "the VENDOR (0x4) bit still passes — no regression" ); - // Innkeeper "make home" lands at slot 0 on a non-vendor, and each id matches its position (the - // value the client echoes back). let inn = build_gossip_message( 0xF130_0000_0000_0001, GOSSIP_GREETING_TEXT_ID, @@ -2107,7 +1876,6 @@ fn gossip_message_echoes_guid_with_a_greeting_option() { ); assert_eq!(inn.gossips[0].message, "Make this inn your home."); assert_eq!(inn.gossips[0].id, 0); - // Vendor + innkeeper → browse goods (0), make home (1), Farewell (2). let both = build_gossip_message( 0xF130_0000_0000_0001, GOSSIP_GREETING_TEXT_ID, @@ -2119,8 +1887,6 @@ fn gossip_message_echoes_guid_with_a_greeting_option() { assert_eq!(both.gossips[1].id, 1); } -/// Byte-parity pin: an NPC with NO imported options must produce EXACTLY the same gossip window as -/// before the option-import feature existed — the flag-derived synthesis must never silently diverge. #[test] fn gossip_message_with_no_imports_is_byte_identical_to_pre_217() { let guid = 0xF130_0000_0000_0042u64; @@ -2130,7 +1896,7 @@ fn gossip_message_with_no_imports_is_byte_identical_to_pre_217() { Vec::new(), &gossip_menu_options(Vec::new(), true, true), ); - assert_eq!(m.gossips.len(), 3); // vendor + innkeeper + Farewell, exactly as pre-import + assert_eq!(m.gossips.len(), 3); assert_eq!(m.gossips[0].message, "I'd like to browse your goods."); assert_eq!(m.gossips[1].message, "Make this inn your home."); assert_eq!(m.gossips[2].message, "Farewell."); @@ -2139,8 +1905,6 @@ fn gossip_message_with_no_imports_is_byte_identical_to_pre_217() { #[test] fn gossip_message_renders_imported_options_verbatim_with_a_trailing_farewell() { use lyracore_shared::constants::gossip_option; - // An imported innkeeper NPC whose menu already covers both flags: browse goods + make-home + chat, - // rendered VERBATIM from the dump. Nothing is synthesized on top. let opt = |icon, text: &str, action| GossipOptionView { icon, text: text.into(), @@ -2172,9 +1936,6 @@ fn gossip_message_renders_imported_options_verbatim_with_a_trailing_farewell() { assert_eq!(m.gossips[3].id, 3); } -/// LIVE VICTIMS of the old imports-suppress-synthesis rule: Orphan Matron Nightingale (stock, no -/// vendor row in her menu) and Katie Hunter (innkeeper, no bind row). The synthesized line must be -/// APPENDED to an imported menu that lacks it, and never duplicate one that has it. #[test] fn gossip_menu_options_appends_only_the_missing_flag_lines() { use lyracore_shared::constants::gossip_option; @@ -2184,18 +1945,15 @@ fn gossip_menu_options_appends_only_the_missing_flag_lines() { action: gossip_option::GOSSIP, ..Default::default() }; - // Vendor with stock but no vendor row → browse-goods appended, marked as synthesized. let merged = gossip_menu_options(vec![chat.clone()], true, false); assert_eq!(merged.len(), 2); assert_eq!(merged[0].row_id, 7, "the imported row keeps its identity"); assert_eq!(merged[1].text, "I'd like to browse your goods."); assert_eq!(merged[1].action, gossip_option::VENDOR); assert_eq!(merged[1].row_id, SYNTHESIZED_ROW_ID); - // Innkeeper with no bind row → make-home appended. let merged = gossip_menu_options(vec![chat.clone()], false, true); assert_eq!(merged.len(), 2); assert_eq!(merged[1].action, gossip_option::INNKEEPER); - // A menu that already carries the row is untouched — no duplicate line. let has_vendor = GossipOptionView { row_id: 9, text: "Let me see your goods.".into(), @@ -2231,19 +1989,15 @@ fn option_condition_holds_gates_on_quest_status_and_fails_open_for_unknown_types true, false )); - // An unrecognized cond_type must fail OPEN (never silently hide an option). assert!(option_condition_holds(99, false, false)); } #[test] fn cast_result_failed_body_and_reason_map() { - // The raw failure body is spell(u32 LE) + 0x02 (CAST_FAILED) + the CastFailureReason byte. assert_eq!( build_cast_result_failed(100, 0x4D), vec![100, 0, 0, 0, 0x02, 0x4D] ); - // The substring map covers the module's stable gate phrasings; unknown text falls back to - // DontReport (0x17 — silent reset, never a wrong red line). assert_eq!( cast_failure_reason_for("not enough power: have 0, need 150"), 0x4D @@ -2279,40 +2033,27 @@ fn cast_result_failed_body_and_reason_map() { #[test] fn spell_damage_log_renders_crit_and_breakdown() { - // A crit cast → hit_info encodes 0x2 (SPELL_HIT_TYPE_CRIT — gtker's AffectsVictim variant; the - // MELEE-named CriticalHit encodes 0x80, which the client ignores in THIS packet — the live - // find: a 22-damage Heroic Strike crit rendered plain). Breakdown fields propagate unchanged. let crit = build_spell_non_melee_damage_log(0x42, 0x7, 133, 30, 4, true, 5, 3); assert_eq!(crit.hit_info, HitInfo::AffectsVictim); - assert_eq!(crit.hit_info.as_int(), 0x2); // the wire truth this test actually guards + assert_eq!(crit.hit_info.as_int(), 0x2); assert_eq!(crit.damage, 30); assert_eq!(crit.resisted, 5); assert_eq!(crit.absorbed_damage, 3); - assert_eq!(crit.blocked, 0); // spells aren't blocked in vanilla - assert_eq!(crit.school, SpellSchool::Frost); // school index 4 = Frost - // A normal (non-crit) cast stays NormalSwing with a zeroed breakdown. + assert_eq!(crit.blocked, 0); + assert_eq!(crit.school, SpellSchool::Frost); let normal = build_spell_non_melee_damage_log(0x42, 0x7, 133, 20, 0, false, 0, 0); assert_eq!(normal.hit_info, HitInfo::NormalSwing); assert_eq!(normal.resisted, 0); assert_eq!(normal.absorbed_damage, 0); - assert_eq!(normal.school, SpellSchool::Normal); // index 0 → Normal/physical + assert_eq!(normal.school, SpellSchool::Normal); } #[test] fn gossip_message_body_is_exact_5875_layout() { - // Pin the EXACT 5875 SMSG_GOSSIP_MESSAGE body for a gossip with 1 option + 1 quest, proving the - // gtker typed message is byte-faithful to vanilla 1.12.1 (no RAW builder needed — unlike loot, - // gtker's vanilla GossipItem is the correct shape; the TBC BoxMoney/BoxText fields are absent). - // The McBride bug was the title_text_id VALUE (0xFFFF_FF01, an out-of-range npc_text id the client - // refuses), not the wire shape. This locks the layout so a future gtker bump that adds the TBC - // fields would fail here. Body (all LE), per the 1.12 gossip-menu wire layout: - // guid u64 | title_text_id u32 | gossip_count u32 | - // per gossip: id u32, icon u8, coded u8, message CString | - // quest_count u32 | per quest: quest_id u32, quest_icon u32, level u32, title CString let guid = 0xF130_0000_0000_0001u64; let msg = SMSG_GOSSIP_MESSAGE { guid: Guid::new(guid), - title_text_id: GOSSIP_GREETING_TEXT_ID, // Low, in-range npc_text id. + title_text_id: GOSSIP_GREETING_TEXT_ID, gossips: vec![GossipItem { id: 0, item_icon: 1, @@ -2320,13 +2061,12 @@ fn gossip_message_body_is_exact_5875_layout() { message: "Hi".to_string(), }], quests: vec![QuestItem { - quest_id: 0x29A, // 666 - quest_icon: 7, // available/turn-in icon + quest_id: 0x29A, + quest_icon: 7, level: Level::new(3), title: "Q".to_string(), }], }; - // Frame it the way the writer does, then strip the 4-byte server header to get the body. let mut framed = Vec::new(); ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(Box::new(msg)) .write_unencrypted_server(&mut framed) @@ -2339,20 +2079,18 @@ fn gossip_message_body_is_exact_5875_layout() { let body = &framed[4..]; let mut want: Vec = Vec::new(); - want.extend_from_slice(&guid.to_le_bytes()); // guid: u64 - want.extend_from_slice(&1u32.to_le_bytes()); // title_text_id: u32. - want.extend_from_slice(&1u32.to_le_bytes()); // amount_of_gossip_items: u32 - // gossip item: id u32 | icon u8 | coded u8 | message CString — NO BoxMoney/BoxText (those are TBC) - want.extend_from_slice(&0u32.to_le_bytes()); // id - want.push(1u8); // item_icon - want.push(0u8); // coded (false) - want.extend_from_slice(b"Hi\0"); // message CString - want.extend_from_slice(&1u32.to_le_bytes()); // amount_of_quests: u32 - // quest item: quest_id u32 | quest_icon u32 | level u32 (full, not u8) | title CString - want.extend_from_slice(&0x29Au32.to_le_bytes()); // quest_id - want.extend_from_slice(&7u32.to_le_bytes()); // quest_icon - want.extend_from_slice(&3u32.to_le_bytes()); // level: Level32 (a full u32, like SMSG_QUESTGIVER_QUEST_LIST) - want.extend_from_slice(b"Q\0"); // title CString + want.extend_from_slice(&guid.to_le_bytes()); + want.extend_from_slice(&1u32.to_le_bytes()); + want.extend_from_slice(&1u32.to_le_bytes()); + want.extend_from_slice(&0u32.to_le_bytes()); + want.push(1u8); + want.push(0u8); + want.extend_from_slice(b"Hi\0"); + want.extend_from_slice(&1u32.to_le_bytes()); + want.extend_from_slice(&0x29Au32.to_le_bytes()); + want.extend_from_slice(&7u32.to_le_bytes()); + want.extend_from_slice(&3u32.to_le_bytes()); + want.extend_from_slice(b"Q\0"); assert_eq!( body, @@ -2365,14 +2103,12 @@ fn gossip_message_body_is_exact_5875_layout() { #[test] fn npc_text_update_carries_the_greeting_in_slot_0() { - // None → generic fallback let t = build_npc_text_update(GOSSIP_GREETING_TEXT_ID, None); assert_eq!(t.text_id, GOSSIP_GREETING_TEXT_ID); - assert_eq!(t.texts.len(), 8); // fixed-size NpcText array - assert!(t.texts[0].texts[0].contains("Greetings")); // the greeting lives in slot 0... - assert_eq!(t.texts[0].probability, 1.0); // ...at probability 1.0 so the client picks it - assert_eq!(t.texts[1].probability, 0.0); // the other 7 slots are default/empty - // Some(imported view) → overrides the generic greeting, single-slot shape (back-compat). + assert_eq!(t.texts.len(), 8); + assert!(t.texts[0].texts[0].contains("Greetings")); + assert_eq!(t.texts[0].probability, 1.0); + assert_eq!(t.texts[1].probability, 0.0); let mut view = NpcTextView::default(); view.slots[0] = ( "Hey, citizen!".to_string(), @@ -2385,8 +2121,6 @@ fn npc_text_update_carries_the_greeting_in_slot_0() { assert_eq!(t2.texts[0].probability, 1.0); } -/// All 8 weighted slots ship verbatim (male/female/probability each), not just slot 0 — -/// the CLIENT does the weighted pick, so the server must never collapse this to a single string. #[test] fn npc_text_update_ships_every_populated_slot_with_its_own_probability() { let mut view = NpcTextView::default(); @@ -2411,19 +2145,12 @@ fn npc_text_update_ships_every_populated_slot_with_its_own_probability() { ["Watch yourself.".to_string(), "Watch yourself.".to_string()] ); assert_eq!(t.texts[3].probability, 0.4); - // Untouched slots stay default (empty text, 0 probability) — the client never selects them. assert_eq!(t.texts[1].probability, 0.0); assert!(t.texts[1].texts[0].is_empty()); } #[test] fn list_inventory_raw_body_is_exact_vanilla_layout() { - // SMSG_LIST_INVENTORY is RAW (gtker's typed message is the tbc/wrath shape, with an extra - // ExtendedCost u32 per item the 1.12 client doesn't read). Pin the opcode + the exact body for - // a vendor + one item, built by hand from the vanilla layout (all LE): - // vendor_guid u64 | count u8 | per item: muid u32 (1-based) | item_entry u32 | display u32 | - // max_count u32 (0 stored → 0xFFFF_FFFF unlimited) | buy_price u32 | max_durability u32 | - // buy_count u32 (=1). let vendor_guid = 0xF130_0000_0000_0001u64; let item = VendorItemView { item_entry: 25, @@ -2431,30 +2158,22 @@ fn list_inventory_raw_body_is_exact_vanilla_layout() { buy_price: 35, max_durability: 20, max_count: 0, - buy_count: 1, // → 0xFFFF_FFFF on the wire (unlimited) + buy_count: 1, }; let (opcode, body) = build_list_inventory_raw(vendor_guid, &[item]); assert_eq!(opcode, 0x019F, "SMSG_LIST_INVENTORY opcode (vanilla 5875)"); assert_eq!(opcode, SMSG_LIST_INVENTORY_OPCODE); let expected: Vec = vec![ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xF1, // vendor_guid u64 LE - 0x01, // count = 1 - 0x01, 0x00, 0x00, 0x00, // muid = 1 (1-based slot index) - 0x19, 0x00, 0x00, 0x00, // item_entry = 25 - 0x06, 0x06, 0x00, 0x00, // display_id = 1542 - 0xFF, 0xFF, 0xFF, 0xFF, // max_count: 0 → unlimited (0xFFFF_FFFF) - 0x23, 0x00, 0x00, 0x00, // buy_price = 35 - 0x14, 0x00, 0x00, 0x00, // max_durability = 20 - 0x01, 0x00, 0x00, 0x00, // buy_count = 1 + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xF1, 0x01, 0x01, 0x00, 0x00, 0x00, 0x19, 0x00, + 0x00, 0x00, 0x06, 0x06, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x23, 0x00, 0x00, 0x00, 0x14, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, ]; assert_eq!( body, expected, "raw SMSG_LIST_INVENTORY body must match the vanilla layout byte-for-byte" ); - // An empty stock is the vendor guid + a zero count + the error byte the client reads INSTEAD of - // an item block. Without that trailing byte it parses past the end and shows no window at all. let (_op, empty) = build_list_inventory_raw(vendor_guid, &[]); assert_eq!( empty, @@ -2465,8 +2184,6 @@ fn list_inventory_raw_body_is_exact_vanilla_layout() { #[test] fn list_inventory_raw_truncates_stock_to_255_items() { - // The wire count is a u8; feeding more stock lines than that must never silently wrap the count - // byte (300 % 256 == 44) — the slice is capped so the count and the item bytes always agree. let items: Vec = (0..300u32) .map(|i| VendorItemView { item_entry: i, @@ -2486,18 +2203,10 @@ fn list_inventory_raw_truncates_stock_to_255_items() { ); } -// ---- P1: values.rs OBJECT_FIELD_TYPE-absence pins (the null+0x110 crash class) ----------------- -// -// The remaining `build_*_values` builders share the same `dirty_reset` discipline as -// `build_health_values` above (finalize seeds OBJECT_FIELD_TYPE dirty; `dirty_reset` strips it; only -// the changed field(s) are re-applied). Each of these was previously unpinned. - #[test] fn unit_flags_values_is_unit_only_no_object_type() { - // The combat-indicator relay (UNIT_FLAG_IN_COMBAT toggling live) must ride a Unit VALUES mask - // carrying ONLY UNIT_FIELD_FLAGS — never re-sending OBJECT_FIELD_TYPE (the 5875 crash field). let guid = 7u64; - let flags = 0x0008_0000u32; // a recognizable flag value, distinct from the TYPE value word (0x09) + let flags = 0x0008_0000u32; let msg = build_unit_flags_values(guid, flags); match &msg.objects[0] { Object::Values { guid1, mask1 } => { @@ -2526,9 +2235,6 @@ fn unit_flags_values_is_unit_only_no_object_type() { #[test] fn power_values_selects_the_descriptor_by_power_byte() { - // power_b picks which UNIT_FIELD_POWERn the current-power value lands on, matching - // build_create_object's own power_b -> descriptor mapping (mana=0, rage=1, focus=2, energy=3, - // happiness=4). let cases: [(u8, i32); 5] = [(0, 111), (1, 222), (2, 333), (3, 444), (4, 555)]; for (power_b, cur) in cases { let msg = build_power_values(1, power_b, cur as u32); @@ -2625,7 +2331,7 @@ fn max_vitals_values_sets_maxhealth_and_selects_max_power_descriptor() { #[test] fn player_ammo_id_values_is_player_only_no_object_type() { - let msg = build_player_ammo_id_values(1, 2512); // an arrow entry + let msg = build_player_ammo_id_values(1, 2512); match &msg.objects[0] { Object::Values { mask1: UpdateMask::Player(p), @@ -2646,10 +2352,6 @@ fn player_ammo_id_values_is_player_only_no_object_type() { #[test] fn self_res_spell_values_sets_only_field_1224() { - // PLAYER_SELF_RES_SPELL is vanilla update field 1224 (cmangos-classic UpdateFields.h: - // PLAYER_AMMO_ID + 1). 1224 = 38 * 32 + 8, so the mask spans 39 blocks: 38 empty ones (block 0 - // carries no OBJECT_FIELD_TYPE bit 2), then bit 8 of block 38, then the one value word. Minor - // Soulstone's self-resurrect spell is 3026 = 0x0BD2. let msg = build_self_res_spell_values(1, 3026); let mut buf = Vec::new(); msg.write_unencrypted_server(&mut buf).unwrap(); @@ -2695,8 +2397,6 @@ fn player_xp_values_carries_xp_and_next_level_no_object_type() { #[test] fn item_values_clamps_zero_stack_count_to_one_and_carries_durability() { - // A stack count of 0 is nonsensical (an item object always represents at least one item) — - // clamp it to 1 rather than emit a wire value the client would render as an empty stack. let msg = build_item_values(1, 0, 15); match &msg.objects[0] { Object::Values { @@ -2712,7 +2412,6 @@ fn item_values_clamps_zero_stack_count_to_one_and_carries_durability() { } other => panic!("expected an Item Values update, got {other:?}"), } - // A real stack count survives unclamped. let stacked = build_item_values(1, 5, 15); match &stacked.objects[0] { Object::Values { @@ -2732,9 +2431,6 @@ fn item_values_clamps_zero_stack_count_to_one_and_carries_durability() { #[test] fn container_slot_values_computes_the_field_index_from_the_bag_slot() { - // CONTAINER_FIELD_SLOT_1 = 50, each slot spans 2 field indices (a u64 guid) — slot 2 must land - // at field 50 + 2*2 = 54. Cross-checked against the raw envelope directly, the same discipline - // as raw_values_body_matches_gtker_envelope. use super::update_mask::{self, idx}; let (bag_guid, item_guid) = (0x4000_0000_0000_0001u64, 0x4000_0000_0000_0099u64); let (opcode, body) = build_container_slot_values(bag_guid, 2, item_guid); @@ -2748,7 +2444,6 @@ fn container_slot_values_computes_the_field_index_from_the_bag_slot() { "slot 2 must set field index 54 (CONTAINER_FIELD_SLOT_1 + 2*2)" ); - // Passing 0 CLEARS the slot pointer — a real zero guid on the wire, not a no-op. let (_op2, cleared) = build_container_slot_values(bag_guid, 2, 0); let mut clear_mask = update_mask::UpdateMaskValues::new(); clear_mask.set_u64(idx::CONTAINER_FIELD_SLOT_1 + 2 * 2, 0); @@ -2772,7 +2467,6 @@ fn inv_slot_values_sets_the_slot_pointer_or_none_for_an_invalid_slot() { } other => panic!("expected a Player Values update, got {other:?}"), } - // Clearing a slot passes guid 0 — a real (zeroed) pointer, not a skipped update. let cleared = build_inv_slot_values(1, 23, 0).unwrap(); match &cleared.objects[0] { Object::Values { @@ -2783,7 +2477,6 @@ fn inv_slot_values_sets_the_slot_pointer_or_none_for_an_invalid_slot() { } other => panic!("expected a Player Values update, got {other:?}"), } - // An out-of-range slot ordinal degrades to None rather than panicking. assert!(build_inv_slot_values(1, 255, item_guid).is_none()); } @@ -2797,12 +2490,8 @@ fn aura_duration_carries_the_slot_and_remaining_window() { assert!(!buf.is_empty()); } -// ---- P1: entity.rs login_sequence_messages + build_create_object derived combat numbers -------- - #[test] fn login_sequence_dedupes_known_spells_and_builds_the_action_bar() { - // ATTACK_SPELL (6603) is always granted first; a duplicate of it (and of a learned id) must not - // appear twice in either the spellbook or the action bar. let msgs = login_sequence_messages( &warrior_entity(), &[6603, 100, 100, 200], @@ -2841,8 +2530,6 @@ fn login_sequence_dedupes_known_spells_and_builds_the_action_bar() { #[test] fn login_sequence_sends_verify_world_at_fresh_login_only() { - // A fresh login starts with SMSG_LOGIN_VERIFY_WORLD; a world-port re-entry must not carry it - // (a resend commands a second map load). Everything else is identical. let fresh = login_sequence_messages(&warrior_entity(), &[], &[], &[], WorldEntry::FreshLogin).unwrap(); assert!( @@ -2870,10 +2557,7 @@ fn login_sequence_sends_verify_world_at_fresh_login_only() { #[test] fn login_sequence_caps_the_action_bar_at_120_slots_but_not_the_spellbook() { - // 130 distinct learned spells (+ the always-granted ATTACK_SPELL) push the known-spell count past - // the fixed 120-slot action bar array; SMSG_INITIAL_SPELLS must still list every one of them, while - // SMSG_ACTION_BUTTONS truncates to the first 120 (no out-of-bounds write into the fixed array). - let learned: Vec = (1000..1130).collect(); // 130 unique ids + let learned: Vec = (1000..1130).collect(); let msgs = login_sequence_messages( &warrior_entity(), &learned, @@ -2916,9 +2600,6 @@ fn login_sequence_caps_the_action_bar_at_120_slots_but_not_the_spellbook() { #[test] fn login_sequence_folds_reputation_by_index_and_ignores_out_of_range_slots() { - // reputation_index must address the SAME Faction.dbc ReputationListID slot the live - // SET_FACTION_STANDING relay uses (0..63); an index >= 64 has no slot and must be dropped - // silently rather than panicking (a corrupt/import-drifted row must never crash login). let reps = [(5, 100, false), (63, -250, true), (64, 999, false)]; let msgs = login_sequence_messages(&warrior_entity(), &[], &reps, &[], WorldEntry::FreshLogin) .unwrap(); @@ -2939,8 +2620,6 @@ fn login_sequence_folds_reputation_by_index_and_ignores_out_of_range_slots() { factions.factions[63].standing as i32, -250, "a negative standing bit-cast through u32" ); - // The persisted At-War checkbox rides the flag byte — VISIBLE|AT_WAR (0x03) on the - // flagged slot, empty everywhere else (a false at_war never writes flags). assert!( factions.factions[63].flag.is_at_war(), "slot 63 carries AT_WAR" @@ -2961,14 +2640,6 @@ fn login_sequence_folds_reputation_by_index_and_ignores_out_of_range_slots() { } } -// ---- Imported action-bar rows vs. the synth fallback ---------------------------- - -/// The BYTE-IDENTICAL synth fallback pin: with NO `game_player_action` rows (pre-import, the state -/// every existing deployment is in today), the action bar must build EXACTLY as it did before this -/// feature — Attack (6603) in slot 0, then each known spell, zeros elsewhere. This is a regression -/// pin on `login_sequence_dedupes_known_spells_and_builds_the_action_bar` above (same fixture, spelled -/// out again here so a future edit to the imported-bar branch can't silently start also touching the -/// fallback branch it must leave alone). #[test] fn login_sequence_empty_player_actions_is_byte_identical_to_the_pre_212_synth() { let msgs = login_sequence_messages( @@ -2994,16 +2665,9 @@ fn login_sequence_empty_player_actions_is_byte_identical_to_the_pre_212_synth() assert!(bar.data[3..].iter().all(|&b| b == 0)); } -/// Once the character has IMPORTED `game_player_action` rows, the bar builds ENTIRELY from them — -/// packed as `action | (action_type << 24)` — and the synth (Attack + spellbook) is not consulted at -/// all, so a button the import omits (e.g. no button 0 row) stays 0, not auto-filled with Attack. #[test] fn login_sequence_builds_the_bar_from_imported_player_actions_packing_the_type_byte() { - let rows = [ - (0u8, 6603u32, 0u8), // button 0: Attack, type 0 (spell) — packs as the raw id - (1u8, 78u32, 0u8), // button 1: Heroic Strike, type 0 - (5u8, 1234u32, 4u8), // button 5: some non-spell action, type 4 — must pack into the high byte - ]; + let rows = [(0u8, 6603u32, 0u8), (1u8, 78u32, 0u8), (5u8, 1234u32, 4u8)]; let msgs = login_sequence_messages( &warrior_entity(), &[100, 200], @@ -3019,11 +2683,9 @@ fn login_sequence_builds_the_bar_from_imported_player_actions_packing_the_type_b _ => None, }) .expect("SMSG_ACTION_BUTTONS must be in the sequence"); - assert_eq!(bar.data[0], 6603); // action 6603 | (0 << 24) == 6603 + assert_eq!(bar.data[0], 6603); assert_eq!(bar.data[1], 78); assert_eq!(bar.data[5], 1234 | (4 << 24)); - // Slots the import didn't mention (incl. 2-4) stay zero — the synth's "100, 200" known spells are - // NEVER consulted once imported rows exist, confirming there's no merge between the two sources. for slot in [2usize, 3, 4, 6, 7, 119] { assert_eq!( bar.data[slot], 0, @@ -3032,8 +2694,6 @@ fn login_sequence_builds_the_bar_from_imported_player_actions_packing_the_type_b } } -/// A button index >= 120 (a corrupt/out-of-range import row) must be dropped, not panic on an -/// out-of-bounds array write — mirrors the reputation fold's out-of-range-slot handling above. #[test] fn login_sequence_drops_an_out_of_range_imported_button() { let rows = [(255u8, 999u32, 0u8)]; @@ -3206,9 +2866,6 @@ fn active_taxi_mount_is_present_in_the_player_create_mask() { } } -/// A LAND mount writes the same field with no taxi flag, and an observer's CREATE carries it (story -/// 5): a peer entering visibility must see the rider on the mount without waiting for a VALUES -/// update. `unit_flags` stays 0, so the client never renders a land mount as a flight. #[test] fn a_land_mount_display_is_present_in_the_peer_create_mask() { let mut entity = warrior_entity(); @@ -3233,10 +2890,6 @@ fn a_land_mount_display_is_present_in_the_peer_create_mask() { } } -/// The standalone land-mount VALUES builder (story 11) carries the display it is handed, and writes -/// an explicit 0 on dismount rather than omitting the field, or the client keeps the model. The -/// partial mask's freedom from `OBJECT_FIELD_TYPE` is asserted at the wire level by -/// `values::lint_tests::every_values_builder_serializes_without_the_object_type_bit`. #[test] fn mount_display_values_carries_the_display_it_is_given() { for display in [1147u32, 0] { @@ -3264,12 +2917,8 @@ fn display_values_carries_only_the_current_appearance() { } } -// ---- P2: visible_item_index slot-19 boundary (entity.rs) --------------------------------------- - #[test] fn visible_item_index_boundary_stops_after_equipment_slot_18() { - // Slot 18 (the last equipment slot) must render on the 3D model; slot 19 (the first equipped-bag - // slot) must NOT — it still gets an inv-slot pointer, just no VISIBLE_ITEM. let (g18, g19) = ((0x4000u64 << 48) | 18, (0x4000u64 << 48) | 19); let msg = build_create_object( &warrior_entity(), @@ -3300,8 +2949,6 @@ fn visible_item_index_boundary_stops_after_equipment_slot_18() { } } -// ---- P1: movement.rs relayed_move_opcode 17-opcode table loop ----------------------------------- - #[test] fn relayed_move_opcode_maps_all_seventeen_client_variants() { use movement_opcodes as m; @@ -3443,8 +3090,6 @@ fn relayed_move_opcode_maps_all_seventeen_client_variants() { } } -// ---- P2: teleport_ack / force_run_speed / monster_move spline flags (movement.rs) --------------- - #[test] fn teleport_ack_carries_target_position_with_no_movement_flags() { let msg = build_teleport_ack(7, 99, 1.0, 2.0, 3.0, 0.5); @@ -3463,8 +3108,6 @@ fn teleport_ack_carries_target_position_with_no_movement_flags() { assert!(!buf.is_empty()); } -// ---- Cross-map teleport wire pins (SMSG_TRANSFER_PENDING / SMSG_NEW_WORLD) -------- - #[test] fn cross_map_teleport_sends_transfer_pending_then_new_world_carrying_target_map_and_position() { let [pending, new_world] = build_cross_map_teleport(36, 10.0, 20.0, 30.0, 1.5).unwrap(); @@ -3560,9 +3203,6 @@ fn monster_move_run_flag_selects_the_run_spline_bit() { #[test] fn monster_move_facing_carries_the_angle_and_a_degenerate_single_point_spline() { - // a stand-and-swing creature turns to face its target without moving. The wire shape must - // be the `FacingAngle` variant (the client's ONLY source of a non-moving heading change), zero - // duration, and a single-point spline at the mover's own position (there IS no destination). let pos = Vector3d { x: 5.0, y: -3.0, @@ -3611,13 +3251,13 @@ fn taxi_move_writes_absolute_catmull_rom_points_byte_for_byte() { let (opcode, bytes) = build_taxi_move_raw(0xF00D, start, points.clone(), 12_345, 77).unwrap(); assert_eq!(opcode, 0x00DD); - let mut expected = vec![0x03, 0x0D, 0xF0]; // PackedGuid(0xF00D) + let mut expected = vec![0x03, 0x0D, 0xF0]; for value in [start.x, start.y, start.z] { expected.extend_from_slice(&value.to_le_bytes()); } expected.extend_from_slice(&77u32.to_le_bytes()); - expected.push(0); // MonsterMoveNormal - expected.extend_from_slice(&0x300u32.to_le_bytes()); // RUN_MODE | FLYING/Catmull-Rom + expected.push(0); + expected.extend_from_slice(&0x300u32.to_le_bytes()); expected.extend_from_slice(&12_345u32.to_le_bytes()); expected.extend_from_slice(&3u32.to_le_bytes()); for point in points { @@ -3628,8 +3268,6 @@ fn taxi_move_writes_absolute_catmull_rom_points_byte_for_byte() { assert_eq!(bytes, expected); } -// ---- P1: item.rs build_buy_failed 5-way map + build_item_create_object CONTAINER branch -------- - #[test] fn buy_failed_maps_module_err_strings_to_the_closest_buy_result() { let cases: [(&str, BuyResult); 7] = [ @@ -3649,9 +3287,6 @@ fn buy_failed_maps_module_err_strings_to_the_closest_buy_result() { } } -/// `lyracore_shared::bank::result`'s constants exist so the module can tag a refusal without a -/// dependency on the wire crate; they must never drift from gtker's own `BuyBankSlotResult::as_int` -/// numbering, or `parse_buy_bank_slot_result` silently relays the wrong client-visible result. #[test] fn bank_result_constants_match_gtker_buy_bank_slot_result() { use lyracore_shared::bank::result; @@ -3682,8 +3317,6 @@ fn buy_bank_slot_result_parses_the_leading_bracketed_code_not_the_prose() { BuyBankSlotResult::InsufficientFunds, ), ("[2] target is not a banker", BuyBankSlotResult::NotBanker), - // Prose alone (no bracket) must not accidentally match a keyword — the fallback is - // the generic refusal, not a guess at intent. ( "insufficient funds but no bracket tag", BuyBankSlotResult::NotBanker, @@ -3702,14 +3335,14 @@ fn buy_bank_slot_result_parses_the_leading_bracketed_code_not_the_prose() { #[test] fn item_create_object_bag_slots_build_a_container_with_num_slots() { let inst = ItemInstanceView { - guid: (0x4000u64 << 48) | (1 << 8) | 19, // equipped-bag slot + guid: (0x4000u64 << 48) | (1 << 8) | 19, entry: 5000, owner_guid: 1, slot: 19, stack_count: 1, durability: 0, max_durability: 0, - container_slots: 8, // an 8-slot bag + container_slots: 8, random_property_id: 509_0101, random_property_enchant_ids: [0; 3], item_text_id: 0, @@ -3743,12 +3376,8 @@ fn item_create_object_bag_slots_build_a_container_with_num_slots() { } } -// ---- P1: corpse.rs display_id==0 -> 49 fallback ------------------------------------------------- - #[test] fn corpse_create_object_falls_back_to_display_49_when_zero() { - // Defensive floor against a null-model crash: display_id 0 must never reach the wire — - // fall back to 49 (Human Male) rather than let the client dereference a null model. let corpse = CorpseView { guid: 1, owner_guid: 1, @@ -3769,7 +3398,6 @@ fn corpse_create_object_falls_back_to_display_49_when_zero() { } other => panic!("expected a Corpse CreateObject2, got {other:?}"), } - // A real display id passes through unchanged. let real = CorpseView { display_id: 304, ..corpse @@ -3786,8 +3414,6 @@ fn corpse_create_object_falls_back_to_display_49_when_zero() { } } -// ---- P2: creature query response (npc.rs) + questgiver_status echo ----------------------------- - #[test] fn creature_query_response_maps_names_and_flags_and_serializes() { let c = CreatureView { @@ -3795,7 +3421,7 @@ fn creature_query_response_maps_names_and_flags_and_serializes() { name: "Innkeeper Farley".to_string(), subname: "Innkeeper".to_string(), display_id: 2068, - creature_type: 7, // Humanoid + creature_type: 7, creature_family: 0, type_flags: 0x8, rank: 0, @@ -3829,7 +3455,6 @@ fn worn_item(slot: u8, guid: u64, entry: u32, enchantment: u32) -> ItemInstanceV #[test] fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { - // Equip: the VALUES partial must carry PLAYER_VISIBLE_ITEM[15] = the item entry. let sword = ItemInstanceView { random_property_id: 117, ..worn_item(15, 1, 25, 0) @@ -3851,7 +3476,6 @@ fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { } other => panic!("expected a Player Values update, got {other:?}"), } - // Unequip: entry 0 is a real (zeroed) write, not a skipped update. let cleared = build_visible_item_values(1, 15, &ItemInstanceView::default()).unwrap(); match &cleared.objects[0] { Object::Values { @@ -3870,7 +3494,6 @@ fn visible_item_values_render_and_clear_equipment_slots_only_work_item_087() { } other => panic!("expected a Player Values update, got {other:?}"), } - // Non-equipment slots (backpack 23, bags 19, bag contents 120+) are None — no model residue. assert!(build_visible_item_values(1, 23, &sword).is_none()); assert!(build_visible_item_values(1, 19, &sword).is_none()); assert!(build_visible_item_values(1, 120, &sword).is_none()); @@ -3949,23 +3572,6 @@ fn player_create_carries_each_worn_items_permanent_enchant_for_self_and_peer() { } } -// =========================================================================================== -// "SMSG_COMPRESSED_MOVES corrupts at crowd scale" — negative-finding regression pin against that -// report. -// -// The gateway does not construct `SMSG_COMPRESSED_MOVES` anywhere (nothing in `gateway/src` builds -// it, and `git log -S "COMPRESSED_MOVES" --all` finds no commit that ever did) — the movement paths -// actually in use are `SMSG_MONSTER_MOVE` (typed, one per creature leg) and the raw `MSG_MOVE_*` -// peer relay (`codec::build_movement_relay_raw`), both pinned at crowd scale in -// `world::tests::writer_thread_survives_movement_bursts_from_100_to_500_movers_without_desync`. -// -// This test instead pins `wow_world_messages` 0.3's OWN codec for the opcode the issue names, since -// the crash report's panic site (`smsg_compressed_moves.rs:28`, a flate2 `read_to_end().unwrap()`) -// lives entirely inside that dependency. Sweeping 1..3000 movers finds NO corruption — the codec -// round-trips cleanly the whole way; it only silently truncates its own u16 size field in the -// thousands-of-movers range (see the ignored `_finds_the_actual_gtker_wrap_threshold` case below), -// an order of magnitude past anything the report describes. So whatever produced the reported crash, it was -// not this codec misbehaving at 150-300 movers — that theory is pinned FALSE here, permanently. fn compressed_move_for(i: u64) -> wow_world_messages::vanilla::CompressedMove { use wow_world_messages::vanilla::{ CompressedMove, CompressedMove_CompressedMoveOpcode, MonsterMove, @@ -3995,10 +3601,6 @@ fn compressed_move_for(i: u64) -> wow_world_messages::vanilla::CompressedMove { } } -/// Encode `n` `CompressedMove` blocks via the crate's own `write_into_vec` (the deflate + size-prefix -/// path the panic trace lives inside), frame it as a real unencrypted packet, and decode it back -/// through `ServerOpcodeMessage::read_unencrypted` — the same dispatcher the gateway's own reader and -/// the headless wire client use. Returns `Ok(decoded_move_count)` or the decode error. fn compressed_moves_roundtrip(n: usize) -> Result { use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::SMSG_COMPRESSED_MOVES; @@ -4010,7 +3612,7 @@ fn compressed_moves_roundtrip(n: usize) -> Result { let mut body = Vec::new(); msg.write_into_vec(&mut body).map_err(|e| e.to_string())?; - let size = 2 + body.len(); // opcode(2) + body, matching gtker's own server framing + let size = 2 + body.len(); let mut full = Vec::new(); full.extend_from_slice(&(size as u16).to_be_bytes()); full.extend_from_slice(&(SMSG_COMPRESSED_MOVES::OPCODE as u16).to_le_bytes()); @@ -4025,9 +3627,6 @@ fn compressed_moves_roundtrip(n: usize) -> Result { #[test] fn compressed_moves_codec_roundtrips_cleanly_from_1_to_3000_movers() { - // 100 = the report's own "clean" control; 150/200/300 = the reported-bad range; 500/1000/3000 = - // margin well past it. Every one of these must decode back to exactly the mover count it encoded - // — if any of them failed, that would BE the reported encode bug, in the one place it could live. for n in [1usize, 10, 50, 100, 150, 200, 300, 500, 1000, 3000] { match compressed_moves_roundtrip(n) { Ok(decoded) => assert_eq!( @@ -4047,10 +3646,6 @@ fn compressed_moves_codec_roundtrips_cleanly_from_1_to_3000_movers() { threshold this crate's SMSG_COMPRESSED_MOVES::write_encrypted_server has — informational, \ not a regression guard for that report (that opcode is never sent at this scale, or at all)"] fn compressed_moves_codec_wraps_its_u16_size_field_only_far_past_reported_scale() { - // Documents WHERE the real (latent, currently-unreachable) limit is: `write_encrypted_server` - // computes `size = v.len().saturating_sub(2) as u16`, which silently truncates once the encoded - // packet exceeds 65535 bytes. This finds the crossover empirically rather than asserting a - // hand-derived number, so it stays correct if the wire shape of `CompressedMove` ever changes. let mut last_clean = 0usize; for n in (1000..=20000).step_by(1000) { match compressed_moves_roundtrip(n) { @@ -4072,9 +3667,6 @@ fn compressed_moves_codec_wraps_its_u16_size_field_only_far_past_reported_scale( panic!("expected the encoder to wrap its u16 size field somewhere in 1000..20000 movers"); } -// ---- SMSG_SET_PROFICIENCY: what the client may wield and wear ----------------------------------- - -/// Extract the typed proficiency message, or fail loudly. fn proficiency(msg: &ServerOpcodeMessage) -> &SMSG_SET_PROFICIENCY { match msg { ServerOpcodeMessage::SMSG_SET_PROFICIENCY(p) => p, @@ -4084,16 +3676,12 @@ fn proficiency(msg: &ServerOpcodeMessage) -> &SMSG_SET_PROFICIENCY { #[test] fn set_proficiency_pins_both_masks_on_the_wire() { - // A Warrior who has trained Plate Mail (750). Weapons: the class table admits every subclass up - // to the fishing pole except the wand. Armor: misc+cloth+leather+mail+shield, plus the plate the - // passive just unlocked. let msgs = build_set_proficiency_msgs(1, &[750]); let bytes = |msg: &ServerOpcodeMessage| { let mut buf = Vec::new(); proficiency(msg).write_unencrypted_server(&mut buf).unwrap(); buf }; - // size (u16 BE) = 5-byte body + 2-byte opcode; opcode 0x0127 (u16 LE); ItemClass; mask (u32 LE). assert_eq!( bytes(&msgs[0]), vec![0x00, 0x07, 0x27, 0x01, 2, 0xFF, 0xFF, 0x17, 0x00], @@ -4104,7 +3692,6 @@ fn set_proficiency_pins_both_masks_on_the_wire() { vec![0x00, 0x07, 0x27, 0x01, 4, 0x5F, 0x00, 0x00, 0x00], "armor mask = misc|cloth|leather|mail|plate|shield" ); - // Each bit is `1 << subclass`, so the mask reads back as the subclass set it stands for. let armor = proficiency(&msgs[1]).item_sub_class_mask; for subclass in [0u8, 1, 2, 3, 4, 6] { assert!(armor & (1 << subclass) != 0, "armor subclass {subclass}"); @@ -4122,14 +3709,11 @@ fn the_armor_mask_follows_the_trained_passives_per_class() { const MAIL: u32 = 1 << 3; const PLATE: u32 = 1 << 4; - // A Warrior wears mail from level 1 and plate only once 750 is trained. assert_eq!(mask(1, &[]) & MAIL, MAIL); assert_eq!(mask(1, &[]) & PLATE, 0); assert_eq!(mask(1, &[750]) & (MAIL | PLATE), MAIL | PLATE); - // A Mage wears cloth and misc, whatever passives it somehow holds. assert_eq!(mask(8, &[]), 0b11); assert_eq!(mask(8, &[750, 8737]), 0b11); - // The mail upgrade is the Hunter's, and it is the only tier it moves. assert_eq!(mask(3, &[]) & MAIL, 0); assert_eq!(mask(3, &[8737]) & MAIL, MAIL); } @@ -4176,15 +3760,8 @@ fn login_sequence_carries_both_proficiency_masks_with_the_initial_state() { } } -// ---- P1: entity.rs realm_datetime — dynamic SMSG_LOGIN_SETTIMESPEED clock --------------------- -// -// Expected packed fields below are worked calendar examples verified independently with -// `date -u`, not recomputed from `realm_datetime`'s own days-to-civil algorithm. Each Unix -// timestamp's day-count offset and weekday were cross-checked by hand. - #[test] fn realm_datetime_year_rollover() { - // 2026-12-31T23:59:00Z (a Thursday) rolls to 2027-01-01T00:00:00Z (a Friday) one minute later. let before = realm_datetime(1_798_761_540); assert_eq!(before.years_after_2000(), 26); assert_eq!(before.month(), Month::December); @@ -4208,7 +3785,6 @@ fn realm_datetime_year_rollover() { #[test] fn realm_datetime_month_rollover() { - // 2027-01-31T14:37:00Z (a Sunday) rolls to 2027-02-01T00:05:00Z (a Monday). let before = realm_datetime(1_801_406_220); assert_eq!(before.month(), Month::January); assert_eq!(before.month_day() + 1, 31); @@ -4230,8 +3806,6 @@ fn realm_datetime_month_rollover() { #[test] fn realm_datetime_leap_year_boundary() { - // 2028-02-29T06:15:00Z: 2028 is a leap year (divisible by 4, not by 100), so a 29th of - // February exists at all; it is a Tuesday. let dt = realm_datetime(1_835_417_700); assert_eq!(dt.years_after_2000(), 28); assert_eq!(dt.month(), Month::February); @@ -4243,8 +3817,6 @@ fn realm_datetime_leap_year_boundary() { #[test] fn realm_datetime_round_trips_through_display() { - // 2026-06-15T12:00:00Z: the date the removed hard-code intended but packed wrong (it fed 15 - // into the zero-based month_day field, so the client actually showed the 16th). A Monday. let dt = realm_datetime(1_781_524_800); assert_eq!(dt.weekday(), Weekday::Monday, "2026-06-15 is a Monday"); assert_eq!( @@ -4261,9 +3833,6 @@ fn realm_datetime_round_trips_through_display() { #[test] fn login_sequence_emits_a_current_realm_clock_not_the_old_hardcode() { - // World entry's 3rd message stays SMSG_LOGIN_SETTIMESPEED, now carrying today's date within - // a couple of minutes of now (proving the removed 2026-06-15 hard-code is gone) at vanilla's - // fixed timescale. let before_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -4291,9 +3860,6 @@ fn login_sequence_emits_a_current_realm_clock_not_the_old_hardcode() { .expect("SMSG_LOGIN_SETTIMESPEED must be in the sequence"); assert_eq!(settimespeed.timescale, 0.016_666_668); - // Bound the emitted value against the same real-clock window this test observed around the - // call — proving it tracks wall time, not that the packing algorithm is correct (the - // boundary tests above establish that against independently derived examples). let lower = realm_datetime(before_secs).as_int(); let upper = realm_datetime(after_secs + 120).as_int(); let emitted = settimespeed.datetime.as_int(); diff --git a/gateway/src/codec/trainer.rs b/gateway/src/codec/trainer.rs index 2594eb400..533ecc986 100644 --- a/gateway/src/codec/trainer.rs +++ b/gateway/src/codec/trainer.rs @@ -1,8 +1,5 @@ -//! Trainer window, purchase results, and learned-spell packets. - use super::*; -/// An offering and the Character facts used to render its training state. #[derive(Clone, Copy, Debug)] pub struct TrainerSpellView { pub spell_id: u32, @@ -10,20 +7,14 @@ pub struct TrainerSpellView { pub required_level: u8, pub player_level: u32, pub known: bool, - /// Skill line taught by this offering, or 0 for a spell or recipe. Includes professions, - /// weapon skills and riding, which need distinct trainer list types. pub learn_skill_line: u32, } -/// Build the trainer window. Profession learn rows select the recipe icon lookup in build 5875. -/// Known offerings are gray, offerings above the Character's level are red, and others are green. pub fn build_trainer_list( trainer_guid: u64, spells: &[TrainerSpellView], greeting: &str, ) -> SMSG_TRAINER_LIST { - // The importer supplies learn rows for each profession a trainer teaches. The packet type is - // distinct from the creature template type, which also labels weapon masters as tradeskills. let teaches_profession = spells.iter().any(|s| { matches!( Skill::try_from(s.learn_skill_line), @@ -71,7 +62,6 @@ pub fn build_trainer_list( } } -/// Build `SMSG_TRAINER_BUY_SUCCEEDED` — confirms the purchase; pair with [`build_learned_spell`]. pub fn build_trainer_buy_succeeded(trainer_guid: u64, spell_id: u32) -> SMSG_TRAINER_BUY_SUCCEEDED { SMSG_TRAINER_BUY_SUCCEEDED { guid: Guid::new(trainer_guid), @@ -79,9 +69,6 @@ pub fn build_trainer_buy_succeeded(trainer_guid: u64, spell_id: u32) -> SMSG_TRA } } -/// Build `SMSG_TRAINER_BUY_FAILED`. The caller picks `error` from the Module's typed Refusal; gtker -/// vanilla carries only 3 reasons, so several Refusals share Unavailable — cosmetic, since the client -/// gates the Learn button on the Green state from the list. pub fn build_trainer_buy_failed( trainer_guid: u64, spell_id: u32, @@ -94,8 +81,6 @@ pub fn build_trainer_buy_failed( } } -/// Build `SMSG_LEARNED_SPELL` — the live push after a buy so the spell appears on the action bar without -/// a relog (the login `SMSG_INITIAL_SPELLS` sync is login-only). pub fn build_learned_spell(spell_id: u32) -> SMSG_LEARNED_SPELL { SMSG_LEARNED_SPELL { id: spell_id } } @@ -131,7 +116,6 @@ mod tests { build_trainer_list(42, &spells, "Greetings") .write_unencrypted_server(&mut packet) .unwrap(); - // Four-byte server header, guid, then the list type and row count. assert_eq!(&packet[12..16], &2u32.to_le_bytes(), "skill line {line}"); assert_eq!(&packet[16..20], &2u32.to_le_bytes()); assert_eq!(&packet[20..24], &3984u32.to_le_bytes()); @@ -165,7 +149,7 @@ mod tests { player_level: 10, known: false, learn_skill_line: 0, - }, // learnable + }, TrainerSpellView { spell_id: 101, cost: 10, @@ -173,7 +157,7 @@ mod tests { player_level: 2, known: false, learn_skill_line: 0, - }, // too low + }, TrainerSpellView { spell_id: 102, cost: 10, @@ -181,7 +165,7 @@ mod tests { player_level: 10, known: true, learn_skill_line: 0, - }, // already known + }, ]; let msg = build_trainer_list(42, &v, "Greetings"); assert_eq!(msg.spells[0].state, TrainerSpellState::Green); @@ -189,7 +173,6 @@ mod tests { assert_eq!(msg.spells[2].state, TrainerSpellState::Gray); assert_eq!(msg.guid, Guid::new(42)); assert_eq!(msg.greeting, "Greetings"); - // boundary: at exactly required_level → learnable (Green) let edge = [TrainerSpellView { spell_id: 1, cost: 0, diff --git a/gateway/src/codec/trainer/subscriptions.rs b/gateway/src/codec/trainer/subscriptions.rs index edc4900d6..5ba66bbcd 100644 --- a/gateway/src/codec/trainer/subscriptions.rs +++ b/gateway/src/codec/trainer/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; @@ -7,10 +5,6 @@ use crate::stdb::world_view::*; use crate::world::Outbound; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -/// Skill pane: the shared-dispatch twin of the per-player skill relays -/// (insert and update ran the same body there too). Audience (owner only) resolved by the -/// owner-session lookup in `world_view::skill_changed`; the slot allocation writes the viewer's -/// OWN `skill_slots` — the same map the per-player leg captures, seeded from the login layout. pub(crate) fn relay_skill( viewer: &Viewer, skill_line: u32, @@ -34,9 +28,6 @@ pub(crate) fn relay_skill( Vec::new() } -/// A skill row changed (line learned / skill-up). Self-only family, same owner-session-lookup -/// audience as [`rest_state_appeared`] — the slot allocation runs in the job on the owner's own -/// writer thread, against the viewer's `skill_slots` (the same map the per-player leg uses). pub(crate) fn skill_changed(view: &WorldView, shard: ShardId, row: &PlayerSkill) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.character_guid)) else { return; diff --git a/gateway/src/codec/update_mask.rs b/gateway/src/codec/update_mask.rs index 38c49ebb9..dc5877334 100644 --- a/gateway/src/codec/update_mask.rs +++ b/gateway/src/codec/update_mask.rs @@ -1,148 +1,63 @@ -//! Hand-rolled vanilla update-mask encoder — the shared foundation that lifts gtker 0.3's -//! `pub(crate)` wall on indexed descriptor arrays (auras 1..47, quest-log counts, multi-field item -//! descriptors all hit it; see the gtker-descriptor-setter-wall note). gtker only exposes the -//! *first slot* of each indexed array and keeps the mask serializer (`set_int`/`set_bytes`/ -//! `write_into_vec`) crate-private, so anything past slot 0 is unreachable through its typed builder. -//! This module re-implements the mask at the byte level so we own every field index. -//! -//! THE WIRE FORMAT (taken from `wow_world_messages`' own `update_mask` unit tests — our oracle): -//! 1. one byte: `N` = number of u32 mask blocks that follow; -//! 2. `N` little-endian u32 mask blocks — bit `i` (block `i/32`, bit `i%32`) is set iff field -//! index `i` is present; -//! 3. each present field's u32 value, little-endian, in ASCENDING field-index order. A u64 field -//! (e.g. `OBJECT_FIELD_GUID`) occupies two adjacent indices `i`,`i+1` (low then high) and so -//! emits 8 bytes; a packed-bytes field (`UNIT_FIELD_BYTES_0`) is one u32 of 4 little-endian u8s. -//! -//! `UpdateMaskValues` models only PRESENT fields, every one of them "dirty". That is exactly the -//! shape `wow_world_messages` serializes when `header == dirty_mask` (a full CREATE mask) — and it's -//! also what its `dirty_reset()→re-set-only-changed` partial-VALUES dance produces on the wire (the -//! masked-out bits, incl. the force-seeded `OBJECT_FIELD_TYPE`, contribute nothing to `h & d`). So -//! the 5875 crash-trap is structural here: we simply never insert index 2 (`OBJECT_FIELD_TYPE`) on a -//! partial update, and the byte-equivalence tests below pin our output to gtker's, both for its -//! published CREATE vectors and for gtker's typed slot-0 aura partial mask. -//! -//! Wired into the outbound path via a raw-opcode send (`codec::build_values_update_raw`) rather -//! than gtker's typed sender, because gtker's mask *reader* rejects a `OBJECT_FIELD_TYPE`-less -//! mask (the 5875 client's own reader accepts one — that asymmetry is what makes the raw-opcode -//! path work at all). Live consumers: `build_values_update_raw` (`codec/values.rs:96`) sends the -//! fog bitmask, the rest byte, and bag-slot updates through it; `full_aura_mask`/ -//! `full_quest_log_mask` compose the packed multi-slot aura and quest-log arrays this module -//! exists to reach past gtker's slot-0-only setters, called live from the aura/quest-log relay -//! paths in `stdb/subscriptions.rs` and `world/mod.rs`. +//! gtker exposes only slot 0 of descriptor arrays and rejects type-less VALUES when reading. use std::collections::BTreeMap; -/// Vanilla (build 5875) UNIT/PLAYER object update-field indices, each a u32 slot. Every number is -/// the literal `set_int(N, ..)` / `set_bytes(N, ..)` argument from `wow_world_messages`' setters, so -/// the byte-equivalence tests cross-check them against gtker's serializer. -/// -/// This is the reference layout for the whole vanilla descriptor, not just the fields a caller -/// reaches today — `build_values_update_raw`/`full_aura_mask`/`full_quest_log_mask` consume a -/// subset live (see the module doc); the rest (health/level/faction/display-id/stat block, etc.) -/// are cross-checked by the byte-equivalence tests and wait for their own live sender, the same -/// way the aura/quest-log/fog fields did before their tick landed. #[allow(dead_code)] pub mod idx { - /// `OBJECT_FIELD_GUID` — u64, occupies slots 0 and 1. pub const OBJECT_GUID: u16 = 0; - /// `OBJECT_FIELD_TYPE` — the object-type bitmask. NEVER set on a partial VALUES update (5875 - /// crash-trap: re-sending it strips the PLAYER bit and the client faults at null+0x110). + /// Never resend OBJECT_FIELD_TYPE in partial VALUES; it can strip PLAYER and crash build 5875. pub const OBJECT_TYPE: u16 = 2; pub const OBJECT_ENTRY: u16 = 3; - /// `OBJECT_FIELD_SCALE_X` — f32. pub const OBJECT_SCALE_X: u16 = 4; pub const UNIT_HEALTH: u16 = 22; - /// `UNIT_FIELD_POWER1..5` (Mana, Rage, Focus, Energy, Happiness) — add 0..4. pub const UNIT_POWER1: u16 = 23; pub const UNIT_MAXHEALTH: u16 = 28; - /// `UNIT_FIELD_MAXPOWER1..5` — add 0..4. pub const UNIT_MAXPOWER1: u16 = 29; pub const UNIT_LEVEL: u16 = 34; pub const UNIT_FACTIONTEMPLATE: u16 = 35; - /// `UNIT_FIELD_BYTES_0` — packed race|class|gender|power (4×u8). + /// Packed race, class, gender and power, one byte each. pub const UNIT_BYTES_0: u16 = 36; - /// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY[0..3]` — creature weapon display ids. The generated vanilla - /// builder exposes only slot 0, so the three-slot live projection uses the raw VALUES path. pub const UNIT_VIRTUAL_ITEM_SLOT_DISPLAY: u16 = 37; pub const UNIT_FLAGS: u16 = 46; - /// `UNIT_FIELD_AURA` — 48 slots, one spell-id u32 each (slot k at `UNIT_AURA + k`). pub const UNIT_AURA: u16 = 47; - /// `UNIT_FIELD_AURAFLAGS` — 6 u32s; 8 auras per u32, **4 bits each** (slot k → word `+k/8`, - /// nibble `(k%8)*4`). + /// Eight aura-flag nibbles per u32. pub const UNIT_AURAFLAGS: u16 = 95; - /// `UNIT_FIELD_AURALEVELS` — 12 u32s; 4 auras per u32, **1 byte each** (slot k → word `+k/4`, - /// byte `(k%4)*8`). + /// Four aura-level bytes per u32. pub const UNIT_AURALEVELS: u16 = 101; - /// `UNIT_FIELD_AURAAPPLICATIONS` — 12 u32s; 4 auras per u32, 1 byte each (stack count, stored as - /// `count-1`; a single application is 0). + /// Four application-count bytes per u32; each stores count minus one. pub const UNIT_AURAAPPLICATIONS: u16 = 113; - /// Number of aura slots in the vanilla unit descriptor. pub const AURA_SLOTS: u16 = 48; pub const UNIT_DISPLAYID: u16 = 131; pub const UNIT_NATIVEDISPLAYID: u16 = 132; - /// `UNIT_FIELD_BYTES_1` — packed; byte 3 carries the vis-ghost flag (4×u8). + /// Byte 3 carries the ghost visibility flag. pub const UNIT_BYTES_1: u16 = 138; - /// `UNIT_FIELD_BYTES_2` — packed; BYTE 0 is the sheath state (0 stowed / 1 melee / 2 ranged), the - /// field `CMSG_SETSHEATHED` drives. Vanilla 1.12 index 164, cross-checked against gtker - /// `set_unit_bytes_2 → set_bytes(164, …)`. NOT `PLAYER_BYTES_2` (194) — different field, and - /// gtker labels this setter's params with PLAYER_BYTES_2's names, which is a red herring. + /// Byte 0 is sheath state; gtker mislabels the setter's parameters as PLAYER_BYTES_2. pub const UNIT_BYTES_2: u16 = 164; - /// `UNIT_FIELD_STAT0..4` — STR, AGI, STA, INT, SPI (add 0..4). pub const UNIT_STAT0: u16 = 150; - /// `CONTAINER_FIELD_SLOT_1` — first bag-content slot pointer, a u64 guid at field indices 50..51. - /// Slot N (0-indexed): `CONTAINER_FIELD_SLOT_1 + N * 2`. The gtker vanilla builder only exposes - /// `set_container_slot_1` (this index); slots 1+ require the hand-rolled raw encoder (same - /// 'gtker descriptor-setter wall' as multi-aura). Cross-checked against `wow_world_messages` - /// vanilla `UpdateContainerBuilder::set_container_slot_1 → set_guid(50, ..)`. pub const CONTAINER_FIELD_SLOT_1: u16 = 50; - /// `PLAYER_DUEL_ARBITER` — the duel flag gameobject guid, u64 at slots 188 and 189. pub const PLAYER_DUEL_ARBITER: u16 = 188; pub const PLAYER_FLAGS: u16 = 190; - /// `PLAYER_GUILDID` and `PLAYER_GUILDRANK` (`UNIT_END + 3`, `+ 4`; `cm:UpdateFields.h:180-181`, - /// gtker's vanilla `set_player_guildid` writes 191). The Guild Projection. pub const PLAYER_GUILDID: u16 = 191; pub const PLAYER_GUILDRANK: u16 = 192; - /// `PLAYER_BYTES_2`: byte 0 = facial hair, byte 3 = rest state (RESTED 0x01 → zzz + blue XP - /// bar / NORMAL 0x02). Vanilla 1.12 index 194 (gtker `set_player_bytes_2` → `set_bytes(194, …)`, - /// cross-checked: `PLAYER_BYTES_3 = 195`, `PLAYER_FLAGS = 190`). Relayed live on an inn crossing. + /// Byte 0 is facial hair; byte 3 is rest state, 1 rested or 2 normal. pub const PLAYER_BYTES_2: u16 = 194; - /// `PLAYER_DUEL_TEAM` — 0 outside a duel, then 1 for initiator and 2 for challenged. pub const PLAYER_DUEL_TEAM: u16 = 196; - /// `PLAYER_QUEST_LOG_1_1` — quest-log slot 0, sub-field 0 (the quest id). 20 slots × 3 u32 each - /// (`_1` id, `_2` packed 6-bit counters + state byte, `_3` timer); slot S sub F = this + S*3 + F. - /// Vanilla 1.12 (build 5875): `UNIT_END(188) + 0x0A = 198`. CROSS-CHECKED against our own anchor — - /// `PLAYER_COINAGE = 1176 = 188 + 0x3DC`, so this index scheme uses the same `UNIT_END = 188` the - /// emulator headers assume. Three emulators (mangos-zero / cmangos / vmangos) agree byte-for-byte. - /// VERIFIED against a live 5875 client 2026-06-24: empty + multi-quest - /// logs render with no crash, and a kill drove the objective to "Kobold Vermin slain: 1/10" live — - /// confirming the index AND the 6-bit-per-counter packing. (No gtker reference exists for these fields.) + // Build 5875: UNIT_END 188 + 0x0A; gtker exposes no quest-log setter. pub const PLAYER_QUEST_LOG_1_1: u16 = 198; - /// Number of quest-log slots in the vanilla player descriptor. pub const QUEST_LOG_SLOTS: u16 = 20; pub const PLAYER_XP: u16 = 716; - /// PLAYER_EXPLORED_ZONES: 64 consecutive u32 fields (1111..=1174) — the map-fog bitmask, one bit per - /// game_area.area_bit. Word for area_bit b = PLAYER_EXPLORED_ZONES_1 + b/32, bit = 1 << (b%32). - /// (PLAYER_REST_STATE_EXPERIENCE=1175 follows immediately, cross-checked vs the gtker descriptor.) pub const PLAYER_EXPLORED_ZONES_1: u16 = 1111; pub const EXPLORED_ZONES_WORDS: u16 = 64; pub const PLAYER_FIELD_COINAGE: u16 = 1176; - /// `GAMEOBJECT_ROTATION` — a GAMEOBJECT-descriptor field (its own index space, separate from - /// UNIT/PLAYER above), 4 consecutive f32 slots (rot0..3, this + 0..=3). Cross-checked against - /// `wow_world_messages` vanilla `UpdateGameObjectBuilder::set_gameobject_rotation` → `set_float(10, - /// ..)`. gtker's typed setter only reaches slot 0 (the descriptor-setter wall, same as multi-aura), - /// so all 4 slots ride the hand-rolled raw encoder. `GAMEOBJECT_STATE` follows - /// immediately at 14, confirming the 4-slot width. pub const GAMEOBJECT_ROTATION: u16 = 10; } -/// A sparse vanilla update mask: field index → u32 value, serialized in the exact wire layout the -/// 5875 client expects. Construct it, set the fields that changed, then [`to_bytes`](Self::to_bytes). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UpdateMaskValues { fields: BTreeMap, @@ -155,39 +70,27 @@ impl UpdateMaskValues { } } - /// Set one u32 field. pub fn set_u32(&mut self, index: u16, value: u32) -> &mut Self { self.fields.insert(index, value); self } - /// Set a u64 field spanning two adjacent slots: low half at `index`, high half at `index + 1` - /// (gtker's `set_guid`). The two halves serialize as the field's 8 little-endian bytes. pub fn set_u64(&mut self, index: u16, value: u64) -> &mut Self { self.fields.insert(index, value as u32); self.fields.insert(index + 1, (value >> 32) as u32); self } - /// Set an f32 field by its IEEE-754 bit pattern (gtker's `set_float`). No live caller yet — - /// today's live senders only touch u32/u64/packed-bytes fields — but it's exercised by the - /// byte-equivalence tests and completes the setter family for the first f32 field a live - /// sender needs (e.g. `OBJECT_FIELD_SCALE_X`). #[allow(dead_code)] pub fn set_f32(&mut self, index: u16, value: f32) -> &mut Self { self.set_u32(index, value.to_bits()) } - /// Set a packed-bytes field: `a,b,c,d` → one little-endian u32 (gtker's `set_bytes`). No live - /// caller yet — see `set_f32`'s note; today's packed-bytes live sends go through `set_u32` - /// with the bytes already packed by the caller (e.g. the rest byte in `codec/values.rs`). #[allow(dead_code)] pub fn set_bytes(&mut self, index: u16, a: u8, b: u8, c: u8, d: u8) -> &mut Self { self.set_u32(index, u32::from_le_bytes([a, b, c, d])) } - /// OR bits into a field that may already hold other sub-fields (the nibble/byte-packed aura - /// arrays, where several aura slots share one u32). Distinct from `set_*`, which overwrites. pub fn or_u32(&mut self, index: u16, bits: u32) -> &mut Self { *self.fields.entry(index).or_insert(0) |= bits; self @@ -197,7 +100,6 @@ impl UpdateMaskValues { self.fields.get(&index).copied() } - /// Number of u32 mask blocks: `highest_set_index / 32 + 1`, or 0 when empty. pub fn block_count(&self) -> usize { match self.fields.keys().next_back() { Some(&max) => max as usize / 32 + 1, @@ -205,9 +107,6 @@ impl UpdateMaskValues { } } - /// Serialize into `out` in the vanilla wire layout (block count byte, mask blocks, then present - /// values in ascending index order). Byte-identical to `wow_world_messages`' - /// `UpdateMask::write_into_vec` for a full (header == dirty) mask. pub fn write_to(&self, out: &mut Vec) { let blocks = self.block_count(); out.push(blocks as u8); @@ -223,9 +122,6 @@ impl UpdateMaskValues { } } - /// Convenience wrapper over [`write_to`](Self::write_to) into a fresh `Vec`. Live senders - /// write into an existing outbound buffer via `write_to` directly; this one is the tests' - /// entry point for the byte-equivalence assertions. #[allow(dead_code)] pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); @@ -234,8 +130,6 @@ impl UpdateMaskValues { } } -/// One aura slot to render in the unit descriptor: which `slot` (0..48), the `spell_id`, the -/// 4-bit slot `flags`, and the unit `level` of the caster (shown in the tooltip). #[derive(Clone, Copy, Debug)] pub struct AuraSlot { pub slot: u8, @@ -244,67 +138,42 @@ pub struct AuraSlot { pub level: u8, } -/// Write a set of aura slots into a mask using the packed array layout (the unlock past gtker's -/// slot-0-only setters): spell-id one u32 per slot, flags 4 bits/slot, level + applications one -/// byte/slot. The caller decides CREATE vs partial-VALUES by which other fields it adds; this only -/// touches the aura arrays (never `OBJECT_FIELD_TYPE`), so it's safe in a partial update. pub fn write_auras(mask: &mut UpdateMaskValues, auras: &[AuraSlot]) { for a in auras { let slot = a.slot as u16; mask.set_u32(idx::UNIT_AURA + slot, a.spell_id); - // flags: 8 per u32, nibble (4 bits) each. mask.or_u32( idx::UNIT_AURAFLAGS + slot / 8, ((a.flags & 0x0F) as u32) << ((slot % 8) * 4), ); - // level: 4 per u32, one byte each. mask.or_u32( idx::UNIT_AURALEVELS + slot / 4, (a.level as u32) << ((slot % 4) * 8), ); - // applications: a single application (stack 1) is stored as 0, so nothing to OR in. } } -/// Build a FULL `UNIT_FIELD_AURA` array VALUES mask from a unit's complete current aura set: every -/// one of the 48 aura slots' spell-id, plus the packed flag/level/application words, present `auras` -/// overlaid (at their own `slot`) and every other slot zeroed. -/// -/// It is a FULL SYNC by necessity, not choice: (1) the spell-id slots must be zeroed so a removed -/// aura's icon clears, and (2) the flag/level/application words are PACKED — 8 auras share one -/// AURAFLAGS u32, 4 share one AURALEVELS/APPLICATIONS u32 — so a single-slot update can only write -/// the whole word and would clobber the co-located slots. Composing the words from the entire -/// current set is the only correct way to touch one slot without corrupting its neighbours. The -/// caller passes each aura at its authoritative module-assigned `slot` (never a gateway-invented -/// index). Touches only the aura arrays — never `OBJECT_FIELD_TYPE` — so it is a safe partial VALUES -/// update. This is the multi-slot shape gtker's slot-0-only setters cannot express; it rides the -/// raw-send path (`codec::build_values_update_raw`). pub fn full_aura_mask(auras: &[AuraSlot]) -> UpdateMaskValues { let mut m = UpdateMaskValues::new(); for s in 0..idx::AURA_SLOTS { m.set_u32(idx::UNIT_AURA + s, 0); } for w in 0..6u16 { - m.set_u32(idx::UNIT_AURAFLAGS + w, 0); // 48 slots / 8 per word + m.set_u32(idx::UNIT_AURAFLAGS + w, 0); } for w in 0..12u16 { - m.set_u32(idx::UNIT_AURALEVELS + w, 0); // 48 / 4 + m.set_u32(idx::UNIT_AURALEVELS + w, 0); m.set_u32(idx::UNIT_AURAAPPLICATIONS + w, 0); } - write_auras(&mut m, auras); // overlay present auras (ORs into the packed words) + write_auras(&mut m, auras); m } -/// Field index of quest-log `slot` (0..[`idx::QUEST_LOG_SLOTS`]) sub-field `sub` (0 = quest id, -/// 1 = packed counters+state, 2 = timer). Each slot is 3 contiguous u32s (vanilla 1.12 layout). pub const fn quest_log_field(slot: u16, sub: u16) -> u16 { idx::PLAYER_QUEST_LOG_1_1 + slot * 3 + sub } -/// Pack a quest slot's middle u32 (`_n_2`): four objective counters at **6 bits each** (0..63, vanilla -/// caps here — NOT a byte per objective like TBC/WotLK) in bits 0–5/6–11/12–17/18–23, plus the quest -/// `state` in byte 3 (bits 24–31; 0 = incomplete, 1 = complete, 2 = failed). Counts above 63 saturate. -/// Pure/testable. +/// Vanilla packs four 6-bit counters below the state byte; later clients use byte counters. pub fn pack_quest_counts(counts: &[u32], state: u8) -> u32 { let mut v = 0u32; for (i, &c) in counts.iter().take(4).enumerate() { @@ -313,8 +182,6 @@ pub fn pack_quest_counts(counts: &[u32], state: u8) -> u32 { v | ((state as u32) << 24) } -/// One quest-log slot to render in the player descriptor: `slot` (0..20), the `quest_id`, its per- -/// objective `counts`, the `state` (0/1/2), and the `timer` (0 if untimed). #[derive(Clone, Debug, PartialEq, Eq)] pub struct QuestLogSlot { pub slot: u8, @@ -324,12 +191,6 @@ pub struct QuestLogSlot { pub timer: u32, } -/// Build a FULL quest-log VALUES mask: all [`idx::QUEST_LOG_SLOTS`] slots' 3 u32s, with the present -/// `slots` overlaid at their own index and every other slot zeroed. Full sync (like [`full_aura_mask`]) -/// so a turned-in / abandoned quest's slot clears. Touches only PLAYER_QUEST_LOG fields — never -/// `OBJECT_FIELD_TYPE` — so it is a safe partial VALUES update over the raw-send path -/// (`codec::build_values_update_raw`). Unlike auras, quest slots are independent (no cross-slot bit -/// packing), so a single slot *could* be updated alone; the full sync is chosen for clear-on-removal. pub fn full_quest_log_mask(slots: &[QuestLogSlot]) -> UpdateMaskValues { let mut m = UpdateMaskValues::new(); for s in 0..idx::QUEST_LOG_SLOTS { @@ -351,18 +212,11 @@ mod tests { use super::*; use wow_world_messages::vanilla::ServerMessage; - /// The keystone: reproduce, byte-for-byte, `wow_world_messages`' own `most_minimal_example` - /// serialized vector. If our block-count / mask-bit / value-order logic is wrong anywhere this - /// fails against the crate author's gold standard. #[test] fn matches_gtker_most_minimal_example() { let expected: &[u8] = &[ - 2, // block count - 7, 0, 64, 0, 16, 0, 0, 0, // 2 mask blocks - 4, 0, 0, 0, 0, 0, 0, 0, // OBJECT_FIELD_GUID (u64 = 4) - 25, 0, 0, 0, // OBJECT_FIELD_TYPE (0x19) - 100, 0, 0, 0, // UNIT_FIELD_HEALTH - 1, 1, 1, 1, // UNIT_FIELD_BYTES_0 (Human/Warrior/Female/Rage) + 2, 7, 0, 64, 0, 16, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 25, 0, 0, 0, 100, 0, 0, 0, 1, 1, + 1, 1, ]; let mut m = UpdateMaskValues::new(); m.set_u64(idx::OBJECT_GUID, 4); @@ -372,24 +226,12 @@ mod tests { assert_eq!(m.to_bytes().as_slice(), expected); } - /// A second gold-standard vector (gtker's `small_example`): a fuller CREATE mask spanning 5 - /// blocks, with an f32 (scale) and the high-index display fields (131/132). Cross-checks our - /// block-count growth and the f32 / split-block bit math against the crate author's output. #[test] fn matches_gtker_small_example() { let expected: &[u8] = &[ - 5, // block count - 23, 0, 64, 16, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, // 5 mask blocks - 4, 0, 0, 0, 0, 0, 0, 0, // OBJECT_FIELD_GUID (u64 = 4) - 25, 0, 0, 0, // OBJECT_FIELD_TYPE - 0, 0, 128, 63, // OBJECT_FIELD_SCALE_X (1.0) - 100, 0, 0, 0, // UNIT_FIELD_HEALTH - 100, 0, 0, 0, // UNIT_FIELD_MAXHEALTH - 1, 0, 0, 0, // UNIT_FIELD_LEVEL - 1, 0, 0, 0, // UNIT_FIELD_FACTIONTEMPLATE - 1, 1, 1, 1, // UNIT_FIELD_BYTES_0 - 50, 0, 0, 0, // UNIT_FIELD_DISPLAYID - 50, 0, 0, 0, // UNIT_FIELD_NATIVEDISPLAYID + 5, 23, 0, 64, 16, 28, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 4, 0, 0, 0, 0, 0, + 0, 0, 25, 0, 0, 0, 0, 0, 128, 63, 100, 0, 0, 0, 100, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, + 1, 1, 1, 1, 50, 0, 0, 0, 50, 0, 0, 0, ]; let mut m = UpdateMaskValues::new(); m.set_u64(idx::OBJECT_GUID, 4); @@ -405,25 +247,17 @@ mod tests { assert_eq!(m.to_bytes().as_slice(), expected); } - /// The partial-VALUES discipline against gtker's typed slot-0 aura mask: our multi-aura encoder's - /// single-slot output must be exactly the mask gtker's `UpdatePlayer` slot-0 aura builder puts on - /// the wire. `gtker`'s mask serializer is `pub(crate)`, but the full `SMSG_UPDATE_OBJECT` is public - /// and the mask is its tail — so we serialize the gtker-typed packet and assert it ENDS WITH our - /// bytes. Proves we reproduce gtker's bytes (and, since `dirty_reset` strips it, that the slot-0 - /// shape never leaks `OBJECT_FIELD_TYPE`). #[test] fn reproduces_live_build_aura_values_slot0() { use wow_world_messages::vanilla::{Object, UpdateMask, UpdatePlayer, SMSG_UPDATE_OBJECT}; use wow_world_messages::Guid; let (spell_id, level, flags) = (6673u32, 1u8, 0x09u8); - // gtker's typed slot-0 aura VALUES update, with the finalize→dirty_reset→re-set-only-changed - // discipline the live builders use so OBJECT_FIELD_TYPE is stripped (never re-sent). let mut player = UpdatePlayer::builder() .set_unit_aura(spell_id as i32) .set_unit_auraflags(flags, 0, 0, 0) .set_unit_auralevels(level, 0, 0, 0) - .set_unit_auraapplications(0, 0, 0, 0) // stack count 1 is stored as 0 + .set_unit_auraapplications(0, 0, 0, 0) .finalize(); player.dirty_reset(); player.set_unit_aura(spell_id as i32); @@ -441,9 +275,6 @@ mod tests { .write_unencrypted_server(&mut packet) .unwrap(); - // Ours, via the multi-aura writer with a single slot. The gtker builder also writes - // auraapplications word 0 (value 0) — a present-but-zero field sets its mask bit and emits a - // zero value word — so include it to match byte-for-byte. let mut ours = UpdateMaskValues::new(); write_auras( &mut ours, @@ -463,7 +294,6 @@ mod tests { ); } - /// The unlock gtker can't express: a second aura slot lands at the right indices/bits. #[test] fn multi_slot_auras_target_distinct_indices() { let mut m = UpdateMaskValues::new(); @@ -484,58 +314,45 @@ mod tests { }, ], ); - // Distinct spell-id slots. assert_eq!(m.get(idx::UNIT_AURA), Some(6673)); assert_eq!(m.get(idx::UNIT_AURA + 1), Some(1459)); - // Both slots' flag nibbles share word 0: slot0 nibble0 = 0x9, slot1 nibble1 = 0x1. assert_eq!(m.get(idx::UNIT_AURAFLAGS), Some(0x19)); - // Both slots' level bytes share word 0: slot0 byte0 = 1, slot1 byte1 = 2. assert_eq!(m.get(idx::UNIT_AURALEVELS), Some(0x0201)); - // Round-trips through the serializer. assert!(!m.to_bytes().is_empty()); } - /// An empty mask is a single zero block-count byte (no blocks, no values). #[test] fn empty_mask_is_one_zero_byte() { assert_eq!(UpdateMaskValues::new().to_bytes(), vec![0u8]); } - /// Quest-log field indices: slot 0 at 198/199/200, stride 3, last slot (19) at 255/256/257. These - /// are the indices a wrong value crashes the client on, so pin them. #[test] fn quest_log_field_indices() { assert_eq!(idx::PLAYER_QUEST_LOG_1_1, 198); - assert_eq!(quest_log_field(0, 0), 198); // slot 0: quest id - assert_eq!(quest_log_field(0, 1), 199); // slot 0: counters+state - assert_eq!(quest_log_field(0, 2), 200); // slot 0: timer - assert_eq!(quest_log_field(1, 0), 201); // slot 1: stride 3 - assert_eq!(quest_log_field(19, 0), 255); // last slot id - assert_eq!(quest_log_field(19, 2), 257); // last slot timer (block 8) + assert_eq!(quest_log_field(0, 0), 198); + assert_eq!(quest_log_field(0, 1), 199); + assert_eq!(quest_log_field(0, 2), 200); + assert_eq!(quest_log_field(1, 0), 201); + assert_eq!(quest_log_field(19, 0), 255); + assert_eq!(quest_log_field(19, 2), 257); } - /// Counts pack 6 bits each (max 63, NOT a byte) with state in byte 3 — the vanilla subtlety. #[test] fn quest_counts_pack_6bit_plus_state() { - // Four distinct counts in their 6-bit lanes. assert_eq!( pack_quest_counts(&[1, 2, 3, 4], 0), 1 | (2 << 6) | (3 << 12) | (4 << 18) ); - // State in byte 3 (bits 24-31), independent of the counters. assert_eq!(pack_quest_counts(&[], 1), 1 << 24); assert_eq!(pack_quest_counts(&[5], 1), 5 | (1 << 24)); - // Saturates at 63 (a count of 100 must not bleed into the next objective's lane). assert_eq!(pack_quest_counts(&[100], 0), 63); assert_eq!(pack_quest_counts(&[63, 63], 0), 63 | (63 << 6)); - // Only the first 4 objectives are packed (vanilla cap). assert_eq!( pack_quest_counts(&[1, 1, 1, 1, 1], 0), 1 | (1 << 6) | (1 << 12) | (1 << 18) ); } - /// A full quest-log mask zeroes all 20 slots and overlays the present ones (so removals clear). #[test] fn full_quest_log_mask_overlays_and_clears() { let m = full_quest_log_mask(&[QuestLogSlot { @@ -545,45 +362,29 @@ mod tests { state: 1, timer: 0, }]); - // Slot 2 carries the quest id + packed (count 10, state 1); other slots present but zero. assert_eq!(m.get(quest_log_field(2, 0)), Some(7)); assert_eq!(m.get(quest_log_field(2, 1)), Some(10 | (1 << 24))); - assert_eq!(m.get(quest_log_field(0, 0)), Some(0)); // empty slot zeroed (clears a removed quest) + assert_eq!(m.get(quest_log_field(0, 0)), Some(0)); assert_eq!(m.get(quest_log_field(19, 2)), Some(0)); - // 20 slots × 3 fields all present. assert!(!m.to_bytes().is_empty()); } - /// `full_quest_log_mask(&[])` is the all-zero 60-field block the relay sends when a turn-in empties the - /// quest log — it CLEARS every slot client-side (slot 0's quest_id → 0 is how the client removes the - /// quest). NOT a crash payload: an earlier theory blamed it for the McBride turn-in crash, but the real - /// cause was SMSG_SET_FACTION_STANDING sending faction_id instead of the rep-index (fixed in dee80cb). - /// This pins the EXACT shape so a future mask/index drift is caught. - /// - /// Wire math (matches the traced packet to the byte): mask = 1 block-count byte + 9 mask blocks - /// (bits 198..=257 ⇒ 257/32+1 = 9) + 60 zero u32 values = 1 + 36 + 240 = 277. The full raw VALUES - /// body wraps it: 4 (amount) + 1 (has_transport) + 1 (update_type) + 2 (packed guid=1) + 277 = 285. #[test] fn empty_quest_log_mask_clears_all_slots() { let m = full_quest_log_mask(&[]); - // All 60 quest-log fields present and zero — every slot's id/counters/timer cleared. for s in 0..idx::QUEST_LOG_SLOTS { assert_eq!(m.get(quest_log_field(s, 0)), Some(0)); assert_eq!(m.get(quest_log_field(s, 1)), Some(0)); assert_eq!(m.get(quest_log_field(s, 2)), Some(0)); } - // NEVER OBJECT_FIELD_TYPE (the other 5875 null+0x110 trap) — present here only by absence. assert_eq!(m.get(idx::OBJECT_TYPE), None); - // Exact mask bytes: 9 blocks then 60 zero value words. Block 6 = bits 198..=223 (bits 6..=31 - // of word 6 ⇒ 0xFFFFFFC0), block 7 = bits 224..=255 (all ⇒ 0xFFFFFFFF), block 8 = bits 256,257 - // (⇒ 0x00000003); blocks 0..=5 zero. let bytes = m.to_bytes(); let mut expected = Vec::new(); - expected.push(9u8); // block count + expected.push(9u8); for w in [0u32, 0, 0, 0, 0, 0, 0xFFFF_FFC0, 0xFFFF_FFFF, 0x0000_0003] { expected.extend_from_slice(&w.to_le_bytes()); } - expected.extend(std::iter::repeat_n(0u8, 60 * 4)); // 60 zero value words + expected.extend(std::iter::repeat_n(0u8, 60 * 4)); assert_eq!( bytes.len(), 277, diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index 4673fd9ab..5c1ae99c2 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -1,8 +1,4 @@ -//! Partial-VALUES update builders — the crash-critical `build_*_values` family plus the raw -//! VALUES path and the packed-guid helper. MOVED VERBATIM out of `mod.rs`: the -//! finalize → `dirty_reset()` → re-set-only-changed-fields discipline (which keeps -//! `OBJECT_FIELD_TYPE` off a partial update) is DELIBERATE and PROVEN against the 5875 client. -//! Do NOT change any logic and do NOT migrate the typed builders onto the raw encoder. +//! Finalize, dirty_reset, then reset changed fields to exclude OBJECT_FIELD_TYPE from VALUES. use super::*; @@ -32,41 +28,18 @@ fn player_values(guid: u64, apply: impl FnOnce(&mut UpdatePlayer)) -> SMSG_UPDAT } } -/// Update the Character's private watched reputation selection. -1 clears the bar. pub fn build_watched_faction_values(guid: u64, reputation_index: i32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { player.set_player_field_watched_faction_index(reputation_index); }) } -/// Build a VALUES partial-update (`Object::Values`) carrying only `UNIT_FIELD_HEALTH` (Tier 3 -/// combat). Unlike `build_create_object`, this pushes a *single changed field* to observers via a -/// sparse update mask — no re-create, no flicker. The foundation for combat damage (and any future -/// live field change). -/// -/// CRITICAL: `wow_world_messages`' update-mask builder force-seeds `OBJECT_FIELD_TYPE` (bit 2) -/// dirty in `new()`, so a naive `builder().set_unit_health().finalize()` re-transmits the object -/// TYPE as a value word *inside this partial update*. A real 1.12 server NEVER re-sends TYPE on a -/// VALUES update — and doing so crashes the 5875 client: it overwrites the client's cached -/// `OBJECT_FIELD_TYPE` with the Unit value `0x09` (OBJECT|UNIT), stripping the PLAYER bit (`0x10`) -/// from the player's OWN object (spawned as `0x19` = OBJECT|UNIT|PLAYER). Mis-typed, the next -/// player-frame dispatch dereferences a null sub-object → ACCESS_VIOLATION at null+0x110 (the combat -/// client crash root-caused 2026-06-16; a creature's VALUES was harmless only because its type is -/// already `0x09`). `dirty_reset()` clears the dirty mask (incl. the seeded TYPE bit); re-setting -/// ONLY health then re-dirties bit 22 alone, so the wire carries just mask word `0x00400000` + the -/// health value — exactly what cmangos/vmangos send. (gtker's own strict reader rejects a TYPE-less -/// mask, but the real client — the only consumer — requires its absence.) pub fn build_health_values(guid: u64, health: u32) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_health(health as i32); }) } -/// Build a VALUES partial-update carrying `PLAYER_FLAGS` and `UNIT_FIELD_BYTES_1`, so observers -/// already holding the object see every `PLAYER_FLAGS` change: the GHOST bit with the vis-ghost -/// render byte on Release Spirit and reclaim, and the AFK and DND bits of an Away Status. Player -/// mask; same `dirty_reset` discipline as `build_health_values` so it never re-sends -/// OBJECT_FIELD_TYPE. pub fn build_player_flags_values( guid: u64, player_flags: u32, @@ -79,10 +52,6 @@ pub fn build_player_flags_values( }) } -/// Build a VALUES partial-update carrying `PLAYER_BYTES_2` — same `dirty_reset` discipline as -/// `build_health_values`. `player_bytes_2` is the FULL descriptor u32 (byte0 facial hair, byte2 owned -/// bank bag slot count, byte3 rest state); a partial VALUES overwrites the whole field, so all three -/// ride along even though only the slot count changed. pub fn build_bank_bag_slots_values(guid: u64, player_bytes_2: u32) -> SMSG_UPDATE_OBJECT { let (a, b, c, d) = unpack4(player_bytes_2); player_values(guid, |player| { @@ -90,36 +59,23 @@ pub fn build_bank_bag_slots_values(guid: u64, player_bytes_2: u32) -> SMSG_UPDAT }) } -/// `SMSG_UPDATE_OBJECT` opcode (vanilla build 5875). Cross-checked against the gtker header in -/// `raw_values_body_matches_gtker_envelope` below. const SMSG_UPDATE_OBJECT_OPCODE: u16 = 0x00A9; -/// Build a RAW `SMSG_UPDATE_OBJECT` (one `Object::Values` block) for `guid` carrying `mask` — the -/// escape past gtker's slot-0 update-mask wall. The body envelope (block count, `has_transport`, -/// update-type byte, packed guid) is byte-identical to what gtker's -/// `SMSG_UPDATE_OBJECT(Object::Values{..})` serializes (pinned by the test below); only the trailing -/// update-mask is ours, so it can carry fields gtker can't express (auras 1..47, multi-field items). -/// Returns `(opcode, body)` for [`Outbound::Raw`](crate::world::Outbound::Raw). The caller owns the -/// 5875 crash-trap discipline: `mask` must NOT set `OBJECT_FIELD_TYPE` on a partial update. pub fn build_values_update_raw(guid: u64, mask: &update_mask::UpdateMaskValues) -> (u16, Vec) { - // 5875 crash trap: a partial VALUES update must never carry OBJECT_FIELD_TYPE (index 2) — the - // client strips the PLAYER bit and faults at null+0x110. UpdateMaskValues never inserts it - // structurally; this guards a caller that wrongly does. + // OBJECT_FIELD_TYPE in partial VALUES can strip PLAYER and crash build 5875 at null+0x110. debug_assert!( mask.get(update_mask::idx::OBJECT_TYPE).is_none(), "raw VALUES update must NOT set OBJECT_FIELD_TYPE (5875 null+0x110 crash trap)" ); let mut body = Vec::new(); - body.extend_from_slice(&1u32.to_le_bytes()); // amount_of_objects = 1 - body.push(0u8); // has_transport = 0 - body.push(0u8); // update_type = VALUES (0) - write_packed_guid_u64(&mut body, guid); // guid1, packed - mask.write_to(&mut body); // mask1 + body.extend_from_slice(&1u32.to_le_bytes()); + body.push(0u8); + body.push(0u8); + write_packed_guid_u64(&mut body, guid); + mask.write_to(&mut body); (SMSG_UPDATE_OBJECT_OPCODE, body) } -/// The Guild Projection as a partial VALUES update: `PLAYER_GUILDID` and `PLAYER_GUILDRANK`. Both -/// are 0 for a Character in no Guild. Raw, so it never carries `OBJECT_FIELD_TYPE`. pub fn build_guild_values(guid: u64, guild_id: u32, rank_id: u32) -> (u16, Vec) { let mut mask = update_mask::UpdateMaskValues::new(); mask.set_u32(update_mask::idx::PLAYER_GUILDID, guild_id); @@ -127,11 +83,6 @@ pub fn build_guild_values(guid: u64, guild_id: u32, rank_id: u32) -> (u16, Vec (u16, Vec) { let mut mask = update_mask::UpdateMaskValues::new(); mask.set_u32( @@ -141,18 +92,13 @@ pub fn build_explored_zones_values(guid: u64, word_idx: u16, word_value: u32) -> build_values_update_raw(guid, &mask) } -/// Build a partial VALUES update that sets `PLAYER_BYTES_2` — the rest-state flip. `player_bytes_2` -/// is the FULL descriptor u32 (byte 0 facial hair + byte 3 rest state); a partial VALUES overwrites the -/// whole field, so the module ships the complete value. Byte 3 = RESTED (0x01) → the client draws the zzz -/// icon + blue XP bar; NORMAL (0x02) → normal. Routes through the OBJECT_FIELD_TYPE-free raw path. +/// VALUES replaces the whole u32; preserve facial hair and the nonzero rest-state byte. pub fn build_rest_state_values(guid: u64, player_bytes_2: u32) -> (u16, Vec) { let mut mask = update_mask::UpdateMaskValues::new(); mask.set_u32(update_mask::idx::PLAYER_BYTES_2, player_bytes_2); build_values_update_raw(guid, &mask) } -/// Vanilla packed-guid encoding (mirrors gtker's `write_packed_guid`): a leading bit-pattern byte -/// where bit `i` marks a NON-zero byte `i` of the little-endian guid, then those non-zero bytes. pub(crate) fn write_packed_guid_u64(out: &mut Vec, guid: u64) { let bytes = guid.to_le_bytes(); let mut bit_pattern: u8 = 0; @@ -167,29 +113,18 @@ pub(crate) fn write_packed_guid_u64(out: &mut Vec, guid: u64) { out.extend_from_slice(&data); } -/// Build a VALUES partial-update carrying `UNIT_DYNAMIC_FLAGS` (idx 143) so a corpse's DEAD bit -/// reaches clients that already have the object (the killing blow). New observers get it at -/// CREATE instead (`build_create_object` sets it). Same `dirty_reset` discipline as -/// `build_health_values` so the wire carries ONLY this field and never re-sends OBJECT_FIELD_TYPE. pub fn build_dynamic_flags_values(guid: u64, dynamic_flags: u32) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_dynamic_flags(dynamic_flags as i32); }) } -/// VALUES partial-update carrying `UNIT_FIELD_FLAGS` so observers see a unit's flag changes live — the -/// `UNIT_FLAG_IN_COMBAT` bit toggling as it enters/leaves combat (the combat indicator, incl. a pure -/// caster the auto-attack `SMSG_ATTACKSTART` relay can't cover). Same `dirty_reset` discipline as -/// `build_dynamic_flags_values` so the wire carries ONLY this field and never re-sends OBJECT_FIELD_TYPE. pub fn build_unit_flags_values(guid: u64, unit_flags: u32) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_flags(unit_flags as i32); }) } -/// One atomic taxi-presentation VALUES update: the imported mount model and TAXI_FLIGHT flag reach -/// the client in the same Unit mask. This uses the crash-safe `dirty_reset` path and never carries -/// OBJECT_FIELD_TYPE. Activation and landing are the only mount-display transitions. pub fn build_taxi_presentation_values( guid: u64, mount_display_id: u32, @@ -201,29 +136,18 @@ pub fn build_taxi_presentation_values( }) } -/// Standalone VALUES partial-update carrying ONLY `UNIT_FIELD_MOUNTDISPLAYID` — the land-mount twin of -/// `build_taxi_presentation_values`, deliberately decoupled from the taxi `UNIT_FLAG_TAXI_FLIGHT` bit. A -/// land mount/dismount never touches that flag, so it never needs the coupled builder; an observer -/// already in range sees the model appear/disappear through this single-field mask alone. Same -/// `dirty_reset` discipline as its siblings (never re-sends OBJECT_FIELD_TYPE). pub fn build_mount_display_values(guid: u64, mount_display_id: u32) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_mountdisplayid(mount_display_id as i32); }) } -/// Update the current appearance without replacing the unit's native appearance. pub fn build_display_values(guid: u64, display_id: u32) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_displayid(display_id as i32); }) } -/// VALUES partial-update carrying `UNIT_FIELD_BYTES_2` so observers see a weapon DRAWN or STOWED the -/// moment it happens — the `CMSG_SETSHEATHED` a client sends on `Z`. Unit mask, not player-gated: a -/// creature draws its weapon on engage too. Same `dirty_reset` discipline as its siblings (the wire -/// carries only this field, never OBJECT_FIELD_TYPE). Where a stowed weapon HANGS is a different -/// field entirely, the per-item `sheathe_type` in the item query. pub fn build_sheath_values(guid: u64, unit_bytes_2: u32) -> SMSG_UPDATE_OBJECT { let (b2a, b2b, b2c, b2d) = unpack4(unit_bytes_2); unit_values(guid, |unit| { @@ -231,11 +155,6 @@ pub fn build_sheath_values(guid: u64, unit_bytes_2: u32) -> SMSG_UPDATE_OBJECT { }) } -/// VALUES partial-update carrying the unit's current power so the resource bar moves LIVE (e.g. a -/// warrior gaining rage in combat — without this the bar stays at 0 and the client greys every -/// rage-costed spell). `power_b` is the power-type byte from `unit_bytes_0` and selects the descriptor -/// index, matching `build_create_object`: rage=1→POWER2, focus=2→POWER3, energy=3→POWER4, -/// happiness=4→POWER5, mana=0→POWER1. Player mask + the `dirty_reset` discipline (no OBJECT_FIELD_TYPE). pub fn build_power_values(guid: u64, power_b: u8, power_cur: u32) -> SMSG_UPDATE_OBJECT { let cur = power_cur as i32; player_values(guid, |player| match power_b { @@ -257,18 +176,12 @@ pub fn build_power_values(guid: u64, power_b: u8, power_cur: u32) -> SMSG_UPDATE }) } -/// VALUES partial-update carrying `UNIT_FIELD_TARGET` so observers see who a unit has selected (the -/// target ring / target-of-target / assist). Relayed for any unit (a creature's aggro target too). -/// UNIT mask + the `dirty_reset` discipline (never re-sends OBJECT_FIELD_TYPE). pub fn build_target_values(guid: u64, target: u64) -> SMSG_UPDATE_OBJECT { unit_values(guid, |unit| { unit.set_unit_target(Guid::new(target)); }) } -/// Build the three creature `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY` fields in one partial VALUES update. -/// The vanilla typed builder exposes only slot 0, so this uses the same raw-mask escape as -/// multi-slot auras and bag contents. It never carries `OBJECT_FIELD_TYPE`. pub fn build_virtual_item_values( guid: u64, main_hand_display: u32, @@ -283,10 +196,6 @@ pub fn build_virtual_item_values( build_values_update_raw(guid, &mask) } -/// VALUES partial-update carrying `UNIT_FIELD_MAXHEALTH` + the unit's max power, so a non-level-up -/// max-vitals change (e.g. +Stamina/+Intellect gear or a Fortitude/Mark aura) moves the bar -/// DENOMINATOR live for observers. `power_b` selects the max-power descriptor exactly like -/// `build_power_values`. UNIT mask + the `dirty_reset` discipline (never re-sends OBJECT_FIELD_TYPE). pub fn build_max_vitals_values( guid: u64, max_health: u32, @@ -316,9 +225,6 @@ pub fn build_max_vitals_values( }) } -/// Build a self-only VALUES update for all seven effective resistances. Armor also carries its positive -/// aura split in `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE[0]` so the paperdoll colors that term green. -/// This uses the same `dirty_reset` path as every partial VALUES update. pub fn build_armor_values(guid: u64, total: [u32; 7], pos_buff: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |p| { p.set_unit_normal_resistance(total[0] as i32); @@ -332,16 +238,6 @@ pub fn build_armor_values(guid: u64, total: [u32; 7], pos_buff: u32) -> SMSG_UPD }) } -/// The character-sheet paperdoll numbers — a plain READ of `module::spell::recompute_sheet`'s output -/// (`stdb::armor::sheet_stats` fetches the row), rendered by [`build_sheet_stats_values`]. -/// `strength`/`agility`/`stamina`/`intellect`/`spirit` are the EFFECTIVE attribute (base + bonus, the -/// white total `UNIT_FIELD_STAT0..4` wants); `*_bonus` is the SIGNED aura+gear(+enchant) delta the -/// module already folded — split into the green/red paperdoll halves below via plain sign arithmetic, -/// not a second aura read. `attack_power` is the stat-derived base AP; `ap_mods` is the `A_MOD_COMBAT(ATTACK_POWER)` -/// aura portion alone (Battle Shout) — vanilla renders those through two different wire fields. -/// `crit_pct` is `module::combat::effective_crit_bp`/100.0, the SAME basis-point value the -/// swing table rolls against, converted to the float percent `PLAYER_CRIT_PERCENTAGE` wants; no -/// second crit formula lives on the gateway. pub struct SheetStatsValues { pub strength: u32, pub agility: u32, @@ -363,14 +259,6 @@ pub struct SheetStatsValues { pub crit_pct: f32, } -/// Build a VALUES partial-update carrying the paperdoll numbers: the five EFFECTIVE attributes (white -/// `UNIT_FIELD_STAT0..4`) PLUS the PLAYER_FIELD_POSSTAT/NEGSTAT split derived from each `*_bonus` sign -/// — the client renders the stat number GREEN with a "(+N)" tooltip when POSSTAT is non-zero (same -/// mechanism as the armor green), the stat-derived base attack power PLUS `UNIT_FIELD_ATTACK_POWER_MODS` -/// (the Battle-Shout-style aura portion, rendered as its own green "(+N)"), and the melee damage range -/// (min/max are FLOAT fields — the client renders "N - M" and derives DPS with BASEATTACKTIME). Player -/// mask (POSSTAT/NEGSTAT/ATTACK_POWER_MODS are PLAYER-block fields, like the armor buff-mods); typed -/// setters throughout. pub fn build_sheet_stats_values(guid: u64, s: &SheetStatsValues) -> SMSG_UPDATE_OBJECT { player_values(guid, |p| { p.set_unit_strength(s.strength as i32); @@ -389,9 +277,7 @@ pub fn build_sheet_stats_values(guid: u64, s: &SheetStatsValues) -> SMSG_UPDATE_ p.set_player_field_negstat3(s.int_bonus.min(0)); p.set_player_field_negstat4(s.spi_bonus.min(0)); p.set_unit_attack_power(s.attack_power as i32); - // UNIT_FIELD_ATTACK_POWER_MODS packs two UNSIGNED shorts (pos, neg-as-magnitude), mirroring - // mangos's `SetInt16Value(field, 0/1, ..)`, never a signed short (a negative AP debuff isn't - // wired yet; `ap_mods` is currently always ≥0 from Battle Shout, so `neg` is 0 in practice). + // ATTACK_POWER_MODS packs unsigned positive and negative magnitudes into two u16 fields. p.set_unit_attack_power_mods(s.ap_mods.max(0) as u16, (-s.ap_mods).max(0) as u16); p.set_unit_mindamage(s.dmg_min as f32); p.set_unit_maxdamage(s.dmg_max as f32); @@ -415,49 +301,24 @@ pub fn build_resistance_values(guid: u64, total: [u32; 7]) -> SMSG_UPDATE_OBJECT }) } -/// Build a VALUES partial-update carrying `PLAYER_FIELD_COINAGE` so the player's money updates LIVE -/// after looting. Player mask. Same `dirty_reset` discipline as `build_health_values` so -/// the wire carries ONLY the coinage field and never re-sends OBJECT_FIELD_TYPE (the crash field). pub fn build_coinage_values(guid: u64, money: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { player.set_player_field_coinage(money as i32); }) } -/// Build a VALUES partial-update carrying `PLAYER_AMMO_ID` so the client treats ammo as loaded — -/// without it Auto Shot greys out / refuses ("Ammo needs to be in the paper-doll ammo slot"). The value -/// is the ammo item ENTRY (mangos convention: `SetUInt32Value(PLAYER_AMMO_ID, item->GetEntry())`; the -/// client resolves the count + projectile from it). Uses gtker's typed `set_player_ammo_id` (it maps to -/// the vanilla field index internally — safer than a hand-rolled constant). Same `dirty_reset` discipline -/// as `build_coinage_values` so the wire carries ONLY this field and never re-sends OBJECT_FIELD_TYPE. pub fn build_player_ammo_id_values(guid: u64, ammo_entry: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { player.set_player_ammo_id(ammo_entry as i32); }) } -/// Build a VALUES partial-update carrying `PLAYER_SELF_RES_SPELL`, the Self-Resurrection Option the -/// 5875 death dialog offers as its second button. `spell_id` 0 clears it. Owner-only field; same -/// `dirty_reset` discipline as `build_coinage_values`, so `OBJECT_FIELD_TYPE` is never re-sent. pub fn build_self_res_spell_values(guid: u64, spell_id: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { player.set_player_self_res_spell(spell_id as i32); }) } -/// `PLAYER_CHARACTER_POINTS1` partial VALUES — the talent pane's unspent-points counter. Pushed -/// LIVE after a talent pick (fires the client's CHARACTER_POINTS_CHANGED → TalentFrame refresh) -/// and once after the login CREATE for a character with spent points (the CREATE's formula counts -/// EARNED only). Same `dirty_reset` discipline as the other partial builders (never re-sends -/// OBJECT_FIELD_TYPE, the 5875 crash field). -/// `PLAYER_SKILL_INFO[slot]` partial VALUES — a LIVE skill-up/train moves the open skill -/// pane without a relog, and the 5875 client generates its own yellow "Your skill in X has -/// increased to N." chat line from this field change (mangos sends no chat packet — verified -/// against cm_Player::UpdateSkillPro). Slot comes from the session's skill-slot map (the login -/// CREATE layout, `skill_slot_layout`); same dirty_reset discipline as every partial builder. -/// Build a VALUES partial carrying only the owner's `UNIT_FIELD_SUMMON`: points the player at -/// their summoned pet's guid (what the client's pet frame keys on). Pass 0 to clear on despawn. -/// Same dirty_reset discipline as every VALUES builder here (see `build_health_values`). pub fn build_owner_summon_values(owner_guid: u64, pet_guid: u64) -> SMSG_UPDATE_OBJECT { player_values(owner_guid, |player| { player.set_unit_summon(Guid::new(pet_guid)); @@ -483,11 +344,6 @@ pub fn build_talent_points_values(guid: u64, points: u32) -> SMSG_UPDATE_OBJECT }) } -/// Build a VALUES partial-update carrying `PLAYER_XP` + `PLAYER_NEXT_LEVEL_XP` so the client's XP -/// bar advances LIVE (Tier 5 / XP). `SMSG_LOG_XPGAIN` only shows the floating "+N" text — the bar -/// itself is driven by these descriptor fields, which otherwise only sync at create-object (login). -/// Player-only (these are player fields). Same `dirty_reset` discipline as `build_health_values` so -/// the wire carries ONLY the two xp fields and never re-sends OBJECT_FIELD_TYPE (the crash field). pub fn build_player_xp_values(guid: u64, xp: u32, next_level_xp: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { player.set_player_xp(xp as i32); @@ -495,11 +351,6 @@ pub fn build_player_xp_values(guid: u64, xp: u32, next_level_xp: u32) -> SMSG_UP }) } -/// Build `SMSG_UPDATE_AURA_DURATION` (opcode 0x0137) — the buff/debuff TIMER for the player's OWN -/// auras. The 1.12 UpdateMask aura array carries NO duration, so without this packet the client shows -/// "0 seconds" and flashes the icon (the buff looks about to expire). `duration_ms` is the remaining -/// window for the aura in `slot`. Sent after the aura VALUES sync when an aura with a finite duration -/// is applied/refreshed on this player. pub fn build_aura_duration(slot: u8, duration_ms: u32) -> SMSG_UPDATE_AURA_DURATION { SMSG_UPDATE_AURA_DURATION { aura_slot: slot, @@ -507,12 +358,6 @@ pub fn build_aura_duration(slot: u8, duration_ms: u32) -> SMSG_UPDATE_AURA_DURAT } } -/// Build a VALUES partial-update setting a single `PLAYER_FIELD_INV_SLOT[slot]` to `item_guid` (pass -/// `0` to CLEAR the slot) — the slot→item pointer the client reads to place an item in a bag cell. A -/// bought/looted item only appears (and a sold/consumed item only vanishes) LIVE if this rides -/// alongside the item CREATE/DESTROY; without it the change shows only after a relog. `None` for a -/// non-inventory slot ordinal. Player mask + the same `dirty_reset` discipline as the rest of this -/// family (so it never re-sends OBJECT_FIELD_TYPE — the 5875 crash field). pub fn build_inv_slot_values( player_guid: u64, slot: u8, @@ -524,12 +369,6 @@ pub fn build_inv_slot_values( })) } -/// Build a VALUES partial-update for `PLAYER_VISIBLE_ITEM[slot]` — the descriptor the client -/// renders the 3D gear model (and paperdoll slot) from. The LOGIN create sets -/// these (entity.rs), but a MID-SESSION equip only relayed the INV_SLOT guid pointer, so gear -/// never appeared on the model until relog. `item` carries the entry, Random Property and -/// permanent enchantment; a default view clears (unequip). `None` for a -/// non-equipment slot (bags/backpack are not model-visible). pub fn build_visible_item_values( player_guid: u64, slot: u8, @@ -541,10 +380,6 @@ pub fn build_visible_item_values( })) } -/// Build a VALUES partial-update for an ITEM object carrying its stack count + durability — so a stack -/// merge/split or a repair/wear shows LIVE without a relog (the item's OWN fields, distinct from the -/// player's bag-slot pointer in [`build_inv_slot_values`]). Item mask + the same `dirty_reset` -/// discipline as the player/unit families (never re-sends OBJECT_FIELD_TYPE). pub fn build_item_values(guid: u64, stack_count: u32, durability: u32) -> SMSG_UPDATE_OBJECT { let mut item = UpdateItem::builder().finalize(); item.dirty_reset(); @@ -559,9 +394,6 @@ pub fn build_item_values(guid: u64, stack_count: u32, durability: u32) -> SMSG_U } } -/// A VALUES update of an item's permanent enchantment word, so an in-place enchant shows without a -/// relog. `client_enchant_id` is the ID the client resolves, 0 for none. Like -/// [`build_item_values`], it never re-sends OBJECT_FIELD_TYPE. pub fn build_item_enchantment_values(guid: u64, client_enchant_id: u32) -> SMSG_UPDATE_OBJECT { let mut item = UpdateItem::builder().finalize(); item.dirty_reset(); @@ -575,32 +407,18 @@ pub fn build_item_enchantment_values(guid: u64, client_enchant_id: u32) -> SMSG_ } } -/// Build a raw VALUES partial-update on a CONTAINER (bag) object setting `CONTAINER_FIELD_SLOT_N` -/// to `item_guid` (pass `0` to CLEAR the slot pointer). `slot_in_bag` is 0-indexed (slot 0 is the -/// first bag slot, matching `CONTAINER_FIELD_SLOT_1`). Uses the raw path because the gtker vanilla -/// `UpdateContainerBuilder` only exposes `set_container_slot_1`; slots 1+ are unreachable through -/// its typed API (the same 'gtker descriptor-setter wall' as multi-aura). Returns `(opcode, body)` -/// for `Outbound::Raw`. Does NOT set `OBJECT_FIELD_TYPE` — safe on a partial VALUES update. pub fn build_container_slot_values( bag_guid: u64, slot_in_bag: u8, item_guid: u64, ) -> (u16, Vec) { let mut mask = update_mask::UpdateMaskValues::new(); - // CONTAINER_FIELD_SLOT_1 = 50; each slot is a u64 spanning two u32 field indices. let field = update_mask::idx::CONTAINER_FIELD_SLOT_1 + u16::from(slot_in_bag) * 2; mask.set_u64(field, item_guid); build_values_update_raw(bag_guid, &mask) } -// One argument per UPDATE_FIELD the ding writes — the wire's field list, not an accidental parameter pile. #[allow(clippy::too_many_arguments)] -/// Build a VALUES partial-update for a level-up "ding" (Tier 5 / XP). Pushes the descriptor fields -/// `SMSG_LEVELUP_INFO` does NOT carry — `UNIT_FIELD_LEVEL` (idx 34; the character panel reads this, -/// the popup is cosmetic only), `UNIT_FIELD_MAXHEALTH` + `UNIT_FIELD_HEALTH` (the new-level full -/// heal), and `PLAYER_XP`/`PLAYER_NEXT_LEVEL_XP`. UpdatePlayer mask (player descriptor). Same -/// `dirty_reset` discipline as `build_health_values`: finalize, reset, then re-set ONLY these fields, -/// so OBJECT_FIELD_TYPE is never re-sent (re-sending it strips the PLAYER bit → 5875 client crash). pub fn build_levelup_values( guid: u64, level: u32, @@ -616,7 +434,6 @@ pub fn build_levelup_values( player.set_unit_level(level as i32); player.set_unit_maxhealth(max_health as i32); player.set_unit_health(health as i32); - // The ding also moves the mana/rage DENOMINATOR (new-level max power), matching build_max_vitals_values. match power_b { 1 => { player.set_unit_maxpower2(max_p); @@ -646,11 +463,6 @@ mod lint_tests { use super::*; use wow_world_messages::vanilla::ServerMessage; - /// The TYPED-path half of the packet-lint wall (pairs with - /// `world::packet_lint`'s raw-frame half): serialize a representative from every - /// `build_*_values` shape in this file and assert the wire mask NEVER carries - /// `OBJECT_FIELD_TYPE` (bit 2) — the 5875 null+0x110 crash class a missing `dirty_reset` - /// reintroduces. `lyracore_shared::values_mask` decodes the frames exactly as the client would. #[test] fn every_values_builder_serializes_without_the_object_type_bit() { let g = 0x9u64; @@ -712,7 +524,6 @@ mod lint_tests { for (name, m) in msgs { let mut buf = Vec::new(); m.write_unencrypted_server(&mut buf).unwrap(); - // Unencrypted server framing: size u16 BE + opcode u16 LE, then the body. let body = &buf[4..]; let updates = lyracore_shared::values_mask::parse_values_updates(body); assert!( diff --git a/gateway/src/codec/weather.rs b/gateway/src/codec/weather.rs index 7390d8506..57a9192cc 100644 --- a/gateway/src/codec/weather.rs +++ b/gateway/src/codec/weather.rs @@ -1,41 +1,21 @@ -//! Weather wire mapping: one zone's stored sky to `SMSG_WEATHER`. -//! -//! The Module owns the durable state and never mentions sounds; the sound band is a client-facing -//! protocol detail, so it lives here with the rest of the packet mapping. - use wow_world_messages::vanilla::{WeatherChangeType, WeatherType, SMSG_WEATHER}; -/// A minimal mirror of the `game_zone_weather` row the encoder needs — the same shape as -/// [`super::EntityView`] and the other row views, decoupled from the module's table type. -/// -/// `weather_type` is the vanilla wire discriminant (0 fine, 1 rain, 2 snow, 3 storm) and -/// `intensity` is the packet's grade. [`Default`] is fine weather, which is also what a zone with -/// no row means. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct ZoneWeatherView { pub weather_type: u8, pub intensity: f32, } -// CMaNGOS 1.12 weather sound ids (`WeatherSounds` in cmangos-classic `Weather.cpp`). The client -// resolves these against its own sound catalogue; sending 0 means "no weather sound". const WEATHER_NOSOUND: u32 = 0; const RAIN_SOUNDS: [u32; 3] = [8533, 8534, 8535]; const SNOW_SOUNDS: [u32; 3] = [8536, 8537, 8538]; const STORM_SOUNDS: [u32; 3] = [8556, 8557, 8558]; -/// Grade thresholds separating the light / medium / heavy sound bands, from cmangos-classic -/// `Weather::GetSound()`. Both comparisons are strict `<`, so a grade sitting exactly on a -/// boundary belongs to the HEAVIER band. const MEDIUM_BAND: f32 = 0.40; const HEAVY_BAND: f32 = 0.70; -/// Rain alone has a fourth, silent band. CMaNGOS's own comment: rain below this grade is not drawn -/// on every client weather-detail setting, and rain without sound beats sound without rain. Snow -/// and storm have no such cutoff — their lightest grade still carries its light sound. const RAIN_AUDIBLE: f32 = 0.27; -/// The band index of `grade` within a three-sound family: 0 light, 1 medium, 2 heavy. fn band(grade: f32) -> usize { if grade < MEDIUM_BAND { 0 @@ -46,8 +26,6 @@ fn band(grade: f32) -> usize { } } -/// The 1.12 sound id for a weather type at a grade. An unknown type is fine weather, which is -/// silent. fn weather_sound(weather_type: WeatherType, grade: f32) -> u32 { match weather_type { WeatherType::Rain if grade < RAIN_AUDIBLE => WEATHER_NOSOUND, @@ -58,18 +36,6 @@ fn weather_sound(weather_type: WeatherType, grade: f32) -> u32 { } } -/// Build `SMSG_WEATHER` for a zone's current sky. -/// -/// Degrades rather than refuses, because the alternative is a broken world entry: an unknown -/// weather discriminant becomes fine weather, and a grade the Module's Gates should already have -/// kept finite and inside `0.0..=1.0` is clamped again here — remote state is untrusted input, and -/// a NaN grade reaches the client as a packet no 1.12 client can render safely. Fine weather is -/// always sent at grade 0 whatever the row claims, matching the client's own expectation that a -/// clear sky has no intensity. -/// -/// `change` is [`WeatherChangeType::Instant`] for a synchronizing send (world entry, zone entry, -/// forced weather) so stale client weather is replaced at once, and -/// [`WeatherChangeType::Smooth`] for a scheduled change that should fade in. pub fn build_weather(view: ZoneWeatherView, change: WeatherChangeType) -> SMSG_WEATHER { let weather_type = WeatherType::try_from(u32::from(view.weather_type)).unwrap_or(WeatherType::Fine); @@ -97,9 +63,6 @@ mod tests { } } - /// The band table, pinned against cmangos-classic `Weather::GetSound()` rather than recomputed - /// from the implementation: rain is silent below 0.27, and every boundary value belongs to the - /// heavier band because CMaNGOS compares with a strict `<`. #[test] fn rain_sound_bands_match_cmangos() { let sound = |grade| build_weather(sky(1, grade), WeatherChangeType::Smooth).sound_id; @@ -113,7 +76,6 @@ mod tests { assert_eq!(sound(0.9999), 8535); } - /// Snow and storm have no silent band — the lightest grade still carries its light sound. #[test] fn snow_and_storm_sound_bands_match_cmangos() { let sound = @@ -143,8 +105,6 @@ mod tests { assert_eq!(msg.sound_id, 0); } - /// Story 11: the grade an unmodified 1.12 client receives is always finite and inside the - /// vanilla range, however damaged the row that produced it. #[test] fn the_grade_sent_is_always_finite_and_inside_the_vanilla_range() { for intensity in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 42.0, 1.0] { @@ -169,7 +129,6 @@ mod tests { ); } - /// A zone with no row reads as fine weather, so the view's own default has to be fine. #[test] fn the_default_view_is_fine_weather() { let msg = build_weather(ZoneWeatherView::default(), WeatherChangeType::Instant); diff --git a/gateway/src/codec/weather/subscriptions.rs b/gateway/src/codec/weather/subscriptions.rs index 151b0fdb3..880b40ad2 100644 --- a/gateway/src/codec/weather/subscriptions.rs +++ b/gateway/src/codec/weather/subscriptions.rs @@ -1,5 +1,3 @@ -//! Table Relays for this Protocol Family. - use crate::codec::{self}; use crate::stdb::bindings::*; use crate::stdb::world_index::ShardId; @@ -9,9 +7,6 @@ use std::sync::atomic::Ordering; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::WeatherChangeType; -/// Encode one zone's sky. The audience decision (the viewer's stored zone) already happened in -/// `world_view::weather_changed`, and the changed row carried the whole packet, so nothing is read -/// here. pub(crate) fn weather_outbound( view: codec::ZoneWeatherView, change: WeatherChangeType, @@ -21,9 +16,6 @@ pub(crate) fn weather_outbound( )))] } -/// Read `zone_id`'s current sky off this shard's cache and encode it — the zone-entry leg, run on -/// the arriving viewer's own writer thread. Goes through the same `WeatherStore` seam world entry -/// uses, so a zone with no row means fine weather here too. pub(crate) fn zone_weather_outbound( store: &St, zone_id: u32, @@ -34,15 +26,6 @@ pub(crate) fn zone_weather_outbound( ))] } -/// A zone's sky changed → `SMSG_WEATHER` to every viewer standing in that zone on this shard, and -/// to nobody else. The zone comparison IS the audience: weather is per zone, so an Elwynn row can -/// never reach a Westfall viewer however close the two stand. -/// -/// Smooth, because this is a scheduled drift the client should fade in. The synchronizing sends -/// (world entry, zone entry) are Instant and come from [`zone_crossed`] and the login path. -/// -/// The row carries the whole packet, so no cache read happens in the job — which also means a -/// later row cannot overtake an earlier one's encoding and show a client the wrong sky. pub(crate) fn weather_changed(view: &WorldView, shard: ShardId, row: &ZoneWeather) { let zone_id = row.zone_id; let view_of_row = crate::codec::ZoneWeatherView { @@ -62,30 +45,11 @@ pub(crate) fn weather_changed(view: &WorldView, shard: ShardId, row: &ZoneWeathe } } -/// The `game_zone_weather` relay leg, reachable from the world-session tests: the encrypted-wire -/// test drives the SAME body [`arm_shard`] wires to the table callback, so a live weather row and a -/// test row take one path. Test-only because a shard's own callback is the sole production caller. #[cfg(test)] pub(crate) fn relay_zone_weather(view: &WorldView, shard: ShardId, row: &ZoneWeather) { weather_changed(view, shard, row); } -/// The self entity's own row says it is standing in a different zone than the viewer's routing -/// state remembers → move the routing state and send the destination zone's current weather ONCE, -/// Instant, so the stale source-zone sky is replaced immediately (story 7). -/// -/// This covers walking, a same-map teleport and a taxi landing — every path that changes the zone -/// without rebuilding the session. A cross-map path tears the viewer down and re-runs world entry, -/// which sends its own weather. -/// -/// `store` is this shard's coordinator, taken through the [`crate::world::WeatherStore`] seam -/// because the destination's sky is READ, on the arriving viewer's own writer thread, rather than -/// carried by the row that triggered this. -/// -/// The row-delta comparison comes FIRST because this sits on the pump, under the highest-rate -/// table in the module: every creature heartbeat and every player step that stays inside its zone -/// leaves here without touching the viewer registry. Creatures never carry a zone at all, so their -/// updates are all `0 -> 0`. pub(crate) fn zone_crossed( view: &WorldView, store: &St, diff --git a/gateway/src/codec/weather/subscriptions/audience_tests.rs b/gateway/src/codec/weather/subscriptions/audience_tests.rs index 67dd08d53..33abee428 100644 --- a/gateway/src/codec/weather/subscriptions/audience_tests.rs +++ b/gateway/src/codec/weather/subscriptions/audience_tests.rs @@ -20,8 +20,6 @@ fn zone_weather(zone_id: u32, weather_type: u8, intensity: f32) -> ZoneWeather { } } -/// A `WeatherStore` holding one zone's sky. Stands in for a shard's `game_zone_weather` cache, -/// which a unit test has no node to populate. #[derive(Clone)] struct CannedWeather(Option<(u32, u8, f32)>); @@ -46,9 +44,6 @@ fn only_weather(out: Vec) -> wow_world_messages::vanilla::SMSG_WEATHER } } -/// Stories 5 and 6, the Elwynn/Westfall pair: a zone's weather reaches every viewer standing in -/// THAT zone and no viewer anywhere else, however near they are — weather is per zone, and the -/// spatial index has no say in it. #[test] fn a_weather_row_reaches_its_own_zone_and_no_other() { let view = WorldView::new(true); @@ -90,8 +85,6 @@ fn a_weather_row_reaches_its_own_zone_and_no_other() { ); } -/// Story 7: crossing into a new zone replaces the stale source-zone sky ONCE, instantly. -/// Staying put is silence — the slow scheduler must not turn every grid crossing into a packet. #[test] fn crossing_into_a_zone_sends_its_weather_once() { let view = WorldView::new(true); @@ -104,7 +97,6 @@ fn crossing_into_a_zone_sends_its_weather_once() { view.add_viewer_on_shard(resident.clone(), anchor, 0); let store = CannedWeather(Some((WESTFALL, 3, 0.9))); - // Elwynn → Westfall. zone_crossed(&view, &store, 0, walker.self_guid, ELWYNN, WESTFALL); let m = only_weather(queued_job(&walker_rx)); assert_eq!(m.weather_type, WeatherType::Storm); @@ -112,9 +104,6 @@ fn crossing_into_a_zone_sends_its_weather_once() { assert_eq!(m.sound_id, 8558); assert_eq!(m.change, WeatherChangeType::Instant); - // Still walking around Westfall; a row the Module could not resolve a zone for; and a row - // whose own delta claims a crossing the viewer has already been told about (a re-created - // entity row starts from the unresolved zone). zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, WESTFALL); zone_crossed(&view, &store, 0, walker.self_guid, WESTFALL, 0); zone_crossed(&view, &store, 0, walker.self_guid, 0, WESTFALL); @@ -133,7 +122,6 @@ fn crossing_into_a_zone_sends_its_weather_once() { "another player's crossing is not this viewer's business" ); - // …and the arriving viewer now routes with the destination zone. weather_changed(&view, 0, &zone_weather(WESTFALL, 2, 0.3)); assert_eq!( only_weather(queued_job(&walker_rx)).weather_type, @@ -141,8 +129,6 @@ fn crossing_into_a_zone_sends_its_weather_once() { ); } -/// Story 10: a destination with no weather row is fine weather, not a missing packet — the -/// client would otherwise keep rendering the sky of the zone it left. #[test] fn crossing_into_a_zone_with_no_weather_row_sends_fine_weather() { let view = WorldView::new(true); diff --git a/gateway/src/config.rs b/gateway/src/config.rs index bf7bf8ca4..301aa1459 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -1,6 +1,3 @@ -//! Gateway configuration. All operational, none of it game state. - -// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. #![cfg_attr( not(test), deny( @@ -17,25 +14,15 @@ use crate::accept::BlockingTaskCapacity; #[derive(Clone, Debug)] pub struct GatewayConfig { - /// Bind address for the logon listener (SRP6 + realm list). Default 0.0.0.0:3724. pub logon_bind: String, - /// Bind address for the world listener. Default 0.0.0.0:8085. pub world_bind: String, - /// SpacetimeDB host URI, e.g. http://127.0.0.1:3000. pub stdb_uri: String, - /// Module/database name to connect to. pub module_name: String, - /// Auth token for the privileged coordination connection (reads account/session). pub coordinator_token: Option, pub gateway_id: String, - /// Shared non-waiting gate for blocking logon and World Session tasks. Its size mirrors the - /// Tokio blocking-pool ceiling configured before this value is built. pub(crate) blocking_task_capacity: BlockingTaskCapacity, } -/// The hostname half of the default `gateway_id` — `HOSTNAME` if set (containers commonly export -/// it), else `/proc/sys/kernel/hostname` (Linux, no extra dependency), else a fixed fallback so a -/// gateway always starts rather than failing to compute its own identity. fn default_hostname() -> String { std::env::var("HOSTNAME") .ok() @@ -66,25 +53,8 @@ impl GatewayConfig { } } -// =============================================================================================== -// Shard map — gateway multi-shard routing: the shard map, per-shard coordinators, login routed -// by character location. -// =============================================================================================== - -/// Instances of one map are spread over a shard pool by `instance_id % INSTANCE_BUCKETS`, so a rule -/// can name a slice of a map's instances instead of all of them. -/// -/// The shipped topology only ever uses map-level wildcards (`:*=`); per-bucket rules -/// (splitting one map's instances across several databases) are documented and tested below but not -/// deployed — that is the upgrade path, not a live feature to go hunting for elsewhere. -// Deliberate simplification: a hardcoded modulus IS the whole bucketing scheme. Ceiling — you -// cannot resize the pool without every gateway agreeing on the same number, and rebalancing means -// restarting them. Upgrade path: realm-core owns the assignment table and -// gateways subscribe to it with an epoch, at which point this constant and the env parsing below -// both go away. pub const INSTANCE_BUCKETS: u64 = 8; -/// One `:=` rule. `None` = the `*` wildcard. #[derive(Clone, Debug, PartialEq)] struct ShardRule { map_id: Option, @@ -92,42 +62,14 @@ struct ShardRule { db: String, } -/// Multi-shard routing table: `(map_id, instance-bucket) → database name`. -/// -/// The gateway holds one coordinator connection per database named here, and pins each player's -/// session's reducer calls + AOI scoping to the shard that owns their character's location. -/// -/// **Unconfigured = one entry** (the `LYRACORE_DATABASE` database): every lookup resolves to it, nothing -/// ever routes, and the gateway behaves byte-identically to the single-database build. That is the -/// safety property — a missing, empty, or malformed shard map can only collapse back to today. -/// -/// Config format (env `LYRACORE_SHARD_MAP`, or a file path in `LYRACORE_SHARD_MAP_FILE`; the env var wins). -/// Rules are separated by commas, semicolons, or newlines, and `#` starts a comment: -/// -/// ```text -/// LYRACORE_SHARD_MAP="1:*=lyracore-instances, 389:*=lyracore-instances" -/// ``` -/// -/// A rule is `:=`, where `map_id` is a number or `*` and `bucket` is a -/// number in `0..INSTANCE_BUCKETS` or `*`. `=` is shorthand for `:*=`. -/// ⚠ The OPEN WORLD is `instance_id == 0`, i.e. **bucket 0** — so `*:0=pool-a`, which reads like -/// "the pool's first slot", moves every open-world location there as well. Name the map when you -/// mean instances (`389:0=pool-a`). -/// **The first matching rule wins**; anything unmatched falls through to the default database. -/// A rule that fails to parse is logged and DROPPED — a typo can never silently reroute players. #[derive(Clone, Debug)] pub struct ShardMap { default_db: String, rules: Vec, - /// `LYRACORE_REALM_CORE`: the database that owns accounts, sessions, and the character→shard - /// index. `None` = unconfigured, which is *exactly* today — auth lives on the default database. realm_core: Option, } impl ShardMap { - /// Read the shard map from `LYRACORE_SHARD_MAP` (or the file named by `LYRACORE_SHARD_MAP_FILE`), defaulting - /// every unmatched location to `default_db` (the `LYRACORE_DATABASE` database). Unset → single entry. - /// `LYRACORE_REALM_CORE` names the realm-core database; unset → auth stays on `default_db`. pub fn from_env(default_db: &str) -> Self { let text = match std::env::var("LYRACORE_SHARD_MAP") { Ok(t) => t, @@ -145,10 +87,6 @@ impl ShardMap { .with_realm_core(std::env::var("LYRACORE_REALM_CORE").ok().as_deref()) } - /// Point auth (accounts / sessions / the character→shard index) at a separate `realm-core` - /// database. Blank, absent, or *equal to the default database* all mean "unconfigured" — - /// the degenerate `LYRACORE_REALM_CORE=` config must collapse to today's single-database - /// behavior rather than opening a second connection to the same place. pub fn with_realm_core(mut self, name: Option<&str>) -> Self { self.realm_core = name .map(str::trim) @@ -157,19 +95,10 @@ impl ShardMap { self } - /// The configured realm-core database, or `None` when auth stays on the default database. pub fn realm_core_db(&self) -> Option<&str> { self.realm_core.as_deref() } - /// The database every auth read/write must target, given which databases actually connected. - /// - /// `Ok(db)` = use it. `Err(name)` = realm-core is CONFIGURED but not connected — the caller must - /// **fail closed** (refuse to authenticate) rather than silently fall back to the world DB's - /// stale auth cache. That fallback would be a real security regression: a ban or a password - /// rotation applied on realm-core would stop being enforced the moment realm-core blinked, which - /// is precisely when an attacker would want it to. Backward compatibility is served by the - /// *unconfigured* case, which never reaches the `Err` arm at all. pub fn auth_db(&self, connected: impl Fn(&str) -> bool) -> std::result::Result<&str, &str> { match self.realm_core.as_deref() { None => Ok(&self.default_db), @@ -178,7 +107,6 @@ impl ShardMap { } } - /// Parse the rule text (see the type docs). Malformed rules are logged and skipped. pub fn parse(default_db: &str, text: &str) -> Self { let mut rules = Vec::new(); for raw in text.split(['\n', ',', ';']) { @@ -200,7 +128,6 @@ impl ShardMap { } } - /// The database that owns `(map_id, instance_id)`. Never fails: unmatched → the default shard. pub fn resolve(&self, map_id: u32, instance_id: u64) -> &str { let bucket = instance_id % INSTANCE_BUCKETS; self.rules @@ -209,11 +136,6 @@ impl ShardMap { .map_or(self.default_db.as_str(), |r| r.db.as_str()) } - /// [`resolve`](Self::resolve), degraded to the DEFAULT database when the owning shard is not in - /// `connected` (named by the map but unreachable at startup). The degradation target is the - /// default and never "wherever the caller already is": that is what makes a bad or partly-down - /// shard map collapse to today's single-database behavior instead of pinning a session to a - /// database that owns nothing of theirs. Pure, so the rule is unit-testable without a node. pub fn resolve_connected( &self, map_id: u32, @@ -232,39 +154,13 @@ impl ShardMap { &self.default_db } - /// The default (realm/home) database — where accounts, sessions, and the character list live. pub fn default_db(&self) -> &str { &self.default_db } - // ========================================================================================== - // The instance POOL — the instance-shard pool (instance lifecycle at scale). - // ========================================================================================== - - /// Every database an instance of `map_id` can be routed to — the map's **instance pool**, - /// deduped, in bucket order. - /// - /// Note: this is [`resolve`](Self::resolve) read the other way round, and the instance-shard - /// pool adds **no new assignment mechanism at all**. The shard map's `:=` - /// rule already IS the pool hook: - /// - /// ```text - /// LYRACORE_SHARD_MAP="36:0=lyracore-instances-1, 36:1=lyracore-instances-1, 36:*=lyracore-instances-0" - /// ``` - /// - /// spreads Deadmines runs over two databases with zero code, because `bucket = instance_id % - /// INSTANCE_BUCKETS` and instance ids come from one `auto_inc` allocator on the world shard, so - /// consecutive runs land in consecutive buckets. This method exists only so the routing code can - /// ask *"is this database one of the places this map's instances live"* — the question that - /// protects an in-flight run from a pool resize (see [`instance_owner`](Self::instance_owner)). - /// - /// Unconfigured it is `[default_db]` — "the pool is the one database" — which is why every rule - /// built on it is a no-op on a single-database gateway. pub fn instance_pool(&self, map_id: u32) -> Vec<&str> { let mut pool: Vec<&str> = Vec::new(); for bucket in 0..INSTANCE_BUCKETS { - // `bucket < INSTANCE_BUCKETS`, so `bucket % INSTANCE_BUCKETS == bucket`: passing it as - // the instance id enumerates exactly the buckets, and only the buckets. let db = self.resolve(map_id, bucket); if !pool.contains(&db) { pool.push(db); @@ -273,45 +169,7 @@ impl ShardMap { pool } - /// Which database owns instance `instance_id` of `map_id`, for a character the `holder` - /// database currently holds. [`resolve_connected`](Self::resolve_connected) plus **one** - /// stickiness rule — and that rule is what makes "a second instances database can be added by - /// config alone, and existing runs are undisturbed" true rather than aspirational. - /// - /// # Why the shard map alone is not sufficient - /// - /// Growing the pool means re-pointing buckets, and a bucket is a property of the instance ID, - /// not of the run: an instance minted while bucket 5 belonged to `instances-0` is still bucket 5 - /// when the operator hands bucket 5 to `instances-1`. The shard map alone would then answer - /// `instances-1` for a run that is LIVE on `instances-0`, and the gateway would dutifully mirror - /// a second, empty copy of the dungeon there and transfer the player into it — forking the party - /// away from the population it was fighting. - /// - /// # What identifies an in-flight instance - /// - /// **The shard that already holds a character inside it.** There is no realm-wide instance→shard - /// index yet, so this is the only durable evidence either database has — and it is - /// evidence the caller has already read, because finding the holder is how world entry starts. - /// So: if the holder is itself a member of this map's pool, and it reports the character inside a - /// non-open-world instance, the holder wins. - /// - /// It deliberately cannot capture the INITIAL hop, and that is the whole trick: a player stepping - /// through the portal is held by the open-world shard, which is not in a dungeon map's pool, so - /// the shard map decides and the new run lands on the pool as designed. New runs spread; live - /// runs stay put. - /// - /// # Residual, stated out loud - /// - /// A party member who has **not yet entered** a re-bucketed live instance is still routed by the - /// map, and will fork it (they are held by the open-world shard, which is not in the pool, so - /// there is nothing to be sticky about). Closing that needs the instance→shard index — - /// until then the operator procedure is "grow the pool while it is idle". - /// - /// # Unconfigured - /// - /// `instance_pool == [default_db]` and the holder is the default database, so this returns - /// exactly what `resolve_connected` returns, for every input. (And a single-database gateway - /// never calls it at all — `Coordinator::is_sharded` short-circuits first.) + /// Rebucket only an idle pool; new entrants can split a live Party across shards. pub fn instance_owner<'a>( &'a self, map_id: u32, @@ -319,20 +177,12 @@ impl ShardMap { holder: &'a str, connected: impl Fn(&str) -> bool, ) -> &'a str { - // `instance_id != 0` first: the OPEN WORLD is instance 0 and is not an instance at all. - // Without it, every character on any second shard would pin itself there forever — the - // `*:0=` footgun the type docs warn about, re-created in code instead of in config. if instance_id != 0 && connected(holder) && self.instance_pool(map_id).contains(&holder) { return holder; } self.resolve_connected(map_id, instance_id, connected) } - /// Every distinct WORLD SHARD this map can resolve to, default first. These are the databases - /// that hold characters, so these — and only these — are what a character-location probe walks. - /// Realm-core is deliberately not here: it holds no characters, so probing it is pure waste. - /// ⚠ **Default stays FIRST.** The fallback census walks the connected set in this order and - /// must try the default database before any rule shard. pub fn shards(&self) -> Vec { let mut dbs = vec![self.default_db.clone()]; for r in &self.rules { @@ -343,45 +193,6 @@ impl ShardMap { dbs } - /// **Will this realm's dungeon populations actually get spawned, exactly once, on the database - /// that runs them?** - /// - /// Two facts have to agree and until now no single process held both: the ROUTING is this - /// gateway's shard map, and `hosts_instances` is a column on each DATABASE. The gateway is the - /// layer that can hold both — it subscribes `game_config` on every connection in the set (see - /// `stdb/connection.rs`), so `hosts_instances(db)` below is a real read, not a guess. The MODULE - /// cannot own this check even in principle: `create_instance_with_id` knows its own flag but has - /// no idea whether any *other* database exists, let alone whether the shard map points dungeon - /// traffic at one — and the elastic-sharding spec forbids it from learning - /// (`no_module_game_logic_reads_a_shard_id`). - /// - /// # The two failure directions - /// - /// A dungeon portal's areatrigger runs on the database the PLAYER is standing on — the default - /// (open-world) shard — which files the `game_instance` row. The POPULATION is spawned by - /// whichever database owns that instance, when the gateway mirrors the id there - /// (`ensure_instance`). So: - /// - /// - [`InstanceHosting::NoHost`] means the owning database refuses population hosting. - /// It is fatal because every dungeon would remain an empty lease. - /// - [`InstanceHosting::LoadNotMoved`] means the default database builds population for - /// a dungeon owned elsewhere. Transfer still works, but the unused population wastes work. - /// - /// A correctly configured Realm is silent on either topology. - /// - /// # Inputs - /// - /// - `imported_maps` records source coverage across connected Shards. Every imported dungeon - /// participates even when its route uses the default or a wildcard. - /// - `hosts_instances(db)` — that database's `game_config.hosts_instances`, or `None` when the - /// row is missing/unreadable. `None` reads as **true**, mirroring the module's own - /// `hosts_instance_populations` default: a database that predates the column behaves as it did - /// before the column existed, and an unreadable config can never fabricate a fatal. - /// - `connected(db)` — the databases that actually connected. Routing to an unreachable shard - /// degrades to the default database ([`resolve_connected`](Self::resolve_connected)), and this - /// check follows that degradation: if the instances shard is down AND the world shard does not - /// host populations, dungeons would be empty. That is [`InstanceHosting::UnreachableHost`] — - /// the same *symptom* as `NoHost`, deliberately NOT the same severity, see that variant. pub fn check_instance_hosting( &self, imported_maps: &[u32], @@ -389,14 +200,6 @@ impl ShardMap { connected: impl Fn(&str) -> bool, ) -> InstanceHosting { let hosts = |db: &str| hosts_instances(db).unwrap_or(true); - // Every (dungeon map, instance bucket) pair this realm can mint — i.e. every database a - // dungeon run can land on. `bucket < INSTANCE_BUCKETS`, so passing the bucket straight in as - // the instance id enumerates exactly the buckets and only the buckets (`instance_pool`'s - // trick, same expression). Bucket 0 is probed as instance id 0, which is the OPEN WORLD id - // and never a real instance — that is fine and deliberately not special-cased: `resolve` - // reads `instance_id % INSTANCE_BUCKETS` and nothing else, so 0 and 8 are the same probe. - // Check imported dungeon maps even when a default or wildcard rule owns them. - // The bootstrap fixture still requires Deadmines before its first source import. let owners = lyracore_shared::instance::DUNGEON_MAPS .iter() .filter(|&&map_id| { @@ -407,9 +210,6 @@ impl ShardMap { .flat_map(|&map_id| (0..INSTANCE_BUCKETS).map(move |bucket| (map_id, bucket))); let mut load_not_moved: Option = None; for (map_id, instance_id) in owners { - // `resolve_connected`'s degradation rule, inlined so this stays a SILENT pure function: - // it logs one warning per call, and calling it 8× per dungeon map at startup would put - // eight identical lines in front of the one message that matters. let resolved = self.resolve(map_id, instance_id); let degraded = !connected(resolved); let owner = if degraded { @@ -417,10 +217,6 @@ impl ShardMap { } else { resolved }; - // The owner hosts nothing ⇒ dungeons WILL be empty. But how we got here decides the - // severity, and conflating the two turns a blip into an outage (see `UnreachableHost`): - // a routed-but-unreachable shard is a TRANSIENT condition on a config the operator got - // right, and `Coordinator::connect` deliberately keeps serving the open world through it. if !hosts(owner) && degraded { return InstanceHosting::UnreachableHost(format!( "instances of DUNGEON map {map_id} are routed to database {resolved}, which is \ @@ -450,10 +246,6 @@ impl ShardMap { lands here on the one member that forgot it, not on all eight." )); } - // The inverse: the portal runs on the default database, so if THAT - // one hosts populations while the run happens elsewhere, it pays for a dungeon twice - // over — spawn then evict. Recorded, not returned: `NoHost` is strictly worse, so a - // configuration that is somehow both must report the fatal. if owner != self.default_db && hosts(&self.default_db) && load_not_moved.is_none() { load_not_moved = Some(format!( "instances of DUNGEON map {map_id} are owned by database {owner}, but the \ @@ -472,8 +264,6 @@ impl ShardMap { } } - /// Every database the gateway opens a coordinator connection to, default first: the world shards - /// plus realm-core when configured. pub fn databases(&self) -> Vec { let mut dbs = self.shards(); if let Some(rc) = &self.realm_core { @@ -485,48 +275,15 @@ impl ShardMap { } } -/// The startup verdict of [`ShardMap::check_instance_hosting`] — will this realm's dungeon -/// populations actually be spawned, and on the right database? -/// -/// Correct hosting carries no message on either topology. #[derive(Clone, Debug, PartialEq)] pub enum InstanceHosting { - /// Every dungeon map's instances land on a database that hosts populations, and no database - /// spawns a population it will not run. Nothing to report. Consistent, - /// The default database will spawn dungeon populations it does not run: they are evicted - /// again after the transfer. Wasteful, not broken — a warning. LoadNotMoved(String), - /// Dungeons WOULD be empty right now, but only because the database the map is routed to did not - /// connect — so entries degrade back to a default database that hosts nothing. **Not fatal**, and - /// that split is the point of this variant existing. - /// - /// The configuration is CORRECT; another process is down. `Coordinator::connect` already treats - /// an unreachable non-default shard as a degradation rather than a failure, on purpose and in - /// writing ("routing to a missing shard degrades to the default database, i.e. to today's - /// behavior") — because the open world, every login, and every non-dungeon session are fine. - /// Refusing to boot here would convert a transient blip in the instances database into a total - /// realm outage, and there is no retry to recover it: `main` propagates the error and the process - /// exits, so a gateway started seconds too early in a node restart would need a human. Empty - /// dungeons for the duration is strictly less bad than no realm at all, so: log it as an error - /// (the shard's own "unreachable" error is already on the line above) and come up. UnreachableHost(String), - /// NOBODY will spawn the population: every dungeon is created empty, and no amount of - /// waiting fixes it because the owning database is CONNECTED and says it hosts nothing. Fatal. NoHost(String), } impl InstanceHosting { - /// Apply the verdict at startup: log the warning, or refuse to start. - /// - /// `Err` is the whole point here — an empty-dungeon realm must not come up quietly, because - /// the symptom (bot tests failing as if combat or questing had regressed) points at everything - /// except the config line that caused it. The severity split is here, in a pure function, so a - /// test can pin "the damaging direction is fatal and the wasteful one is not" without a node. - /// - /// Only a MISCONFIGURATION is fatal. The two recoverable verdicts log and return `Ok`, because a - /// startup check that refuses to boot on a transient condition trades a recoverable degradation - /// for an outage — see [`InstanceHosting::UnreachableHost`]. pub fn enforce(self) -> std::result::Result<(), String> { match self { Self::Consistent => Ok(()), @@ -543,7 +300,6 @@ impl InstanceHosting { } } -/// `[:]=` → a rule, or `None` if it doesn't parse. fn parse_shard_rule(rule: &str) -> Option { let (loc, db) = rule.split_once('=')?; let db = db.trim(); @@ -574,60 +330,30 @@ fn parse_shard_rule(rule: &str) -> Option { }) } -/// Emit the `PLAYER_QUEST_LOG_*` descriptor fields (the L-window quest log). Default ON — the layout -/// (index 198, 20 slots × 3 u32, 6-bit-per-counter packing) was VERIFIED -/// against a live 5875 client on 2026-06-24 (empty log + multi-quest log render with no crash; a kill -/// updated the objective to "1/10" live). Set `LYRACORE_QUEST_LOG=0` to disable (escape hatch if a future -/// content edge case misbehaves). Pure config, shared by the world login-sync path and the stdb quest-log -/// relay — it lives here so the stdb tier doesn't reach up into `world` for it. pub fn quest_log_fields_enabled() -> bool { std::env::var("LYRACORE_QUEST_LOG").map_or(true, |v| v != "0") } -/// The address the realm list advertises, overriding the `game_realm` row. -/// -/// The row is seeded `127.0.0.1:8085`, which is right for the loopback contributor fixture and -/// wrong for every other way of reaching this gateway: a client that completed the SRP6 handshake -/// against a LAN (or hosted) logon tier is then told to open the world connection to *its own* -/// loopback. That failure is silent and looks like "the realm doesn't work", because the logon half -/// succeeded. -/// -/// Unset — production and every existing deployment — is byte-identical to before: the row wins. -/// `lyracore dev up [--lan IP]` sets it to the address it actually bound the world listener to, so -/// what is advertised is a property of how the gateway was launched rather than of a row somebody -/// may have edited on one shard. pub fn advertised_realm_address() -> Option { realm_address_override(std::env::var("LYRACORE_REALM_ADDRESS").ok()) } -/// The pure half of [`advertised_realm_address`]. An empty or blank value reads as unset — an -/// exported-but-empty variable is a shell accident (`LYRACORE_REALM_ADDRESS=$MAYBE_UNSET`), and -/// advertising "" would break the realm list rather than override it. pub fn realm_address_override(raw: Option) -> Option { raw.map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } -/// What the realm list advertises: the override when set, the `game_realm` row otherwise. One -/// implementation, so the realm list and the startup check cannot disagree about what is advertised. pub fn advertised_realm_address_or(row_address: String) -> String { advertised_realm_address().unwrap_or(row_address) } -/// Loopback advertised while the world listener is reachable from elsewhere — the one combination -/// that is wrong whatever the surrounding network. NAT and firewalls are unknowable from here, and -/// unparseable input reads as reachable because this only drives a warning. pub fn advertised_address_is_unreachable(advertised: &str, world_bind: &str) -> bool { let bind = host_of(world_bind); - // An empty or absent bind proves nothing about who can reach this listener, so it is not - // grounds to report the advertisement. is_loopback_host(host_of(advertised)) && !bind.is_empty() && !is_loopback_host(bind) } -/// The host half of a `host:port`, with IPv6 brackets stripped. A value with no port reads whole. fn host_of(address: &str) -> &str { let host = match address.rsplit_once(':') { - // `[::1]:8085` — the split is the port separator only because the host is bracketed. Some((host, _)) if !host.is_empty() => host, _ => address, }; @@ -637,23 +363,14 @@ fn host_of(address: &str) -> &str { fn is_loopback_host(host: &str) -> bool { match host.parse::() { Ok(ip) => ip.is_loopback(), - // `127.example.com` is a hostname that merely starts like one, not a loopback address. Err(_) => host.eq_ignore_ascii_case("localhost"), } } -/// Area-of-Interest gate (scaling): the per-player `game_world_entity` subscription is SCOPED to a -/// grid-cell box (before the shared-connection model, a re-subscribing per-player subscription; now -/// an in-process cell index — `stdb::world_index`), instead of the global `SELECT *`. Default ON; -/// the 5×5 50-yd box ≈ vanilla view distance. `LYRACORE_AOI=0` is the escape hatch back to the -/// global subscription. pub fn aoi_enabled() -> bool { std::env::var("LYRACORE_AOI").map_or(true, |v| v != "0") } -/// Total reducer-call connections per shard (the call-pipe pool). Default 4 — sized from the -/// measured ~1000-seats-per-pipe wall; `LYRACORE_CALL_PIPES=1` restores the single-connection -/// call path. Values are clamped to [1, 16]. pub fn call_pipes() -> usize { std::env::var("LYRACORE_CALL_PIPES") .ok() @@ -662,12 +379,6 @@ pub fn call_pipes() -> usize { .clamp(1, 16) } -/// The per-account OWNER identity bound by `establish_session` — deterministic and derived, so -/// logins build NO per-player connection just to mint one. Never node-issued (real identities -/// carry the node's c200 prefix; this is a tagged literal), unique per account. The module stamps -/// it as row owner exactly like a real bound identity (`gw_player_login`'s fail-closed check is -/// satisfied); no client connection ever presents it, so the GATEWAY predicates are the -/// visibility layer for owned rows. pub fn synthetic_owner_identity(account_id: u64) -> [u8; 32] { let mut id = [0u8; 32]; id[..8].copy_from_slice(b"LYRAGWID"); @@ -675,23 +386,14 @@ pub fn synthetic_owner_identity(account_id: u64) -> [u8; 32] { id } -/// A crash-diagnosis probe for the SMSG_COMPRESSED_MOVES corruption at crowd scale: -/// `LYRACORE_WRITER_TRACE=1` turns on a per-session black-box ring inside `spawn_writer` — the -/// last 32 (opcode, declared size, rolling checksum) triples for the exact bytes handed to the -/// socket, dumped to `/tmp/gw-writer-crash/.txt` when a session's writer loop ends on a -/// write error. Default OFF: the ring costs a checksum pass over every outbound body, which is not -/// a cost production should pay to stand still. Mirrors the wire harness's own crash ring -/// (`/tmp/wire-crash/`) so the two can be diffed frame-for-frame at a corruption offset. pub fn writer_trace_enabled() -> bool { std::env::var("LYRACORE_WRITER_TRACE").is_ok_and(|v| v == "1") } -/// Maximum established World Sessions. Unset or zero means unlimited; malformed values fail startup. pub fn max_sessions() -> anyhow::Result { admission_limit("LYRACORE_MAX_SESSIONS") } -/// Queued admissions per tick. Unset or zero means unlimited; malformed values fail startup. pub fn admit_concurrency() -> anyhow::Result { admission_limit("LYRACORE_ADMIT_CONCURRENCY") } @@ -716,30 +418,8 @@ fn parse_admission_limit(name: &str, raw: Option<&std::ffi::OsStr>) -> anyhow::R }) } -/// tokio's own default `max_blocking_threads`, restated here so an unset -/// `LYRACORE_MAX_BLOCKING_THREADS` is today's behaviour byte for byte. -/// -/// This constant is a *mirror* of a tokio internal (`runtime::Builder::new_multi_thread` seeds -/// `max_blocking_threads: 512`), not a value tokio exposes — there is no accessor to read it back. -/// If a tokio bump moves it, this mirror is what keeps "unset == before" true for THIS gateway, -/// which is the property that matters; it is not trying to track tokio. pub const DEFAULT_MAX_BLOCKING_THREADS: usize = 512; -/// The ceiling on tokio's blocking pool, which is the real ceiling on concurrent players. -/// -/// Both accept loops hand admitted sockets to `spawn_blocking` (the wire codecs in -/// `wow_login_messages` / `wow_world_messages` are blocking `std::io`), and a World Session holds -/// its blocking thread for the session's entire life. So `max_blocking_threads` is both the pool -/// ceiling and the size of the listeners' shared, non-waiting [`BlockingTaskCapacity`]. -/// -/// It was previously tokio's inherited default of 512, configured by nothing and reported by -/// nothing. Before the admission gate, a full pool did not refuse: `spawn_blocking` queued the -/// socket without a bound. The shared capacity now rejects excess sockets before task submission. -/// Measured 2026-08-07 on an 8-core box: 600 clients offered seated **477** at 512 and **535** at -/// 4096. -/// -/// The default is 512. A malformed or zero value falls back to this finite -/// default. Zero blocking threads would leave the Gateway unable to serve accepted sockets. pub fn max_blocking_threads() -> usize { max_blocking_threads_from_env( std::env::var("LYRACORE_MAX_BLOCKING_THREADS") @@ -748,9 +428,6 @@ pub fn max_blocking_threads() -> usize { ) } -/// The pure half of [`max_blocking_threads`] (house pattern: [`realm_address_override`]) — the -/// variable is process-global, so the parse is exercised through this rather than by mutating the -/// test process's own environment. pub fn max_blocking_threads_from_env(raw: Option<&str>) -> usize { raw.map(str::trim) .and_then(|v| v.parse::().ok()) @@ -762,8 +439,6 @@ pub fn max_blocking_threads_from_env(raw: Option<&str>) -> usize { mod max_blocking_threads_tests { use super::*; - /// The safety property of this knob: **unset changes nothing**. Every existing deployment - /// keeps the exact 512-thread pool it has been running on. #[test] fn unset_is_tokios_own_default() { assert_eq!(max_blocking_threads_from_env(None), 512); @@ -780,9 +455,6 @@ mod max_blocking_threads_tests { assert_eq!(max_blocking_threads_from_env(Some(" 2048 ")), 2048); } - /// Zero is not "unlimited" the way it is for `max_sessions` — a zero-thread blocking pool is a - /// gateway that accepts every socket and services none of them, silently, which is precisely - /// the failure shape this exists to make impossible. Fall back rather than obey. #[test] fn zero_falls_back_rather_than_seating_nobody() { assert_eq!( @@ -791,9 +463,6 @@ mod max_blocking_threads_tests { ); } - /// A malformed value falls back to the finite default. - /// `-1` matters specifically: it is the shape an operator reaches for to mean "no limit", and - /// it does not parse as `usize`. #[test] fn a_malformed_value_falls_back_to_the_default() { for junk in ["", " ", "-1", "lots", "4096x", "4_096", "1e3", "512.0"] { @@ -810,8 +479,6 @@ mod max_blocking_threads_tests { mod realm_address_tests { use super::*; - /// The whole safety property of this override: **unset changes nothing**. Every existing - /// deployment, production included, keeps advertising the `game_realm` row it always did. #[test] fn an_unset_override_leaves_the_game_realm_row_in_charge() { assert_eq!(realm_address_override(None), None); @@ -819,8 +486,6 @@ mod realm_address_tests { #[test] fn a_blank_override_reads_as_unset_rather_than_advertising_nothing() { - // `LYRACORE_REALM_ADDRESS=$SOMETHING_UNSET` is one exported-but-empty variable away, and - // advertising an empty address would break the realm list instead of overriding it. for blank in ["", " ", "\t\n"] { assert_eq!( realm_address_override(Some(blank.to_string())), @@ -836,15 +501,12 @@ mod realm_address_tests { realm_address_override(Some("192.168.1.50:8085".to_string())), Some("192.168.1.50:8085".to_string()) ); - // Shell/env-file whitespace is not part of the address the client is told to connect to. assert_eq!( realm_address_override(Some(" 192.168.1.50:8085\n".to_string())), Some("192.168.1.50:8085".to_string()) ); } - /// The reported failure: seeded loopback row, wildcard bind, every startup marker green, and no - /// remote client can enter the world. #[test] fn advertising_loopback_while_listening_for_remote_clients_is_unreachable() { for bind in ["0.0.0.0:8085", "159.69.88.70:8085", "192.168.1.50:8085"] { @@ -855,8 +517,6 @@ mod realm_address_tests { } } - /// The contributor fixture. Loopback everywhere is the supported local setup, so it must stay - /// silent or the warning becomes noise every developer learns to skip. #[test] fn a_loopback_fixture_is_never_reported() { for bind in ["127.0.0.1:8085", "localhost:8085", "[::1]:8085"] { @@ -881,8 +541,6 @@ mod realm_address_tests { } } - /// Every spelling of loopback the row or the bind can carry — a check that only knows - /// `127.0.0.1` would miss the same fault written another way. #[test] fn every_spelling_of_loopback_counts_as_loopback() { for advertised in [ @@ -899,7 +557,6 @@ mod realm_address_tests { } } - /// `127.0.0.1` is loopback; a hostname that merely starts with those characters is not. #[test] fn a_host_that_only_looks_like_loopback_is_left_alone() { assert!(!advertised_address_is_unreachable( @@ -912,8 +569,6 @@ mod realm_address_tests { )); } - /// This drives a log line, nothing more. Anything unparseable reads as reachable rather than - /// panicking the gateway on the way up. #[test] fn malformed_input_stays_silent_instead_of_panicking() { for (advertised, bind) in [ @@ -927,8 +582,6 @@ mod realm_address_tests { ] { let _ = advertised_address_is_unreachable(advertised, bind); } - // The one malformed case with a definite answer: a loopback host and an unparseable bind - // must not be reported, since nothing proves the listener is remote-reachable. assert!(!advertised_address_is_unreachable("127.0.0.1:8085", "")); } } @@ -939,8 +592,6 @@ mod shard_map_tests { #[test] fn an_unconfigured_map_is_a_single_entry_that_never_routes() { - // The safety property: absent config → every location resolves to the one database, - // so no reducer call and no subscription can target anything else. let m = ShardMap::parse("lyracore", ""); assert_eq!(m.resolve(0, 0), "lyracore"); assert_eq!(m.resolve(389, 12345), "lyracore"); @@ -966,10 +617,10 @@ mod shard_map_tests { #[test] fn a_bucket_rule_splits_one_maps_instances_across_a_pool() { let m = ShardMap::parse("world", "389:0=pool-a\n389:1=pool-b\n389:*=pool-c"); - assert_eq!(m.resolve(389, 0), "pool-a"); // 0 % 8 == 0 + assert_eq!(m.resolve(389, 0), "pool-a"); assert_eq!(m.resolve(389, 8), "pool-a"); assert_eq!(m.resolve(389, 1), "pool-b"); - assert_eq!(m.resolve(389, 2), "pool-c"); // falls through to the map-wide rule + assert_eq!(m.resolve(389, 2), "pool-c"); assert_eq!(m.resolve(0, 0), "world"); } @@ -979,25 +630,10 @@ mod shard_map_tests { assert_eq!(m.resolve(1, 3), "first"); } - // ========================================================================================== - // Instance hosting: the detector that replaced the old always-firing reminder. - // - // Every test here drives the same pure function with the same two inputs — the routing (this - // gateway's) and the flag (each database's) — because the bug was precisely that no process - // held both. - // ========================================================================================== - - /// Convenience: `hosts_instances` reader from a `(db, flag)` list; anything unlisted reads - /// `None`, i.e. the module's own "no row ⇒ hosts" default. fn flags<'a>(pairs: &'a [(&'a str, bool)]) -> impl Fn(&str) -> Option + 'a { move |db: &str| pairs.iter().find(|(d, _)| *d == db).map(|(_, f)| *f) } - /// **The damaging direction.** No shard map + `hosts_instances = false` = every - /// dungeon created with 0 entities, forever, because there is no instances shard to route to. - /// Measured live as 8 consecutive empty instances and four bot-test failures (`bot_deadmines` - /// "no Defias death within 300s", `party_brains`, `bot_goals`, `class_roles`) that looked like - /// gameplay regressions until someone counted instance rows. #[test] fn no_shard_map_plus_hosting_off_is_fatal_and_names_both_remedies() { let single = ShardMap::parse("lyracore", ""); @@ -1010,7 +646,6 @@ mod shard_map_tests { populations must be FATAL — nothing else can spawn the dungeon. Got: {other:?}" ), }; - // The AC's exact-remedy requirement, both branches of it. assert!( msg.contains("UPDATE game_config SET hosts_instances = true WHERE id = 0"), "the message must name the exact SQL that fixes it: {msg}" @@ -1023,16 +658,12 @@ mod shard_map_tests { msg.contains("lyracore"), "…and the database to run it on: {msg}" ); - // Fatal means fatal. assert!( verdict.enforce().is_err(), "the empty-dungeon verdict must refuse to start the gateway" ); } - /// **The ordinary single-database realm** — `LYRACORE_SHARD_MAP` unset, flag at its `true` default, - /// and the same again for a database with no config row at all. Silent, both ways. This is the - /// half the old reminder promised and this detector must not spend. #[test] fn the_unconfigured_single_database_realm_is_silent() { let single = ShardMap::parse("lyracore", ""); @@ -1051,10 +682,6 @@ mod shard_map_tests { ); } - /// **The wasteful direction, now actually detected.** A shard map that routes Deadmines away - /// while the world shard still hosts populations warns; the same shard map with the flag - /// correctly `false` says NOTHING. The second half fixes the old reminder's always-fires noise — - /// the operator confirmed it firing on a correctly-configured gateway. #[test] fn a_routed_dungeon_warns_only_while_the_world_shard_still_hosts() { let phase_a = ShardMap::parse("lyracore", "36:*=lyracore-instances"); @@ -1088,10 +715,6 @@ mod shard_map_tests { ); } - /// The partial misconfiguration the old reminder's "does the map route anywhere" test could - /// never see: a shard map that routes the open world but not the DUNGEON, with the flag off. - /// There IS a second database, it DOES host instances, and dungeons are still empty — because - /// nothing routes map 36 to it. #[test] fn a_shard_map_that_routes_everything_except_the_dungeon_is_still_fatal() { let m = ShardMap::parse("lyracore", "1:*=lyracore-kalimdor"); @@ -1107,10 +730,6 @@ mod shard_map_tests { ); } - /// A pool member that forgot the flag. `36:1=` sends every 8th run to a database whose - /// `hosts_instances` is false, so one dungeon in eight is empty — the hardest possible version - /// of the empty-dungeon failure to diagnose from the outside, and the reason the check walks - /// every bucket instead of asking about one representative instance id. #[test] fn one_unhosted_pool_member_out_of_eight_is_still_fatal() { let m = ShardMap::parse("lyracore", "36:1=instances-1, 36:*=instances-0"); @@ -1119,7 +738,7 @@ mod shard_map_tests { flags(&[ ("lyracore", false), ("instances-0", true), - ("instances-1", false), // the operator published this one and forgot the SQL + ("instances-1", false), ]), |_| true, ); @@ -1130,28 +749,13 @@ mod shard_map_tests { ); } - /// An instances shard the map names but that failed to connect degrades to the default database - /// (`resolve_connected`'s documented rule) — and on a non-hosting World Shard the default database - /// is exactly the one that hosts nothing, so dungeons WOULD be empty. Reported, and deliberately - /// **not fatal**. - /// - /// This is the availability half of the empty-dungeon failure, and it cuts the other way from - /// the rest of the file. The operator's config is CORRECT here; a different process is down, - /// transiently — a node that has not finished coming up, a gateway launched a few seconds - /// early in a restart, an instances - /// database mid-republish. `Coordinator::connect` decided in writing that an unreachable - /// non-default shard degrades rather than fails, precisely so the open world and every login - /// survive it, and this check must not quietly overrule that one call below: `main` propagates - /// the error and the process EXITS, with no retry anywhere, so a fatal here turns a blip in the - /// instances database into a total realm outage that needs a human. Empty dungeons for the - /// duration beats no realm at all. #[test] fn an_unreachable_instances_shard_is_reported_but_does_not_stop_the_gateway() { let phase_a = ShardMap::parse("lyracore", "36:*=lyracore-instances"); let verdict = phase_a.check_instance_hosting( &[], flags(&[("lyracore", false), ("lyracore-instances", true)]), - |d| d == "lyracore", // the instances database never connected + |d| d == "lyracore", ); match &verdict { InstanceHosting::UnreachableHost(msg) => { @@ -1187,16 +791,15 @@ mod shard_map_tests { live.check_instance_hosting( &[], flags(&[ - ("lyracore", false), // the world shard does not host populations - ("lyracore-instances", true), // the instances shard does - ("lyracore-realm", false), // auth only — never spawns anything, never asked + ("lyracore", false), + ("lyracore-instances", true), + ("lyracore-realm", false), ]), up ), InstanceHosting::Consistent, "the deployed sharded Realm is correct and must produce NO output at all" ); - // realm-core's flag is irrelevant either way — it owns no map, so flipping it changes nothing. assert_eq!( live.check_instance_hosting( &[], @@ -1213,7 +816,6 @@ mod shard_map_tests { ); } - /// Only a configuration inconsistency that requires an operator edit prevents startup. #[test] fn only_the_unrecoverable_verdict_refuses_to_start() { assert!(InstanceHosting::Consistent.enforce().is_ok()); @@ -1248,7 +850,7 @@ mod shard_map_tests { #[test] fn an_unreachable_shard_degrades_to_the_default_database_not_to_the_asker() { let m = ShardMap::parse("world", "1:*=pool-a, 2:*=pool-b"); - let up = |db: &str| db != "pool-b"; // pool-b is down + let up = |db: &str| db != "pool-b"; assert_eq!( m.resolve_connected(1, 0, up), "pool-a", @@ -1266,15 +868,6 @@ mod shard_map_tests { ); } - // ========================================================================================== - // The instance pool: assignment by the EXISTING bucket rule, and the stickiness that - // keeps a live run where it is when the pool grows. - // ========================================================================================== - - /// The requirement that instance creation targets the pool via the shard map, and that the - /// pool can grow by config alone — in one test: **the pool is the bucket rule, and nothing - /// else.** Two pool members, ids allocated consecutively by the world shard's `auto_inc`, and - /// the runs alternate — no new assignment machinery was added or needed. #[test] fn the_existing_bucket_rule_spreads_consecutive_runs_across_the_pool() { let m = ShardMap::parse( @@ -1282,7 +875,6 @@ mod shard_map_tests { "36:1=instances-1, 36:3=instances-1, 36:5=instances-1, 36:7=instances-1, \ 36:*=instances-0", ); - // Instance ids 1..=8 as `auto_inc` mints them, one per dungeon run. let landed: Vec<&str> = (1..=8).map(|id| m.resolve(36, id)).collect(); assert_eq!( landed, @@ -1299,7 +891,6 @@ mod shard_map_tests { "consecutive runs must alternate across the pool — that IS the load spread" ); assert_eq!(m.instance_pool(36), vec!["instances-0", "instances-1"]); - // The open world is untouched: it is map 0 / instance 0, which no `36:` rule can match. assert_eq!(m.resolve(0, 0), "world"); assert_eq!( m.instance_pool(0), @@ -1308,9 +899,6 @@ mod shard_map_tests { ); } - /// The `*:0=` footgun, pinned from the POOL side. A wildcard bucket rule captures the open - /// world (instance 0 is bucket 0), and `instance_pool` must report that honestly rather than - /// quietly hiding it — the pool listing is what an operator reads to check a config. #[test] fn a_wildcard_bucket_rule_puts_the_open_world_in_the_pool_and_says_so() { let trap = ShardMap::parse("world", "*:0=pool-a"); @@ -1320,7 +908,6 @@ mod shard_map_tests { "the whole open world moved — the documented trap" ); assert_eq!(trap.instance_pool(0), vec!["pool-a", "world"]); - // Naming the map is the fix the type docs prescribe, and it keeps the open world home. let named = ShardMap::parse("world", "36:0=pool-a"); assert_eq!(named.resolve(0, 0), "world"); assert_eq!(named.instance_pool(36), vec!["pool-a", "world"]); @@ -1328,9 +915,6 @@ mod shard_map_tests { #[test] fn an_unconfigured_map_has_a_one_database_pool_and_instance_owner_is_resolve() { - // The byte-identical-when-unset property, for the pool's two new resolvers. With no rules the - // pool is the single database and stickiness has nothing to stick to, so `instance_owner` - // answers exactly what `resolve` answers for every input it can be asked about. let m = ShardMap::parse("lyracore", ""); assert_eq!(m.instance_pool(0), vec!["lyracore"]); assert_eq!(m.instance_pool(36), vec!["lyracore"]); @@ -1346,28 +930,19 @@ mod shard_map_tests { } } - /// **Growing the pool by config alone.** The operator adds `instances-1` by editing - /// `LYRACORE_SHARD_MAP` alone. New runs land on - /// it; the run already live on `instances-0` — whose bucket the edit just re-pointed — keeps - /// resolving to `instances-0` for the players who are inside it. #[test] fn adding_a_second_instances_database_moves_new_runs_and_leaves_a_live_run_where_it_is() { let up = |_: &str| true; - // Before: one instances database owns every bucket of map 36. let before = ShardMap::parse("world", "36:*=instances-0"); assert_eq!(before.instance_pool(36), vec!["instances-0"]); - // A party walks into Deadmines. The world shard mints instance 5 and holds them, so the - // shard map decides — the holder is not in the pool — and the run lands on instances-0. assert_eq!(before.instance_owner(36, 5, "world", up), "instances-0"); - // The operator adds instances-1, by config alone. Bucket 5 is one of the buckets it takes. let after = ShardMap::parse( "world", "36:4=instances-1, 36:5=instances-1, 36:6=instances-1, 36:7=instances-1, \ 36:*=instances-0", ); assert_eq!(after.instance_pool(36), vec!["instances-0", "instances-1"]); - // NEW runs land on the new database… assert_eq!( after.instance_owner(36, 13, "world", up), "instances-1", @@ -1378,16 +953,11 @@ mod shard_map_tests { "instances-0", "10 % 8 == 2" ); - // …and the LIVE run does not move. instances-0 is in the pool and is where the character - // actually is, so it outranks the map's new answer for that bucket. Without this, the - // player's next world entry would mirror an empty instance 5 onto instances-1 and transfer - // them into it, away from the party and the population they were fighting. assert_eq!( after.instance_owner(36, 5, "instances-0", up), "instances-0", "a run already live on a pool member must survive that bucket being re-pointed" ); - // The shard map on its own would have moved them — that is precisely what is being fixed. assert_eq!(after.resolve(36, 5), "instances-1"); } @@ -1395,8 +965,6 @@ mod shard_map_tests { fn stickiness_never_applies_to_the_open_world_or_to_a_shard_outside_the_pool() { let up = |_: &str| true; let m = ShardMap::parse("world", "36:*=instances-0"); - // Instance 0 is the OPEN WORLD, not an instance: a character standing on instances-0 in - // the open world routes by the map, or they would be pinned to the instance shard forever. assert_eq!(m.instance_owner(0, 0, "instances-0", up), "world"); let split = ShardMap::parse("world", "0:0=world-a, 0:*=world-b"); assert_eq!(split.instance_pool(0), vec!["world-a", "world-b"]); @@ -1406,19 +974,13 @@ mod shard_map_tests { "world-a", "the open world is never sticky — stickiness is a property of INSTANCES" ); - // A holder that is not in this map's pool never wins — this is the INITIAL HOP, and it is - // what makes instances reach the pool at all. assert_eq!(m.instance_owner(36, 5, "world", up), "instances-0"); - // A pool member for a DIFFERENT map is not a pool member for this one. let two = ShardMap::parse("world", "36:*=instances-0, 389:*=instances-1"); assert_eq!(two.instance_owner(36, 5, "instances-1", up), "instances-0"); } #[test] fn a_sticky_holder_that_is_not_connected_falls_back_to_the_shard_map() { - // Same degradation rule as everywhere else in this file: a database the gateway never - // reached can never be routed to, not even by stickiness. Anything else would strand a - // login on a shard that is not answering. let m = ShardMap::parse("world", "36:*=instances-0"); assert_eq!( m.instance_owner(36, 5, "instances-0", |db| db != "instances-0"), @@ -1430,15 +992,8 @@ mod shard_map_tests { ); } - // ========================================================================================== - // Realm-core: the auth database, and the character→shard index's self-heal rule. - // ========================================================================================== - #[test] fn realm_core_unconfigured_keeps_auth_on_the_default_database() { - // The safety property (mirroring the shard map's): with no LYRACORE_REALM_CORE the auth - // database IS the world database, so every account/session read and write goes exactly - // where it goes today, and no second coordinator connection is opened. let m = ShardMap::parse("world", "1:*=instances"); assert_eq!(m.realm_core_db(), None); assert_eq!(m.auth_db(|_| true), Ok("world")); @@ -1473,11 +1028,6 @@ mod shard_map_tests { #[test] fn an_unreachable_realm_core_fails_closed_instead_of_falling_back_to_the_world_cache() { - // THE auth-boundary rule. The world DB keeps `game_account`/`game_session` as a - // write-through cache (removing the tables would be a destructive migration), so a - // fallback is *possible* — and must not happen: it would resurrect a rotated password or - // un-enforce a ban exactly when realm-core is down. Degrade to nobody logs in, never to - // "everybody logs in against yesterday's credentials". let m = ShardMap::parse("world", "").with_realm_core(Some("lyracore-realm")); assert_eq!( m.auth_db(|db| db != "lyracore-realm"), @@ -1487,10 +1037,6 @@ mod shard_map_tests { #[test] fn realm_core_naming_the_world_database_or_nothing_is_treated_as_unconfigured() { - // `LYRACORE_REALM_CORE=lyracore` (== LYRACORE_DATABASE) is a plausible operator mistake and a - // plausible deliberate "hub == world" single-realm posture (the shared-auth-hub design's - // zero-migration default). Both must collapse to today rather than opening a second - // connection to the same database. for cfg in [Some("world"), Some(" "), Some(""), None] { let m = ShardMap::parse("world", "").with_realm_core(cfg); assert_eq!( @@ -1500,29 +1046,12 @@ mod shard_map_tests { ); assert_eq!(m.databases(), vec!["world".to_string()]); } - // Surrounding whitespace is trimmed, not treated as part of the name. let m = ShardMap::parse("world", "").with_realm_core(Some(" lyracore-realm ")); assert_eq!(m.realm_core_db(), Some("lyracore-realm")); } - // ========================================================================================== - // The SINGLE-DATABASE realm, and the parser's totality - // ========================================================================================== - - /// The configuration nearly every deployment runs, and exactly what you silently get when a - /// topology env var is omitted: nothing configured at all. - /// - /// Individual accessors are pinned elsewhere; this asserts the whole surface AT ONCE, because - /// the failure mode is not "one accessor is wrong" — it is "the gateway looks like it is running - /// a five-database realm and is actually running one", or the reverse. Every routing question - /// must answer with the one database, and no accessor may invent a second name. - /// - /// It is also the contract every sharding feature is built against: each shard-map and - /// instance-pool rule is a no-op here, which is what makes them safe to ship default-off. #[test] fn the_unconfigured_realm_is_exactly_one_database_in_every_accessor() { - // Exactly what `from_env` produces when LYRACORE_SHARD_MAP / _REALM_CORE are unset: an - // empty rule text and no realm-core configured. let m = ShardMap::parse("lyracore", "").with_realm_core(None); assert_eq!(m.default_db(), "lyracore"); @@ -1540,7 +1069,6 @@ mod shard_map_tests { fail closed" ); - // Routing is the identity function over the whole address space. for map_id in [ 0u32, 1, @@ -1571,7 +1099,6 @@ mod shard_map_tests { ); } - // And the hosting check is inert rather than merely quiet. assert_eq!( m.check_instance_hosting(&[], |_| Some(true), |_| true), InstanceHosting::Consistent, @@ -1579,13 +1106,6 @@ mod shard_map_tests { ); } - /// The rule text is split on `\n`, `,` AND `;`, so an operator may write the map on one line, on - /// several, or with semicolons — and `docs/danger-zones.md`'s stack recipe uses one form while a - /// `LYRACORE_SHARD_MAP_FILE` naturally uses another. - /// - /// All three must produce an IDENTICAL map. A separator that quietly failed to split would fold - /// several rules into one malformed rule, which `parse` then drops — so a whole realm's routing - /// would silently collapse to the default database with only a log line to show for it. #[test] fn every_separator_spelling_of_the_same_map_parses_identically() { let rules = ["1:*=world-1", "36:0=inst-a", "36:*=inst-b", "*:*=world-2"]; @@ -1593,12 +1113,10 @@ mod shard_map_tests { rules.join(","), rules.join(";"), rules.join("\n"), - // Mixed, with the whitespace and blank lines a hand-edited file accumulates. format!( " {} ;\n\n {} ,\n{}\n\n; {} ", rules[0], rules[1], rules[2], rules[3] ), - // With comments, which are stripped per-rule at the first `#`. format!( "# the realm\n{} # eastern kingdoms\n{},{}\n{}", rules[0], rules[1], rules[2], rules[3] @@ -1638,13 +1156,6 @@ mod shard_map_tests { } } - /// Every malformed rule shape, one per case rather than the existing test's single mixed blob — - /// so a parser change that started ACCEPTING one of them names which one. - /// - /// The rule that matters is uniform: a rule the parser cannot fully understand is DROPPED, never - /// half-applied. A half-applied rule (say, a bad bucket treated as a wildcard) would route a - /// whole map's instances somewhere the operator never wrote, and the log line would describe a - /// rule that was accepted. #[test] fn each_malformed_rule_shape_is_dropped_whole_and_never_half_applied() { let garbage = [ @@ -1680,26 +1191,10 @@ mod shard_map_tests { } } - /// PROPERTY: `LYRACORE_SHARD_MAP` is operator input, read at startup, and a mistake in it - /// fails silently into a working-looking realm. The parser's whole job is therefore to - /// DEGRADE — never to abort the process, and never to invent a destination. - /// - /// Two invariants over generated rule text: - /// - /// * `parse` returns for any input at all (no panic — an aborted parse is a gateway that will - /// not start, on a config change made at 3am); - /// * whatever it produces, `resolve` only ever answers with a database that appears in - /// `databases()`. A resolver that could name an unlisted database would route players to a - /// handle the gateway never connected, which surfaces as a mid-session hang rather than a - /// startup error. - /// - /// Deterministic: a fixed seed, replayed identically every run. #[test] fn any_operator_shard_map_text_degrades_and_never_routes_off_its_own_database_list() { use crate::codec::property_tests::Rng; let mut rng = Rng::new(0x5348_4152_4400); - // Fragments that individually look plausible, so the generator produces near-miss configs - // (the realistic typo) as well as noise. let pieces = [ "1", "36", @@ -1753,7 +1248,6 @@ mod shard_map_tests { #[test] fn malformed_rules_are_dropped_and_the_map_degrades_to_the_default() { - // Every one of these is garbage; none may reroute a player. Comments + blanks are skipped. let m = ShardMap::parse( "world", "# a comment\n\n nonsense , 1:= , =db , x:1=db , 1:99=db , 1:*=good", diff --git a/gateway/src/fd_limit.rs b/gateway/src/fd_limit.rs index e5ea595fe..0c4a397d6 100644 --- a/gateway/src/fd_limit.rs +++ b/gateway/src/fd_limit.rs @@ -1,22 +1,11 @@ use std::io; -/// What [`plan_raise`] decided to do about the limits it read. Split out from the syscalls so the -/// decision — including the "don't bother" and "never lower" cases — is a pure function with tests. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RaisePlan { - /// Soft is already at (or, pathologically, above) hard. Nothing to raise, no syscall to make. AlreadyAtHard { limit: u64 }, - /// Raise the soft limit from `from` to `to`. Raise { from: u64, to: u64 }, } -/// Decide what to do given the current soft/hard `RLIMIT_NOFILE` pair. -/// -/// The only rule with teeth: **never lower**. `setrlimit` would happily accept a soft limit below -/// the current one and it is irreversible for the hard limit, so a container that has already been -/// launched with `--ulimit nofile=65536:65536` must come out of here untouched rather than -/// "normalised". A soft limit somehow *above* hard (which the kernel does not produce, but which we -/// should not react to by shrinking it) takes the same branch. pub fn plan_raise(soft: u64, hard: u64) -> RaisePlan { if soft >= hard { RaisePlan::AlreadyAtHard { limit: soft } @@ -28,9 +17,6 @@ pub fn plan_raise(soft: u64, hard: u64) -> RaisePlan { } } -/// Render a limit for a log line, spelling `RLIM_INFINITY` as `unlimited` rather than as -/// `18446744073709551615` — an operator reading the startup banner should not have to recognise -/// `u64::MAX` on sight. pub fn describe_limit(v: u64) -> String { if v == unlimited() { "unlimited".to_string() @@ -39,21 +25,10 @@ pub fn describe_limit(v: u64) -> String { } } -/// `RLIM_INFINITY` as a `u64`. -/// -/// This module speaks `u64` throughout and relies on `libc::rlim_t` **being** `u64` — true on Linux -/// and macOS, the two platforms this workspace builds on. No `as` cast anywhere here, deliberately: -/// a platform with a narrower `rlim_t` should fail to compile loudly rather than have a cast -/// quietly truncate a limit. The `const` below is that check, evaluated at compile time. const fn unlimited() -> u64 { libc::RLIM_INFINITY } -/// Raise `RLIMIT_NOFILE` soft to hard, logging the before/after. Never fails: every error path logs -/// and returns. -/// -/// Returns the soft limit in force when this returns, purely so tests and callers can assert on it; -/// the log line is the real product. pub fn raise_nofile_soft_to_hard() -> Option { let (soft, hard) = match read_nofile() { Ok(pair) => pair, @@ -105,8 +80,7 @@ fn read_nofile() -> io::Result<(u64, u64)> { rlim_cur: 0, rlim_max: 0, }; - // SAFETY: `getrlimit` only writes through the pointer, and `lim` is a live, correctly typed, - // fully initialised `rlimit` we own for the duration of the call. + // SAFETY: getrlimit writes only to our live, initialized rlimit for the duration of the call. let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) }; if rc != 0 { return Err(io::Error::last_os_error()); @@ -119,8 +93,7 @@ fn set_nofile_soft(soft: u64, hard: u64) -> io::Result<()> { rlim_cur: soft, rlim_max: hard, }; - // SAFETY: `setrlimit` only reads through the pointer, and `lim` is a live, correctly typed, - // fully initialised `rlimit` we own for the duration of the call. + // SAFETY: setrlimit reads only from our live, initialized rlimit for the duration of the call. let rc = unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) }; if rc != 0 { return Err(io::Error::last_os_error()); @@ -132,8 +105,6 @@ fn set_nofile_soft(soft: u64, hard: u64) -> io::Result<()> { mod tests { use super::*; - /// The container shape the gateway died in: soft 1024, hard 524288 — 512x of headroom that - /// nothing was claiming. #[test] fn the_default_docker_shape_gets_raised() { assert_eq!( @@ -145,23 +116,18 @@ mod tests { ); } - /// An operator who already raised it outside the process must not be second-guessed, and — the - /// property that actually matters — must never be silently LOWERED. #[test] fn an_already_raised_limit_is_left_alone() { assert_eq!( plan_raise(65_536, 65_536), RaisePlan::AlreadyAtHard { limit: 65_536 } ); - // Pathological (the kernel does not produce it), but shrinking would be the wrong answer. assert_eq!( plan_raise(65_536, 1024), RaisePlan::AlreadyAtHard { limit: 65_536 } ); } - /// The maintainer-workstation shape from the measured ceiling table: nothing to do, and no - /// syscall made. #[test] fn an_unlimited_hard_limit_is_still_a_raise_when_soft_is_lower() { let inf = unlimited(); @@ -185,13 +151,6 @@ mod tests { assert_eq!(describe_limit(524_288), "524288"); } - /// End to end against the real kernel — the whole claim of the module, checked for real rather - /// than asserted about a plan. It mutates only this test process's own rlimit, and only upward. - /// - /// Deliberately tolerant of `setrlimit` being denied outright (a seccomp-filtered sandbox), so - /// this reports the module's own bug and not the environment's policy: what is pinned is - /// **"never lower, never move the hard limit"**, plus "reached the hard limit" whenever the - /// syscall was actually allowed to run. #[test] fn raising_for_real_never_lowers_and_reaches_the_hard_limit_when_permitted() { let (soft_before, hard) = read_nofile().expect("getrlimit on our own process"); @@ -208,9 +167,6 @@ mod tests { ); assert_eq!(hard_after, hard, "the hard limit must not move"); if soft_after != hard { - // A silent no-op is observationally identical to a refused syscall, so prove which one - // happened instead of tolerating both: if the raise works when made directly, then - // `raise_nofile_soft_to_hard` skipping it is a bug in this module. let e = set_nofile_soft(hard, hard).expect_err( "setrlimit succeeded when called directly but raise_nofile_soft_to_hard left the \ soft limit below hard — that is a bug here, not a sandbox policy", diff --git a/gateway/src/load_sample.rs b/gateway/src/load_sample.rs index 148ebf065..f84615c86 100644 --- a/gateway/src/load_sample.rs +++ b/gateway/src/load_sample.rs @@ -8,13 +8,8 @@ use anyhow::{anyhow, bail, Context, Result}; use crate::realm_core::RealmDb; -/// Default sampling cadence — matches the internal capacity-benchmark harness's own read of the same -/// node metric, and is short enough that a `SHARD_LOAD_RING` of 20 -/// (`module/src/load.rs`) covers ~10 minutes of history. const DEFAULT_SAMPLE_SECS: u64 = 30; -/// How long to sleep between samples — `LYRACORE_LOAD_SAMPLE_SECS`, default [`DEFAULT_SAMPLE_SECS`]. `0` -/// or unparseable falls back to the default rather than busy-looping. pub(crate) fn sample_interval() -> Duration { let secs = std::env::var("LYRACORE_LOAD_SAMPLE_SECS") .ok() @@ -24,14 +19,6 @@ pub(crate) fn sample_interval() -> Duration { Duration::from_secs(secs) } -/// Deterministic 64-bit FNV-1a hash of this gateway's own identity string (`GatewayConfig::gateway_id`, -/// `LYRACORE_GATEWAY_ID`). `game_shard_load.gateway_key` is `u64`, not `String`: a -/// `String` END-appended to an existing table cannot carry a `#[default(...)]` -/// (`docs/danger-zones.md` §1.2, "cannot default a `String` column"), so the raw identity never -/// crosses the wire into the table — this hash does instead. FNV-1a (not `DefaultHasher`) because -/// it is a fixed, documented algorithm: `std::collections::hash_map::DefaultHasher`'s algorithm is -/// explicitly NOT guaranteed stable across Rust versions, which would silently reshuffle which ring -/// bucket a redeployed gateway's samples land in. pub(crate) fn gateway_key(id: &str) -> u64 { const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; const FNV_PRIME: u64 = 0x0000_0100_0000_01B3; @@ -43,14 +30,6 @@ pub(crate) fn gateway_key(id: &str) -> u64 { hash } -/// `=` pairs, comma/semicolon/newline separated, `#` comments — the -/// same separator convention `ShardMap::parse` uses for its rule text. -/// -/// This mapping exists because SpacetimeDB's `/v1/metrics` labels samples by database IDENTITY -/// (`db=""`), not by the friendly name the gateway's own config uses -/// (the internal capacity-benchmark tool's `--db` flag takes the identical prefix, for the -/// identical reason). Discover a shard's identity the same way that tool does: a `--dry-run` -/// query against `/v1/metrics` lists every identity the node currently exposes. pub(crate) fn parse_db_ids(text: &str) -> HashMap { let mut out = HashMap::new(); for raw in text.split(['\n', ',', ';']) { @@ -68,20 +47,9 @@ pub(crate) fn parse_db_ids(text: &str) -> HashMap { out } -// =================================================================================================== -// Prometheus scrape + parse — a from-scratch reimplementation of -// the wire harness's `src/bin/bench/metrics.rs`'s identical computation, trimmed to the one family -// this task needs (see the module doc for why it is not shared as a library). -// =================================================================================================== - -/// One scrape, keyed on the verbatim `name{labels}` text of each sample line — same shape as the -/// capacity benchmark's `Snapshot`, and for the same reason: a key-wise lookup is all `sum` needs, -/// and the raw key keeps label filtering to a substring match. #[derive(Clone, Debug, Default)] struct Snapshot(BTreeMap); -/// The one metric family this task reads: "time spent executing a transaction, EXCLUDING time -/// spent waiting to acquire database locks" — i.e. the serialized writer's busy time. const OCCUPANCY_FAMILY: &str = "spacetime_txn_cpu_time_sec_sum"; fn http_get(url: &str) -> Result { @@ -141,15 +109,11 @@ fn split_key(key: &str) -> (&str, &str) { } } -/// Substring filter selecting one database on the node: `db="`. fn db_filter(db_id: &str) -> String { format!("db=\"{db_id}") } impl Snapshot { - /// Sum every sample of `name` whose label blob contains `filter` — same rule the capacity - /// benchmark's `Snapshot::sum` uses, trimmed to a single filter (this task only ever filters by - /// one shard's `db=` prefix at a time). fn sum(&self, name: &str, filter: &str) -> f64 { self.0 .iter() @@ -162,9 +126,6 @@ impl Snapshot { } } -/// Occupancy percentage from a CPU-time delta over a wall-clock delta (the same computation the -/// internal capacity-benchmark tool uses). Pure, so it is the one piece of this -/// module pinned directly against concrete numbers. pub(crate) fn occupancy_pct(cpu_sec_delta: f64, wall_dt_secs: f64) -> f32 { if wall_dt_secs <= 0.0 || !cpu_sec_delta.is_finite() { return 0.0; @@ -172,26 +133,13 @@ pub(crate) fn occupancy_pct(cpu_sec_delta: f64, wall_dt_secs: f64) -> f32 { ((cpu_sec_delta / wall_dt_secs) * 100.0).max(0.0) as f32 } -/// Scrapes `metrics_url` on demand and turns the delta since the PREVIOUS scrape into an -/// occupancy-percentage-per-shard map, for every shard named in `db_ids`. -/// -/// One scrape serves every configured shard (the node's `/v1/metrics` already reports every -/// database it hosts in one response) — this samples the endpoint ONCE per cycle and slices it by -/// `db=` label per shard, rather than re-scraping per shard. pub(crate) struct OccupancySampler { metrics_url: String, - /// shard name -> database identity hex prefix (`LYRACORE_METRICS_DB_IDS`). db_ids: HashMap, - /// The previous cycle's scrape + the instant it was taken, shared across every configured - /// shard (they all delta against the SAME two scrapes). last: Mutex>, } impl OccupancySampler { - /// `stdb_base_url` is `LYRACORE_SPACETIMEDB_URL` (the same node every shard's coordinator connection - /// targets) — `/v1/metrics` is appended here. Reads `LYRACORE_METRICS_DB_IDS` once; warns (loudly, - /// once, at startup — the AC's "fails loudly when [manual config] disagrees with the topology") - /// if it names nothing, since every shard then samples sessions but never occupancy. pub(crate) fn from_env(stdb_base_url: &str) -> Self { let db_ids = parse_db_ids(&std::env::var("LYRACORE_METRICS_DB_IDS").unwrap_or_default()); if db_ids.is_empty() { @@ -209,11 +157,6 @@ impl OccupancySampler { } } - /// One sampling cycle: scrape, then return `{shard -> occupancy_pct}` for every shard with a - /// configured identity AND a prior baseline to delta against. A shard absent from the result is - /// UNMEASURED this cycle (no configured id, this is the first-ever scrape, or the scrape - /// itself failed) — never a measured zero; the caller must not conflate the two (the same care - /// the internal capacity-benchmark tool takes over the identical trap). pub(crate) fn sample_all(&self) -> HashMap { if self.db_ids.is_empty() { return HashMap::new(); @@ -242,8 +185,6 @@ impl OccupancySampler { }) .collect() } - // First scrape ever: no baseline, so nothing can be DELTA'd yet — every shard is - // unmeasured for exactly this one cycle. None => HashMap::new(), }; *guard = Some((snap, now)); @@ -251,10 +192,6 @@ impl OccupancySampler { } } -// =================================================================================================== -// The driver — pure given the RealmDb abstraction, tested against `realm_core::fake::Handle`. -// =================================================================================================== - pub(crate) fn sample_and_record( db: &D, occupancy_by_shard: &HashMap, @@ -299,10 +236,6 @@ pub(crate) fn sample_and_record( mod tests { use super::*; - // ------------------------------------------------------------------------------------- - // Pure math: parsing, filtering, occupancy. - // ------------------------------------------------------------------------------------- - const SAMPLE: &str = r#" # HELP spacetime_txn_cpu_time_sec_sum the writer's busy time spacetime_txn_cpu_time_sec_sum{db="abc123",txn_type="Reducer"} 10.0 @@ -337,15 +270,11 @@ spacetime_txn_cpu_time_sec_sum{db="zzz999",txn_type="Reducer"} 99.0 #[test] fn occupancy_pct_does_not_clamp_a_brief_overrun_to_100() { - // A real momentary skew can read slightly over 100% — clamping it away would hide a real - // saturation signal behind a false ceiling. See the function's own doc. assert_eq!(occupancy_pct(33.0, 30.0), 110.0); } #[test] fn occupancy_pct_floors_a_negative_delta_at_zero_instead_of_going_negative() { - // A counter reset (node restart / republish mid-window) makes the delta negative; reading - // that as a negative percentage would make no sense on a graph. assert_eq!(occupancy_pct(-5.0, 30.0), 0.0); } @@ -440,7 +369,7 @@ spacetime_txn_cpu_time_sec_sum{db="zzz999",txn_type="Reducer"} 99.0 fn sample_and_record_marks_a_shard_with_no_occupancy_entry_as_unmeasured_not_zero() { let h = one_shard_realm(); *h.db_at(WORLD).open_sessions.lock().unwrap() = 3; - let occ = HashMap::new(); // nothing measured this cycle + let occ = HashMap::new(); let lines = sample_and_record(&h, &occ, 99); @@ -462,10 +391,6 @@ spacetime_txn_cpu_time_sec_sum{db="zzz999",txn_type="Reducer"} 99.0 #[test] fn sample_and_record_samples_every_connected_world_shard_not_just_the_first() { - // `world_shards()` is the default database plus every RULE's target (`ShardMap::shards`'s - // own doc) — a database that is merely connected, with no rule naming it, is not one of - // them. "1:*=world-b" is what actually puts world-b in the set, mirroring how a real - // second world shard is declared. let h = realm(&["world-a", "world-b", CORE], "1:*=world-b", Some(CORE)); *h.db_at("world-a").open_sessions.lock().unwrap() = 10; *h.db_at("world-b").open_sessions.lock().unwrap() = 20; @@ -554,8 +479,6 @@ spacetime_txn_cpu_time_sec_sum{db="zzz999",txn_type="Reducer"} 99.0 #[test] fn an_unconfigured_single_database_gateway_samples_and_records_onto_itself() { - // No LYRACORE_REALM_CORE: `realm_core()` answers `self`, so the sample still lands SOMEWHERE - // sensible rather than silently doing nothing. let h = realm(&[WORLD], "", None); *h.db_at(WORLD).open_sessions.lock().unwrap() = 4; sample_and_record(&h, &HashMap::from([(WORLD.to_string(), 33.0f32)]), 1); diff --git a/gateway/src/logon/limiter.rs b/gateway/src/logon/limiter.rs index a63fb969b..df645c205 100644 --- a/gateway/src/logon/limiter.rs +++ b/gateway/src/logon/limiter.rs @@ -1,35 +1,21 @@ -//! Logon Limiter: the caps on SRP6 proof attempts, per connection and per peer address. -//! -//! Every challenge and every proof costs the gateway a modular exponentiation, and a wrong -//! password was free to retry without bound. The limiter is in-memory and per gateway process; -//! nothing here is durable, so a restart forgives every address. - use std::collections::HashMap; use std::fmt; use std::net::IpAddr; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// Logon attempts one connection may start. The classic client's own retry ceiling. pub const ATTEMPTS_PER_CONNECTION: u32 = 3; -/// Failed logons one address may make per window; the rest of the window is refused. pub const FAILURES_PER_WINDOW: u32 = 10; pub const FAILURE_WINDOW: Duration = Duration::from_secs(60); -/// Open logon connections one address may hold at once. pub const CONNECTIONS_PER_ADDRESS: usize = 8; -/// Pause before answering a failed proof, so a guess costs wall-clock time as well as a modexp. pub const PROOF_FAILURE_DELAY: Duration = Duration::from_millis(200); const PRUNE_INTERVAL: Duration = Duration::from_secs(1); -/// Why the limiter closed or refused a connection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogonRefusal { - /// The address already holds `CONNECTIONS_PER_ADDRESS` open logon connections. TooManyConnections, - /// The address failed `FAILURES_PER_WINDOW` logons inside the current window. TooManyFailures, - /// This connection has used its `ATTEMPTS_PER_CONNECTION`. AttemptsExhausted, } @@ -60,13 +46,11 @@ impl std::error::Error for LogonRefusal {} #[derive(Default)] struct AddressRecord { open_connections: usize, - /// Start of the failure window; `None` until the first failure. window_started: Option, failures: u32, } impl AddressRecord { - /// Drop a window that has run out, so the next failure opens a fresh one. fn roll_window(&mut self, now: Instant) { if self .window_started @@ -88,7 +72,6 @@ impl AddressRecord { self.failures = self.failures.saturating_add(1); } - /// Nothing left to remember: no open connection and no live window. fn is_idle(&mut self, now: Instant) -> bool { self.roll_window(now); self.open_connections == 0 && self.window_started.is_none() @@ -101,7 +84,6 @@ struct TrackedAddresses { last_pruned: Option, } -/// Shared across every logon connection of one listener. #[derive(Default)] pub struct LogonLimiter { addresses: Mutex, @@ -112,9 +94,6 @@ impl LogonLimiter { Arc::new(Self::default()) } - /// Admit one connection from `ip`, or refuse it before it costs a thread. Check this address - /// every time; prune idle addresses on the first admission at least one second after the last - /// scan. Admissions between scans only look up this address under the shared lock. pub fn admit( self: &Arc, ip: IpAddr, @@ -164,7 +143,7 @@ impl LogonLimiter { } } - /// A poisoned lock only means a connection task panicked mid-update; the counts are still usable. + // Poisoning can follow a connection panic; the retained counts remain usable. fn lock(&self) -> std::sync::MutexGuard<'_, TrackedAddresses> { self.addresses .lock() @@ -177,7 +156,6 @@ impl LogonLimiter { } } -/// One admitted connection's share of the limits. Dropping it frees its address slot. pub struct LogonConnection { limiter: Arc, ip: IpAddr, @@ -185,8 +163,6 @@ pub struct LogonConnection { } impl LogonConnection { - /// Called on every challenge, before any SRP math. Refuses once this connection or its - /// address is out of attempts. pub fn start_attempt(&mut self, now: Instant) -> Result<(), LogonRefusal> { if self.attempts >= ATTEMPTS_PER_CONNECTION { return Err(LogonRefusal::AttemptsExhausted); @@ -198,8 +174,6 @@ impl LogonConnection { Ok(()) } - /// Called after a refused challenge or a failed proof. Counts it against the address, and - /// returns the refusal that closes this connection once its attempts are spent. pub fn record_failure(&mut self, now: Instant) -> Result<(), LogonRefusal> { self.limiter.record_failure(self.ip, now); if self.attempts >= ATTEMPTS_PER_CONNECTION { @@ -251,7 +225,6 @@ mod tests { for i in 0..FAILURES_PER_WINDOW { let mut connection = limiter.admit(ip(1), start).unwrap(); connection.start_attempt(start).unwrap(); - // Every attempt on a fresh connection is that connection's first, so none closes. connection .record_failure(start) .unwrap_or_else(|_| panic!("failure {i} closed")); diff --git a/gateway/src/logon/mod.rs b/gateway/src/logon/mod.rs index 00a6d4cd7..483cd8e94 100644 --- a/gateway/src/logon/mod.rs +++ b/gateway/src/logon/mod.rs @@ -1,15 +1,3 @@ -//! Logon listener (TCP 3724): SRP6 + realm list. -//! -//! The protocol logic is implemented as a transport- and storage-agnostic state machine -//! ([`handle_logon`]) over a blocking `Read + Write` stream, driven by `wow_login_messages` -//! codecs and `wow_srp` for all SRP6 math (never hand-rolled). Storage/coordination is behind -//! the [`LogonStore`] trait so the crypto can be unit-tested end-to-end without a database or a -//! socket (see the tests below, which drive a full handshake with `wow_srp`'s client side). -//! -//! The only state this touches is the `game_account` read (salt/verifier) and the session write -//! (K) — both via `LogonStore`. Everything else is per-connection handshake scratch. - -// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. #![cfg_attr( not(test), deny( @@ -36,15 +24,6 @@ use std::time::Instant; use tokio::net::TcpListener; use wow_login_messages::all::CMD_AUTH_LOGON_CHALLENGE_Client; use wow_login_messages::errors::ExpectedOpcodeError; -// Build 5875 is vanilla and connects with `protocol_version = 3`, so the whole logon flow speaks -// gtker's `version_3` message set — the module purpose-built for 1.12.1.5875. It is the exact fit: -// - its CMD_AUTH_LOGON_CHALLENGE_Server appends the trailing `security_flag` byte the real client -// REQUIRES (version_2 omits it → the client rejects the challenge and disconnects); -// - its CMD_AUTH_LOGON_PROOF_Server is a re-export of version_2's short 26-byte proof (version_8's -// proof adds account_flag + survey + 14×padding ≈ 60 bytes, which desyncs the client right after -// it shows "Success!"); -// - its realm list re-exports version_2's vanilla u8-count format; -// - its proof_client reader consumes the trailing security_flag byte the real client sends. use wow_login_messages::version_3::opcodes::ClientOpcodeMessage; use wow_login_messages::version_3::{ CMD_AUTH_LOGON_CHALLENGE_Server, CMD_AUTH_LOGON_CHALLENGE_Server_SecurityFlag, @@ -55,7 +34,6 @@ use wow_srp::normalized_string::NormalizedString; use wow_srp::server::{SrpProof, SrpVerifier}; use wow_srp::{PublicKey, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN}; -/// SRP6 material for one account, read from `game_account`. #[derive(Clone, Debug)] pub struct LogonAccount { pub id: u64, @@ -64,38 +42,19 @@ pub struct LogonAccount { pub banned: bool, } -/// One realm for the realm-list reply, read from `game_realm`. #[derive(Clone, Debug)] pub struct RealmInfo { pub id: u8, pub name: String, - pub address: String, // "ip:port" + pub address: String, pub realm_type: u32, pub population: f32, pub number_of_characters: u8, } -/// Storage/coordination the logon flow needs. Implemented by the SpacetimeDB-backed -/// [`CoordinatorStore`] in production and by an in-memory fake in tests. pub trait LogonStore: Send + Sync { - /// Look up an account by (already uppercased) username. fn account(&self, username: &str) -> Result>; - /// The per-account SpacetimeDB identity the world connection will authenticate as. - /// - /// Takes `username` for the same reason [`LogonStore::save_session`] does: the connection this - /// identity belongs to lives on the WORLD shard and is keyed by the WORLD shard's account id, - /// while `account_id` is the authenticating (realm-core) database's. Handing the wrong id here - /// binds the account's characters to a connection the world phase never uses — and, when two - /// accounts' ids happen to be transposed between the two databases, to ANOTHER account's - /// connection. fn bound_identity(&self, account_id: u64, username: &str) -> Result<[u8; 32]>; - /// Persist the shared session key K (writes `game_session` via `establish_session`). - /// - /// `username` is passed alongside `account_id` because the two are NOT interchangeable across - /// databases: `account_id` is the authenticating database's `#[auto_inc]` surrogate key, - /// while the username is the realm-wide identity of the account. An implementation that has to - /// write more than one database (the realm-core split) must re-resolve the id per database from - /// the username; it may never carry one database's id to another. fn save_session( &self, account_id: u64, @@ -103,16 +62,9 @@ pub trait LogonStore: Send + Sync { session_key: &[u8; 40], bound_identity: [u8; 32], ) -> Result<()>; - /// Realms to advertise (with each realm's character count for this account). `username` again: - /// the character count is a WORLD-shard fact (`game_character.account_id`), so it must be - /// counted against the world shard's id, not the authenticating database's. fn realms(&self, account_id: u64, username: &str) -> Result>; } -/// Drive one logon connection to completion (challenge -> proof -> realm list). Returns when -/// the client disconnects. Pure protocol logic; no async, no direct IO beyond `stream`. -/// One total pre-auth I/O budget covers every read and write until the proof succeeds, and a -/// fresh Logon Limiter connection caps the attempts. #[cfg(test)] pub fn handle_logon( stream: &mut S, @@ -129,7 +81,6 @@ fn loopback_connection() -> LogonConnection { .expect("a fresh limiter admits its first connection") } -/// A refused attempt ends the connection: the limiter's verdict is the error the listener logs. fn handle_logon_with_deadline< S: Read + Write + IoDeadline, St: LogonStore + ?Sized, @@ -140,13 +91,7 @@ fn handle_logon_with_deadline< deadline: &mut PreAuthDeadline, connection: &mut LogonConnection, ) -> Result<()> { - // Per-connection scratch: the in-flight SRP proof + which account it is for (id AND the - // normalized username — see `LogonStore::save_session`). `SrpProof` is consumed by - // `into_server`, so it lives here between the challenge and the proof. let mut pending: Option<(SrpProof, u64, String)> = None; - // The account (id AND normalized username) once the proof has succeeded — both drive the - // realm-list reply, because the character count it carries is counted on a database that may - // not be the one that issued `id`. let mut authenticated: Option<(u64, String)> = None; loop { @@ -157,7 +102,6 @@ fn handle_logon_with_deadline< }; let msg = match read { Ok(m) => m, - // Clean client disconnect. Err(ExpectedOpcodeError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { return Ok(()); } @@ -191,13 +135,11 @@ fn handle_logon_with_deadline< connection, )? .map(|id| (id, username)); - // The realm-list phase after a successful proof has no pre-auth deadline. if authenticated.is_some() { deadline.finish(stream)?; } } ClientOpcodeMessage::CMD_REALM_LIST(_) => { - // Without a completed proof we don't know the account; advertise nothing. let realms = match &authenticated { Some((id, username)) => store.realms(*id, username)?, None => Vec::new(), @@ -225,7 +167,6 @@ fn handle_logon_with_deadline< } } -/// Look up the account, run SRP6 to the point of the server public key, and reply. fn handle_challenge( stream: &mut S, store: &St, @@ -254,10 +195,7 @@ fn handle_challenge( account.id, c.protocol_version ); - // An un-provisioned account has an all-zero verifier (the `init` placeholder). `wow_srp`'s - // key math panics on an all-zero key, so reject it cleanly as unknown rather than letting it - // crash the connection. Provision the account (`gateway provision --password-stdin`) - // first. + // wow_srp panics on the all-zero verifier used by unprovisioned accounts. if account.verifier.iter().all(|&b| b == 0) { log::warn!( "logon: account {} ({username}) has no SRP6 verifier (not provisioned) — rejecting", @@ -274,7 +212,7 @@ fn handle_challenge( large_safe_prime: LARGE_SAFE_PRIME_LITTLE_ENDIAN.to_vec(), salt: *proof.salt(), server_public_key: *proof.server_public_key(), - // Vanilla clients require this trailing byte; `None` = no PIN/matrix challenge. + // Vanilla clients require this trailing byte; None means no PIN or matrix challenge. security_flag: CMD_AUTH_LOGON_CHALLENGE_Server_SecurityFlag::None, } .write(&mut *stream)?; @@ -282,8 +220,6 @@ fn handle_challenge( Ok(Some((proof, account.id, username))) } -/// Verify the client proof; on success derive K, persist the session, and reply with M2. -/// Returns the authenticated account id on success, `None` on a (clean) proof failure. fn handle_proof( stream: &mut S, store: &St, @@ -297,8 +233,6 @@ fn handle_proof( .ok() .and_then(|key| proof.into_server(key, packet.client_proof).ok()); let Some((server, server_proof)) = verified else { - // A failed reply must still spend the address budget. Deliver the final permitted - // failure reply before ending the connection. let attempt = connection.record_failure(Instant::now()); std::thread::sleep(PROOF_FAILURE_DELAY); CMD_AUTH_LOGON_PROOF_Server::FailIncorrectPassword.write(&mut *stream)?; @@ -337,15 +271,9 @@ fn to_realm(r: &RealmInfo) -> Realm { } } -// =========================================================================================== -// Async listener (bridges accepted sockets to the blocking handler) -// =========================================================================================== - pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { let listener = TcpListener::bind(&cfg.logon_bind).await?; log::info!("logon listening on {}", cfg.logon_bind); - // A transient accept errno must cost ONE connection, not the realm. See `crate::accept` - // for the policy and for the errno that actually killed the gateway on 2026-08-07. let mut backoff = AcceptBackoff::new(); let mut capacity_was_full = false; let limiter = LogonLimiter::new(); @@ -378,7 +306,6 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { } }, }; - // Refused before the socket costs a deadline, a permit or a thread. let mut connection = match limiter.admit(peer.ip(), Instant::now()) { Ok(connection) => connection, Err(refusal) => { @@ -386,7 +313,6 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { continue; } }; - // The budget begins at accept, before this socket can wait for a blocking-pool thread. let mut deadline = PreAuthDeadline::after(LOGON_AUTH_DEADLINE); let Some(task_permit) = cfg.blocking_task_capacity.try_admit() else { if !capacity_was_full { @@ -400,11 +326,6 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { }; capacity_was_full = false; let coord = coordinator.clone(); - // wow_login_messages uses blocking std::io codecs, so run the per-connection state - // machine on a blocking task with the socket in blocking mode. - // These two are per-SOCKET (a dup and an fcntl on the fd we just accepted), so they fail - // for the same reasons accept does — EMFILE above all. Drop the one connection. - // The session clears both socket timeouts and cancels the watchdog once the proof succeeds. let std_sock = match sock.into_std().and_then(|s| { s.set_nonblocking(false)?; Ok(s) @@ -444,26 +365,6 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { } } -/// `LogonStore` backed by the SpacetimeDB coordinator connections. -/// -/// # Which database answers what -/// -/// | question | database | why | -/// |---|---|---| -/// | SRP6 salt/verifier/banned | **realm-core** | the account is realm-wide state | -/// | session key K | **realm-core** | so any gateway completes any handshake (the stateless-gateway invariant, now realm-scoped) | -/// | the world connection's bound identity | world shard | it is that shard's connection | -/// | `game_character.owner_identity` binding | world shard | characters are shard state | -/// | realm list + per-realm character count | world shard | `game_realm` / `game_character` | -/// -/// With `LYRACORE_REALM_CORE` unset, `Coordinator::realm_core()` returns the world handle and this table -/// collapses to one database — today's behavior, byte for byte. -/// -/// Every row in the "world shard" column takes an account id, and every one of those ids is -/// re-resolved from the username by [`CoordinatorStore::world_account_id`]. The id this store is -/// handed came from realm-core and is meaningless anywhere else. -/// -/// Production binds `Coordinator`; the RealmDb Fake exercises the same Store in unit tests. pub(crate) struct CoordinatorStore { coordinator: D, } @@ -473,13 +374,6 @@ impl CoordinatorStore { Self { coordinator } } - /// Translate the AUTHENTICATING database's account id into the WORLD shard's, by username. - /// - /// Every world-side call in this file takes an id, and every one of those ids has to be the - /// world shard's own `#[auto_inc]` key — `game_character.account_id` references it, - /// `player_conn` is cached under it, and `account_by_identity` resolves back through it. The id - /// that arrives here came from realm-core. This is the one function allowed to bridge them, and - /// the rule it applies is [`world_account_id`], which is pure and tested. fn world_account_id(&self, realm_account_id: u64, username: &str) -> Result { world_account_id( realm_account_id, @@ -496,20 +390,7 @@ impl CoordinatorStore { } } -/// The cross-database account-key rule, extracted so it can be tested without a node. -/// -/// `Account.id` is `#[auto_inc]`, so it is a *per-database* surrogate key; the USERNAME is the only -/// realm-wide account identity. Given realm-core's id for `username`, answer with the id the WORLD -/// shard issued for the same username: -/// -/// - `same_database` (realm-core unconfigured, or naming the world database) → the two ids are the -/// same id, so pass it straight through and do not spend a lookup. This is what keeps the -/// unconfigured path identical to the pre-realm-core gateway. -/// - otherwise → whatever the world shard's own `game_account` says. -/// - **never** the realm-core id on a world-shard call. A missing world-side account is an `Err`, -/// not a fallback: falling back would hand the world shard an id that, on any deployment whose -/// two databases were not seeded in the same order, names a DIFFERENT player's account — binding -/// this login's session key and connection identity to that player's characters. +// Account ids are shard-local; only the username is realm-wide. fn world_account_id( realm_account_id: u64, username: &str, @@ -531,9 +412,6 @@ fn world_account_id( impl LogonStore for CoordinatorStore { fn account(&self, username: &str) -> Result> { - // The SRP6 material is realm-core's, always. `realm_core()` errors (rather than falling - // back to the world DB's auth cache) when realm-core is configured and unreachable, which - // surfaces as a failed logon connection — the deliberate fail-closed posture. let Some(a) = self .coordinator .realm_core()? @@ -541,9 +419,6 @@ impl LogonStore for CoordinatorStore { else { return Ok(None); }; - // game_account stores these as 32-byte vecs. A wrong-length blob (corrupt provisioning, - // half-written migration) must surface as an error — silently coercing it to all-zeros - // would forge the legitimate all-zero `init` verifier placeholder (see :160). let salt = a.salt.try_into().map_err(|v: Vec| { anyhow!("account {}: salt is {} bytes, expected 32", a.id, v.len()) })?; @@ -563,30 +438,11 @@ impl LogonStore for CoordinatorStore { } fn bound_identity(&self, account_id: u64, username: &str) -> Result<[u8; 32]> { - // The stable per-account identity for this account, DERIVED by the world shard's key - // (`synthetic_owner_identity`) — the world phase later checks out with the same id - // (`WorldSession::account_id`, itself re-resolved by username in - // `world_store::lookup_session`). Handing realm-core's id here would bind - // `game_character.owner_identity` to an identity nothing ever presents again. let world_id = self.world_account_id(account_id, username)?; self.coordinator.bound_identity(world_id) } - /// Two writes, in this order: - /// - /// 1. **realm-core** — the authoritative `game_session` row (K + the bound identity). This is - /// the row every world gateway later reads to complete a handshake, so it is what makes the - /// gateway stateless at REALM scope. - /// 2. **the world shard** — the same reducer, whose real job here is binding - /// `game_character.owner_identity` for this account's characters so per-owner RLS lets the - /// player see them. Its `game_account`/`game_session` writes are the WRITE-THROUGH CACHE of - /// the realm-core rows: refreshed on every logon, never read for - /// authentication while realm-core is configured. That is why the tables stay in the world - /// schema — dropping them is a destructive migration, and they are load-bearing for the - /// character binding regardless. - /// - /// The world-shard id is re-resolved from the USERNAME. `account_id` is realm-core's - /// `#[auto_inc]` key and means nothing on another database; see [`LogonStore::save_session`]. + // Persist Realm-core's authoritative key before the World Shard's identity binding. fn save_session( &self, account_id: u64, @@ -597,7 +453,7 @@ impl LogonStore for CoordinatorStore { let realm_core = self.coordinator.realm_core()?; realm_core.establish_session(account_id, session_key, bound_identity)?; if realm_core.shard_name() == self.coordinator.shard_name() { - return Ok(()); // unconfigured realm-core: one database, one write — exactly as before + return Ok(()); } self.coordinator.establish_session( self.world_account_id(account_id, username)?, @@ -608,17 +464,12 @@ impl LogonStore for CoordinatorStore { fn realms(&self, account_id: u64, username: &str) -> Result> { let realm = self.coordinator.realm()?; - // `game_character.account_id` is the WORLD shard's key — counting realm-core's id here - // would advertise a different account's character count on the realm-select screen. let n = self .coordinator .character_count(self.world_account_id(account_id, username)?)?; Ok(vec![RealmInfo { id: realm.id, name: realm.name, - // The row says where the world listener is *for this deployment* only if nobody - // told us otherwise. `LYRACORE_REALM_ADDRESS` is how `lyracore dev up --lan` makes the - // realm reachable from another machine — see `config::advertised_realm_address`. address: crate::config::advertised_realm_address_or(realm.address), realm_type: realm.realm_type, population: realm.population, @@ -691,14 +542,10 @@ mod tests { use wow_login_messages::version_3::opcodes::ServerOpcodeMessage; use wow_srp::client::SrpClientChallenge; - /// In-memory `LogonStore` for end-to-end SRP6 tests. struct InMemoryStore { account: LogonAccount, username: String, saved: Mutex>, - /// `(method, username)` for every store call that was told the account's name — the - /// cross-database account key. A world-side implementation re-resolves its own id from it, - /// so a method that stops being told it silently regresses to carrying realm-core's id. usernames: Mutex>, } @@ -776,7 +623,6 @@ mod tests { #[test] fn full_srp6_handshake_and_realm_list() { - // Seed an account whose verifier matches password "PASSWORD". let v = SrpVerifier::from_username_and_password(ns("TEST"), ns("PASSWORD")); let salt = *v.salt(); let verifier = *v.password_verifier(); @@ -799,10 +645,8 @@ mod tests { handle_logon(&mut s, server_store.as_ref()).unwrap(); }); - // --- client: send challenge --- challenge_client("TEST").write(&mut client).unwrap(); - // --- client: read server challenge --- let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( CMD_AUTH_LOGON_CHALLENGE_Server::Success { @@ -817,7 +661,6 @@ mod tests { }; assert_eq!(g, vec![7]); - // --- client: compute proof with wow_srp client side --- let n: [u8; 32] = n.try_into().unwrap(); let challenge = SrpClientChallenge::new( ns("TEST"), @@ -841,7 +684,6 @@ mod tests { .write(&mut client) .unwrap(); - // --- client: read M2 and verify the server also knows the password --- let server_proof = match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_PROOF(CMD_AUTH_LOGON_PROOF_Server::Success { server_proof, @@ -851,17 +693,14 @@ mod tests { }; let srp_client = challenge.verify_server_proof(server_proof).unwrap(); - // Both sides derived the same session key K. assert_eq!( srp_client.session_key(), store.saved.lock().unwrap().as_ref().unwrap() ); - // --- client: request the realm list --- wow_login_messages::version_8::CMD_REALM_LIST_Client {} .write(&mut client) .unwrap(); - // The realm list is emitted in the vanilla (version_3 == version_2) format. match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_REALM_LIST(reply) => { assert_eq!(reply.realms.len(), 1); @@ -871,19 +710,12 @@ mod tests { other => panic!("expected realm list, got {other:?}"), } - drop(client); // client disconnect -> server handler returns + drop(client); server.join().unwrap(); } #[test] fn save_session_is_told_the_normalized_username_not_just_the_account_id() { - // `CoordinatorStore::save_session` has to write TWO databases — realm-core (the - // authoritative K) and the world shard (the character owner_identity binding) — and - // `account_id` is only meaningful on the database that issued it (`#[auto_inc]`). The - // username is the cross-database key, so the state machine must hand it down, already - // uppercased exactly as the account lookup normalized it. Without this the world-side write - // would have to reuse realm-core's id and could bind one account's session to another - // account's characters. let v = SrpVerifier::from_username_and_password(ns("TEST"), ns("PASSWORD")); let store = std::sync::Arc::new(InMemoryStore { account: LogonAccount { @@ -904,8 +736,6 @@ mod tests { let _ = handle_logon(&mut s, server_store.as_ref()); }); - // The client sends the account name in lowercase; the server uppercases it once, at the - // challenge, and everything downstream must see THAT form. challenge_client("test").write(&mut client).unwrap(); let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( @@ -945,7 +775,6 @@ mod tests { other => panic!("expected proof success, got {other:?}"), } - // Ask for the realm list too, so `realms` — the third world-side call — is exercised. wow_login_messages::version_8::CMD_REALM_LIST_Client {} .write(&mut client) .unwrap(); @@ -968,8 +797,6 @@ mod tests { #[test] fn a_world_side_id_is_re_resolved_from_the_username_never_carried_from_realm_core() { - // The rule behind the assertions above. Realm-core says this account is id 7; the - // world shard issued id 4 for the same username. Every world-side call must use 4. assert_eq!( world_account_id(7, "TEST", false, "world", || Ok(Some(4))).unwrap(), 4, @@ -979,8 +806,6 @@ mod tests { #[test] fn an_unconfigured_realm_core_passes_the_id_through_without_a_lookup() { - // One database ⇒ the two ids ARE the same id. Not merely equal-valued: the lookup must not - // even run, so the unconfigured logon costs exactly what it costs today. let id = world_account_id(7, "TEST", true, "world", || { panic!("no cross-database lookup may happen when realm-core is unconfigured") }) @@ -990,9 +815,6 @@ mod tests { #[test] fn an_account_missing_on_the_world_shard_is_an_error_not_a_reused_realm_core_id() { - // The dangerous fallback, stated as a test. If the world shard has no row for this - // username, reusing realm-core's id would name whichever account the world shard happens - // to have issued that id to — a different player. Refuse instead. let err = world_account_id(7, "TEST", false, "world", || Ok(None)).unwrap_err(); let msg = format!("{err:#}"); assert!( @@ -1017,7 +839,6 @@ mod tests { let (mut client, server_end) = UnixStream::pair().unwrap(); let server = std::thread::spawn(move || { let mut s = server_end; - // store moved in; handler returns when client drops. let _ = handle_logon(&mut s, &store); }); @@ -1032,7 +853,6 @@ mod tests { server.join().unwrap(); } - /// A provisioned account (real salt/verifier for `password`), for the negative-path tests. fn provisioned_store(username: &str, password: &str, banned: bool) -> InMemoryStore { let v = SrpVerifier::from_username_and_password(ns(username), ns(password)); InMemoryStore { @@ -1070,9 +890,6 @@ mod tests { #[test] fn unprovisioned_all_zero_verifier_is_rejected_as_unknown_account() { - // The `init` placeholder account has an all-zero verifier. wow_srp's key math PANICS on an - // all-zero key, so the challenge handler must reject it cleanly BEFORE the SRP6 setup — a - // regression here is a gateway crash, not a wrong reply. let store = InMemoryStore { account: LogonAccount { id: 1, @@ -1105,9 +922,6 @@ mod tests { #[test] fn wrong_password_proof_replies_fail_incorrect_password() { - // The account's verifier is for "PASSWORD"; the client computes its proof with "WRONGPASS", - // so `into_server` must reject the digest and the server replies FailIncorrectPassword - // (a clean per-attempt failure — the connection stays open for a retry). let store = provisioned_store("TEST", "PASSWORD", false); let (mut client, server_end) = UnixStream::pair().unwrap(); let server = std::thread::spawn(move || { @@ -1131,7 +945,7 @@ mod tests { let n: [u8; 32] = n.try_into().unwrap(); let challenge = SrpClientChallenge::new( ns("TEST"), - ns("WRONGPASS"), // ≠ the provisioned password + ns("WRONGPASS"), g[0], n, PublicKey::from_le_bytes(server_pubkey).unwrap(), @@ -1160,8 +974,6 @@ mod tests { #[test] fn proof_before_a_challenge_is_a_protocol_error() { - // CMD_AUTH_LOGON_PROOF with no pending challenge → `handle_logon` returns Err (there is no - // SRP state to verify against) instead of limping on. let store = provisioned_store("TEST", "PASSWORD", false); let (mut client, server_end) = UnixStream::pair().unwrap(); let server = std::thread::spawn(move || { @@ -1191,8 +1003,6 @@ mod tests { #[test] fn realm_list_before_a_completed_proof_is_empty() { - // Without a completed proof the account is unknown — the realm list must advertise nothing - // (not leak the realm to unauthenticated probes). let store = provisioned_store("TEST", "PASSWORD", false); let (mut client, server_end) = UnixStream::pair().unwrap(); let server = std::thread::spawn(move || { @@ -1235,20 +1045,12 @@ mod tests { to_realm(&realm(0)).realm_type, RealmType::PlayerVsEnvironment ); - // Any other code (not 1/6/8) falls back to PvE, not just the canonical 0. assert_eq!( to_realm(&realm(99)).realm_type, RealmType::PlayerVsEnvironment ); } - // =================================================================================== - // Credential normalization, store-failure handling, and replay - // =================================================================================== - - /// Drive a complete SRP6 handshake for `typed_account` against a store provisioned under - /// `stored_account`/`password`, and hand back the store so the caller can inspect what it was - /// told. Panics with a specific message at whichever step diverges. fn handshake_as( stored_account: &str, password: &str, @@ -1279,8 +1081,6 @@ mod tests { ), }; let n: [u8; 32] = n.try_into().unwrap(); - // The real client uppercases before computing its proof, so the proof is always over the - // UPPERCASED name — that is the contract `gateway provision` matches by uppercasing too. let challenge = SrpClientChallenge::new( ns(&typed_account.to_uppercase()), ns(password), @@ -1307,8 +1107,6 @@ mod tests { other => panic!("proof for {typed_account:?} was rejected: {other:?}"), } - // The realm list is the first authenticated read, and the last store call told the - // account's name — so drive it too rather than stopping at the proof. wow_login_messages::version_8::CMD_REALM_LIST_Client {} .write(&mut client) .unwrap(); @@ -1324,16 +1122,6 @@ mod tests { store } - /// The provisioning↔logon contract, end to end and offline. `gateway provision` computes the - /// verifier over the UPPERCASED username (`main.rs`'s `provision`), and `handle_challenge` - /// uppercases whatever the client sent before looking the account up. Those two `to_uppercase()` - /// calls are the same agreement written twice, in files that never reference each other. - /// - /// If either side stopped normalizing, an account provisioned as `TESTER` would simply not be - /// found for a player who typed `tester` — the account exists, the password is right, and every - /// login is refused as "unknown account". Asserting it through a REAL SRP6 handshake (not a - /// string comparison) is what makes this catch a one-sided change: the verifier is bound to the - /// name, so a mismatch fails the proof even if the lookup somehow succeeded. #[test] fn an_account_provisioned_uppercase_authenticates_whatever_case_the_player_types() { for typed in ["tester", "TESTER", "TeStEr", "tESTER"] { @@ -1353,16 +1141,6 @@ mod tests { } } - /// The SRP6 material `gateway provision` writes is what this handler later authenticates - /// against, and the two are computed in files that never meet. `provision` normalizes through - /// `provision_cli::normalize_provision_credentials` and stores `salt`/`password_verifier`; this - /// drives that exact derivation — the real function, not a re-typed copy — through a real - /// handshake. - /// - /// The salt must be RANDOM per provision — re-provisioning the same account must not reproduce - /// the same salt, or the verifier becomes a password-equivalent value that is identical across - /// every server sharing this code, and a stolen `game_account` table becomes a rainbow table. - /// Both independently-salted verifiers must still authenticate the same password. #[test] fn provisioning_the_same_credentials_twice_yields_different_salts_that_both_authenticate() { let (user, pass) = @@ -1383,7 +1161,6 @@ mod tests { derivation at all" ); - // Both are equally valid credentials: the password authenticates against either. for (which, v) in [("first", &first), ("second", &second)] { let store = std::sync::Arc::new(InMemoryStore { account: LogonAccount { @@ -1460,22 +1237,8 @@ mod tests { } } - /// The PROVISIONING↔LOGON seam, driven end to end offline. - /// - /// `provision_cli`'s tests assert what `normalize_provision_credentials` returns (`"alice"` → - /// `"ALICE"`); this file's tests assert what `handle_logon` accepts. Neither on its own catches - /// the failure that matters: the two normalizing the same input DIFFERENTLY. An account is - /// provisioned once and authenticated forever after, so a divergence is not a compile error and - /// not a failing unit test — it is "the account exists, the password is right, every login is - /// refused as unknown", which is precisely the class that once cost a 200-account benchmark run. - /// - /// So: run the real provisioning derivation over the raw bytes an operator would pipe in, store - /// exactly what it produces, and complete a real SRP6 handshake against it — typing the account - /// name in every casing a player might use. #[test] fn credentials_from_the_provisioning_path_authenticate_through_a_real_handshake() { - // The exact inputs `gateway provision tester --password-stdin` would carry: an unnormalized - // username from argv, and a password as raw bytes off stdin. let (user, pass) = crate::provision_cli::normalize_provision_credentials("tester", b"my pass phrase") .expect("an ordinary username/password must be accepted by provisioning"); @@ -1489,7 +1252,6 @@ mod tests { verifier: *verifier.password_verifier(), banned: false, }, - // The account row is keyed by whatever provisioning canonicalized the name to. username: user.as_ref().to_string(), saved: Mutex::new(None), usernames: Mutex::new(Vec::new()), @@ -1521,8 +1283,6 @@ mod tests { ), }; let n: [u8; 32] = n.try_into().unwrap(); - // The client uppercases and normalizes the same way — the password is case-folded too, - // which is why an operator may pipe it in any casing. let challenge = SrpClientChallenge::new( ns(typed), ns("MY PASS PHRASE"), @@ -1555,8 +1315,6 @@ mod tests { } } - /// The other half of normalization: it must not merge accounts that are genuinely different. - /// Case is the ONLY thing folded — `TESTER` and `TESTER2` stay distinct. #[test] fn normalization_folds_case_only_and_never_merges_two_different_accounts() { let store = provisioned_store("TESTER", "PASSWORD", false); @@ -1576,7 +1334,6 @@ mod tests { server.join().unwrap(); } - /// A store that fails every read — a dead realm-core, a torn subscription, a wrong-length blob. struct FailingStore { on: &'static str, account: LogonAccount, @@ -1630,15 +1387,6 @@ mod tests { } } - /// An INFRASTRUCTURE failure during the challenge must not be reported to the client as - /// `FailUnknownAccount`. Those two conditions look identical from the client's seat and are - /// opposites for an operator: one means "you typed the wrong name", the other means "realm-core - /// is down and every account on this realm is affected". - /// - /// `handle_challenge` uses `store.account(..)?`, so the error propagates out of `handle_logon` - /// and the connection drops — loudly, in the log, with the store's own message. The regression - /// this guards is someone "helpfully" turning that `?` into `.unwrap_or(None)`, which would - /// turn a realm-wide outage into a silent, per-player wrong-password story. #[test] fn a_store_failure_during_the_challenge_is_an_error_never_an_unknown_account_reply() { let store = failing_store("account"); @@ -1649,8 +1397,6 @@ mod tests { }); challenge_client("TEST").write(&mut client).unwrap(); - // Nothing may be sent: the handler returns Err before writing a reply, so the client sees - // a closed socket rather than a verdict on its credentials. let seen = ServerOpcodeMessage::read(&mut client); assert!( seen.is_err(), @@ -1667,9 +1413,6 @@ mod tests { drop(client); } - /// The same rule after a CORRECT proof: the credentials verified, and the realm list read then - /// failed. Answering with an empty realm list would show the player "no realms available" — a - /// dead end they cannot act on and an operator cannot see. It must be an error. #[test] fn a_realm_list_read_failure_after_a_good_proof_fails_loudly_not_with_an_empty_list() { let store = failing_store("realms"); @@ -1711,14 +1454,12 @@ mod tests { } .write(&mut client) .unwrap(); - // The proof itself succeeds... match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_PROOF(CMD_AUTH_LOGON_PROOF_Server::Success { .. }) => {} other => panic!("the proof should have been accepted, got {other:?}"), } - // ...and the realm list read is what fails. wow_login_messages::version_8::CMD_REALM_LIST_Client {} .write(&mut client) .unwrap(); @@ -1733,12 +1474,6 @@ mod tests { drop(client); } - /// A captured `CMD_AUTH_LOGON_PROOF` must not authenticate twice. `handle_logon` holds the SRP - /// state in a `pending` slot and `take()`s it on use, so a replayed proof finds nothing to - /// verify against and is the same protocol error as a proof with no challenge at all. - /// - /// Without the `take()`, the same captured proof bytes would re-derive the session key on - /// demand — a replay attack against a plaintext-framed, pre-encryption handshake. #[test] fn a_replayed_proof_cannot_authenticate_a_second_time() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -1786,7 +1521,6 @@ mod tests { other => panic!("the first proof must succeed, got {other:?}"), } - // Byte-identical replay of the exact same proof. proof.write(&mut client).unwrap(); let err = server .join() @@ -1799,8 +1533,6 @@ mod tests { drop(client); } - /// One failed logon attempt: a challenge for `username`, then a proof for `wrong_password`, - /// answered with FailIncorrectPassword. fn fail_proof(client: &mut UnixStream, username: &str, wrong_password: &str) { challenge_client(username).write(&mut *client).unwrap(); let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut *client).unwrap() { @@ -2060,7 +1792,6 @@ mod tests { ); } - /// Client side of challenge -> proof for a provisioned account; asserts the proof succeeds. fn prove(client: &mut UnixStream, username: &str, password: &str) { challenge_client(username).write(&mut *client).unwrap(); let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut *client).unwrap() { @@ -2102,7 +1833,6 @@ mod tests { } } - /// A peer that connects and sends nothing must give its blocking thread back at the deadline. #[test] fn a_silent_connection_is_closed_at_the_pre_auth_deadline() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -2129,8 +1859,6 @@ mod tests { ); } - /// The uncapped pre-auth request (an unauthenticated realm list gets an empty reply) must - /// still share the one total deadline; challenges are capped by the Logon Limiter instead. #[test] fn repeated_pre_auth_requests_cannot_extend_the_pre_auth_deadline() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -2213,8 +1941,6 @@ mod tests { ); } - /// The deadline covers the handshake only. Once the proof succeeds the client may wait at the - /// realm list past it and still get an answer. #[test] fn the_pre_auth_deadline_ends_once_the_proof_succeeds() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -2238,10 +1964,6 @@ mod tests { server.join().unwrap().unwrap(); } - /// The complement: a SECOND challenge must replace the pending SRP state, not be ignored in - /// favour of the first. A client that retries its challenge (the 1.12.1 client does, on a - /// timeout) computes its next proof against the SECOND server public key, so a handler that - /// kept the first would reject a perfectly correct password. #[test] fn a_second_challenge_replaces_the_pending_srp_state_rather_than_stranding_the_client() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -2251,7 +1973,6 @@ mod tests { handle_logon(&mut s, &store) }); - // First challenge — its material is deliberately discarded. challenge_client("TEST").write(&mut client).unwrap(); let first_pubkey = match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( @@ -2262,7 +1983,6 @@ mod tests { other => panic!("expected challenge success, got {other:?}"), }; - // Second challenge on the same connection. challenge_client("TEST").write(&mut client).unwrap(); let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut client).unwrap() { ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( @@ -2282,7 +2002,6 @@ mod tests { challenges would make the handshake replayable" ); - // The proof is computed against the SECOND challenge, which is what a retrying client does. let n: [u8; 32] = n.try_into().unwrap(); let challenge = SrpClientChallenge::new( ns("TEST"), @@ -2315,13 +2034,6 @@ mod tests { server.join().unwrap().expect("the session ends cleanly"); } - /// An account name longer than SRP6's 16-byte `NormalizedString` limit. The client's field is a - /// length-prefixed string with far more room than that, so this is reachable from the wire - /// without any modified client — and it arrives BEFORE the account has proven anything. - /// - /// The lookup misses first, so the reply is a clean `FailUnknownAccount`. The point is that the - /// oversized name never reaches `NormalizedString::new` as an unhandled case and never indexes - /// past its fixed-size buffer. #[test] fn an_over_long_account_name_is_declined_cleanly_before_any_srp_math() { let store = provisioned_store("TEST", "PASSWORD", false); @@ -2343,19 +2055,6 @@ mod tests { server.join().unwrap(); } - /// PROPERTY: the logon listener is the only surface on this server that parses bytes from - /// a peer who has proven NOTHING — no session key, no header cipher, no account. Whatever - /// arrives, `handle_logon` must return: `Ok(())` on a clean disconnect, `Err` on anything it - /// cannot make sense of. It must never panic (an unwound thread per malformed connect is a - /// denial-of-service with no rate limit) and never block forever. - /// - /// `main.rs`'s `boundary_panic_tripwire` already pins the LEXICAL half of this — that no - /// `.unwrap()`/`panic!` is written in this file. This pins the behavioural half it cannot see: a - /// panic reached through slicing, arithmetic, or a dependency. Both are needed; neither implies - /// the other. - /// - /// Deterministic: a fixed seed, replayed identically every run (see - /// `codec::property_tests::Rng` for why this is not `proptest`). #[test] fn any_byte_string_from_an_unauthenticated_peer_returns_rather_than_panicking() { use crate::codec::property_tests::Rng; @@ -2368,18 +2067,15 @@ mod tests { handle_logon(&mut s, &store) }); - // Half pure noise; half noise behind a valid opcode byte, so the generator reaches the - // per-message decoders instead of bouncing off the opcode dispatch every time. let mut buf = Vec::new(); if case % 2 == 1 { - buf.push([0u8, 1, 16][rng.below(3)]); // CHALLENGE / PROOF / REALM_LIST + buf.push([0u8, 1, 16][rng.below(3)]); } let noise_len = rng.below(80); buf.extend_from_slice(&rng.bytes(noise_len)); let _ = client.write_all(&buf); drop(client); - // The assertion is that the thread finishes and yields a Result at all. let _resolved = server.join().unwrap_or_else(|_| { panic!( "handle_logon panicked on unauthenticated input (case {case}): {buf:02X?} — a \ diff --git a/gateway/src/main.rs b/gateway/src/main.rs index bb1450fa9..09ff841ce 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -1,17 +1,3 @@ -//! Gateway — the edge/protocol tier. Terminates the unmodified WoW 1.12.1 client protocol -//! and translates it to/from SpacetimeDB. **Holds no game state**: everything it knows is -//! per-connection protocol scratch or a read of a config/session/entity table. -//! -//! Two listeners: -//! - logon (TCP 3724): SRP6 + realm list -> `logon` -//! - world (TCP 8085): handshake, header crypto, char enum, login, movement -> `world` -//! -//! Each connection is a state machine over `stdb::Coordinator` (module reads + reducer calls, -//! `stdb/`) and the `codec` translators (`codec/`, one file per wire-message family); `world` -//! additionally owns the in-gateway AOI scoping (`stdb::aoi` + the shared `world_view` index) -//! and the stuck-state relay planes (quest/item/rep/xp/level-up/teleport). See `docs/danger-zones.md` §3 for how to -//! launch this against the real five-database realm — do not hand-roll the launch. - mod accept; mod codec; mod config; @@ -35,32 +21,14 @@ use provision_cli::{parse_gateway_mode, read_password_line, GatewayMode}; #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; -/// Startup, in the order the three things have to happen: -/// -/// 1. **Logging first**, so everything below is visible. -/// 2. **`RLIMIT_NOFILE` raised before anything opens a descriptor.** Best-effort; a failure logs and -/// continues — see `fd_limit`. -/// 3. **The runtime built by hand rather than by `#[tokio::main]`**, purely so -/// `max_blocking_threads` is a configured, logged number instead of tokio's silently inherited -/// 512. Every other builder setting matches what `#[tokio::main]` does -/// (`new_multi_thread().enable_all()`). The same configured number sizes the listeners' shared, -/// non-waiting blocking-task capacity. -/// -/// The `dhat` profiler guard is declared before the runtime so it drops *after* it — a profiler that -/// stops recording while the runtime is still tearing down would under-report the shutdown path. fn main() -> Result<()> { #[cfg(feature = "dhat-heap")] let _dhat = dhat::Profiler::new_heap(); env_logger::init(); - // A stock container gives us soft 1024 against a hard limit of 524288, and the - // gateway dies at ~200 sessions with EMFILE while 512x of headroom sits unclaimed. fd_limit::raise_nofile_soft_to_hard(); - // THE ceiling on concurrent players: every World Session parks one blocking thread for its - // whole life, and logon handshakes draw from the same pool. The listeners use the same number - // for a shared non-waiting capacity, so they reject excess connections before submission. let max_blocking_threads = config::max_blocking_threads(); log::info!( "tokio blocking pool: max_blocking_threads={max_blocking_threads} \ @@ -81,9 +49,6 @@ async fn run() -> Result<()> { let mode = parse_gateway_mode(std::env::args().skip(1))?; let cfg = GatewayConfig::from_env(); - // Bring-up helper: the password is accepted only over stdin so it never appears in argv or the - // process list. It computes the same SRP6 salt/verifier the logon path verifies, calls the - // `provision_account` reducer, and exits. if let GatewayMode::Provision { username } = mode { let stdin = std::io::stdin(); let mut stdin = stdin.lock(); @@ -101,45 +66,24 @@ async fn run() -> Result<()> { cfg.stdb_uri ); - // The privileged coordination connection: reads config/session/account, calls - // establish_session / provision_account. Stateless gateways share K through the DB. let coordinator = stdb::Coordinator::connect(&cfg).await?; - // Here because this is the first point the realm row is readable. warn_on_unreachable_realm_address(&coordinator, &cfg); - // Bot-initiated (serendipity) invites: a session-less Character's invite has no client and no - // player connection to ride, so it is picked up here — on the coordinator, independent of any - // session — rather than from a per-player relay. coordinator.spawn_bot_invite_relay(); coordinator.spawn_party_command_relay(); - // Session-less Shard crossings, armed here for the same reason: a bot following its party - // through a portal has no loading screen for the escrowed transfer to run inside. coordinator.spawn_bot_transfer_relay(); - // A Character deleted on a Shard (a despawned bot, a deleted character) must also leave the - // party realm-core still lists it in; no client and no Shard reducer can reach realm-core. coordinator.spawn_character_gone_relay(); - // Realm-core can change a party no session acted in. Push each Roster Revision to the World - // Shard mirrors that lack it. coordinator.spawn_roster_revision_relay(); - // Keep this gateway's lease alive. The module's lease reaper despawns every session bound to - // a lease that stops heartbeating, so the heartbeat is what bounds ghost lifetime after a - // gateway crash — and its ABSENCE while sessions are bound is what would despawn a healthy - // gateway's players, which is why it spawns unconditionally at startup, not lazily on first - // use. coordinator.spawn_gateway_heartbeat(); - // Run both listeners concurrently. Each admitted socket gets its own blocking task. let logon = tokio::spawn(logon::run(cfg.clone(), coordinator.clone())); let world = tokio::spawn(world::run(cfg.clone(), coordinator.clone(), login_queue)); - // Heap profiling (feature-gated): a `dhat::Profiler` writes its report on DROP, - // and a signal-killed process never drops anything. So under this feature the gateway runs for - // `LYRACORE_PROFILE_SECS` and then RETURNS from main, letting the profiler write dhat-heap.json. #[cfg(feature = "dhat-heap")] { let secs: u64 = std::env::var("LYRACORE_PROFILE_SECS") @@ -161,8 +105,6 @@ async fn run() -> Result<()> { } } -/// Report a realm advertising loopback while listening for remote clients — otherwise the symptom -/// is a login loop with every startup marker green. An unreadable row is not fatal: this only warns. fn warn_on_unreachable_realm_address(coordinator: &stdb::Coordinator, cfg: &GatewayConfig) { let Ok(realm) = coordinator.realm() else { return; @@ -178,9 +120,6 @@ fn warn_on_unreachable_realm_address(coordinator: &stdb::Coordinator, cfg: &Gate } } -/// Compute SRP6 credentials for `username`/`password` and write them via the `provision_account` -/// reducer. The client uppercases the account name, so the verifier is computed over the uppercased -/// username to match the proof the client will send. async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Result<()> { use wow_srp::server::SrpVerifier; @@ -191,12 +130,8 @@ async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Resu let pw_verifier = *verifier.password_verifier(); let coordinator = stdb::Coordinator::connect(cfg).await?; - // The WORLD shard's copy. `Account.id` is `#[auto_inc]`, so it is a per-database surrogate key, - // and `characters` / `player_login` / `create_character` all mean THIS database's id — a - // character is owned by the world shard's account row. coordinator.provision_account(&user, &salt, &pw_verifier)?; - // SRP6 credentials belong to Realm-core. Unsharded, it is the world handle already written. let rc = coordinator.realm_core()?; if rc.shard_name() != coordinator.shard_name() { rc.provision_account(&user, &salt, &pw_verifier)?; diff --git a/gateway/src/movement_batch_metrics.rs b/gateway/src/movement_batch_metrics.rs index 1771fbc79..8672f75ff 100644 --- a/gateway/src/movement_batch_metrics.rs +++ b/gateway/src/movement_batch_metrics.rs @@ -1,5 +1,3 @@ -//! Lock-free observations at the movement batch submission boundary. - use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -47,8 +45,6 @@ impl MovementBatchMetrics { } } - /// Record one attempted reducer call. Entries in a failed call are reported as dropped, - /// separately from successful submissions. pub fn observe(&self, entries: usize, succeeded: bool) { let entries = entries as u64; self.entries.fetch_add(entries, Ordering::Relaxed); diff --git a/gateway/src/provision_cli.rs b/gateway/src/provision_cli.rs index ed6ff6061..1025b76a4 100644 --- a/gateway/src/provision_cli.rs +++ b/gateway/src/provision_cli.rs @@ -6,7 +6,6 @@ use zeroize::Zeroizing; pub(crate) const PROVISION_USAGE: &str = "usage: gateway provision --password-stdin"; const MAX_PASSWORD_BYTES: usize = MAXIMUM_STRING_LENGTH_IN_BYTES as usize; -// A valid password plus CRLF. The reader stops at LF and never allocates beyond this bound. const MAX_PASSWORD_STDIN_BYTES: usize = MAX_PASSWORD_BYTES + 2; #[derive(Debug, PartialEq, Eq)] @@ -28,19 +27,15 @@ pub(crate) fn parse_gateway_mode(mut args: impl Iterator) -> Resu bail!("{PROVISION_USAGE}"); }; if args.next().as_deref() != Some("--password-stdin") || args.next().is_some() { - // Do not include the rejected argument: in the legacy invocation it is the plaintext - // password that this interface exists to keep out of argv and diagnostics. + // This argument may be a legacy plaintext password; never include it in diagnostics. bail!("{PROVISION_USAGE}"); } Ok(GatewayMode::Provision { username }) } -/// Read exactly one bounded password line. LF and the CR in a CRLF terminator are removed; no -/// other byte is trimmed or rewritten. The returned allocation is wiped when it leaves scope. pub(crate) fn read_password_line(reader: &mut impl Read) -> Result>> { - // Reserve the full bound up front: growing a Vec could free its old allocation before - // `Zeroizing` gets a chance to wipe it. + // Reserve before reading secrets; Vec growth can free an allocation before Zeroizing wipes it. let mut password = Zeroizing::new(Vec::with_capacity(MAX_PASSWORD_STDIN_BYTES)); let mut byte = Zeroizing::new([0_u8; 1]); let mut bytes_read = 0; @@ -69,7 +64,7 @@ pub(crate) fn read_password_line(reader: &mut impl Read) -> Result, interrupt_next: bool, @@ -220,9 +208,6 @@ mod tests { ); } - /// A read error that is NOT `Interrupted` must fail, not spin. The retry loop has no iteration - /// bound, so treating every error as retryable would hang the provisioning command forever on a - /// closed or broken stdin. #[test] fn a_real_read_error_fails_instead_of_retrying_forever() { struct Broken; @@ -238,20 +223,10 @@ mod tests { ); } - /// PROPERTY: whatever bytes arrive on stdin, `read_password_line` returns — never panics, - /// and never yields more than the bound it advertises. It runs before any validation, on input - /// nobody has checked, and it does its own manual buffer arithmetic (a hand-rolled byte loop - /// with a capacity reservation and a CR fixup), which is exactly the shape that hides an - /// off-by-one. - /// - /// Deterministic: a fixed seed, replayed identically every run (see - /// `codec::property_tests::Rng` for why this is not `proptest`). #[test] fn any_stdin_byte_stream_is_read_within_the_advertised_bound() { let mut rng = crate::codec::property_tests::Rng::new(0x5354_4449_4E00); for _ in 0..2_000 { - // Bias toward lengths straddling the 16-byte limit and its CRLF allowance, and inject - // newlines often enough that the terminator branches are reached. let len = rng.below(40); let mut input: Vec = rng.bytes(len); for byte in input.iter_mut() { @@ -279,10 +254,6 @@ mod tests { } } - /// The companion property for the validator the reader hands off to: for ANY byte string, - /// `normalize_provision_credentials` returns a `Result`, and every accepted password is - /// something `wow_srp` can actually build a verifier from. Anything it accepts is written into - /// an account row that the logon tier must be able to authenticate forever after. #[test] fn any_password_bytes_are_normalized_or_rejected_but_never_panic() { let mut rng = crate::codec::property_tests::Rng::new(0x4E4F_524D); @@ -291,8 +262,6 @@ mod tests { let password = rng.bytes(len); if let Ok((username, normalized)) = normalize_provision_credentials("TEST", &password) { assert_eq!(username.as_ref(), "TEST"); - // An accepted password must survive the derivation the provisioning path performs - // next; a value that normalizes but cannot be hashed would panic in production. let _ = wow_srp::server::SrpVerifier::from_username_and_password(username, normalized); } diff --git a/gateway/src/read_deadline.rs b/gateway/src/read_deadline.rs index 8ae1550b5..c63b77ea7 100644 --- a/gateway/src/read_deadline.rs +++ b/gateway/src/read_deadline.rs @@ -1,24 +1,12 @@ -//! The total pre-auth I/O budget shared by both listeners. -//! -//! Each connection pins one blocking-pool thread, so a peer that connects and sends nothing would -//! hold a thread until the pool (512 by default) has no seat left for a real login. The listener -//! starts one budget when it accepts the socket. Each pre-auth read or write receives only the time -//! that remains. An async watchdog shuts down the socket at the same deadline even if its blocking -//! task has not started. The session clears both socket timeouts and cancels the watchdog once the -//! peer has proven itself. - use std::io::{Read, Write}; use std::net::Shutdown; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::oneshot; -/// Total time a logon connection gets to complete authentication after accept. pub const LOGON_AUTH_DEADLINE: Duration = Duration::from_secs(10); -/// Total time a world connection gets to complete client proof after accept. pub const WORLD_AUTH_DEADLINE: Duration = Duration::from_secs(15); -/// A socket whose blocking reads and writes can share one pre-auth deadline. pub trait IoDeadline { fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()>; fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()>; @@ -37,7 +25,6 @@ impl DeadlineClock for SystemClock { } } -/// One total budget shared by every read and write before authentication succeeds. pub(crate) struct PreAuthDeadline { expires_at: Instant, clock: C, @@ -54,7 +41,6 @@ impl PreAuthDeadline { } } - /// Shut the socket down at this deadline even if its blocking task has not started yet. pub(crate) fn arm(&mut self, stream: &std::net::TcpStream) -> std::io::Result<()> { let stream = stream.try_clone()?; let socket = Arc::new(Mutex::new(Some(stream))); @@ -103,7 +89,6 @@ impl PreAuthDeadline { } } - /// End the pre-auth policy without leaving either socket timeout on the long-lived session. pub(crate) fn finish(&mut self, stream: &S) -> std::io::Result<()> { if let Some(watchdog) = self.watchdog.as_mut() { watchdog.disarm_before(self.expires_at, &self.clock)?; @@ -176,8 +161,7 @@ struct PreAuthWatchdog { } impl PreAuthWatchdog { - /// Claim the socket before the deadline or leave it for the expiry task. The mutex makes proof - /// completion and the timer choose exactly one owner at the boundary. + // The mutex lets proof completion and expiry choose exactly one socket owner. fn disarm_before( &mut self, expires_at: Instant, @@ -241,8 +225,6 @@ impl IoDeadline for std::os::unix::net::UnixStream { } } -/// Linux reports `WouldBlock` when a socket read or write timeout expires; other platforms may -/// report `TimedOut`. pub fn is_io_deadline(e: &std::io::Error) -> bool { matches!( e.kind(), diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index ab0d176d6..b411e3ca6 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -1,21 +1,15 @@ -//! Realm-core routing through the `RealmDb` seam. -//! Production uses Coordinators; tests use a Fake with multiple databases. -//! Credentials and session keys come from Realm-core, while Character state stays on World Shards. - use anyhow::{anyhow, Result}; use crate::config::ShardMap; use crate::stdb::{AccountRow, RealmRow}; use crate::world::{Actor, WorldSession}; -/// The `game_session` row as the world handshake reads it: K and the end of its validity. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct SessionKey { pub key: [u8; 40], pub expires_at_micros: i64, } -/// Expiry includes the deadline itself, as in the Module reaper. pub(crate) fn session_expired(now_micros: i64, expires_at_micros: i64) -> bool { expires_at_micros <= now_micros } @@ -28,33 +22,19 @@ fn now_micros() -> i64 { i64::try_from(micros).unwrap_or(i64::MAX) } -/// A SpacetimeDB database handle, reduced to exactly what the realm-core split touches. -/// -/// Implemented by [`crate::stdb::Coordinator`] (production, `stdb/realm_db.rs`) and -/// [`fake::Handle`] (tests). pub(crate) trait RealmDb: Clone + Sized + Send + Sync { - /// The database this handle targets. fn shard_name(&self) -> &str; - /// Is the gateway running against more than one database? `false` is the unconfigured default. fn is_sharded(&self) -> bool; - /// The routing table this handle resolves through. fn shard_map(&self) -> &ShardMap; - /// The **realm-core** handle: the database that owns accounts, sessions and the character→shard - /// index. Unconfigured → this handle (one database, today's behaviour). Configured but not - /// connected → `Err`, which every auth path must propagate rather than fall back to the world - /// database's stale auth cache. fn realm_core(&self) -> Result; - /// The connected WORLD SHARDS, default first. Realm-core is never in it: it holds no characters. fn world_shards(&self) -> Vec<(String, Self)>; - // --- auth reads fn account_by_username(&self, username: &str) -> Result>; fn session_key(&self, account_id: u64) -> Result>; fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]>; fn character_count(&self, account_id: u64) -> Result; fn realm(&self) -> Result; - // --- auth write fn establish_session( &self, account_id: u64, @@ -62,22 +42,14 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { bound_identity: [u8; 32], ) -> Result<()>; - // --- Account-authorized command routing fn request_gm_command(&self, actor: Actor, alpha_test_tools: bool, text: String) -> Result<()>; - // --- the character→shard index - /// Where THIS database's own rows say the character is. `None` = not here. fn character_location(&self, guid: u64) -> Option<(u32, u64)>; - /// Write `guid`'s location into this database's character→shard index. fn set_character_shard(&self, guid: u64, map_id: u32, instance_id: u64) -> Result<()>; - /// Read the ordered Realm partition and any pending Transfer phase. fn realm_character_partition( &self, guid: u64, ) -> Result>; - /// Begin one exact Realm Transfer phase from its observed predecessor. Revision zero requires - /// an absent human locator and creates pending revision one. - // The arguments preserve the Realm reducer's predecessor, destination, and crossing Gate. #[allow(clippy::too_many_arguments)] fn begin_character_shard_transfer( &self, @@ -91,12 +63,10 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { controller_generation: u64, character_guid: u64, ) -> Result<()>; - /// Settle the Realm phase for one exact session-less Transfer. fn finish_character_shard_transfer( &self, intent: &crate::world::transfer::BotTransferIntent, ) -> Result<()>; - /// Settle the Realm phase for one exact player Transfer. fn finish_player_character_shard_transfer( &self, character_guid: u64, @@ -106,8 +76,6 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { destination_map: u32, destination_instance: u64, ) -> Result<()>; - /// Resume settlement from an exact destination arrival fence. - // The arguments preserve the Realm reducer's predecessor, destination, and crossing Gate. #[allow(clippy::too_many_arguments)] fn finish_pending_character_shard_transfer( &self, @@ -121,23 +89,9 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { transfer_intent_id: u64, controller_generation: u64, ) -> Result<()>; - /// Does THIS database hold an in-flight escrow (`game_transfer_out`) for `guid`? A shard - /// answering `true` is the SOURCE of a resumed transfer and wins outright over any shard merely - /// holding a durable row for the guid — see [`locate_home_shard`]. fn has_escrow(&self, guid: u64) -> bool; - // --- Load sampling. The gateway is the one component that can see the whole realm (every - // shard's metrics endpoint, every session, every live position), so it samples; realm-core just - // records what it is told (`module/src/load.rs`, ring-buffered per key). `session_count` reads - // THIS handle's own shard; `record_shard_load` is fired against whichever handle the CALLER - // holds — in production that is always the `realm_core()` handle, since that table is only - // ever read from there. - /// Live player sessions cached on THIS shard's coordinator connection — an approximate - /// per-shard population (a point-in-time snapshot, not a windowed average). fn session_count(&self) -> usize; - /// Record `shard`'s occupancy + session sample, tagged with `gateway_key` (this gateway - /// process's identity, hashed by `load_sample::gateway_key`), so N gateway processes - /// sampling the same shard keep independent ring history instead of clobbering each other. fn record_shard_load( &self, shard: &str, @@ -147,9 +101,6 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { ) -> Result<()>; } -/// The GM named an Account Realm-core does not hold. The Gateway detects this itself, so it is a -/// Refusal the GM reads as a system line. An unreachable Realm-core stays an untyped error and is a -/// Transport Loss. fn unknown_account(account_name: &str) -> anyhow::Error { crate::stdb::ReducerCallError::Rejected { operation: "gm_command".to_string(), @@ -158,8 +109,6 @@ fn unknown_account(account_name: &str) -> anyhow::Error { .into() } -/// Read current Alpha Test Tools authority from Realm-core and convey it to the Actor's Home Shard -/// in one caller-facing Store operation. Account ids never cross between databases. pub(crate) fn run_gm_command( home_shard: &D, account_name: &str, @@ -175,28 +124,6 @@ pub(crate) fn run_gm_command( home_shard.request_gm_command(actor, authority, text) } -// =============================================================================================== -// The world handshake's account→K lookup -// =============================================================================================== - -/// The world handshake's account→K lookup, split across the two databases that own the two halves -/// of the answer. The body of `SessionStore::lookup_session` for `Coordinator`. -/// -/// **K comes from realm-core**, which is the whole point of a realm-wide session table: the session -/// key is realm state, not -/// gateway state, so a gateway that is killed and restarted mid-session re-reads it and the same -/// handshake succeeds — now no matter which gateway, on which shard, the client reconnects to. -/// -/// **The account id comes from THIS (world) shard**, and that is not a detail. `Account.id` is -/// `#[auto_inc]`, so it is a *per-database* surrogate key; `game_character.account_id` and every -/// world-phase call (`characters`, `player_login`, `create_character`, …) mean the world shard's -/// id. The cross-database key is the USERNAME — which the client sends in `CMSG_AUTH_SESSION` and -/// the logon protocol carries end to end. Passing realm-core's id to the world shard would bind one -/// player to another player's characters, silently, on any deployment whose two databases were not -/// seeded in identical order. Never do it. -/// -/// With realm-core unconfigured [`RealmDb::realm_core`] IS this handle, so both reads hit one -/// database and this is today's code path with an extra (identical) lookup. pub(crate) fn lookup_session( db: &D, account_name: &str, @@ -205,25 +132,14 @@ pub(crate) fn lookup_session( let Some(authoritative) = realm_core.account_by_username(account_name)? else { return Ok(None); }; - // Deliberate simplification: no ban check here. The logon challenge already refuses a banned - // account BEFORE K is ever written, so the only case a handshake check would add is "banned - // while a live session row exists" — and the right answer to that is a live kick - // (subscribe to the account row, disconnect on `banned`), not a one-shot check at handshake - // time. Adding one here would also change unconfigured-realm-core behavior, which the split - // promises it will not. Ceiling: a ban does not take effect until the player's next logon. let Some(session) = realm_core.session_key(authoritative.id)? else { return Ok(None); }; - // Expired is refused exactly like absent: the client has to log on again. if session_expired(now_micros(), session.expires_at_micros) { log::info!("account {account_name}: session expired; refusing the world handshake"); return Ok(None); } let Some(local) = db.account_by_username(account_name)? else { - // `Ok(None)` (reject the handshake), NOT the id from realm-core: that id names whichever - // account this world shard happened to issue it to. Unreachable with realm-core - // unconfigured — the read above is the same read against the same database — so this - // arm cannot change single-database behaviour. log::error!( "account {account_name} authenticated on realm-core but has no account row on world \ shard {} — provision it there (accounts are keyed across databases by USERNAME; ids \ @@ -238,10 +154,6 @@ pub(crate) fn lookup_session( })) } -// =============================================================================================== -// The character→shard index -// =============================================================================================== - pub(crate) fn publish_bot_shard_index( db: &D, intent: &crate::world::transfer::BotTransferIntent, @@ -534,15 +446,7 @@ fn wait_for_settled_locator( } } -// =============================================================================================== -// `settle_home_shard`'s holder lookup — the index is used, not just written -// =============================================================================================== - -/// Resolve a Character holder from the Realm-core index, verifying each hint against its Shard. -/// A missing or stale hint falls back to a census. An escrow holder takes precedence over -/// a destination copy so a resumed transfer can complete from its source. pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { - // An unsharded Gateway needs no locator read or repair to find its own Character. if !db.is_sharded() { return None; } @@ -550,8 +454,7 @@ pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { let shards = db.world_shards(); let is_connected = |d: &str| shards.iter().any(|(n, _)| n == d); - // A pending locator may already name the destination map. Scan for source Escrow before - // accepting an imported destination copy. + // Source Escrow remains authoritative even after a pending locator names the destination. if let Some(rc) = &index { if let Some(hint) = rc .realm_character_partition(guid) @@ -571,21 +474,6 @@ pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { } } - // Miss: the full scan. A shard holding an escrow wins outright; otherwise prefer a shard whose - // row AGREES with the shard map, and only then fall back to first-responder order. - // - - // - // The rule, stated so it can be argued with: **a durable row is authoritative only on the shard - // the shard map assigns its own location to.** A row on `core` saying "I am at map 36" describes - // a character that belongs on the instances shard, so it is evidence of a leftover, not of - // residence — the same principle as `heal_shard_entry`'s guard, which is why both now read - // the same way: CONFIRM where a character is, never infer it from row order. - // - // Deliberately a PREFERENCE, not a filter: if the only row anywhere disagrees with the map, it is - // still returned (`disagreeing` below). Routing to a stale-looking row is what happens today and - // is recoverable; routing to NOTHING is a failed login. This can therefore only improve on the - // old behaviour, never regress it. let mut agreeing: Option<&D> = None; let mut disagreeing: Option<&D> = None; for (_, shard) in &shards { @@ -612,37 +500,11 @@ pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { holder.cloned() } -/// Write `guid`'s ACTUAL location (`shard`'s own `character_location`) back into the realm-core -/// index when it disagrees with what the index currently holds — including "holds nothing yet". -/// Best effort: a failed heal costs the next login -/// one extra probe and nothing else, so it must never fail a login that already routed fine. fn heal_shard_entry(rc: &D, guid: u64, shard: &D) { let Some(actual) = shard.character_location(guid) else { - // Matched by escrow alone with no durable row to read a location from — cannot happen before - // `finish_transfer` (the row is frozen, not deleted, until then), but nothing to write back. return; }; - // THE HEAL MUST CONFIRM, NEVER PREDICT. The index answers "which SHARD holds this - // character": every reader resolves the stored `(map, instance)` back through the shard map to a - // database name — `locate_home_shard`'s fast path does exactly that. So a pair that resolves to - // some OTHER database records "she lives over there" about a character we just found HERE. - // - // `character_location` does not rule that out. It prefers the LIVE entity row and, for a - // character mid-instance-entry, reports the PENDING destination (`stdb/reads.rs`) — so a - // character still standing on the source with a portal already stamped reads as the map the - // DESTINATION owns. Healing that wrote the destination into the index BEFORE `begin_transfer` - // had even opened the escrow, and the next login's fast path then trusted it: it resolved the - // destination as the holder, never saw the source's escrow (escrows are read from the holder - // only), resolved owner == holder, and `settle_transfer` took its no-op branch — leaving the - // source copy and its escrow stranded with no component left that would ever re-drive them. - // - // The legitimate way this index moves to the destination is `finish_player_shard_index_transfer`, driven only - // after `finish_transfer` returned Ok. This function only ever confirms where a character IS. - // - // Note: declining also covers the case where a shard genuinely holds a character whose map - // the shard map assigns elsewhere (a re-partition, before anything moved her). The index cannot - // express that — it stores a location, not a shard name — so the honest answer is to leave it - // alone and let the scan answer, at the cost of one extra probe per login until she moves. + // Heal only a confirmed durable location; predicting the destination can strand recovery. if shard.shard_map().resolve(actual.0, actual.1) != shard.shard_name() { return; } @@ -662,26 +524,6 @@ fn heal_shard_entry(rc: &D, guid: u64, shard: &D) { } } -// =============================================================================================== -// `delete_character`'s cross-shard routing -// =============================================================================================== - -/// Where `delete_character` must run for `guid`, or why it must not run anywhere right now. -/// -/// Reuses `locate_home_shard`'s index-first resolution — the SAME routing decision the -/// world-entry path already trusts — rather than inventing a second mechanism. `Ok(Some(owner))` is -/// the shard to delete on (which may legitimately BE `self` — nothing special-cases that, since -/// calling delete on a handle that happens to name `self`'s own database is harmless). `Ok(None)` -/// means the gateway is unsharded, or `guid` resolves nowhere at all (unknown/already-deleted), so -/// the caller falls back to its existing single-database delete and gets the exact NOT_FOUND-shaped -/// answer it always did. -/// -/// `Err` means REFUSE: a shard holding an in-flight escrow (`game_transfer_out`) for `guid` is -/// never a valid delete target. Between `import_character_blob` and `finish_transfer` the row can -/// be split across two databases, so deleting either half mid-flight either destroys the character -/// a moment before it lands on the other side, or leaves a frozen copy the resumed transfer then -/// fails against. The caller must surface this as a delete FAILURE — never a silent success, and -/// never a silent no-op indistinguishable from "nothing to delete". pub(crate) fn resolve_delete_shard(db: &D, guid: u64) -> Result> { if !db.is_sharded() { return Ok(None); @@ -699,12 +541,6 @@ pub(crate) fn resolve_delete_shard(db: &D, guid: u64) -> Result>, - // Test-double storage: each map mirrors one module TABLE's columns, so the tuple is the row shape. #[allow(clippy::type_complexity)] - /// `game_session`: account_id -> (K, bound identity, expires_at micros). pub sessions: Mutex>, - // Test-double storage: each map mirrors one module TABLE's columns, so the tuple is the row shape. #[allow(clippy::type_complexity)] - /// `game_character`: guid -> (map, instance). Also the character COUNT per account. pub characters: Mutex>, - /// `game_character_shard`. pub shard_index: Mutex>, - /// Ordered `game_character_shard` Transfer phases used by the crossing tests. pub shard_phases: Mutex>, - /// Reads before a Realm locator subscription observes the committed row. pub unobserved_locator_reads: Mutex, - /// The node-issued identity of this database's per-account player connection. pub identities: Mutex>, - /// How many identities this database has ever minted. Stamped into every identity so - /// a connection that was released and REBUILT is distinguishable from one that was reused — - /// the node mints a fresh identity per connection, and a rebuilt one is exactly what - /// `establish_session` has not bound. pub mints: Mutex, - /// `game_transfer_out`: guids with an in-flight escrow ON this database. pub escrows: Mutex>, - /// This shard's live player-SESSION count (`CoordinatorInner::players.len()`'s fake - /// equivalent) — set directly by a test, not derived from `characters`/`identities`. pub open_sessions: Mutex, - /// Every `record_shard_load` call this database RECEIVED, in order: (shard, - /// writer_occupancy_pct, sessions, gateway_key). pub recorded_shard_loads: Mutex>, - /// Every command this World Shard received: Actor, conveyed Alpha Test Tools authority, - /// and raw dot-command text. pub gm_commands: Mutex>, - /// Every `RealmDb` call served by this database, in order. pub log: Mutex>, } @@ -768,12 +581,9 @@ pub(crate) mod fake { pub(crate) struct Realm { pub dbs: HashMap>, pub map: ShardMap, - /// `false` = realm-core is configured but its websocket is down: `realm_core()` must `Err` - /// rather than serve the world database's stale auth cache. pub realm_core_up: bool, } - /// A handle on ONE database of a [`Realm`] — the fake's `Coordinator`. #[derive(Clone)] pub(crate) struct Handle { pub realm: Arc, @@ -912,8 +722,6 @@ pub(crate) mod fake { } } - /// Build a realm. `dbs` are the database names (the first is the default world shard), `rules` - /// is `LYRACORE_SHARD_MAP` text, `realm_core` is `LYRACORE_REALM_CORE`. pub(crate) fn realm(dbs: &[&str], rules: &str, realm_core: Option<&str>) -> Handle { let map = ShardMap::parse(dbs[0], rules).with_realm_core(realm_core); let realm = Realm { @@ -930,7 +738,6 @@ pub(crate) mod fake { } } - /// The same, with realm-core's connection DEAD. pub(crate) fn realm_with_dead_core(dbs: &[&str], rules: &str, realm_core: &str) -> Handle { let h = realm(dbs, rules, Some(realm_core)); let mut realm = Arc::try_unwrap(h.realm).ok().expect("sole owner"); @@ -1005,9 +812,6 @@ pub(crate) mod fake { fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { let db = self.store(); db.note(&format!("bound_identity({account_id})")); - // Mints a stable per-(database, account) identity on first use, exactly as - // `Coordinator::player_conn` does — and that is the point: two databases hand out - // DIFFERENT identities for the same account id. let mut ids = db.identities.lock().unwrap(); let n = ids.len(); let mut mints = db.mints.lock().unwrap(); @@ -1017,9 +821,6 @@ pub(crate) mod fake { id[0] = account_id as u8; id[1] = n as u8 + 1; id[2] = self.db.len() as u8; - // MONOTONIC, unlike the three above — a rebuild after a release re-enters an - // empty slot and would otherwise mint a byte-identical identity, hiding the very - // cost a release carries. id[3] = *mints; id })) @@ -1358,13 +1159,11 @@ mod tests { fn split_realm() -> super::fake::Handle { let h = realm(&[WORLD, CORE], "", Some(CORE)); - // realm-core: the authority. id 9, salt 0xAA. h.db_at(CORE) .accounts .lock() .unwrap() .insert(USER.into(), account(9, USER, 0xAA, 0xBB)); - // the world shard: a DIFFERENT id, and the write-through cache's stale SRP6 material. h.db_at(WORLD) .accounts .lock() @@ -1435,8 +1234,6 @@ mod tests { ); } - /// Realm-core being unreachable is infrastructure: the GM's command outcome is unknown, so it - /// ends the World Session. #[test] fn a_dead_realm_core_fails_before_the_home_shard_command_request() { let h = realm_with_dead_core(&[WORLD, CORE], "", CORE); @@ -1449,7 +1246,6 @@ mod tests { assert!(h.db_at(WORLD).gm_commands.lock().unwrap().is_empty()); } - /// The Gateway finds a mistyped Account itself. The GM reads the reason, so it is a Refusal. #[test] fn an_unknown_account_is_a_refusal_the_gm_reads() { let h = split_realm(); @@ -1488,18 +1284,6 @@ mod tests { ); } - /// A PROVISIONING corruption, not a client one. `game_account.salt`/`.verifier` are - /// 32-byte vectors in the schema, and nothing in SpacetimeDB enforces the length: a half-written - /// migration, a hand-typed `spacetime sql` row, or an importer bug can leave a short blob there. - /// - /// `CoordinatorStore::account` must report that as an ERROR. The alternative — coercing a short - /// blob into a zero-padded `[u8; 32]` — would manufacture exactly the all-zero verifier that the - /// unprovisioned `init` placeholder uses, and `handle_challenge` special-cases all-zero as - /// "unknown account". A corrupt row would then present as a wrong username: unfixable by the - /// player, invisible to the operator, and identical in the log to a typo. - /// - /// Both fields are checked separately because they are two independent `try_into`s; a fix that - /// only guarded one would still ship the other. #[test] fn a_wrong_length_credential_blob_is_an_error_never_a_zero_padded_verifier() { for field in ["salt", "verifier"] { @@ -1590,7 +1374,6 @@ mod tests { #[test] fn an_unconfigured_realm_core_writes_exactly_one_session_row() { - // The byte-identical claim, as behaviour: LYRACORE_REALM_CORE unset ⇒ one database, one write. let h = realm(&[WORLD], "", None); h.db_at(WORLD) .accounts @@ -1616,8 +1399,6 @@ mod tests { #[test] fn every_world_side_call_resolves_the_world_shards_own_account_id_from_the_username() { let h = split_realm(); - // Two characters, owned by the WORLD shard's ids 3 (ours) and 9 (someone else's — the id - // realm-core happens to use for us). h.db_at(WORLD) .characters .lock() @@ -1664,7 +1445,6 @@ mod tests { .lock() .unwrap() .insert(USER.into(), account(9, USER, 0xAA, 0xBB)); - // …and nothing on the world shard. let store = CoordinatorStore::new(h); assert!( store.save_session(9, USER, &K, [0x5A; 32]).is_err(), @@ -1676,8 +1456,6 @@ mod tests { #[test] fn the_world_handshake_reads_k_from_realm_core_and_the_account_id_from_the_world_shard() { let h = split_realm(); - // The authoritative session row (realm-core, id 9) and a STALE world-shard cache row under - // the world's own id 3. Both exist, as they do live after a logon. h.db_at(CORE) .sessions .lock() @@ -1711,7 +1489,6 @@ mod tests { #[test] fn an_expired_session_is_refused_like_an_absent_one() { let h = split_realm(); - // Expired one microsecond after the epoch: no clock this test runs under is earlier. h.db_at(CORE) .sessions .lock() @@ -1804,11 +1581,6 @@ mod tests { h } - // ------------------------------------------------------------------------------------- - // `locate_home_shard`: `settle_home_shard`'s holder lookup, index-first - // ------------------------------------------------------------------------------------- - - /// An in-flight source Escrow remains the recovery authority after destination import. #[test] fn a_mid_portal_heal_never_makes_the_next_login_resolve_the_destination_as_the_holder() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); @@ -1853,14 +1625,11 @@ mod tests { #[test] fn the_scan_prefers_the_shard_whose_row_agrees_with_the_map_over_iteration_order() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); - // WORLD is the default shard and is visited FIRST. It carries a stale artefact whose location - // (map 36) the shard map assigns to INSTANCES — so it describes a character living elsewhere. h.db_at(WORLD) .characters .lock() .unwrap() .insert(100, (1, (36, 7))); - // INSTANCES carries the real row, and map 36 resolves to INSTANCES, so it agrees. h.db_at(INSTANCES) .characters .lock() @@ -1879,8 +1648,6 @@ mod tests { #[test] fn a_lone_disagreeing_row_still_resolves_rather_than_failing_the_login() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); - // The character's ONLY row is on WORLD, carrying a location the map assigns to INSTANCES — - // e.g. mid-portal, where `teleport_player` has already stamped the destination. h.db_at(WORLD) .characters .lock() @@ -1894,28 +1661,9 @@ mod tests { ); } - /// The SAME rule with the roles swapped, and it exists because the test above does not pin the - /// rule it claims to. - /// - /// ```ignore - /// if shard.shard_name() == shard.shard_map().default_db() { return; } // "never heal on the default shard" - /// ``` - /// - /// Every other test in this module puts the character on `INSTANCES` when a heal must be - /// PERMITTED and on `WORLD` (the default) when it must be DECLINED — so "holder disagrees with - /// the shard map" and "holder is the default database" are indistinguishable across the whole - /// suite. That mutant is not harmless: it never heals an open-world character at all, and it - /// fails to fix the stranded copy on the RETURN leg (instances → world), where the real guard declines - /// `resolve(0,0) == "world" != "instances"` and the mutant permits — putting the index back to - /// naming `world` while the escrow sits on `instances`, straight back into the strand. - /// - /// So: character found on the NON-default shard, carrying a location the default shard owns. - /// The heal must still decline. Passes with the real guard, red with the mutant. #[test] fn a_heal_is_declined_by_the_shard_map_not_by_being_the_default_database() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); - // She is durable on INSTANCES, but her location is open-world map 0 — which the shard map - // assigns to WORLD. Healing must follow the durable location. h.db_at(INSTANCES) .characters .lock() @@ -1932,7 +1680,6 @@ mod tests { ); } - /// A settled locator avoids reads on unrelated World Shards. #[test] fn locate_home_shard_trusts_a_correct_index_entry_without_scanning_the_other_shard() { let h = routed_realm(); @@ -1958,7 +1705,6 @@ mod tests { #[test] fn a_wrong_index_entry_still_routes_correctly_on_the_production_path_and_is_healed() { let h = routed_realm(); - // The index insists the character is in the open world on the default shard. It is not. h.db_at(CORE) .shard_index .lock() @@ -2130,13 +1876,9 @@ mod tests { ); } - /// Fallback census prefers the Escrow holder over an imported destination copy. #[test] fn locate_home_shard_still_prefers_the_shard_holding_the_escrow_in_the_fallback_scan() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); - // No index entry, so this forces the fallback scan. BOTH databases hold a durable row for - // guid 100 — the fenced-import window — and WORLD is iterated FIRST (default-first - // order), so a plain first-found scan would wrongly pick it. h.db_at(WORLD) .characters .lock() @@ -2178,12 +1920,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // `resolve_delete_shard`: `delete_character`'s cross-shard routing - // ------------------------------------------------------------------------------------- - - /// The bug this routing exists to fix, proven at the resolver: a character resident on a NON-default - /// shard must resolve to THAT shard, not `None` (which the caller reads as "delete on `self`"). #[test] fn resolve_delete_shard_routes_to_the_shard_actually_holding_the_character() { let h = routed_realm(); @@ -2198,11 +1934,6 @@ mod tests { ); } - /// A character that already lives on the asking handle still resolves — to a handle naming - /// `self`'s OWN database, not a wrong one. `resolve_delete_shard` does not special-case "already - /// here" into `None`; the caller treats `Some(self-equivalent)` and `None` identically (both end - /// up calling `delete_character` on a `Coordinator` pointed at this database), so what matters - /// is that the resolution never drifts onto a DIFFERENT shard when it should not. #[test] fn resolve_delete_shard_still_names_self_when_the_character_already_lives_here() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); @@ -2222,8 +1953,6 @@ mod tests { ); } - /// An unknown/never-existed guid must not be manufactured a route either — `None`, so the - /// caller's existing single-database delete answers NOT_FOUND exactly as it always did. #[test] fn resolve_delete_shard_answers_none_for_an_unknown_character() { let h = routed_realm(); @@ -2235,8 +1964,6 @@ mod tests { ); } - /// The corruption guard (the design constraint on this routing): a character mid-transfer must be REFUSED, - /// never deleted out from under a resumed transfer. #[test] fn resolve_delete_shard_refuses_a_character_mid_transfer() { let h = routed_realm(); @@ -2249,8 +1976,6 @@ mod tests { ); } - /// This resolver's own copy of the single-shard short-circuit: an unconfigured gateway must pay zero - /// reads resolving a delete target, exactly like `locate_home_shard`'s identical rule. #[test] fn resolve_delete_shard_short_circuits_on_an_unsharded_gateway_reading_nothing() { let h = realm(&[WORLD], "", None); diff --git a/gateway/src/stdb/account_sessions.rs b/gateway/src/stdb/account_sessions.rs index 6db537e08..0909b6bb4 100644 --- a/gateway/src/stdb/account_sessions.rs +++ b/gateway/src/stdb/account_sessions.rs @@ -1,5 +1,3 @@ -//! Realm-core Account claims and their World Shard fences. - use anyhow::{anyhow, Result}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::{Arc, Mutex}; @@ -38,9 +36,6 @@ fn shadow_account(account: &Account) -> bool { && account.verifier.is_empty() } -/// The Realm Account name one World Shard holds for a Character. The Account Character Owner is -/// exact. Without one, a real local Account carries the Realm Account's name, and a shadow -/// Account names nobody. fn realm_account_of( owner: Option<&AccountCharacterOwner>, local_account: Option<&Account>, @@ -100,7 +95,6 @@ impl SessionOwnership { } impl Coordinator { - /// The Realm Account name THIS handle holds for `character_guid`, by primary key reads only. pub(crate) fn realm_account_name(&self, character_guid: u64) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; @@ -116,13 +110,10 @@ impl Coordinator { realm_account_of(owner.as_ref(), local_account.as_ref()) } - /// `actor` acting under this handle's Account Claim. pub(crate) fn session_actor(&self, actor: Actor) -> SessionActor { self.signed_actor(actor.guid()) } - /// The bound owner's Character, for Durable Requests the Gateway makes on the World Session's - /// behalf rather than a client's. Guid 0 when no session is bound. pub(crate) fn owner_actor(&self) -> SessionActor { self.signed_actor(self.2.as_ref().map_or(0, |owner| owner.character_guid)) } @@ -264,8 +255,6 @@ impl Coordinator { ) )?; } - // No renewal is started for an incomplete admission. Its claim expires so another - // generation can finish fencing Shards that the interrupted attempt did not reach. Ok(token) } @@ -296,8 +285,7 @@ impl Coordinator { } let coord = unbound.clone(); let token = owner.token; - // World Sessions can occupy every Tokio blocking slot. Renewal must keep - // progressing independently of those long-lived readers. + // Renew off Tokio's blocking pool; World Sessions can occupy every slot. let (reply, result) = tokio::sync::oneshot::channel(); let started = std::thread::Builder::new() .name("account-renewal".into()) @@ -727,8 +715,6 @@ mod tests { 1 ); - // Hold A's cleanup while its Realm claim expires. The World Shard still has A's old - // generation, which B must advance before entering. fixture.assert_sql(&format!( "UPDATE game_account_claim SET expires_micros = 0 WHERE account_id = {}", first.account_id diff --git a/gateway/src/stdb/aoi.rs b/gateway/src/stdb/aoi.rs index 089d3245f..cd7539eff 100644 --- a/gateway/src/stdb/aoi.rs +++ b/gateway/src/stdb/aoi.rs @@ -1,48 +1,9 @@ -//! What is left of the Area-of-Interest tier after the shared-connection model landed: the -//! per-viewer state that outlived the per-player subscription, and the frozen SQL oracle its -//! replacement is tested against. - -//! -//! Before the shared-connection model, this file owned a per-player, grid-SCOPED subscription -//! over four tables that recentered (unsubscribe + resubscribe) as the player crossed cells. All -//! of it existed to push AOI filtering into the database. -//! -//! It measured badly. At 600 players it produced ~600 distinct subscription query strings that -//! could neither be shared nor pruned, and every committed transaction woke ~600 SDK pumps — -//! load average 11–14 on 8 cores at 34% CPU with 7 runnable threads, i.e. a wakeup storm rather -//! than work. The filtering now happens in-process, indexed by cell, in -//! [`super::world_index`] + [`super::world_view`], driven by ONE global subscription per shard on -//! the coordinator connection. Every shard's stream feeds one index. -//! -//! `GridBox` itself stayed where it was (`lyracore_shared::spatial`) — it is pure, unit-tested, and -//! still defines the interest square both tiers agree on. - use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -/// Count every AOI recenter (a viewer crossing a cell) across every session, cheaply — one -/// `fetch_add` on the movement hot path, read back by the periodic log line in `world/mod.rs`. A -/// COUNT, not a rate; the log line takes the delta. -/// -/// Under the shared-connection model a recenter is a 10-cell set diff in memory instead of a -/// subscription round trip, so the number is no longer a proxy for database load — it is still -/// the right proxy for how much -/// CREATE/DESTROY churn the movement path is generating. pub(crate) static AOI_RECENTERS: AtomicU64 = AtomicU64::new(0); -/// Viewer-owned state that a relay running on a SHARED connection cannot look up. -/// -/// The ghost flag was already a mirror before the shared-connection model (a relay could not read -/// the viewer's own row, which lives only on its home shard). It stayed a mirror afterwards for -/// the same reason with a different cause: a coordinator callback holds the cache of the shard that owns the ROW, which for -/// a cross-shard peer is not the shard that owns the VIEWER. A plain atomic, written on paths that -/// already run per session — never a cross-shard read per row. #[derive(Default)] pub(crate) struct ViewerGates { - /// The viewer's own GHOST player-flag, for the spirit-healer visibility gate. Seeded at world - /// entry from the character's own row and edge-detected thereafter by the self-row update relay. - /// - /// The seeding is not optional: before the shared-connection model the gate re-read the live - /// row on every offer, so a player who logged in ALREADY dead saw the spirit healer without any transition ever firing. pub(crate) is_ghost: AtomicBool, } @@ -52,10 +13,7 @@ impl ViewerGates { } } -/// The FROZEN subscription-SQL oracle, from before the shared-connection model. -/// -/// **Do not "fix" these to match the index.** Their whole value is being the independent side of -/// that comparison; a change here that makes a failing differential test pass has deleted the test. +// Keep the SQL oracle independent of the index; matching it to the implementation voids the test. #[cfg(test)] mod frozen_sql_oracle { use lyracore_shared::spatial::GridBox; @@ -81,9 +39,6 @@ mod frozen_sql_oracle { ) } - /// ONE point-probe query per (table, cell) — the shape the missing-AOI-index fix landed. Kept alongside the range - /// form because a future change has to satisfy BOTH or it has narrowed visibility on one of - /// them. fn cell_queries( map_id: u32, instance_id: u64, @@ -101,8 +56,6 @@ mod frozen_sql_oracle { out } - /// THE box's query set — every table the player's 5×5 box scoped, in one place. `per_cell` picks - /// the query SHAPE: one indexed equality probe per cell, or the single range box. pub(crate) fn box_queries(instance_id: u64, box_: &GridBox, per_cell: bool) -> Vec { if per_cell { return cell_queries(box_.map_id, instance_id, box_.cell_ids()); @@ -133,10 +86,6 @@ mod tests { use super::*; use lyracore_shared::spatial::GridBox; - /// The oracle must still describe the box the shared crate defines: anchor ± span on both axes, - /// partition-qualified. If the spatial constants ever move, this fails HERE — the one place that - /// would otherwise let the frozen oracle and the live index drift apart for a reason that is - /// nobody's bug. #[test] fn the_frozen_oracle_still_describes_the_shared_crates_box() { let b = GridBox { @@ -159,8 +108,6 @@ mod tests { assert!(entity.contains("grid_y >= 5") && entity.contains("grid_y <= 9")); } - /// The per-cell shape probes exactly the box's own 25 cells per table — no more (a peer spawned - /// outside the box) and no fewer (a blind spot the player walks into). #[test] fn the_frozen_oracle_probes_exactly_the_boxs_own_cells() { let b = GridBox { diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 5fbfb87e8..a1272912f 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -1,7 +1,3 @@ -//! Character-sheet resistances from the coordinator's entity, aura, item and catalogue caches. -//! The shared combat fold includes applied enchantments and Random Properties. -//! Other character-sheet values come directly from the Module's derived entity fields. - use super::bindings::*; use spacetimedb_sdk::Table; use std::collections::HashMap; @@ -24,24 +20,9 @@ fn aura_values(db: &RemoteTables, guid: u64) -> Vec { .collect() } -/// Everything the paperdoll shows that GEAR/AURAS move on the STR/AGI/STA/INT/SPI/AP/damage-range/crit -/// half of the sheet, a plain row read of `module::spell::recompute_sheet`'s output, -/// never a second fold. `strength`/`agility`/`stamina`/`intellect`/`spirit` are the BASE attributes (the -/// white `UNIT_FIELD_STAT0..4` total); `*_bonus` is the SIGNED aura+gear(+enchant) delta the caller -/// splits into the green/red `PLAYER_FIELD_POSSTAT`/`NEGSTAT` pair via `.max(0)`/`.min(0)` — sign -/// arithmetic only, not aura interpretation, so no drift risk re-enters the gateway. `attack_power` is -/// the stat-derived base (folds effective STR/AGI, so a +STR trinket already moves it) and `ap_mods` is -/// the `A_MOD_COMBAT(ATTACK_POWER)` aura portion alone (Battle Shout) — vanilla renders those through -/// two different wire fields. `dmg_min`/`dmg_max` are `combat::swing_range_ctx`'s own numbers (weapon + -/// AP + disarm folded in module-side), so the sheet can never show a range the swing doesn't roll. -/// `crit_pct` is `sheet_crit_bp`/100.0 — `combat::effective_crit_bp`'s output, the exact band the swing -/// table rolls a crit against. pub(crate) fn sheet_stats(db: &RemoteTables, guid: u64) -> Option { let e = db.game_world_entity().guid().find(&guid)?; - // `e.strength`/`agility`/`stamina`/`intellect` are BASE only (unlike `e.spirit`, which - // `recompute_vitals` already overwrites to the effective value) — the wire field is the white - // EFFECTIVE total (base + bonus), matching the armor half's `total` (see module docs above), so - // add the signed `sheet_*_bonus` delta back in here rather than sending the base straight through. + // Strength, agility, stamina and intellect are base values; spirit is already effective. Some(crate::codec::SheetStatsValues { strength: (e.strength as i32 + e.sheet_str_bonus).max(0) as u32, agility: (e.agility as i32 + e.sheet_agi_bonus).max(0) as u32, @@ -60,9 +41,7 @@ pub(crate) fn sheet_stats(db: &RemoteTables, guid: u64) -> Option u32 { resistance::positive_armor(aura_values(db, guid)) } -/// Effective armor followed by Holy, Fire, Nature, Frost, Shadow and Arcane resistance. -/// The order matches `UNIT_FIELD_RESISTANCES[0..=6]`. +/// Descriptor order is armor, Holy, Fire, Nature, Frost, Shadow, Arcane. pub(crate) fn effective_resistances(db: &RemoteTables, guid: u64) -> [u32; 7] { let base_armor = db .game_world_entity() @@ -112,7 +90,6 @@ pub(crate) fn effective_resistances(db: &RemoteTables, guid: u64) -> [u32; 7] { }) }) .collect(); - // The SDK exposes no non-unique enchantment index. Read its catalogue once per projection. let mut enchantments = HashMap::>::new(); for effect in db.game_item_enchantment().iter() { enchantments.entry(effect.enchant_id).or_default().push(( diff --git a/gateway/src/stdb/auction_holds.rs b/gateway/src/stdb/auction_holds.rs index ba9bf074e..ecac4b8e1 100644 --- a/gateway/src/stdb/auction_holds.rs +++ b/gateway/src/stdb/auction_holds.rs @@ -1,7 +1,3 @@ -//! The auction rows of each Character, kept from the same row callbacks that fill the coordinator -//! cache. The SDK cache indexes these tables by primary key only, and the Hold and receipt tables -//! keep every finished row, so a per-request lookup by Character would otherwise scan them. - use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock, RwLockWriteGuard}; @@ -10,12 +6,10 @@ use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::bindings::*; -/// A Hold still owes a phase: Realm-core has not decided it, or its refund waits for mail. pub(crate) fn hold_is_unfinished(hold: &AuctionBidHold) -> bool { hold.outcome == bid_outcome::PENDING || hold.deferred_refund != 0 } -/// Primary keys of one table's rows, by the Character each row belongs to. #[derive(Default)] pub(crate) struct KeysByCharacter { by_character: HashMap>, @@ -38,8 +32,6 @@ impl KeysByCharacter { } } - /// The keys of `character_guid`'s rows, lowest first. A caller reads each row back by primary - /// key, so a stale key costs one missed lookup and nothing else. pub(crate) fn keys_of(&self, character_guid: u64) -> Vec { self.by_character .get(&character_guid) @@ -48,17 +40,11 @@ impl KeysByCharacter { } } -/// Every per-Character auction index one coordinator keeps. #[derive(Default)] pub(crate) struct AuctionIndex { - /// Unfinished bid and Cancellation Holds, by bidder. pub(crate) unfinished_bid_holds: KeysByCharacter, - /// Listing Holds, by seller. Settle and release delete a listing Hold, so each one is - /// unfinished. pub(crate) listing_holds: KeysByCharacter, - /// Listing receipts, by seller. pub(crate) listing_receipts: KeysByCharacter, - /// Auctions, by seller and by highest bidder. pub(crate) auctions: KeysByCharacter, } @@ -96,8 +82,6 @@ fn locked(index: &RwLock) -> RwLockWriteGuard<'_, AuctionIndex> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } -/// Keep an [`AuctionIndex`] current from the auction row callbacks. Initial subscription rows -/// arrive as inserts. pub(crate) fn watch_auctions(conn: &DbConnection) -> Arc> { let index = Arc::new(RwLock::new(AuctionIndex::default())); @@ -201,7 +185,6 @@ mod tests { index.add_bid_hold(&hold(9, 8, 0, 0)); assert_eq!(index.unfinished_bid_holds.keys_of(7), vec![3, 5]); - // A decision whose refund overflowed the purse still owes the refund phases. let overflowed = hold(5, 7, 2, 10); index.remove_bid_hold(&pending); index.add_bid_hold(&overflowed); diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index fd7d5c0de..90d657f38 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1,8 +1,3 @@ -//! Connection tier: the `Coordinator` facade + its inner shared state, the live privileged -//! connection (with the reconnect watchdog), the per-account player connections, the -//! `call_reducer!` macro, and the connection lifecycle constructors. Reads/reducers/subscriptions -//! live in sibling modules as additional `impl Coordinator` blocks. - use crate::config::{GatewayConfig, ShardMap}; use anyhow::{anyhow, Context, Result}; use spacetimedb_sdk::{DbContext, SubscriptionHandle as _, Table, TableWithPrimaryKey}; @@ -24,15 +19,6 @@ pub(crate) struct CharacterPresenceSnapshot { pub(crate) present: bool, } -/// Shared handle to the privileged coordination connection **of one shard**. -/// -/// Field 0 is the shard this handle talks to — every read, every reducer call, and every -/// subscription in the `reads`/`reducers`/`subscriptions` sibling modules goes through it, so -/// "which database does this call hit" is decided entirely by which handle you hold. Field 1 is -/// the shared, shard-independent routing table + connection set: `shard_for` mints the -/// handle for another shard, and `Coordinator::connect` hands back the DEFAULT shard's handle. -/// With a single-entry shard map the set has exactly one entry and `shard_for` always resolves -/// back to `self` — byte-identical to the pre-sharding gateway. #[derive(Clone)] pub struct Coordinator( pub(crate) Arc, @@ -40,36 +26,20 @@ pub struct Coordinator( pub(crate) Option>, ); -/// The Gateway's configured Shards and shared Relay view. pub(crate) struct ShardSet { map: ShardMap, - /// db name → that database's coordinator connection. Always contains the default database; - /// an extra shard that failed to connect is ABSENT (routing then degrades to the default). conns: HashMap>, - /// Realm-wide delivery bookkeeping. Globally allocated guids let one viewer index span - /// all configured Shards; durable Account Claims own World Session authority. world: Arc, - /// Coalesce row-delete and reconnect requests behind one off-pump reconciliation worker. pub(crate) deleted_character_reconciliation_requested: AtomicBool, pub(crate) deleted_character_reconciliation_running: AtomicBool, - /// The guild cleanup the reconciliation worker still owes. pub(crate) guild_cleanup: std::sync::Mutex, } -/// One live SDK connection generation and the handles that keep its pump and subscription alive. -/// Coordinator generations own the shard cache; call-pipe generations carry only the reducer -/// liveness subscription. Recovery replaces either role as one unit. pub(crate) struct LiveConn { - /// Privileged owner connection. Coordinators use its subscription cache for reads; call pipes - /// use it only for the module's operator-gated reducer surface. pub(crate) conn: Arc, - /// Completes reducer waits as soon as this connection's transport dies. It belongs to the - /// connection generation, so a watchdog replacement cannot fail calls on the fresh socket. pub(crate) reducer_completion: Arc, - /// Keeps the SDK message-pump thread alive for the connection's lifetime. _pump: Option>, pump_commands: tokio::sync::mpsc::UnboundedSender, - /// Identity of this cache plus the Character-presence revision maintained by row callbacks. cache_generation: u64, character_revision: Arc, pub(crate) party_memberships: Arc>, @@ -79,11 +49,9 @@ pub(crate) struct LiveConn { pub(crate) guilds: Arc>, pub(crate) contacts: Arc>, pub(crate) character_names: Arc>, - /// Keeps this role's subscription active for the connection's lifetime. _sub: SubscriptionHandle, } -/// A group id and its members as `(member_row_id, guid)`, in join order. pub(crate) type GroupMemberRows = (u64, Vec<(u64, u64)>); #[derive(Default)] @@ -119,7 +87,6 @@ impl PartyMembershipIndex { } } - /// The group of `character_guid`'s oldest membership row, if it has one. pub(crate) fn group_of(&self, character_guid: u64) -> Option { self.by_character .get(&character_guid)? @@ -128,7 +95,6 @@ impl PartyMembershipIndex { .copied() } - /// Every member row id of `group_id`, in join order. pub(crate) fn member_row_ids(&self, group_id: u64) -> Vec { self.by_group .get(&group_id) @@ -136,9 +102,6 @@ impl PartyMembershipIndex { .unwrap_or_default() } - /// `character_guid`'s group and its members in join order as `(member_row_id, guid)`. The row - /// id lets a caller read the member row itself. More than `member_limit` members, or more than - /// one membership, is a damaged cache and fails. pub(crate) fn bounded_member_rows( &self, character_guid: u64, @@ -203,7 +166,6 @@ mod party_membership_index_tests { ); } - /// The roster reads find a member's group and the group's rows without scanning the cache. #[test] fn a_members_group_and_the_groups_rows_are_indexed_in_join_order() { let mut index = PartyMembershipIndex::default(); @@ -227,8 +189,6 @@ enum PumpCommand { Stop, } -/// Run setup and SDK advancement on the same thread. SDK advancement is the external edge; -/// its cache update and row callbacks complete in one poll after the incoming message arrives. async fn pump_messages>>( conn: &C, mut commands: tokio::sync::mpsc::UnboundedReceiver>, @@ -253,9 +213,7 @@ async fn pump_messages, role: &str, @@ -285,20 +243,12 @@ fn start_pump( } impl LiveConn { - /// A connection needs its transport, subscription, and pump. A Module republish can - /// invalidate the subscription while the socket remains connected. pub(super) fn is_healthy(&self) -> bool { self.conn.is_active() && self._sub.is_active() && self._pump.as_ref().is_some_and(|pump| !pump.is_finished()) } - /// Ask the server for a fresh, filtered subscription before sampling Character presence. - /// - /// The ordinary subscription can lag a commit made through another Gateway. `on_applied` - /// runs after the server's snapshot has updated this connection's cache, so the returned sample - /// is current through a server-acknowledged boundary. The main subscription continues to own - /// every Character row after this temporary query is removed. pub(super) fn durable_character_presence_snapshot( &self, guid: u64, @@ -314,6 +264,7 @@ impl LiveConn { let (tx, rx) = std::sync::mpsc::channel::>(); let tx_error = tx.clone(); let query = format!("SELECT * FROM game_character WHERE guid = {guid}"); + // on_applied gives a fresh snapshot; the cache can lag another Gateway's commit. let subscription = self .conn .subscription_builder() @@ -356,8 +307,6 @@ impl LiveConn { } } -/// Retire callbacks before publishing a fresh resource, then run setup and release the old resource. -/// This external connection seam exposes publication order without constructing SDK types. fn replace_slot_ordered( slot: &RwLock, fresh: T, @@ -378,7 +327,7 @@ fn replace_slot_ordered( teardown(old); } -/// An unwinding hook must stop the fresh pump before partially installed Relays can run. +// An unwinding hook must stop the fresh pump before partially installed Relays can run. fn finish_relay_setup( commands: &tokio::sync::mpsc::UnboundedSender>, setup: impl FnOnce(), @@ -405,9 +354,7 @@ fn finish_relay_setup( completion.complete = true; } -/// Drain the old pump before exposing the fresh cache. A fresh source cache can advance a -/// Transfer to its destination, so no old source callback may update the shared indexes afterward. -/// The slot lock is released before disconnect or join; callbacks can finish their cache reads. +// Drain the old pump before publishing the fresh cache; release the slot lock before joining. fn replace_live_conn( slot: &RwLock, fresh: LiveConn, @@ -450,17 +397,12 @@ fn replace_live_conn( ); } -/// One replaceable reducer-only connection. Reconnection happens off the watchdog thread; this -/// flag prevents a slow or unavailable node from spawning another repair attempt every poll. struct CallPipe { live: RwLock, reconnecting: AtomicBool, } -/// One-shot completion registry for calls sent through one SDK connection. -/// -/// A reducer callback and the SDK disconnect callback may race. Removing the sender while -/// holding this lock makes whichever signal arrives first the caller-visible outcome. +// Remove completion senders under the lock so result and Transport Loss choose one outcome. pub(crate) struct ReducerCompletion { next_id: AtomicU64, state: Mutex, @@ -479,7 +421,6 @@ pub(crate) enum ReducerCompletionFailure { TransportDisconnected, } -/// Typed provenance for a reducer call after it leaves the completion channel. #[derive(Debug)] pub(crate) enum ReducerCallError { Rejected { @@ -523,13 +464,11 @@ impl ReducerCallError { Self::Fatal(message) } - /// A Module Gate refusing `operation` with `reason`, as a Fake reports it. #[cfg(test)] pub(crate) fn refused(operation: &str, reason: &str) -> Self { Self::rejected(operation, reason.to_string()) } - /// The transport dropping `operation` mid-call, as a Fake reports it. #[cfg(test)] pub(crate) fn transport_lost(operation: &str) -> Self { Self::fatal(format!( @@ -563,18 +502,12 @@ impl std::error::Error for ReducerCallError { } } -/// How a failed Durable Read or Durable Request ended, as the World Session must treat it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DurableFailure<'e> { - /// A Module Gate refused. `reason` is the Module's stable tag. Durable state is unchanged. Refusal { reason: &'e str }, - /// The outcome is unknown: transport lost, timeout, SDK send or internal failure. - /// Ends the World Session. TransportLoss, } -/// Fails closed: only a reducer the Module rejected is a Refusal. Any other error, typed or not, -/// leaves the durable outcome unknown. pub(crate) fn classify(error: &anyhow::Error) -> DurableFailure<'_> { match reducer_refusal_reason(error) { Some(reason) => DurableFailure::Refusal { reason }, @@ -582,7 +515,6 @@ pub(crate) fn classify(error: &anyhow::Error) -> DurableFailure<'_> { } } -/// The Module's unwrapped refusal reason, preserving its stable gameplay prefix. pub(crate) fn reducer_refusal_reason(error: &anyhow::Error) -> Option<&str> { error.chain().find_map(|cause| { let ReducerCallError::Rejected { reason, .. } = cause.downcast_ref::()? @@ -654,49 +586,20 @@ impl ReducerCompletion { } pub(crate) struct CoordinatorInner { - /// The live coordinator connection, swappable so the watchdog can reconnect in place. Reads take a - /// (near-always-uncontended) read lock for the duration of one query/call; the watchdog takes the - /// write lock only for the instant swap — so the steady-state (no-drop) read path is unchanged. live: RwLock, - /// Connect params kept so the watchdog can rebuild the connection. token: Option, - /// Connection params for opening per-account player connections lazily. uri: String, db_name: String, - /// Whether this connection subscribes the multi-database tables (realm-core's `game_character_shard`) - /// — kept so the watchdog rebuilds with the SAME subscription set it was created with. See - /// [`coordinator_queries`]. sharded_tables: bool, - /// The CALL-PIPE POOL — extra connections to the same database used exclusively for - /// reducer calls, round-robined by `call_pipe()`, so seated players' call traffic and login - /// calls stop serializing on one websocket (measured wall: ~1000 seats/pipe). Each pipe - /// carries one tiny liveness subscription (`game_config`, 1 row), never the coordinator set. - /// Empty when `LYRACORE_CALL_PIPES` <= 1 — calls then ride `coord()` exactly as before. - /// Dead pipes are skipped immediately and rebuilt in the background. Until their replacement - /// lands, calls use another healthy pipe or the watchdogged coordinator connection. call_pipes: Vec, call_pipe_next: std::sync::atomic::AtomicUsize, - /// Edge-triggered diagnosis for a persistent Module/Gateway dispatch-lane mismatch. pub(crate) party_command_lane_overflow: AtomicBool, - /// Edge-triggered diagnosis for a populated Transfer Intent set beyond the current writer Gate. pub(crate) bot_transfer_pending_overflow: AtomicBool, - /// The per-shard movement batch — the hot path pushes one `GwMove` per inbound - /// heartbeat and the 40ms flush task sends the whole tick as ONE `gw_movement_batch` - /// transaction (was: one transaction per heartbeat — ~10k tx/s of per-transaction machinery - /// at 2000 players, the measured 92%-writer wall). pub(crate) motion_batch: MovementBatch, - /// Re-arm hooks for connection-scoped coordinator relays with no per-login recovery point. - /// The list is empty until those relays register at startup. After each successful coordinator - /// replacement, the watchdog clones the list and invokes every hook against the fresh - /// generation without holding this mutex or the live-slot lock. pub(crate) on_reconnect: Mutex>>, } impl CoordinatorInner { - /// The live coordinator connection (read-locked for the call). A reconnect swaps the inner connection - /// under the write lock, so callers transparently pick up the fresh connection on their NEXT access. - /// Recovers a poisoned lock (matches the file's `.lock().unwrap()` discipline) — a stale read beats - /// poisoning every future request. pub(crate) fn coord(&self) -> std::sync::RwLockReadGuard<'_, LiveConn> { self.live.read().unwrap_or_else(|p| { log::error!( @@ -706,7 +609,6 @@ impl CoordinatorInner { }) } - /// Complete Relay setup on the current connection's pump, without retaining its slot lock. pub(crate) fn on_pump(&self, work: impl FnOnce(&DbConnection) + Send + 'static) -> Result<()> { let pump = self.coord().pump_commands.clone(); let (tx, rx) = std::sync::mpsc::channel(); @@ -720,9 +622,6 @@ impl CoordinatorInner { .map_err(|error| anyhow!("{} {error}", self.db_name)) } - /// The connection for the NEXT reducer call: round-robin over the call-pipe pool, - /// skipping dead pipes; no pool (or every pipe dead) falls back to the watchdogged - /// coordinator connection, which is exactly the behavior before the pool existed. pub(crate) fn call_pipe(&self) -> std::sync::RwLockReadGuard<'_, LiveConn> { let n = self.call_pipes.len(); if n > 0 { @@ -748,22 +647,12 @@ impl CoordinatorInner { self.coord() } - /// The subscribed connection for a reducer whose successful return authorizes presentation of - /// its committed rows. SpacetimeDB's SDK applies a reducer result to this connection's cache and - /// runs its row callbacks before the reducer callback. Waiting for that callback is therefore a - /// deterministic visibility receipt: relays for the transaction are queued before the caller - /// can enqueue success presentation. Reducer-only call pipes cannot provide that ordering - /// because they do not subscribe the relayed tables. Every Realm-core party op that changes a - /// roster rides this pipe, so the Group mirror push after it reads the committed roster. It - /// blocks on the pump, so a pump callback must never call it. + /// Row Relays queue before success presentation; waiting here on the pump would deadlock. pub(crate) fn visibility_pipe(&self) -> std::sync::RwLockReadGuard<'_, LiveConn> { self.coord() } } -/// Return the round-robin pipe that is currently usable, or let the caller use its coordinator -/// fallback when there is none. Kept independent of the SDK connection so recovery routing has a -/// deterministic unit-test seam. fn select_active_call_pipe( pipe_count: usize, start: usize, @@ -788,8 +677,6 @@ fn call_pipe_role_label(db_name: &str, index: usize) -> String { format!("shard {db_name} call pipe {index}") } -/// Build one SDK connection generation, start its pump, and wait for the caller's exact -/// subscription policy to apply. Reconnect Coordinators park at apply until their Relays are armed. fn connect_subscribed( uri: String, db_name: String, @@ -877,7 +764,7 @@ fn connect_subscribed( }; if let Err(error) = applied { let _ = conn.disconnect(); - // A late apply can race this timeout and park the unpublished pump. Stop works in either state. + // A late apply can park the unpublished pump after timeout; Stop must handle either state. let _ = pump_commands.send(PumpCommand::Stop); if let Err(panic) = pump.join() { log::warn!("{role_label} failed subscription pump panicked: {panic:?}"); @@ -904,8 +791,6 @@ fn connect_subscribed( }) } -/// Keep a [`super::reads::ChannelIndex`] current from the channel and member callbacks. Initial -/// subscription rows arrive as inserts. fn watch_chat_channels(conn: &DbConnection) -> Arc> { let index = Arc::new(RwLock::new(super::reads::ChannelIndex::default())); let inserted = index.clone(); @@ -947,7 +832,6 @@ fn watch_chat_channels(conn: &DbConnection) -> Arc) { for (index, pipe) in inner.call_pipes.iter().enumerate() { let is_healthy = pipe @@ -1014,26 +894,10 @@ fn repair_dead_call_pipes(inner: &Arc) { } } -/// A repair is started exactly once for a failed pipe. This is separate from selection because a -/// healthy coordinator remains available for reducer calls while the repair is in flight. fn call_pipe_needs_repair(is_healthy: bool, already_reconnecting: bool) -> bool { !is_healthy && !already_reconnecting } -/// The coordinator subscription set, as a pure function of ONE flag — so the property that -/// matters can be asserted without a node (see the tests at the bottom of this file). -/// -/// `sharded_tables` adds the tables that only mean something on a MULTI-DATABASE deployment: -/// realm-core's `game_character_shard`. It is OFF -/// for a single-database gateway, and that is load-bearing rather than an optimization: **a -/// subscription to a table the deployed module does not have FAILS TO APPLY**, which fails -/// `connect_blocking`, which — for the default database — fails `Coordinator::connect` and the -/// whole gateway. Subscribing them unconditionally would therefore mean a gateway restarted -/// before the module was republished never comes back, in a configuration (`LYRACORE_SHARD_MAP` and -/// `LYRACORE_REALM_CORE` both unset) that multi-shard routing and realm-core both promise costs -/// nothing. Nothing reads -/// any of them on a single-database gateway anyway — `settle_home_shard` short-circuits on -/// `is_sharded()` before it looks at the index. fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { let mut queries = crate::stdb::relays::queries(); queries.extend([ @@ -1132,15 +996,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { queries } -/// Build + connect the privileged SDK connection on a dedicated OS thread. -/// -/// `DbConnection::build()` creates/uses a tokio runtime via `block_in_place`; calling it from a -/// tokio worker or a `spawn_blocking` thread panics. A plain `std::thread` has no ambient runtime, -/// so the SDK builds and owns its own — the supported pattern for a native client. -/// -/// `sharded_tables` adds the MULTI-DATABASE tables (realm-core's `game_character_shard`) to the -/// subscription set — see [`coordinator_queries`] for why that flag exists and must stay off by -/// default. +/// Build SDK connections on an OS thread outside Tokio to avoid blocking-pool starvation. fn connect_blocking( uri: String, db_name: String, @@ -1161,13 +1017,8 @@ fn connect_blocking( ) } -/// How often the coordinator watchdog polls connection liveness. const COORDINATOR_WATCHDOG_POLL: Duration = Duration::from_secs(3); -/// Rebuild a Coordinator when its transport, subscription, or pump stops. A Module migration -/// can invalidate the subscription while the socket remains connected. -/// Subscribe and park off-lock, drain the old pump, then publish, arm, and resume the fresh connection. -/// Readers retain the old cache during recovery; failed connections retry on the next poll. fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::JoinHandle<()> { std::thread::Builder::new() .name("stdb-coordinator-watchdog".into()) @@ -1200,18 +1051,13 @@ fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::Join previous = current; sampled_at = std::time::Instant::now(); } - // Pipe repair is independent of coordinator recovery: a healthy coordinator is the - // fallback while a failed reducer-only pipe reconnects, and must not prevent that - // pipe returning to the round-robin pool. repair_dead_call_pipes(&inner); { - // One guard covers the whole health decision. Healthy means the transport and - // subscription belong to the same generation and are both active. let live = inner.coord(); if live.is_healthy() { continue; } - } // drop the read guard BEFORE the (blocking) rebuild + } // Drop the read guard before the blocking rebuild. let role_label = coordinator_role_label(&inner.db_name); log::warn!("{role_label} connection/subscription down; rebuilding (gateway self-heal)"); match connect_blocking( @@ -1222,10 +1068,7 @@ fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::Join true, ) { Ok(fresh) => { - // Re-arm any relay with no per-login point to self-heal through — MUST run - // after the install: each hook re-reads `inner.coord()` and must see fresh. - // The old pump has finished before publication. Hooks read the fresh cache - // without holding the slot write lock. + // Publish before re-arming; hooks read fresh cache without the slot lock. replace_live_conn(&inner.live, fresh, &role_label, || { let hooks = inner.on_reconnect.lock().unwrap().clone(); for hook in hooks { @@ -1242,15 +1085,8 @@ fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::Join .expect("spawn coordinator watchdog") } -/// How often the loot-roll relay promotes staging rolls and settles resolved winners. Short -/// relative to the 60s roll window: it bounds promotion and settlement latency only. A vote cast -/// before the next tick flushes the pending promotion itself (`world::loot::run_vote`). const LOOT_ROLL_RELAY_POLL: Duration = Duration::from_millis(200); -/// Background loop driving `world::loot::relay_tick`: promotes each world shard's freshly -/// staged loot rolls onto realm-core, and settles resolved winners back down. A no-op on an unsharded -/// gateway — `relay_tick` returns immediately when `Coordinator::realm_store()` answers `Ok(None)` — so -/// this thread costs a single-database deployment nothing but its own idle wakeups. fn spawn_loot_roll_relay(coordinator: Coordinator) -> std::thread::JoinHandle<()> { std::thread::Builder::new() .name("stdb-loot-roll-relay".into()) @@ -1264,13 +1100,6 @@ fn spawn_loot_roll_relay(coordinator: Coordinator) -> std::thread::JoinHandle<() .expect("spawn loot-roll relay") } -/// Background loop driving `load_sample::sample_and_record`: every `LYRACORE_LOAD_SAMPLE_SECS` -/// (default 30s), scrape the node's `/v1/metrics` for each configured shard's writer occupancy, -/// read this gateway's own session counts, and record all of it onto -/// realm-core — then log one SHARDLOAD line per shard, the same "visible without SQL" convention -/// QUEUESTAT/AOISTAT use (`world/mod.rs`). Costs an unconfigured single-database gateway nothing -/// but its own idle wakeups and one metrics scrape (occupancy still measurable there — realm-core -/// unconfigured just means `record_shard_load` runs against this same database). fn spawn_load_sampler( coordinator: Coordinator, stdb_uri: String, @@ -1278,9 +1107,6 @@ fn spawn_load_sampler( ) -> std::thread::JoinHandle<()> { let sampler = crate::load_sample::OccupancySampler::from_env(&stdb_uri); let interval = crate::load_sample::sample_interval(); - // Hashed once at startup: `game_shard_load.gateway_key` is what keys this - // process's samples apart from every OTHER gateway process's, so N gateways sampling the same - // shard sum to a realm-wide total instead of the last writer clobbering the rest. let this_gateway_key = crate::load_sample::gateway_key(&gateway_id); std::thread::Builder::new() .name("stdb-load-sampler".into()) @@ -1296,35 +1122,13 @@ fn spawn_load_sampler( .expect("spawn load sampler") } -/// **Every world shard holds a guid range before it can mint a character.** -/// -/// Runs once per gateway start, right where `check_instance_hosting` enforces the other -/// cross-database invariant: this is the only place that knows both realm-core and the shard set, -/// and it is the courier between them — a module cannot read another database, so the claim can -/// only be driven from here. -/// -/// Per world shard: read the high-water it is already minting from, ask realm-core to assign it a -/// range (idempotent — a shard keeps the one it holds), then install that range on the shard. The -/// mark is what makes the four live databases land on the floors they were given by hand, in any -/// order (`realm_core::assign_slot`). -/// -/// **Never fatal.** The guarantee is enforced module-side, in `create_character`, which refuses -/// outright without a range; a shard that fails to claim here simply cannot create characters, and -/// says so at the point of creation. Aborting the gateway would take down a whole realm's *play* -/// over a *creation* problem. Realm-core is skipped: it holds accounts, not characters, and never -/// mints a character guid. fn ensure_guid_ranges(conns: &HashMap>, map: &ShardMap) { - // `call_reducer!` expands to a `?`, so each call needs a Result-returning home. fn claim(rc: &Arc, shard: &str, mark: u64) -> Result> { call_reducer!( rc.coord().conn.reducers, "claim_guid_range", claim_guid_range_then(shard.to_string(), mark) )?; - // The claim committed on THIS connection, so its row normally rides in with the reducer - // callback — but the cache update is a separate delivery, and losing this read would leave - // the shard unlicensed (creation refused) until someone restarted the gateway. Same bounded - // poll `player_login` uses for the same reason, one connection over. for _ in 0..100 { if let Some(row) = rc .coord() @@ -1356,7 +1160,7 @@ fn ensure_guid_ranges(conns: &HashMap>, map: &Shar } let Some(inner) = conns.get(&shard) else { continue; - }; // unreachable shard: already logged + }; let mark = inner .coord() .conn @@ -1383,11 +1187,6 @@ fn ensure_guid_ranges(conns: &HashMap>, map: &Shar continue; } }, - // No realm-core: there is no registry to arbitrate, so there is also only one authority - // deciding anything — this gateway. Slot 0 keeps a single-database realm (and every dev - // run) minting exactly as it did before ranges existed. A SECOND world shard in this - // state is the colliding-guid hazard itself (two shards minting the same character - // guids), so it is called out rather than quietly assigned. None => { if map.shards().len() > 1 { log::error!( @@ -1412,22 +1211,13 @@ fn ensure_guid_ranges(conns: &HashMap>, map: &Shar } } -/// Claim this gateway's mail-escrow id range once and resume after the largest durable fence or -/// receipt in it. We deliberately share realm-core's existing range registry: it already provides -/// idempotent ownership plus a unique slot constraint. Escrow ids remain a separate keyspace -/// because they are used only as keys in the mail ledgers, never as game GUIDs. -/// -/// Failure is fail-closed. Installing the old process-time allocator would reintroduce the very -/// collision this claim removes, so sharded sends refuse until a range can be claimed. fn ensure_mail_escrow_range( conns: &HashMap>, map: &ShardMap, gateway_id: &str, ) { let Some(rc_name) = map.realm_core_db() else { - // The Gateway must not mint escrow ids on the single-database plane. There a Reward - // Letter's id comes from the database's own GUID Range, and any local Gateway range would - // overlap it. No range is installed, so a mint here refuses. + // Single-shard Escrow ids belong to the Module's GUID Range; a Gateway range would overlap. return; }; let Some(rc) = conns.get(rc_name) else { return }; @@ -1506,7 +1296,6 @@ mod mail_escrow_claimant_tests { } } -/// Block on a reducer-completion channel, mapping the outcome to `anyhow`. pub(crate) fn recv_reducer( rx: std::sync::mpsc::Receiver>, what: &str, @@ -1567,26 +1356,13 @@ mod coordinator_query_tests { } } - /// Every table that only exists on a MULTI-DATABASE deployment. A subscription to one of these - /// against a module that predates it FAILS TO APPLY and takes the whole gateway down on - /// restart (`coordinator_queries`' doc comment) — so an unconfigured gateway must ask for none. const MULTI_DB_TABLES: &[&str] = &[ "SELECT * FROM game_character_shard", - // The party STATE table. `game_group_event` and `game_group_member` - // moved OUT of this list to the base set when the shared dispatch landed — both predate sharding, so the - // restart hazard this list exists for cannot bite them, and the shared dispatch needs - // them on every coordinator (a cache-only subscription with the flag off: the realm - // relay registers only on multi-database gateways, so there is no double delivery). "SELECT * FROM game_group", "SELECT * FROM game_group_roster_revision", "SELECT * FROM game_group_member_partition", - // The loot-roll pair: both PRIVATE, no per-player subscriber to duplicate — the restart hazard alone is why - // they belong on this list (a module published before they exist refuses the subscription). "SELECT * FROM game_loot_roll", "SELECT * FROM game_loot_roll_vote", - // Realm-core's guid-range registry. The per-shard `game_guid_range`/ - // `game_guid_allocator` pair is deliberately NOT here — a database that holds no range - // cannot create characters at all, so every deployment needs those two, sharded or not. "SELECT * FROM game_guid_range_registry", ]; @@ -1621,8 +1397,6 @@ mod coordinator_query_tests { #[test] fn the_multi_database_flag_adds_exactly_those_tables_and_changes_nothing_else() { - // The no-op guarantee, stated as set arithmetic: turning the flag on is PURELY additive, - // and turning it off leaves the pre-sharding subscription list untouched, in order. let single = coordinator_queries(false); let sharded = coordinator_queries(true); assert_eq!( @@ -1641,8 +1415,6 @@ mod coordinator_query_tests { "{table} must be subscribed when sharded" ); } - // No duplicates in either shape (a duplicate query is a wasted subscription, and a sign - // someone re-added a table already in the base list). let mut sorted = sharded.clone(); sorted.sort_unstable(); let before = sorted.len(); @@ -1867,7 +1639,6 @@ mod pump_tests { .unwrap(); let result = pump_messages(&cache, work, || async { - // SDK 2.7.1 applies queued registrations before any waiting remote delta. if cache.pending_registration.swap(false, Ordering::Relaxed) { cache.registered.store(true, Ordering::Relaxed); return Ok(()); @@ -2098,9 +1869,6 @@ mod recv_reducer_tests { #[test] fn a_failed_reducer_is_prefixed_with_the_call_label() { - // The `what` label prefixes the module's raw Err string so a log line attributes the - // failure to the reducer that ran (" reducer failed: "). The timeout - // arm is not driven here (it would block the suite for its full 10s). let (tx, rx) = std::sync::mpsc::channel(); tx.send(Err(ReducerCompletionFailure::Rejected( "not enough copper".to_string(), @@ -2229,12 +1997,6 @@ mod call_pipe_routing_tests { } } -/// Fire a reducer over `$reducers` and block (≤10s) on its completion, mapping the outcome to -/// `anyhow` (evaluates to `Result<()>`). Collapses the channel + status-flatten callback + -/// `recv_reducer` that every reducer wrapper otherwise repeats verbatim; the trailing completion -/// callback is appended after the reducer's own positional args. `$what` (a literal) labels the -/// send-error and timeout messages. The double `Result` is classified here so module refusals and -/// SDK failures retain distinct typed provenance. macro_rules! call_reducer { ($live:ident . conn . reducers, $what:literal, $method:ident ( $($arg:expr),* $(,)? )) => {{ call_reducer!(@call $live, $what, $method($($arg),*)) @@ -2304,9 +2066,6 @@ impl Coordinator { } impl Coordinator { - /// Connect ONE database and wrap it in a watchdogged `CoordinatorInner`. `sharded_tables` — see - /// [`connect_blocking`]; a single-database gateway passes `false` and subscribes exactly the - /// tables it subscribed before realm-core existed. async fn connect_shard( cfg: &GatewayConfig, db_name: &str, @@ -2317,7 +2076,6 @@ impl Coordinator { let token = cfg.coordinator_token.clone(); let role_label = coordinator_role_label(db_name); let join_role_label = role_label.clone(); - // Build on a dedicated OS thread (no tokio context), join it off the reactor. let build_thread = std::thread::Builder::new() .name("stdb-coordinator-connect".into()) .spawn(move || connect_blocking(uri, db, token, sharded_tables, false)) @@ -2329,9 +2087,6 @@ impl Coordinator { }) .await .context("join coordinator connect task")??; - // The call-pipe pool. Total pipes = LYRACORE_CALL_PIPES (default 4); <=1 keeps - // the single-connection call path. A pipe that fails to build is logged and - // skipped — the pool degrades toward `coord()`, never fails the gateway. let want_pipes = crate::config::call_pipes(); let mut call_pipes = Vec::new(); if want_pipes > 1 { @@ -2381,10 +2136,6 @@ impl Coordinator { on_reconnect: Mutex::new(Vec::new()), }); spawn_coordinator_watchdog(inner.clone()); - // The movement batch flusher — one gw_movement_batch transaction per 40ms tick - // (25/s, the same cadence philosophy as publish_motion's write batching). Fire-and-forget - // per flush; a failed send logs and drops that tick's moves (the next heartbeat - // supersedes them anyway — movement is idempotent positional state). { let inner = inner.clone(); tokio::spawn(async move { @@ -2393,10 +2144,6 @@ impl Coordinator { let mut ticks_until_metrics = 250u16; loop { tick.tick().await; - // Batch shape: cap each call at 128 moves and spread the chunks across - // the pipe pool — a single ~400-move transaction head-of-line-blocked login - // calls for its whole execution (the measured 1970→1071 seat regression); - // several ~5ms transactions interleave with them instead. let failures = inner.motion_batch.drain(|chunk| { let entries = chunk.len(); let pipe = inner.call_pipe(); @@ -2428,14 +2175,6 @@ impl Coordinator { Ok(inner) } - /// Connect the coordinator set: one privileged connection per database in the shard map, - /// and return the handle for the DEFAULT (realm/home) database — the one the logon + world - /// listeners are handed, exactly as before. With no `LYRACORE_SHARD_MAP` this is one connection and - /// nothing else changes. - /// - /// An EXTRA shard that fails to connect is logged and left out of the set rather than failing - /// the gateway: routing to a missing shard degrades to the default database, i.e. to today's - /// behavior. The default database's own failure is still fatal (it always was). pub async fn connect(cfg: &GatewayConfig) -> Result { if cfg.coordinator_token.is_none() { log::warn!( @@ -2444,10 +2183,6 @@ impl Coordinator { ); } let map = ShardMap::from_env(&cfg.module_name); - // The character→shard index only exists to answer "which of several databases owns - // this". One database ⇒ nothing to answer, nothing subscribes it, and the subscription set - // is exactly the pre-sharding one — which is what makes "the env vars unset ⇒ today's - // gateway" true of the wire, not just of the routing logic. let sharded_tables = map.databases().len() > 1; let mut conns: HashMap> = HashMap::new(); for db in map.databases() { @@ -2456,11 +2191,7 @@ impl Coordinator { log::info!("coordinator connected to shard {db}"); conns.insert(db, inner); } - // The default database is listed FIRST, so its failure aborts before any other. Err(e) if db == map.default_db() => return Err(e), - // Realm-core down is NOT a routing degradation — it is an auth outage. Nobody can - // log in until it is back (`realm_core()` fails closed), but the world sessions - // already in progress keep playing, so this is a warning, not a fatal. Err(e) if Some(db.as_str()) == map.realm_core_db() => log::error!( "realm-core database {db} unreachable ({e:#}) — LOGONS WILL BE REFUSED until it \ is reachable (the gateway will NOT fall back to the world DB's auth cache); \ @@ -2490,13 +2221,6 @@ impl Coordinator { map.databases() ); } - // The ROUTING is ours and `game_config.hosts_instances` is each database's, and - // this is the only place both are known. Reads the flag out of each coordinator's just- - // applied subscription cache (`connect_blocking` blocks until it is applied, so the row is - // there); a database with no row reads `None` ⇒ "hosts", the module's own default. Silent - // when correctly configured — including the ordinary single-database realm — a WARNING when - // the world shard spawns populations it will not run, and FATAL when nothing anywhere - // will spawn them (every dungeon then gets created with 0 entities). let hosts_instances = |db: &str| -> Option { conns .get(db) @@ -2538,22 +2262,9 @@ impl Coordinator { }), None, ); - // Arm the shared spatial, broadcast, private, and owner dispatch — one callback set per - // shard instead of one set per player. Must run before any session can log in, and be re-armed - // after a coordinator reconnect (the watchdog treats a module republish as one), which is - // what the `on_reconnect` hook below is for: the callbacks are bound to a `LiveConn` that - // the swap replaces. coordinator.arm_shared_world_view()?; - // The loot-roll relay: promotes each world shard's staging loot rolls onto realm-core and settles - // resolved winners back down. A no-op loop on an unsharded gateway (`relay_tick` returns - // immediately when `realm_store()` is `Ok(None)`), so this costs a single-database deployment - // nothing but one idle thread. spawn_loot_roll_relay(coordinator.clone()); - // Member Stats for every group mate outside a viewer's AOI, on its own 5 s thread. super::member_stats_relay::spawn_member_stats_relay(coordinator.clone()); - // The load sampler: per-shard writer occupancy + session counts, sampled on a timer and recorded - // onto realm-core so an operator can answer "which shard is hot" with `spacetime sql` - // alone. spawn_load_sampler( coordinator.clone(), cfg.stdb_uri.clone(), @@ -2562,18 +2273,11 @@ impl Coordinator { Ok(coordinator) } - /// The gateway-wide shared area-of-interest view — the cell index + viewer registry the - /// coordinator dispatch routes through. Shard-independent: every handle answers the same one. pub(crate) fn world_view(&self) -> Arc { self.1.world.clone() } - /// Register the shared relays on every shard coordinator and install their reconnect hooks. - /// - /// The shard ORDER fixes the `ShardId`s the index stores, so this runs once, before any session - /// exists, and the vector is never reordered afterwards. The re-arm re-registers on the fresh - /// `LiveConn`. Reconciliation replaces only that Shard's rows and keeps viewer registrations. - /// It removes missing rows because initial subscription apply does not replay their deletes. + /// Register before any World Session; shard-vector order fixes stable ShardIds. fn arm_shared_world_view(&self) -> Result<()> { let view = self.world_view(); let shards = self.all_shards(); @@ -2590,9 +2294,6 @@ impl Coordinator { } })); } - // The cross-shard group and chat twins ride realm-core's connection, armed only - // when realm-core is a DISTINCT database (a world shard's own `arm_shard` above already - // watches these tables, and a second registration would deliver every packet twice). if let Ok(realm) = self.realm_core() { if !shards.iter().any(|s| s.shard_name() == realm.shard_name()) { let world = self.clone(); @@ -2610,42 +2311,22 @@ impl Coordinator { Ok(()) } - /// The database name this handle targets — the routing identity of every call made through it. pub fn shard_name(&self) -> &str { &self.0.db_name } - /// Is the gateway actually running against more than one database? `false` is the unconfigured - /// default and lets the routing paths short-circuit to exactly the pre-sharding behavior — no - /// location read, no handle clone, no pin. pub(crate) fn is_sharded(&self) -> bool { self.1.conns.len() > 1 } - /// The handle for the shard that owns `(map_id, instance_id)`, or `None` when that is already - /// THIS handle's shard (the single-shard default: nothing to swap). - /// - /// An owner that is named by the map but failed to connect degrades to the DEFAULT database — - /// never to whatever handle happened to ask. Degrading to the asker looks the same from the - /// default handle but is wrong from any other: a session already pinned to shard B that ports - /// to a map owned by an unreachable shard C would KEEP its B pin and be served by a database - /// that owns neither its old location nor its new one. Collapsing to the default is the - /// documented safety property — a bad shard map can only ever fall back to today's behavior. pub fn shard_for(&self, map_id: u32, instance_id: u64) -> Option { let db = self .1 .map .resolve_connected(map_id, instance_id, |d| self.1.conns.contains_key(d)); - // Always present: `resolve_connected` only ever answers a connected database or the - // default, and `connect` proves the default is in the set. self.shard_handle(db) } - /// [`shard_for`](Self::shard_for) for an INSTANCE, given the database that currently holds the - /// character. Same contract — `None` means "already this handle's shard" — with the one - /// extra rule that keeps a live dungeon run on its pool member when the operator re-points that - /// bucket to a newly added instances database. The decision is the pure, unit-tested - /// [`crate::config::ShardMap::instance_owner`]; this only supplies it with the connected set. pub(crate) fn instance_shard_for( &self, map_id: u32, @@ -2658,17 +2339,6 @@ impl Coordinator { self.shard_handle(db) } - /// The handle for `db`, or `None` when `db` is already THIS handle's shard ("nothing to swap", - /// the answer every routing path gives when the session is where it belongs). One place, so a - /// second routing overlay cannot grow a second opinion about what "stay put" means. - /// - /// `None` therefore means one of TWO different things, and a caller that treats it as a single - /// failure signal cannot tell them apart (a live bug read exactly this way: a database - /// that was trivially connected produced a "not connected" warning because the caller asked for - /// its OWN shard — the fix is to never call this with `db == self.shard_name()` in the first - /// place, not to guess here after the fact). The warning below only fires for the SECOND case — - /// `db` genuinely absent from the connected set — so it stays silent on every "already home" - /// call, which is the common case for `shard_for`/`instance_shard_for`/`settle_home_shard`. pub(crate) fn shard_handle(&self, db: &str) -> Option { if db == self.shard_name() { return None; @@ -2684,21 +2354,6 @@ impl Coordinator { Some(Coordinator(inner?.clone(), self.1.clone(), self.2.clone())) } - /// Every connected shard's handle, DEFAULT FIRST. Two callers: the character-select list - /// (a character parked inside an instance lives on the instance shard, and the player must - /// still see it) and the party mirror fan-out (`world_stores`). - /// - /// ⚠ This is exactly `world_shards()` with the names dropped — REALM-CORE IS NOT IN - /// IT. It was originally written over `conns`, which was the same set only while the default - /// database was also the auth database; once realm-core is a third connection, probing it for characters - /// asks a database that owns none and (worse) could answer with the shadow account rows the - /// transfer's `import_character_blob` writes there. `ShardMap::shards()` excludes it by - /// construction, so the probe set is the right one for both topologies. - /// - /// Deliberate simplification: O(shards) per login for a caller that needs every handle. The - /// holder LOOKUP no longer pays this unconditionally — `realm_core::locate_home_shard` - /// tries the realm-core `character_shard` hint first and only falls back to scanning - /// `world_shards()` (this method's underlying set) on a miss. pub(crate) fn all_shards(&self) -> Vec { self.world_shards() .into_iter() @@ -2706,8 +2361,6 @@ impl Coordinator { .collect() } - /// Check current subscription health before returning Realm-core authority. - /// The configured connection map survives disconnection and may retain stale rows. pub(crate) fn realm_core(&self) -> Result { let db = self .1 @@ -2729,9 +2382,6 @@ impl Coordinator { Ok(Coordinator(inner.clone(), self.1.clone(), self.2.clone())) } - /// The connected WORLD SHARDS, default first — the probe order for the character→shard fallback - /// when the realm-core index has no hint. Realm-core is excluded by `ShardMap::shards`: it - /// holds no characters. pub(crate) fn world_shards(&self) -> Vec<(String, Coordinator)> { self.1 .map @@ -2744,7 +2394,6 @@ impl Coordinator { .collect() } - /// Every configured World Shard, only while each Coordinator subscription is healthy. pub(crate) fn world_shards_for_absence(&self) -> Result> { self.1 .map @@ -2764,13 +2413,10 @@ impl Coordinator { .collect() } - /// The routing table this handle resolves through (read-only; the pure resolvers in - /// `crate::config` take it by reference). pub(crate) fn shard_map(&self) -> &ShardMap { &self.1.map } - /// Account admission requires every configured World Shard, including an unavailable one. pub(crate) fn configured_world_shards(&self) -> Result> { self.1 .map @@ -2791,16 +2437,6 @@ impl Coordinator { } } -/// Why the SDK connection builds (`connect_blocking`, the call pipes) run on threads the -/// runtime does NOT own. -/// -/// The gateway spends one tokio blocking-pool thread per world session and per logon handshake, for -/// that session's whole life (`world::run`, `logon::run`). `block_in_place` on a runtime worker gives -/// the worker's core away and asks that same pool to run it (tokio `multi_thread/worker.rs`), so once -/// the pool is full the core is never picked up — the runtime shrinks by one core per in-flight -/// connection build, and every SDK message pump on it stalls with it. These tests pin the difference -/// between the two shapes with no node and no network; they are the offline half of the evidence -/// for that diagnosis (the live half was a ~535-seat ceiling with bimodal connect timeouts). #[cfg(test)] mod connect_build_shape { use std::sync::atomic::{AtomicBool, Ordering}; @@ -2809,9 +2445,6 @@ mod connect_build_shape { use std::time::Duration; use tokio::runtime::{Builder, Handle, Runtime}; - /// A runtime shaped like the gateway's: `workers` cores and a blocking pool with EVERY slot held - /// by a task that does not return — the session-per-`spawn_blocking` shape at its ceiling. - /// Flip the returned flag before dropping the runtime, or the drop waits forever. fn saturated(workers: usize, cap: usize) -> (Runtime, Arc) { let rt = Builder::new_multi_thread() .worker_threads(workers) @@ -2839,10 +2472,6 @@ mod connect_build_shape { (rt, stop) } - /// The failure mode itself. Four workers, pool full, four `block_in_place` calls in flight ⇒ the - /// runtime has NO core left, so a plain task cannot run at all. In the gateway those tasks are - /// the SDK's websocket message and parse loops, which is why one slow connect shows up as every - /// other session's reducer timing out, and as the measured 12.6 s movement p95. #[test] fn block_in_place_on_a_worker_costs_a_core_when_the_blocking_pool_is_full() { let (rt, stop) = saturated(4, 4); @@ -2868,8 +2497,6 @@ mod connect_build_shape { ); } - /// The shape `player_conn` uses instead: a thread the runtime does not own, with an `enter()` - /// guard. Same saturated runtime as the test above, and it does not care. #[test] fn connect_build_does_not_starve_on_a_full_blocking_pool() { let (rt, stop) = saturated(4, 4); @@ -2877,9 +2504,6 @@ mod connect_build_shape { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { let _guard = handle.enter(); - // Stands in for `DbConnection::build()`, which ends in - // `block_in_place(|| handle.block_on(WsConnection::connect(..)))` — i.e. it awaits - // something only this runtime's driver can complete. tokio::task::block_in_place(|| { handle.block_on(async { tokio::time::sleep(Duration::from_millis(50)).await; @@ -2897,9 +2521,7 @@ mod connect_build_shape { ); } - /// The other half of the same line: the `enter()` guard is what keeps the SDK on the SHARED runtime. - /// Without it `Handle::try_current()` fails and `enter_or_create_runtime()` builds a private - /// 1-worker runtime per connection — ~1-2 OS threads per account, never released. + // enter() shares the runtime; without it the SDK creates a private runtime per connection. #[test] fn an_enter_guard_is_what_makes_the_sdk_reuse_the_shared_runtime() { let rt = Builder::new_multi_thread() diff --git a/gateway/src/stdb/member_stats_relay.rs b/gateway/src/stdb/member_stats_relay.rs index 077df35ce..8377d457f 100644 --- a/gateway/src/stdb/member_stats_relay.rs +++ b/gateway/src/stdb/member_stats_relay.rs @@ -1,10 +1,3 @@ -//! The Member Stats Relay. Every [`MEMBER_STATS_INTERVAL`] its own thread queues one job per World -//! Session. Each job runs [`member_stats_tick`] on that session's writer, so no shard pump does the -//! work and the packets stay in order with the session's other Relays. -//! -//! The Relay reads cached rows only. Each Gateway process serves its own sessions from its own -//! caches, so several Gateways need no coordination. - use std::sync::Arc; use std::time::Duration; @@ -12,8 +5,6 @@ use super::connection::Coordinator; use super::world_view::{self, Viewer, WorldView}; use crate::world::{member_stats_tick, MemberStatsStore, Outbound}; -/// The player heartbeat that sends cmangos group updates (cm:Player.cpp:1609-1613, 5000 ms at -/// cm:Object.h:861). const MEMBER_STATS_INTERVAL: Duration = Duration::from_secs(5); pub(super) fn spawn_member_stats_relay(coordinator: Coordinator) -> std::thread::JoinHandle<()> { @@ -29,7 +20,6 @@ pub(super) fn spawn_member_stats_relay(coordinator: Coordinator) -> std::thread: .expect("spawn member stats relay") } -/// Queue one Member Stats job on every registered viewer's writer. fn queue_member_stats(view: &WorldView, store: &St) { for viewer in view.all_viewers() { let store = store.clone(); @@ -37,7 +27,6 @@ fn queue_member_stats(view: &WorldView, } } -/// A failed read skips this viewer until the next tick. Its record stays as it was. fn viewer_member_stats( store: &St, viewer: &Arc, @@ -77,7 +66,6 @@ mod tests { const VIEWER: u64 = 10; const MATE: u64 = 11; - /// One party of two, the mate live everywhere. #[derive(Clone)] struct TwoMemberParty; diff --git a/gateway/src/stdb/mod.rs b/gateway/src/stdb/mod.rs index 228f67bfb..c471b8d99 100644 --- a/gateway/src/stdb/mod.rs +++ b/gateway/src/stdb/mod.rs @@ -1,49 +1,25 @@ -//! SpacetimeDB client wiring. The gateway is just a client: it calls reducers and reads -//! state via subscriptions. Each database has one privileged coordinator connection for the -//! shared cache and callbacks, plus a small reducer-only call-pipe pool. Player verbs name their -//! actor explicitly through the operator-gated reducer surface. -//! -//! Wired via `spacetimedb_sdk` (connect, subscribe, reducer calls). -//! -//! Each Store family trait is implemented on `Coordinator` in `store/.rs`; its trait -//! methods call the reducer directly. `classify` decides whether a failed call is a Refusal or a -//! Transport Loss. -//! - `connection`: the `Coordinator` facade + inner state + live connections + watchdog + -//! the `call_reducer!` macro, `classify` + lifecycle constructors. -//! - `account_sessions`: durable Account ownership, the Actor's signed `SessionActor`, and -//! socket closure on renewal failure. -//! - `reads`: cache reads that several families, relays or logon share. -//! - `reducers`: reducer calls no Store trait owns (relays, logon, provisioning, heartbeat). -//! - `realm_db`: `RealmDb` for `Coordinator`, the Realm-core routing seam. -//! - `store`: one Store family adapter per file. -//! - `subscriptions`: `PlayerSubscriptions`, viewer setup, and durable background work. -//! - `views`: row→view converters + the thin `RealmRow`/`AccountRow` mirrors. - include!(concat!(env!("OUT_DIR"), "/bindings.rs")); mod account_sessions; -pub(crate) mod aoi; // `world/mod.rs`'s 10s task reads `aoi::AOI_RECENTERS` for the AOISTAT line -pub(crate) mod armor; // Character sheet projection from Coordinator rows +pub(crate) mod aoi; +pub(crate) mod armor; mod auction_holds; mod connection; -mod member_stats_relay; // Member Stats for group mates outside the viewer's AOI +mod member_stats_relay; pub(crate) mod movement_batch; pub(crate) mod reads; -mod realm_db; // impl RealmDb for Coordinator +mod realm_db; mod reducers; -mod store; // one Store family adapter per file +mod store; pub(crate) mod subscriptions; pub(crate) mod views; pub(crate) mod world_index; -pub(crate) mod world_view; // shared per-shard spatial, broadcast, private, and owner dispatch +pub(crate) mod world_view; pub use connection::Coordinator; pub(crate) use connection::ReducerCallError; pub(crate) use connection::{classify, DurableFailure}; pub use subscriptions::PlayerSubscriptions; -// Re-exported so `crate::stdb::{RealmRow, AccountRow}` resolves (they are the return types of -// `Coordinator::realm` / `account_by_username`). `allow(unused_imports)` because in this *binary* -// crate the re-export has no external consumer to mark it used, yet the path must stay resolvable. #[allow(unused_imports)] pub use views::{AccountRow, RealmRow}; diff --git a/gateway/src/stdb/movement_batch.rs b/gateway/src/stdb/movement_batch.rs index e5e02a0ad..67623e390 100644 --- a/gateway/src/stdb/movement_batch.rs +++ b/gateway/src/stdb/movement_batch.rs @@ -5,8 +5,6 @@ use super::bindings::GwMove; const MAX_MOVES_PER_CALL: usize = 128; -/// One shard's queued steady heartbeats. The scheduler decides when to drain it; -/// this type owns the deterministic queue and bounded submission behavior. pub(crate) struct MovementBatch { queued: Mutex>, pub(crate) delivery: MotionDelivery, @@ -33,8 +31,7 @@ impl MovementBatch { self.delivery.queued.fetch_add(1, Ordering::Relaxed); } - /// Removes the current window before submitting it, so movements queued by a - /// submission callback belong to the next drain. + // Drain before submitting so movement queued by a submission callback enters the next window. pub(crate) fn drain( &self, mut submit: impl FnMut(Vec) -> Result<(), E>, @@ -58,8 +55,6 @@ impl MovementBatch { } } -/// One Shard's accepted movement and incoming motion rows, before AOI selection or writer work. -/// These counters survive Coordinator replacement. Queue acceptance does not prove Module commit. #[derive(Default)] pub(crate) struct MotionDelivery { queued: AtomicU64, diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index 1aaf50db1..89fc89497 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -1,6 +1,3 @@ -//! Account / character / session cache-accessor methods (pure code-motion split of the -//! former `reads.rs`). See `stdb::reads` for the domain split's overview. - use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; @@ -13,7 +10,6 @@ use super::super::views::{AccountRow, RealmRow}; use crate::realm_core::SessionKey; impl Coordinator { - /// Read the single realm row for the realm-list reply. pub fn realm(&self) -> Result { self.0 .coord() @@ -34,7 +30,6 @@ impl Coordinator { .ok_or_else(|| anyhow!("no game_realm row in the coordinator cache")) } - /// Read an account's SRP6 salt/verifier for the logon challenge. pub fn account_by_username(&self, username: &str) -> Result> { Ok(self .0 @@ -54,7 +49,6 @@ impl Coordinator { })) } - /// Count characters on the realm for an account (realm-list character count). pub fn character_count(&self, account_id: u64) -> Result { let n = self .0 @@ -68,17 +62,6 @@ impl Coordinator { Ok(n.min(u8::MAX as usize) as u8) } - /// Where a character IS **on this shard**, as `(map_id, instance_id)` — the input to shard - /// routing. Prefers the LIVE entity and falls back to the durable `game_character` row, - /// which is what a fresh login (and a mid-teleport character, whose entity was despawned) - /// reads. `None` = this shard has no row for that guid, which is also how - /// `realm_core::locate_home_shard` finds the shard that does. - /// - /// The durable fallback reads `pending_instance_id`, NOT a hardcoded 0: that column is - /// where `teleport_player` parks the DESTINATION instance for a cross-map hop, so it is the - /// whole routing key for instance entry — reading 0 there would route a player walking into - /// Deadmines by map alone, which is correct only as long as no shard-map rule ever names a - /// bucket (`389:0=pool-a`, see `config::ShardMap`). pub fn character_location(&self, guid: u64) -> Option<(u32, u64)> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -91,15 +74,10 @@ impl Coordinator { .map(|c| (c.map_id, c.pending_instance_id)) } - /// This shard's raw durable `game_character` row for `guid` — the transfer driver needs - /// the DESTINATION fields (`pending_instance_id`, position) that `CharacterView` does not carry. pub(crate) fn character_row(&self, guid: u64) -> Option { self.0.coord().conn.db.game_character().guid().find(&guid) } - /// This shard's escrow row for `guid`, raw. `Coordinator`-level rather than - /// `WorldStore`-level because it's needed (via `has_escrow`) before any handle has been chosen - /// as the session's home — see `realm_core::locate_home_shard`. pub(crate) fn escrow_row(&self, guid: u64) -> Option { use spacetimedb_sdk::Table as _; self.0 @@ -141,15 +119,10 @@ impl Coordinator { })) } - /// The account's bound 32-byte SpacetimeDB identity — DERIVED, not minted by a connection - /// (the per-player-connection build this replaced was the ~850/process wall; see - /// `synthetic_owner_identity`'s contract). `establish_session` writes it into - /// `game_account.identity`, which `gw_player_login` fail-closes on. pub fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { Ok(crate::config::synthetic_owner_identity(account_id)) } - /// Read the shared session key K, with its expiry, for the world handshake. pub fn session_key(&self, account_id: u64) -> Result> { Ok(self .0 @@ -168,13 +141,8 @@ impl Coordinator { } } -/// The Gateway-side index over `game_character_contact`, keyed by owner and kept current by the -/// cache's insert, update and delete callbacks. The SDK bindings expose no finder for the table's -/// `by_owner` index, and a whole-table scan per whisper or guild invite copies every contact row on -/// the Shard under the lock the pump needs. #[derive(Default)] pub(crate) struct ContactIndex { - /// Owner guid to its contact rows by row id: `(target guid, is_ignore)`. by_owner: HashMap>, } @@ -204,21 +172,15 @@ impl ContactIndex { .map(|(target_guid, _)| *target_guid) } - /// The guids `owner_guid` ignores. pub(crate) fn ignored_by(&self, owner_guid: u64) -> Vec { self.targets(owner_guid, true).collect() } - /// The guids `owner_guid` lists as friends. pub(crate) fn friends_of(&self, owner_guid: u64) -> Vec { self.targets(owner_guid, false).collect() } } -/// The Gateway-side index from lowercase Character name to guid on one Shard, kept current by the -/// `game_character` callbacks. The bindings' unique `name` finder is case-sensitive, and a typed -/// name is not; a whole-table scan per whisper and per Shard copies every Character row under the -/// lock the pump needs. #[derive(Default)] pub(crate) struct CharacterNameIndex { by_name: HashMap>, @@ -242,8 +204,6 @@ impl CharacterNameIndex { } } - /// The Character `name` names, ASCII case-insensitive. Names are unique per Shard only up to - /// case, so two rows that differ only in case answer the lower guid, the same one every time. pub(crate) fn guid_named(&self, name: &str) -> Option { self.by_name .get(&name.to_ascii_lowercase()) @@ -251,8 +211,6 @@ impl CharacterNameIndex { } } -/// Keep a [`CharacterNameIndex`] current from the Character callbacks. Initial subscription rows -/// arrive as inserts. pub(crate) fn watch_character_names(conn: &DbConnection) -> Arc> { let index = Arc::new(RwLock::new(CharacterNameIndex::default())); let inserted = index.clone(); @@ -274,8 +232,6 @@ pub(crate) fn watch_character_names(conn: &DbConnection) -> Arc Arc> { let index = Arc::new(RwLock::new(ContactIndex::default())); let inserted = index.clone(); @@ -363,8 +319,6 @@ mod tests { ); } - /// `watch_character_names`'s `on_update` callback does exactly this: remove the old name, then - /// insert the new one. A rename must not answer to both names, nor to neither. #[test] fn a_renamed_character_answers_only_to_its_new_name() { let mut index = CharacterNameIndex::default(); diff --git a/gateway/src/stdb/reads/channel.rs b/gateway/src/stdb/reads/channel.rs index 2e2454a84..809d68127 100644 --- a/gateway/src/stdb/reads/channel.rs +++ b/gateway/src/stdb/reads/channel.rs @@ -1,17 +1,10 @@ -//! Chat Channel reads from the Realm-core cache, through [`ChannelIndex`] so a request never scans -//! the channel or member tables. - use std::collections::{BTreeMap, HashMap}; use super::*; -/// The Gateway-side index over `game_chat_channel` and `game_chat_channel_member`, kept current by -/// the cache's insert, update and delete callbacks. The SDK cache has only unique-index finds, and a -/// whole-table scan would copy every row under the lock the pump needs. #[derive(Default)] pub(crate) struct ChannelIndex { by_name: HashMap<(u32, String), u64>, - /// Channel id to its members by member row id, which is join order: `(guid, member flags)`. members: HashMap>, } @@ -105,7 +98,6 @@ mod tests { index.insert_member(&member(4, 1, 40, 0x03)); index.insert_member(&member(5, 2, 50, 0)); assert_eq!(index.members(1), [(40, 0x03), (90, 0)]); - // An update arrives as remove then insert. index.remove_member(&member(9, 1, 90, 0)); index.insert_member(&member(9, 1, 90, 0x08)); assert_eq!(index.members(1), [(40, 0x03), (90, 0x08)]); @@ -115,7 +107,6 @@ mod tests { assert_eq!(index.members(2), [(50, 0)]); } - /// A replaced channel's late delete must not unmap the channel now holding its name. #[test] fn a_stale_channel_delete_keeps_the_current_mapping() { let mut index = ChannelIndex::default(); diff --git a/gateway/src/stdb/reads/guild.rs b/gateway/src/stdb/reads/guild.rs index 9934250f4..3d8e01861 100644 --- a/gateway/src/stdb/reads/guild.rs +++ b/gateway/src/stdb/reads/guild.rs @@ -1,7 +1,3 @@ -//! Guild cache reads. The guild tables are read on the Realm-core handle; Character facts are read -//! across every World Shard. A Fee Hold and a Guild Charter item are read on the payer's Home -//! Shard. - use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; @@ -12,14 +8,10 @@ use super::super::bindings::*; use super::super::connection::Coordinator; use crate::world::CharacterFacts; -/// The Guild Ranks and members of each Guild, and the Characters that Petitions and Signatures -/// name, kept from the row callbacks. The SDK cache has no index on these columns, so a guild read -/// finds its rows here by key instead of scanning the tables. #[derive(Default)] pub(crate) struct GuildIndex { rank_ids: HashMap>, member_guids: HashMap>, - /// Petition owner or signer guid to the number of Petition and Signature rows naming it. pub(in crate::stdb) petition_characters: HashMap, } @@ -53,7 +45,6 @@ impl GuildIndex { .unwrap_or_default() } - /// The members of `guild_id`, lowest guid first. pub(in crate::stdb) fn member_guids(&self, guild_id: u32) -> Vec { self.member_guids .get(&guild_id) @@ -74,7 +65,6 @@ impl GuildIndex { } } - /// Every member of every Guild, every Petition owner and every signer. pub(in crate::stdb) fn named_characters(&self) -> BTreeSet { self.member_guids .values() @@ -94,8 +84,6 @@ fn remove_key(index: &mut HashMap>, guild_id: u32, key: u64) } } -/// Keep a [`GuildIndex`] current from the Guild Rank and member row callbacks. Register it before -/// the subscription applies: the initial rows arrive as inserts. pub(crate) fn watch_guilds(conn: &DbConnection) -> Arc> { let index = Arc::new(RwLock::new(GuildIndex::default())); let inserted = index.clone(); @@ -179,7 +167,6 @@ pub(crate) fn watch_guilds(conn: &DbConnection) -> Arc> { index } -/// A Petition with its signers, found by key: the SDK cache has no index on the Petition column. pub(in crate::stdb) fn petition_view( db: &RemoteTables, row: GuildPetition, @@ -203,8 +190,6 @@ pub(in crate::stdb) fn petition_view( } impl Coordinator { - /// The Guild Projection `(guild_id, rank_id)` of `character_guid` in THIS handle's cache. - /// `(0, 0)` outside a Guild. Cheap enough for a relay job. pub(crate) fn guild_projection(&self, character_guid: u64) -> (u32, u32) { self.0 .coord() @@ -216,10 +201,6 @@ impl Coordinator { .map_or((0, 0), |member| (member.guild_id, member.rank_id)) } - /// Facts for one Character: the last logout and Realm Account from whichever World Shard holds - /// its row, and its Realm Presence. `None` when no World Shard holds a Character row, so a - /// creature's live entity never passes for a Character. `Err` when the World Shards cannot - /// vouch for an absence. pub(crate) fn guild_character_facts( &self, character_guid: u64, @@ -241,8 +222,6 @@ impl Coordinator { crate::world::character_facts(durable, || crate::world::presence::of(self, character_guid)) } - /// The GM level on THIS handle's own Character row, 0 when it holds none. The guild Gates call - /// it on the actor's Home Shard, so a copy an interrupted Transfer left elsewhere never counts. pub(crate) fn home_gm_level(&self, character_guid: u64) -> u8 { self.0 .coord() @@ -254,8 +233,6 @@ impl Coordinator { .map_or(0, |character| character.gm_level) } - /// The open Petition of the Guild Charter `charter_item_guid` in THIS handle's cache. Call it - /// on the Realm-core handle. pub(crate) fn guild_petition_of_charter( &self, charter_item_guid: u64, @@ -269,8 +246,6 @@ impl Coordinator { Some(petition_view(db, row)) } - /// The Petition id of the Guild Charter `charter_item_guid` in THIS handle's cache. Call it on - /// the Realm-core handle. pub(crate) fn charter_petition_id(&self, charter_item_guid: u64) -> Option { self.0 .coord() @@ -282,9 +257,6 @@ impl Coordinator { .map(|petition| petition.petition_id) } - /// Fill each Guild Charter's ITEM_FIELD_ENCHANTMENT with its Petition id from the Realm-core - /// cache. Call it on the owner's Home Shard, with no cache guard held. A Charter whose - /// Petition is unreadable shows 0; the client then asks about no Petition. pub(crate) fn project_charter_petitions(&self, items: &mut [crate::codec::ItemInstanceView]) { use lyracore_shared::guild::GUILD_CHARTER_ENTRY; if !items.iter().any(|item| item.entry == GUILD_CHARTER_ENTRY) { diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index d818c5db2..076b8fa86 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -1,6 +1,3 @@ -//! Item-instance / loot cache-accessor methods — pure code-motion split of the former -//! `reads.rs`. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -9,12 +6,6 @@ use super::super::connection::Coordinator; use lyracore_shared::item_property::client_enchantment_id; impl Coordinator { - /// Read every item a character owns, joined with its template for the CREATE - /// descriptors (max-durability). Read from the privileged cache (the coordinator bypasses RLS), - /// filtered by `owner_guid` — the SDK exposes only the PK index, so iterate+filter like the other - /// row queries. Returns the instance views ready for `build_item_create_object` + inventory slots. - /// The character's learned skills as `(skill_line, current, max_rank)` — the self-CREATE - /// SkillInfo block's live source. RLS-bypassed cache read like the sibling item read. pub fn player_skills(&self, character_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -38,10 +29,6 @@ impl Coordinator { db.game_item_instance() .iter() .filter(|i| i.owner_guid == owner_guid) - // Skip an instance whose template is absent: a delete-template migration can leave orphaned rows - // (e.g. synthetic ids deleted by the real-item remap), and sending a phantom item — blank/unknown - // to the client, faulting on use/equip — is worse than dropping it. Defense-in-depth so a future - // template delete can never push a broken item to a 5875 client. .filter_map(|i| { let tmpl = db.game_item_template().entry().find(&i.entry)?; Some(crate::codec::ItemInstanceView { @@ -54,11 +41,7 @@ impl Coordinator { .collect() } - /// Bag slot of the item instance with `item_guid`, from the privileged cache. The Module - /// reducer that uses the slot enforces ownership. pub fn item_slot_by_guid(&self, item_guid: u64) -> Option { - // `collect()` forces the iterator to complete (and drop its borrow from `guard`) before `guard` - // itself drops — same pattern as `player_items`. let guard = self.0.coord(); let slots: Vec = guard .conn @@ -72,8 +55,6 @@ impl Coordinator { } } -/// The view fields an item row holds itself, without template or catalogue reads. The enchantment -/// is the ID the client resolves, so no send path ships a stored compatibility ID. pub(crate) fn view_of_item_row(row: &ItemInstance) -> crate::codec::ItemInstanceView { crate::codec::ItemInstanceView { guid: row.guid, @@ -89,8 +70,6 @@ pub(crate) fn view_of_item_row(row: &ItemInstance) -> crate::codec::ItemInstance } } -/// A Random Property's enchant ids from the cached catalogue; `[0; 3]` for property 0 or an -/// unknown property. pub(crate) fn property_enchant_ids(db: &RemoteTables, random_property_id: u32) -> [u32; 3] { db.game_item_random_property() .property_id() diff --git a/gateway/src/stdb/reads/mail.rs b/gateway/src/stdb/reads/mail.rs index c5964e437..27dae5647 100644 --- a/gateway/src/stdb/reads/mail.rs +++ b/gateway/src/stdb/reads/mail.rs @@ -1,18 +1,9 @@ -//! Mail cache-accessor methods: the mailbox list, and the mailbox-proximity gate. -//! -//! Both read the privileged coordinator cache. Which DATABASE that cache belongs to is the caller's -//! decision, not this file's — `world::mail` asks the realm-core handle when there is one and the -//! session's own handle when there is not, and both reach the same body here. - use std::collections::{BTreeSet, HashMap}; use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::super::bindings::*; -/// The mail escrow ids each Character holds on one Shard, kept from the `game_mail_escrow` row -/// callbacks. A drive runs after every quest turn-in, at world entry and at every mailbox visit, so -/// it reads a Character's fences by primary key instead of scanning the cache. #[derive(Default)] pub(crate) struct MailEscrowIndex { by_sender: HashMap>, @@ -35,7 +26,6 @@ impl MailEscrowIndex { } } - /// The escrow ids `sender_guid` holds, lowest id first. pub(in crate::stdb) fn of(&self, sender_guid: u64) -> Vec { self.by_sender .get(&sender_guid) @@ -44,8 +34,6 @@ impl MailEscrowIndex { } } -/// Keep a [`MailEscrowIndex`] current from the escrow row callbacks. Initial subscription rows -/// arrive as inserts. pub(crate) fn watch_mail_escrows( conn: &DbConnection, ) -> std::sync::Arc> { diff --git a/gateway/src/stdb/reads/mod.rs b/gateway/src/stdb/reads/mod.rs index 375b65e44..24294b283 100644 --- a/gateway/src/stdb/reads/mod.rs +++ b/gateway/src/stdb/reads/mod.rs @@ -1,11 +1,3 @@ -//! Pure cache-accessor methods on `Coordinator`: read the privileged subscription cache (RLS -//! bypass) and project rows into codec views. A read only one Store family calls lives in -//! `store/.rs`. -//! -//! Split by domain (pure code-motion): each submodule below is its own `impl Coordinator` -//! block. A free-fn helper used by callers in more than one submodule lives here instead of in -//! either domain file. - mod account; mod channel; mod guild; @@ -21,8 +13,6 @@ use spacetimedb_sdk::Table; use super::bindings::*; -// Re-exported so `subscriptions.rs` keeps resolving `super::reads::build_quest_log_slots` at the -// same path it used before the domain split. pub(crate) use quest::build_quest_log_slots; pub(crate) use account::{watch_character_names, watch_contacts, CharacterNameIndex, ContactIndex}; @@ -31,15 +21,10 @@ pub(crate) use guild::{watch_guilds, GuildIndex}; pub(crate) use items::{property_enchant_ids, view_of_item_row}; pub(crate) use mail::{watch_mail_escrows, MailEscrowIndex}; -// Shared with the Store family adapters in `stdb::store`. pub(in crate::stdb) use guild::petition_view; pub(in crate::stdb) use quest::quest_objectives_complete; pub(in crate::stdb) use spell::spell_ranks_stack_in_book; -/// Sum a player's held quantity of item `entry` over `game_item_instance` (the coordinator reads any -/// player's items — RLS-bypassed, like the quest log). The gateway twin of the module's -/// `items::item_count`. Shared by `quest::quest_objectives_complete` and -/// `items::viewer_needs_quest_item`. pub(in crate::stdb) fn player_item_count(db: &RemoteTables, owner_guid: u64, entry: u32) -> u32 { lyracore_shared::item::carried_quantity( entry, diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index 53aec1963..032078536 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -1,21 +1,8 @@ -//! Party/group + loot-roll cache-accessor methods — pure code-motion split of the former -//! `reads.rs`. - use super::super::bindings::*; use super::super::connection::Coordinator; use lyracore_shared::group::{GroupKind, RaidSlot}; impl Coordinator { - /// The party `character_guid` belongs to, read from THIS handle's database. - /// - /// Which database the handle points at is the whole meaning of the answer: on the **realm-core** - /// handle this is the AUTHORITATIVE roster, and on a world shard it is that shard's mirror of it - /// (`group::sync_group_mirror`). Nothing here knows or cares which — routing is the caller's job, - /// exactly as it is for every other read in this file. - /// - /// A cache read through the membership index, so it is cheap enough to run inside an SDK - /// callback (which the realm-core group relay does): no reducer call, no round trip, no table - /// scan. pub fn group_roster(&self, character_guid: u64) -> Option { let group_id = self .0 @@ -27,12 +14,6 @@ impl Coordinator { self.group_roster_by_id(group_id) } - /// [`group_roster`](Self::group_roster) keyed by the group itself — the read the mirror push - /// needs for a party the acting character has just LEFT (their own membership row is gone, but - /// the remaining members' rows still have to reach every shard). - /// - /// Realm membership revisions retain join order when Transfer replaces a World mirror row. - /// Realm-core and unmirrored local parties use their own member-row ids. pub fn group_roster_by_id(&self, group_id: u64) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; diff --git a/gateway/src/stdb/reads/quest.rs b/gateway/src/stdb/reads/quest.rs index d05728d2e..eabb90e8d 100644 --- a/gateway/src/stdb/reads/quest.rs +++ b/gateway/src/stdb/reads/quest.rs @@ -1,6 +1,3 @@ -//! Quest-log / quest-giver cache-accessor methods — pure code-motion split of the former -//! `reads.rs`. - use spacetimedb_sdk::Table; use super::super::bindings::*; @@ -37,13 +34,6 @@ pub(in crate::stdb) fn quest_objectives_complete( ) } -/// Build the player's quest-log descriptor slots from raw quest + objective rows. Shared by the login -/// read ([`Coordinator::player_quest_log`], over the privileged coordinator cache) and the live relay -/// (`subscriptions.rs`, over the player connection's own RLS-scoped cache) so both produce identical -/// slot assignments. Active (un-rewarded) quests for `player_guid`, sorted by quest_entry → slot 0.., -/// capped at the 20 vanilla slots; `state` is 1 when every objective is met (INVENTORY-AWARE for -/// COLLECT_ITEM, via `quest_objectives_complete` — so the log shows "(Complete)" for a collect quest), -/// else 0. Reads objectives, carried items, event requirements and event credits from the same cache. pub(crate) fn build_quest_log_slots( db: &RemoteTables, quests: &[CharacterQuest], @@ -53,7 +43,7 @@ pub(crate) fn build_quest_log_slots( .iter() .filter(|q| q.character_guid == player_guid && !q.rewarded) .collect(); - active.sort_by_key(|q| q.quest_entry); // deterministic slot order + active.sort_by_key(|q| q.quest_entry); active .into_iter() .take(crate::codec::update_mask::idx::QUEST_LOG_SLOTS as usize) @@ -64,7 +54,7 @@ pub(crate) fn build_quest_log_slots( slot: i as u8, quest_id: q.quest_entry, counts: q.counts.clone(), - state: u8::from(complete), // 1 = complete, 0 = incomplete + state: u8::from(complete), timer: 0, } }) diff --git a/gateway/src/stdb/reads/spell.rs b/gateway/src/stdb/reads/spell.rs index 8e5b3f7c5..e3e75b27a 100644 --- a/gateway/src/stdb/reads/spell.rs +++ b/gateway/src/stdb/reads/spell.rs @@ -1,20 +1,7 @@ -//! Spell/spellbook cache-accessor methods — pure code-motion split of the former -//! `reads.rs`. - use lyracore_shared::spell::taxonomy::SPELL_ATTR_PASSIVE; use super::super::bindings::*; -// --- quest read helpers (free fns over the cache, shared by the methods above) --------------------- -// NOTE: game_creature_quest / game_quest_objective / game_quest_reward_item / game_character_quest only -// expose their `id` PK index via the SDK, so these scan with iter().filter() (not a secondary-index -// find). game_quest_template / game_quest_text / game_item_template / game_creature_template DO have a -// unique-PK index, so those use .entry()/.quest_entry().find() (point lookups). - -/// cmangos `canStackSpellRanksInSpellBook`: MANA spells (power_type 0) keep EVERY rank -/// visible in the client book — downranking is a real caster mechanic; PASSIVES and -/// non-mana-cost spells (rage/energy/health) SUPERSEDE their old rank. Unknown spell → stack -/// (never hide a rank on missing data). LOCKSTEP: SPELL_ATTR_PASSIVE = 0x40 (module taxonomy). pub(in crate::stdb) fn spell_ranks_stack_in_book(db: &RemoteTables, spell_id: u32) -> bool { const POWER_MANA: u8 = 0; match db.game_spell().spell_id().find(&spell_id) { diff --git a/gateway/src/stdb/reads/talent_reputation.rs b/gateway/src/stdb/reads/talent_reputation.rs index ec7b6f898..8edcc8175 100644 --- a/gateway/src/stdb/reads/talent_reputation.rs +++ b/gateway/src/stdb/reads/talent_reputation.rs @@ -1,6 +1,3 @@ -//! Talent-pane + faction/reputation cache-accessor methods — pure code-motion split of the -//! former `reads.rs`. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -8,15 +5,6 @@ use super::super::bindings::*; use super::super::connection::Coordinator; impl Coordinator { - /// Does `npc_guid` REFUSE to interact with `player_guid` (vanilla - /// `Unit::GetReactionTo` for the gossip/vendor/trainer/questgiver windows)? The NPC's - /// faction_template resolves to its parent faction: - /// - parent has a REP BAR (`reputation_index >= 0`) and the player has a standing row → use - /// its rank and At-War state; refuse at Unfriendly (2) or below. - /// - no player standing row or no rep bar → FactionTemplate mask fallback, which is race-safe. - /// - no rep bar → FactionTemplate mask fallback: refuse when the NPC is HOSTILE to the player. - /// - /// Missing data anywhere → do NOT refuse (fail-open: an unfactioned fixture NPC keeps working). pub fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; @@ -71,12 +59,12 @@ mod tests { #[test] fn reputation_rank_thresholds_match_mangos() { use lyracore_shared::faction::reputation_rank; - assert_eq!(reputation_rank(42000), 7); // Exalted - assert_eq!(reputation_rank(9000), 5); // Honored - assert_eq!(reputation_rank(0), 3); // Neutral - assert_eq!(reputation_rank(-1), 2); // Unfriendly - assert_eq!(reputation_rank(-3001), 1); // Hostile - assert_eq!(reputation_rank(-42000), 0); // Hated + assert_eq!(reputation_rank(42000), 7); + assert_eq!(reputation_rank(9000), 5); + assert_eq!(reputation_rank(0), 3); + assert_eq!(reputation_rank(-1), 2); + assert_eq!(reputation_rank(-3001), 1); + assert_eq!(reputation_rank(-42000), 0); assert!(reputation_rank(-1) <= 2, "Unfriendly refuses"); assert!(reputation_rank(0) > 2, "Neutral interacts"); } @@ -99,7 +87,6 @@ mod tests { friend_2: 0, friend_3: 0, }; - // Monster (group mask): enemy_group intersects the player's faction_group → hostile. let monster = ft(14, 14, 8, 1, 0, 0); let player = ft(1, 1, 1, 0, 0, 0); assert!(super::faction_template_hostile(&monster, &player)); @@ -107,7 +94,6 @@ mod tests { !super::faction_template_hostile(&player, &monster), "player group 0 enemy mask" ); - // Explicit friend list beats the mask; explicit enemy list beats everything. let befriended = ft(2, 14, 8, 1, 0, 1); assert!(!super::faction_template_hostile(&befriended, &player)); let sworn = ft(3, 14, 8, 0, 1, 0); diff --git a/gateway/src/stdb/reads/templates.rs b/gateway/src/stdb/reads/templates.rs index e7b7a49af..0d2f63c75 100644 --- a/gateway/src/stdb/reads/templates.rs +++ b/gateway/src/stdb/reads/templates.rs @@ -1,15 +1,9 @@ -//! Static-template cache-accessor methods (creature/gameobject/item entries) — pure -//! code-motion split of the former `reads.rs`. - use anyhow::Result; use super::super::bindings::*; use super::super::connection::Coordinator; impl Coordinator { - /// Read a gameobject template by entry for a `CMSG_GAMEOBJECT_QUERY` reply. A Meeting Stone's - /// `data0..data2` come from its stone row, the only copy of its level range and dungeon area, - /// so the client tooltip shows the range. pub fn gameobject_template( &self, entry: u32, diff --git a/gateway/src/stdb/realm_db.rs b/gateway/src/stdb/realm_db.rs index df985dfd2..f42bd1e85 100644 --- a/gateway/src/stdb/realm_db.rs +++ b/gateway/src/stdb/realm_db.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`RealmDb`] adapter. - use anyhow::{anyhow, Result}; use spacetimedb_sdk::Table; @@ -9,8 +7,6 @@ use crate::stdb::connection::call_reducer; use crate::stdb::{AccountRow, Coordinator, RealmRow}; use crate::world::Actor; -/// `realm_core.rs`'s Fake replaces this whole block. Reads that other code paths also use stay -/// inherent on `Coordinator`; a Realm-only read or reducer call lives here. impl RealmDb for Coordinator { fn shard_name(&self) -> &str { self.shard_name() @@ -51,8 +47,6 @@ impl RealmDb for Coordinator { self.establish_session(account_id, session_key, bound_identity) } - /// The Module combines the conveyed Account authority with its own Character GM level and - /// remains the final Gate. Its Refusal reason is the text the GM sees. fn request_gm_command(&self, actor: Actor, alpha_test_tools: bool, text: String) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -65,8 +59,6 @@ impl RealmDb for Coordinator { self.character_location(guid) } - /// Call it on the Realm-core handle: on a World Shard `finish_transfer` maintains the index - /// in the same transaction. fn set_character_shard(&self, guid: u64, map_id: u32, instance_id: u64) -> Result<()> { let actor = character_actor(self, guid)?; call_reducer!( @@ -83,7 +75,6 @@ impl RealmDb for Coordinator { self.realm_character_partition(guid) } - // The arguments mirror the Realm reducer's exact Transfer Gate. fn begin_character_shard_transfer( &self, source_map: u32, @@ -201,8 +192,6 @@ impl RealmDb for Coordinator { self.escrow_row(guid).is_some() } - /// Player entities (`account_id != 0`) in this shard's cache. Shard truth, so every Gateway - /// reports the same number. fn session_count(&self) -> usize { self.0 .coord() @@ -214,7 +203,6 @@ impl RealmDb for Coordinator { .count() } - /// Callers hold the Realm-core handle: only Realm-core reads `game_shard_load`. fn record_shard_load( &self, shard: &str, @@ -235,7 +223,6 @@ impl RealmDb for Coordinator { } } -/// The Realm index reducers act for the Character they move. fn character_actor(coordinator: &Coordinator, character_guid: u64) -> Result { let actor = Actor::new(character_guid) .ok_or_else(|| anyhow!("Character guid 0 cannot act in the Realm index"))?; diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index ac9bdf58e..745622da5 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -1,7 +1,3 @@ -//! Durable Requests on `Coordinator` that no Store trait owns: the relays' intent claims, logon, -//! provisioning and the Gateway heartbeat. A family's reducer calls live in `store/.rs`, -//! the Realm index calls in `realm_db.rs`. - use anyhow::Result; use spacetimedb_sdk::Identity; use std::time::Duration; @@ -51,21 +47,12 @@ impl Coordinator { ) } - /// Heartbeat this gateway's `game_gateway_lease` row every 15 s on EVERY connected - /// database's coordinator connection, forever. Every shard, not just the default: - /// `gw_player_login` fail-closes on the lease of the database it runs ON, and a - /// cross-database login (an instance entry resuming a transfer) runs on that destination - /// shard — a default-only lease made every instance login die with "no lease for this - /// gateway". Fire-and-forget per beat — a missed beat is harmless (the TTL tolerates - /// several) and the loop must never stall on a slow call. Spawned from `main`. pub fn spawn_gateway_heartbeat(&self) { let coord = self.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(Duration::from_secs(15)); loop { tick.tick().await; - // `world_shards()` is default-first; realm-core is appended when it is a - // distinct database (unconfigured, it aliases the default handle). let mut shards = coord.world_shards(); if let Ok(rc) = coord.realm_core() { if !shards.iter().any(|(n, _)| n == rc.shard_name()) { @@ -84,7 +71,6 @@ impl Coordinator { }); } - /// Provision SRP6 credentials computed by the Gateway. pub fn provision_account(&self, username: &str, salt: &[u8], verifier: &[u8]) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -93,7 +79,6 @@ impl Coordinator { ) } - /// Logon writes K + the bound per-account identity. pub fn establish_session( &self, account_id: u64, diff --git a/gateway/src/stdb/relays.rs b/gateway/src/stdb/relays.rs index c70c76127..594a25da6 100644 --- a/gateway/src/stdb/relays.rs +++ b/gateway/src/stdb/relays.rs @@ -1,5 +1,3 @@ -//! Typed Relay declarations own both the Coordinator query and its row callbacks. - use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::Arc; @@ -10,7 +8,6 @@ use super::world_index::ShardId; use super::world_view::{GroupEventSource, WorldView}; use super::Coordinator; -/// Initial subscription rows arrive as inserts. Updates and deletes are always live deltas. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Replay { Snapshot, @@ -26,9 +23,7 @@ impl Replay { #[derive(Clone)] pub(crate) struct RelayContext { pub(crate) view: Arc, - /// World reads use the default World Shard even for Realm-core callbacks. pub(crate) coord: Coordinator, - /// Guild reads use the connection that owns the subscribed rows. pub(crate) realm: Coordinator, pub(crate) shard: ShardId, pub(crate) source: GroupEventSource, @@ -44,8 +39,6 @@ pub(crate) struct RelayTable { pub(crate) legs: &'static [(&'static str, Replay)], } -/// The declaration's table accessor fixes the row type of every callback. The same entry emits -/// its query, replay policy and registration, so adding a Relay cannot omit its subscription. macro_rules! declare_relays { ($($table:ident [$realm:literal] { $($leg:ident($replay:ident) => $callback:expr;)* diff --git a/gateway/src/stdb/store/auction.rs b/gateway/src/stdb/store/auction.rs index 658465a98..d1e45c80e 100644 --- a/gateway/src/stdb/store/auction.rs +++ b/gateway/src/stdb/store/auction.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`AuctionActionStore`] adapter. - use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Result}; @@ -65,8 +63,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { })) } - /// Create one auction listing. Single-database deployments use one atomic reducer; sharded - /// deployments drive the durable Hold -> Auction -> receipt -> settle protocol. fn create_auction(&self, request: CreateAuctionRequest) -> Result { let item_is_present = self .0 @@ -77,9 +73,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { .guid() .find(&request.item_guid) .is_some(); - // A successful listing removes the source item in the same transaction that records its - // Hold or receipt. Presence therefore proves this is a new request even when a returned - // item later reuses the same guid and terms. if !item_is_present { if let Some(receipt) = self.matching_active_auction_receipt(request)? { if self @@ -126,8 +119,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { } } - /// Place one full-offer bid. The sharded path fences the bidder purse before realm-core makes - /// its serialized Auction decision; both paths finish with the same normalized terminal Hold. fn place_bid(&self, request: PlaceBidRequest) -> Result { let operation_id = self .matching_unfinished_bid_hold(request) @@ -147,9 +138,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { } } - /// Cancel one listing. The Gateway reads the listing from the Realm-core cache and fences the - /// Auction Cut it computes; Realm-core refuses the Cancellation if a later bid changed that cut. - /// A pending Cancellation of the same listing is driven again instead of fencing a second cut. fn cancel_auction(&self, request: CancelAuctionRequest) -> Result { let pending = self .unfinished_auction_holds(request.actor.guid()) @@ -183,8 +171,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { } } - /// Finish every unfinished Hold of `actor` on this Home Shard, listing Holds first. A Refusal - /// leaves that Hold for a later attempt; a Transport Loss ends the request. fn resume_auction_holds(&self, actor: Actor) -> Result<()> { let actor_guid = actor.guid(); for hold in self.listing_holds(actor_guid) { @@ -228,9 +214,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { .guid() .find(&player_guid) .ok_or_else(|| anyhow!("auction query actor {player_guid} is not in world"))?; - // The "usable" checkbox asks what THIS Character can wear, so the armor half reads its - // trained passives, not the class ceiling — a level-1 Warrior must not see plate as - // usable. One spellbook scan per query, not per row. let learned: Vec = db .game_player_spell() .iter() @@ -295,7 +278,6 @@ impl AuctionActionStore for crate::stdb::Coordinator { } impl Coordinator { - /// The keys `pick` names for `character_guid` in THIS handle's auction index. pub(super) fn auction_keys( &self, pick: impl Fn( @@ -311,9 +293,6 @@ impl Coordinator { pick(&index).keys_of(character_guid) } - /// The rows `pick` names for `character_guid` in THIS handle's auction index, read back by - /// primary key through `find`. A row whose `owner` is no longer `character_guid` is dropped, - /// because the index can trail the cache. pub(super) fn indexed_rows( &self, pick: impl Fn( @@ -331,7 +310,6 @@ impl Coordinator { .collect() } - /// `actor_guid`'s unfinished Holds on THIS handle. pub(super) fn unfinished_auction_holds( &self, actor_guid: u64, @@ -346,7 +324,6 @@ impl Coordinator { .filter(crate::stdb::auction_holds::hold_is_unfinished) } - /// `seller_guid`'s listing Holds on THIS handle. pub(super) fn listing_holds(&self, seller_guid: u64) -> Vec { self.indexed_rows( |index| &index.listing_holds, @@ -365,22 +342,15 @@ impl Coordinator { self.complete_listing_hold(&hold) } - /// Realm-core's Auction, this Home Shard's receipt and the settle of one listing Hold, or its - /// refund when Realm-core refuses the listing, from the phase a stopped Gateway left it in. - /// Every phase is idempotent, so a replay changes nothing. fn complete_listing_hold(&self, hold: &AuctionHold) -> Result { let operation_id = hold.operation_id; let realm = self.realm_core()?; if let Err(error) = realm.auction_commit_listing(hold) { - // Only a Refusal proves realm-core took nothing. A timeout or transport failure - // leaves the Hold for the next replay, which commits idempotently. + // Only a Refusal proves nothing was taken; retain the Hold after Transport Loss. if matches!(classify(&error), DurableFailure::Refusal { .. }) && !realm.auction_receipt_is_visible(operation_id) { - // Mail is authoritative on Realm-core. Commit its idempotent refund receipt and - // the exact returned value there before deleting the source Hold. If either call - // is interrupted, the Hold remains recovery evidence and the next replay resumes - // from the same operation id. + // Commit the refund receipt and returned value before deleting the source Hold. realm.auction_refund_listing(hold)?; self.auction_release_listing_hold(hold)?; } @@ -518,7 +488,6 @@ impl Coordinator { ) } - /// The Character a Hold acts for. A Hold without one is corrupt. fn hold_actor(&self, character_guid: u64) -> Result { let actor = Actor::new(character_guid).ok_or_else(|| anyhow!("auction Hold names no Character"))?; @@ -558,9 +527,6 @@ impl Coordinator { return Ok(None); } - // Receipts outlive Auctions so an operation replay stays auditable. Only reuse a receipt - // while its Auction is still active: returned items can later be granted the same item guid - // and legitimately listed again with the same terms. let realm = self.realm_core()?; let guard = realm.0.coord(); let receipt = candidates.into_iter().find(|receipt| { @@ -610,8 +576,6 @@ impl Coordinator { self.complete_auction_hold(hold) } - /// Realm-core's decision, the Home Shard's finish, and any refund phases of one Hold, from the - /// phase a stopped Gateway left it in. Every phase is idempotent, so a replay changes nothing. fn complete_auction_hold(&self, mut hold: AuctionBidHold) -> Result { let operation_id = hold.operation_id; let realm = self.realm_core()?; @@ -708,7 +672,6 @@ impl Coordinator { ) } - /// Realm-core's decision for a Hold, by the operation the Hold pays for. fn auction_decide(&self, hold: &AuctionBidHold) -> Result<()> { let actor = self.hold_actor(hold.bidder_guid)?; use lyracore_shared::auction::hold_operation; @@ -742,8 +705,6 @@ impl Coordinator { } } - /// The Auction Cut the seller pays to cancel the listing, read from THIS handle's cache. - /// `None` when the listing is gone, is not the seller's, or lists in another market. fn cancellation_cut(&self, request: crate::world::CancelAuctionRequest) -> Option { let guard = self.0.coord(); let auction = guard @@ -755,7 +716,6 @@ impl Coordinator { listing_cut(&auction, request) } - /// `seller_guid`'s listing receipts on THIS handle. fn listing_receipts(&self, seller_guid: u64) -> Vec { self.indexed_rows( |index| &index.listing_receipts, @@ -976,8 +936,6 @@ fn select_active_page( } fn bidder_matches(row: &Auction, player_guid: u64, _outbid_auction_ids: &[u32]) -> bool { - // Vanilla sends recently outbid ids as cache-invalidation hints. They never authorize a - // displaced row back into the bidder tab; the authoritative winner is the realm Auction. row.highest_bidder_guid == player_guid } @@ -1068,8 +1026,6 @@ fn same_auction_request( && row.house_id() == request.house_id } -/// The Module's typed auction Refusal. Only a reducer the Module rejected carries a tag; a Transport -/// Loss stays an error with an unknown outcome. fn auction_refusal(error: &anyhow::Error) -> Option { match classify(error) { DurableFailure::Refusal { reason } => AuctionRefusal::parse_tag(reason), @@ -1092,8 +1048,6 @@ fn bid_refund_is_recorded(hold: &AuctionBidHold, decision: &AuctionBidDecision) && hold.deferred_refund == decision.deferred_refund } -/// The seller's Auction Cut for `request`'s listing, when the listing is the seller's and lists in -/// the auctioneer's market. fn listing_cut(auction: &Auction, request: crate::world::CancelAuctionRequest) -> Option { use lyracore_shared::auction::{auction_cut, market_of}; if auction.owner_guid != request.actor.guid() @@ -1186,7 +1140,6 @@ mod tests { } } - /// A Character of `player_class` that has trained neither level-40 armor upgrade. fn untrained(player_class: u8) -> Proficiency { Proficiency::derive(player_class, false, false) } @@ -1277,7 +1230,6 @@ mod tests { Proficiency::derive(1, true, false), plate )); - // A Mage trains neither upgrade, so plate stays unusable however the flags fall. assert!(!browse_matches( &usable, 60, @@ -1340,9 +1292,9 @@ mod tests { let mut at_deadline = auction(2); at_deadline.expires_at = spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(20); let mut other_market = auction(3); - other_market.house = 7; // Blackwater is neutral, outside the queried Horde market. + other_market.house = 7; let mut same_market_other_house = auction(4); - same_market_other_house.house = 5; // Thunder Bluff pools into the same Horde market. + same_market_other_house.house = 5; let mut other_owner = auction(5); other_owner.house = 4; other_owner.owner_guid = 2; @@ -1567,7 +1519,6 @@ mod auction_reducer_tests { } } - /// 5% of a bid of 201 is 10.05, truncated to 10 (`cm:AuctionHouseMgr.cpp:733-736`). #[test] fn the_gateway_fences_the_cut_of_the_sellers_own_listing_in_its_market() { assert_eq!(listing_cut(&listing(7, 1, 201), cancel_request()), Some(10)); diff --git a/gateway/src/stdb/store/bank.rs b/gateway/src/stdb/store/bank.rs index f65c20083..46a0b8dc2 100644 --- a/gateway/src/stdb/store/bank.rs +++ b/gateway/src/stdb/store/bank.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`BankStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::*; @@ -8,9 +6,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, BankStore}; impl BankStore for Coordinator { - /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) - /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and - /// resolves the receiving free slot itself. fn auto_bank_item(&self, actor: Actor, slot: u8) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -19,8 +14,6 @@ impl BankStore for Coordinator { ) } - /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator - /// connection. A refusal carries the module's `[N]` `SMSG_BUY_BANK_SLOT_RESULT` code tag. fn buy_bank_slot(&self, actor: Actor, banker_guid: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs index b2a7e0be0..c7bfcaedb 100644 --- a/gateway/src/stdb/store/cast.rs +++ b/gateway/src/stdb/store/cast.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`CastStore`] adapter. - use lyracore_shared::spell::taxonomy::{ E_DISENCHANT, E_ENCHANT_ITEM, E_FISH, E_NEXT_SWING, E_OPEN_LOCK, E_PERSISTENT_AREA, SPELL_ATTR_RANGED_AUTO_REPEAT, @@ -16,9 +14,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, CastStore, EnchantRoute}; impl CastStore for Coordinator { - /// The spell's cast time (ms) from the static `game_spell` header — 0 = instant. Used by the - /// CMSG_CAST_SPELL handler to clear an instant cast SYNCHRONOUSLY (the async relay delivers START/GO - /// after the aura effects, which wedges the 5875 client's cast slot). None = unknown spell. fn spell_cast_time(&self, spell_id: u32) -> Option { self.0 .coord() @@ -30,10 +25,6 @@ impl CastStore for Coordinator { .map(|s| s.cast_time_ms) } - /// Enchant/disenchant routing for `spell_id`, read off the spell's effect rows — `None` if the spell - /// has no item-target enchanting effect (a normal cast). The gateway uses this to route ITEM-target - /// casts by effect KIND, with the enchant id carried in the effect's `p_0`, instead of a hardcoded - /// spell-id list — a new enchant spell is a data row, no gateway change. fn enchant_route(&self, spell_id: u32) -> Option { self.0 .coord() @@ -49,11 +40,6 @@ impl CastStore for Coordinator { }) } - /// True iff `spell_id` has a ground-area effect (taxonomy `E_PERSISTENT_AREA` 0x1B — - /// Consecration etc.). The instant-cast GO for such a spell must carry an EMPTY hit list: the - /// default self-cast fallback put the CASTER in `hits[]`, and the 5875 client rendered the - /// spell's impact animation ON the paladin ("hit animation on the caster"). Kind-routed - /// like `enchant_route` — a new ground spell is a data row. fn spell_is_ground_area(&self, spell_id: u32) -> bool { self.0 .coord() @@ -64,8 +50,6 @@ impl CastStore for Coordinator { .any(|e| e.spell_id == spell_id && e.kind == E_PERSISTENT_AREA) } - /// True iff `spell_id` is a FISHING cast (taxonomy `E_FISH` 0x1C — the three tier ids carry a - /// synthesized marker effect row). Kind-routed like `enchant_route`/`spell_is_ground_area`. fn spell_is_fishing(&self, spell_id: u32) -> bool { self.0 .coord() @@ -76,9 +60,6 @@ impl CastStore for Coordinator { .any(|e| e.spell_id == spell_id && e.kind == E_FISH) } - /// True iff `spell_id` is an OPEN-LOCK cast (taxonomy `E_OPEN_LOCK` 0x1D — Pick Lock 1804). Routed to - /// the `pick_lock` reducer, gateway-intercepted exactly like `spell_is_fishing`/`enchant_route`. The - /// GO guid the pick targets rides the cast's SpellCastTargets (GAMEOBJECT flag). fn spell_is_open_lock(&self, spell_id: u32) -> bool { self.0 .coord() @@ -89,16 +70,6 @@ impl CastStore for Coordinator { .any(|e| e.spell_id == spell_id && e.kind == E_OPEN_LOCK) } - /// Resolve a trainer offering's LEARN TARGET (the rank the buy actually granted) so - /// SMSG_LEARNED_SPELL books the REAL spell, not the LearnSpell wrapper (live find 2026-07-11: - /// "Devotion Aura appeared in my General tab as the spell that teaches Devotion Aura"). - /// LOCKSTEP with module trainer.rs::resolve_learn_target — the same first-qualifying-trigger - /// rule; the excluded kinds are the module's taxonomy values (A_PERIODIC_TRIGGER 0x93, - /// A_FLAG 0xBE, A_PROC_TRIGGER 0xAB, E_TRIGGER 0x05). - /// True iff `spell_id` is an on-next-swing QUEUE spell (Heroic Strike/Cleave — any effect of kind - /// E_NEXT_SWING). The CMSG_CAST_SPELL handler then sends NO synchronous START/CAST_RESULT/GO: the - /// 5875 client lights the button locally on the press and holds it as a pending cast until the - /// swing-fire GO arrives. Kind value LOCKSTEP with module taxonomy.rs::E_NEXT_SWING (0x13). fn spell_queues_next_swing(&self, spell_id: u32) -> bool { let guard = self.0.coord(); let queues = guard @@ -110,10 +81,6 @@ impl CastStore for Coordinator { queues } - /// True iff `spell_id` is an AUTO-REPEAT ranged attack (Auto Shot / wand Shoot) — the - /// `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit set by the importer (from the DBC AttributesEx2 - /// AUTOREPEAT bit / by name). The CMSG_CAST_SPELL handler routes on this instead of a hardcoded - /// `spell == 75 || 5019` id list, so a new ranged auto-repeat ability onboards as data. fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool { let guard = self.0.coord(); guard @@ -125,7 +92,6 @@ impl CastStore for Coordinator { .is_some_and(|s| s.cast_flags & SPELL_ATTR_RANGED_AUTO_REPEAT != 0) } - /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply. fn item_template(&self, entry: u32) -> Result> { Ok(self .0 @@ -138,9 +104,6 @@ impl CastStore for Coordinator { .map(item_template_view)) } - /// Start the player's RANGED auto-attack on `target_guid` with `spell_id` (75 Auto Shot / 5019 Shoot) - /// over the coordinator connection so the module attributes the shot to the caller. - /// Rides the coordinator connection as `gw_ranged_attack`. fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -150,9 +113,6 @@ impl CastStore for Coordinator { ) } - /// Cast a spell (`CMSG_CAST_SPELL`, aura tracer) over the coordinator connection so the module - /// attributes the cast to the caller. `target_guid` is the client's selected unit (0 = none/self → - /// the module substitutes the caster), threaded so target-keyed effects see the real target. fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -162,7 +122,6 @@ impl CastStore for Coordinator { ) } - /// Resolve an item-target cast through the module's generic item-effect seam. fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -172,10 +131,6 @@ impl CastStore for Coordinator { ) } - /// Cast a GROUND-TARGETED spell at a clicked world point (`CMSG_CAST_SPELL` with a DEST_LOCATION — - /// Flamestrike/Blizzard/Rain of Fire). Same per-account attribution as `cast_spell`; the `(x,y,z)` is - /// the ground click so the module anchors the AoE/patch there. - // The ground point rides flat because it mirrors the `gw_cast_spell_at` reducer signature. #[allow(clippy::too_many_arguments)] fn cast_spell_at( &self, @@ -194,8 +149,6 @@ impl CastStore for Coordinator { ) } - /// Cancel one of the caller's own auras by spell id (`CMSG_CANCEL_AURA`) over the coordinator - /// connection so the module attributes the removal to the caller. fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -205,8 +158,6 @@ impl CastStore for Coordinator { ) } - /// Cancel the caller's in-progress cast (`CMSG_CANCEL_CAST`) over the coordinator connection so the - /// module clears the caller's pending cast — no phantom completion GO. fn cancel_cast(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -234,8 +185,6 @@ impl CastStore for Coordinator { ) } - /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the - /// skill and grants the fish straight to the bag. Caller resolved via ctx.sender. fn fish(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -245,9 +194,6 @@ impl CastStore for Coordinator { ) } - /// Pick Lock: unlock the locked GameObject `go_guid` over the coordinator connection (so the - /// module attributes the pick to the caller via ctx.sender). The module gates range / lock - /// requirement / Lockpicking skill; on success it records the GO unlocked + climbs the skill. fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -257,7 +203,6 @@ impl CastStore for Coordinator { ) } - // The shared reads stay inherent: vendor and the Character reads call them too. fn item_slot_by_guid(&self, item_guid: u64) -> Option { Coordinator::item_slot_by_guid(self, item_guid) } diff --git a/gateway/src/stdb/store/channel.rs b/gateway/src/stdb/store/channel.rs index 3feb041e0..1bc10e258 100644 --- a/gateway/src/stdb/store/channel.rs +++ b/gateway/src/stdb/store/channel.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`ChannelActionStore`] adapter. - use anyhow::Result; use lyracore_shared::channel::{ChannelName, ChannelRefusal}; @@ -12,8 +10,6 @@ use crate::world::{ }; impl ChannelActionStore for Coordinator { - /// `realm_channel_op`: run one Chat Channel op on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every channel rule. The actor's name stays here. fn channel_op( &self, actor: Actor, @@ -40,15 +36,12 @@ impl ChannelActionStore for Coordinator { )) } - /// The channel `channel_name` names for `team` on Realm-core, with its members in join order - /// and its owner's name from whichever World Shard holds the Character. fn channel_roster(&self, team: u32, channel_name: &str) -> Result> { let realm = self.realm_core()?; let key = ChannelName::normalize(channel_name).key; let found = { let guard = realm.0.coord(); - // The index lock is released before the cache read: the pump takes the cache and - // then the index in its callbacks. + // Release the index lock before cache reads; pump callbacks take cache then index. let indexed = { let index = guard.chat_channels.read().unwrap(); index @@ -65,7 +58,6 @@ impl ChannelActionStore for Coordinator { Some((channel, members)) }) }; - // The guard is gone: the owner's name reads other Shards' caches. let Some((channel, members)) = found else { return Ok(None); }; @@ -94,8 +86,6 @@ impl ChannelActionStore for Coordinator { } } -/// The Module's typed channel Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. fn channel_outcome(result: Result<()>) -> Result { match result { Ok(()) => Ok(ChannelOutcome::Done), diff --git a/gateway/src/stdb/store/character.rs b/gateway/src/stdb/store/character.rs index d853d7dad..6531f4a58 100644 --- a/gateway/src/stdb/store/character.rs +++ b/gateway/src/stdb/store/character.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`CharacterStore`] adapter. - use lyracore_shared::spell::taxonomy::{A_SPELLMOD_FLAT, A_SPELLMOD_PCT}; use anyhow::Result; @@ -42,9 +40,6 @@ fn durable_character_presence_snapshots( } impl CharacterStore for Coordinator { - /// Union the account's Characters across connected World Shards. Deduplicate by guid - /// during interrupted Transfers, keeping the first copy in default-first probe order. - /// Guild membership comes from Realm-core; an unavailable authority leaves guild names empty. fn characters(&self, account_id: u64) -> Result> { let mut out: Vec = if self.is_sharded() { let mut out: Vec = Vec::new(); @@ -70,36 +65,6 @@ impl CharacterStore for Coordinator { Ok(out) } - /// Create a character on `self` — the DEFAULT/realm shard — always, even when the race's start - /// position (`module/src/auth.rs`'s per-race spawn table) routes to a DIFFERENT world shard - /// under the configured `LYRACORE_SHARD_MAP` (e.g. an Orc/Tauren/Troll/Night Elf's Kalimdor - /// start position on a two-continent split). The DECISION, made explicit: - /// - /// **Create-then-transfer-on-first-login, not create-directly-on-the-owning-shard.** The row - /// lands on `self` and stays there until the character's very first `CMSG_PLAYER_LOGIN` runs - /// `route_home`/`settle_home_shard` — the SAME resolve-and-transfer every OTHER - /// login already goes through, freshly created or not. Creating directly on the owning shard - /// instead would need its own routing resolved BEFORE the character exists (nothing to look up - /// in the character→shard index yet), built from scratch for a path that runs exactly once per - /// character and is not latency-sensitive the way login is. - /// - /// This costs nothing NEW: the escrowed transfer it rides is the one already proven against a - /// full gateway-kill crash matrix - /// (`a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy`, - /// `world/transfer_tests.rs`), and a fresh character has no live history to lose in transit — if - /// anything the simplest case that machinery handles. What it did NOT have at first is a - /// test that the first login of a freshly created character actually drives that transfer - /// end-to-end rather than merely reusing already-tested machinery by assumption: - /// `a_freshly_created_characters_first_login_transfers_off_the_default_shard` - /// (`world/shard_routing_tests.rs`). - /// - /// Runs on the VISIBILITY pipe (`visibility_pipe`), not a call pipe: the client re-sends - /// `CMSG_CHAR_ENUM` the instant it sees `SMSG_CHAR_CREATE` success, and that read hits the - /// Coordinator's `game_character` subscription cache. A call pipe carries only the liveness - /// subscription, so its return does not order the insert ahead of the read and the fresh - /// character is missing until the update lands on the Coordinator (or the next login). The - /// subscribed pipe returns only after the SDK has applied the committed row to that cache. - /// A Refusal maps to a game outcome; a Transport Loss is `Err`. fn create_character( &self, account_id: u64, @@ -109,8 +74,6 @@ impl CharacterStore for Coordinator { gender: u8, appearance: codec::Appearance, ) -> Result { - // The SpacetimeDB-generated reducer binding takes the five appearance bytes positionally; - // unbundle `Appearance` here, at the single generated-boundary call. let coordinator = self.0.visibility_pipe(); let result = call_reducer!( coordinator.conn.reducers, @@ -131,31 +94,6 @@ impl CharacterStore for Coordinator { create_outcome(result, self.shard_name()) } - /// Delete a character wherever it actually LIVES, not only on `self`. - /// - /// `characters()` above is a cross-shard UNION: a character resident on ANY connected - /// shard shows at char-select. `delete_character` did not match that — the reducer - /// call only ever ran on `self`, so a character resident on a non-default shard was NOT_FOUND - /// there (surfaced as `Failed`) even though the row was real and deletable. - /// The resolution is `realm_core::resolve_delete_shard` — the SAME index-first - /// lookup (`locate_home_shard`) the world-entry path already trusts, not a second routing - /// mechanism — and delete runs EXACTLY ONCE, on the resolved owner: turning "the wrong shard" - /// into "every shard" would trade a correctness bug for a data-loss footgun. A character caught - /// mid-transfer (an in-flight escrow) is refused rather than raced — see - /// `resolve_delete_shard`'s own doc for why. - /// - /// `account_id` is forwarded UNCHANGED to whichever shard `resolve_delete_shard` names — not - /// re-resolved for that database. This is safe, not merely assumed: `game_character.account_id` - /// is a per-database `#[auto_inc]` surrogate in general (`realm_core::lookup_session`'s doc - /// covers that boundary — the REALM-CORE ↔ world-shard one, where ids legitimately differ), but - /// a character can only ever REACH a non-default world shard through the escrowed transfer - /// (`create_character` never targets one directly — see its own doc comment), and - /// `ensure_shadow_account` (`module/src/auth.rs`) creates that shard's shadow `game_account` row - /// with the EXACT numeric id carried in the import blob, bypassing `#[auto_inc]` — so - /// `account_id` is preserved bit-for-bit, world-shard to world-shard, across every transfer. - /// `characters()`'s cross-shard union above and `route_home`'s `bind_shard_session` already - /// rely on this same invariant (`world/mod.rs`), so this is the established precedent, not a - /// new assumption introduced here. fn delete_character( &self, account_id: u64, @@ -205,9 +143,6 @@ impl CharacterStore for Coordinator { self.effective_magic_resistances(guid) } - /// The character's active SPELL-MODIFIER auras as raw `(family_mask, op, amount, is_pct)` - /// rows — the client-mirror source for SMSG_SET_FLAT/PCT_SPELL_MODIFIER (aggregation by - /// (op, mask-bit) happens in the codec helper; mangos sends the TOTAL per bit). fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)> { self.0 .coord() @@ -230,9 +165,6 @@ impl CharacterStore for Coordinator { .collect() } - /// The player's LEARNED spells — `game_player_spell` rows for this character (the coordinator - /// bypasses RLS so it reads any player's). Chained into the login spellbook so a taught ability - /// (Auto Shot) reaches the client and `CastSpellByName` can fire it. fn player_learned_spells(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -242,11 +174,6 @@ impl CharacterStore for Coordinator { .filter(|s| s.character_guid == player_guid) .map(|s| s.spell_id) .collect(); - // 258 rank collapse: drop a known rank that another KNOWN spell supersedes (a game_spell_chain - // row whose prev_spell is this id) — GATED on the same cmangos stacking rule as - // superseded_old_rank (operator-corrected): MANA spells keep every rank in the book - // (downranking Holy Light is a real thing); only non-mana/passive chains collapse - // (Heroic Strike). One pass suffices: each superseded rank is prev of its own successor. let known_set: std::collections::HashSet = known.iter().copied().collect(); let superseded: std::collections::HashSet = db .game_spell_chain() @@ -265,11 +192,6 @@ impl CharacterStore for Coordinator { .collect()) } - /// The player's IMPORTED action-bar rows as `(button, action, action_type)` triples — - /// `game_player_action` rows copied at character creation from `game_createinfo_action` (empty when - /// no dump has been imported, the common case today). Chained into the login codec - /// (`login_sequence_messages`), which builds the bar from these when non-empty and falls back to - /// the spellbook synth otherwise. RLS-bypassed read, like `player_learned_spells`. fn player_actions(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -281,11 +203,6 @@ impl CharacterStore for Coordinator { .collect()) } - /// The player's persisted reputation standings as `(reputation_index, standing, - /// at_war)` triples — chained into the login `SMSG_INITIALIZE_FACTIONS` so a relog carries the - /// real standing + the At-War checkbox instead of the all-neutral stub. Rows with - /// `reputation_index < 0` (stale pre-migration filler) are skipped — there is no slot to - /// address. RLS-bypassed read, like `player_learned_spells`. fn player_reputations(&self, player_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -299,10 +216,6 @@ impl CharacterStore for Coordinator { } impl Coordinator { - /// Read an account's characters for the character-select screen. In production - /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). - /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` - /// so the client renders the character's gear on the select screen instead of all-naked. pub fn characters(&self, account_id: u64) -> Result> { use wow_world_messages::vanilla::{CharacterGear, InventoryType}; let guard = self.0.coord(); @@ -313,9 +226,6 @@ impl Coordinator { .filter(|c| c.account_id == account_id) .map(character_view) .collect(); - // Fill equipment slots 0..=18 from item instances. Slots ≥ 19 are backpack/bag slots; - // skip them. An unknown inventory_type degrades to InventoryType::default (Non) which - // the client treats the same as display_id=0 — no model shown, no crash. for view in &mut views { for item in db .game_item_instance() @@ -336,9 +246,6 @@ impl Coordinator { Ok(views) } - /// Read a single character by guid (any owner) for a `CMSG_NAME_QUERY` reply. The queried guid - /// is usually a *peer*, so this reads across owners via the privileged cache (no RLS on the - /// owner connection), unlike `characters` which filters to one account. pub fn character_by_guid(&self, guid: u64) -> Result> { Ok(self .0 @@ -351,10 +258,6 @@ impl Coordinator { .map(character_view)) } - /// folded from the coordinator's privileged cache: base (`game_world_entity.armor`) + worn gear armor. - /// The coordinator isn't subscribed to `game_aura`, so the aura term is 0 here — login-present armor - /// auras are pushed by the on_aura relay the instant they insert. Delegates to the shared - /// `stdb::armor::effective_armor` so CREATE and the relays compute the IDENTICAL fold. pub fn effective_armor(&self, guid: u64) -> u32 { let guard = self.0.coord(); super::super::armor::effective_armor(&guard.conn.db, guid) @@ -365,7 +268,6 @@ impl Coordinator { super::super::armor::effective_magic_resistances(&guard.conn.db, guid) } - /// Does `character_guid` sell, or lead the bidding on, an Auction on THIS handle? fn has_auction(&self, character_guid: u64) -> bool { let auctions = self.auction_keys(|index| &index.auctions, character_guid); let guard = self.0.coord(); @@ -380,10 +282,6 @@ impl Coordinator { }) } - /// Delete `character` on THIS handle via the `delete_character` reducer (owner connection — the - /// reducer is operator-gated, mirroring `create_character`). Ownership is enforced module-side - /// (`NOT_OWNER` if `character` isn't `account_id`'s), so a malicious/buggy client can't delete - /// another account's character. A Refusal maps to `Failed`; a Transport Loss is `Err`. fn delete_here( &self, account_id: u64, @@ -426,7 +324,6 @@ impl Coordinator { } } -/// A create Refusal is an outcome the client renders; a Transport Loss stays `Err`. fn create_outcome(result: Result<()>, shard_name: &str) -> Result { use codec::CharCreateOutcome; let Err(error) = result else { @@ -440,10 +337,6 @@ fn create_outcome(result: Result<()>, shard_name: &str) -> Result { Ok(CharCreateOutcome::ServerLimit) } - // The 5875 client has no code for "this database may not mint guids", so the outcome is - // the generic failure — but the REASON must not be swallowed: the whole point of - // guid-range licensing is that an unlicensed shard fails loudly instead of minting - // into someone else's range. DurableFailure::Refusal { .. } => { log::warn!("create_character on {shard_name} failed: {error:#}"); Ok(CharCreateOutcome::Failed) @@ -451,15 +344,11 @@ fn create_outcome(result: Result<()>, shard_name: &str) -> Result, character_guid: u64) -> Result { let Err(error) = result else { return Ok(codec::CharDeleteOutcome::Success); }; match classify(&error) { - // The 1.12 client has one reason for every refusal, so the reason goes to the log: - // CHAR_HAS_GUILD_FEE_HOLD clears when the Character next enters the world and the - // Gateway finishes its Fee Hold. DurableFailure::Refusal { .. } => { log::info!("delete_character: {character_guid} not deleted: {error:#}"); Ok(codec::CharDeleteOutcome::Failed) diff --git a/gateway/src/stdb/store/chat.rs b/gateway/src/stdb/store/chat.rs index a75d22a65..57b29f9dc 100644 --- a/gateway/src/stdb/store/chat.rs +++ b/gateway/src/stdb/store/chat.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`ChatActionStore`] adapter. - use anyhow::Result; use lyracore_shared::chat::ChatRefusal; @@ -9,9 +7,6 @@ use crate::stdb::Coordinator; use crate::world::{whisper, Actor, ChatActionStore, ChatOutcome}; impl ChatActionStore for Coordinator { - /// The Speaker Facts for `speaker_guid` on this Home Shard: race from `UNIT_FIELD_BYTES_0` - /// byte 0 and the chat tag from `PLAYER_FLAGS`, both off the live entity, plus the Character's - /// name. `None` when the speaker has no live entity here. fn speaker_facts(&self, speaker_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -31,8 +26,6 @@ impl ChatActionStore for Coordinator { })) } - /// `realm_chat`: commit one Realm Chat Line on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every chat Gate. The speaker's name stays here. fn realm_chat( &self, actor: Actor, @@ -57,8 +50,6 @@ impl ChatActionStore for Coordinator { )) } - /// `gw_set_away`: one `/afk` or `/dnd` on the Character's Home Shard, where its live entity - /// and Auto-Reply live. fn set_away(&self, actor: Actor, kind: u8, message: String) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -75,8 +66,6 @@ impl ChatActionStore for Coordinator { whisper::target_facts(self, speaker_guid, typed_name) } - /// `realm_whisper`: commit one whisper's lines on Realm-core, or on the one database of an - /// unsharded Realm. The Module applies every whisper Gate. The speaker's name stays here. fn realm_whisper( &self, actor: Actor, @@ -112,8 +101,6 @@ impl ChatActionStore for Coordinator { } } -/// The Module's typed chat Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. pub(super) fn chat_outcome(result: Result<()>) -> Result { match result { Ok(()) => Ok(ChatOutcome::Delivered), diff --git a/gateway/src/stdb/store/combat.rs b/gateway/src/stdb/store/combat.rs index 9c509a5a6..0548316e5 100644 --- a/gateway/src/stdb/store/combat.rs +++ b/gateway/src/stdb/store/combat.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`CombatStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::*; @@ -8,8 +6,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, CombatStore}; impl CombatStore for Coordinator { - /// Targets are attributed to the caller through the coordinator connection. `target_guid` 0 - /// clears the selection. fn set_target(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( diff --git a/gateway/src/stdb/store/death.rs b/gateway/src/stdb/store/death.rs index 8dc58ad9c..08582104a 100644 --- a/gateway/src/stdb/store/death.rs +++ b/gateway/src/stdb/store/death.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`DeathStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -45,7 +43,6 @@ impl DeathStore for Coordinator { ) } - /// `gw_spirit_res` takes no healer guid: the Module revives the ghost in place. fn spirit_healer_res(&self, actor: Actor, _healer_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -55,7 +52,6 @@ impl DeathStore for Coordinator { ) } - /// Reads the corpse from the privileged cache. `None` when the owner has no corpse. fn corpse_location(&self, owner_guid: u64) -> Result> { Ok(self .0 diff --git a/gateway/src/stdb/store/duel.rs b/gateway/src/stdb/store/duel.rs index 4d0ecd974..b5f592018 100644 --- a/gateway/src/stdb/store/duel.rs +++ b/gateway/src/stdb/store/duel.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`DuelActionStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::*; diff --git a/gateway/src/stdb/store/guild.rs b/gateway/src/stdb/store/guild.rs index 118da271e..2097b013f 100644 --- a/gateway/src/stdb/store/guild.rs +++ b/gateway/src/stdb/store/guild.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`GuildActionStore`] adapter. - use anyhow::Result; use lyracore_shared::guild::GuildRefusal; @@ -13,7 +11,6 @@ use crate::world::{ }; impl GuildActionStore for Coordinator { - /// The membership row of `character_guid` on Realm-core. fn guild_member(&self, character_guid: u64) -> Result> { let realm = self.realm_core()?; let member = realm @@ -28,7 +25,6 @@ impl GuildActionStore for Coordinator { Ok(member) } - /// One Guild and its Guild Ranks on Realm-core, ranks ordered by `rank_id`. fn guild(&self, guild_id: u32) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -65,7 +61,6 @@ impl GuildActionStore for Coordinator { })) } - /// Every member row of one Guild on Realm-core, ordered by guid. fn guild_members(&self, guild_id: u32) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -91,7 +86,6 @@ impl GuildActionStore for Coordinator { Ok(self.home_gm_level(actor_guid)) } - /// The unit `actor_guid` has selected, from this Home Shard's live entity. 0 for none. fn guild_selected_target(&self, actor_guid: u64) -> u64 { self.0 .coord() @@ -107,8 +101,6 @@ impl GuildActionStore for Coordinator { whisper::ignored_anywhere(self, owner_guid, other_guid) } - /// `realm_guild_op`: one guild op on Realm-core, or on the one database of an unsharded - /// Realm. fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result { let realm = self.realm_core()?; let op = match request { @@ -227,7 +219,6 @@ impl GuildActionStore for Coordinator { .guild_petition_of_charter(charter_item_guid)) } - /// The open Petition `owner_guid` owns on Realm-core. fn guild_petition_of_owner(&self, owner_guid: u64) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -239,7 +230,6 @@ impl GuildActionStore for Coordinator { .map(|row| petition_view(db, row))) } - /// Does a Guild on Realm-core hold `name`, without regard to case? fn guild_name_taken(&self, name: &str) -> Result { let realm = self.realm_core()?; let taken = realm @@ -254,7 +244,6 @@ impl GuildActionStore for Coordinator { Ok(taken) } - /// Does `actor_guid` hold the Guild Charter `charter_item_guid` on this Home Shard? fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result { Ok(self .0 @@ -270,7 +259,6 @@ impl GuildActionStore for Coordinator { })) } - /// `gw_destroy_guild_charter` on this Home Shard. A Charter already gone is Ok. fn guild_destroy_charter(&self, actor: Actor, charter_item_guid: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -279,9 +267,6 @@ impl GuildActionStore for Coordinator { ) } - /// Every Character that a Guild, a Petition or a Signature on Realm-core names: the - /// Characters the deleted-Character reconciliation checks. It fails while the subscription - /// is unhealthy, so a stale cache never hides a Character. fn guild_character_guids(&self) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -295,8 +280,6 @@ impl GuildActionStore for Coordinator { Ok(named.into_iter().collect()) } - /// Does a Guild, a Petition or a Signature on Realm-core name `character_guid`? Keyed reads - /// only. Fails while the subscription is unhealthy. fn guild_names_character(&self, character_guid: u64) -> Result { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -322,8 +305,6 @@ impl GuildActionStore for Coordinator { .contains_key(&character_guid)) } - /// The name of the Guild `character_guid` belongs to on Realm-core, if any. Two keyed reads, - /// cheap enough for every `/who` row. fn guild_name_of_member(&self, character_guid: u64) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); @@ -343,8 +324,6 @@ impl GuildActionStore for Coordinator { } } -/// The Module's typed guild Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. fn guild_outcome(result: Result<()>) -> Result { match result { Ok(()) => Ok(GuildOutcome::Ran), diff --git a/gateway/src/stdb/store/guild_fee.rs b/gateway/src/stdb/store/guild_fee.rs index 4ab15200f..7904bf363 100644 --- a/gateway/src/stdb/store/guild_fee.rs +++ b/gateway/src/stdb/store/guild_fee.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`GuildFeeStore`] adapter. - use anyhow::{anyhow, Result}; use lyracore_shared::guild::GuildRefusal; @@ -16,9 +14,6 @@ impl GuildFeeStore for Coordinator { Ok(self.guild_fee_hold_row(actor)) } - /// `gw_guild_fee_hold` on this Home Shard under a fresh operation id, then the Hold once this - /// handle's cache shows it: the hold mints a Guild Charter's guid. A tagged Refusal answers - /// `Ok(Err(_))`. The call is made once per operation id and never retried. fn guild_fee_hold( &self, actor: Actor, @@ -54,9 +49,6 @@ impl GuildFeeStore for Coordinator { } } - /// `realm_guild_fee_decide` on Realm-core, then the decision once Realm-core's cache shows - /// it. The reducer is idempotent, so a re-drive calls it again. A Charter needs the owner - /// facts Realm-core cannot read. fn guild_fee_decide(&self, actor: Actor, hold: FeeHold) -> Result { let terms = match hold.terms { FeeTerms::Emblem(emblem) => GuildFeeTerms::Emblem(guild_emblem(emblem)), @@ -93,7 +85,6 @@ impl GuildFeeStore for Coordinator { fee_outcome(&decision) } - /// `gw_guild_fee_finish` on this Home Shard. fn guild_fee_finish(&self, actor: Actor, operation_id: u64, accepted: bool) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -104,8 +95,6 @@ impl GuildFeeStore for Coordinator { } impl Coordinator { - /// The Fee Hold of `payer` in THIS handle's cache. Call it on the payer's Home Shard. A kind - /// this Gateway does not know reads as none. fn guild_fee_hold_row(&self, payer: Actor) -> Option { let hold = self .0 @@ -146,8 +135,6 @@ fn guild_emblem(emblem: guild_fee::Emblem) -> GuildEmblem { } } -/// Realm-core's decision row as a fee outcome. A refusal tag this Gateway does not know is an -/// error, so the Hold stays for a Gateway that does. fn fee_outcome(decision: &GuildFeeDecision) -> Result { if decision.accepted { return Ok(guild_fee::FeeOutcome::Accepted); diff --git a/gateway/src/stdb/store/item.rs b/gateway/src/stdb/store/item.rs index bafd4f3ef..977d01919 100644 --- a/gateway/src/stdb/store/item.rs +++ b/gateway/src/stdb/store/item.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`ItemActionStore`] adapter. - use anyhow::Result; use lyracore_shared::item::ItemRefusal; @@ -9,8 +7,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, ItemActionResult, ItemActionStore}; impl ItemActionStore for Coordinator { - /// Equip the item in main-inventory `from_slot` (`CMSG_AUTOEQUIP_ITEM`) over the coordinator - /// connection. The module resolves the matching equipment slot and gates the required level. fn equip_item(&self, actor: Actor, from_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( @@ -20,8 +16,6 @@ impl ItemActionStore for Coordinator { )) } - /// Unequip the item in equipment `from_slot` to a free backpack slot (`CMSG_AUTOSTORE_BAG_ITEM`) - /// over the coordinator connection. The module gates "is equipped" + "backpack has room". fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( @@ -31,8 +25,6 @@ impl ItemActionStore for Coordinator { )) } - /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) - /// over the coordinator connection. The module's move primitive validates equip-slot transitions. fn move_item(&self, actor: Actor, from_slot: u8, to_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( @@ -42,9 +34,6 @@ impl ItemActionStore for Coordinator { )) } - /// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection — - /// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and - /// decrements the stack; a gameplay `Err` (no item / not usable) is per-action. fn use_item(&self, actor: Actor, slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( @@ -79,8 +68,6 @@ impl ItemActionStore for Coordinator { } } -/// The Module's typed item Refusal, on the same rule as the auction family: only a reducer the -/// Module rejected carries a tag, so a timeout or transport failure keeps its unknown outcome. fn item_action(result: Result<()>) -> Result { match result { Ok(()) => Ok(ItemActionResult::Done), diff --git a/gateway/src/stdb/store/loot_roll.rs b/gateway/src/stdb/store/loot_roll.rs index 65c1089dd..198e6473f 100644 --- a/gateway/src/stdb/store/loot_roll.rs +++ b/gateway/src/stdb/store/loot_roll.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`LootRollStore`] adapter. - use anyhow::Result; use lyracore_shared::loot::LootRefusal; use spacetimedb_sdk::Table; @@ -11,10 +9,6 @@ use crate::world::loot::{LootRollStore, PendingLootRoll}; use crate::world::{Actor, LootActionStatus}; impl LootRollStore for Coordinator { - /// Promotes a world shard's staging roll onto the database THIS handle points at (Realm-core). - /// Through the coordinator connection, not a player's: the reducer is operator-gated because - /// Realm-core has no live entity to derive an Actor from. `recipients` are the spatial snapshot - /// a world shard computed at kill time. #[allow(clippy::too_many_arguments)] fn realm_loot_start( &self, @@ -72,13 +66,6 @@ impl LootRollStore for Coordinator { )) } - /// Every UNRESOLVED `game_loot_roll` row on THIS handle's database, joined with its votes. - /// - /// A cache read, like [`group_roster`](Self::group_roster) — this table is now part of the - /// coordinator subscription list, so no reducer call is - /// needed. Meaningful only on a WORLD SHARD in a sharded deployment: realm-core never has a row - /// here that a world shard wrote (only its own `realm_loot_op` START arm inserts there), and on - /// an unsharded gateway the relay that calls this never runs at all (`realm_store()` is `Ok(None)`). fn pending_local_rolls(&self) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -107,9 +94,6 @@ impl LootRollStore for Coordinator { .collect()) } - /// Operator-gated, coordinator connection; the loot-roll relay calls it on every connected - /// world shard after observing realm-core's `ROLL_WON` event — the module's own `withheld` - /// guard makes a wrong-shard call a harmless no-op. fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -126,15 +110,6 @@ impl LootRollStore for Coordinator { ) } - /// Reads every `ROLL_WON` `game_group_event` row on THIS handle's database with `id > - /// after_id`, decoded to `(corpse_guid, slot, winner_guid)`, plus the new high-water mark (the - /// max id seen, or `after_id` unchanged if none). Meaningful on the **realm-core** handle: that - /// is the only database `resolve_roll`/`force_resolve_rolls_for_disband` push a `ROLL_WON` - /// event on in a sharded deployment (voting is routed there exclusively — `world::loot::run_vote`). - /// - /// An unparseable payload is skipped + logged rather than failing the whole scan — the module - /// writes this grammar, so a decode failure here means the two crates' `lyracore_shared::loot_roll` - /// copies have drifted, not that this event is meaningless. fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)> { use lyracore_shared::loot_roll::event_kind as roll_kind; let guard = self.0.coord(); @@ -158,8 +133,6 @@ impl LootRollStore for Coordinator { Ok((watermark, wins)) } - /// Records the caller's need/greed/pass vote on a live roll. Live votes and roll numbers relay - /// to every eligible member via the `game_group_event` roll-kind rows (`stdb/subscriptions.rs`). fn loot_roll( &self, actor: Actor, @@ -196,8 +169,6 @@ impl LootRollStore for Coordinator { } } -/// The typed answer for Loot Roll and master-loot requests. A Refusal with an unknown tag or a -/// Transport Loss stays an error with an unknown durable result. fn loot_action_status(result: Result<()>) -> Result { match result { Ok(()) => Ok(LootActionStatus::Applied), diff --git a/gateway/src/stdb/store/loot_window.rs b/gateway/src/stdb/store/loot_window.rs index 1e6a4a37f..daa0460a1 100644 --- a/gateway/src/stdb/store/loot_window.rs +++ b/gateway/src/stdb/store/loot_window.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`LootWindowStore`] adapter. - use anyhow::Result; use lyracore_shared::group::GroupKind; use lyracore_shared::loot::{LootBoundaryFailure, LootRefusal}; @@ -13,9 +11,6 @@ use crate::stdb::{classify, Coordinator, DurableFailure}; use crate::world::{Actor, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore}; impl LootWindowStore for Coordinator { - /// Reads a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. - /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a - /// lootable corpse, but we stay defensive (an empty loot window is harmless). fn loot_target_money(&self, target_guid: u64) -> Result { Ok(self .0 @@ -30,14 +25,6 @@ impl LootWindowStore for Coordinator { .unwrap_or(0)) } - /// Reads a corpse's item loot for the loot window, joined with each item's - /// template for the display id, then filtered PER VIEWER for `quest_only` rows (quest items are - /// per-looter, not gated on whoever got kill credit) AND group-loot rows (a live NEED/GREED - /// roll is withheld from EVERYONE; a round-robin/master-designated row is visible only to its - /// designee). Read from the privileged cache (the coordinator bypasses RLS), filtered by - /// `corpse_guid` (iterate+filter, like the other row queries). Returns `(slot, item_id, count, - /// display_id)` triples for `build_loot_response_raw`. An item whose template isn't loaded falls - /// back to display 0 (the client still resolves the name via query). fn loot_target_items( &self, target_guid: u64, @@ -79,12 +66,10 @@ impl LootWindowStore for Coordinator { ) }) .collect(); - items.sort_by_key(|(slot, ..)| *slot); // stable loot-slot order (SQL has no ORDER BY in 2.5) + items.sort_by_key(|(slot, ..)| *slot); Ok(items) } - /// A chest rolls its loot into the corpse-loot table keyed on the GO guid, a quest-use object - /// grants quest credit. The module gates range + type. fn use_gameobject(&self, actor: Actor, go_guid: u64) -> Result { let coord = self.0.call_pipe(); legacy_loot_request_status(call_reducer!( @@ -94,7 +79,6 @@ impl LootWindowStore for Coordinator { )) } - /// Authorizes opening a creature corpse before the Gateway reads its loot rows. fn open_creature_loot( &self, actor: Actor, @@ -126,8 +110,6 @@ impl LootWindowStore for Coordinator { )) } - /// The module moves the item into a free slot and deletes the corpse-loot row; the inventory - /// relay then shows it in the bag. fn take_loot( &self, actor: Actor, @@ -143,9 +125,6 @@ impl LootWindowStore for Coordinator { } } -/// Whether `viewer_guid` is in a Raid, read from this handle's cache: the `PartyMembershipIndex` -/// (an indexed lookup, never a table scan) names the group, and the cached `game_group.group_type` -/// names its kind. The Gateway twin of the Module's `group::in_raid`. fn viewer_in_raid(guard: &LiveConn, viewer_guid: u64) -> bool { let Some(group_id) = guard .party_memberships @@ -166,7 +145,6 @@ fn viewer_in_raid(guard: &LiveConn, viewer_guid: u64) -> bool { }) } -/// Read active collect objectives and carried quantities for the shared quest-item rule. fn viewer_needs_quest_item(guard: &LiveConn, viewer_guid: u64, item_entry: u32) -> bool { let db = &guard.conn.db; let active: Vec = db @@ -199,7 +177,6 @@ fn viewer_needs_quest_item(guard: &LiveConn, viewer_guid: u64, item_entry: u32) ) } -/// Quest rows use the same reservation and need rule as the Module's take Gate. pub(crate) fn quest_row_visible_to_viewer( quest_only: bool, reserved_for: u64, @@ -210,7 +187,6 @@ pub(crate) fn quest_row_visible_to_viewer( || lyracore_shared::loot::quest_take_allowed(reserved_for, viewer_guid, viewer_needs_item) } -/// Withheld rows stay hidden until their roll settles, including reserved rows. pub(crate) fn group_loot_row_visible_to_viewer( reserved_for: u64, withheld: bool, @@ -233,13 +209,10 @@ enum UntaggedLootRejection { LegacyUnanswered, } -/// Classify a Durable Request from a core whose gameplay refusals all have loot tags. fn strict_loot_request_status(result: Result<()>) -> Result { loot_request_status(result, UntaggedLootRejection::Fatal) } -/// Preserve silent gameplay refusals from the legacy GameObject and skinning cores. Boundary -/// failures and every tagged result remain explicit, so this compatibility cannot hide them. fn legacy_loot_request_status(result: Result<()>) -> Result { loot_request_status(result, UntaggedLootRejection::LegacyUnanswered) } @@ -283,7 +256,6 @@ mod tests { #[test] fn non_quest_rows_are_always_visible_regardless_of_viewer_state() { - // FFA, unconditional — the original baseline behavior. reserved_for/need are irrelevant. assert!(quest_row_visible_to_viewer(false, 0, 7, false)); assert!(quest_row_visible_to_viewer(false, 99, 7, false)); assert!(quest_row_visible_to_viewer(false, 7, 7, false)); @@ -303,14 +275,11 @@ mod tests { #[test] fn a_reserved_clone_is_visible_only_to_its_owner() { - // The viewer's OWN clone is visible even if `viewer_needs_item` somehow reads false (a stale - // held-count race) — the reservation itself is the authority once split. assert!( quest_row_visible_to_viewer(true, 7, 7, false), "the viewer's own reserved clone" ); assert!(quest_row_visible_to_viewer(true, 7, 7, true)); - // Reserved for a DIFFERENT character — invisible to this viewer even if they also need it. assert!( !quest_row_visible_to_viewer(true, 7, 8, true), "reserved for someone else" @@ -318,8 +287,6 @@ mod tests { assert!(!quest_row_visible_to_viewer(true, 7, 8, false)); } - // ---- Group loot methods ---- - #[test] fn group_loot_baseline_ffa_is_visible_to_everyone() { assert!(group_loot_row_visible_to_viewer(0, false, false, 0, 7)); @@ -328,15 +295,12 @@ mod tests { #[test] fn group_loot_withheld_row_is_invisible_to_everyone_including_the_eventual_winner() { - // The AutoLoot-safety trap: a live roll's row must not appear in ANYONE's window, not even - // the guid who will eventually win it. assert!(!group_loot_row_visible_to_viewer(0, true, false, 0, 7)); assert!(!group_loot_row_visible_to_viewer(0, true, false, 7, 7)); } #[test] fn group_loot_master_only_row_never_shows_in_the_plain_window() { - // Not even the stamped master sees it here — they act via SMSG_LOOT_MASTER_LIST instead. assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 42)); assert!(!group_loot_row_visible_to_viewer(0, false, true, 42, 8)); } @@ -354,14 +318,10 @@ mod tests { assert!(!group_loot_row_visible_to_viewer(7, true, true, 99, 8)); } - /// The AutoLoot-addon trap, spelled out as ONE corpse's two rows: a grey - /// (below-threshold, FFA-in-group) item autoloots normally while a green (at/above threshold, - /// mid-roll) item on the SAME corpse is simultaneously withheld from every window — proving the - /// two rows are decided independently and a live roll can never be swept up by autostore. #[test] fn a_grey_autoloots_while_a_green_on_the_same_corpse_is_withheld() { - let grey = (0u64, false, false, 0u64); // FFA baseline: (reserved_for, withheld, master_only, designated) - let green = (0u64, true, false, 0u64); // a live NEED/GREED roll in progress + let grey = (0u64, false, false, 0u64); + let green = (0u64, true, false, 0u64); for viewer in [7u64, 8u64] { assert!( group_loot_row_visible_to_viewer(grey.0, grey.1, grey.2, grey.3, viewer), diff --git a/gateway/src/stdb/store/mail.rs b/gateway/src/stdb/store/mail.rs index ef0f4e203..cc01d9fb8 100644 --- a/gateway/src/stdb/store/mail.rs +++ b/gateway/src/stdb/store/mail.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`MailStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -9,9 +7,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, MailStore}; impl MailStore for Coordinator { - /// Every mail addressed to `recipient_guid`, delivered or not, in no set order. - /// `codec::build_mail_list` orders the inbox. The SDK exposes only the PK index, so this - /// iterates and filters like every other per-owner read here (`player_items`, `player_skills`). fn mail_list(&self, recipient_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -23,7 +18,6 @@ impl MailStore for Coordinator { .collect()) } - /// The mail `mail_id`, delivered or not, by its primary key. fn mail_by_id(&self, mail_id: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -34,15 +28,6 @@ impl MailStore for Coordinator { Ok(self.realm_account_name(character_guid)) } - /// Is `player_guid` standing at the mailbox `mailbox_guid` names? - /// - /// A PK lookup on `game_gameobject`, then the same map/instance/range check - /// `module/src/gameobject.rs::usable_go` applies to a chest. It must STAY a PK lookup: - /// `game_gameobject` is spatial, and a scan over a sharded table returns a silent subset, so a - /// mailbox availability would depend on which database the session reads. - /// - /// `false` for an unknown guid, a non-mailbox gameobject, another map or instance, and anything - /// out of range — the gate answers the question, and the caller decides what a refusal costs. fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; @@ -64,10 +49,6 @@ impl MailStore for Coordinator { Ok(dx * dx + dy * dy + dz * dz <= lyracore_shared::mail::MAILBOX_RANGE_SQ) } - /// `realm_mail_mark_read` — flip a mail's read state against the database THIS handle points - /// at. Operator-gated, `recipient` passed - /// explicitly (the plane may hold no live entity), called on whichever handle `world::mail` - /// picked — realm-core when sharded, this shard's own database when not. fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -76,7 +57,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_delete` — [`mail_mark_read`](Self::mail_mark_read)'s twin for delete. fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -85,9 +65,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_return` — [`mail_delete`](Self::mail_delete)'s twin for return-to-sender: the row - /// is re-addressed in place, on the database THIS handle points at. No sharded variant — the row - /// never leaves the plane that already holds it. fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -96,13 +73,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_send` — write one sent letter against the database THIS handle points at, and - /// charge the sender for it in the same transaction. Same trust shape as the two above, and the - /// guid it carries is the one the socket authenticated: every gate deciding who may write to - /// whom ran in `world::mail`, because realm-core can answer none of them. - /// - /// **The single-database plane only.** A sharded realm cannot have that one transaction and - /// drives the escrow below instead. #[allow(clippy::too_many_arguments)] fn mail_send( &self, @@ -131,8 +101,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_take_money` — credit a mail's copper to the recipient and empty the row, in one - /// transaction. The single-database plane's whole take, for the same reason. fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -141,8 +109,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_take_item` — re-create a mail's attachment in the recipient's bags and empty the - /// row, in one transaction. The single-database plane's whole item take. fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -151,9 +117,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_item_room` — the bag-space probe a sharded item take runs BEFORE it fences, on - /// the taker's own handle. A read dressed as a reducer, because only the module can answer it - /// without a second copy of the bag search. fn mail_item_room(&self, payee: Actor) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -162,8 +125,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_copy_text` — Letter Copy step 1, against the database THIS handle points at. - /// Same trust shape as `mail_mark_read`: operator-gated, `recipient` passed explicitly. fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -172,8 +133,6 @@ impl MailStore for Coordinator { ) } - /// `gw_mail_grant_letter` — Letter Copy step 2, on the PAYEE's own handle: grants one Plain - /// Letter carrying `item_text_id`. fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -182,8 +141,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_mark_letter_granted` — Letter Copy step 3, against the database THIS handle - /// points at: the durable record that the Home Shard grant landed. fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -192,9 +149,6 @@ impl MailStore for Coordinator { ) } - /// A copied letter's text, read from `game_item_text` on THIS handle's database. Private table, - /// read through the owner token, by its PK — the same shape `mailbox_in_range` uses to resolve - /// a gameobject. fn item_text(&self, item_text_id: u32) -> Result> { let guard = self.0.coord(); Ok(guard @@ -206,16 +160,6 @@ impl MailStore for Coordinator { .map(|t| t.text)) } - /// Does `owner_guid` hold an item carrying `item_text_id`? `CMSG_ITEM_TEXT_QUERY`'s ownership - /// Gate: a client cannot use this to probe what anyone else holds, since the answer is a bare - /// bool. Read from the privileged cache. - /// - /// `hint_item_guid` is the wire's own second field — a bag item guid when the client is - /// reading that item's tooltip (`cm:MailHandler.cpp:630-646`), and vmangos always sends it for - /// this shape of the query. A PK lookup answers in O(1); a miss (item deleted, wrong id, or a - /// crafted query) answers `false` rather than falling back to a table-wide scan — this runs on - /// every such query, including ones for an id that names nothing, and the SDK exposes no - /// owner-keyed index to narrow it by. fn owns_item_with_text( &self, owner_guid: u64, @@ -235,8 +179,6 @@ impl MailStore for Coordinator { Ok(owns) } - /// `realm_mail_fence` — step 1 of a sharded SEND, on the SENDER's own handle: the postage plus - /// the attached coin leave the purse into an escrow row keyed by the caller-chosen `escrow_id`. #[allow(clippy::too_many_arguments)] fn mail_fence( &self, @@ -271,9 +213,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_commit` — step 2 of a sharded send, on the REALM handle: the mail row plus a - /// receipt under the same `escrow_id`, so a replay writes one letter and not two. A Reward - /// Letter commits here on every plane, with its `reward` header. #[allow(clippy::too_many_arguments)] fn mail_commit( &self, @@ -318,8 +257,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_take_money_fence` — step 1 of a sharded TAKE, on the handle that OWNS THE MAIL - /// ROW: the copper leaves the row into an escrow there. The mirror of `mail_fence`. fn mail_take_money_fence( &self, escrow_id: u64, @@ -339,8 +276,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_payout` — step 2 of a sharded take, on the TAKER's own handle: the purse plus a - /// receipt under the same `escrow_id`. `mail_commit`'s twin. fn mail_payout(&self, escrow_id: u64, payee: Actor, mail_id: u64, amount: u32) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -349,8 +284,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_take_item_fence` — step 1 of a sharded ITEM take, on the handle that OWNS THE - /// MAIL ROW: the attachment leaves the row into an escrow there. fn mail_take_item_fence( &self, escrow_id: u64, @@ -370,8 +303,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_item_payout` — step 2 of a sharded item take, on the TAKER's own handle: the - /// item plus a receipt under the same `escrow_id`. `mail_payout`'s twin. fn mail_item_payout( &self, escrow_id: u64, @@ -397,8 +328,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_confirm_delivery` — step 3, on the handle that HOLDS THE FENCE. The attestation - /// that the other database committed, and the only thing that licenses the settle. fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -407,7 +336,6 @@ impl MailStore for Coordinator { ) } - /// `realm_mail_settle` — step 4, on the handle that holds the fence: destroy it. Delete-last. fn mail_settle(&self, escrow_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -416,17 +344,6 @@ impl MailStore for Coordinator { ) } - /// Every mail escrow this database is holding for `sender_guid` — the fences a drive filed and - /// never finished. - /// - /// The one module→gateway data flow the escrow adds, and it exists for the same reason - /// `escrowed_transfer` does: the gateway is the only component that can see both databases, so - /// re-driving a stalled fence means re-deriving the whole letter from its row. A Character's fresh - /// send reads nothing more than its own fence; a Reward Letter, which the Module files at - /// turn-in, is always driven from its row. Private table, read through the owner token. - /// - /// Keyed by `sender_guid`, which on a payout escrow is the PAYEE — the character owed the - /// copper either way, and the one whose session is about to re-drive it. fn mail_escrows_of(&self, sender_guid: u64) -> Result> { let guard = self.0.coord(); let ids = guard.mail_escrows.read().unwrap().of(sender_guid); @@ -435,8 +352,6 @@ impl MailStore for Coordinator { .into_iter() .filter_map(|id| escrows.escrow_id().find(&id)) .filter_map(|e| { - // A row no letter could have written stays held rather than commit as the wrong - // letter. let reward = lyracore_shared::mail::RewardHeader::from_columns( e.sender_kind, e.sender_entry, @@ -483,9 +398,6 @@ fn mail_view(db: &RemoteTables, m: Mail) -> crate::codec::MailView { item_entry: m.item_entry, item_stack_count: m.item_stack_count, item_durability: m.item_durability, - // The row only ever snapshots CURRENT durability (mail.rs's `ItemSnapshot`); the - // true max lives on the attachment's own template, the same read `player_items` - // joins for. 0 for no attachment — `entry().find(0)` finds nothing. max_durability: db .game_item_template() .entry() @@ -503,9 +415,6 @@ fn mail_view(db: &RemoteTables, m: Mail) -> crate::codec::MailView { sender_entry: m.sender_entry, check_flags: m.check_flags, mail_template_id: m.mail_template_id, - // Rounded up to the second. The Gateway compares it with its own clock, which can run - // ahead of the Module's, so the Module Gates mark-read, delete, every take, the return - // and a COD payment against its own clock again. deliver_secs: m .deliver_micros .saturating_add(999_999) diff --git a/gateway/src/stdb/store/meeting_stone.rs b/gateway/src/stdb/store/meeting_stone.rs index cc3392219..31bfb4f3e 100644 --- a/gateway/src/stdb/store/meeting_stone.rs +++ b/gateway/src/stdb/store/meeting_stone.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`MeetingStoneActionStore`] adapter. - use anyhow::Result; use lyracore_shared::constants::go_type; use lyracore_shared::meeting_stone::MeetingStoneRefusal; @@ -10,7 +8,6 @@ use crate::stdb::Coordinator; use crate::world::{presence, Actor, MeetingStoneActionStore, MeetingStoneOutcome}; impl MeetingStoneActionStore for Coordinator { - /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. fn admit_meeting_stone(&self, actor: Actor, go_guid: u64) -> Result { meeting_stone_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -19,7 +16,6 @@ impl MeetingStoneActionStore for Coordinator { )) } - /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. fn meeting_stone_area(&self, go_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -57,8 +53,6 @@ impl MeetingStoneActionStore for Coordinator { ) } - /// `realm_meeting_stone_op`: run one Meeting Stone Queue op on Realm-core, or on the one - /// database of an unsharded Realm. The Module applies every queue rule. fn meeting_stone_op( &self, actor: Actor, @@ -82,7 +76,6 @@ impl MeetingStoneActionStore for Coordinator { )) } - /// The area `character_guid` is queued for, read from the party authority. fn queued_area(&self, character_guid: u64) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); diff --git a/gateway/src/stdb/store/melee.rs b/gateway/src/stdb/store/melee.rs index 51ff4ac98..d961e132b 100644 --- a/gateway/src/stdb/store/melee.rs +++ b/gateway/src/stdb/store/melee.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`MeleeActionStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::*; @@ -8,8 +6,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, MeleeActionStore}; impl MeleeActionStore for Coordinator { - /// Start the player's melee auto-attack on `target_guid` (`CMSG_ATTACKSWING`, combat C1) over - /// the coordinator connection so the module attributes the swing to the caller. fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -19,7 +15,6 @@ impl MeleeActionStore for Coordinator { ) } - /// Stop the player's melee auto-attack (`CMSG_ATTACKSTOP`, combat C1). fn stop_attack(&self, actor: Actor) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( diff --git a/gateway/src/stdb/store/member_stats.rs b/gateway/src/stdb/store/member_stats.rs index ac31db320..82c13daa4 100644 --- a/gateway/src/stdb/store/member_stats.rs +++ b/gateway/src/stdb/store/member_stats.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`MemberStatsStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -8,9 +6,6 @@ use crate::stdb::Coordinator; use crate::world::{MemberPresence, MemberStatsStore}; impl MemberStatsStore for Coordinator { - /// Every other member of `self_guid`'s group, from the party authority's membership index: - /// Realm-core on a sharded Realm, this database otherwise. A list longer than a Raid is a - /// damaged cache, not a group. fn group_mates(&self, self_guid: u64) -> Result> { let authority = if self.is_sharded() { self.realm_core()? @@ -34,8 +29,6 @@ impl MemberStatsStore for Coordinator { .unwrap_or_default()) } - /// Find `guid` for Member Stats from its Realm Presence, the same live-entity / in-transit / - /// absence-gated-offline decision every other realm-wide read shares. fn member_presence(&self, guid: u64) -> Result { use crate::world::presence::Whereabouts; Ok( @@ -54,14 +47,6 @@ impl MemberStatsStore for Coordinator { } impl Coordinator { - /// Overlay `guid`'s aura slots and live pet onto `entity` — the one piece of Member Stats a - /// generic Realm Presence read cannot supply, because it does not know about `ShardId` or - /// `WorldView`'s `AuraIndex`. Reads from the exact Shard named `shard_name`, the same one - /// `presence::of` already searched to find the live entity `entity` itself came from, keyed by - /// its position in `all_shards()` — the same `enumerate()` order `arm_shared_world_view` - /// assigned when it built the `AuraIndex`. A name this handle's own `all_shards()` no longer - /// lists (a Shard dropped between the two reads) leaves `entity` as `presence::of` built it, - /// pet-less and aura-less rather than wrong. fn with_member_shard_stats( &self, shard_name: &str, @@ -82,7 +67,6 @@ impl Coordinator { } } - /// `owner_guid`'s occupied `UNIT_FIELD_AURA` slots on `shard`, from `WorldView`'s `AuraIndex`. fn member_aura_slots( &self, shard: crate::stdb::world_index::ShardId, @@ -99,15 +83,6 @@ impl Coordinator { .collect() } - /// `owner_guid`'s live pet, resolved the way the pet bar resolves it: a Hunter's name comes - /// from `game_hunter_pet_protocol`, a summoned pet's from its creature template, and every - /// other field from the pet's own `game_world_entity` row (`pet_name`, `stdb/reads/pet.rs`). - /// - /// The pet guid is deterministic (`lyracore_shared::pet::pet_guid_for`, the same derivation - /// the Module's `pet_of` uses), so this is one keyed lookup plus an owner check, never a scan. - /// `game_world_entity().iter()` clones every cached row while holding the client cache's own - /// lock, and this read runs for every live group mate on every Relay tick; a scan here would - /// contend with the shard's coordinator pump, the busiest callback in the process. fn member_pet( &self, shard: crate::stdb::world_index::ShardId, diff --git a/gateway/src/stdb/store/mod.rs b/gateway/src/stdb/store/mod.rs index 1dacd8f1c..1f851aaf4 100644 --- a/gateway/src/stdb/store/mod.rs +++ b/gateway/src/stdb/store/mod.rs @@ -1,11 +1,3 @@ -//! `Coordinator`'s Store family adapters, one file per Protocol Family. -//! -//! Each file holds the family's trait impl and the inherent methods only that family calls. -//! Inherent methods with other callers stay in `reads`, `reducers`, `subscriptions` and -//! `connection`. Rust method resolution prefers inherent methods over trait methods, so -//! `self.characters(..)` here calls the inherent `Coordinator::characters`, not the trait method: -//! these are thin views, not recursion. - use anyhow::{anyhow, Result}; use std::time::Duration; @@ -44,8 +36,6 @@ mod transfer; mod vendor; mod weather; -/// Wait up to two seconds for a row a value-flow reducer just committed to reach this handle's -/// cache. fn wait_for_cache_row( operation_id: u64, row_name: &str, @@ -62,7 +52,6 @@ fn wait_for_cache_row( )) } -/// A random nonzero operation id for one auction or guild fee value flow. fn next_operation_id() -> Result { loop { let mut bytes = [0; 8]; @@ -75,7 +64,6 @@ fn next_operation_id() -> Result { } } -/// A Character request whose Refusal the client reads as a system message. fn interaction_outcome(result: Result<()>) -> Result { match result { Ok(()) => Ok(InteractionOutcome::Done), diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs index ce2d68791..a2f52f039 100644 --- a/gateway/src/stdb/store/npc.rs +++ b/gateway/src/stdb/store/npc.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`NpcStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -13,7 +11,6 @@ use crate::world::{Actor, InteractionOutcome, NpcStore}; use super::interaction_outcome; impl NpcStore for Coordinator { - /// Standing-derived reaction gate. The inherent read has other callers (vendor, guild, quest). fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result { Coordinator::npc_refuses_interaction(self, npc_guid, player_guid) } @@ -22,8 +19,6 @@ impl NpcStore for Coordinator { Coordinator::gameobject_template(self, entry) } - /// Resolve the active title text for one NPC, with a per-creature menu override before the - /// entry-owned default menu. fn npc_gossip_text_id(&self, npc_guid: u64) -> u32 { let guard = self.0.coord(); let db = &guard.conn.db; @@ -42,18 +37,6 @@ impl NpcStore for Coordinator { } } - /// Look up the full weighted greeting for a `text_id`: `game_npc_text` (slot 0 - /// male, back-compat) + any `game_npc_text_slot` rows for the same id. Returns `None` when the - /// text_id has no `game_npc_text` row at all (the gateway falls back to the generic greeting). - /// - /// Back-compat normalization: a `text_id` with NO `game_npc_text_slot` rows (never imported by the - /// weighted-greeting importer — either a pre-existing row, or the legacy `npc_text[entry]` - /// fallback path, which only ever populates `game_npc_text`) is read as slot 0 = `(text, text, - /// 1.0)` and every other slot silent, matching the old single-slot behavior byte-for-byte. When - /// slot rows DO exist, they - /// are used verbatim (real per-slot probabilities from the dump) and slot 0's base-row `text` is - /// NOT separately re-applied (the slot-0 row, always emitted by the importer alongside the others, - /// is the source of truth once any slot row exists). fn npc_text_for_id(&self, text_id: u32) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; @@ -68,7 +51,7 @@ impl NpcStore for Coordinator { view.slots[0] = (base.text.clone(), base.text, 1.0); return Some(view); } - slot_rows.sort_by_key(|s| s.slot_index); // stable slot order (SQL has no ORDER BY in 2.5) + slot_rows.sort_by_key(|s| s.slot_index); let mut view = crate::codec::NpcTextView::default(); for row in slot_rows { if (row.slot_index as usize) < 8 { @@ -79,8 +62,6 @@ impl NpcStore for Coordinator { Some(view) } - /// Return the active imported options for one NPC. A live per-creature override replaces the - /// entry-owned default options. The dispatcher applies conditions at both HELLO and SELECT. fn gossip_options(&self, npc_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -136,8 +117,6 @@ impl NpcStore for Coordinator { .collect()) } - /// Does the NPC at `guid` carry the innkeeper flag? Shows its gossip option. Reads `npc_flags` - /// off the entity (privileged cache); absent → false. fn npc_is_innkeeper(&self, guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; @@ -148,9 +127,6 @@ impl NpcStore for Coordinator { .is_some_and(|e| e.npc_flags & lyracore_shared::constants::npc_flags::INNKEEPER != 0)) } - /// Resolve a live pet visible to the world. Pet names are public unit presentation: Hunter - /// names come from the bounded durable projection, while summoned-pet names remain authored - /// creature-template data. The requester must be in world, but need not own the observed pet. fn pet_name( &self, _requester: Actor, @@ -188,7 +164,6 @@ impl NpcStore for Coordinator { Ok(resolve_pet_name(pet_number, pet_guid, None, summoned)) } - /// Read a creature template by entry for a `CMSG_CREATURE_QUERY` reply (Tier 2 / NPCs). fn creature_template(&self, entry: u32) -> Result> { Ok(self .0 @@ -210,12 +185,6 @@ impl NpcStore for Coordinator { })) } - /// The `type_id` of a SPAWNED gameobject, by its live guid (join `game_gameobject` → - /// `game_gameobject_template`). Feeds the `CMSG_GAMEOBJ_USE` dispatch: a - /// `lyracore_shared::constants::go_type::QUESTGIVER` GO (the Wanted Poster, the Lost Guards corpses) - /// opens the quest window instead of rolling loot / toggling state — that is what a questgiver - /// gameobject does in vanilla. `None` for an unspawned/unknown guid (the caller falls back to the - /// ordinary use-reducer path, which itself no-ops on an unknown guid). fn gameobject_type(&self, go_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -231,9 +200,6 @@ impl NpcStore for Coordinator { .map(|t| t.type_id)) } - /// Validate a `CMSG_INSPECT` request (target is a real in-world player, on the caller's map, in - /// range, friendly) over the coordinator connection so the module resolves the caller from - /// `ctx.sender`. `Err` (out of range / hostile / no such target) → the caller ignores it. fn inspect(&self, actor: Actor, target_guid: u64) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -243,7 +209,6 @@ impl NpcStore for Coordinator { ) } - /// Enter an area trigger (`CMSG_AREATRIGGER`) — credit any active explore quest tied to `trigger_id`. fn enter_areatrigger(&self, actor: Actor, trigger_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -253,8 +218,6 @@ impl NpcStore for Coordinator { ) } - /// `CMSG_GOSSIP_SELECT_OPTION` — the NOTIFY-ONLY module chokepoint. Fired - /// best-effort BEFORE the gateway's own gossip behavior; a failure never blocks the reply. fn gossip_select( &self, actor: Actor, @@ -275,7 +238,6 @@ impl NpcStore for Coordinator { ) } - /// Bind the Character's home after the Module checks the selected innkeeper. fn bind_home(&self, actor: Actor, innkeeper_guid: u64) -> Result { let coord = self.0.call_pipe(); interaction_outcome(call_reducer!( diff --git a/gateway/src/stdb/store/party.rs b/gateway/src/stdb/store/party.rs index 829996293..1fb595cf9 100644 --- a/gateway/src/stdb/store/party.rs +++ b/gateway/src/stdb/store/party.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`PartyStore`] adapter. - use anyhow::{anyhow, Result}; use lyracore_shared::group::{GroupKind, GroupRefusal, RaidSlot}; use spacetimedb_sdk::{Identity, Table}; @@ -13,7 +11,6 @@ use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, Par use crate::world::{Actor, PartyStore, ShardRoutingStore}; impl PartyStore for Coordinator { - /// Claim and admit a Group Intent in one World Shard transaction. fn claim_bot_invite_intent(&self, intent_id: u64) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -147,7 +144,6 @@ impl PartyStore for Coordinator { .map(|entity| (entity.map_id, entity.instance_id)) } - /// Wait for the owning World Shard's current consent Gate, without subscription readback. fn admit_sessionless_group_action(&self, character_guid: u64) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -156,18 +152,6 @@ impl PartyStore for Coordinator { )) } - /// `realm_group_op` — one party op against the database THIS handle points at. The gateway - /// calls it on the **realm-core** handle, where membership is authoritative. - /// - /// Through an operator connection, not the player's: the reducer is operator-gated because it - /// takes the acting character's guid as an argument (realm-core has no live entity to derive - /// one from), so only the token that holds the operator identity may call it. The guid passed is - /// the one this socket authenticated into the world with — see `world::party`. - /// - /// This form rides a call pipe, which subscribes no group table, so the Coordinator cache may - /// still hold the old roster when it returns. Only an op that pushes no Group mirror after it - /// uses it: a Group Broadcast or a Target Icon request. Every roster op uses - /// [`Self::realm_group_op_visible`]. fn realm_group_op( &self, op: u8, @@ -191,9 +175,7 @@ impl PartyStore for Coordinator { )) } - /// [`Self::realm_group_op`] on the visibility pipe: it returns only after the Coordinator cache - /// holds the committed roster, so the mirror push that follows reads the op's own result. The - /// caller must not run on the Coordinator pump: World Sessions and the bot intent threads. + /// Wait off-pump for the committed roster before pushing the Group mirror. fn realm_group_op_visible( &self, op: u8, @@ -218,8 +200,6 @@ impl PartyStore for Coordinator { )) } - /// Remove a deleted Character from a party and return only after the Coordinator cache holds - /// the committed Realm-core roster. The caller always runs off the Coordinator pump. fn deleted_character_party_leave(&self, character: Actor) -> Result { let coordinator = self.0.visibility_pipe(); party_outcome(call_reducer!( @@ -243,8 +223,6 @@ impl PartyStore for Coordinator { Ok(self.group_roster(character_guid)) } - /// Read only the bounded roster projection accepted by companion-command authority. The bound - /// is the Raid cap: a longer list is a damaged cache. fn party_command_group_roster( &self, character_guid: u64, @@ -299,7 +277,6 @@ impl PartyStore for Coordinator { Ok(self.group_roster_by_id(group_id)) } - /// Realm-core's roster order survives disband in `game_group_roster_revision`. fn group_roster_revision(&self, group_id: u64) -> Result { Ok(self .0 @@ -411,9 +388,6 @@ impl PartyStore for Coordinator { .collect()) } - /// `sync_group_mirror` — replace THIS shard's mirror of one party with realm-core's roster. - /// Operator-gated, coordinator connection, same reasoning as above; called - /// on each WORLD shard after a party op and at world entry. fn sync_group_mirror(&self, roster: &crate::world::party::GroupRoster) -> Result<()> { let partitions = roster .partitions @@ -460,7 +434,6 @@ impl PartyStore for Coordinator { ) } - /// `CMSG_GROUP_INVITE` — `target_guid` is already resolved by the gateway. fn group_invite(&self, actor: Actor, target_guid: u64) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -469,7 +442,6 @@ impl PartyStore for Coordinator { )) } - /// `CMSG_GROUP_ACCEPT`. Rides the coordinator connection as `gw_accept_group_invite`. fn group_accept(&self, actor: Actor) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -486,7 +458,6 @@ impl PartyStore for Coordinator { )) } - /// `CMSG_GROUP_DISBAND` — leave the caller's group. fn group_leave(&self, actor: Actor) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -495,8 +466,6 @@ impl PartyStore for Coordinator { )) } - /// `CMSG_GROUP_UNINVITE` or `CMSG_GROUP_UNINVITE_GUID` — the leader or an Assistant kicks - /// `target_guid`. fn group_uninvite(&self, actor: Actor, target_guid: u64) -> Result { party_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -505,10 +474,6 @@ impl PartyStore for Coordinator { )) } - /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/threshold/master. Echoed to - /// every member via the existing `SMSG_GROUP_LIST` relay (the module's `group_loot_method` - /// reducer re-renders the roster payload); no separate ack packet (vanilla sends none for this - /// opcode either). fn group_loot_method( &self, actor: Actor, @@ -530,8 +495,6 @@ impl PartyStore for Coordinator { } impl Coordinator { - /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a - /// World Shard this is the mirror's revision, which outlives a disband. pub fn held_roster_revision(&self, group_id: u64) -> Option { self.0 .coord() @@ -544,7 +507,6 @@ impl Coordinator { } } -/// The Module's Refusal tag. A Transport Loss carries none, so its outcome stays unknown. fn refusal_tag(error: &anyhow::Error) -> Option<&str> { match classify(error) { DurableFailure::Refusal { reason } => Some(reason), @@ -601,8 +563,6 @@ fn command_outcome_binding( } } -/// A tagged party Refusal is an outcome the client renders; an untagged one or a Transport Loss -/// stays `Err` and ends the session. fn party_outcome(result: Result<()>) -> Result { let Err(error) = result else { return Ok(PartyOutcome::Ran); diff --git a/gateway/src/stdb/store/quest.rs b/gateway/src/stdb/store/quest.rs index b08b63029..7ef0a1bbd 100644 --- a/gateway/src/stdb/store/quest.rs +++ b/gateway/src/stdb/store/quest.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`QuestActionStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -10,11 +8,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, QuestActionStore}; impl QuestActionStore for Coordinator { - /// Item-starts-quest: does the item in `owner_guid`'s inventory `slot` carry a - /// non-zero `item_template.start_quest`? Returns `(item_guid, quest_id)` if so — the gateway's - /// `CMSG_USE_ITEM` handler uses this to open `SMSG_QUESTGIVER_QUEST_DETAILS` (item guid as giver) - /// INSTEAD of calling `use_item` (never both — the item isn't consumed). `None` for every ordinary - /// item (the ubiquitous case), which falls through to the normal use-item path unchanged. fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -32,10 +25,6 @@ impl QuestActionStore for Coordinator { hit } - /// `(taken, rewarded)` for `quest_id` in `guid`'s quest log (privileged read — RLS bypassed, like - /// `giver_quest_evals`) — feeds `option_condition_holds` for the QUEST_TAKEN/QUEST_REWARDED gossip - /// option conditions. `taken` is true whenever a log row exists at all (accepted, - /// whether or not yet turned in); a quest never seen by this player is `(false, false)`. fn quest_status(&self, guid: u64, quest_id: u32) -> (bool, bool) { let guard = self.0.coord(); let db = &guard.conn.db; @@ -50,20 +39,6 @@ impl QuestActionStore for Coordinator { } } - /// Evaluate every quest a creature OR gameobject `giver_guid` starts/ends against `player_guid`, - /// for the overhead status icon (`SMSG_QUESTGIVER_STATUS`) + the quest menu - /// (`SMSG_QUESTGIVER_QUEST_LIST`). The giver is resolved EXACTLY like the module's - /// `quest::validate_giver` fallback: a live `game_world_entity` first (a - /// creature), else a spawned `game_gameobject` (GO 68 "Wanted Poster" starts q176 with NO creature - /// giver at all; GO 55/56 "Lost Guards" corpses drive the q37/q45/q71 END chain) — never both, and - /// never a live player (this reader doesn't special-case party-share givers; a - /// player guid here matches the entity lookup (players share the table) but resolves to Giver::Creature(0) — a player entry is always 0, and no game_creature_quest row keys entry 0, so the result is empty, same as before). For each relation - /// on the giver's entry (`game_creature_quest` or `game_gameobject_quest`): join its - /// `game_quest_template`, look the quest up in the player's `game_character_quest` log (the - /// coordinator bypasses RLS so it reads any player's log), and compute `startable` (a fresh, - /// level-qualifying START), `active` (held + un-rewarded END), and `complete` (active + every - /// objective's count met). One guard, all inline — no nested cache locks. The codec - /// ([`crate::codec::quest_giver_status`]) folds these. fn giver_quest_evals( &self, giver_guid: u64, @@ -82,9 +57,6 @@ impl QuestActionStore for Coordinator { } else { return Ok(Vec::new()); }; - // No live player → no status (don't fabricate a level-0 phantom). A DEAD player (ghost) gets no - // status either: the module rejects both accept AND turn-in while dead, so showing a `!`/`?` the - // reducer would then reject is exactly the icon-vs-reducer mismatch we forbid. let Some(player) = db.game_world_entity().guid().find(&player_guid) else { return Ok(Vec::new()); }; @@ -92,10 +64,8 @@ impl QuestActionStore for Coordinator { return Ok(Vec::new()); } let player_level = player.level; - // Race/class packed in unit_bytes_0 (race | class<<8 | …), for the gates. let race = (player.unit_bytes_0 & 0xFF) as u8; let class = ((player.unit_bytes_0 >> 8) & 0xFF) as u8; - // The player's quest log, guid-keyed (privileged read — RLS bypassed). let log: Vec<_> = db .game_character_quest() .iter() @@ -140,11 +110,6 @@ impl QuestActionStore for Coordinator { Ok(evals) } - /// Build the detail view for one quest (the accept screen, the offer-reward screen, and the - /// completion popup all read this): title, money, the RESOLVED XP reward (explicit, else the shared - /// `lyracore_shared::quest::xp_reward` so it matches the module's grant), reward items joined with their - /// display ids, and a synthesized objectives line per kill objective ("Creature slain: 0/N" from the - /// creature-template name — quest body text isn't imported yet). `None` if the quest isn't loaded. fn quest_detail_view(&self, quest_id: u32) -> Result> { let guard = self.0.coord(); Ok(quest_detail_view(&guard.conn.db, quest_id)) @@ -154,10 +119,6 @@ impl QuestActionStore for Coordinator { Coordinator::npc_refuses_interaction(self, giver_guid, player_guid) } - /// The player's active (un-rewarded) quests as quest-log descriptor slots: the quest-log window. - /// Deterministic slot assignment (sorted by quest_entry → slot 0..), capped at the 20 vanilla slots; - /// each slot carries the quest id, per-objective counts, and a state byte (1 = all objectives met, - /// else 0). The gateway encodes these into the `PLAYER_QUEST_LOG_*` fields. RLS-bypassed read. fn player_quest_log( &self, player_guid: u64, @@ -168,8 +129,6 @@ impl QuestActionStore for Coordinator { Ok(build_quest_log_slots(db, &quests, player_guid)) } - /// `CMSG_PUSHQUESTTOPARTY` — over the coordinator connection so the module - /// attributes the sender + its grouped/on-quest gates to the caller. fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -179,10 +138,6 @@ impl QuestActionStore for Coordinator { ) } - /// Accept quest `quest_id` from giver `giver_guid` (`CMSG_QUESTGIVER_ACCEPT_QUEST`) over the - /// coordinator connection so the module attributes it to the caller. The module gates the accept - /// (giver relation + range + level + not-already-held); a gameplay `Err` is per-action, not fatal. - /// Rides the coordinator connection as `gw_accept_quest`. fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -192,10 +147,6 @@ impl QuestActionStore for Coordinator { ) } - /// Turn quest `quest_id` in to giver `giver_guid` (`CMSG_QUESTGIVER_CHOOSE_REWARD`). The module - /// validates completion + grants the rewards (money/XP/items). `reward_index` is the player's - /// pick-1-of-N choice slot; ignored when the quest has no choices. This call uses the subscribed - /// visibility pipe so committed item relays are queued before success presentation is allowed. fn turn_in_quest( &self, actor: Actor, @@ -216,8 +167,6 @@ impl QuestActionStore for Coordinator { ) } - /// Abandon quest `quest_id` (`CMSG_QUESTLOG_REMOVE_QUEST`) over the coordinator connection. The - /// module deletes the player's quest-log row; the quest-log relay then clears the slot. fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()> { let coord = self.0.call_pipe(); call_reducer!( @@ -228,12 +177,6 @@ impl QuestActionStore for Coordinator { } } -/// Build one quest's `QuestDetailView` off `db` (any connection's cache carrying the public quest -/// tables — the privileged coordinator cache via `Coordinator::quest_detail`, OR a per-player -/// subscription's own cache when it's subscribed to them too). `None` if the quest isn't loaded. -/// Extracted from `Coordinator::quest_detail` so the QUEST_SHARE group-event relay -/// (`subscriptions.rs`) can build the SAME view over a cloned `Coordinator` handle without -/// duplicating this join logic — one chokepoint, not two copies to keep in sync. pub(crate) fn quest_detail_view( db: &RemoteTables, quest_id: u32, @@ -245,7 +188,6 @@ pub(crate) fn quest_detail_view( tmpl.reward_money_max_level, tmpl.quest_level, ); - // Body text from the side table (cmangos Details/Objectives/OfferReward/RequestItems). let text = db.game_quest_text().quest_entry().find(&quest_id); let details = text.as_ref().map(|t| t.details.clone()).unwrap_or_default(); let offer_reward_text = text @@ -257,11 +199,6 @@ pub(crate) fn quest_detail_view( .map(|t| t.request_items_text.clone()) .unwrap_or_default(); let imported_objectives = text.map(|t| t.objectives).unwrap_or_default(); - // All objectives in obj_index order → wire (creature_or_go, count, req_item, req_item_count): - // KILL_CREATURE(0): (+creature_entry, count, 0, 0) - // COLLECT_ITEM(1): (0, 0, item_entry, count) - // USE_GAMEOBJECT(2): (negative go_entry as u32, count, 0, 0) — client checks sign to tell apart - // EXPLORE_AREATRIGGER(3): no wire slot (area triggers have no entry in the objectives array) let mut raw_objs: Vec<(u8, u32, u32, u32, u32)> = db .game_quest_objective() .iter() @@ -271,7 +208,7 @@ pub(crate) fn quest_detail_view( 0 => (o.target_entry, o.required_count, 0u32, 0u32), 1 => (0, 0, o.target_entry, o.required_count), 2 => ((-(o.target_entry as i32)) as u32, o.required_count, 0, 0), - _ => return None, // EXPLORE_AREATRIGGER and unknown: no wire slot + _ => return None, }; Some((o.obj_index, slot.0, slot.1, slot.2, slot.3)) }) @@ -288,8 +225,6 @@ pub(crate) fn quest_detail_view( request_items_text, money_reward: tmpl.reward_money, reward_xp, - // Threaded from the template — chains (successor auto-offer) + the level-cap - // payout preview (always computed; the client only surfaces it when the VIEWER is capped). next_quest_id: tmpl.next_quest_id, max_level_money_reward: lyracore_shared::quest::max_level_money_reward(reward_xp), rewards: quest_rewards(db, quest_id), @@ -301,20 +236,7 @@ pub(crate) fn quest_detail_view( }) } -// One argument per column of the condition row being evaluated; the SDK row type is not in scope on this side of the read. #[allow(clippy::too_many_arguments)] -/// Evaluate ONE giver relation (a `game_creature_quest` OR `game_gameobject_quest` row, already -/// filtered to the giver's entry by the caller) against `player_guid`'s quest log. Takes the bare -/// `quest_entry`/`role` rather than a table row type — `game_creature_quest` and `game_gameobject_quest` -/// have the identical `(quest_entry, role)` shape, so `giver_quest_evals` calls this -/// same function for both, keyed only on which relation table it iterated. -/// -/// PARITY: `startable` answers the same question as the Module's `quest::accept_gates` -/// (`module/src/quest.rs`) — level, race, class, prerequisite chain, not already held — and the two -/// must agree, else the `!` icon offers a quest the accept reducer refuses. It stays a second copy -/// because it reads the Gateway's own subscription-cache row types and cannot call Module code; the -/// mask functions in `lyracore_shared::quest` are the part both tiers do share. A Gate changed in -/// `accept_gates` has to be changed here as well. fn eval_relation( db: &RemoteTables, quest_entry: u32, @@ -362,7 +284,6 @@ fn eval_relation( }) } -/// A quest's guaranteed reward items joined with each item's `display_id` (0 if the template isn't loaded). fn quest_rewards(db: &RemoteTables, quest_id: u32) -> Vec { db.game_quest_reward_item() .iter() @@ -383,9 +304,6 @@ fn quest_rewards(db: &RemoteTables, quest_id: u32) -> Vec Vec { let mut rows: Vec<_> = db .game_quest_reward_choice() @@ -410,8 +328,6 @@ fn quest_choice_rewards(db: &RemoteTables, quest_id: u32) -> Vec String { if !imported.trim().is_empty() { return imported; @@ -423,7 +339,6 @@ fn synthesized_objectives(db: &RemoteTables, quest_id: u32, imported: String) -> .map(|o| { let text = match o.kind { 0 => { - // KILL_CREATURE let name = db .game_creature_template() .entry() @@ -433,7 +348,6 @@ fn synthesized_objectives(db: &RemoteTables, quest_id: u32, imported: String) -> format!("{name} slain: 0/{}", o.required_count) } 1 => { - // COLLECT_ITEM let name = db .game_item_template() .entry() @@ -442,7 +356,7 @@ fn synthesized_objectives(db: &RemoteTables, quest_id: u32, imported: String) -> .unwrap_or_default(); format!("{name}: 0/{}", o.required_count) } - _ => String::new(), // USE_GAMEOBJECT / EXPLORE: cmangos Objectives text covers these + _ => String::new(), }; (o.obj_index, text) }) diff --git a/gateway/src/stdb/store/session.rs b/gateway/src/stdb/store/session.rs index f485926dd..cccfe07c0 100644 --- a/gateway/src/stdb/store/session.rs +++ b/gateway/src/stdb/store/session.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`SessionStore`] adapter. - use std::time::Duration; use anyhow::{anyhow, Result}; @@ -18,19 +16,12 @@ impl SessionStore for Coordinator { crate::realm_core::lookup_session(self, account_name) } - /// Enter the world: call the `player_login` reducer on the coordinator connection - /// (so `ctx.sender` is the player's bound identity), then read the resulting - /// `game_world_entity` row back through the privileged cache as an `EntityView`. fn player_login( &self, account_id: u64, character: Actor, entry: codec::WorldEntry, ) -> Result { - // Login rides `gw_player_login` on the COORDINATOR connection (module half: delegates to - // apply_player_login with the account's bound identity as row owner, binds entity→lease, - // fail-closed on either missing) — no per-player connection exists anywhere. A world-port - // rides its twin `gw_player_world_port`, which keeps the Away Status. let coord = self.0.call_pipe(); let actor = self.session_actor(character); match entry { @@ -46,14 +37,7 @@ impl SessionStore for Coordinator { )?, } - // The reducer committed; the row propagates to the owner cache asynchronously. Poll - // briefly until it appears (home_* ride along from the game_character row, and its - // zone_id is the fallback for a live row the Module could not resolve a zone for). let character_guid = character.guid(); - // 15 s cap, 15 ms steps. Was 3 s — the cold-1000 measurement showed the reducer - // COMMITTING while the coordinator stream lagged the login-burst tail past 3 s (writer at - // 34.5%, so pure propagation, not CPU): 67/1000 logins died here with the entity already - // live. The poll exits on first sight, so the longer cap costs nothing outside a burst. for _ in 0..1000 { if let Some(e) = self .0 @@ -94,9 +78,6 @@ impl SessionStore for Coordinator { } fn movement_update(&self, actor: Actor, opcode: u32, info: &MovementInfo) -> Result<()> { - // Carry the MovementInfo verbatim so the module can relay it to in-range observers under - // the same opcode. Queueing is the only completion observable on the shared batch; - // individual reducer outcomes are intentionally unavailable. let body = codec::movement_info_to_bytes(info)?; self.0.motion_batch.push(GwMove { actor: self.session_actor(actor), @@ -122,7 +103,6 @@ impl SessionStore for Coordinator { self.subscribe_player_events(account_id, self_guid, arrival, tx) } - /// Forward an addon-bridge command to the module's `client_command` dispatch. fn client_command(&self, actor: Actor, cmd: String, payload: String) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -131,9 +111,6 @@ impl SessionStore for Coordinator { ) } - /// Undelivered private System Messages addressed to `self_guid`, oldest-first, from the - /// shard's `game_system_message_event` cache. Rows live until the module's event GC reaps - /// them, so a message emitted inside `player_login` is still here at world entry. fn pending_system_messages(&self, self_guid: u64) -> Vec { let mut rows: Vec<(u64, String)> = self .0 @@ -160,7 +137,6 @@ impl SessionStore for Coordinator { .is_some() } - /// Read from the privileged cache; the module applies the authoritative fall damage itself. fn entity_max_health(&self, guid: u64) -> u32 { self.0 .coord() diff --git a/gateway/src/stdb/store/shard_routing.rs b/gateway/src/stdb/store/shard_routing.rs index cd9a38e17..59e9ed66c 100644 --- a/gateway/src/stdb/store/shard_routing.rs +++ b/gateway/src/stdb/store/shard_routing.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`ShardRoutingStore`] adapter. - use anyhow::Result; use crate::stdb::Coordinator; @@ -7,16 +5,9 @@ use crate::world::{ShardRoutingStore, WorldStore}; impl ShardRoutingStore for Coordinator { fn shard_name(&self) -> &str { - // Inherent method (see the module note): this is a view, not recursion. self.shard_name() } - /// The production world-entry resolver: find where the character actually LIVES, resolve who - /// should own it, and run the escrowed transfer when those differ. Single-shard → the - /// `is_sharded` short-circuit, so the unconfigured gateway does not even read a row. - /// - /// The HOLDER lookup below is `realm_core::locate_home_shard` — the realm-core - /// index consulted FIRST, the shard scan only on a miss, self-heal write-back on either path. fn settle_home_shard( &self, character_guid: u64, @@ -24,50 +15,22 @@ impl ShardRoutingStore for Coordinator { if !self.is_sharded() { return Ok(None); } - // The shard that currently holds the durable row (or the in-flight escrow). Realm-core's - // character→shard index is consulted first; the scan (`Coordinator::all_shards`) is the - // fallback, not the only path. let Some(holder) = crate::realm_core::locate_home_shard(self, character_guid) else { - // Unknown character: leave the session where it is. The - // module's own ownership check is what refuses the login. return Ok(None); }; - // The ESCROW's destination outranks the durable row's location: `settle_transfer` RESUMES - // an existing escrow against the destination the escrow was opened for, so an owner - // resolved from anything else would hand `run_transfer` a `dst` that is not the database the - // blob is already filed against — importing the character into a third shard and orphaning - // the fenced copy. `run_transfer` cannot catch that for itself: a `&dyn WorldStore` cannot be - // asked which `(map, instance)` it owns, so the obligation is here, at the resolution site. - // With no escrow (the fresh-transfer path) this is the durable row, exactly as before — the - // same fields `character_destination` builds the fresh plan from. + // Resume the Escrow's recorded destination, which outranks the Character's stored location. let escrow = holder.escrow_row(character_guid); let (map_id, instance_id) = escrow .as_ref() .map(|r| (r.dest_map_id, r.dest_instance_id)) .or_else(|| holder.character_location(character_guid)) .ok_or_else(|| anyhow::anyhow!("character {character_guid} vanished mid-routing"))?; - // OWNER RESOLUTION. The shard map answers, in DATABASE NAMES resolved through - // `shard_for`/`instance_shard_for`, so "the answer names the shard I already am" decodes to - // `None` in both, and `self` is the handle that means "stay put". The NAME never depends on - // which handle asked (`shard_for` and `world_shards` both read the shared `ShardSet`), so - // this replaces the older "resolve from the default handle" without reintroducing - // `realm_shard()`, which realm-core deleted. - // - // The instance-pool stickiness applies ONLY when there is no escrow in flight. With an - // escrow the destination is already fixed by the row (see the paragraph above): being - // sticky about the holder there would resolve an owner that differs from the database the - // blob is filed against, which is the third-shard hazard this comment block exists to - // prevent. Without one, the holder is the only durable evidence of which pool member a - // live run is on, and it is what stops a pool resize from forking a party. let owner = if escrow.is_none() { self.instance_shard_for(map_id, instance_id, holder.shard_name()) } else { self.shard_for(map_id, instance_id) } .unwrap_or_else(|| self.clone()); - // The three facts that decide whether a resume is DRIVEN or silently skipped. Nothing logged - // them, so every diagnosis of a stranded copy (twice, live) had to infer the holder from what - // did not happen — and `settle_transfer`'s `holder == owner` branch is silent by design. log::info!( "settle {character_guid}: holder={} owner={} escrow={} ({map_id}/{instance_id})", holder.shard_name(), @@ -80,9 +43,6 @@ impl ShardRoutingStore for Coordinator { )) } - /// `instance_shard_for`, not `shard_for`: this handle is the character's HOLDER, and the - /// stickiness rule that keeps a live dungeon run on its pool member is written against exactly - /// that pair. `settle_home_shard` above resolves a fresh (escrow-free) hop the same way. fn shard_for_location( &self, map_id: u32, @@ -93,16 +53,11 @@ impl ShardRoutingStore for Coordinator { } fn bind_shard_session(&self, account_id: u64, session_key: &[u8; 40]) -> Result<()> { - // `bound_identity` DERIVES this shard's identity for the account - // (`synthetic_owner_identity`); `establish_session` then binds it to the shadow account - // row that `import_character_blob` created. On the realm shard this is the same binding - // the logon tier already wrote, re-asserted — idempotent, and cheap next to a login. let identity = self.bound_identity(account_id)?; self.establish_session(account_id, session_key, identity) } fn realm_store(&self) -> Result>> { - // A configured Realm-core may have failed its initial connection. if !self.is_sharded() && self.shard_map().realm_core_db().is_none() { return Ok(None); } @@ -110,8 +65,6 @@ impl ShardRoutingStore for Coordinator { .map(|realm| Some(std::sync::Arc::new(realm) as std::sync::Arc)) } - /// Every connected WORLD shard (realm-core excluded by `ShardMap::shards`, as always) — the - /// mirror fan-out set. Empty when unsharded, so the push costs a single-database gateway nothing. fn world_stores(&self) -> Vec> { if !self.is_sharded() { return Vec::new(); diff --git a/gateway/src/stdb/store/social.rs b/gateway/src/stdb/store/social.rs index 2dbf31637..4aabb7565 100644 --- a/gateway/src/stdb/store/social.rs +++ b/gateway/src/stdb/store/social.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`SocialStore`] adapter. - use anyhow::Result; use lyracore_shared::social::ContactRefusal; use spacetimedb_sdk::Table; @@ -11,8 +9,6 @@ use crate::stdb::{classify, DurableFailure}; use crate::world::{Actor, ContactOutcome, SocialStore}; impl SocialStore for Coordinator { - /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if - /// this Shard holds no `game_character` row for it. fn character_identity( &self, guid: u64, @@ -36,9 +32,6 @@ impl SocialStore for Coordinator { })) } - /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns - /// [`crate::codec::MemberEntity`] carries, joined with nothing else: level and zone come - /// straight off the entity, current unlike the durable row. fn live_entity(&self, guid: u64) -> Option { let guard = self.0.coord(); let entity = guard.conn.db.game_world_entity().guid().find(&guid)?; @@ -54,15 +47,10 @@ impl SocialStore for Coordinator { y: entity.y, dead: entity.dead, player_flags: entity.player_flags, - // Member Stats' own aura/pet overlay (`Coordinator::with_member_shard_stats`) fills - // these afterward, keyed by the `ShardId` this generic, guid-only read cannot carry. ..Default::default() }) } - /// Does this Shard show `guid` between two places: its own Character row reading online with - /// no live entity here, or a Transfer Intent naming a session-less bot mid-crossing? The - /// Transfer Intent table is bounded by the Module's writer Gate, so the scan is short. fn character_in_transit(&self, guid: u64) -> bool { let guard = self.0.coord(); let db = &guard.conn.db; @@ -78,8 +66,6 @@ impl SocialStore for Coordinator { .any(|intent| intent.bot_guid == guid) } - /// The stored Auto-Reply for `guid` on this Shard: one primary-key read of the private - /// `game_character_away` cache. fn auto_reply_text(&self, guid: u64) -> Result> { Ok(self .0 @@ -97,11 +83,6 @@ impl SocialStore for Coordinator { Ok(()) } - /// Every in-world player Character on this Shard, for `CMSG_WHO → SMSG_WHO` - /// (`presence::in_world_characters`'s per-Shard input, replacing the former `online_players`). - /// Iterates `game_world_entity` for entries with `entry == 0` (player entities; creatures have a - /// non-zero entry), then joins each against `game_character`. The coordinator bypasses RLS so it - /// sees every player's entity regardless of the caller's scope. fn in_world_players(&self) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -109,7 +90,7 @@ impl SocialStore for Coordinator { let rows = db .game_world_entity() .iter() - .filter(|e| e.entry == 0) // players have entry == 0; creatures have a template entry + .filter(|e| e.entry == 0) .filter_map(|e| { let ch = db.game_character().guid().find(&e.guid)?; let away = crate::world::presence::away_from_player_flags(e.player_flags); @@ -125,7 +106,6 @@ impl SocialStore for Coordinator { y: e.y, dead: e.dead, player_flags: e.player_flags, - // `/who` does not read auras or a pet, so this bulk scan never fills them. ..Default::default() }; Some(crate::world::presence::RealmPresence { @@ -147,8 +127,6 @@ impl SocialStore for Coordinator { Ok(rows) } - /// `game_area.name` for `zone_id`, `/who`'s search-string match against a zone name — a static - /// catalogue, subscribed unconditionally. Empty when the catalogue holds no row for it. fn zone_name(&self, zone_id: u32) -> String { self.0 .coord() @@ -161,15 +139,6 @@ impl SocialStore for Coordinator { .unwrap_or_default() } - /// `owner_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`, per - /// [`crate::world::SocialStore::contact_lists`]. `owner_guid` is always the CALLING - /// World Session's own guid, which is always live and registered right now, so this reads the - /// guids off its Gateway-side `Viewer` — the friend and ignore sets `world_view`'s contact - /// Relay already keeps current — instead of scanning `game_character_contact`. `None` Viewer - /// (should not happen for this caller) degrades to two empty lists rather than erroring. - /// Presence composition (online/team/Away Status) lives over the Store seam in - /// `world::social::friend_views`, not here, so a Store Fake exercises the same code a - /// Coordinator does. fn contact_lists(&self, owner_guid: u64) -> Result<(Vec, Vec)> { let Some(viewer) = self .world_view() @@ -180,22 +149,12 @@ impl SocialStore for Coordinator { Ok((viewer.friend_guids(), viewer.ignored_guids())) } - /// `owner_guid`'s ignore guids from this Shard's [`ContactIndex`], realm-wide safe for ANY - /// owner, per [`crate::world::SocialStore::ignored_guids`]. Unlike `contact_lists`, - /// `owner_guid` here is a Character this Gateway process may never have a `Viewer` for at all (a - /// whisper sender or a guild-invite target is usually a PEER, not the connected session), so - /// this cannot route through one. fn ignored_guids(&self, owner_guid: u64) -> Result> { let guard = self.0.coord(); let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); Ok(contacts.ignored_by(owner_guid)) } - /// Resolve a typed name to a Character guid on this Shard, ASCII case-insensitive like the - /// Module's own `helpers::character_by_name`, from the Shard's [`CharacterNameIndex`]. `None` - /// if no Character here has that name. Every realm-wide name lookup (whisper, party and guild - /// invites, contacts, channel moderation) reaches this through `presence::resolve_by_name` or - /// `presence::resolve_all_by_name`. fn character_guid_by_name(&self, name: &str) -> Result> { let guard = self.0.coord(); let names = guard @@ -205,8 +164,6 @@ impl SocialStore for Coordinator { Ok(names.guid_named(name)) } - /// `CMSG_ADD_FRIEND` — `target_guid` is already resolved by the gateway. `target_race` is the - /// target's Speaker Fact, read realm-wide (`presence::of`): the Module's Enemy Gate needs it. fn add_friend( &self, actor: Actor, @@ -228,7 +185,6 @@ impl SocialStore for Coordinator { )) } - /// `CMSG_ADD_IGNORE` — `target_guid` is already resolved by the gateway. fn add_ignore(&self, actor: Actor, target_guid: u64) -> Result { contact_outcome(call_reducer!( self.0.call_pipe().conn.reducers, @@ -246,8 +202,6 @@ impl SocialStore for Coordinator { } } -/// The friends and ignore lists' Refusal decoder: a tagged Refusal is an outcome the client -/// renders, and an untagged one or a Transport Loss stays `Err`. fn contact_outcome(result: Result<()>) -> Result { let Err(error) = result else { return Ok(ContactOutcome::Done); diff --git a/gateway/src/stdb/store/speech.rs b/gateway/src/stdb/store/speech.rs index 6edb28684..a069d4234 100644 --- a/gateway/src/stdb/store/speech.rs +++ b/gateway/src/stdb/store/speech.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`SpeechStore`] adapter. - use anyhow::Result; use super::chat::chat_outcome; @@ -9,8 +7,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, ChatOutcome, SpeechStore}; impl SpeechStore for Coordinator { - /// Say, yell or `/e` on the speaker's Home Shard. The Module's language Gate answers a - /// `chat:*` Refusal. fn send_chat( &self, actor: Actor, @@ -44,10 +40,6 @@ impl SpeechStore for Coordinator { ) } - /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to - /// the Home Shard through one Store operation. A Refusal carries the text the GM reads: the - /// Module's reason, or the Gateway's own when the Account is unknown. Realm-core being - /// unreachable is a Transport Loss. fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()> { crate::realm_core::run_gm_command(self, account_name, actor, text) } diff --git a/gateway/src/stdb/store/taxi.rs b/gateway/src/stdb/store/taxi.rs index c29b8dd0e..2364b8690 100644 --- a/gateway/src/stdb/store/taxi.rs +++ b/gateway/src/stdb/store/taxi.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`TaxiActionStore`] adapter. - use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -13,7 +11,6 @@ use crate::stdb::Coordinator; use crate::world::{Actor, TaxiActionStore}; impl TaxiActionStore for Coordinator { - /// Cohesive status request: module resolution + policy runs before this returns one known bit. fn taxi_node_status( &self, actor: Actor, @@ -61,8 +58,6 @@ impl TaxiActionStore for Coordinator { })) } - /// Cohesive direct-flight activation. The module commits every gameplay outcome as a stable - /// result code; only a failed Durable Request escapes as `Err`. fn activate_taxi( &self, actor: Actor, @@ -113,9 +108,7 @@ impl Coordinator { operation: u8, ) -> Result> { let character_guid = actor.guid(); - // Reducer completion and subscription propagation travel on the same SDK connection but - // are separate events. Poll by the caller-chosen id so an older cached reply can never be - // mistaken for this operation's result. + // Subscription propagation can lag completion; correlate replies by this request's id. for _ in 0..100 { let reply = self .0 @@ -128,8 +121,7 @@ impl Coordinator { if let Some(reply) = reply.filter(|reply| taxi_reply_matches(reply, character_guid, npc_guid, operation)) { - // Copy the cache row before acknowledging it. The ack deletes only this request's - // module row; a failed ack is infrastructure loss and remains session-fatal. + // Copy before acknowledging; the acknowledgement deletes the durable reply. call_reducer!( self.0.call_pipe().conn.reducers, "gw_ack_taxi_reply", diff --git a/gateway/src/stdb/store/trade.rs b/gateway/src/stdb/store/trade.rs index 631987bd5..b2e57480f 100644 --- a/gateway/src/stdb/store/trade.rs +++ b/gateway/src/stdb/store/trade.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`TradeStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::*; diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index 6e69d9f03..879175959 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`TrainerStore`] adapter. - use anyhow::Result; use lyracore_shared::trainer::TrainerRefusal; use spacetimedb_sdk::Table; @@ -13,7 +11,6 @@ use crate::world::{Actor, InteractionOutcome, TrainerStore}; use super::interaction_outcome; impl TrainerStore for Coordinator { - /// The Module's current talent reset price for the confirmation dialog. fn talent_reset_cost(&self, character_guid: u64) -> Option { self.0 .coord() @@ -25,8 +22,6 @@ impl TrainerStore for Coordinator { .map(|character| lyracore_shared::talent::respec_cost_copper(character.respec_count)) } - /// A character's live presence `(online, level, class, zone_id)` for `SMSG_FRIEND_STATUS`/ - /// `SMSG_FRIEND_LIST`. `None` if the guid doesn't resolve to any character (a stale reference). fn character_presence(&self, guid: u64) -> Result> { Ok(self .0 @@ -39,8 +34,6 @@ impl TrainerStore for Coordinator { .map(|c| (c.online, c.level, c.class, c.zone_id))) } - /// Does this trainer serve `player_guid`'s class? Fail-open on any missing read (trainer, - /// character, template), matching `npc_refuses_interaction`. fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { let guard = self.0.coord(); let db = &guard.conn.db; @@ -59,9 +52,6 @@ impl TrainerStore for Coordinator { })) } - /// The skill line offering `spell_id` teaches at trainer `trainer_guid`, or 0 for an ordinary spell - /// offering (and for any missing trainer/offering row). The offering list is keyed by the trainer's - /// creature-template `entry`, like the vendor stock. Read from the privileged cache. fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32 { let guard = self.0.coord(); let db = &guard.conn.db; @@ -82,10 +72,6 @@ impl TrainerStore for Coordinator { line } - /// The spells trainer `trainer_guid` teaches, folded with `player_guid`'s level + known-state so the - /// codec can render each Green/Red/Gray. The trainer's creature-template `entry` keys the list (like - /// the vendor stock); `known` = a `game_player_spell` row (the one castability source). A missing - /// trainer/player → empty list. Read from the privileged cache (coordinator bypasses RLS). fn trainer_list( &self, player_guid: u64, @@ -110,9 +96,6 @@ impl TrainerStore for Coordinator { .filter(|s| s.character_guid == player_guid) .map(|s| s.spell_id) .collect(); - // The player's trained skill caps, by line — for the per-row "known" of a PROFESSION offering - // (game_player_spell never holds a profession id, so we gray a tier on the skill cap, not the - // spellbook). RLS-bypassed via the coordinator cache, same as the reads above. let skill_caps: std::collections::HashMap = db .game_player_skill() .iter() @@ -169,14 +152,6 @@ impl TrainerStore for Coordinator { .collect()) } - /// The KNOWN rank that learning `new_spell` supersedes: the game_spell_chain prev of - /// `new_spell`, if the player knows it AND the spell's ranks don't stack in the book. Drives - /// SMSG_SUPERCEDED_SPELL instead of LEARNED_SPELL on a trainer buy. - /// - /// The stacking rule is cmangos' canStackSpellRanksInSpellBook (operator-corrected live: - /// downranking Holy Light is a real thing): MANA spells keep EVERY rank visible (casters - /// downrank for mana efficiency); rage/energy/health-cost spells and PASSIVES supersede - /// (Heroic Strike replaces its old rank — there is no "downranked HS"). fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option { let guard = self.0.coord(); let db = &guard.conn.db; @@ -217,8 +192,6 @@ impl TrainerStore for Coordinator { ) } - /// The `grant_spell_id` for `talent_id` from the coordinator's `game_talent` cache (0 = passive, - /// no ability granted, or an unseeded talent). fn talent_grant_spell(&self, talent_id: u32) -> u32 { self.0 .coord() @@ -231,9 +204,6 @@ impl TrainerStore for Coordinator { .unwrap_or(0) } - /// Sum of the character's spent talent ranks (`game_character_talent`, coordinator RLS-bypassed). - /// Non-zero gates the post-CREATE login correction of `PLAYER_CHARACTER_POINTS1` (the CREATE's - /// formula counts points EARNED only — see `codec/entity.rs`). fn talent_points_spent(&self, character_guid: u64) -> u32 { let guard = self.0.coord(); guard @@ -246,15 +216,6 @@ impl TrainerStore for Coordinator { .sum() } - /// Talent-pane sync data read AFTER a successful `learn_talent` (the blocking reducer call - /// returns with the cache already consistent): `(teach_spell, superseded_prev, points_remaining)`. - /// `teach_spell` = the rank-spell the module just put in the spellbook — the 1.12 TalentFrame - /// derives a talent's shown rank from which rank-spell is KNOWN, so this must relay live as - /// SMSG_LEARNED_SPELL or the pane freezes until relog; 0 when the Module spellbook has no row - /// for that rank. `superseded_prev` = the previous - /// rank's now-replaced spell (drives SMSG_SUPERCEDED_SPELL; 0 for rank 1 / same-spell demo - /// trees). `points_remaining` = earned (level−9, floor 0) minus spent — the live - /// `PLAYER_CHARACTER_POINTS1` value. Callers may pass `talent_id = 0` to get just the points. fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32) { let guard = self.0.coord(); let db = &guard.conn.db; @@ -286,7 +247,6 @@ impl TrainerStore for Coordinator { } else { 0 }; - // The Module's resulting spellbook row is the authority for what was taught. let teach = if new_spell != 0 && db .game_player_spell() @@ -305,10 +265,6 @@ impl TrainerStore for Coordinator { (teach, superseded, remaining) } - /// Learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`) over the coordinator - /// connection. The module gates it (range / level / cost / not-already-known) and charges copper. - /// A Refusal the Module tagged comes back as an outcome; anything else stays an error. - /// Rides the coordinator connection as `gw_trainer_buy`. fn buy_trainer_spell( &self, actor: Actor, @@ -339,7 +295,6 @@ impl TrainerStore for Coordinator { ) } - /// Confirm a talent reset. The Module checks the trainer, range and price. fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result { let coord = self.0.call_pipe(); interaction_outcome(call_reducer!( @@ -349,8 +304,6 @@ impl TrainerStore for Coordinator { )) } - /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`): upsert by (character, button); - /// action 0 clears. Without this every bar drag was lost on relog (only creation seeds survived). fn set_action_button( &self, actor: Actor, @@ -366,9 +319,7 @@ impl TrainerStore for Coordinator { ) } - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's - /// u32 is the client's 0..63 rep-array slot (ReputationListID — the gtker `Faction` field name - /// lies, same as SET_FACTION_STANDING); the module reverse-resolves the faction and upserts. + /// The wire's Faction field is a ReputationListID slot, 0..63, not a faction id. fn set_faction_at_war( &self, actor: Actor, @@ -423,8 +374,6 @@ fn pick_rank_spell(rank: u8, t: &Talent) -> u32 { ) } -/// The Module's typed trainer Refusal. Only a reducer the Module rejected carries a tag; a timeout, -/// transport, or SDK failure stays an error with an unknown outcome. fn trainer_refusal(error: &anyhow::Error) -> Option { reducer_refusal_reason(error).and_then(TrainerRefusal::parse_tag) } diff --git a/gateway/src/stdb/store/transfer.rs b/gateway/src/stdb/store/transfer.rs index 1bada1391..2de137f00 100644 --- a/gateway/src/stdb/store/transfer.rs +++ b/gateway/src/stdb/store/transfer.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`TransferStore`] adapter. - use anyhow::Result; use crate::stdb::bindings::game_instance_table::GameInstanceTableAccess; @@ -41,8 +39,6 @@ impl TransferStore for Coordinator { }) } - /// `begin_transfer` — freeze the character, serialize it (row + every manifest table's rows), - /// and delete its live entity, in ONE transaction. Idempotent on `transfer_id`. fn begin_transfer( &self, character: Actor, @@ -112,7 +108,6 @@ impl TransferStore for Coordinator { self.finish_transfer(transfer_id) } - /// `release_transfer` — drop the arrival copy's fence at the destination. fn release_transfer(&self, transfer_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -163,8 +158,6 @@ impl TransferStore for Coordinator { .map(|row| (row.map_id, row.party_id)) } - /// `ensure_instance` — mirror an instance id onto this shard (idempotent), spawning its - /// population the first time. fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -173,7 +166,6 @@ impl TransferStore for Coordinator { ) } - /// `evict_instance_population` — stop this shard ticking an instance whose run moved elsewhere. fn evict_instance_population(&self, instance_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -325,8 +317,6 @@ impl TransferStore for Coordinator { } impl Coordinator { - /// `release_player_transfer_arrival`, with the exact Realm locator predecessor carried by the - /// destination fence. pub fn release_player_transfer_arrival( &self, transfer_id: u64, @@ -347,8 +337,6 @@ impl Coordinator { ) } - /// `import_player_character_blob`, with the Realm locator predecessor written as part of the - /// destination import transaction. pub fn import_player_character_blob( &self, transfer_id: u64, @@ -369,8 +357,6 @@ impl Coordinator { ) } - /// `confirm_import` — attest ON THE SOURCE that the destination copy committed. Called only - /// after `import_character_blob` returned Ok; see `world::transfer::run_transfer`. pub fn confirm_import(&self, transfer_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -379,7 +365,6 @@ impl Coordinator { ) } - /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. pub fn finish_transfer(&self, transfer_id: u64) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs index b4e6981eb..6d41c04c5 100644 --- a/gateway/src/stdb/store/vendor.rs +++ b/gateway/src/stdb/store/vendor.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`VendorActionStore`] adapter. - use anyhow::Result; use spacetimedb_sdk::Table; @@ -10,16 +8,9 @@ use crate::stdb::reads::property_enchant_ids; use crate::world::{Actor, VendorActionStore}; impl VendorActionStore for crate::stdb::Coordinator { - /// Read a vendor's stock for `SMSG_LIST_INVENTORY`. Resolve the vendor's creature entry from - /// its `game_world_entity` row, filter `game_npc_vendor` to that entry, and join each - /// `game_item_template` for the display id / buy price / max durability (carrying the - /// npc_vendor row's `max_count`). Read from the privileged cache; the SDK exposes only the PK - /// index, so iterate and filter. A stock line whose template isn't loaded is skipped: it can't - /// be priced or displayed. Ordered by the vendor `slot`. fn vendor_stock(&self, vendor_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; - // The vendor's creature entry drives the stock lookup; a missing entity → no stock. let Some(creature_entry) = db .game_world_entity() .guid() @@ -33,7 +24,7 @@ impl VendorActionStore for crate::stdb::Coordinator { .iter() .filter(|v| v.creature_entry == creature_entry) .collect(); - rows.sort_by_key(|v| v.slot); // stable vendor-slot order (SQL has no ORDER BY in 2.5) + rows.sort_by_key(|v| v.slot); let items = rows .into_iter() .filter_map(|v| { @@ -57,8 +48,6 @@ impl VendorActionStore for crate::stdb::Coordinator { self.npc_refuses_interaction(vendor_guid, actor.guid()) } - /// The module gates the purchase on the vendor (stock + NPC flags + range) and debits the - /// buyer's copper. fn vendor_buy( &self, actor: Actor, @@ -73,8 +62,6 @@ impl VendorActionStore for crate::stdb::Coordinator { ) } - /// The player's buyback ring, newest-first: `(item_entry, stack_count, price, - /// random_property_id)`, at most 12. fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -98,8 +85,6 @@ impl VendorActionStore for crate::stdb::Coordinator { crate::stdb::Coordinator::item_slot_by_guid(self, item_guid) } - /// The module gates the REPAIR NPC and charges copper; the player's item and purse replicate - /// back via subscription. fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -108,8 +93,6 @@ impl VendorActionStore for crate::stdb::Coordinator { ) } - /// The gateway resolves the client's item-INSTANCE guid to the owning slot before calling - /// (the reducer takes the slot); the module credits the seller's copper. fn vendor_sell(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, diff --git a/gateway/src/stdb/store/weather.rs b/gateway/src/stdb/store/weather.rs index 86cac243e..9dfec7dab 100644 --- a/gateway/src/stdb/store/weather.rs +++ b/gateway/src/stdb/store/weather.rs @@ -1,5 +1,3 @@ -//! `Coordinator`'s [`WeatherStore`] adapter. - use anyhow::Result; use crate::codec::ZoneWeatherView; @@ -8,12 +6,6 @@ use crate::stdb::Coordinator; use crate::world::WeatherStore; impl WeatherStore for Coordinator { - /// One zone's current sky, read from the shard's `game_zone_weather` cache. - /// - /// `None` means the zone has no row, which the Module defines as fine weather; zone 0 is the - /// unresolved zone (`WorldEntity.zone_id`'s "no terrain covers this position") and never has - /// one. Callers that only need a packet go through - /// [`crate::world::zone_weather_message`], which folds both cases into fine weather. fn zone_weather(&self, zone_id: u32) -> Result> { if zone_id == 0 { return Ok(None); diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index af23db098..d19474170 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1,5 +1,3 @@ -//! World Session registration, queue metrics and durable background work. - use crate::codec; use crate::codec::corpse::subscriptions::relay_corpse_create; use crate::codec::entity::subscriptions::instance_relay_gate; @@ -21,18 +19,12 @@ use super::world_view::{self, Viewer, WorldView}; #[cfg(test)] pub(crate) static DURABLE_TOPOLOGY_ENV_LOCK: Mutex<()> = Mutex::new(()); -/// RAII guard for one world session's shared-view registration, held by the world connection. -/// Dropping the guard makes the session unreachable from every shard-level dispatcher. pub struct PlayerSubscriptions { - /// This session's registration in the gateway-wide shared view. `None` only for the - /// in-memory test store. Dropping the guard unregisters it, which is what stops the shared - /// coordinator dispatch from ever enqueueing for a dead session. viewer: Option>, view: Option>, } impl PlayerSubscriptions { - /// A no-op guard (used by the in-memory test store; drops harmlessly). #[cfg(test)] pub fn empty() -> Self { Self { @@ -41,7 +33,6 @@ impl PlayerSubscriptions { } } - /// Build a real shared-view registration for world-session ordering tests. #[cfg(test)] pub(crate) fn registered_for_test( view: Arc, @@ -84,11 +75,7 @@ impl PlayerSubscriptions { } } - /// Remove this session from owner, identity, shard, and spatial routing immediately. - /// - /// World-port uses this before driving a cross-shard transfer because the drive can delete - /// owner rows on the source shard. `Drop` calls the same method, so failure and re-entry paths - /// remain idempotent. + /// Unregister before Transfer deletes source rows whose Relays could reach the destination. pub(crate) fn unregister_viewer(&mut self) { if let (Some(view), Some(viewer)) = (self.view.take(), self.viewer.take()) { viewer @@ -98,19 +85,10 @@ impl PlayerSubscriptions { } } - /// What this session's client holds of its group mates' Member Stats. `None` for the in-memory - /// test store. pub(crate) fn member_stats_record(&self) -> Option<&crate::world::MemberStatsRecord> { self.viewer.as_ref().map(|viewer| &viewer.member_stats) } - /// Drive the shared AOI index from the player's movement: on a cell crossing, move the - /// viewer's anchor and relay the CREATE/DESTROY delta the move implies. - /// - /// Runs on the session's own thread and does no I/O: the delta is a 10-cell set diff in memory - /// and the entering rows are read out of coordinator caches that are already resident. Before - /// Before the shared-connection model, this was an unsubscribe + resubscribe round trip per - /// cell crossing, per player. pub fn aoi_update(&mut self, x: f32, y: f32) { let (Some(viewer), Some(view)) = (self.viewer.as_ref(), self.view.as_ref()) else { return; @@ -125,32 +103,13 @@ impl Drop for PlayerSubscriptions { } } -/// Diagnosis counters for a failure where the peer-movement relay died permanently on a session — -/// the motion subscription stopped delivering while the module kept accepting movement. Deliberately -/// atomics read ONCE at logout rather than a log per call: a -/// `log::info!` on the relay path made the intermittent failure vanish across six consecutive runs, -/// so any instrument that touches the hot path perturbs the very race being measured. -/// -/// `MOTION_CALLS` counts per-viewer [`motion_outbound`] work before its audience Gate. -/// `MOTION_SENT` counts packets built, and `MOTION_DROPPED` counts discarded writer work. -/// These are fan-out counters. Incoming subscription rows are counted separately per Shard. pub(crate) static MOTION_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); pub(crate) static MOTION_SENT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); pub(crate) static MOTION_DROPPED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -// One argument per counter in the diagnostic line this formats. #[allow(clippy::too_many_arguments)] -/// The `MOTIONSTAT` 10-second line — pure, so what the operator reads is pinned by a -/// test instead of by eyeballing a `log::info!`. Deltas are the CHANGE over the last window, which is -/// what makes a degradation visible while the cumulative totals keep climbing. -/// -/// `delivery` is `sent/calls` over the window: the relay's own guard-rejection rate. `fanout` is -/// `calls per submitted movement` — the observers-per-mover the subscription engine actually -/// delivered to. That second number is the one the 371-client run needed and did not have: at 371 -/// co-located clients each submitted movement SHOULD wake ~370 relay callbacks, and the under- -/// delivery showed up as that ratio collapsing, not as a guard rejecting anything. pub(crate) fn motionstat_line( calls: u64, sent: u64, @@ -177,95 +136,29 @@ pub(crate) fn motionstat_line( ) } -/// Observers per submitted movement over one window — `None` for a window with no movement at all -/// (an idle server must not read as a fan-out of zero). ONE definition, shared by the `MOTIONSTAT` -/// line and the collapse check below, so the number the operator reads and the number the warning -/// fires on can never disagree. pub(crate) fn fanout_ratio(d_calls: u64, d_submitted: u64) -> Option { (d_submitted > 0).then(|| d_calls as f64 / d_submitted as f64) } -/// How far below its own trailing baseline the fan-out must fall to count as collapsed. 0.6 = "at -/// least 40 % of the observers a mover was reaching ten seconds ago are no longer being reached". -/// Well outside the wobble of a live crowd (players logging in and out, AOI boxes recentering, a -/// window that lands mid-teleport), so it takes an actual step change to reach. pub(crate) const FANOUT_COLLAPSE_RATIO: f64 = 0.6; -/// Consecutive collapsed windows before the warning fires — 3 × 10 s, so ~30 s of sustained -/// under-delivery. THE HYSTERESIS IS A DELIBERATE DIVERGENCE FROM THE STUCK-RELAY SIGNAL (the -/// `MOTION_CALLS`/`MOTION_SENT` diagnosis above), which has none -/// and re-fires every window: that signal keys on "movement submitted but the relay callback fired almost -/// never", which is a binary, unambiguous, does-not-self-heal condition worth shouting about -/// instantly. Fan-out is a RATIO of two noisy counters over a 10-second window, and it moves for -/// legitimate reasons every time the crowd changes shape. A signal like that fires spuriously if it -/// is instant, and a warning that has cried wolf once is a warning the operator greps away. pub(crate) const FANOUT_COLLAPSE_WINDOWS: u32 = 3; -/// After this many consecutive collapsed windows the current (low) fan-out is accepted as the new -/// baseline, which silences the warning until the NEXT collapse. Deliberate: fan-out also drops -/// permanently for the entirely legitimate reason that the crowd thinned, and an alarm that never -/// stops is an alarm nobody reads. So the operator gets windows 3..6 of the collapse — ~30 s of loud -/// warnings — and then the gateway adapts. pub(crate) const FANOUT_REBASELINE_WINDOWS: u32 = 6; -/// Weight of the newest healthy window in the trailing baseline. An EWMA rather than a ring of -/// recent windows because it needs no allocation and no window bookkeeping, and because the -/// smoothing constant is the only tunable: 0.3 over 10-second windows ≈ a 30-second memory, fast -/// enough to follow a crowd filling up a zone and slow enough that one quiet window does not become -/// the reference point. Note the baseline is fed ONLY by non-collapsed windows (see -/// [`fanout_health_step`]) — an EWMA that absorbed the collapse would decay under the current -/// fan-out within two windows and the run would never reach [`FANOUT_COLLAPSE_WINDOWS`]. pub(crate) const FANOUT_BASELINE_ALPHA: f64 = 0.3; -/// Trailing fan-out baseline + the length of the current run of collapsed windows. Lives in the -/// 10-second `MOTIONSTAT` task; nothing on the relay hot path touches it. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(crate) struct FanoutHealth { - /// `None` until the first informative window establishes it. pub(crate) baseline: Option, pub(crate) low_windows: u32, } -/// One 10-second window of the fan-out health check — pure, so the decision is unit-tested directly -/// instead of being eyeballed in a log. Returns the next state, and -/// `Some(consecutive_collapsed_windows)` when this window should warn (`None` = stay quiet). -/// -/// **What it catches.** Peer movement being delivered to fewer observers than it was a moment ago, -/// while players are still moving — the 371-client shape, where nothing errored, nothing logged, and -/// peers simply updated less often than they moved. `delivery` (= sent/calls) read ~100 % throughout -/// that run, because the shortfall was `calls` never reaching the rate it should have; `fanout` is -/// the field that showed it, and this turns that field from "a number an operator has to know the -/// expected value of" into a warning. -/// -/// **What it does NOT catch — read this before trusting it.** -/// * **A gateway that has been degraded since startup.** The baseline is the gateway's OWN recent -/// behaviour, so if fan-out was already wrong when the first window was measured, that wrong value -/// IS the baseline and nothing ever trips. This check reports a CHANGE, never an absolute truth. -/// Nothing in the gateway currently knows how many observers a mover *should* have reached — -/// the AOI box is per-session, invisible to this task, and `GridBox` has no -/// overlap test, so a true expectation needs a global registry of per-session boxes plus box -/// intersection. That is the honest version of this warning and it is not what this is. -/// * **A slow slide.** A degradation that takes minutes to develop keeps each window within -/// [`FANOUT_COLLAPSE_RATIO`] of the last, and the baseline follows it down. Only a step change of -/// ≥40 % inside ~30 s trips. -/// * **A collapse that starts during a quiet spell.** Windows below -/// [`crate::world::MOVE_ACTIVITY_FLOOR`] submitted movements are treated as uninformative: they do -/// not warn, and they break a run in progress. -/// * **Under-delivery to SOME sessions.** Both counters are process-global, so one wedged session -/// among 300 healthy ones is invisible here. -/// -/// A TOTAL loss of the relay trips this as well as the stuck-relay signal (fan-out goes to ~0, which is -/// certainly below any baseline) — two lines about one failure, 30 s apart, which is the right way -/// round: the stuck-relay signal is instant and keeps repeating, this one adds "and it stayed that way". pub(crate) fn fanout_health_step( state: FanoutHealth, fanout: Option, submitted_delta: u64, ) -> (FanoutHealth, Option) { - // Uninformative window: too little movement to compare, or none at all. Keep the baseline (a - // quiet window is not evidence about fan-out) but break any run in progress — the same activity - // floor the stuck-relay signal uses, for the same reason: a handful of movements with little relay - // traffic is one lonely player, not a broken relay. let Some(f) = fanout.filter(|_| submitted_delta > crate::world::MOVE_ACTIVITY_FLOOR) else { return ( FanoutHealth { @@ -276,7 +169,6 @@ pub(crate) fn fanout_health_step( ); }; let Some(baseline) = state.baseline else { - // First informative window: nothing to compare against yet. return ( FanoutHealth { baseline: Some(f), @@ -286,7 +178,6 @@ pub(crate) fn fanout_health_step( ); }; if f >= baseline * FANOUT_COLLAPSE_RATIO { - // Healthy window — fold it into the trailing baseline and forget any partial run. let next = baseline + FANOUT_BASELINE_ALPHA * (f - baseline); return ( FanoutHealth { @@ -299,8 +190,6 @@ pub(crate) fn fanout_health_step( let low_windows = state.low_windows + 1; let warn = (low_windows >= FANOUT_COLLAPSE_WINDOWS).then_some(low_windows); if low_windows >= FANOUT_REBASELINE_WINDOWS { - // Warned for ~30 s and it is still low: accept this as the new normal (see - // FANOUT_REBASELINE_WINDOWS) — this window still warns, the next one will not. return ( FanoutHealth { baseline: Some(f), @@ -318,28 +207,6 @@ pub(crate) fn fanout_health_step( ) } -/// **Should this peer-motion packet be dropped instead of queued?** The one decision behind the -/// per-session egress bound (task B2) — pure and unit-tested, called with `tx.depth()`. -/// -/// The channel behind [`crate::world::SessionTx`] is unbounded, so a session whose socket has -/// stopped taking bytes grows its backlog for as long as the stall lasts: a memory path under crowd -/// load, and the reason `MOTION_DROPPED` could not mean backpressure before this. Bounding the -/// CHANNEL is not available to us — ~110 of the send sites run inside SpacetimeDB SDK row callbacks -/// on the connection's pump thread, and a blocking send from there on a full channel would deadlock -/// that connection (see `SessionTx`'s own comment). So the queue stays unbounded and the only -/// traffic that can plausibly outrun a socket sheds itself. -/// -/// **Why only peer motion and creature legs, out of ~180 send sites.** They are the N²-shaped -/// traffic: one packet per OBSERVER per mover per heartbeat, ~740/s per session at 371 co-located -/// clients, versus a handful per player action for everything else. And they are the only sends that -/// are SELF-HEALING when lost — a movement heartbeat is superseded by the next one 500 ms later, so -/// a shed packet costs a moment of coarser interpolation on a peer that is already being rendered -/// from stale data (the queue is half a second deep). A quest-log page, an item CREATE, a -/// `SMSG_DESTROY_OBJECT` is not superseded by anything: dropping one leaves the client permanently -/// wrong. Those keep queueing exactly as before. -/// -/// Shedding is counted in [`MOTION_DROPPED`], so it is visible on the existing `MOTIONSTAT` line as -/// both a rising `dropped` and a falling `delivery` — a shed that fires is not silent. pub(crate) fn shed_motion_at_depth(depth: usize) -> bool { depth > crate::world::EGRESS_SHED_DEPTH } @@ -410,8 +277,6 @@ impl Coordinator { } } - /// Prepare and register one live viewer. Row callbacks are already armed once per shard in - /// `world_view::arm_shard`; this method registers no callback of its own. pub fn subscribe_player_events( &self, account_id: u64, @@ -427,37 +292,12 @@ impl Coordinator { arrival.x, arrival.y, ); - // Ghost-only visibility constants (vanilla 1.12): a spirit-healer creature carries - // UNIT_NPC_FLAGS SPIRITHEALER and is visible ONLY to a viewer in the GHOST player-flag state. - // BUG FIX: was 0x4000 — but 0x4000 is REPAIR in cmangos 1.12 numbering (SPIRITHEALER is 0x20). - // The wrong constant (a) failed to ghost-gate the real Spirit Healer (npc_flags 0x21) and - // (b) wrongly ghost-gated the 39 armorers/quartermasters that carry REPAIR (0x4000), hiding - // them from living players. let created = Arc::new(Mutex::new(HashSet::from([self_guid]))); - // The view-merge fast-follow gap: the one piece of viewer-owned state the away leg's - // peer-visibility gate needs but cannot read off an away connection. Written by the ghost-flag - // relay (this viewer's OWN row), read by every per-viewer relay body in this file — a - // shared coordinator callback holds the cache of the shard that owns the ROW, which for a - // cross-shard peer is not the shard that owns the VIEWER. See `ViewerGates`'s doc comment. + // Read ghost state from the mirror; re-entering the cache from a callback would deadlock. let viewer_gates = Arc::new(ViewerGates::default()); - // This session's handle on the gateway-wide shared view. Minted HERE, at the top, - // because relays registered further down capture it — the actual REGISTRATION (which makes - // the shared dispatch start enqueueing for this session) happens near the end, once - // everything that could still fail with `?` has succeeded. let view = self.world_view(); let session = view.next_session_id(); - // ====================================================================================== - // SKILL-UP — game_player_skill (insert/update) - // Live PLAYER_SKILL_INFO[slot] VALUES so a newly-learned or trained skill line updates the - // open pane without a relog. - // ====================================================================================== - // LIVE skill pane relay: a game_player_skill INSERT (new line learned) or UPDATE - // (skill-up/train) pushes the PLAYER_SKILL_INFO[slot] partial so the open pane moves and - // the client prints its own "Your skill in X has increased to N." line — no relog. The - // slot map is seeded from the SAME deterministic layout the login CREATE used - // (codec::skill_slot_layout); a line learned mid-session appends at the next free slot, - // exactly as the next login's CREATE will lay it out. let skill_slots: std::sync::Arc< std::sync::Mutex<(std::collections::HashMap, u8)>, > = { @@ -485,19 +325,11 @@ impl Coordinator { let mut explored = world_view::ExplorationReplay::default(); - // The bound identity WITHOUT touching a per-player connection — synthetic under the - // flag (what establish_session bound and the module stamps on event rows), the cached - // connection's real identity otherwise. Identical values to the old `player.identity` read. - // Stored with the viewer for identity-addressed shared dispatch. let self_identity = spacetimedb_sdk::Identity::from_byte_array(self.bound_identity(account_id)?); { - // Seed the viewer's replay state before registration. A coordinator reconnect replays - // every resident row: exploration is state-sync (the fog word must re-send, the - // discovery feedback must not repeat), so it carries this per-viewer seed, while the - // event families (teleport, faction announce, item toast) drop replayed rows outright - // in `world_view`'s live-insert registrations. + // Seed before registration so replay restores fog without discovery feedback. let guard = self.0.coord(); explored.seed( guard @@ -510,18 +342,6 @@ impl Coordinator { ); } - // Register with the gateway-wide shared view, then sweep everything already inside - // the box into this client's world. - // - // The sweep is not belt-and-braces here, it is the ONLY thing that populates a fresh - // client: there is no subscription apply to fire per-row `on_insert` any more — the rows - // were resident in the coordinator caches long before this session existed. It is - // idempotent through `created` regardless. - // Seed the ghost mirror from the character's OWN row before anything can read it. Before - // the shared-connection model, the spirit-healer gate re-read the live row on every offer, - // so a player who logged in - // ALREADY DEAD saw the healer without any ghost transition ever firing; the mirror has to - // start at the truth or that login regresses. { const GHOST_PLAYER_FLAG: u32 = lyracore_shared::constants::player_flags::GHOST; let is_ghost = self @@ -538,11 +358,6 @@ impl Coordinator { .is_ghost .store(is_ghost, std::sync::atomic::Ordering::Relaxed); } - // The Realm Chat Relay's per-listener filter, and the Account Claim Relay's per-listener - // audience: this Character's own ignore and friend guids, from its contact rows on this - // Home Shard. The contact Relay keeps both current from here on. Read from the Shard's - // owner-keyed `ContactIndex` rather than `contact_lists`, which reads the registered Viewer - // this call is about to seed. let (ignored, friends): (HashSet, HashSet) = { let guard = self.0.coord(); let contacts = guard.contacts.read().unwrap_or_else(|p| p.into_inner()); @@ -579,14 +394,6 @@ impl Coordinator { )?; world_view::sweep_into_view(&view, &viewer); - // Corpse resident sweep: corpse rows ride the base subscription - // and their callbacks may have fired before this point. Re-offer every resident one through - // the same instance gate — idempotent for the client (a repeat CREATE for the same corpse - // guid re-renders it), and the gate is now the session's authoritative `login_instance` - // rather than a cache lookup that could answer `None`. - // - // Read from the COORDINATOR cache: same global rows in both flag states, - // while the per-player cache stops carrying the corpse query under the flag. let resident_corpses: Vec = { let guard = self.0.coord(); guard.conn.db.game_corpse().iter().collect() @@ -596,13 +403,6 @@ impl Coordinator { relay_corpse_create(&tx, self_guid, row); } } - // Armor/sheet resident sweep (login-low-armor live find, 2026-07-11): the base sub's ITEM - // and aura replay callbacks fire before the self entity is readable, so `effective_armor`'s - // base term read 0 and their pushes OVERWROTE the CREATE's correct gear-folded armor. Push - // the authoritative values once, from the COORDINATOR's cache — which holds the entity, the - // auras, the item instances AND the item templates, i.e. every term of the fold (the - // per-player cache lost `game_item_template` to the connection reclaim and has never held - // the entity since the shared-connection model). { let guard = self.0.coord(); let db = &guard.conn.db; @@ -619,10 +419,6 @@ impl Coordinator { ))); } } - // Login fog restore: with no per-player subscription there - // is no initial-sync replay to re-send the fog words, so sweep them explicitly from the - // coordinator cache — one idempotent full-word PLAYER_EXPLORED_ZONES VALUES per explored - // 32-bit bucket of THIS character. { let guard = self.0.coord(); let mut words: std::collections::HashMap = std::collections::HashMap::new(); @@ -700,13 +496,6 @@ fn next_party_command_claim_token() -> u64 { } impl Coordinator { - /// Wire up the bot-initiated (serendipity) invite relay: one registration per - /// connected WORLD SHARD — `all_shards()`, not a per-player relay — because there is no player - /// session to hang this off. A bot's goal tick decides "invite this fellow quester" with no - /// client behind it, so nothing else in the gateway would ever notice the row. - /// - /// Called ONCE, at gateway startup (`main.rs`). Also installs its own reconnect hook, parallel - /// to `world_view::arm_shard`, so a module republish binds the callback to the fresh `LiveConn`. pub fn spawn_bot_invite_relay(&self) { for shard in self.all_shards() { shard.arm_bot_invite_relay(); @@ -722,11 +511,6 @@ impl Coordinator { } } - /// One shard's half of [`spawn_bot_invite_relay`] — pulled out so both the initial call and the - /// watchdog's post-reconnect re-arm run the IDENTICAL registration, rather than two copies that - /// could drift. Re-reads `self.0.coord()` fresh each call, which is the whole point: called again - /// after a reconnect, it registers on the NEW `LiveConn`, not the dead one the old callback was - /// bound to. fn arm_bot_invite_relay(&self) { let store = self.clone(); self.0 @@ -738,9 +522,7 @@ impl Coordinator { let store = store.clone(); let (intent_id, op, inviter_guid, target_guid) = (row.id, row.op, row.inviter_guid, row.target_guid); - // The claim targets this same Shard. Run it off the SDK callback thread so a - // one-pipe Gateway can receive the reducer completion instead of blocking its own - // connection pump. + // Run off-pump so a one-pipe Gateway can receive its own reducer completion. let spawned = std::thread::Builder::new() .name("bot-invite-intent".into()) .spawn(move || { @@ -767,11 +549,6 @@ impl Coordinator { }); } - /// Start the Roster Revision Relay: Realm-core's `game_group_roster_revision` callbacks mark a - /// party dirty, and one worker pushes it to every stale World Shard mirror. Called once at - /// startup. Arms nothing unless Realm-core is a distinct database. A Realm-core reconnect - /// re-arms the callbacks on the fresh connection; changes missed while it was down are the - /// reconciliation pass's, which that reconnect also starts. pub fn spawn_roster_revision_relay(&self) { let realm = match self.realm_core() { Ok(realm) => realm, @@ -810,8 +587,6 @@ impl Coordinator { })); } - /// Realm-core's half of [`Self::spawn_roster_revision_relay`], on the current connection. The - /// callbacks only mark the party dirty; the worker makes every reducer call. fn arm_roster_revision_relay( &self, relay: &std::sync::Arc, @@ -830,8 +605,6 @@ impl Coordinator { .on_update(move |_ctx, _old, row| updated.mark_dirty(row.group_id)); } - /// Start one bounded Transfer Intent dispatcher per configured World Shard. Each pass reads the - /// current connection, so a reconnect needs no second thread or connection-scoped callback. pub fn spawn_bot_transfer_relay(&self) { for shard in self.all_shards() { if let Err(error) = std::thread::Builder::new() @@ -905,10 +678,6 @@ impl Coordinator { next_after } - /// Reconcile party and guild membership when a World Shard deletes a Character. - /// - /// Fresh Character subscriptions on every Shard distinguish deletion from a Transfer whose - /// destination update has not reached this Gateway yet. pub fn spawn_character_gone_relay(&self) { for shard in self.all_shards() { shard.arm_character_gone_relay(); @@ -939,14 +708,11 @@ impl Coordinator { self.request_deleted_character_reconciliation(); } - /// A full pass: every party member and every Character guild state names. Startup and a - /// reconnect ask for it, since a delete may have gone unseen. fn request_deleted_character_reconciliation(&self) { self.1.guild_cleanup.lock().unwrap().sweep = true; self.start_deleted_character_reconciliation(); } - /// A `game_character` delete: the party pass, and guild cleanup for `character_guid` only. fn request_deleted_character_cleanup(&self, character_guid: u64) { self.1 .guild_cleanup @@ -978,7 +744,6 @@ impl Coordinator { .1 .deleted_character_reconciliation_requested .store(false, Ordering::Release); - // Both run on every pass: a party failure must not hold up guild cleanup. let guild_work = std::mem::take(&mut *store.1.guild_cleanup.lock().unwrap()); let parties = crate::world::party::reconcile_deleted_character_parties(&store); let guilds = @@ -1142,31 +907,9 @@ mod tests { assert_eq!(wrapped, [1, 2, 3, 4, 5, 6]); } - // ── Peer-motion relay: the CALL SITE, not just the codec helper ────────── - // - // §8 of the agent playbook: the dominant failure here is a test that pins an extracted helper - // while the wiring that calls it stays free to vanish. `codec::tests` proves the raw builder - // emits the same bytes the typed path did; these tests drive `relay_entity_motion` / - // `relay_creature_leg` themselves — a real `SessionTx`, a real `created` set, a real row — - // so deleting the send, sending the wrong variant, passing the wrong guid or opcode, or dropping - // a guard all show up here. - - /// `MOTION_CALLS`/`SENT`/`DROPPED` are process-global atomics and the test binary runs tests in - /// PARALLEL, so any test that moves them or measures a delta around them must hold this lock. - /// Without it, the delta a counter test measures silently includes whatever a concurrent relay - /// test did (which is exactly how the shed test below first turned the motion-counters test red). Poison is - /// ignored deliberately: a panicking test has already failed and must not cascade into every - /// other one. + // Tests that read or change process-global motion counters must hold this lock. static MOTION_COUNTERS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// Test shims for the two relays the shared-connection model split in half. - /// - /// Before the shared-connection model, each of these pushed straight onto the session channel - /// from the per-player SDK - /// callback. Now the SHARED dispatch (`stdb::world_view`) makes the shed decision on the pump - /// and enqueues a job, and the job calls the `*_outbound` half on the session's writer thread. - /// These shims are that pair, back to back, so the wire-pinning and counter tests below keep - /// asserting on the queue exactly as they did — and keep failing if either half regresses. fn relay_entity_motion( tx: &SessionTx, created: &Arc>>, @@ -1204,7 +947,6 @@ mod tests { MOTION_COUNTERS.lock().unwrap_or_else(|e| e.into_inner()) } - /// One motion row for `guid`, carrying the wire-form `MovementInfo` the module stores. fn motion_row(guid: u64, opcode: u16, movement_info: Vec) -> EntityMotion { EntityMotion { guid, @@ -1219,7 +961,6 @@ mod tests { } } - /// A running-forward movement block plus the bytes the module would have stored for it. fn moving_forward() -> (wow_world_messages::vanilla::MovementInfo, Vec) { let info = wow_world_messages::vanilla::MovementInfo { flags: wow_world_messages::vanilla::MovementInfo_MovementFlags::new_forward(), @@ -1236,10 +977,6 @@ mod tests { (info, bytes) } - /// What the observer's writer actually receives for one peer heartbeat: an `Outbound::Raw` whose - /// opcode and body equal what the TYPED path would have serialized. The typed - /// message is built here independently of the raw builder, so this fails if the relay passes the - /// wrong guid, the wrong opcode, the row's bytes in the wrong place — or stops sending at all. #[test] fn the_motion_relay_queues_the_bytes_the_typed_path_would_have_sent() { let _counters = motion_counter_lock(); @@ -1281,10 +1018,6 @@ mod tests { assert!(rx.try_recv().is_err(), "exactly one packet per motion row"); } - /// The two guards that were free before the raw path and must stay: a player never receives - /// their OWN movement echoed back (they would fight the server for authority over their - /// position), and no `MSG_MOVE_*` goes out for a guid this client never got a CREATE for - /// (a movement packet for an unknown entity desyncs the client). #[test] fn the_motion_relay_skips_self_and_uncreated_movers() { let _counters = motion_counter_lock(); @@ -1306,8 +1039,6 @@ mod tests { ); } - /// A malformed row must produce no packet at all — the raw path memcpys the block, so a short - /// one would be framed with a `size` the client trusts and would desync its header stream. #[test] fn the_motion_relay_drops_a_truncated_movement_block() { let _counters = motion_counter_lock(); @@ -1328,9 +1059,6 @@ mod tests { ); } - /// - /// Deltas rather than absolutes because the counter is a process-global atomic; this is the only - /// test that increments it, so the deltas are exact. #[test] fn a_discarded_relay_send_increments_the_dropped_counter_287() { let _counters = motion_counter_lock(); @@ -1340,7 +1068,6 @@ mod tests { let opcode = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT as u16; let before = MOTION_DROPPED.load(Relaxed); - // A live receiver: delivered, so nothing is counted as dropped. let (tx, rx) = crate::world::SessionTx::with_depth(0); relay_entity_motion(&tx, &created, 1, &motion_row(42, opcode, bytes.clone())); assert!(rx.try_recv().is_ok()); @@ -1350,7 +1077,6 @@ mod tests { "a delivered packet is not a drop" ); - // The writer half is gone (the session ended) — the send is discarded and MUST be counted. let (dead_tx, dead_rx) = crate::world::SessionTx::with_depth(0); drop(dead_rx); relay_entity_motion(&dead_tx, &created, 1, &motion_row(42, opcode, bytes)); @@ -1360,7 +1086,6 @@ mod tests { "a peer-motion packet discarded by a dead writer channel must increment MOTION_DROPPED" ); - // The sibling relay discards the same way (creature legs share the writer channel). relay_creature_leg( &dead_tx, &created, @@ -1393,13 +1118,8 @@ mod tests { ); } - /// The operator-facing half of the delivery instrument: the 10-second line must carry the - /// delivery ratio, the - /// per-movement fan-out, and the dropped count — the three numbers the 371-client run had to - /// reconstruct by hand afterwards. Pinned as text because that text IS the instrument. #[test] fn the_motionstat_line_reports_delivery_dropped_and_fanout_287() { - // 1000 callbacks, 900 packets queued, 7 discarded, from 10 submitted movements. let line = motionstat_line(5000, 4500, 21, 1000, 900, 7, 10, 400); assert!( line.contains("calls=5000"), @@ -1438,7 +1158,6 @@ mod tests { "retired outstanding count remains: {line}" ); - // An idle window must not divide by zero or print a fake 0 %. let idle = motionstat_line(5000, 4500, 21, 0, 0, 0, 0, 400); assert!( idle.contains("delivery=--") && idle.contains("fanout=--/move"), @@ -1446,11 +1165,6 @@ mod tests { ); } - // ── Fan-out collapse signal ───────────────────────────────────────────────────────────────── - - /// The shared ratio: observers per submitted movement, and `None` — never a fake 0.0 — for a - /// window with no movement in it. The `MOTIONSTAT` line and the warning both read this, which is - /// the point: a number the operator sees and a number the gateway warns on cannot disagree. #[test] fn fanout_ratio_is_observers_per_move_and_none_when_idle() { assert_eq!(fanout_ratio(3710, 10), Some(371.0)); @@ -1466,9 +1180,6 @@ mod tests { ); } - /// A quiet window is not evidence. Below `MOVE_ACTIVITY_FLOOR` submitted movements the check must - /// stay silent AND keep the baseline it learned while the server was busy — one lone player - /// wandering with nobody nearby must not become the reference point for a full zone. #[test] fn a_quiet_window_neither_warns_nor_rebaselines_b1() { let busy = FanoutHealth { @@ -1487,14 +1198,10 @@ mod tests { "and it breaks any collapse run in progress" ); - // No movement at all: same treatment, no divide-by-zero fake. let (idle, warn) = fanout_health_step(busy, None, 0); assert_eq!((idle.baseline, warn), (Some(300.0), None)); } - /// The first informative window can only ESTABLISH the baseline — there is nothing to compare it - /// against. This is also the honest limit of the whole signal: a gateway that is already - /// under-delivering when it starts up bakes that into the baseline and never trips. #[test] fn the_first_window_establishes_the_baseline_and_cannot_warn_b1() { let (next, warn) = fanout_health_step(FanoutHealth::default(), Some(40.0), 1_000); @@ -1504,10 +1211,6 @@ mod tests { ); } - /// **The hysteresis, which is the deliberate divergence from the stuck-relay signal.** A 40 %+ fan-out - /// collapse must persist for `FANOUT_COLLAPSE_WINDOWS` consecutive windows before it says - /// anything: the ratio wobbles, and a warning that cries wolf gets grepped away. Windows 1 and 2 - /// are silent; window 3 warns and reports the run length. #[test] fn a_fanout_collapse_warns_only_after_three_consecutive_windows_b1() { let mut st = FanoutHealth { @@ -1540,8 +1243,6 @@ mod tests { ); } - /// Half a collapse is not a collapse: one bad window followed by a healthy one resets the run, so - /// an alternating wobble never fires however long it goes on. #[test] fn a_healthy_window_resets_the_collapse_run_b1() { let mut st = FanoutHealth { @@ -1561,29 +1262,23 @@ mod tests { } } - /// Just above the ratio is NOT a collapse (the boundary), and a healthy window moves the trailing - /// baseline by exactly one EWMA step — the mechanism that lets the signal follow a zone filling up - /// instead of firing on it. #[test] fn the_collapse_boundary_and_the_ewma_step_b1() { let st = FanoutHealth { baseline: Some(100.0), low_windows: 2, }; - // Exactly at the ratio: healthy, so the run resets rather than firing on window 3. let (next, warn) = fanout_health_step(st, Some(100.0 * FANOUT_COLLAPSE_RATIO), 1_000); assert_eq!( warn, None, "fan-out exactly at the ratio is not yet a collapse" ); assert_eq!(next.low_windows, 0); - // baseline 100 + 0.3 * (60 - 100) = 88 assert!( (next.baseline.unwrap() - 88.0).abs() < 1e-9, "one EWMA step at alpha {FANOUT_BASELINE_ALPHA}, got {:?}", next.baseline ); - // A hair below it, on the third window, does fire. let (_, warn) = fanout_health_step(st, Some(100.0 * FANOUT_COLLAPSE_RATIO - 0.001), 1_000); assert_eq!( warn, @@ -1592,10 +1287,6 @@ mod tests { ); } - /// After `FANOUT_REBASELINE_WINDOWS` the low level is accepted as the new normal, so the warning - /// stops. Deliberate and documented: fan-out also drops permanently because the crowd thinned, - /// and an alarm that never stops is one nobody reads. It fires for windows 3..6 — ~30s of loud - /// warnings — and then goes quiet with the new baseline in place. #[test] fn a_sustained_collapse_rebaselines_and_stops_warning_b1() { let mut st = FanoutHealth { @@ -1622,11 +1313,6 @@ mod tests { ); } - // ── Egress shedding (task B2) ────────────────────────────────────────────────────────────────── - - /// The shed decision and its boundary. A session AT the threshold still gets its packet — only a - /// queue that has grown PAST it sheds — so the constant reads as "this many queued packets are - /// acceptable", which is how the comment on it is written. #[test] fn the_egress_shed_boundary_is_strictly_above_the_threshold_b2() { assert!(!shed_motion_at_depth(0), "an empty queue must never shed"); @@ -1640,11 +1326,6 @@ mod tests { ); } - /// The shed at its CALL SITE (playbook §8: pinning the pure decision while the wiring stays free - /// to vanish is this repo's most repeated test defect). A real `SessionTx` whose queue is one item - /// past the threshold must produce NO packet from either high-volume relay, and each skip must be - /// counted in `MOTION_DROPPED` — so a shed shows up on `MOTIONSTAT` as falling `delivery` instead - /// of being silent. #[test] fn a_backed_up_session_sheds_peer_motion_and_creature_legs_b2() { let _counters = motion_counter_lock(); @@ -1674,10 +1355,6 @@ mod tests { path: None, }; - // A session AT the threshold still receives its packet (this half is what stops the test from - // passing for the trivial reason that the relays send nothing at all). One `SessionTx` each, - // because a delivered packet raises the depth by one — the second send on a shared sender - // would already be over the line, which is the mechanism working, not a failure. let (tx, rx) = crate::world::SessionTx::with_depth(crate::world::EGRESS_SHED_DEPTH); relay_entity_motion(&tx, &created, 1, &motion_row(42, opcode, bytes.clone())); assert!( @@ -1691,8 +1368,6 @@ mod tests { "at-threshold creature legs must still flow" ); - // One item past it: each relay skips, and each counts its skip. Driven SEPARATELY so a - // failure names which of the two call sites lost its shed. let (sent_before, dropped_before) = (MOTION_SENT.load(Relaxed), MOTION_DROPPED.load(Relaxed)); let (tx, rx) = crate::world::SessionTx::with_depth(crate::world::EGRESS_SHED_DEPTH + 1); @@ -1724,10 +1399,6 @@ mod tests { ); } - /// The depth counter's own arithmetic: every enqueue counts, and a send that FAILS (the writer - /// half is gone) must roll its increment back. Without the rollback a session that ends while - /// relays are still firing would leave a permanently high depth — on a live gateway the - /// `SessionTx` clones outlive the writer briefly on every logout. #[test] fn the_egress_depth_counts_queued_items_and_rolls_back_a_failed_send_b2() { let (tx, rx) = crate::world::SessionTx::with_depth(0); diff --git a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs index 5776a40c3..15484a28d 100644 --- a/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_character_gone_durable_tests.rs @@ -120,7 +120,6 @@ fn reconciliation_is_running(coordinator: &Coordinator) -> bool { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -// One private topology stays alive across setup, Transfer, and deletion to prove one durable scenario. #[allow(clippy::too_many_lines)] fn another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character() { const INSTANCES: &str = "party-cleanup-instances"; diff --git a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs index bc26d1393..4c7e354d1 100644 --- a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs @@ -1,8 +1,3 @@ -//! Meeting Stone Seekers on two World Shards meet in one queue on Realm-core. Each Seeker uses its -//! stone through the real Coordinator, the stone forms one Party, the Roster Revision Relay carries -//! it to both World Shard mirrors, and the group event relay gives each session its stone events -//! in the order the Module inserted them. - use super::roster_relay_durable_tests::{mirrors_follow_realm_core, TopologyEnv}; use super::*; use crate::accept::BlockingTaskCapacity; @@ -21,14 +16,11 @@ use wow_world_messages::Guid; const INSTANCES: &str = "meeting-stone-instances"; const REALM: &str = "meeting-stone-realm"; -// Fixture ids sit in 509_6000-509_6099. const STONE_ENTRY: &str = "5096000"; const WORLD_STONE: u64 = 509_6001; const INSTANCES_STONE: u64 = 509_6002; -/// The Deadmines (AreaTable 1581). const DEADMINES: u32 = 1581; const STONE_AT: [&str; 3] = ["-11208", "1672", "24"]; -/// The default World Shard serves map 0; the shard map sends map 36 to the Instance Pool. const WORLD_MAP: &str = "0"; const INSTANCES_MAP: &str = "36"; @@ -39,7 +31,6 @@ const MAGE: &str = "8"; const ROGUE: &str = "4"; const HUNTER: &str = "3"; -/// In queue order. The Warrior waits longest, so it leads; the Priest heals; the rest deal damage. const TANK: u64 = 509_6010; const HEALER: u64 = 509_6011; const MAGE_SEEKER: u64 = 509_6012; @@ -47,7 +38,6 @@ const ROGUE_SEEKER: u64 = 509_6013; const HUNTER_SEEKER: u64 = 509_6014; const SEEKERS: [u64; 5] = [TANK, HEALER, MAGE_SEEKER, ROGUE_SEEKER, HUNTER_SEEKER]; -/// The World Session Token the fixture claims carry: each Seeker owns the Account of its guid. fn token(guid: u64) -> WorldSessionToken { WorldSessionToken { account_id: guid, @@ -56,7 +46,6 @@ fn token(guid: u64) -> WorldSessionToken { } } -/// A Seeker at the stone on `database`, with its claim on that database and on Realm-core. fn stage_seeker(standalone: &Standalone, database: &str, map: &str, guid: u64, class: &str) { let guid = guid.to_string(); standalone.assert_call_database( @@ -115,8 +104,6 @@ fn send(session: &Coordinator, guid: u64, msg: ClientOpcodeMessage) -> Vec) -> Vec { let mut messages = Vec::new(); for item in outbound { @@ -151,7 +138,6 @@ fn member_added(guid: u64) -> ServerOpcodeMessage { )) } -/// One Seeker's World Session as the relay sees it: its registration and what reached its queue. struct Session { guid: u64, _registration: PlayerSubscriptions, @@ -179,7 +165,6 @@ impl Session { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -// One topology stays alive from staging to the filled Party to prove one realm-wide scenario. #[allow(clippy::too_many_lines)] fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { let mut standalone = Standalone::start_persistent("meeting-stone-world"); @@ -245,15 +230,12 @@ fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { .map(|&guid| Session::open(gateway.world_view(), guid)) .collect(); - // The tank queues alone on the default World Shard. assert!(use_stone(&bind(&gateway, TANK), TANK, WORLD_STONE).is_empty()); assert!(poll_until(POLL_TIMEOUT, || realm .queued_area(TANK) .unwrap() == Some(DEADMINES))); - // It crosses into the Instance Pool, which holds it now. Its Account Claim stays on - // Realm-core, so it stays queued, and the status query on arrival says so. stage_seeker(&standalone, INSTANCES, INSTANCES_MAP, TANK, WARRIOR); standalone.assert_call("debug_delete_character", &[&TANK.to_string()]); assert!(poll_until(POLL_TIMEOUT, || { @@ -267,8 +249,6 @@ fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { )); assert_eq!(arrived, [set_queue(DEADMINES, JOINED_QUEUE)]); - // Three Seekers wait on the Instance Pool now and two on the default World Shard. The fifth - // JOIN forms the Party and fills it in the same transaction. for (home, guid, stone) in [ (&instances, HEALER, INSTANCES_STONE), (&gateway, MAGE_SEEKER, WORLD_STONE), @@ -298,7 +278,6 @@ fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { .query_database_rows(REALM, "SELECT * FROM game_meeting_stone_party") .is_empty()); - // No party op ran for these Characters, so only the Roster Revision Relay moved the mirrors. assert!( poll_until(POLL_TIMEOUT, || mirrors_follow_realm_core( &realm, &shards, group_id, TANK diff --git a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs index e3d6b57ae..1bb410c97 100644 --- a/gateway/src/stdb/subscriptions_party_command_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_party_command_durable_tests.rs @@ -1,7 +1,3 @@ -//! Core's fixture command crosses World Shards with no Package installed. Realm-core certifies the -//! party, an issuer's sequence survives its Transfer, a Command Receipt travels with its Character, -//! and a command that waits too long finishes as Expired or OutcomeUnknown without a second apply. - use super::{party_command_intent, Coordinator, DURABLE_TOPOLOGY_ENV_LOCK}; use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; @@ -16,7 +12,6 @@ use std::ffi::OsString; use std::sync::MutexGuard; use std::time::Duration; -/// The Module's debug fixture command, active while no Package registers a handler. const COMMAND: &str = "fixture.command"; const REPLY: &str = "fixture.command.result"; const GROUP: u64 = 5_098_000; @@ -67,7 +62,6 @@ impl Drop for TopologyEnv { } } -/// Three session-less Characters with live bodies, the targets of every command. struct CommandTopology { node: Standalone, target: String, @@ -101,7 +95,6 @@ impl CommandTopology { node.assert_call_database(database, "install_guid_range", &[guid_base]); } node.assert_call_database(&realm, "claim_operator", &[]); - // The seeded Account owns the companions. Each has no Account Claim, so it acts session-less. let companions = ["Cmdone", "Cmdtwo", "Cmdthree"].map(|name| stage_character(&node, &target, "1", name)); let (leader_one, actor_one) = stage_leader(&node, &source, "Cmdleadone", USERNAME_ONE); @@ -155,8 +148,6 @@ impl CommandTopology { (runtime, coordinator) } - /// Write one Party mirror, led by `leader`, onto each of `databases` at the next Roster - /// Revision. Membership revisions come from Realm-core, so a retained member keeps its own. fn mirror_party(&self, databases: &[&str], leader: u64, companions: &[u64]) { let retained = rows( &self.node, @@ -222,7 +213,6 @@ impl CommandTopology { serde_json::json!({"guid": leader, "ownership": {"none": []}}).to_string(), serde_json::to_string(&partitions).unwrap(), revision.to_string(), - // A Party, every member in Subgroup 0. "0".to_string(), serde_json::to_string(&vec![0u8; members.len()]).unwrap(), ]; @@ -233,7 +223,6 @@ impl CommandTopology { } } - /// Record every party member's current partition in Realm-core's character-to-shard index. fn publish_locators(&self, coordinator: &Coordinator) { let realm = coordinator.realm_core().unwrap(); let members = self @@ -273,7 +262,6 @@ impl CommandTopology { })); } - /// Queue the fixture command for `bot` through the authenticated client path; returns its id. fn queue(&self, source: &str, actor: &str, bot: u64) -> u64 { let ids = || { rows( @@ -296,7 +284,6 @@ impl CommandTopology { .expect("authenticated command did not queue") } - /// The fixture replies `source` holds for one intent. fn replies(&self, source: &str, intent_id: u64) -> Vec { rows( &self.node, @@ -357,7 +344,6 @@ fn row(node: &Standalone, database: &str, query: &str) -> Row { rows.into_iter().next().unwrap() } -/// Create a Human Warrior on `account` with a live body at the race's start; returns its guid. fn stage_character(node: &Standalone, database: &str, account: &str, name: &str) -> u64 { node.assert_call_database( database, @@ -385,8 +371,6 @@ fn stage_character(node: &Standalone, database: &str, account: &str, name: &str) guid.parse().unwrap() } -/// A human leader with its own Account and a live Account Claim. Returns its guid and the Session -/// Actor its commands carry. fn stage_leader(node: &Standalone, database: &str, name: &str, username: &str) -> (u64, String) { node.assert_call_database( database, @@ -442,7 +426,6 @@ fn cached_intent_when( found.unwrap() } -/// The command as Realm-core certified it for `leader`'s current party. fn admitted( source: &Coordinator, intent: &party::PartyCommandIntent, @@ -475,8 +458,6 @@ fn applied(receipts: &[Row]) -> bool { .contains("applied") } -/// Sign the Character's Account in on Realm-core and on `shard`, then enter the world there. -/// Returns the Session Actor its commands carry. fn enter_transferred_actor( topology: &CommandTopology, runtime: &tokio::runtime::Runtime, @@ -545,7 +526,7 @@ fn enter_transferred_actor( #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -#[allow(clippy::too_many_lines)] // One durable step per crash, Transfer, and replay boundary. +#[allow(clippy::too_many_lines)] fn command_receipts_recover_both_gateway_crash_boundaries() { let topology = CommandTopology::new("party-command-crash-recovery"); let bot = topology.companions[0]; @@ -561,7 +542,6 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { let target = source.shard_handle(&topology.target).unwrap(); topology.publish_locators(&source); - // A worker claims and stops. Once the lease ends, another worker applies and replies once. let claimed = topology.queue(topology.source(), &topology.actor_one, bot); let claimed_intent = cached_intent(&source, claimed); source.claim_party_command_intent(claimed, 101).unwrap(); @@ -576,7 +556,6 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { vec![format!("{claimed}|Applied")] ); - // The target applies and the source never hears of it. let target_applied = topology.queue(topology.source(), &topology.actor_one, bot); let target_intent = cached_intent(&source, target_applied); source @@ -594,7 +573,6 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { .replies(topology.source(), target_applied) .is_empty()); - // A second source Module mints the same intent id. Its receipt stays distinct. topology.mirror_party( &[&topology.realm, &topology.target], topology.leader_two, @@ -617,8 +595,6 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { CompanionCommandOutcome::Applied ); - // The bot leaves for the source's map. No holder applies a command while it is in transit or - // after it has gone. topology.transfer( &topology.target, bot, @@ -659,7 +635,6 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { .receipts(&topology.target, delayed.intent_id) .is_empty()); - // The source finishes from the receipt that travelled, and a replayed apply finds it too. assert_eq!( party::run_party_command_intent(&source, &target_intent, 201).unwrap(), CompanionCommandOutcome::Applied @@ -685,7 +660,7 @@ fn command_receipts_recover_both_gateway_crash_boundaries() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -#[allow(clippy::too_many_lines)] // One durable step per issuer Transfer boundary. +#[allow(clippy::too_many_lines)] fn command_issuer_sequence_continues_after_the_issuer_transfers() { let topology = CommandTopology::new("party-command-issuer-sequence-transfer"); let bot = topology.companions[0]; @@ -699,7 +674,6 @@ fn command_issuer_sequence_continues_after_the_issuer_transfers() { let older = cached_intent(&source, older_id); assert_eq!(older.issuer_sequence, 1); - // The leader logs out and crosses to the target with its Account. target.provision_account(USERNAME_ONE, &[], &[]).unwrap(); let actor: serde_json::Value = serde_json::from_str(&topology.actor_one).unwrap(); let ownership = serde_json::to_string(&actor["ownership"]["some"]).unwrap(); @@ -743,7 +717,6 @@ fn command_issuer_sequence_continues_after_the_issuer_transfers() { assert!(issuer(topology.source()).is_empty()); assert_eq!(issuer(&topology.target)[0]["last_sequence"], "1"); - // The next command, from the new Shard, continues the issuer's sequence. let moved_actor = enter_transferred_actor(&topology, &runtime, &target, topology.leader_one); let newer_id = topology.queue(&topology.target, &moved_actor, bot); let newer = cached_intent(&target, newer_id); @@ -755,8 +728,6 @@ fn command_issuer_sequence_continues_after_the_issuer_transfers() { CompanionCommandOutcome::Applied ); - // The older command still finishes and replies on its own source. Whether a newer sequence - // supersedes it is the handler's decision; the fixture handler applies both. assert_eq!( party::run_party_command_intent(&source, &older, 702).unwrap(), CompanionCommandOutcome::Applied @@ -871,7 +842,6 @@ fn command_realm_admission_rejects_a_changed_unrelated_member() { let intent_id = topology.queue(topology.source(), &topology.actor_one, bot); let intent = cached_intent(&source, intent_id); let authority = realm.group_roster(topology.leader_one).unwrap(); - // Another companion leaves on Realm-core after the Gateway read the roster. topology.mirror_party( &[&topology.realm], topology.leader_one, @@ -893,7 +863,7 @@ fn command_realm_admission_rejects_a_changed_unrelated_member() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -#[allow(clippy::too_many_lines)] // One durable step per capacity, lane, and expiry boundary. +#[allow(clippy::too_many_lines)] fn command_capacity_waits_without_ack_then_recovers_or_expires() { let topology = CommandTopology::new("party-command-capacity"); let bot = topology.companions[0]; @@ -909,7 +879,6 @@ fn command_capacity_waits_without_ack_then_recovers_or_expires() { ) }; - // Fill the bot's receipt capacity. for token in 1..=32 { let intent = cached_intent(&source, queue()); assert_eq!( @@ -932,7 +901,6 @@ fn command_capacity_waits_without_ack_then_recovers_or_expires() { .defer_party_command_intent(waiting_id, 2_001) .unwrap(); - // A full lane does not hold back a command for a bot in another lane. let blocked: Vec = (0..16).map(|_| queue()).collect(); let lane_count = u64::from(lyracore_shared::group::COMMAND_DISPATCH_LANES); let bot_lane = bot % lane_count; @@ -950,7 +918,6 @@ fn command_capacity_waits_without_ack_then_recovers_or_expires() { .iter() .all(|id| intent_row(*id)["pending"] == "true")); - // Deferring every blocked command rotates the waiting command back to the lane head. for (offset, intent_id) in blocked.iter().enumerate() { let claim_token = 4_000 + offset as u64; source @@ -969,7 +936,6 @@ fn command_capacity_waits_without_ack_then_recovers_or_expires() { ); assert_eq!(retry_head["head_intent_id"], waiting_id.to_string()); - // A released receipt lets the waiting command apply. topology.node.assert_call_database( &topology.target, "party_command_fixture_release_receipt", @@ -984,7 +950,6 @@ fn command_capacity_waits_without_ack_then_recovers_or_expires() { vec![format!("{waiting_id}|Applied")] ); - // A command that waits past its deadline expires once, under its own claim. let expiry_id = blocked[0]; let expiry = cached_intent(&source, expiry_id); assert_eq!( diff --git a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs index 5f9b8932d..7e1eb0984 100644 --- a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs @@ -6,7 +6,6 @@ use crate::world::party::PartyOutcome; use crate::world::{Actor, PartyStore}; use lyracore_shared::group::realm_op; -/// Holds the process-wide topology variables for the whole test, then restores them. pub(super) struct TopologyEnv { shard_map: Option, realm_core: Option, @@ -42,8 +41,6 @@ impl Drop for TopologyEnv { } } -/// Every World Shard holds Realm-core's Roster Revision for the party and, while it exists, its -/// members. pub(super) fn mirrors_follow_realm_core( realm: &Coordinator, shards: &[(String, Coordinator)], @@ -100,7 +97,6 @@ fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { let shards = gateway.world_shards(); assert_eq!(shards.len(), 2, "the default World Shard and {INSTANCES}"); - // Straight to Realm-core: no `party::run`, so only the relay can update the mirrors. assert_eq!( realm .realm_group_op( @@ -137,7 +133,6 @@ fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { .collect::>() ); - // A party of one disbands, so the mirrors get the tombstone and forget the party. assert_eq!( realm .realm_group_op(realm_op::LEAVE, Actor::new(MEMBER).unwrap(), 0, 0, 0, 0) diff --git a/gateway/src/stdb/views.rs b/gateway/src/stdb/views.rs index 31e996638..e259e8d68 100644 --- a/gateway/src/stdb/views.rs +++ b/gateway/src/stdb/views.rs @@ -1,6 +1,3 @@ -//! Row→view converters and the thin row mirrors the gateway reads from subscription caches. -//! Pure projections from the generated `bindings` row types into the codec's view structs. - use super::bindings::*; pub(crate) fn hunter_pet_protocol_view( @@ -21,8 +18,6 @@ pub(crate) fn hunter_pet_protocol_view( } } -/// Flatten a `game_character` row into the codec's `CharacterView` (the char-select projection). -/// One mapper so the 17-field projection isn't copy-pasted per query (mirrors `entity_view`). pub(crate) fn character_view(c: Character) -> crate::codec::CharacterView { crate::codec::CharacterView { guid: c.guid, @@ -42,14 +37,13 @@ pub(crate) fn character_view(c: Character) -> crate::codec::CharacterView { y: c.y, z: c.z, first_login: c.first_login, - equipment: Default::default(), // filled by Coordinator::characters from item tables + equipment: Default::default(), played_total_secs: c.played_total_secs, session_start_micros: c.session_start_micros, - guild_id: 0, // filled by Coordinator::characters from Realm-core membership + guild_id: 0, } } -/// Flatten a `game_item_template` row into the codec's `ItemTemplateView`. pub(crate) fn item_template_view(t: ItemTemplate) -> crate::codec::ItemTemplateView { crate::codec::ItemTemplateView { entry: t.entry, @@ -108,12 +102,6 @@ pub(crate) fn item_template_view(t: ItemTemplate) -> crate::codec::ItemTemplateV } } -/// Map a `game_world_entity` row into the codec's `EntityView`. -/// -/// The entity carries its own live zone, resolved from terrain by the Module wherever a position is -/// established. `durable_zone` is the `game_character` copy, used only when the live row says `0` — -/// the Module's "no terrain covers this position", which a character parked off the imported slice -/// really can have. pub(crate) fn entity_view(e: WorldEntity, durable_zone: u32) -> crate::codec::EntityView { crate::codec::EntityView { guid: e.guid, @@ -171,26 +159,19 @@ pub(crate) fn entity_view(e: WorldEntity, durable_zone: u32) -> crate::codec::En sheet_ranged_dmg_min: e.sheet_ranged_dmg_min, sheet_ranged_dmg_max: e.sheet_ranged_dmg_max, npc_flags: e.npc_flags, - // Default the sheet's EFFECTIVE armor to the row's BASE armor (peers/creatures don't fold gear — - // your sheet only shows your own armor). The self-login CREATE overrides this with the gear-folded - // value (`Coordinator::effective_armor`); the relays then keep it live. effective_armor: e.armor, magic_resistances: [0; 6], owner_guid: e.owner_guid, - // Home fields are not on the world-entity row; caller fills them in from game_character when - // building the self-login sequence. Peer/creature views never need these, so 0 is fine. home_map: 0, home_zone: 0, home_x: 0.0, home_y: 0.0, home_z: 0.0, - // The Guild Projection is not on the entity row; the CREATE paths read it from Realm-core. guild_id: 0, guild_rank: 0, } } -/// Map a `game_corpse` row into the codec's `CorpseView` for the CORPSE CREATE_OBJECT. pub(crate) fn corpse_view(c: Corpse) -> crate::codec::CorpseView { crate::codec::CorpseView { guid: c.guid, @@ -206,8 +187,6 @@ pub(crate) fn corpse_view(c: Corpse) -> crate::codec::CorpseView { } } -/// Flatten a `game_gameobject` row joined with its `game_gameobject_template` into the codec's -/// `GameObjectView` for the GameObject CREATE_OBJECT (display + type come from the template). pub(crate) fn go_view(go: GameObject, tmpl: &GameObjectTemplate) -> crate::codec::GameObjectView { crate::codec::GameObjectView { guid: go.guid, @@ -227,19 +206,12 @@ pub(crate) fn go_view(go: GameObject, tmpl: &GameObjectTemplate) -> crate::codec } } -// --- thin row mirrors the gateway reads from subscription caches --- - #[derive(Clone, Debug)] pub struct RealmRow { pub id: u8, pub name: String, pub address: String, pub realm_type: u32, - /// `game_realm.flags` / `.timezone`, mirrored for the realm-list reply. Nothing reads them yet — - /// `logon::realms` builds `RealmInfo` from the four fields above plus a live character count, and - /// the 1.12 realm-list packet's flags/timezone are still written from gtker defaults. Carried - /// (rather than dropped) so filling those two wire fields in is a codec edit, not a re-thread of - /// the whole read path. #[allow(dead_code)] pub flags: u8, pub population: f32, @@ -250,8 +222,6 @@ pub struct RealmRow { #[derive(Clone, Debug)] pub struct AccountRow { pub id: u64, - /// The account name this row was looked UP by (`account_by_username`), echoed back for callers - /// that log or re-key on it. No current caller does — the SRP path already has the name in hand. #[allow(dead_code)] pub username: String, pub salt: Vec, diff --git a/gateway/src/stdb/world_index.rs b/gateway/src/stdb/world_index.rs index 503981726..44a33df45 100644 --- a/gateway/src/stdb/world_index.rs +++ b/gateway/src/stdb/world_index.rs @@ -1,37 +1,17 @@ -//! Indexed snapshots of subscribed World Shard rows. - use lyracore_shared::spatial::{grid_cell, grid_cell_id, BOX_HALF_SPAN}; use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -/// One world session's identity inside the index. Minted per world session (NOT per account: an -/// account can hold a logon socket and a world socket at once, and only the latter is a viewer), -/// so a relogin can never inherit the previous session's interest set. pub type SessionId = u64; -/// Which shard's coordinator cache holds an entity's row — an index into the dispatcher's shard -/// table, kept alongside the cell so a sweep can read the row back without probing every shard. pub type ShardId = usize; -/// The two independent row namespaces that share one viewer anchor index. -/// -/// GUIDs are unique inside each SpacetimeDB table, but the gateway does not assume a world entity -/// and a game object cannot have the same numeric GUID. Every row operation therefore names its -/// layer explicitly. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum EntityLayer { WorldEntity, GameObject, } -/// A grid cell, partition-qualified. `cell` is the packed `(grid_x, grid_y)` id -/// ([`grid_cell_id`]) — a bijection on the whole `(i32, i32)` domain, so this triple names exactly -/// one cell of exactly one `(map_id, instance_id)` partition and two distinct cells can never -/// collide. -/// -/// **`instance_id` is part of the key, not a filter applied afterwards**: a per-instance -/// dungeon copy reuses its source map's coordinates, so a key without the partition would place a -/// raid boss and an open-world critter in the same bucket. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct CellKey { pub map_id: u32, @@ -40,7 +20,6 @@ pub struct CellKey { } impl CellKey { - /// The key for grid cell `(gx, gy)` of one partition. pub fn at(map_id: u32, instance_id: u64, gx: i32, gy: i32) -> Self { Self { map_id, @@ -49,32 +28,19 @@ impl CellKey { } } - /// The key for a world position — the one call every row writer and every viewer recenter uses, - /// so a cell can never be computed two different ways. pub fn of_position(map_id: u32, instance_id: u64, x: f32, y: f32) -> Self { let (gx, gy) = grid_cell(x, y); Self::at(map_id, instance_id, gx, gy) } - /// This key's partition — the `(map_id, instance_id)` pair the AOI-off path scopes by. pub fn partition(&self) -> (u32, u64) { (self.map_id, self.instance_id) } - /// The 25 keys of the `(2·BOX_HALF_SPAN+1)`-square neighbourhood centred here, this key - /// included. - /// - /// Used in BOTH directions, which is the whole trick: centred on an ENTITY's cell it enumerates - /// the anchors of every viewer that can see it; centred on a VIEWER's anchor it enumerates the - /// cells of every entity they can see. Both are true only because the interest square is - /// symmetric — `neighbourhood_is_exactly_the_grid_box_bounds` (below) pins it against - /// `GridBox::bounds`, the predicate the deleted subscription SQL was built from. pub fn neighbourhood(&self) -> impl Iterator { self.neighbourhood_within(BOX_HALF_SPAN) } - /// The `(2·half_span+1)`-square of keys centred here, this key included. The box is the - /// `BOX_HALF_SPAN` case; a relay whose range exceeds the box (a yell) widens it. pub fn neighbourhood_within(&self, half_span: i32) -> impl Iterator { let (gx, gy) = lyracore_shared::spatial::grid_cell_of_id(self.cell); let (map_id, instance_id) = (self.map_id, self.instance_id); @@ -84,10 +50,7 @@ impl CellKey { }) } - /// (Inspection only — the dispatch never needs it; the differential tests do.) #[cfg(test)] - /// The `GridBox` this key anchors — the bridge back to the shared spatial crate, and what the - /// differential test drives the (frozen) subscription-SQL oracle from. pub fn grid_box(&self) -> lyracore_shared::spatial::GridBox { let (gx, gy) = lyracore_shared::spatial::grid_cell_of_id(self.cell); lyracore_shared::spatial::GridBox { @@ -98,14 +61,12 @@ impl CellKey { } } -/// What one layer gained and lost after a viewer crossed a cell boundary. #[derive(Default, Debug, PartialEq, Eq)] pub struct ViewerDelta { pub entered: Vec<(u64, ShardId)>, pub left: Vec<(u64, ShardId)>, } -/// Both layer deltas produced by one anchor move and one neighbourhood comparison. #[derive(Default, Debug, PartialEq, Eq)] pub struct RecenterDelta { pub world_entities: ViewerDelta, @@ -114,37 +75,21 @@ pub struct RecenterDelta { #[derive(Default)] struct EntityIndex { - /// cell → the guids resident in it. The forward direction of the interest question. cell_entities: HashMap>, - /// GUID to cell, Shard, and pet owner. The owner survives a reconnect so a missing - /// pet clears its Character's action bar as well as its client object. entity_cell: HashMap, } #[derive(Default)] struct Inner { - /// Separate row namespaces. Equal GUID values in the two tables never overwrite each other. world_entities: EntityIndex, game_objects: EntityIndex, - /// cell → the sessions ANCHORED there (not the sessions that can see it). Small by - /// construction — a `Vec` beats a `HashSet` at these sizes and keeps iteration cache-friendly. cell_viewers: HashMap>, - /// session → its anchor cell. The recenter's "where was it" half. viewer_cell: HashMap, } -/// The gateway-wide AOI index. One instance per gateway, shared by every shard's coordinator -/// dispatch and by every world session. -/// -/// Everything is behind ONE mutex on purpose: every operation is a handful of hash lookups with no -/// I/O and no encoding inside the critical section (see the module doc's complexity table), so the -/// lock is held for nanoseconds and sharding it would buy contention accounting rather than -/// throughput. The rule that keeps it that way: **never call out into relay/encode work while -/// holding this lock** — `viewers_of` and `visible_entities` return owned answers precisely so the -/// caller does its work after the guard drops. +/// Keep I/O, encoding and Relays outside the index mutex; copy results before calling them. pub struct WorldIndex { inner: Mutex, - /// `false` = `LYRACORE_AOI=0`: no cell scoping, whole-partition visibility (see the module doc). cell_scoped: bool, } @@ -157,8 +102,6 @@ impl WorldIndex { } fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { - // Matches the file-wide `.lock().unwrap()` discipline in `connection.rs`: a poisoned index - // must degrade to a possibly-stale read, never take every future relay down with it. self.inner.lock().unwrap_or_else(|p| { log::error!( "world index lock poisoned (a prior panic in a critical section) — recovering" @@ -181,12 +124,6 @@ impl WorldIndex { } } - // --------------------------------------------------------------------------------------- - // Entities - // --------------------------------------------------------------------------------------- - - /// Record (or move) an entity. Returns the cell it came FROM when this was a move, so a caller - /// can decide whether a viewer needs a DESTROY for it. pub fn upsert_entity( &self, layer: EntityLayer, @@ -217,7 +154,6 @@ impl WorldIndex { previous } - /// Which shard's coordinator cache holds this entity's row. pub fn shard_of(&self, layer: EntityLayer, guid: u64) -> Option { let inner = self.lock(); Self::layer(&inner, layer) @@ -226,7 +162,6 @@ impl WorldIndex { .map(|(_, s, _)| *s) } - /// Remove a row only from its current Shard. Returns its old cell and pet owner GUID. pub fn remove_entity( &self, layer: EntityLayer, @@ -249,9 +184,6 @@ impl WorldIndex { Some((key, owner_guid)) } - /// Where the index believes `guid` is: the anchor of a relay driven by a table with no cell of - /// its own (an aura names its target, a chat line its speaker). `None` for a guid it has never - /// seen (a row on a table it does not index, or one deleted already). #[cfg(test)] pub fn entity_cell(&self, layer: EntityLayer, guid: u64) -> Option { let inner = self.lock(); @@ -274,9 +206,6 @@ impl WorldIndex { .and_then(|(key, owner, _)| (*owner == shard).then_some(*key)) } - /// Replace one shard's snapshot, preserving viewers and rows already owned by another shard. - /// Rows and returned removals carry GUID, cell, and pet owner GUID. Former viewers can - /// drop vanished or moved rows and clear a vanished pet's action bar. pub fn replace_shard( &self, layer: EntityLayer, @@ -322,17 +251,11 @@ impl WorldIndex { removed } - // --------------------------------------------------------------------------------------- - // Viewers - // --------------------------------------------------------------------------------------- - - /// Register a session as a viewer anchored on `key`. pub fn add_viewer(&self, session: SessionId, key: CellKey) { let mut inner = self.lock(); Self::place_viewer(&mut inner, session, key); } - /// Unregister a session. Idempotent. pub fn remove_viewer(&self, session: SessionId) { let mut inner = self.lock(); if let Some(old) = inner.viewer_cell.remove(&session) { @@ -340,9 +263,7 @@ impl WorldIndex { } } - /// (Inspection only — the dispatch never needs it; the differential tests do.) #[cfg(test)] - /// A viewer's current anchor. pub fn viewer_cell(&self, session: SessionId) -> Option { self.lock().viewer_cell.get(&session).copied() } @@ -366,21 +287,10 @@ impl WorldIndex { } } - // --------------------------------------------------------------------------------------- - // The two interest queries - // --------------------------------------------------------------------------------------- - - /// Every session that can see cell `key` — the dispatch's hot path, called once per indexed row - /// delta. The explicit layer keeps the row namespace visible at every dispatch call even though - /// both layers use the same viewer anchors. O(25 + V) with cell scoping on; O(total viewers) - /// with `LYRACORE_AOI=0`. pub fn viewers_of(&self, _layer: EntityLayer, key: CellKey) -> Vec { self.viewers_within(key, BOX_HALF_SPAN) } - /// Every session anchored within `half_span` cells of `key`. The box (`BOX_HALF_SPAN`) is - /// what [`Self::viewers_of`] asks; a relay whose range exceeds the box widens the span so the - /// yard-range gate in its job still sees every candidate. O((2·half_span+1)² + V). pub fn viewers_within(&self, key: CellKey, half_span: i32) -> Vec { let inner = self.lock(); if !self.cell_scoped { @@ -401,9 +311,6 @@ impl WorldIndex { out } - /// Move a viewer's anchor and report the VISIBILITY delta the move implies, or `None` when the - /// anchor did not change (the overwhelmingly common per-heartbeat call — a plain hash lookup and - /// a compare, no allocation). pub fn move_viewer_delta(&self, session: SessionId, key: CellKey) -> Option { let mut inner = self.lock(); let old = *inner.viewer_cell.get(&session)?; @@ -412,9 +319,6 @@ impl WorldIndex { } Self::place_viewer(&mut inner, session, key); if !self.cell_scoped { - // No cell scoping: the box IS the partition, so a move inside one changes nothing and a - // move ACROSS one is a map/instance change, which tears the whole session's view down - // and rebuilds it through `sweep_into_view` rather than through a delta. return Some(RecenterDelta::default()); } let old_cells: HashSet = old.neighbourhood().collect(); @@ -448,9 +352,6 @@ impl WorldIndex { }) } - /// Every entity a viewer can see, with the shard whose cache holds each row — the login / - /// world-entry sweep, and the inverse the differential test checks. O(25 + E) with cell scoping - /// on; O(world) with `LYRACORE_AOI=0`. pub fn visible_entities(&self, layer: EntityLayer, session: SessionId) -> Vec<(u64, ShardId)> { let inner = self.lock(); let Some(anchor) = inner.viewer_cell.get(&session).copied() else { @@ -479,11 +380,6 @@ impl WorldIndex { out } - /// Can `session` currently see `guid`? The point query behind relays that are driven by a - /// DIFFERENT table (a stealth aura, say) and therefore have no cell of their own to dispatch on. - /// Before the shared-connection model these asked "is the row in my connection's cache", - /// which the box subscription made equivalent; on a shared connection the cache holds the whole world, so the question has - /// to be asked of the index instead. pub fn can_see(&self, layer: EntityLayer, session: SessionId, guid: u64) -> bool { let inner = self.lock(); let Some(anchor) = inner.viewer_cell.get(&session) else { @@ -503,7 +399,6 @@ impl WorldIndex { (ax - cx).abs() <= BOX_HALF_SPAN && (ay - cy).abs() <= BOX_HALF_SPAN } - /// Index sizes for tests: `(entities, viewers, occupied entity cells)`. #[cfg(test)] pub fn stats(&self, layer: EntityLayer) -> (usize, usize, usize) { let inner = self.lock(); @@ -521,10 +416,6 @@ mod tests { use super::*; use crate::stdb::aoi::box_queries; - // =========================================================================================== - // Fixture + the SQL ORACLE. - // - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Row { guid: u64, @@ -534,9 +425,6 @@ mod tests { gy: i32, } - /// Evaluate one generated AOI query against a fixture row. Understands exactly the grammar the - /// AOI builders emit — `col = n`, `col >= n`, `col <= n`, joined by ` AND ` — and PANICS on - /// anything else, so a future query shape cannot silently pass by being unparsed. fn sql_selects(query: &str, row: &Row) -> bool { let (_, where_clause) = query .split_once(" WHERE ") @@ -573,7 +461,6 @@ mod tests { }) } - /// The set the deleted per-player subscription would have delivered to a viewer for one layer. fn sql_visible(layer: EntityLayer, viewer: CellKey, rows: &[Row]) -> HashSet { let queries = box_queries(viewer.instance_id, &viewer.grid_box(), false); let table = match layer { @@ -590,7 +477,6 @@ mod tests { .collect() } - /// The same set, per the index. fn index_visible(index: &WorldIndex, layer: EntityLayer, session: SessionId) -> HashSet { index .visible_entities(layer, session) @@ -613,8 +499,6 @@ mod tests { index } - /// xorshift64* — a deterministic PRNG so a failure is reproducible from the seed alone, with no - /// new dev-dependency. struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { @@ -628,20 +512,6 @@ mod tests { } } - // =========================================================================================== - // THE DIFFERENTIAL PROPERTY (the shared-connection model's whole safety argument) - // =========================================================================================== - - /// For every viewer, over randomised fixtures: **the index's visible set equals the set the box - /// subscription's SQL predicate selects.** Both directions matter and both are checked by set - /// equality — a missing guid is a peer that never appears, an extra one is a peer visible - /// through a wall. - /// - /// The fixture deliberately packs viewers and entities into a small cell range (so boxes - /// overlap heavily, and edge cells are exercised constantly), spreads them over several - /// `(map_id, instance_id)` partitions (so a partition leak shows up), and includes negative - /// grid indices (legal: `grid_cell` inverts the axis, so out-of-bounds coordinates produce - /// them). #[test] fn the_index_visible_set_equals_the_box_subscription_sql_for_every_viewer() { let mut rng = Rng(0x5EED_1234_ABCD_0001); @@ -677,10 +547,6 @@ mod tests { } } - /// The INVERSE direction, over the same oracle: `viewers_of(cell)` must name exactly the - /// viewers whose box query selects a row in that cell. The dispatch calls this one on every row - /// delta, so an asymmetry here is a peer that spawns but never moves (or moves but never - /// spawns) — the two halves would be scoped by different predicates. #[test] fn viewers_of_a_cell_is_exactly_the_inverse_of_the_box_subscription_sql() { let mut rng = Rng(0x00C0_FFEE_0000_0042); @@ -793,9 +659,6 @@ mod tests { } } - /// The property the whole 25-lookup shape rests on: the neighbourhood of a cell is EXACTLY the - /// cell set `GridBox::bounds` accepts. If the shared crate's span ever changes, this fails here - /// rather than silently shrinking everyone's view. #[test] fn neighbourhood_is_exactly_the_grid_box_bounds() { let key = CellKey::at(3, 9, 12, -7); @@ -809,15 +672,11 @@ mod tests { .collect(); assert_eq!(got, want); assert_eq!(got.len(), ((2 * BOX_HALF_SPAN + 1) as usize).pow(2)); - // Every neighbour keeps the partition — a leak here is a dungeon copy visible from the - // open world. assert!(key .neighbourhood() .all(|k| k.map_id == 3 && k.instance_id == 9)); } - /// A widened span names exactly the anchors within that many cells on both axes, and the - /// box span is the ordinary `viewers_of` answer. #[test] fn viewers_within_widens_the_box_by_chebyshev_distance() { let index = WorldIndex::new(true); @@ -835,7 +694,7 @@ mod tests { } } } - index.add_viewer(session + 1, CellKey::at(0, 1, 0, 0)); // another instance + index.add_viewer(session + 1, CellKey::at(0, 1, 0, 0)); for span in 0..=8 { let got: HashSet = index.viewers_within(centre, span).into_iter().collect(); assert_eq!(got, expected[&span], "span {span}"); @@ -846,15 +705,11 @@ mod tests { ); } - // =========================================================================================== - // Bookkeeping - // =========================================================================================== - #[test] fn an_entity_move_leaves_no_residue_in_its_old_cell() { let index = WorldIndex::new(true); let a = CellKey::at(0, 0, 0, 0); - let b = CellKey::at(0, 0, 40, 40); // far outside a's box + let b = CellKey::at(0, 0, 40, 40); index.upsert_entity(EntityLayer::WorldEntity, 5, a, 0, 0); index.add_viewer(1, a); assert_eq!( @@ -924,8 +779,6 @@ mod tests { .move_viewer_delta(1, CellKey::at(0, 0, 1, 0)) .is_some()); assert_eq!(index.viewer_cell(1), Some(CellKey::at(0, 0, 1, 0))); - // Only one anchor is ever held: walking away must not leave the session visible from the - // cell it started in. index.upsert_entity(EntityLayer::WorldEntity, 5, CellKey::at(0, 0, 0, 0), 0, 0); index.move_viewer_delta(1, CellKey::at(0, 0, 30, 30)); assert!(index @@ -937,11 +790,9 @@ mod tests { ); index.remove_viewer(1); assert_eq!(index.viewer_cell(1), None); - index.remove_viewer(1); // idempotent + index.remove_viewer(1); } - /// `LYRACORE_AOI=0` reproduces the pre-AOI global-subscription semantics: whole partition, and - /// nothing across a partition boundary. #[test] fn the_aoi_off_hatch_sees_the_whole_partition_and_nothing_outside_it() { let index = WorldIndex::new(false); @@ -953,8 +804,8 @@ mod tests { 0, 0, ); - index.upsert_entity(EntityLayer::WorldEntity, 3, CellKey::at(0, 77, 0, 0), 0, 0); // another instance - index.upsert_entity(EntityLayer::WorldEntity, 4, CellKey::at(1, 0, 0, 0), 0, 0); // another map + index.upsert_entity(EntityLayer::WorldEntity, 3, CellKey::at(0, 77, 0, 0), 0, 0); + index.upsert_entity(EntityLayer::WorldEntity, 4, CellKey::at(1, 0, 0, 0), 0, 0); index.add_viewer(1, CellKey::at(0, 0, 0, 0)); assert_eq!( index_visible(&index, EntityLayer::WorldEntity, 1), @@ -969,9 +820,6 @@ mod tests { .is_empty()); } - /// `can_see` is a point query and `visible_entities` is the set form of the same question — - /// they must never disagree, or the stealth reveal would show a peer the box does not contain - /// (or hide one it does). #[test] fn can_see_agrees_with_visible_entities_everywhere() { let mut rng = Rng(0x1234_5678_9ABC_DEF1); diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 76f1a22ef..9b1a8ba82 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -1,9 +1,3 @@ -//! Shared viewer routing, AOI indexes and Relay queue lifetime. -//! -//! Protocol Families declare their table callbacks beside their codecs. Callbacks update indexes -//! and enqueue owned rows; the World Session writer builds packets. Arming registers callbacks -//! before reconciling the cache on the same Shard pump. - use crate::codec::entity::subscriptions::{entity_key, offer_create_job}; use crate::codec::gameobject::subscriptions::gameobject_appeared; use crate::codec::social::subscriptions::{ @@ -23,78 +17,33 @@ use super::connection::Coordinator; use super::world_index::{CellKey, EntityLayer, SessionId, ShardId, WorldIndex}; use crate::world::{Outbound, SessionTx}; -/// Everything the dispatch needs to know about one live world session. Registered at world entry -/// (`subscribe_player_events`) and dropped by `PlayerSubscriptions`'s guard. -/// -/// Every field here is session relay state used by the shared callbacks' writer jobs. pub(crate) struct Viewer { - /// Cleared when this World Session leaves. Queued Relays must not reach its replacement. + /// Cleared on departure so queued Relays cannot reach the replacement World Session. pub(crate) active: std::sync::atomic::AtomicBool, pub(crate) session: SessionId, pub(crate) self_guid: u64, - /// The account identity stamped on identity-addressed event rows. pub(crate) bound_identity: spacetimedb_sdk::Identity, - /// The map this session is on. Constant for the session's lifetime — every path that changes - /// a character's map (cross-map teleport, portal entry) tears the subscriptions - /// down and calls `subscribe_player_events` again, which builds a fresh viewer. pub(crate) map_id: u32, - /// The partition this session is playing in — supplied by the caller (login row / transfer - /// plan), never re-read from a cache. The lesson: the authoritative value is known at - /// construction, and a second possibly-stale copy can only disagree with it. pub(crate) instance_id: u64, - /// The zone this session is standing in — the weather relay's whole audience predicate. - /// - /// Unlike `map_id` this changes inside one session: walking, a same-map teleport and a taxi - /// landing all cross zone boundaries without tearing the viewer down, so it is interior-mutable - /// and written from the self entity's own `zone_id` deltas. `0` is the unresolved zone and - /// matches no weather row. Seeded at `add_viewer` time with the zone world entry already sent - /// weather for, so the first stored value is never a phantom crossing. pub(crate) zone_id: AtomicU32, pub(crate) tx: SessionTx, - /// WorldEntity GUIDs already shown to this viewer. pub(crate) created: Arc>>, pub(crate) gates: Arc, - /// PLAYER_SKILL_INFO slot map `(skill_line → slot, next_free)` — seeded at login from the same - /// deterministic layout the CREATE used. pub(crate) skill_slots: Arc, u8)>>, - /// Areas known before this viewer entered the world. Reconnect replays use the same set, so - /// restoring fog never repeats the discovery feedback. pub(crate) explored: Mutex, - /// This viewer's motion-coalescing buffer. Shared-dispatch-only state (no per-player - /// twin), so it is constructed inline and pinned by no tripwire. pub(crate) motion_pending: Arc, - /// What the Member Stats Relay last sent about each group mate. A new viewer starts empty, so - /// world entry and a group join both get every field on the next tick. pub(crate) member_stats: crate::world::MemberStatsRecord, - /// The Characters on this viewer's own ignore list. Seeded at world entry from its contact - /// rows on the Home Shard, kept current by that Shard's contact Relay, and rebuilt when the - /// Shard reconciles after a resubscribe. The Realm Chat Relay reads it for ignorable lines only. pub(crate) ignored: Mutex>, - /// The Characters on this viewer's own friend list. Seeded and kept current exactly like - /// `ignored`, from the same contact rows and callbacks. The Account Claim Relay reads it to - /// decide who hears a login/logout notice; `CMSG_FRIEND_LIST` reads it for the friend guids. pub(crate) friends: Mutex>, - /// This viewer's own team (`lyracore_shared::faction::TEAM_ALLIANCE`/`TEAM_HORDE`), read once - /// at world entry from its race. A friend online/offline notice goes only to a same-team - /// viewer, the faction separation the friends pane has in vanilla. pub(crate) team: u32, - /// The group events already relayed to this viewer, so one transaction's rows go out once - /// and in order. pub(crate) group_events: GroupEventCursor, } impl Viewer { - /// Move this viewer's weather routing into `zone_id`, answering whether that was a real - /// crossing — the ONE place [`Viewer::zone_id`] is written after construction. - /// - /// The unresolved zone (`0`) is not a crossing and never overwrites a known zone: it means the - /// Module's terrain could not answer, not that the player left every zone, and treating it as a - /// destination would send fine weather over a real storm. pub(crate) fn enter_zone(&self, zone_id: u32) -> bool { zone_id != 0 && self.zone_id.swap(zone_id, Ordering::Relaxed) != zone_id } - /// Whether this viewer ignores `guid`. pub(crate) fn ignores(&self, guid: u64) -> bool { self.ignored .lock() @@ -111,7 +60,6 @@ impl Viewer { } } - /// Whether this viewer lists `guid` as a friend. pub(crate) fn is_friend(&self, guid: u64) -> bool { self.friends .lock() @@ -128,7 +76,6 @@ impl Viewer { } } - /// A snapshot of this viewer's ignore guids, for `CMSG_FRIEND_LIST`'s `SMSG_IGNORE_LIST`. pub(crate) fn ignored_guids(&self) -> Vec { self.ignored .lock() @@ -138,7 +85,6 @@ impl Viewer { .collect() } - /// A snapshot of this viewer's friend guids, for `CMSG_FRIEND_LIST`'s `SMSG_FRIEND_LIST`. pub(crate) fn friend_guids(&self) -> Vec { self.friends .lock() @@ -149,8 +95,6 @@ impl Viewer { } } -/// Per-viewer exploration replay state. `area_bit` is stable across a transfer's re-minted row -/// ids, so it identifies discoveries rather than the table's surrogate key. #[derive(Default)] pub(crate) struct ExplorationReplay { announced: HashSet, @@ -166,18 +110,11 @@ impl ExplorationReplay { } } -/// Dense-crowd coalescing: the pump UPSERTS the latest motion row per mover here and queues -/// at most ONE flush job per viewer; a superseded heartbeat dies in the map instead of living as a -/// queued job. A viewer needs only the LATEST position of each mover per flush — with N movers in -/// a cell the per-viewer queue cost is now O(distinct movers), not O(heartbeats), which is the -/// difference between a packed cell starving logins and not (the measured dense-500 numbers). #[derive(Default)] pub(crate) struct MotionPending { pub(crate) entity: Mutex>>, pub(crate) creature: Mutex>>, - /// True while a flush job sits in the viewer's queue. Cleared by the flush BEFORE it drains, - /// so an upsert racing the drain re-queues a (possibly empty, harmless) follow-up flush - /// rather than being stranded. + /// Clear before draining so a racing upsert can queue its own flush. pub(crate) flush_queued: std::sync::atomic::AtomicBool, } @@ -186,11 +123,9 @@ struct RegisteredViewer { shard: ShardId, } -/// A viewer address carried by owner-GUID-scoped rows. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct OwnerGuid(pub(crate) u64); -/// A viewer address carried by identity-scoped rows. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct BoundIdentity(pub(crate) spacetimedb_sdk::Identity); @@ -246,7 +181,6 @@ impl ViewerRegistry { } } -/// Auras indexed by shard and target. Transfer copies on different shards remain independent. #[derive(Default)] pub(crate) struct AuraIndex { by_target: Mutex>>, @@ -277,8 +211,6 @@ impl AuraIndex { } } - /// Replace the Shard snapshot and return previous auras grouped by every old or current target. - /// A newly present target has an empty previous set. Each target needs one snapshot Relay. pub(crate) fn replace_shard( &self, shard: ShardId, @@ -332,31 +264,14 @@ impl AuraIndex { } } -/// The gateway-wide shared view: one layered spatial index, the aura index, the live viewer -/// registry, and the shard table the index's [`ShardId`]s point into. pub(crate) struct WorldView { - /// One authoritative viewer anchor with independent world-entity and game-object layers. pub(crate) spatial: WorldIndex, pub(crate) auras: AuraIndex, viewers: RwLock, - /// [`ShardId`] → that shard's coordinator handle. Written once at startup, read on every - /// dispatch; the order fixes the ids, so it must never be reordered after arming. + /// Shard-vector order fixes ShardIds; never reorder it after arming. shards: RwLock>, next_session: AtomicU64, - /// Characters whose client was last sent a running Instance Removal countdown. It outlives the - /// viewer: a world-port drops the source viewer before the source Shard deletes the row, so - /// the next world entry must hide the countdown itself. countdowns_shown: Mutex>, - /// Characters this Gateway has itself watched the `game_character.online` false→true edge - /// for (`character_online_changed`), not yet paired with a later Account Claim close. The - /// friend-notice dedup state that lets a claim close (`account_claim_changed`) answer - /// FRIEND_OFFLINE only for a Character THIS Gateway confirmed came online — never for a claim - /// that opened and closed without `player_login` ever running, which happens whenever - /// `enter_world` refuses after `claim_session` already succeeded (`CMSG_PLAYER_LOGIN`'s - /// handler runs them in that order). A Gateway that starts, or reconnects a Shard, after a - /// Character already logged in has no mark for it and answers nothing for that Character's - /// NEXT close — a known gap, not a correctness hazard: the friends pane itself still reads - /// live Realm Presence, so it is never wrong, only occasionally a push notification behind. online_characters: Mutex>, } @@ -373,7 +288,6 @@ impl WorldView { } } - /// Record whether `character_guid`'s client now shows an Instance Removal countdown. pub(crate) fn note_countdown(&self, character_guid: u64, shown: bool) { let mut countdowns = self.countdowns_shown.lock().unwrap(); if shown { @@ -390,8 +304,6 @@ impl WorldView { .contains(&character_guid) } - /// Record that `guid` reached the `game_character.online` transition — the one signal a - /// later Account Claim close checks before it answers FRIEND_OFFLINE. pub(crate) fn mark_character_online(&self, guid: u64) { self.online_characters .lock() @@ -399,8 +311,6 @@ impl WorldView { .insert(guid); } - /// Consume `guid`'s online mark, if this Gateway ever recorded one. `true` only for a - /// Character this Gateway itself watched come online. pub(crate) fn take_character_online(&self, guid: u64) -> bool { self.online_characters .lock() @@ -426,11 +336,6 @@ impl WorldView { .copied() } - /// The owner's viewer regardless of shard — for a caller that only knows "my own session", - /// never a peer's (a shard-scoped row Relay resolves through - /// [`Self::viewer_of_owner_on_shard`] instead, so a transfer's source shard cannot address the - /// viewer after it re-registers on the destination shard). `CMSG_FRIEND_LIST`'s own contact - /// read is the production caller: it only ever asks for the CONNECTED session's own guid. pub(crate) fn viewer_of_owner(&self, owner: OwnerGuid) -> Option> { let registry = self.viewers.read().unwrap(); let session = registry.session_of_owner.get(&owner.0)?; @@ -440,9 +345,6 @@ impl WorldView { .map(|registered| registered.viewer.clone()) } - /// The owner's viewer, only when it is registered on `shard`. Shard-row owner relays - /// (teleport/quest/exploration/reputation/item) resolve through this so a transfer's source - /// shard cannot address the viewer after it re-registered on the destination shard. pub(crate) fn viewer_of_owner_on_shard( &self, shard: ShardId, @@ -491,7 +393,6 @@ impl WorldView { .collect() } - /// Every registered viewer on every shard, for a Relay that visits each World Session. pub(crate) fn all_viewers(&self) -> Vec> { let registry = self.viewers.read().unwrap(); registry @@ -505,7 +406,6 @@ impl WorldView { self.shards.read().unwrap().get(id).cloned() } - /// The Shard `session`'s viewer is registered on. pub(crate) fn shard_of_viewer(&self, session: SessionId) -> Option { let registry = self.viewers.read().unwrap(); registry @@ -514,14 +414,10 @@ impl WorldView { .map(|registered| registered.shard) } - /// Mint a session id. Monotonic and never reused, so a relogin can never inherit a previous - /// session's interest set. pub(crate) fn next_session_id(&self) -> SessionId { self.next_session.fetch_add(1, Ordering::Relaxed) } - /// Register a live session on the shard owned by `coord`. The coordinator must be one of the - /// stable handles installed by [`Self::set_shards`]. pub(crate) fn add_viewer( &self, coord: &Coordinator, @@ -557,8 +453,6 @@ impl WorldView { self.spatial.remove_viewer(session); } - /// Geometric recipients plus the row owner's session. The owner lookup lives in the viewer - /// registry because it is session identity, not spatial state. pub(crate) fn world_entity_recipients( &self, shard: ShardId, @@ -581,15 +475,6 @@ impl WorldView { recipients } - /// The viewers a cell-anchored event reaches: every session anchored within `half_span` cells - /// of `key`, plus the sessions of the characters the row names. The owner leg keeps a - /// self-addressed packet (the attacker's ATTACKSTOP, the caster's cast result, a player's own - /// aura timer) reaching its owner while the owner's anchor and the row's cell disagree by a - /// heartbeat. When the source Shard has no anchor, only its owners receive the event. - /// Every recipient must still belong to that Shard after a transfer. - /// - /// The per-viewer gate the job runs is the final filter, unchanged; this only stops the pump - /// enqueueing a job for every viewer on the shard so it can reject the row later. pub(crate) fn cell_audience( &self, shard: ShardId, @@ -616,18 +501,11 @@ impl WorldView { .collect() } - // =========================================================================================== - // Arming — one registration per shard, re-run by the coordinator watchdog after a reconnect. - // =========================================================================================== - - /// Give this view its shard table. Called once, from `Coordinator::connect`, before arming. pub(crate) fn set_shards(&self, shards: Vec) { *self.shards.write().unwrap() = shards; } } -/// Register Relays and reconcile the snapshot between messages on this Shard's pump. -/// Connection replacement joins the old pump before this connection becomes readable. pub(crate) fn arm_shard(view: Arc, coord: Coordinator, shard: ShardId) -> Result<()> { let owner = coord.clone(); coord.0.on_pump(move |conn| { @@ -636,39 +514,16 @@ pub(crate) fn arm_shard(view: Arc, coord: Coordinator, shard: ShardId }) } -/// Register the cross-shard private relays on the REALM-CORE connection: group -/// events, Realm Chat Lines (whispers included), Channel Notices and Mail Arrivals written -/// realm-side for recipients whose home shard is elsewhere. Same recipient-keyed dispatchers as -/// `arm_shard`'s private tier, armed ONCE per realm-core connection instead of once per session; -/// the last per-session registrations are gone. -/// -/// Only called when realm-core is a DISTINCT database (`Coordinator::connect` gates it): on a -/// single-database gateway the world shard's own `arm_shard` registration already watches these -/// tables, and a second registration would deliver every private packet twice. -/// -/// `coord` is a WORLD handle (the default shard) — `group_event_appeared` needs one for the -/// QUEST_SHARE detail JOIN; realm-core's own cache holds no quest catalogue. -/// -/// Re-reads `realm.0.coord()` on every call, so it doubles as the watchdog's post-reconnect -/// re-arm hook, exactly like `arm_shard`. pub(crate) fn arm_realm_private(view: Arc, realm: Coordinator, coord: Coordinator) { let guard = realm.0.coord(); crate::stdb::relays::register_realm(view, realm.clone(), coord, &guard.conn.db); } -/// Reads one Character's `(guild_id, rank_id)` from the Realm-core cache. A closure so relay -/// tests can supply membership without a connection. pub(crate) type GuildMembershipRead = Arc Option<(u32, u32)> + Send + Sync>; -/// Reads the Guild data one settings-changing Guild Event needs from the Realm-core cache: -/// `TABARD_CHANGED` needs the query response, `ROSTER_TO_ACTOR` needs the roster, `ROSTER_REFRESH` -/// needs both. `guild_event_appeared` calls this once per event and shares the result with every -/// recipient's job. A closure so relay tests can supply a snapshot without a live connection. pub(crate) type GuildRosterSnapshotRead = Arc crate::world::GuildEventSnapshot + Send + Sync>; -/// Reads the open Petition of one Guild Charter from the Realm-core cache. A closure so relay -/// tests can supply a Petition without a connection. pub(crate) type PetitionRead = Arc Option + Send + Sync>; #[cfg(test)] @@ -676,47 +531,25 @@ fn is_initial_apply(event: &spacetimedb_sdk::Event) -> bool { matches!(event, spacetimedb_sdk::Event::SubscribeApplied) } -/// Every viewer a shard-broadcast family may reach: the sessions whose characters `shard`'s -/// database owns — the same audience the per-player subscriptions produced (each player connection -/// was to its own shard's database, and the base queries were unscoped `SELECT *`s). Filtered by -/// direct shard membership; family-specific gates (instance, range) run per viewer at the call site. pub(crate) fn viewers_on_shard(view: &WorldView, shard: ShardId) -> Vec> { view.viewers_on_shard(shard) } -// =============================================================================================== -// Cell-anchored event dispatch. The Module stamps the actor's cell on the event row (or the -// actor's own row is in the index), so the audience is the actor's box rather than the shard; -// each job's per-viewer gate stays the final filter. -// =============================================================================================== - -/// Cells a yard range can reach from an anchor cell: the range in cells, rounded up, plus one -/// because the listener's anchor is its client position while the gate reads its last durable -/// row, and the two can sit one cell apart. pub(crate) fn cells_within(range_yd: f32) -> i32 { (range_yd / GRID_CELL_SIZE).ceil() as i32 + 1 } -/// The database a `game_group_event` row came from. Each database numbers its rows itself. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum GroupEventSource { Shard(ShardId), RealmCore, } -/// The newest `game_group_event` id relayed to one viewer from each source database. #[derive(Default)] pub(crate) struct GroupEventCursor(Mutex>); impl GroupEventCursor { - /// The rows to relay for the insert callback of `row`, oldest first. - /// - /// The SDK fires the inserts of one transaction in hash order, and the client must hear them - /// in insert order: a filled Meeting Stone party's `COMPLETE` comes before `SETQUEUE(0, NONE)`. - /// So the first callback of a transaction takes every row of that transaction for this - /// recipient from `cached`, which reads the table that already holds the whole transaction, and - /// the later callbacks find theirs taken without calling it. Rows of one transaction share - /// `created_at`. + /// SDK insert callbacks use hash order; read the committed batch and emit rows in id order. pub(crate) fn take( &self, source: GroupEventSource, @@ -745,17 +578,10 @@ impl GroupEventCursor { } } -/// The `game_group_event` table as one source database last cached it, grouped by recipient guid. -/// Every recipient of a transaction takes its rows from the same read. #[derive(Default)] pub(crate) struct GroupEventSnapshot(HashMap>); impl GroupEventSnapshot { - /// [`GroupEventCursor::take`] over the recipient's rows of the snapshot. - /// - /// `read_table` runs only when the recipient's group lacks `row`, which is the first callback - /// of each transaction. The cache already holds the whole transaction by then, so the later - /// callbacks reuse the read. A row the table lacks reads it again. pub(crate) fn take( &mut self, cursor: &GroupEventCursor, @@ -782,8 +608,6 @@ impl GroupEventSnapshot { } } -/// Every World Session on this Gateway whose Character is a member of `guild_id`. The one -/// audience read for broadcast Guild Events. pub(crate) fn online_audience( view: &WorldView, membership: &GuildMembershipRead, @@ -795,8 +619,7 @@ pub(crate) fn online_audience( .collect() } -// The relay-order tests below scan this file's own source, so the production items that follow -// them must stay where they are. +// Source-scanning Relay tests require production items to remain below them. #[allow(clippy::items_after_test_module)] #[cfg(test)] mod cell_audience_tests; @@ -835,9 +658,6 @@ mod routing_tests { use std::sync::{Arc, Mutex}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; - /// The private tier's whole privacy guarantee (group/resurrect): the addressee and - /// NOBODY else. The zero cases are the leak vectors — an unset recipient must never match a - /// real viewer and a real recipient must never match a half-initialized viewer. #[test] fn private_rows_reach_their_addressee_and_nobody_else() { assert!(private_recipient_audience(42, 42)); @@ -865,8 +685,6 @@ mod routing_tests { let viewer = viewer(1, 9001); view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), 0); - // Moving the anchor one cell right adds x=3 to the 5x5 neighbourhood. The same GUID in - // both row tables must appear once in each independent layer delta. let entering = CellKey::at(0, 0, 3, 0); view.spatial .upsert_entity(EntityLayer::WorldEntity, 77, entering, 0, 0); @@ -879,7 +697,6 @@ mod routing_tests { assert_eq!(delta.world_entities.entered, vec![(77, 0)]); assert_eq!(delta.game_objects.entered, vec![(77, 1)]); - // Removing one table's row cannot erase the equal-valued GUID in the other table. assert_eq!( view.spatial.remove_entity(EntityLayer::WorldEntity, 77, 0), Some((entering, 0)) @@ -908,7 +725,7 @@ mod routing_tests { view.remove_viewer(old.session); assert_eq!(view.session_of_owner(9001), Some(new.session)); assert!(view.viewer(new.session).is_some()); - view.remove_viewer(old.session); // idempotent + view.remove_viewer(old.session); assert_eq!(view.session_of_owner(9001), Some(new.session)); assert_eq!( view.viewer_of_identity(BoundIdentity(new.bound_identity)) @@ -1060,10 +877,6 @@ mod routing_tests { } } - /// The reconnect contract, behaviorally: a re-arm builds a fresh callback generation over the - /// same shared view (each generation is a `view.clone()` capture, exactly what `wire_insert` - /// takes), and the viewer registered before the reconnect still receives owner relays through - /// the new generation — while the fresh connection's initial-apply replays stay suppressed. #[test] fn a_rearmed_owner_relay_still_reaches_the_logged_in_viewer() { let view = std::sync::Arc::new(WorldView::new(true)); @@ -1079,8 +892,6 @@ mod routing_tests { first_generation(&teleport(owner.self_guid)); assert_eq!(queued_job(&owner_rx).len(), 1); - // The watchdog's re-arm: a new generation on the replacement connection; the old one - // dies with its `LiveConn`. The viewer never re-registers. let rearmed = arm(&view); drop(first_generation); rearmed(&teleport(owner.self_guid)); @@ -1094,8 +905,6 @@ mod routing_tests { "each live row queues exactly once" ); - // The replay flavor the re-armed generation races: initial-apply rows are history, not - // live deltas, and the event-family registrations drop them before dispatch. assert!(is_initial_apply(&spacetimedb_sdk::Event::SubscribeApplied)); assert!(!is_initial_apply(&spacetimedb_sdk::Event::Transaction)); } @@ -1210,11 +1019,6 @@ mod routing_tests { assert!(queued_job(&rx).is_empty(), "there are no resident rows"); assert!(rx.try_recv().is_err(), "one sweep must be one writer job"); } - /// **The other half of the fix:** the online mark is one-shot and starts unset, so a claim - /// that closes for a Character this Gateway never watched come online — a login that fails - /// after `claim_session` succeeds, before `enter_world`/`player_login` ever runs — has nothing - /// to consume. No Fake reaches a Coordinator-backed `account_claim_changed` call to prove the - /// end-to-end "no notice at all" path for a failed login directly. #[test] fn take_character_online_answers_false_for_a_login_it_never_observed() { let view = WorldView::new(true); @@ -1262,8 +1066,6 @@ mod reconciliation_tests { const SPEAKER: u64 = 10; - /// A resubscribe replays inserts but not the deletes it missed. Reconciling the Shard must - /// rebuild each of its viewers' ignore sets from the rows that are there now. #[test] fn reconciling_a_shard_rebuilds_its_viewers_ignore_sets_from_the_contact_rows() { let view = Arc::new(WorldView::new(true)); @@ -1304,7 +1106,6 @@ mod reconciliation_tests { ); } - /// A resubscribe rebuilds the friend set the same way it rebuilds the ignore set. #[test] fn reconciling_a_shard_rebuilds_its_viewers_friend_sets_from_the_contact_rows() { let view = Arc::new(WorldView::new(true)); @@ -1348,9 +1149,6 @@ mod group_event_order_tests { rows.iter().map(|row| row.id).collect() } - /// One transaction wrote rows 10 to 12 for the recipient and row 13 for someone else. Row 5 is - /// an older transaction's. The SDK fires the inserts in hash order; the recipient still gets - /// 10, 11, 12 once, in that order. #[test] fn the_first_callback_of_a_transaction_relays_its_rows_in_insert_order() { let cached = [ @@ -1377,8 +1175,6 @@ mod group_event_order_tests { assert!(later.is_empty()); } - /// The next transaction starts after the cursor, even with the same timestamp, and another - /// database's ids are counted apart. #[test] fn each_source_database_keeps_its_own_cursor() { let cursor = GroupEventCursor::default(); @@ -1402,9 +1198,6 @@ mod group_event_order_tests { ); } - /// A Raid roster list writes two rows for each of 40 members in one transaction. The SDK - /// fires an insert callback per row; the table is read once, and every member relays both of - /// its rows once, oldest first. #[test] fn one_transaction_reads_the_table_once_for_every_recipient() { let members = 40_u64; @@ -1441,7 +1234,6 @@ mod group_event_order_tests { } } - /// A row the snapshot lacks starts the next transaction, even with the same timestamp. #[test] fn the_next_transaction_reads_the_table_again() { let table = [ @@ -1468,13 +1260,7 @@ mod group_event_order_tests { } } -// =============================================================================================== -// The pump→writer hand-off -// =============================================================================================== - -/// Queue work for one World Session. The socket writer checks its lifetime before running it. -/// Jobs already running finish before the destination entry batch on the same FIFO writer; -/// jobs selected by an old callback but enqueued after that batch are discarded here. +// Old jobs finish before destination entry on the FIFO writer; late stale jobs must drop. pub(crate) fn enqueue( viewer: Arc, job: impl FnOnce(Arc) -> Vec + Send + 'static, @@ -1488,23 +1274,14 @@ pub(crate) fn enqueue( }))); } -// =============================================================================================== -// Viewer recentre — the session-thread half -// =============================================================================================== - -/// Move a session's anchor and relay the visibility delta the move implies: a CREATE for everything -/// that entered the box, a DESTROY for everything that left it. pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, x: f32, y: f32) { let key = CellKey::of_position(map_id, viewer.instance_id, x, y); let Some(delta) = view.spatial.move_viewer_delta(viewer.session, key) else { - return; // no crossing: the by-far-most-common per-heartbeat call, and it allocates nothing + return; }; AOI_RECENTERS.fetch_add(1, Ordering::Relaxed); - // Everything below is enqueued as a JOB rather than sent directly, for ORDERING: the shard - // pumps are concurrently enqueueing CREATE/VALUES jobs for the same session, and putting both - // producers on the one queue is what makes "the DESTROY for a peer I walked away from lands - // after the CREATE that showed it to me" true rather than a race between two threads. + // Queue AOI changes with shard Relays so DESTROY cannot overtake a peer's CREATE. { for (guid, shard) in delta.world_entities.left { let owner_guid = view @@ -1567,9 +1344,7 @@ pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, } } -/// Offer resident rows in one writer job. Reading the AOI and caches inside the job keeps a -/// queued DESTROY from being followed by a CREATE built from an earlier resident snapshot. -/// The writer sends every CREATE and its follow-up VALUES before running another Relay. +// Read residents inside the writer job so a stale snapshot cannot CREATE after DESTROY. pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { let view = view.clone(); enqueue(viewer.clone(), move |viewer| { @@ -1628,8 +1403,6 @@ pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { ); } - // The login sequence queued self CREATE before registration, so the resident flight - // follows it on this writer too. for coord in view.shards.read().unwrap().iter() { out.extend( crate::codec::taxi::subscriptions::resident_taxi_spline_outbound( @@ -1639,8 +1412,6 @@ pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { ), ); } - // Only the Home Shard can hold this Character's countdown. Another Shard's row would be a - // Transfer source's delete still on its way. if let Some(coord) = view .shard_of_viewer(viewer.session) .and_then(|shard| view.shard(shard)) @@ -1655,8 +1426,6 @@ pub(crate) fn sweep_into_view(view: &Arc, viewer: &Arc) { }); } -/// Replace this Shard's rows between SDK messages. Its old pump has stopped, and other -/// Shards retain their current rows. fn seed_shard_from_cache(view: &Arc, shard: ShardId, db: &RemoteTables) { reconcile_shard( view, @@ -1741,7 +1510,6 @@ fn reconcile_shard( } } } - // Existing WorldEntity rows retain their CREATE dedup state. for row in entities { if view.spatial.shard_of(EntityLayer::WorldEntity, row.guid) != Some(shard) { continue; @@ -1757,8 +1525,6 @@ fn reconcile_shard( offer_create_job(view, shard, session, &row); } } - // Refresh resident GameObjects too: state and rotation may have changed while disconnected. - // Their CREATE packets do not use the WorldEntity dedup set. for row in objects { if view.spatial.shard_of(EntityLayer::GameObject, row.guid) == Some(shard) { gameobject_appeared(view, shard, &row); diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index 09cd51b9e..156c37663 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1,5 +1,3 @@ -//! The cell-anchored relays against the whole-shard-then-gate path they replaced. - use super::*; use crate::codec::breath::subscriptions::breath_relay_appeared; use crate::codec::combat::subscriptions::{ @@ -29,11 +27,9 @@ const PLAYER_BASE: u64 = 0x1000; const CREATURE_BASE: u64 = 0xF130_0000_0000_2000; const YELL: u8 = 1; const EMOTE: u8 = lyracore_shared::chat::broadcast_chat::EMOTE; -const HUMAN_RACE: u8 = 1; // Alliance -const ORC_RACE: u8 = 2; // Horde +const HUMAN_RACE: u8 = 1; +const ORC_RACE: u8 = 2; -/// A randomised realm: players and creatures at random positions inside a `span_yd` square, one -/// partition, every viewer's `created` set holding exactly what its box shows it plus itself. struct Realm { view: Arc, viewers: Vec>, @@ -128,7 +124,6 @@ fn realm(rng: &mut Rng, players: usize, creatures: usize, span_yd: f32) -> Realm } impl Realm { - /// A random unit: a player two times in three, else a creature. fn actor(&self, rng: &mut Rng) -> u64 { if rng.below(3) < 2 { self.viewers[rng.below(self.viewers.len())].self_guid @@ -146,7 +141,6 @@ impl Realm { self.viewers.iter().map(|v| v.session).collect() } - /// The whole-shard path: every viewer, filtered by `gate`. fn shard_then_gate(&self, gate: impl Fn(&Viewer) -> bool) -> HashSet { self.viewers .iter() @@ -155,7 +149,6 @@ impl Realm { .collect() } - /// The sessions whose writer queue holds at least one job, drained. fn jobs_landed(&self) -> HashSet { let mut landed = HashSet::new(); for (v, rx) in self.viewers.iter().zip(&self.queues) { @@ -334,18 +327,10 @@ fn aura(id: u64, target: u64, eff_kind: u8) -> Aura { } } -/// 24 players and 40 creatures over a 600 yd square: twelve cells a side, so a box covers well -/// under half of it and the whole-shard path and the cell path differ on most events. fn spread_realm(rng: &mut Rng) -> Realm { realm(rng, 24, 40, 600.0) } -// =============================================================================================== -// Gated relays: the candidates filtered by the job's gate equal the shard filtered by the gate. -// =============================================================================================== - -/// A swing reaches exactly the viewers whose `created` set holds the attacker, and the pump -/// enqueues for the attacker's box and the two owners only. #[test] fn combat_candidates_gate_to_exactly_the_whole_shard_recipients() { let mut rng = Rng::new(0xC0A7_0001); @@ -380,8 +365,6 @@ fn combat_candidates_gate_to_exactly_the_whole_shard_recipients() { ); } -/// An engagement row has no cell of its own; the attacker's indexed cell stands in, and the -/// `created` gate still admits exactly the whole-shard recipients. #[test] fn melee_candidates_gate_to_exactly_the_whole_shard_recipients() { let mut rng = Rng::new(0x3E1E_E002); @@ -413,8 +396,6 @@ fn melee_candidates_gate_to_exactly_the_whole_shard_recipients() { ); } -/// An aura job passes its gate for the target itself, for a viewer showing the target, or (the -/// stealth reveal) for a viewer whose box covers the target. The target's cell admits all three. #[test] fn aura_candidates_gate_to_exactly_the_whole_shard_recipients() { let mut rng = Rng::new(0xA07A_0003); @@ -446,8 +427,6 @@ fn aura_candidates_gate_to_exactly_the_whole_shard_recipients() { assert!(skipped_someone); } -/// A unit that is already gone from the index (its row deleted in the same transaction) can be -/// on nobody's screen, so only the owner leg remains. #[test] fn an_aura_on_an_unindexed_unit_reaches_its_owner_only() { let mut rng = Rng::new(0xA07A_0004); @@ -469,12 +448,6 @@ fn an_aura_on_an_unindexed_unit_reaches_its_owner_only() { assert!(aura_audience(&realm.view, 0, &aura(2, creature, 0xA0)).is_empty()); } -// =============================================================================================== -// Ungated relays: the audience is the actor's box plus the two named owners. -// =============================================================================================== - -/// The viewers showing the actor (their `created` sets were seeded from the index's forward -/// direction) plus the actor's and target's own sessions, whether or not they can see the cell. fn box_plus_owners(realm: &Realm, actor: u64, target: u64) -> HashSet { let mut want: HashSet = realm .viewers @@ -527,11 +500,6 @@ fn cast_impact_and_emote_reach_the_actors_box_and_both_owners() { assert!(skipped_someone); } -// =============================================================================================== -// Chat: the widened span never loses a listener the yard-range gate would admit. -// =============================================================================================== - -/// The job's gate, over the positions the fixture placed the units at. fn hears(realm: &Realm, speaker: u64, listener: &Viewer, chat_type: u8) -> bool { if listener.self_guid == speaker { return true; @@ -547,7 +515,6 @@ fn say_and_yell_candidates_gate_to_exactly_the_whole_shard_listeners() { let mut rng = Rng::new(0x5A11_0006); let mut yell_skipped_someone = false; for trial in 0..300 { - // 1200 yd a side: a yell (300 yd) reaches a corner of it, never all of it. let realm = realm(&mut rng, 30, 20, 1200.0); let speaker = realm.actor(&mut rng); let chat_type = if trial % 2 == 0 { YELL } else { 0 }; @@ -578,8 +545,6 @@ fn say_and_yell_candidates_gate_to_exactly_the_whole_shard_listeners() { assert!(yell_skipped_someone); } -/// A speaker with no indexed row cannot be placed, so nobody but the speaker hears it, which is -/// what the job's gate decides for the others too (no speaker row, no packet). #[test] fn chat_from_an_unindexed_speaker_reaches_the_speaker_only() { let mut rng = Rng::new(0x5A11_0007); @@ -595,12 +560,6 @@ fn chat_from_an_unindexed_speaker_reaches_the_speaker_only() { ); } -// =============================================================================================== -// EMOTE (`/e`) and text emote (`/wave`): T8's 25 yd range, EMOTE's same-team-only gate. -// =============================================================================================== - -/// Register one viewer at `(x, y)` and return its session id — the candidate side of the same -/// composed gate `chat_event_outbound`/`emote_event_outbound` run against a live coordinator cache. fn place(view: &WorldView, guid: u64, x: f32, y: f32) -> SessionId { let key = CellKey::of_position(0, 0, x, y); view.spatial @@ -611,17 +570,13 @@ fn place(view: &WorldView, guid: u64, x: f32, y: f32) -> SessionId { v.session } -/// Criterion 2 (T8): a same-team `/e` listener 20 yd away hears it, one 30 yd away does not, and -/// neither does an opposite-team listener at 5 yd — [`chat_audience`]'s candidate set combined with -/// the exact `chat_in_range` + [`lyracore_shared::faction::same_team`] gate `chat_event_outbound` -/// applies. #[test] fn emote_reaches_a_same_team_20_yd_listener_not_30_yd_nor_a_5_yd_enemy() { let view = WorldView::new(true); - place(&view, PLAYER_BASE, 0.0, 0.0); // the speaker, Human - let near = place(&view, PLAYER_BASE + 1, 20.0, 0.0); // same team, 20 yd - let far = place(&view, PLAYER_BASE + 2, 30.0, 0.0); // same team, 30 yd - let enemy = place(&view, PLAYER_BASE + 3, 0.0, 5.0); // opposite team, 5 yd + place(&view, PLAYER_BASE, 0.0, 0.0); + let near = place(&view, PLAYER_BASE + 1, 20.0, 0.0); + let far = place(&view, PLAYER_BASE + 2, 30.0, 0.0); + let enemy = place(&view, PLAYER_BASE + 3, 0.0, 5.0); let candidates = sessions(chat_audience(&view, 0, &chat(PLAYER_BASE, EMOTE))); let range = chat_range_yd(EMOTE); @@ -645,13 +600,10 @@ fn emote_reaches_a_same_team_20_yd_listener_not_30_yd_nor_a_5_yd_enemy() { ); } -/// Criterion 5 (T8): `/wave` reaches a listener at 20 yd of either team; one at 30 yd gets neither -/// packet — [`emote_audience`]'s widened span combined with the same `chat_in_range` gate -/// `emote_event_outbound` applies, with no team check. #[test] fn text_emote_reaches_20_yd_of_either_team_not_30_yd() { let view = WorldView::new(true); - place(&view, PLAYER_BASE, 0.0, 0.0); // the speaker + place(&view, PLAYER_BASE, 0.0, 0.0); let near_same_team = place(&view, PLAYER_BASE + 1, 20.0, 0.0); let near_other_team = place(&view, PLAYER_BASE + 2, 0.0, 20.0); let far = place(&view, PLAYER_BASE + 3, 30.0, 0.0); @@ -689,23 +641,18 @@ fn text_emote_reaches_20_yd_of_either_team_not_30_yd() { ); } -// =============================================================================================== -// The aura index -// =============================================================================================== - #[test] fn aura_index_insert_update_delete_by_shard_and_id() { let index = AuraIndex::default(); index.upsert(0, &aura(1, 77, A_STEALTH)); index.upsert(0, &aura(2, 77, 0xA0)); - index.upsert(1, &aura(1, 78, 0xA0)); // shard 1 reuses id 1 for another unit + index.upsert(1, &aura(1, 78, 0xA0)); assert_eq!(index.stealth_count(0, 77), 1); assert!(index.is_stealthed(0, 77)); assert!(!index.is_stealthed(1, 78)); assert_eq!(index.on_target(0, 77).len(), 2); assert_eq!(index.stats(), (3, 2)); - // An update lands on the same (shard, id) and replaces the row. let mut refreshed = aura(2, 77, 0xA0); refreshed.amount = 25; index.upsert(0, &refreshed); @@ -719,13 +666,12 @@ fn aura_index_insert_update_delete_by_shard_and_id() { Some(25) ); - // A delete on shard 1 of id 1 must not touch shard 0's id 1. index.remove(1, &aura(1, 78, 0xA0)); assert!(index.on_target(1, 78).is_empty()); assert_eq!(index.stealth_count(0, 77), 1); index.remove(0, &aura(1, 77, A_STEALTH)); assert_eq!(index.stealth_count(0, 77), 0); - index.remove(0, &aura(1, 77, A_STEALTH)); // idempotent + index.remove(0, &aura(1, 77, A_STEALTH)); assert_eq!(index.on_target(0, 77).len(), 1); index.remove(0, &aura(2, 77, 0xA0)); assert_eq!(index.stats(), (0, 0)); @@ -901,7 +847,6 @@ fn old_shard_combat_cannot_address_a_transferred_character() { assert!(realm.queues[0].try_recv().is_err()); assert!(aura_audience(&realm.view, 0, &aura(1, transferred.self_guid, 0xA0)).is_empty()); - // A current-shard owner still receives its private stop with no indexed anchor. realm .view .spatial @@ -939,7 +884,6 @@ fn jobs_selected_before_worldport_cannot_reach_the_replacement_world_session() { melee_engaged(&view, 0, &row); drop(old_registration); - // WORLDPORT writes destination entry on the same socket before registering its fresh Viewer. tx.send(Outbound::Raw { opcode: 0xCAFE, body: vec![], @@ -1279,8 +1223,6 @@ fn distant_characters_do_not_add_jobs_to_a_local_combat_event() { assert!(distant.iter().all(|rx| rx.try_recv().is_err())); } -/// The aura audience follows the viewer's anchor: walking into the target's box adds the viewer, -/// walking out removes it, with nothing re-registered. #[test] fn aura_audience_follows_a_viewer_recenter() { let view = WorldView::new(true); @@ -1304,7 +1246,6 @@ fn aura_audience_follows_a_viewer_recenter() { view.spatial .move_viewer_delta(watcher.session, CellKey::at(0, 0, 20, 20)); assert!(aura_audience(&view, 0, &row).is_empty()); - // The target moves under a resident viewer. view.spatial.upsert_entity( EntityLayer::WorldEntity, stealther, @@ -1376,7 +1317,6 @@ fn motion_delivery_counts_coalesced_rows_before_a_stalled_peer_writer() { for seq in 2..=172 { latest = queue_motion(&batch, seq); } - // The Module may coalesce several queued movements into one row. The writer has not run. motion(&view, 0, &latest, &batch.delivery); assert!(matches!(rx.try_recv().unwrap(), Outbound::Job(_))); diff --git a/gateway/src/stdb/world_view/item_durable_tests.rs b/gateway/src/stdb/world_view/item_durable_tests.rs index 604f3479a..b7956c263 100644 --- a/gateway/src/stdb/world_view/item_durable_tests.rs +++ b/gateway/src/stdb/world_view/item_durable_tests.rs @@ -104,7 +104,6 @@ fn live_worn_item_insert_enchant_and_delete_reach_a_nearby_peer() { standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &["0"]); - // Seeded Northshire creatures must not add unrelated packets to this item Relay check. standalone.assert_sql("UPDATE game_character SET map_id = 1 WHERE guid = 1"); standalone.assert_call("debug_spawn_player_entity", &["1"]); standalone.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1 AND slot = 15"); diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index 588eb0ca8..fefced4a6 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -1,11 +1,3 @@ -//! Relay scaling measurement using a synthetic realm in memory, without sockets or a database. -//! -//! Run with `cargo test -p lyracore-gateway --release relay_scaling -- --ignored --nocapture`. -//! It prints one row per population size: the pump cost of a burst of combat, cast and impact -//! events, the writer-side cost of draining every job those enqueued, the aura relay's audience -//! and array-sync cost, and the yell audience. Ignored by default because it is a measurement, -//! not a check. - use super::*; use crate::codec::combat::subscriptions::{ aura_audience, cast_event_appeared, combat_event_appeared, impact_appeared, @@ -101,8 +93,6 @@ fn aura(id: u64, target_guid: u64, eff_kind: u8) -> Aura { } } -/// Build Characters, four creatures per Character, and the selected cell and aura density. -/// Seed each viewer's created set from its visible cells before dispatching events. fn build(players: usize, rng: &mut Rng, workload: Workload) -> SyntheticRealm { let side = match workload { Workload::Dense => 1, @@ -276,7 +266,6 @@ impl SyntheticRealm { } } - /// Run every queued job on every writer queue: `(jobs run, packets produced)`. fn drain(&self) -> (usize, usize) { let (mut jobs, mut packets) = (0, 0); for rx in &self.queues { @@ -321,8 +310,6 @@ fn micros_per(total: Duration, count: usize) -> f64 { total.as_secs_f64() * 1e6 / count as f64 } -/// The aura relay, event by event: audience selection on the pump, then the per-recipient job -/// gate and the aura array sync each surviving job builds. fn aura_burst(realm: &SyntheticRealm, rng: &mut Rng) -> (Duration, usize, Duration, usize) { let mut audience = Duration::ZERO; let mut sync = Duration::ZERO; diff --git a/gateway/src/stdb/world_view/test_support.rs b/gateway/src/stdb/world_view/test_support.rs index 249800e5d..8f9804d85 100644 --- a/gateway/src/stdb/world_view/test_support.rs +++ b/gateway/src/stdb/world_view/test_support.rs @@ -23,7 +23,6 @@ pub(crate) fn viewer_with_tx( viewer_in_zone(session, self_guid, bound_identity, 0, tx) } -/// A viewer already standing in `zone_id` for weather Relay tests. pub(crate) fn viewer_in_zone( session: u64, self_guid: u64, @@ -75,7 +74,6 @@ pub(crate) fn teleport(mover_guid: u64) -> TeleportEvent { } } -/// A registered viewer for `self_guid` on `shard`, with the receiving end of its writer queue. pub(crate) fn registered_viewer( view: &WorldView, shard: usize, diff --git a/gateway/src/world/actor.rs b/gateway/src/world/actor.rs index e5e22fa3a..2dde6dfe3 100644 --- a/gateway/src/world/actor.rs +++ b/gateway/src/world/actor.rs @@ -1,9 +1,5 @@ -//! The Character a Durable Request acts as. - use std::num::NonZeroU64; -/// A resolved Actor guid. Guid 0 means the World Session has no Character yet, so it is never an -/// Actor. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) struct Actor(NonZeroU64); diff --git a/gateway/src/world/alpha_test_tools_tests.rs b/gateway/src/world/alpha_test_tools_tests.rs index 8a69a5f6f..d17c629c0 100644 --- a/gateway/src/world/alpha_test_tools_tests.rs +++ b/gateway/src/world/alpha_test_tools_tests.rs @@ -1,9 +1,3 @@ -//! Account-owned Alpha Test Tools through a Headless Client. -//! -//! The Fake models the production Store operation's two outcomes: Realm-core answers current -//! Account authority, then the Home Shard accepts or refuses the Durable Request. Module tests -//! own command parsing and teleport coordinates. - use super::*; fn alpha_test_tools_store( @@ -59,7 +53,6 @@ fn alpha_test_tools_dispatch_speed_and_tele_without_say_chat() { send_dot_say(&mut client, &mut encrypt, ".speed 3"); send_dot_say(&mut client, &mut encrypt, ".tele stormwind"); - // Neither command sends chat on success. The next response must be this unrelated reply. assert_quest_status_reply(&mut client, &mut encrypt, &mut decrypt); drop(client); diff --git a/gateway/src/world/auction_tests.rs b/gateway/src/world/auction_tests.rs index 81e07af19..ee3260d7a 100644 --- a/gateway/src/world/auction_tests.rs +++ b/gateway/src/world/auction_tests.rs @@ -1,5 +1,3 @@ -//! Auction replies through the Protocol Family and encrypted World Session. - use super::handlers::{store_with, Auction, InMemoryAuctionActions}; use super::*; @@ -13,7 +11,6 @@ fn run(actions: &InMemoryAuctionActions, msg: impl Into) -> .outbound } -/// The one typed packet a request answered with. fn only_message(mut sent: Vec) -> ServerOpcodeMessage { match (sent.pop(), sent.is_empty()) { (Some(Outbound::One(message)), true) => message, @@ -61,8 +58,6 @@ fn auction_house_round_trip_stays_typed_over_the_dispatcher() { #[test] fn raw_auction_browse_answers_an_empty_page_over_the_encrypted_session() { - // The raw CMSG_AUCTION_LIST_ITEMS path runs before typed dispatch in the session read loop, - // so only a socket reaches it. let store = std::sync::Arc::new({ let base = quest_store(); WorldFake { @@ -94,8 +89,6 @@ fn raw_auction_browse_answers_an_empty_page_over_the_encrypted_session() { #[test] fn an_unresolved_auctioneer_gets_no_reply() { - // An auctioneer the Module does not resolve (no interaction) gets no reply at all: the refusal - // is silent and the dispatcher returns Ok, so the session keeps serving. let actions = store_with(None); let sent = run( &actions, @@ -108,11 +101,6 @@ fn an_unresolved_auctioneer_gets_no_reply() { #[test] fn refused_auctioneer_interaction_keeps_the_encrypted_world_session_alive() { - // A resolvable requester with no seeded in-world Characters (`entity_in_world: false` - // overrides `quest_store`'s blanket flag), so the WHO answer this test cares about is the - // empty-but-present reply, not "no answer for an unknown requester" (a different rule, pinned - // in `social.rs`'s own WHO tests). It stays a socket test because the sentinel proves the - // session survives the silent refusal, and nothing else pins that WHO reply. let store = std::sync::Arc::new({ let base = quest_store(); WorldFake { @@ -140,8 +128,6 @@ fn refused_auctioneer_interaction_keeps_the_encrypted_world_session_alive() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // RAW-encoded (codec::build_who_response_raw); the auction refusal must be silent and leave - // WHO's own empty-roster reply next. let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); assert_eq!( diff --git a/gateway/src/world/bank_tests.rs b/gateway/src/world/bank_tests.rs index 586abb5d8..af9245d86 100644 --- a/gateway/src/world/bank_tests.rs +++ b/gateway/src/world/bank_tests.rs @@ -1,23 +1,15 @@ -//! Bank replies and durable outcomes through the Bank and Npc Stores. - use super::handlers::{Bank, BankStore, NpcStore}; use super::*; use crate::stdb::ReducerCallError; use std::collections::BTreeSet; use std::sync::Mutex; -/// A bank that moves an item by its source slot: a carried slot goes into the bank, a banked slot -/// comes back. The Module infers that direction from the slot, so the Fake does too. #[derive(Default)] struct BankFake { npc_refuses: bool, - /// Every banker the Module accepts a bag-slot purchase from. bankers: BTreeSet, - /// Refuses `auto_bank_item` with this reason, like a full bank. move_error: Option, - /// Refuses `buy_bank_slot` with this reason. purchase_error: Option, - /// Loses the transport on every Durable Request. transport_lost: bool, carried: Mutex>, banked: Mutex>, @@ -85,14 +77,12 @@ impl BankStore for BankFake { } } -/// Handle `msg` as Character 1 of Account 7 and return the replies in order. fn run(store: &BankFake, msg: impl Into) -> Vec { let mut session = ProtocolSession::in_world(7, 1); let reply = Bank::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); outbound_messages(reply.outbound) } -/// What the handler returns when the Store loses the transport. fn run_transport_lost(msg: impl Into) -> Result { let store = BankFake { transport_lost: true, @@ -142,8 +132,6 @@ fn banker_activate_on_a_standing_refusing_banker_sends_no_reply() { assert!(sent.is_empty(), "got [{}]", kinds(&sent)); } -/// Right-click a bag item with the bank open: the gateway names the source slot and the Module -/// resolves the free bank slot. #[test] fn autobank_item_from_the_main_bag_deposits_the_item() { let store = BankFake::carrying(23); @@ -158,7 +146,6 @@ fn autobank_item_from_the_main_bag_deposits_the_item() { assert_eq!((store.carried(), store.banked()), (vec![], vec![23])); } -/// Right-click a banked item: withdraw, through the same Store method as the deposit. #[test] fn autostore_bank_item_from_the_main_bag_withdraws_the_item() { let store = BankFake::banking(39); @@ -173,8 +160,6 @@ fn autostore_bank_item_from_the_main_bag_withdraws_the_item() { assert_eq!((store.carried(), store.banked()), (vec![39], vec![])); } -/// A full destination (bank or carry space) is a per-action error. The client gets the existing -/// inventory-change failure, and the session goes on. #[test] fn autobank_item_err_sends_smsg_inventory_change_failure() { let store = BankFake { @@ -199,8 +184,6 @@ fn autobank_item_err_sends_smsg_inventory_change_failure() { assert_eq!((store.carried(), store.banked()), (vec![23], vec![])); } -/// Only the main pseudo-bag (255) is addressed, like the item handler. A sub-bag index is logged -/// and ignored, never fatal. #[test] fn autobank_item_from_a_sub_bag_is_unsupported_and_moves_nothing() { let store = BankFake::carrying(0); @@ -229,8 +212,6 @@ fn autostore_bank_item_from_a_sub_bag_is_unsupported_and_moves_nothing() { assert_eq!((store.carried(), store.banked()), (vec![], vec![0])); } -/// The Fake accepts a purchase only from a banker it knows, so an `Ok` reply shows the wire's -/// banker guid reached the Store. #[test] fn buy_bank_slot_success_sends_ok_and_reaches_the_named_banker() { let store = BankFake { @@ -254,8 +235,6 @@ fn buy_bank_slot_success_sends_ok_and_reaches_the_named_banker() { assert_eq!(*store.bought_slots.lock().unwrap(), 1); } -/// The Module tags a refusal with its `SMSG_BUY_BANK_SLOT_RESULT` code in brackets. The gateway -/// reads the code, not the prose. #[test] fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { for (err, want) in [ @@ -287,8 +266,6 @@ fn buy_bank_slot_failure_maps_the_bracketed_code_to_the_matching_result() { } } -/// A Transport Loss leaves the durable outcome unknown, so it ends the World Session instead of -/// posing as a failed bank move or a refused purchase. #[test] fn a_transport_loss_ends_the_world_session() { assert!(run_transport_lost(CMSG_AUTOBANK_ITEM { diff --git a/gateway/src/world/benilla_tests.rs b/gateway/src/world/benilla_tests.rs index 49c3d4675..6bca35392 100644 --- a/gateway/src/world/benilla_tests.rs +++ b/gateway/src/world/benilla_tests.rs @@ -1,5 +1,3 @@ -//! Headless Client checks through the WorldStore Seam, using Benilla's independent codec. - use super::{tester_store, warrior_entity, world_session_socket_pair, WorldFake}; use crate::logon::{LogonAccount, LogonStore, RealmInfo}; use crate::world::run_world_session; @@ -316,8 +314,6 @@ impl MovementWorld { } } -// The caller lives beside the shard callbacks, so it can supply the production motion callback -// without exposing a private production operation for tests. pub(crate) fn two_clients_observe_movement( relay: fn(&crate::stdb::world_view::WorldView, &crate::stdb::bindings::EntityMotion), ) { @@ -354,8 +350,6 @@ pub(crate) fn two_clients_observe_movement( clients.push(client); } - // Supply the Fake's initial peer snapshot. Movement delivery below uses the real cell audience, - // known-object Gate, writer queue and encryption. for index in 0..2 { let peer = &entities[1 - index]; let viewer = view @@ -391,7 +385,7 @@ pub(crate) fn two_clients_observe_movement( info.position.y = -132.0; let body = messages::movement(&info); clients[sender].send(opcode, &body); - clients[sender].ping(u32::from(opcode)); // Also proves no movement echo to the mover. + clients[sender].ping(u32::from(opcode)); assert_movement_packet( clients[1 - sender].recv(), opcode, @@ -412,7 +406,7 @@ pub(crate) fn two_clients_observe_movement( distant.position.y = -132.0; clients[1].send(opcode::MSG_MOVE_STOP, &messages::movement(&distant)); clients[1].ping(100); - clients[0].ping(101); // The out-of-range movement must not precede this pong. + clients[0].ping(101); for client in &mut clients { client.logout(); } @@ -555,9 +549,9 @@ fn benilla_loots_money_and_an_item_then_moves_it_in_inventory() { if let Object::Create { guid, mask, .. } = object { if guid == inventory[0].guid { let fields: std::collections::BTreeMap<_, _> = mask.raw_fields().collect(); - assert_eq!(fields.get(&3), Some(&117)); // OBJECT_FIELD_ENTRY - assert_eq!(fields.get(&6), Some(&1)); // ITEM_FIELD_OWNER - assert_eq!(fields.get(&14), Some(&3)); // ITEM_FIELD_STACK_COUNT + assert_eq!(fields.get(&3), Some(&117)); + assert_eq!(fields.get(&6), Some(&1)); + assert_eq!(fields.get(&14), Some(&3)); found = true; } } @@ -711,7 +705,6 @@ fn authenticate() -> ([u8; 40], [u8; 40]) { }, saved_key: Mutex::new(None), }); - // Match Benilla's logon policy for public keys whose integer encoding has an ambiguous width. for _ in 0..8 { let (mut socket, mut gateway_socket) = world_session_socket_pair(); let logon_store = store.clone(); @@ -942,7 +935,6 @@ fn benilla_ticket_refusals_accept_harassment_and_long_text() { client.recv(), ServerPacket::GmTicketCreated { response: 3 } )); - // The unavailable service does not decompress or allocate a transcript from its header. body.extend_from_slice(&1u32.to_le_bytes()); body.extend_from_slice(&u32::MAX.to_le_bytes()); body.extend_from_slice(b"opaque transcript"); diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs index 75d5fd016..a822bcb6c 100644 --- a/gateway/src/world/benilla_tests/durable.rs +++ b/gateway/src/world/benilla_tests/durable.rs @@ -1,5 +1,3 @@ -//! Benilla packets across the Gateway and a private Module instance. - use super::*; use crate::accept::BlockingTaskCapacity; use crate::config::GatewayConfig; @@ -211,7 +209,6 @@ fn benilla_durable_target_selection_during_taxi_keeps_the_world_session_connecte let realm = Realm::start("benilla-taxi-selection"); let (mut client, gateway) = realm.connect("TEST"); client.enter_world(); - // Stage an active flight without a schedule so selection cannot race landing. realm.standalone.assert_sql( "INSERT INTO game_active_taxi_flight \ (character_guid,path_id,source_node_id,destination_node_id,mount_display_id,fare,\ @@ -303,7 +300,6 @@ fn benilla_durable_created_character_appears_in_the_first_roster() { client.socket.shutdown(std::net::Shutdown::Both).unwrap(); (result, roster) }); - // Call pipes can finish while the subscribed Coordinator cannot advance its cache. let early = completion.recv_timeout(Duration::from_secs(1)); realm.coordinator.resume_pump_for_test(); let (result, roster) = reader.join().unwrap(); diff --git a/gateway/src/world/cast_tests.rs b/gateway/src/world/cast_tests.rs index f4553994b..3e22a55a0 100644 --- a/gateway/src/world/cast_tests.rs +++ b/gateway/src/world/cast_tests.rs @@ -1,15 +1,8 @@ -//! Cast opcodes through their dispatcher. Route selection, target decoding and durable dispatch -//! are covered per route by the focused seam tests in `handlers/cast`; these tests pin what the -//! session sees: the ordered batch, the ranged auto-repeat state it carries between casts, and -//! the effect of each cancellation. - use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryCasts; use super::*; use std::sync::Mutex; -/// `SMSG_CAST_RESULT`, a raw frame. The synchronous ORDER across raw and typed packets is the -/// contract under test. const OP_CAST_RESULT: u16 = 0x0130; fn run( @@ -26,7 +19,6 @@ fn repeating(session: &ProtocolSession) -> bool { matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) } -/// `SpellCastTargets` carrying a UNIT target (the client's selected mob). fn unit_targets(guid: u64) -> SpellCastTargets { SpellCastTargets { target_flags: SpellCastTargets_SpellCastTargetFlags::new_unit( @@ -39,10 +31,6 @@ fn unit_targets(guid: u64) -> SpellCastTargets { #[test] fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_target() { - // Root-cause client-wedge fix: an INSTANT cast must emit START(0) -> raw CAST_RESULT(OK, - // opcode 0x0130, 5-byte body) -> GO, IN THAT ORDER. That order is a real invariant: the 5875 - // client needs the OK ack between START and GO to clear its cast slot. The unit target must - // reach the cast, which shows in the GO's hit list. let store = InMemoryCasts::instant(); let sent = run( &store, @@ -54,7 +42,7 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ ); let mut want = 100u32.to_le_bytes().to_vec(); - want.push(0x00); // SPELL_RESULT_STATUS_OKAY: 5 bytes, NO trailing reason byte + want.push(0x00); // SPELL_RESULT_STATUS_OKAY has no trailing reason byte. match sent.as_slice() { [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START(_)), Outbound::Raw { opcode: OP_CAST_RESULT, @@ -76,11 +64,6 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ #[test] fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { - // Vanilla shape: Auto Shot (75) and wand Shoot (5019) are auto-repeat ranged attacks. The - // activation arms the loop with the cast's unit target, then the ack is SMSG_SPELL_START ALONE - // with timer 0 (no CAST_RESULT, no GO: the cast parks in the client's AUTOREPEAT slot and - // never resolves; each shot's GO comes from the swing-tick relay). The ordinary cast route - // would send all three packets, so a lone START also shows `cast_spell` did not run. for spell in [75u32, 5019] { let store = InMemoryCasts { ranged_auto_repeat: vec![75, 5019], @@ -132,9 +115,6 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() { #[test] fn a_cast_before_entering_the_world_makes_no_request_and_does_not_fail() { - // A cast can arrive while the session is still at character select: a stale addon macro, a - // reconnect race. The seam has no Actor to cast as, so it makes no durable request and - // answers a failed cast. The dispatcher returns Ok, so the session serves the next opcode. let store = InMemoryCasts::instant(); let sent = run( &store, @@ -156,8 +136,6 @@ fn a_cast_before_entering_the_world_makes_no_request_and_does_not_fail() { #[test] fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() { - // The cancel tears the loop down only when one is armed (the `was_repeat` gate). Arm Auto - // Shot, then cancel, and check that the durable engagement for this attacker is gone. let store = InMemoryCasts { ranged_auto_repeat: vec![75], ..InMemoryCasts::instant() @@ -173,7 +151,6 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack() ); assert_eq!(store.engaged.lock().unwrap().as_slice(), &[1]); - // The cancel sends no ack of its own (the on_delete relay does). let sent = run(&store, &mut caster, CMSG_CANCEL_AUTO_REPEAT_SPELL {}); assert!(sent.is_empty()); assert!(!repeating(&caster)); diff --git a/gateway/src/world/character_tests.rs b/gateway/src/world/character_tests.rs index e1f18e5e3..ec22df48c 100644 --- a/gateway/src/world/character_tests.rs +++ b/gateway/src/world/character_tests.rs @@ -1,19 +1,16 @@ -//! Character select over an encrypted World Session: enumerate, create and delete. - use super::*; #[test] fn char_enum_returns_the_seeded_character() { - // The pre-seeded Human Warrior "Tester" must appear on the character-select screen. let tester = codec::CharacterView { guid: 1, name: "Tester".into(), - race: 1, // Human - class: 1, // Warrior - gender: 0, // Male + race: 1, + class: 1, + gender: 0, level: 1, - map_id: 0, // Eastern Kingdoms - zone_id: 12, // Elwynn Forest + map_id: 0, + zone_id: 12, x: -8949.95, y: -132.493, z: 83.5312, @@ -34,7 +31,6 @@ fn char_enum_returns_the_seeded_character() { run_world_session(server_end, server_store.clone()).unwrap(); }); - // Full handshake, then request the character list over the encrypted channel. let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) @@ -60,7 +56,6 @@ fn char_enum_returns_the_seeded_character() { #[test] fn char_create_replies_success_then_name_in_use() { - // The fake store reports a name already among its characters as in-use, else success. let tester = codec::CharacterView { guid: 1, name: "Tester".into(), @@ -95,7 +90,6 @@ fn char_create_replies_success_then_name_in_use() { facial_hair: 0, }; - // A fresh name → CharCreateSuccess. mk("Newbie") .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -106,7 +100,6 @@ fn char_create_replies_success_then_name_in_use() { other => panic!("expected SMSG_CHAR_CREATE, got {other}"), } - // An existing name → CharCreateNameInUse, and the session stays alive. mk("Tester") .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -191,7 +184,6 @@ fn char_delete_failure_replies_failed_and_keeps_session_alive() { other => panic!("expected SMSG_CHAR_DELETE, got {other}"), } - // Session should still be alive: CMSG_CHAR_ENUM gets a normal reply, not a dropped socket. CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); diff --git a/gateway/src/world/chat_flood.rs b/gateway/src/world/chat_flood.rs index dd1985f47..bac41a1bf 100644 --- a/gateway/src/world/chat_flood.rs +++ b/gateway/src/world/chat_flood.rs @@ -1,33 +1,17 @@ -//! Chat Flood Limiter: the Gateway's in-memory count of fast chat lines per World Session. -//! -//! cmangos keeps the same count on the player and the mute on the session, and saves neither -//! (cm:Player.cpp:16344-16377, cm:ChatHandler.cpp:157-167). The limiter runs before any Durable -//! Request, so a muted line costs no reducer call. The defaults are cmangos's -//! (cm:mangosd.conf.dist.in:1158-1160). - use std::time::{Duration, Instant}; use lyracore_shared::chat::{chat_kind, speakable_language}; use wow_world_messages::vanilla::opcodes::{ClientOpcodeMessage, ServerOpcodeMessage}; use wow_world_messages::vanilla::{CMSG_MESSAGECHAT_ChatType, Language, SMSG_NOTIFICATION}; -/// `ChatFlood.MessageDelay`: a counted line that arrives sooner than this after the previous one -/// is fast. const FAST_LINE_WINDOW: Duration = Duration::from_secs(1); -/// `ChatFlood.MessageCount`: this many fast lines in a row mute the World Session. const FAST_LINES_TO_MUTE: u32 = 10; -/// `ChatFlood.MuteTime`. const MUTE: Duration = Duration::from_secs(10); -/// What the limiter does with one client message. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FloodRule { - /// A chat line the limiter counts and refuses while muted. Counted, - /// Refused while muted, never counted: `CMSG_TEXT_EMOTE` (cm:ChatHandler.cpp:761-766). RefusedWhileMuted, - /// Everything else, including `/afk`, `/dnd` and any addon-language line - /// (cm:ChatHandler.cpp:113-119, cm:ChatHandler.cpp:157). Free, } @@ -49,8 +33,6 @@ fn rule(msg: &ClientOpcodeMessage) -> FloodRule { } } -/// The `chat_kind` a client `CMSG_MESSAGECHAT` line carries, for every chat type this limiter -/// counts. `None` for a type with no Chat Kind of its own (`rule` never marks those `Counted`). fn wire_chat_kind(chat_type: &CMSG_MESSAGECHAT_ChatType) -> Option { match chat_type { CMSG_MESSAGECHAT_ChatType::Say => Some(chat_kind::SAY), @@ -68,10 +50,6 @@ fn wire_chat_kind(chat_type: &CMSG_MESSAGECHAT_ChatType) -> Option { } } -/// cm:ChatHandler.cpp:100-111: the language check runs, and returns on failure, before -/// `UpdateSpeakTime`, so a line the language Gate refuses never reaches the mute check and never -/// counts. `race` is `None` when the Gateway cannot read the speaker (no live entity); such a line -/// counts as usual; the Module's own Gate is the fallback authority when this read is stale. fn language_refused(msg: &ClientOpcodeMessage, race: Option) -> bool { let ClientOpcodeMessage::CMSG_MESSAGECHAT(chat) = msg else { return false; @@ -84,19 +62,12 @@ fn language_refused(msg: &ClientOpcodeMessage, race: Option) -> bool { #[derive(Debug, Default)] pub(crate) struct ChatFloodLimiter { - /// The previous counted line's time plus [`FAST_LINE_WINDOW`]. fast_until: Option, fast_lines: u32, muted_until: Option, } impl ChatFloodLimiter { - /// Judge one client message at `now`. `Some` is the answer to a refused message, which must go - /// no further. `exempt` is asked only when a line would mute: cmangos never mutes a game master - /// for flooding (cm:Player.cpp:16346-16348), so the cost of that read lands on the tenth fast - /// line only. `speaker_race` is asked for every counted line, to keep a line the language Gate - /// would refuse off the count (cm:ChatHandler.cpp:100-111); that line still reaches the Module, - /// which answers "You don't know that language" as its own authority. pub(crate) fn judge( &mut self, msg: &ClientOpcodeMessage, @@ -126,8 +97,6 @@ impl ChatFloodLimiter { .filter(|remaining| !remaining.is_zero()) } - /// `Player::UpdateSpeakTime`: the tenth fast line in a row mutes and starts the count again. - /// A mute never shortens one already running. fn count(&mut self, now: Instant, exempt: impl FnOnce() -> bool) { if self.fast_until.is_some_and(|until| now < until) { self.fast_lines += 1; @@ -148,10 +117,6 @@ impl ChatFloodLimiter { } } -/// cm mangos.sql:3981 (`LANG_WAIT_BEFORE_SPEAKING`) with the remaining whole seconds formatted by -/// cm Util.cpp:211-232 `secsToTimeString`. The mute never reaches a minute, so only its seconds -/// part is ever printed. cmangos counts in whole wall-clock seconds; rounding up keeps the first -/// answer at "10 Second(s)." and the last at "1 Second(s).". fn wait_notice(remaining: Duration) -> ServerOpcodeMessage { let seconds = remaining.as_secs() + u64::from(remaining.subsec_nanos() > 0); ServerOpcodeMessage::SMSG_NOTIFICATION(Box::new(SMSG_NOTIFICATION { @@ -195,13 +160,10 @@ mod tests { false } - /// A Human, who knows Common: every fixture line in this file speaks Common or forces - /// Universal, so this race never trips the language check. fn human_race() -> Option { Some(1) } - /// Send `count` lines 100 ms apart from `start` and return the answer to each. fn flood(limiter: &mut ChatFloodLimiter, start: Instant, count: u64) -> Vec> { (0..count) .map(|n| { @@ -211,8 +173,6 @@ mod tests { .collect() } - /// The first line starts the count at zero and each of the next ten adds one - /// (cm:Player.cpp:16354-16374), so the eleventh fast line mutes and still goes out. #[test] fn the_eleventh_fast_line_mutes_and_the_twelfth_is_refused() { let start = Instant::now(); @@ -317,7 +277,6 @@ mod tests { ); } - /// Every counted kind the 1.12 client can send, each spoken while muted. #[test] fn every_other_chat_kind_counts_and_is_refused_while_muted() { let kinds = [ @@ -359,8 +318,6 @@ mod tests { } } - /// cm:ChatHandler.cpp:113-119 and :157 keep `/afk`, `/dnd` and the addon language out of the - /// count and out of the mute. #[test] fn away_and_addon_lines_never_count_and_pass_while_muted() { let free = [ @@ -432,9 +389,6 @@ mod tests { assert_eq!(reads.get(), 1); } - /// cm:ChatHandler.cpp:100-111: the language check runs first and returns early, so a line the - /// race does not know never reaches the mute check and never counts. It still reaches the - /// Module, which answers "You don't know that language" on its own authority. #[test] fn a_line_in_an_unknown_language_does_not_count_toward_the_mute() { let orcish = || line(CMSG_MESSAGECHAT_ChatType::Say, Language::Orcish); @@ -460,8 +414,6 @@ mod tests { ); } - /// A read that fails, or finds no live entity, must not stop a line from counting: the Module's - /// own language Gate is the fallback authority. #[test] fn an_unreadable_race_still_counts_toward_the_mute() { let no_race = || None; diff --git a/gateway/src/world/chat_tests.rs b/gateway/src/world/chat_tests.rs index f8bc5d36a..c6d58a2f1 100644 --- a/gateway/src/world/chat_tests.rs +++ b/gateway/src/world/chat_tests.rs @@ -1,7 +1,3 @@ -//! Chat lines over an encrypted World Session: Speech, the GM dot-command and the Chat Flood -//! Limiter. Realm Chat Lines and away status are tested against `InMemoryChatActions` in -//! `handlers/chat.rs`. - use super::*; #[test] @@ -22,7 +18,7 @@ fn messagechat_say_and_yell_route_to_chat_types_0_and_1() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - drop(client); // no reply on success — the speaker sees their line via the broadcast relay + drop(client); server.join().unwrap(); assert_eq!( store.speech.chats.lock().unwrap().as_slice(), @@ -42,7 +38,7 @@ fn messagechat_emote_routes_to_chat_type_3() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - drop(client); // no reply on success — the speaker sees their line via the broadcast relay + drop(client); server.join().unwrap(); assert_eq!( store.speech.chats.lock().unwrap().as_slice(), @@ -68,7 +64,7 @@ fn messagechat_dot_say_diverts_to_gm_command_never_touching_chat() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // nothing was sent for a successful dot-command + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} other => panic!("expected the sentinel (no reply on gm_command success), got {other}"), } drop(client); @@ -86,8 +82,6 @@ fn messagechat_dot_say_diverts_to_gm_command_never_touching_chat() { #[test] fn messagechat_non_dot_say_is_byte_identical_to_before_223() { - // The 223 divert must be a no-op for ordinary chat: a Say line NOT starting with '.' still routes - // to send_chat exactly as before, and never touches gm_command. let store = std::sync::Arc::new(quest_store()); let (mut client, mut c_enc, _c_dec, server) = enter_world(store.clone(), 1); CMSG_MESSAGECHAT { @@ -111,9 +105,6 @@ fn messagechat_non_dot_say_is_byte_identical_to_before_223() { #[test] fn messagechat_dot_say_error_relays_a_system_chat_line_to_the_sender_only() { - // A rejected dot-command (bad gm_level, unknown command, bad args) is relayed back - // to the SENDER as a System SMSG_MESSAGECHAT carrying the module's raw message VERBATIM — no - // "reducer failed" wrapper prefix, no broadcast, no game_chat_event row. let mut s = quest_store(); s.speech.gm_command_error = Some(|| { crate::stdb::ReducerCallError::refused("gw_gm_command", "permission denied").into() @@ -178,7 +169,6 @@ fn messagechat_dot_say_transport_loss_ends_the_world_session() { ); } -/// Character 1 in the world, with a Character row so `sync`'s sentinel is answered. fn chat_store() -> WorldFake { let base = quest_store(); WorldFake { @@ -212,8 +202,6 @@ fn notification(message: ServerOpcodeMessage) -> String { #[test] fn a_flooding_session_is_muted_before_any_chat_durable_request() { - // cm:Player.cpp:16344-16377: eleven fast lines mute the speaker for ten seconds. The twelfth - // line and the party line after it answer the cmangos notice and reach no reducer. let store = std::sync::Arc::new(chat_store()); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); for n in 0..12 { @@ -243,7 +231,6 @@ fn a_flooding_session_is_muted_before_any_chat_durable_request() { #[test] fn a_muted_session_still_sends_addon_lines() { - // cm:ChatHandler.cpp:113-119: the addon language is never flood-controlled. let store = std::sync::Arc::new(chat_store()); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); for n in 0..12 { @@ -269,7 +256,6 @@ fn a_muted_session_still_sends_addon_lines() { #[test] fn a_game_master_is_never_muted_for_flooding() { - // cm:Player.cpp:16346-16348 skips the flood count for any account above SEC_PLAYER. let mut s = chat_store(); s.chat.gm_level = 1; let store = std::sync::Arc::new(s); @@ -288,7 +274,6 @@ fn a_game_master_is_never_muted_for_flooding() { #[test] fn a_say_line_in_a_language_the_speaker_does_not_know_answers_the_vanilla_notice() { - // cm:ChatHandler.cpp:107-110 with cm mangos.sql:4044. let mut s = chat_store(); s.speech.send_chat_outcome = Some(ChatOutcome::Refused( lyracore_shared::chat::ChatRefusal::UnknownLanguage, diff --git a/gateway/src/world/coalesce.rs b/gateway/src/world/coalesce.rs index da7cb36ac..32ed11ee4 100644 --- a/gateway/src/world/coalesce.rs +++ b/gateway/src/world/coalesce.rs @@ -1,41 +1,12 @@ -//! -//! The decision logic is a PURE state machine (no I/O, no real clock) per this repo's testing -//! convention ("extract pure functions and test those"): `now_ms` is -//! an opaque monotonic-ms reading the caller supplies. Production wires it to a per-connection -//! `Instant` epoch via the `_now` methods below; unit tests drive `on_movement`/`flush` directly -//! with synthetic timestamps so the 150ms cadence never needs a real sleep (a real sleep-based -//! test would be flaky under CI load — this codebase has no precedent for timing-dependent tests -//! and this item doesn't need to set one). - use std::time::Instant; use wow_world_messages::vanilla::{MovementInfo, MovementInfo_MovementFlags}; -/// Forward cadence for a coalesced pure heartbeat, in ms — the item's window is "~150-200ms"; -/// 150 (the low end) is chosen so a continuously-moving player's module-side position is never -/// more than 150ms behind the wire, comfortably inside what peer interpolation already tolerates -/// (client movement is client-authoritative; the module position only has to be fresh enough for -/// server-side range gates and late-observer spawns — see `flush_now`/rule 2 below), while still -/// buying the item's "~3-5x drop" for a client heartbeat cadence in the several-hundred-ms range. pub const MOVE_COALESCE_WINDOW_MS: u64 = 150; -/// Per-connection coalescing state ("a small CoalesceState on the conn", the item's own wording). -/// Lives on `WorldConn` (see `world/mod.rs`), so it survives across `dispatch` calls for the life -/// of one socket. pub struct CoalesceState { - /// Real-clock epoch this connection's `_now` methods measure `now_ms` from. Unused by the - /// pure `on_movement`/`flush` (tests supply `now_ms` directly). epoch: Instant, - /// `(flags, heading, forwarded-at-ms)` of the last packet actually forwarded to the module — - /// `None` until the very first forward, which is always immediate (nothing to compare yet, so - /// classification trivially falls out as "changed"). last_forwarded: Option<(MovementInfo_MovementFlags, f32, u64)>, - /// The latest pending coalesced heartbeat. A newer pending packet always REPLACES the older - /// one (rule 3: the pending slot itself IS the sub-yard-intermediate drop mechanism — there is - /// no queue, so an in-between heartbeat that never becomes "latest" is simply overwritten and - /// never ships). pending: Option<(u32, MovementInfo)>, - /// Debug-only counters (rule 5): every inbound movement packet vs. every one actually - /// forwarded to the module. `run_world_session` logs both once at disconnect. received: u64, forwarded: u64, } @@ -65,10 +36,6 @@ impl CoalesceState { self.epoch.elapsed().as_millis() as u64 } - /// Production entry point: classify + coalesce one inbound movement packet against the real - /// clock. Returns the packets to actually forward to `SessionStore::movement_update`, IN ORDER - /// (0, 1, or 2 items — a flushed pending heartbeat followed by a state change, when both fire - /// in the same call; ordering matters for peer extrapolation, rule 2b). pub fn on_movement_now( &mut self, opcode: u32, @@ -78,19 +45,11 @@ impl CoalesceState { self.on_movement(opcode, info, now) } - /// Production entry point for rule 2's robust flush-on-any-other-opcode: drain the pending - /// heartbeat (if any) against the real clock. The dispatch loop calls this before handling ANY - /// non-movement CMSG, so a loot/vendor/gossip/etc. range check downstream never reads a stale - /// module position — the one-line fix that removes the whole "stale position mid-coalesce" - /// class instead of auditing every position-sensitive reducer individually. pub fn flush_now(&mut self) -> Option<(u32, MovementInfo)> { let now = self.now_ms(); self.flush(now) } - /// The pure decision (see module doc): classify `(opcode, info)` as STATE-CHANGE or a - /// coalescible pure heartbeat against `last_forwarded`, given the caller's `now_ms`. Mutates - /// `pending`/`last_forwarded`/the counters; returns what to forward, in order. fn on_movement( &mut self, opcode: u32, @@ -103,18 +62,11 @@ impl CoalesceState { .last_forwarded .as_ref() .is_some_and(|(f, h, _)| *f == info.flags && *h == info.orientation); - // Rule 1: any non-heartbeat opcode is ALWAYS a state change (start/stop/turn/jump/ - // fall-land/strafe/run-walk-mode/facing are one-shot edge events by construction — there's - // no "pure cadence" reading of them). A HEARTBEAT whose flags or heading drifted from the - // last FORWARDED packet is ALSO treated as a state change — defensive: a well-behaved - // client only sends HEARTBEAT while nothing changed, but a stale/reordered/malformed one - // must never get silently swallowed as if the state hadn't moved. let is_state_change = !is_heartbeat || !same_as_last_forwarded; if is_state_change { let mut out = Vec::with_capacity(2); - // Flush the pending heartbeat FIRST — a peer must see the run continue up to where it - // actually was before it sees the turn/stop, or its extrapolation overshoots. + // Flush before turn or stop to prevent peers extrapolating beyond the last position. if let Some(flushed) = self.flush(now_ms) { out.push(flushed); } @@ -123,9 +75,6 @@ impl CoalesceState { return out; } - // Pure heartbeat, same state as the last forward: coalesce unless the cadence window has - // elapsed. `last_forwarded` is guaranteed `Some` here (a HEARTBEAT only matches - // `same_as_last_forwarded` when there IS a last-forwarded key to match). let elapsed_ok = self .last_forwarded .as_ref() @@ -135,15 +84,11 @@ impl CoalesceState { self.mark_forwarded(info, now_ms); vec![(opcode, info.clone())] } else { - // Supersede any earlier pending heartbeat — THIS is the sub-yard-intermediate drop - // (rule 3): the slot holds only the newest, so an older in-between one never ships. self.pending = Some((opcode, info.clone())); Vec::new() } } - /// Drain + forward the pending heartbeat (if any) against `now_ms`. Shared by a state change's - /// flush-then-forward (above) and rule 2's flush-on-any-other-opcode. fn flush(&mut self, now_ms: u64) -> Option<(u32, MovementInfo)> { let (opcode, info) = self.pending.take()?; self.mark_forwarded(&info, now_ms); @@ -162,10 +107,6 @@ mod tests { use lyracore_shared::opcodes::movement as m; use wow_world_messages::vanilla::Vector3d; - /// Build a synthetic `MovementInfo`: `flags`/`orientation` drive the state-change - /// classification; `x` stands in for "position advanced along the same vector" (the actual - /// coordinate value never matters to the coalescing decision — only flags+heading do; a real - /// same-vector heartbeat stream would also advance x/y/z, which this mirrors for realism). fn info(flags: MovementInfo_MovementFlags, orientation: f32, x: f32) -> MovementInfo { MovementInfo { flags, @@ -180,11 +121,8 @@ mod tests { MovementInfo_MovementFlags::empty() } - // ---- Rule 1: state changes are never delayed (the HARD requirement) ------------------------ - #[test] fn first_packet_ever_forwards_immediately_even_if_its_a_heartbeat() { - // Nothing to compare against yet -> trivially "changed" -> forward now, not held. let mut cs = CoalesceState::default(); let out = cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(empty(), 0.0, 0.0), 0); assert_eq!( @@ -197,8 +135,6 @@ mod tests { #[test] fn non_heartbeat_opcodes_always_forward_immediately_regardless_of_flags() { - // Every MSG_MOVE_* opcode other than HEARTBEAT is a one-shot edge event: forward now, no - // exceptions, even when flags/heading happen to match the last forward exactly. let mut cs = CoalesceState::default(); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(empty(), 0.0, 0.0), 0); for (i, opcode) in [ @@ -227,11 +163,8 @@ mod tests { #[test] fn state_change_flushes_pending_heartbeat_first_then_itself_in_order() { - // run-start (t=0) -> heartbeat held pending (t=10, well inside the window) -> turn (t=20, - // a real flags change) must flush the pending heartbeat FIRST, then the turn — in THAT - // order, so a peer's extrapolation advances to the held position before snapping to turn. let mut cs = CoalesceState::default(); - let run_flags = MovementInfo_MovementFlags::new(0x1, None, None, None, None); // FORWARD bit + let run_flags = MovementInfo_MovementFlags::new(0x1, None, None, None, None); assert_eq!( cs.on_movement( m::MSG_MOVE_START_FORWARD, @@ -249,7 +182,7 @@ mod tests { "same-vector heartbeat well inside the window must be held, not forwarded" ); - let turn_flags = MovementInfo_MovementFlags::new(0x1 | 0x4, None, None, None, None); // + a turn bit + let turn_flags = MovementInfo_MovementFlags::new(0x1 | 0x4, None, None, None, None); let turn_info = info(turn_flags, 5.0, 1.0); let out = cs.on_movement(m::MSG_MOVE_START_TURN_LEFT, &turn_info, 20); assert_eq!(out.len(), 2, "expected [flushed heartbeat, turn]"); @@ -269,11 +202,9 @@ mod tests { #[test] fn heartbeat_with_drifted_heading_is_treated_as_a_state_change() { - // Defensive rule-1 clause: a HEARTBEAT opcode whose heading differs from the last forward - // is NOT a pure heartbeat, even though the opcode itself looks like cadence traffic. let mut cs = CoalesceState::default(); cs.on_movement(m::MSG_MOVE_START_FORWARD, &info(empty(), 0.0, 0.0), 0); - let drifted = info(empty(), 1.5, 1.0); // same flags, DIFFERENT heading + let drifted = info(empty(), 1.5, 1.0); let out = cs.on_movement(m::MSG_MOVE_HEARTBEAT, &drifted, 5); assert_eq!( out.len(), @@ -287,7 +218,7 @@ mod tests { let mut cs = CoalesceState::default(); cs.on_movement(m::MSG_MOVE_START_FORWARD, &info(empty(), 0.0, 0.0), 0); let swim_flags = MovementInfo_MovementFlags::new(0x0020_0000, None, None, None, None); - let drifted = info(swim_flags, 0.0, 1.0); // same heading, DIFFERENT flags + let drifted = info(swim_flags, 0.0, 1.0); let out = cs.on_movement(m::MSG_MOVE_HEARTBEAT, &drifted, 5); assert_eq!( out.len(), @@ -296,8 +227,6 @@ mod tests { ); } - // ---- Rule 2: cadence window + flush-on-anything-else ---------------------------------------- - #[test] fn same_vector_heartbeat_forwards_once_the_window_elapses() { let mut cs = CoalesceState::default(); @@ -315,12 +244,10 @@ mod tests { #[test] fn flush_now_style_flush_drains_pending_and_marks_it_forwarded() { - // Mirrors what the dispatch loop's flush-on-any-other-opcode rule does: a non-movement - // CMSG must see the pending heartbeat land before it runs. let mut cs = CoalesceState::default(); let flags = empty(); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags.clone(), 0.0, 0.0), 0); - cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags, 0.0, 3.0), 50); // held (50ms < 150ms) + cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags, 0.0, 3.0), 50); assert_eq!( cs.forwarded(), 1, @@ -335,14 +262,11 @@ mod tests { ); } - // ---- Rule 3: sub-yard same-vector intermediates drop entirely (superseded, not queued) ------ - #[test] fn intermediate_held_heartbeats_are_superseded_not_queued() { let mut cs = CoalesceState::default(); let flags = empty(); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags.clone(), 0.0, 0.0), 0); - // Three intermediates arrive within the window; only the LAST must ever ship. cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags.clone(), 0.0, 1.0), 30); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags.clone(), 0.0, 2.0), 60); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags, 0.0, 3.0), 90); @@ -353,12 +277,8 @@ mod tests { ); } - // ---- Rule 5 / the item's own done-when: N same-vector heartbeats at 60ms -> ~N/3 forwards --- - #[test] fn synthetic_60ms_heartbeat_stream_forwards_roughly_a_third() { - // The item's own acceptance check, verbatim: "N same-vector heartbeats at 60ms spacing -> - // ~N/3 forwards" against the 150ms window (150 / 60 = 2.5 heartbeats per window). let mut cs = CoalesceState::default(); let flags = empty(); let n = 60u64; @@ -382,9 +302,6 @@ mod tests { #[test] fn stationary_after_running_leaves_one_heartbeat_pending_until_next_packet() { - // Rule 4's documented bound on the lazy (no-timer) design: the LAST heartbeat before a - // player goes idle can sit pending until their next packet flushes it — never lost, just - // delayed by at most one window. Proven here so the bound is pinned, not just described. let mut cs = CoalesceState::default(); let flags = empty(); cs.on_movement(m::MSG_MOVE_HEARTBEAT, &info(flags.clone(), 0.0, 0.0), 0); @@ -393,9 +310,6 @@ mod tests { held.is_empty(), "the player's last heartbeat before going idle sits pending" ); - // No more packets arrive for a long time (the player is now stationary and silent). The - // pending heartbeat is NOT lost — it is still there, waiting for the next inbound packet - // (any opcode) or the periodic flush, whichever comes first. let flushed = cs.flush(5_000); assert_eq!( flushed.unwrap().1.position.x, diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs index 428979c42..d0e0426a9 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -1,17 +1,13 @@ -//! Targeting and sheathing through the Combat Store. - use super::handlers::{Combat, CombatStore}; use super::*; use crate::stdb::ReducerCallError; use std::collections::BTreeMap; use std::sync::Mutex; -/// What each Character has selected and how it carries its weapons. #[derive(Default)] struct CombatFake { selected: Mutex>, sheath: Mutex>, - /// Answered once by the next request, in place of success. failure: Mutex>, } @@ -60,7 +56,6 @@ impl CombatStore for CombatFake { } } -/// Handle `msg` as Character 1 of Account 7. fn run(store: &CombatFake, msg: impl Into) { let mut session = ProtocolSession::in_world(7, 1); let reply = Combat::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs index 4e3b5419d..13b1a0b39 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -1,5 +1,3 @@ -//! Death and resurrection requests, handled against a Fake that models only who is alive. - use super::family::{ProtocolFamily, ProtocolSession}; use super::*; use handle_loot_fake::{HandleLootFake, Life}; @@ -9,13 +7,10 @@ pub(crate) mod handle_loot_fake; pub(crate) const SELF_GUID: u64 = 1; -/// Protocol state for the Character named by `SELF_GUID`. pub(crate) fn in_world_conn() -> ProtocolSession { ProtocolSession::in_world(7, SELF_GUID) } -/// Handle one request and return what the client is sent. Fails when the handler ends the session -/// or passes the request on. pub(crate) fn run( store: &HandleLootFake, session: &mut ProtocolSession, @@ -32,7 +27,6 @@ fn repop_revives_the_caller_and_no_one_else() { .with_life(SELF_GUID, Life::Dead) .with_life(2, Life::Dead); - // The revive reaches the client through the entity relay, not as a direct reply. let sent = run( &store, &mut in_world_conn(), @@ -92,7 +86,6 @@ fn self_res_revives_the_caller_and_spends_the_option() { .with_life(SELF_GUID, Life::Dead) .with_self_res_option(SELF_GUID); - // The revive reaches the client through the entity relay, not as a direct reply. let sent = run( &store, &mut in_world_conn(), @@ -108,7 +101,6 @@ fn self_res_revives_the_caller_and_spends_the_option() { fn a_refused_self_res_sends_nothing_and_keeps_the_session() { let store = HandleLootFake::default().with_life(SELF_GUID, Life::Dead); - // `run` fails the test when the handler ends the session. let sent = run( &store, &mut in_world_conn(), diff --git a/gateway/src/world/entry.rs b/gateway/src/world/entry.rs index 58c4df355..4d24b1d7f 100644 --- a/gateway/src/world/entry.rs +++ b/gateway/src/world/entry.rs @@ -1,5 +1,3 @@ -//! World Session entry keeps queue, viewer registration, and Transfer operations in order. - use super::handlers::build_buyback_view_replay; use super::*; @@ -18,7 +16,6 @@ pub(super) fn login( if let Some(bound) = store.bind_session(token)? { conn.store.pin(bound); } - // Route before login and viewer registration so both use the Character's Home Shard. conn.route_home(character_guid)?; let home = conn.store.current(); enter_world(tx, &*home, conn, character, codec::WorldEntry::FreshLogin) @@ -37,8 +34,7 @@ pub(super) fn world_port_ack( log::debug!("world: spurious WORLDPORT_ACK ignored (guid {character_guid} still in world)"); return Ok(()); } - // Transfer cleanup must not publish source-shard changes to this viewer. Retain InWorld - // until entry succeeds so a failed Transfer still follows the existing logout policy. + // Unregister before source cleanup; retain InWorld until entry succeeds for failure logout. if let WorldState::InWorld(iw) = &mut conn.state { iw.subs.unregister_viewer(); } @@ -54,7 +50,6 @@ pub(super) fn world_port_ack( Ok(()) } -/// Queue the Transfer abort when possible, preserving the original failure. fn abort_pending_transfer( tx: &SessionTx, store: &St, @@ -90,7 +85,7 @@ fn abort_pending_transfer( } } -/// Queue self creation before registering the viewer; initial AOI Relays must follow it. +// Queue self CREATE before registration so initial AOI Relays follow it. fn enter_world( tx: &SessionTx, store: &dyn WorldStore, @@ -137,7 +132,6 @@ fn enter_world( entity.zone_id, WeatherChangeType::Instant, )); - // The login sequence and self creation stay contiguous on the writer. send(tx, Outbound::Batch(batch))?; for (opcode, body) in items.iter().filter_map(codec::build_random_property_values) { send(tx, Outbound::Raw { opcode, body })?; @@ -151,7 +145,6 @@ fn enter_world( } } } - // Registration may queue initial AOI objects immediately. let subs = store.subscribe_player_events(conn.account_id, character_guid, &entity, tx.clone())?; if quests_enabled { @@ -188,7 +181,6 @@ fn enter_world( ))), )?; } - // Record successful sign-on so cleanup owes a sign-off even if later entry work fails. if entry == codec::WorldEntry::FreshLogin && handlers::guild_sign_on(store, character_guid) { conn.guild_signed_on = Some(character_guid); } @@ -207,7 +199,6 @@ fn enter_world( .collect(); handlers::destroy_inert_charters(store, character_guid, &charters); crate::world::mail::redrive(store, character_guid); - // Forget snapshots after the party frame so the next tick supplies every field. if let Some(record) = subs.member_stats_record().cloned() { let forget = move || { record.forget_all(); diff --git a/gateway/src/world/family.rs b/gateway/src/world/family.rs index 615fc6cfd..a387041f6 100644 --- a/gateway/src/world/family.rs +++ b/gateway/src/world/family.rs @@ -1,5 +1,3 @@ -//! Protocol Family handlers share session state and return replies to the World Session. - use super::*; pub(crate) trait ProtocolFamily { @@ -48,7 +46,6 @@ impl From> for ProtocolReply { } impl ProtocolReply { - /// Queue all replies before running the next World Session operation on the reader thread. pub(super) fn complete( self, tx: &SessionTx, @@ -72,7 +69,6 @@ pub(crate) enum WorldSessionAction { RedriveMail(Actor), } -/// Protocol state shared by handlers. Crypto, routing and Account Claims stay on WorldConn. pub struct ProtocolSession { pub(crate) account_id: u64, pub(crate) account_name: String, @@ -105,9 +101,6 @@ impl ProtocolSession { } } - /// Admit one `CMSG_WHO`, or refuse it because the last one this session sent was inside - /// [`WHO_THROTTLE`]. Advances the cooldown on every admitted request, including a malformed - /// one that answers nothing — the request itself is what cost the scan. pub(super) fn admit_who(&mut self) -> bool { let now = Instant::now(); if self.who_throttled_until.is_some_and(|until| now < until) { @@ -117,7 +110,6 @@ impl ProtocolSession { true } - /// [`party::GroupBroadcastCooldowns::admit_at`] now. pub(super) fn admit_group_broadcast(&mut self, op: party::Op) -> bool { self.group_broadcast_cooldowns.admit_at(op, Instant::now()) } diff --git a/gateway/src/world/framing_tests.rs b/gateway/src/world/framing_tests.rs index bf179db55..a5335eb0f 100644 --- a/gateway/src/world/framing_tests.rs +++ b/gateway/src/world/framing_tests.rs @@ -1,35 +1,6 @@ -//! The INBOUND framing boundary: what an authenticated socket does with a header or a -//! body it cannot make sense of. -//! -//! Every other World Session socket test sends well-formed `wow_world_messages` types, so the read -//! loop in `run_world_session_with_queue` (`world/mod.rs`) is only ever exercised on its happy path. -//! That loop is nonetheless the first thing a hostile or buggy client reaches after the handshake, -//! and it makes four separate decisions no typed test can reach: -//! -//! 1. a short/absent HEADER is a clean disconnect (`Ok(())`), not an error — the ordinary way -//! every real session ends; -//! 2. a header whose declared `size` outruns the bytes actually sent is `Err` — the read must not -//! block forever waiting for a body the peer will never send; -//! 3. `size` counts the u32 opcode, so `size < 4` must not underflow the `body_len` computation -//! (it is a `saturating_sub`, and this pins that it stays one); -//! 4. an unparseable opcode/body is session-FATAL, deliberately — the stream cipher is a -//! continuous keystream, so a frame we cannot decode means we have also lost our place in it, -//! and continuing would interpret ciphertext as headers forever. -//! -//! These drive the REAL `run_world_session` over a real `wow_srp` cipher, using -//! `EncrypterHalf::write_encrypted_client_header` to put bytes on the wire that no typed builder can -//! produce. Each asserts the externally visible outcome — how the session ENDS and what the client -//! sees — rather than any internal flag. -//! -//! Deterministic by construction: every test writes a fixed byte sequence, closes the socket, and -//! joins the session thread. There is no timer, no scheduled reducer and no wall-clock window -//! anywhere in this file. - use super::*; use std::os::unix::net::UnixStream; -/// The store every test here uses: one account, session key [`K`], nothing else configured. The -/// frames below never reach a handler, so no fixture beyond the handshake is needed. fn framing_store() -> std::sync::Arc { std::sync::Arc::new(WorldFake { session: SessionState { @@ -44,7 +15,6 @@ fn framing_store() -> std::sync::Arc { }) } -/// Handshake, then hand the caller the client's cipher halves and the joined-later session thread. fn framing_session() -> ( UnixStream, EncrypterHalf, @@ -58,14 +28,6 @@ fn framing_session() -> ( (client, c_enc, c_dec, server) } -// =========================================================================================== -// 1. Truncated / absent headers — the CLEAN close -// =========================================================================================== - -/// The ordinary end of every session: the client closes with nothing pending. `UnexpectedEof` out -/// of `read_and_decrypt_client_header` is the one read failure that is NOT an error, and it has to -/// stay that way — classifying it as `Err` would make every normal logout log a spurious failure -/// and would bury the real framing errors this file's other tests produce. #[test] fn a_clean_disconnect_with_no_frame_pending_ends_the_session_without_an_error() { let (client, _c_enc, _c_dec, server) = framing_session(); @@ -76,13 +38,6 @@ fn a_clean_disconnect_with_no_frame_pending_ends_the_session_without_an_error() .expect("an EOF at a frame boundary is a normal disconnect, not a framing error"); } -/// A header cut in half. The vanilla client header is 6 bytes (u16 size + u32 opcode); sending 3 of -/// them and closing is what a killed client or a severed link looks like mid-header. -/// -/// This is still `UnexpectedEof` from the same read, so it is still the clean path — the point of -/// pinning it separately is that the loop must not try to interpret a PARTIAL header as a whole one -/// (which would decrypt 3 bytes of nothing into an arbitrary size + opcode and then block reading a -/// body that does not exist). #[test] fn a_header_truncated_mid_read_is_a_disconnect_not_a_decoded_frame() { let (mut client, _c_enc, _c_dec, server) = framing_session(); @@ -94,24 +49,12 @@ fn a_header_truncated_mid_read_is_a_disconnect_not_a_decoded_frame() { ); } -// =========================================================================================== -// 2. Truncated and oversized BODIES — the loud close -// =========================================================================================== - -/// A header that promises more body than the client ever sends. `read_exact` cannot be satisfied, -/// and the session must END rather than block: a peer that can announce a body and then go quiet -/// would otherwise pin a gateway thread (and its per-player SpacetimeDB connection) forever. -/// -/// The error text is asserted because it is the operator's only signal for this class — a bare -/// "session ended" would be indistinguishable from a normal logout in the log. #[test] fn a_body_shorter_than_its_declared_size_ends_the_session_with_an_error() { let (mut client, mut c_enc, _c_dec, server) = framing_session(); - // size = 4 (opcode) + 64 body bytes promised... c_enc - .write_encrypted_client_header(&mut client, 4 + 64, 0x0037 /* CMSG_CHAR_ENUM */) + .write_encrypted_client_header(&mut client, 4 + 64, 0x0037) .unwrap(); - // ...and only 8 delivered before the socket closes. client.write_all(&[0u8; 8]).unwrap(); drop(client); @@ -127,12 +70,6 @@ fn a_body_shorter_than_its_declared_size_ends_the_session_with_an_error() { ); } -/// The maximum a u16 size field can claim: 65535, i.e. 65531 body bytes. Nothing legitimate is that -/// large, so this is the shape a scan or a corrupted stream produces. -/// -/// Two properties at once: the gateway must not pre-allocate its way into trouble on an unverified -/// length (it allocates 64 KiB here and that is the ceiling the u16 imposes — the reason the size -/// field being a u16 is load-bearing), and it must not wait forever for the 65531 bytes. #[test] fn a_maximum_size_header_with_no_body_ends_the_session_instead_of_waiting_for_64_kib() { let (mut client, mut c_enc, _c_dec, server) = framing_session(); @@ -151,31 +88,15 @@ fn a_maximum_size_header_with_no_body_ends_the_session_instead_of_waiting_for_64 ); } -/// `size` counts the u32 opcode plus the body, so any value below 4 is already malformed. The loop -/// computes `body_len = (hdr.size as usize).saturating_sub(4)`; a plain `-` would underflow to -/// `usize::MAX - 3` here and the very next line (`vec![0u8; body_len]`) would abort the process on -/// a multi-exabyte allocation. That is the invariant this pins, and it is the whole point: the -/// gateway must survive a size field it did not choose. -/// -/// It deliberately does NOT assert that the frame is REJECTED. The loop re-frames with the declared -/// size and lets `ClientOpcodeMessage::read_unencrypted` adjudicate, so a body-less opcode like -/// `CMSG_CHAR_ENUM` decodes anyway and the session carries on — a design choice (gtker owns the -/// per-opcode length rules, not this loop) rather than an oversight. Asserting a rejection here -/// would pin a behaviour that does not exist and would break the moment gtker's tables changed. -/// -/// Every value 0..=3 is exercised, because the saturating boundary is exactly this range. #[test] fn a_size_field_below_the_opcode_width_never_underflows_the_body_length() { for size in 0u16..=3 { let (mut client, mut c_enc, _c_dec, server) = framing_session(); c_enc - .write_encrypted_client_header(&mut client, size, 0x0037 /* CMSG_CHAR_ENUM */) + .write_encrypted_client_header(&mut client, size, 0x0037) .unwrap(); drop(client); - // `join()` returning AT ALL is the assertion. Under the underflow the session thread never - // gets here: the allocation aborts the whole process, taking the test runner with it. - // Whether the frame then decodes or errors is gtker's call; both are safe. let _resolved = server.join().unwrap_or_else(|_| { panic!( "size={size} (below the 4-byte opcode width) must not underflow `body_len` — the \ @@ -185,19 +106,6 @@ fn a_size_field_below_the_opcode_width_never_underflows_the_body_length() { } } -// =========================================================================================== -// 3. Unsupported and malformed OPCODES — session-fatal, and why -// =========================================================================================== - -/// An opcode this build has no parser for. Vanilla's opcode space is sparse and the 1.12.1 client -/// never sends this one, so it means either a foreign/modified client or a stream we have lost -/// sync with. -/// -/// Ending the session is the deliberate choice: header crypto is a CONTINUOUS keystream, so the -/// gateway cannot "skip this frame and carry on" — it has already consumed this frame's keystream -/// bytes and has no way to know whether the body length it read was real. Every later header would -/// decrypt to garbage. The client sees a clean close and reconnects; that is recoverable, an -/// endlessly desynced socket is not. #[test] fn an_unsupported_opcode_ends_the_session_rather_than_desyncing_the_keystream() { let (mut client, mut c_enc, _c_dec, server) = framing_session(); @@ -213,17 +121,14 @@ fn an_unsupported_opcode_ends_the_session_rather_than_desyncing_the_keystream() assert!(format!("{err:#}").contains("world read error"), "{err:#}"); } -/// A second `CMSG_AUTH_SESSION` once auth is over. The typed decoder would size a buffer from the -/// addon field and unwrap the zlib, so the loop refuses the opcode before decoding. Session-fatal -/// like every other frame the loop cannot take. #[test] fn an_auth_session_after_auth_is_refused_before_the_typed_decoder_sees_it() { let (mut client, mut c_enc, _c_dec, server) = framing_session(); let mut body = Vec::new(); - body.extend_from_slice(&[0u8; 8]); // build, server_id + body.extend_from_slice(&[0u8; 8]); body.extend_from_slice(b"TESTER\0"); - body.extend_from_slice(&[0u8; 24]); // client_seed, client_proof - body.extend_from_slice(&u32::MAX.to_le_bytes()); // 4 GiB addon claim over garbage + body.extend_from_slice(&[0u8; 24]); + body.extend_from_slice(&u32::MAX.to_le_bytes()); body.extend_from_slice(&[0xFF; 32]); c_enc .write_encrypted_client_header( @@ -245,17 +150,11 @@ fn an_auth_session_after_auth_is_refused_before_the_typed_decoder_sees_it() { ); } -/// A KNOWN opcode carrying a body its parser cannot consume: `CMSG_CAST_SPELL` needs a spell id and -/// a target block, and gets one stray byte. -/// -/// This is the case an opcode allow-list would miss — the frame passes any "do we know this -/// opcode?" check and still fails to decode. Same verdict, same reason as an unknown opcode: the -/// decode failure means the declared length was not the real length. #[test] fn a_known_opcode_with_an_undecodable_body_is_session_fatal_too() { let (mut client, mut c_enc, _c_dec, server) = framing_session(); c_enc - .write_encrypted_client_header(&mut client, 4 + 1, 0x012E /* CMSG_CAST_SPELL */) + .write_encrypted_client_header(&mut client, 4 + 1, 0x012E) .unwrap(); client.write_all(&[0x01]).unwrap(); drop(client); @@ -267,18 +166,6 @@ fn a_known_opcode_with_an_undecodable_body_is_session_fatal_too() { assert!(format!("{err:#}").contains("world read error"), "{err:#}"); } -// =========================================================================================== -// 4. Session CLEANUP on the error path — the seat and the world entity -// =========================================================================================== - -/// The login queue's seat accounting has to survive the abnormal exit, not only the polite one. A -/// malformed frame ends `run_world_session_with_queue` through its `Err` path; the `queue.depart()` -/// in the teardown runs after the read loop returns, however it returned. -/// -/// If a seat leaked on the error path, a realm at `LYRACORE_MAX_SESSIONS` would lose one seat per -/// crashed or hostile client and eventually refuse every login, with nothing in the log to say why -/// and no cure short of a restart. That is the exact shape of an outage that gets misdiagnosed as -/// "the module is wedged". #[test] fn a_session_ending_in_a_framing_error_still_gives_its_seat_back() { let store = framing_store(); @@ -296,7 +183,6 @@ fn a_session_ending_in_a_framing_error_still_gives_its_seat_back() { "precondition: the admitted session holds the only seat" ); - // End it the ugly way. c_enc .write_encrypted_client_header(&mut client, 4, 0xDEAD) .unwrap(); @@ -319,13 +205,6 @@ fn a_session_ending_in_a_framing_error_still_gives_its_seat_back() { ); } -/// The other half of teardown: the player's world ENTITY. A session that dies on a framing error -/// while the player is in the world must still run `logout`, or the character stays spawned — -/// visible to everyone, unattackable, and blocking their own relog. -/// -/// The clean-logout path is already covered (`logout_while_out_of_combat_succeeds`); this pins that -/// the same cleanup happens when the loop unwinds instead of returning `Ok`, which is the case an -/// early-return added to the error branch would silently break. #[test] fn a_session_that_dies_in_world_still_deletes_the_players_entity() { let store = std::sync::Arc::new(quest_store()); @@ -342,8 +221,6 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Drain the login sequence; the last frames are partial-VALUES updates gtker's decoder rejects - // by design, so read tolerantly (same reason as `enter_world`'s drain). for _ in 0..10 { let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); } @@ -355,7 +232,6 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { "precondition: the player is in the world and has not logged out" ); - // Die on a malformed frame rather than logging out. c_enc .write_encrypted_client_header(&mut client, 4, 0xDEAD) .unwrap(); @@ -376,13 +252,6 @@ fn a_session_that_dies_in_world_still_deletes_the_players_entity() { ); } -/// The counterweight to the three tests above, and the reason they are worth having: the read loop -/// must be strict about FRAMING without becoming strict about CONTENT. -/// -/// An addon frame (`CMSG_MESSAGECHAT` in the addon pseudo-language) whose `STC` envelope is -/// malformed is other-server traffic or an addon bug — never grounds to disconnect. It is dropped -/// inside `handle_addon_message`, and the proof that the session survived is that a perfectly -/// ordinary `CMSG_CHAR_ENUM` sent AFTER it is still answered. #[test] fn a_malformed_addon_envelope_is_dropped_and_the_session_keeps_serving() { let store = framing_store(); @@ -390,9 +259,8 @@ fn a_malformed_addon_envelope_is_dropped_and_the_session_keeps_serving() { let server = std::thread::spawn(move || run_world_session(server_end, store.clone())); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // A well-FORMED addon chat frame carrying a body the `STC` envelope parser rejects. let mut body = Vec::new(); - body.extend_from_slice(&0u32.to_le_bytes()); // chat type SAY — no target CString to skip + body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&codec::addon::LANG_ADDON.to_le_bytes()); body.extend_from_slice(b"NOTSTC|garbage\0"); c_enc @@ -404,7 +272,6 @@ fn a_malformed_addon_envelope_is_dropped_and_the_session_keeps_serving() { .unwrap(); client.write_all(&body).unwrap(); - // The session must still be alive to answer this. CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); diff --git a/gateway/src/world/guild_fee.rs b/gateway/src/world/guild_fee.rs index 87c925b30..d3b49d592 100644 --- a/gateway/src/world/guild_fee.rs +++ b/gateway/src/world/guild_fee.rs @@ -1,20 +1,8 @@ -//! The Gateway half of the Fee Hold. It puts the three Durable Requests of a guild fee in order: -//! hold on the actor's Home Shard, decide on Realm-core, finish on the Home Shard. Every Gate and -//! the exactly-once rules live in the Module (`module/src/guild/fee.rs`). -//! -//! Recovery is forward only. A Hold that a crash or a lost connection left behind is decided from -//! its own terms and finished, at the next world entry and before the next fee. It is never -//! dropped. A Character has one Fee Hold at a time, on whichever Shard holds the Character. -//! -//! A hold call mints its own operation id and is never retried, so no Hold is taken twice for one -//! id. After a lost answer the Hold is found by its payer and re-driven, not held again. - use anyhow::Result; use lyracore_shared::guild::GuildRefusal; use super::Actor; -/// The five tabard design values of a Guild Emblem, in wire order. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Emblem { pub(crate) emblem_style: u32, @@ -24,62 +12,44 @@ pub(crate) struct Emblem { pub(crate) background_color: u32, } -/// What the actor asks to pay for. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum FeeRequest { - /// Save `emblem` at the Tabard Designer `npc_guid`. Emblem { npc_guid: u64, emblem: Emblem }, - /// Buy a Guild Charter for a Guild named `name` at the Petitioner `npc_guid`. Charter { npc_guid: u64, name: String }, } -/// What Realm-core decides on, as a Fee Hold records it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum FeeTerms { Emblem(Emblem), - /// The Guild Charter the Hold created. Charter { charter_item_guid: u64, name: String, }, } -/// One Fee Hold on the actor's Home Shard. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct FeeHold { pub(crate) operation_id: u64, pub(crate) terms: FeeTerms, } -/// Realm-core's decision on a guild fee. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum FeeOutcome { Accepted, Refused(GuildRefusal), } -/// The Durable Requests and reads of a guild fee. `self` is the actor's Home Shard; decisions go -/// to Realm-core. pub(crate) trait GuildFeeStore: Send + Sync { - /// The actor's Fee Hold on its Home Shard, if one is left. fn guild_fee_held(&self, actor: Actor) -> Result>; - /// Move the fee for `request` from the actor's purse into a new Fee Hold under a fresh - /// operation id. `Ok(Err(_))` is a hold Refusal, and nothing changed. fn guild_fee_hold( &self, actor: Actor, request: FeeRequest, ) -> Result>; - /// Realm-core's one decision for `hold`, committed now unless it already exists. fn guild_fee_decide(&self, actor: Actor, hold: FeeHold) -> Result; - /// Spend the Hold when `accepted`, refund it otherwise, then delete it. Without a Hold for - /// `operation_id` this changes nothing. fn guild_fee_finish(&self, actor: Actor, operation_id: u64, accepted: bool) -> Result<()>; } -/// Pay a guild fee. A leftover Hold is finished first. A hold Refusal (the NPC, the purse) -/// returns before any copper moved. Any other answer is Realm-core's decision, and the Hold has -/// been spent or refunded to match it. `Err` leaves any Hold for the next re-drive. pub(crate) fn pay( store: &St, actor: Actor, @@ -92,8 +62,6 @@ pub(crate) fn pay( } } -/// Finish the actor's leftover Fee Hold at world entry. A failure logs and leaves the Hold for -/// the next attempt. pub(crate) fn redrive(store: &St, actor_guid: u64) { let Some(actor) = Actor::new(actor_guid) else { return; @@ -157,8 +125,6 @@ mod tests { } } - /// The Durable Request after which the Gateway loses its answer: the reducer committed, the - /// Gateway heard an error. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Step { Hold, @@ -166,7 +132,6 @@ mod tests { Finish, } - /// Realm-core, shared by every Home Shard of the realm. #[derive(Default)] struct Realm { leader: u64, @@ -175,8 +140,6 @@ mod tests { emblems_saved: usize, } - /// One Home Shard: purses, the Fee Holds keyed by payer, and a step that fails once after - /// committing. struct Shard { realm: Arc>, purses: Mutex>, @@ -228,7 +191,6 @@ mod tests { Ok(()) } - /// Move the actor's Hold to `other`, as a Transfer carries it with the Character. fn transfer_holds_to(&self, other: &Shard) { other .holds @@ -375,8 +337,6 @@ mod tests { assert!(realm.lock().unwrap().decisions.is_empty()); } - /// A crash after each step leaves one Hold. The next world entry finishes it with exactly - /// one outcome: the copper is spent or refunded once, and the Guild agrees. #[test] fn world_entry_finishes_a_hold_left_after_any_step_exactly_once() { for step in [Step::Hold, Step::Decide, Step::Finish] { diff --git a/gateway/src/world/guild_tests.rs b/gateway/src/world/guild_tests.rs index 907143993..063f8aefc 100644 --- a/gateway/src/world/guild_tests.rs +++ b/gateway/src/world/guild_tests.rs @@ -1,10 +1,5 @@ -//! Guild session facts over an encrypted World Session: sign-on at world entry, sign-off at -//! logout, and the guild cleanup a Character delete triggers. Guild opcode behaviour is tested -//! against `InMemoryGuildActions` in `handlers/guild.rs`. - use super::*; -/// Character 1 as a member of Guild 7 at rank 3. fn guild_member_store() -> WorldFake { let base = tester_store(7); WorldFake { @@ -26,7 +21,6 @@ fn guild_member_store() -> WorldFake { } } -/// The recorded operations, in call order, without their Shard. fn recorded(store: &WorldFake) -> Vec { store .topology @@ -120,7 +114,6 @@ fn a_world_port_that_fails_after_sign_on_still_signs_off() { let base = guild_member_store(); WorldFake { session: SessionState { - // The world-port ack is only actionable after teleport_player removed the old entity. entity_in_world: false, worldport_login_error: Some("character 1 is stranded on map 36".into()), ..base.session @@ -153,9 +146,6 @@ fn a_world_port_that_fails_after_sign_on_still_signs_off() { assert!(position(&calls, "guild_op:SignOff") < position(&calls, "logout")); } -/// Guild state on Realm-core for the deleted-Character reconciliation: Leader 5 and member 6 in -/// one Guild, and a Petition owned by 7 with a Signature by 8. Characters 5 and 7 still exist on -/// the `instances` peer; 6 and 8 exist on no World Shard. fn guild_cleanup_topology() -> std::sync::Arc { guild_cleanup_topology_failing_lookup_for(None) } @@ -255,8 +245,6 @@ fn only_characters_absent_from_every_world_shard_are_forgotten() { ); } -/// A Character row in any Shard's cache proves the Character exists, so the pass takes no durable -/// snapshot for it. Only 6 and 8, gone from every cache, pay for the absence check. #[test] fn a_character_still_in_a_shard_cache_takes_no_durable_snapshot() { let world = guild_cleanup_topology(); @@ -269,8 +257,6 @@ fn a_character_still_in_a_shard_cache_takes_no_durable_snapshot() { assert!(forgotten(&kept).is_empty()); } -/// A `game_character` delete owes cleanup for that Character only, and none at all for a -/// Character that no Guild, Petition or Signature names. #[test] fn a_single_delete_checks_only_its_own_character() { let world = guild_cleanup_topology(); @@ -279,8 +265,6 @@ fn a_single_delete_checks_only_its_own_character() { assert_eq!(durable_absence_checks(&world), 1); } -/// A lookup that fails for one deleted Character does not hold up the others. The pass still -/// fails, so the worker keeps the work and retries it. #[test] fn a_failed_lookup_still_cleans_the_other_deleted_characters() { let world = guild_cleanup_topology_failing_lookup_for(Some(6)); @@ -313,8 +297,6 @@ fn an_unavailable_world_shard_defers_every_guild_cleanup() { assert!(forgotten(&incomplete).is_empty()); } -/// A Guild Leader's delete answers 0x3A, FAILED_GUILD_LEADER in mangos, and never reaches the -/// Home Shard (`cm:CharacterHandler.cpp:540-546`). #[test] fn char_delete_of_a_guild_leader_replies_failed_and_deletes_nothing() { let store = std::sync::Arc::new({ diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index b7ec95228..177859333 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -1,7 +1,3 @@ -//! A Fake for the traits `Loot::handle` is bounded on: Death, Loot Window, Npc, Shard Routing and -//! Loot Roll. Death and the Loot Window carry state. The other three are not reachable from the -//! tests that use it. - use crate::codec; use crate::stdb::ReducerCallError; use crate::world::handlers::{ @@ -23,15 +19,11 @@ pub(crate) enum Life { #[derive(Default)] pub(crate) struct HandleLootFake { life: Mutex>, - /// Owner guid to the guid of the corpse that owner can reclaim. corpses: HashMap, - /// Characters with an unanswered resurrect offer. res_offers: Mutex>, - /// Characters holding an unspent Self-Resurrection Option. self_res_options: Mutex>, spirit_healers: HashSet, loot_by_viewer: HashMap>, - /// When set, every Durable Request fails as a Transport Loss. transport_lost: bool, } @@ -71,7 +63,6 @@ impl HandleLootFake { self } - /// Characters nobody set up are alive. pub(crate) fn life(&self, guid: u64) -> Life { self.life .lock() @@ -89,7 +80,6 @@ impl HandleLootFake { self.self_res_options.lock().unwrap().contains(&guid) } - /// The Module refuses a request the character's state does not allow. fn require(&self, operation: &str, guid: u64, needed: Life) -> Result<()> { self.reachable(operation)?; match self.life(guid) { @@ -359,7 +349,6 @@ impl LootRollStore for HandleLootFake { } } -/// A single-database gateway: every handle serves every location, and nothing routes. impl ShardRoutingStore for HandleLootFake { fn shard_name(&self) -> &str { "handle-loot-fake" diff --git a/gateway/src/world/handlers/auction.rs b/gateway/src/world/handlers/auction.rs index 241292666..84e79fc9a 100644 --- a/gateway/src/world/handlers/auction.rs +++ b/gateway/src/world/handlers/auction.rs @@ -1,5 +1,3 @@ -//! Auction-house protocol family. - use super::super::*; use lyracore_shared::auction::AuctionRefusal; @@ -10,8 +8,6 @@ pub(crate) struct AuctionHousePolicy { pub(crate) consignment_rate: u32, } -/// What an auction packet needs from the auctioneer: the house it serves, and whether its faction -/// refuses to talk to this Character. Every other interaction condition belongs to the Module. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct AuctionInteraction { pub(crate) house: AuctionHousePolicy, @@ -37,7 +33,6 @@ pub(crate) enum CreateAuctionOutcome { Database, } -// The vanilla client has no code for invalid terms; it reads as the generic database failure. impl From for CreateAuctionOutcome { fn from(refusal: AuctionRefusal) -> Self { match refusal { @@ -92,9 +87,6 @@ pub(crate) struct CancelAuctionRequest { pub(crate) house_id: u32, } -/// What a Cancellation came to. `NotFound` covers a missing or expired listing, another player's, -/// and another market's; `Stale` covers a cut that changed after the read and every other -/// Refusal. A seller who `CannotAfford` the cut gets no answer at all, as in vanilla. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CancelAuctionOutcome { Cancelled, @@ -159,9 +151,6 @@ pub(crate) trait AuctionActionStore: Send + Sync { fn cancel_auction(&self, request: CancelAuctionRequest) -> Result; - /// Drive every unfinished bid or Cancellation Hold of `actor` to its end. A Gateway that - /// stopped between phases leaves them fenced, and a Cancellation has no natural retry because - /// the listing leaves the owner list. fn resume_auction_holds(&self, actor: Actor) -> Result<()>; fn auction_query( @@ -183,8 +172,7 @@ enum AuctionRequest { pub(crate) const CMSG_AUCTION_LIST_ITEMS_OPCODE: u32 = 0x0258; -/// Decode build-5875 browse bodies locally because the protocol dependency narrows the wire's -/// `u32` quality to an enum before callers can observe the real client's `0xffff_ffff` sentinel. +// The typed quality enum cannot represent the client's 0xffff_ffff sentinel. pub(crate) fn decode_auction_browse(body: &[u8]) -> Result { if !(32..=287).contains(&body.len()) { return Err(anyhow!( @@ -259,8 +247,6 @@ fn browse( )])) } -/// Resolve the Character and auction house. Read paths apply the faction verdict; Durable -/// Requests leave the interaction Gate to the Module. fn auction_actor_interaction( store: &St, player: &ProtocolSession, @@ -269,7 +255,6 @@ fn auction_actor_interaction( let Some(actor) = player.self_guid().and_then(Actor::new) else { return Ok(None); }; - // A missing auctioneer or house is `None`; a failed Durable Read is a failure, not a Refusal. let interaction = store.auction_interaction(actor, auctioneer_guid)?; Ok(interaction.map(|interaction| (actor, interaction))) } @@ -330,9 +315,6 @@ fn bid_result(auction_id: u32, outcome: PlaceBidOutcome) -> ProtocolReply { )]) } -/// `SMSG_AUCTION_COMMAND_RESULT` for a Cancellation (`cm:AuctionHouseHandler.cpp:423-428,441-442, -/// 459`): the auction id with Removed and Ok, id 0 with Removed and ErrDatabase, or nothing for a -/// seller who cannot pay the cut. fn cancel_result(auction_id: u32, outcome: CancelAuctionOutcome) -> ProtocolReply { use wow_world_messages::vanilla::{ SMSG_AUCTION_COMMAND_RESULT_AuctionCommandAction as Action, @@ -413,8 +395,6 @@ impl ProtocolFamily for Auction { else { return Ok(bid_result(auction_id, PlaceBidOutcome::Database)); }; - // A Refusal arrives as an outcome. An error is a failure with an unknown durable - // result, so it ends the session instead of posing as a gameplay answer. let outcome = store.place_bid(PlaceBidRequest { actor, auctioneer_guid, @@ -445,8 +425,6 @@ impl ProtocolFamily for Auction { ClientOpcodeMessage::CMSG_AUCTION_REMOVE_ITEM(message) => { let auction_id = message.auction_id; let auctioneer_guid = message.auctioneer.guid(); - // Vanilla ignores a Cancellation away from an auctioneer - // (`cm:AuctionHouseHandler.cpp:413-415`). let Some((actor, interaction)) = auction_actor_interaction(store, player, auctioneer_guid)? else { @@ -528,7 +506,6 @@ pub(super) mod tests { CMSG_AUCTION_SELL_ITEM, }; - /// How a Fake call fails: a constructor for the typed error, so every call builds a fresh one. type Failure = fn() -> ReducerCallError; pub(crate) struct InMemoryAuctionActions { @@ -1348,7 +1325,6 @@ pub(super) mod tests { .outbound) } - /// The encoded `SMSG_AUCTION_COMMAND_RESULT` body of the one packet in `outbound`. fn command_result_bytes(outbound: &[Outbound]) -> Vec { use wow_world_messages::Message; let [Outbound::One(ServerOpcodeMessage::SMSG_AUCTION_COMMAND_RESULT(message))] = outbound @@ -1360,7 +1336,6 @@ pub(super) mod tests { bytes } - /// `cm:AuctionHouseHandler.cpp:64-69,459`: auction id, AUCTION_REMOVED (1), AUCTION_OK (0). #[test] fn a_cancelled_listing_answers_removed_ok_with_its_auction_id() { let store = store_with(Some(valid_interaction())); @@ -1380,8 +1355,6 @@ pub(super) mod tests { ); } - /// `cm:AuctionHouseHandler.cpp:423-428`: auction id 0, AUCTION_REMOVED (1), - /// AUCTION_ERR_DATABASE (2). #[test] fn a_missing_foreign_or_stale_listing_answers_removed_database_error() { for outcome in [CancelAuctionOutcome::NotFound, CancelAuctionOutcome::Stale] { @@ -1395,8 +1368,6 @@ pub(super) mod tests { } } - /// `cm:AuctionHouseHandler.cpp:441-442` and `:413-415`: no answer to a seller who cannot pay - /// the cut, or who is not at an auctioneer. #[test] fn an_unaffordable_cut_or_an_absent_auctioneer_is_answered_by_silence() { let store = store_with(Some(valid_interaction())); diff --git a/gateway/src/world/handlers/bank.rs b/gateway/src/world/handlers/bank.rs index aba0a1e49..d2ba0450b 100644 --- a/gateway/src/world/handlers/bank.rs +++ b/gateway/src/world/handlers/bank.rs @@ -1,25 +1,13 @@ -//! Bank family: open the bank window, right-click to bank, right-click to withdraw. Same shape as -//! the vendor family. - use super::super::*; use super::show_bank; use crate::stdb::{classify, DurableFailure}; -/// Bank Durable Requests. pub(crate) trait BankStore: Send + Sync { - /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM` - /// — right-click to bank, right-click to withdraw). The module infers the direction from `slot` - /// and resolves the receiving free slot itself; a full destination (bank or carry space) is a - /// per-action `Err`. fn auto_bank_item(&self, actor: Actor, slot: u8) -> Result<()>; - /// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`). A refusal `Err` leads - /// with its `SMSG_BUY_BANK_SLOT_RESULT` code in brackets (the trainer `[N]` precedent). fn buy_bank_slot(&self, actor: Actor, banker_guid: u64) -> Result<()>; } -/// Move the item in `slot_index` between carry space and the bank. A Refusal, or no Character yet, -/// answers `SMSG_INVENTORY_CHANGE_FAILURE`; a Transport Loss ends the World Session. fn auto_bank( outbound: &mut Vec, store: &St, @@ -49,12 +37,6 @@ fn auto_bank( } } -/// Bank family: `CMSG_BANKER_ACTIVATE` opens the bank window (a standing-refusing banker gets no -/// reply at all, matching the vendor `CMSG_LIST_INVENTORY` gate); `CMSG_AUTOBANK_ITEM` deposits a -/// carried item into the first free bank slot, `CMSG_AUTOSTORE_BANK_ITEM` withdraws a banked item -/// into the first free carry slot — both resolve to the same module entry point, which infers the -/// direction from the source slot and carries the banker-proximity gate for free (it reuses the -/// move core). Only the main pseudo-bag (255) is addressed, matching the item handler's restriction. pub(crate) struct Bank; impl ProtocolFamily for Bank { @@ -65,7 +47,7 @@ impl ProtocolFamily for Bank { ) -> Result { let msg = request.message()?; let mut outbound = Vec::new(); - const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 — same restriction as the item handler + const MAIN_BAG: u8 = 255; match msg { ClientOpcodeMessage::CMSG_BANKER_ACTIVATE(c) => { let banker_guid = c.guid.guid(); @@ -79,7 +61,6 @@ impl ProtocolFamily for Bank { } outbound.push(show_bank(banker_guid)); } - // Right-click a bag item with the bank open → deposit into the first free bank slot. ClientOpcodeMessage::CMSG_AUTOBANK_ITEM(c) => { if c.bag_index == MAIN_BAG { auto_bank(&mut outbound, store, conn, c.slot_index, "deposit")?; @@ -91,11 +72,8 @@ impl ProtocolFamily for Bank { ); } } - // Buy the next bank bag slot from the named banker. Success and every refusal relay - // `SMSG_BUY_BANK_SLOT_RESULT`; the refusal code rides the module's `[N]` error tag. ClientOpcodeMessage::CMSG_BUY_BANK_SLOT(c) => { let banker_guid = c.guid.guid(); - // No Character yet gets an untagged reason, which answers NotBanker. let refusal = match conn .self_guid() .and_then(Actor::new) @@ -120,7 +98,6 @@ impl ProtocolFamily for Bank { ), )); } - // Right-click a banked item → withdraw into the first free backpack/bag slot. ClientOpcodeMessage::CMSG_AUTOSTORE_BANK_ITEM(c) => { if c.bag_index == MAIN_BAG { auto_bank(&mut outbound, store, conn, c.slot_index, "withdraw")?; diff --git a/gateway/src/world/handlers/cast/cancel.rs b/gateway/src/world/handlers/cast/cancel.rs index 14c68ed5f..3c454de5e 100644 --- a/gateway/src/world/handlers/cast/cancel.rs +++ b/gateway/src/world/handlers/cast/cancel.rs @@ -1,10 +1,5 @@ -//! Cast, channel and aura cancellation. Durable changes drive the protocol feedback. - use super::*; -/// `CMSG_CANCEL_CAST`: drop the caller's pending cast so its scheduled completion cannot fire a -/// phantom `SMSG_SPELL_GO` that wedges the client in "Another action is in progress". The client's -/// spell id is unused: the caller has at most one pending cast, which names it. pub(super) fn cancel_cast( store: &St, session: &mut ProtocolSession, @@ -12,8 +7,6 @@ pub(super) fn cancel_cast( best_effort(session, "cancel_cast", |actor| store.cancel_cast(actor)) } -/// `CMSG_CANCEL_AURA`: remove the caller's own aura named by the wire spell id. The aura relay then -/// re-syncs the buff bar. pub(super) fn cancel_aura( store: &St, session: &mut ProtocolSession, @@ -24,9 +17,6 @@ pub(super) fn cancel_aura( }) } -/// The shared cancellation outcome: nothing on the wire, no session transition, and a Refusal that -/// is logged rather than raised. Only a Transport Loss ends the session, because no later request -/// could be served either. A session with no Actor has nothing to cancel. fn best_effort( session: &mut ProtocolSession, what: &str, @@ -48,7 +38,6 @@ mod tests { use super::*; use wow_world_messages::vanilla::{CMSG_CANCEL_AURA, CMSG_CANCEL_CAST}; - /// A store that refuses both cancellations, as a race with completion or expiry would. fn refusing_store(error: &str) -> InMemoryCasts { InMemoryCasts { cancel_error: Some(error.into()), diff --git a/gateway/src/world/handlers/cast/manual.rs b/gateway/src/world/handlers/cast/manual.rs index dc203d8c3..66714e022 100644 --- a/gateway/src/world/handlers/cast/manual.rs +++ b/gateway/src/world/handlers/cast/manual.rs @@ -1,25 +1,13 @@ -//! Manual-completion routes: enchant, disenchant, fishing and lock opening. Their durable -//! operations emit no cast event, so this module sends the client's completion sequence itself. -//! The four routes share one shape — decode a target, request a durable operation, then either the -//! shared success sequence or the shared simple failure — and differ only in which target they -//! decode and which operation they call. - use super::*; use wow_world_messages::vanilla::SpellCastTargets_SpellCastTargetFlags_Gameobject as GoTarget; use wow_world_messages::vanilla::SpellCastTargets_SpellCastTargetFlags_Item as ItemTarget; -/// Which manual-completion operation a cast requests, decided once in `route_for` from the spell's -/// effect metadata. pub(crate) enum ManualRoute { - /// Enchant or disenchant an item; the target is an item-instance GUID. Enchant(EnchantRoute), - /// Fishing; needs no target. Fish, - /// Pick Lock; the target is a GameObject GUID. OpenLock, } -/// The item-instance GUID off an ITEM-target cast, or 0 if the cast carries none. fn item_target(c: &CMSG_CAST_SPELL) -> u64 { match c.targets.target_flags.get_item() { Some(ItemTarget::Item { item }) => item.guid(), @@ -27,8 +15,6 @@ fn item_target(c: &CMSG_CAST_SPELL) -> u64 { } } -/// The GameObject GUID off a cast, accepting both target-flag shapes the vanilla client sends for -/// Pick Lock, or 0 if the cast carries neither. fn gameobject_target(c: &CMSG_CAST_SPELL) -> u64 { match c.targets.target_flags.get_gameobject() { Some(GoTarget::Gameobject { gameobject }) => gameobject.guid(), @@ -37,9 +23,6 @@ fn gameobject_target(c: &CMSG_CAST_SPELL) -> u64 { } } -/// Enchant, disenchant, fishing and lock opening: decode the route's target, request the durable -/// operation, then send the shared completion sequence on success or the shared simple failure on -/// refusal. None of these reducers emit a cast event, so the gateway is the only sender either way. pub(super) fn manual_completion_cast( store: &St, session: &mut ProtocolSession, @@ -47,8 +30,6 @@ pub(super) fn manual_completion_cast( route: ManualRoute, ) -> Result { let spell = c.spell; - // `Err` carries the reason the request ended in a Refusal: the Module's, or the Gateway's own - // when the cast names no usable target. let completed: std::result::Result = 'request: { let Some(actor) = session.actor() else { break 'request Err(NO_ACTOR.to_string()); diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 9c7fde662..b74d2ac3d 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -1,16 +1,3 @@ -//! Cast-lifecycle seam: one entry point owns `CMSG_CAST_SPELL` route selection, client target -//! decoding, durable dispatch and the ordered client-visible messages. -//! -//! The module root holds the seam types, the route decision tree and the ordinary cast route -//! (instant, timed, next-swing, ground-area and ground-targeted casts). Ranged auto-repeat lives in -//! [`ranged`], the routes whose reducers emit no cast event — enchant, disenchant, fishing and lock -//! opening — in [`manual`], and both cancellations in [`cancel`]. -//! -//! Deleting this module does not delete the decisions in it. Spell taxonomy routing, client target -//! decoding, durable-operation selection, ranged auto-repeat state, failure mapping and the -//! message-order rules would spread back across the broad combat handler and the store adapters, -//! which is where they lived before and why one cast change needed edits in several files. - mod cancel; mod manual; mod ranged; @@ -21,40 +8,26 @@ use crate::stdb::{classify, DurableFailure}; use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; use wow_world_messages::vanilla::CMSG_CAST_SPELL; -/// `SMSG_CAST_RESULT`. Both bodies are hand-rolled (gtker's typed message inverts the status -/// semantics), so they ride `Outbound::Raw` under this opcode. +// gtker inverts CAST_RESULT status semantics; encode both result bodies raw. const OP_CAST_RESULT: u16 = 0x0130; -/// Imported spell taxonomy and the durable cast operations the module needs. Narrow on purpose: -/// nothing here exposes a coordinator read, a reducer handle or a spell table to the caller. pub(crate) trait CastStore: MeleeActionStore + Send + Sync { - /// True iff `spell_id` is an auto-repeat ranged attack (Auto Shot / wand Shoot) — the - /// `RANGED_AUTO_REPEAT` cast_flags bit, so a new ranged ability onboards as data. fn spell_is_ranged_auto_repeat(&self, spell_id: u32) -> bool; - /// Enchant/disenchant routing from `spell_id`'s effect rows; `None` for every other cast. fn enchant_route(&self, spell_id: u32) -> Option; - /// True iff `spell_id` is a Fishing cast (E_FISH). fn spell_is_fishing(&self, spell_id: u32) -> bool; - /// True iff `spell_id` is an Open-Lock cast (E_OPEN_LOCK). fn spell_is_open_lock(&self, spell_id: u32) -> bool; - /// True iff `spell_id` spawns a ground area (E_PERSISTENT_AREA) — its GO carries no hit list. fn spell_is_ground_area(&self, spell_id: u32) -> bool; - /// The spell's cast time in ms — 0 is instant, `None` is unknown metadata. fn spell_cast_time(&self, spell_id: u32) -> Option; - /// True iff `spell_id` queues on the caster's next melee swing (Heroic Strike/Cleave). fn spell_queues_next_swing(&self, spell_id: u32) -> bool; - /// Cast a spell at a unit. `target_guid` 0 means no unit target — the module then applies its - /// own self-cast rule. fn cast_spell(&self, actor: Actor, spell_id: u32, target_guid: u64) -> Result<()>; - /// Cast a ground-targeted spell at the exact point the client clicked. fn cast_spell_at( &self, actor: Actor, @@ -65,63 +38,32 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { z: f32, ) -> Result<()>; - /// Cast a spell whose explicit target is an owned inventory item. The module validates the - /// effect kind and consumes the item only after the gameplay gates pass. fn cast_item_target(&self, actor: Actor, spell_id: u32, slot: u8) -> Result<()>; - /// Arm the ranged auto-repeat loop on `target_guid` with `spell_id`. The module requires an - /// equipped ranged weapon; `Err` is the Refusal the client sees as a cast failure. fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()>; - /// The bag slot holding the item instance a client spell-target names, so the enchant and - /// disenchant operations receive a slot rather than a guid. fn item_slot_by_guid(&self, item_guid: u64) -> Option; - /// Disenchant the item in `slot`. The module validates skill and disenchantability, and yields - /// the resulting reagents into the bag. fn disenchant_item(&self, actor: Actor, slot: u8) -> Result<()>; - /// Apply `enchant_id` to the item in `slot`. The module validates skill, consumes the reagent - /// and stamps the enchant on the item instance. fn enchant_item_on_slot(&self, actor: Actor, slot: u8, enchant_id: u32) -> Result<()>; - /// The instant-resolve Fishing catch. fn fish(&self, actor: Actor) -> Result<()>; - /// Pick the lock on GameObject `go_guid`. The module gates range, the lock requirement and the - /// caller's skill; `Err` is the Refusal the client sees as a cast failure. fn pick_lock(&self, actor: Actor, go_guid: u64) -> Result<()>; - /// Drop the caller's pending cast, so a scheduled completion cannot fire later. Under the - /// one-pending-cast rule the caller identifies the cast, so the client's spell id is unused. fn cancel_cast(&self, actor: Actor) -> Result<()>; - /// Remove the caller's own aura named by the wire spell id. The aura relay re-syncs the buff bar. fn cancel_aura(&self, actor: Actor, spell_id: u32) -> Result<()>; - // The two reads below are shared with the character, vendor and query paths. They are declared - // on this family only, because a second declaration of the same name on another family would - // make every call through the `WorldStore` umbrella ambiguous. The - // ranged teardown comes from `MeleeActionStore`: melee and ranged share one durable row, so it - // has one declaration, on the seam that owns that row. - - /// Every item a character owns. The ranged route reads the equipped launcher and the projectile - /// stacks from it. fn player_items(&self, owner_guid: u64) -> Result>; - /// One item template by entry — the launcher's class/subclass and the projectile's display id. fn item_template(&self, entry: u32) -> Result>; } -/// Which route a `CMSG_CAST_SPELL` takes, selected from imported taxonomy alone. enum CastRoute { - /// Auto Shot and wand Shoot: an auto-repeat attack loop, not a one-shot cast. RangedAutoRepeat, - /// Enchant, disenchant, fishing and lock opening. Their reducers emit no cast event, so the - /// route sends the completion sequence itself. The payload is the one classification decision, - /// made once here instead of re-testing the same effect metadata inside the route. ManualCompletion(manual::ManualRoute), - /// Every other cast: instant, timed, next-swing, ground-area and ground-targeted. Ordinary, } @@ -139,11 +81,8 @@ fn route_for(store: &St, spell_id: u32) -> CastRoute { } } -/// What the cast failure mapping reads when the session has no Actor to cast as. const NO_ACTOR: &str = "no resolved actor"; -/// The Module's reason for a Refusal, which the client sees as a cast failure. A Transport Loss -/// cannot serve any further request, so it ends the World Session. fn refusal_reason(error: anyhow::Error) -> Result { match classify(&error) { DurableFailure::Refusal { reason } => Ok(reason.to_string()), @@ -151,7 +90,6 @@ fn refusal_reason(error: anyhow::Error) -> Result { } } -/// The client's unit target, or 0 for "none" — the module substitutes the caster. fn unit_target(c: &CMSG_CAST_SPELL) -> u64 { c.targets .target_flags @@ -159,7 +97,6 @@ fn unit_target(c: &CMSG_CAST_SPELL) -> u64 { .map_or(0, |u| u.unit_target.guid()) } -/// The ground point the client clicked (`DEST_LOCATION`), if the cast carries one. fn dest_target(c: &CMSG_CAST_SPELL) -> Option<(f32, f32, f32)> { c.targets .target_flags @@ -167,7 +104,6 @@ fn dest_target(c: &CMSG_CAST_SPELL) -> Option<(f32, f32, f32)> { .map(|d| (d.destination.x, d.destination.y, d.destination.z)) } -/// Handle cast requests, cancellations and ranged auto-repeat state. pub(crate) struct Cast; impl ProtocolFamily for Cast { @@ -196,33 +132,22 @@ impl ProtocolFamily for Cast { } } -/// The ordinary cast route: instant, timed, next-swing, ground-area and ground-targeted casts. fn ordinary_cast( store: &St, session: &mut ProtocolSession, c: &CMSG_CAST_SPELL, ) -> Result { let spell = c.spell; - // Thread the client's unit target so target-keyed effects — combo finishers, enemy spells — - // see the real target. No unit target stays 0 so the module applies its own self-cast rule. let target = unit_target(c); let mut outbound = Vec::new(); - // An INSTANT cast clears the client's cast slot HERE, before the durable request. The - // cast-event relay would deliver START/GO after the aura callback (the SDK fires table - // callbacks in a fixed alphabetical order), so the applied buff would reach the client first - // and wedge its cast slot in "Another action is in progress". Unknown cast time counts as - // instant: a stray START/GO is harmless, a missing one wedges. + // Clear instant casts before the Durable Request; SDK aura callbacks otherwise precede GO. let instant = store.spell_cast_time(spell).is_none_or(|t| t == 0); - // A next-swing spell (Heroic Strike/Cleave) sends nothing. The client lights the button on the - // press and holds the pending cast until the durable swing-fire emits its completion; the - // sequence below would un-light the button and resolve the cast at queue time. + // Next-swing spells must stay pending until the swing fires; do not clear them here. let queues_swing = instant && store.spell_queues_next_swing(spell); if instant && !queues_swing { if let Some(caster) = session.actor().map(Actor::guid) { - // vmangos order: START(0) then the raw CAST_RESULT(OK) then GO. The 5875 client needs - // that 5-byte ack before GO to make m_currentSpells clearable. - // A cast at a clicked ground point echoes the point in both target blocks. + // Build 5875 clears its cast slot with START, five-byte CAST_RESULT OK, then GO. let dest = dest_target(c); let start = match dest { Some(dest) => codec::build_spell_start_dest(caster, spell, dest), @@ -235,9 +160,7 @@ fn ordinary_cast( opcode: OP_CAST_RESULT, body: codec::build_cast_result_ok(spell), }); - // Any cast at a clicked ground point and a caster-anchored ground area (Consecration) - // impact the ground: an EMPTY hit list, or the self-cast fallback puts the caster in - // `hits[]` and the client plays the impact animation on the caster. + // Ground casts need empty hits; the caster would become a self target. let go = if let Some(dest) = dest { codec::build_spell_go_dest(caster, spell, dest) } else if store.spell_is_ground_area(spell) { @@ -251,8 +174,6 @@ fn ordinary_cast( } } - // A DEST_LOCATION block is a ground click (Flamestrike/Blizzard/Rain of Fire) — the durable - // ground cast anchors the area there. The clearing sequence above is unchanged either way. let item_guid = match c.targets.target_flags.get_item() { Some(wow_world_messages::vanilla::SpellCastTargets_SpellCastTargetFlags_Item::Item { item, @@ -282,9 +203,6 @@ fn ordinary_cast( } }; if let Some(reason) = refusal { - // Carry the mapped REASON so the client prints the red error line ("Not enough rage", - // "You must be behind your target"). A bare failure only resets the button and leaves - // server-only gates — behind, stealth, stance, react window — invisible. log::debug!( "world: cast {spell} rejected (account {}): {reason}", session.account_id @@ -297,8 +215,6 @@ fn ordinary_cast( Ok(outbound.into()) } -/// The focused in-memory cast adapter and the shared seam-test helpers. Every route module's tests -/// use this one fake, so `pub(super)` reaches the sibling route modules. #[cfg(test)] pub(super) mod tests { use super::*; @@ -312,22 +228,13 @@ pub(super) mod tests { Vector3d, }; - /// One recorded durable cast: caster, spell and unit target. pub(crate) type RecordedCast = (u64, u32, u64); - /// One recorded durable ground cast: a [`RecordedCast`] plus the click point. pub(crate) type GroundCast = (u64, u32, u64, f32, f32, f32); - /// One recorded ranged activation: caster, unit target and spell. pub(crate) type RangedAttack = (u64, u64, u32); - /// One recorded `enchant_item_on_slot` call: slot and enchant id. pub(crate) type EnchantCall = (u8, u32); - /// One recorded `pick_lock` call: caster and GameObject guid. pub(crate) type PickLockCall = (u64, u64); - /// One recorded `cancel_aura` call: caster and wire spell id. pub(crate) type CancelAuraCall = (u64, u32); - /// Spell-route metadata, item state and durable results for the cast routes, plus a record of - /// every durable call. Nothing else: no vendors, parties, mail, transfer or unrelated world - /// state. #[derive(Default)] pub(crate) struct InMemoryCasts { pub(crate) cast_time_ms: Option, @@ -337,20 +244,13 @@ pub(super) mod tests { pub(crate) enchant: Option, pub(crate) fishing: Vec, pub(crate) open_lock: Vec, - /// The Module's reason when the ordinary cast operations refuse. pub(crate) cast_error: Option, - /// When set, `start_ranged_attack` refuses with this reason. pub(crate) ranged_error: Option, - /// Every durable operation fails with a Transport Loss. pub(crate) transport_lost: bool, - /// The caller's owned items, and the templates their entries resolve to. pub(crate) items: Vec, pub(crate) templates: Vec, - /// Item-instance guid → bag slot, for the manual-completion item target. pub(crate) item_slots: Vec<(u64, u8)>, - /// Shared refusal for every manual-completion durable operation. pub(crate) manual_error: Option, - /// Shared refusal for both cancellation operations. pub(crate) cancel_error: Option, pub(crate) casts: Mutex>, pub(crate) ground_casts: Mutex>, @@ -363,12 +263,8 @@ pub(super) mod tests { pub(crate) pick_lock_calls: Mutex>, pub(crate) cancel_cast_calls: Mutex>, pub(crate) cancel_aura_calls: Mutex>, - /// The attackers with a live engagement: an armed ranged loop adds its caster, a stop - /// removes it. pub(crate) engaged: Mutex>, - /// Spell ids of the auras on the caster; a cancellation removes the one it names. pub(crate) auras: Mutex>, - /// Casters with a pending cast; a cancellation removes the caller's. pub(crate) pending_casts: Mutex>, } @@ -387,7 +283,6 @@ pub(super) mod tests { } } - /// The Module's answer to a durable request: a Refusal with `reason`, or success. fn answer(&self, operation: &str, reason: &Option) -> Result<()> { if self.transport_lost { return Err(ReducerCallError::transport_lost(operation).into()); @@ -531,8 +426,6 @@ pub(super) mod tests { } } - /// The ranged teardown the cast module shares with the melee seam. `start_attack` is never - /// reached from a cast route; it exists because the two share one durable engagement row. impl MeleeActionStore for InMemoryCasts { fn start_attack(&self, _actor: Actor, _target_guid: u64) -> Result<()> { unreachable!("no cast route arms a melee engagement") @@ -590,8 +483,6 @@ pub(super) mod tests { } } - /// The GAMEOBJECT target shape, or its ObjectUnk sibling — the two the vanilla client sends for - /// Pick Lock. fn gameobject_targets(go_guid: u64, unk_shape: bool) -> SpellCastTargets { let target = if unk_shape { SpellCastTargets_SpellCastTargetFlags_Gameobject::ObjectUnk { @@ -607,7 +498,6 @@ pub(super) mod tests { } } - /// The ranged state and client messages after one request. pub(crate) fn handled(result: (bool, ProtocolReply)) -> (bool, Vec) { (result.0, result.1.outbound) } @@ -622,8 +512,6 @@ pub(super) mod tests { Ok((repeating, reply)) } - /// One label per outbound unit, in order — the synchronous sequence is the contract, and it - /// spans both raw and typed sends. pub(crate) fn sequence(outbound: &[Outbound]) -> Vec { outbound .iter() @@ -648,7 +536,6 @@ pub(super) mod tests { .collect() } - /// The single `SMSG_SPELL_GO` in the batch. pub(crate) fn spell_go(outbound: &[Outbound]) -> &wow_world_messages::vanilla::SMSG_SPELL_GO { outbound .iter() @@ -659,8 +546,6 @@ pub(super) mod tests { .expect("the batch has no SMSG_SPELL_GO") } - // ── Instant casts ──────────────────────────────────────────────────────── - #[test] fn instant_unit_target_cast_clears_the_client_then_requests_the_durable_cast() { let store = InMemoryCasts::instant(); @@ -779,8 +664,6 @@ pub(super) mod tests { ); } - // ── Casts that resolve later ───────────────────────────────────────────── - #[test] fn timed_cast_sends_no_synchronous_messages_and_still_requests_the_durable_cast() { let store = InMemoryCasts { @@ -815,8 +698,6 @@ pub(super) mod tests { assert_eq!(store.casts.lock().unwrap().as_slice(), &[(CASTER, 78, 77)]); } - // ── Ground targeting ───────────────────────────────────────────────────── - #[test] fn destination_target_cast_requests_the_durable_ground_cast_with_the_client_coordinates() { let store = InMemoryCasts { @@ -840,8 +721,6 @@ pub(super) mod tests { assert!(store.casts.lock().unwrap().is_empty()); } - // ── Refusal ────────────────────────────────────────────────────────────── - #[test] fn refused_instant_cast_maps_the_reason_after_the_clearing_sequence() { let store = InMemoryCasts { @@ -888,8 +767,6 @@ pub(super) mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── World Session context ─────────────────────────────────────────────────────── - #[test] fn a_player_with_no_character_in_world_makes_no_request_and_gets_a_failed_cast() { let store = InMemoryCasts::instant(); @@ -901,8 +778,6 @@ pub(super) mod tests { assert!(store.casts.lock().unwrap().is_empty()); } - // ── Manual completion: enchant, disenchant, fishing, lock opening ──────── - #[test] fn enchant_cast_resolves_the_item_guid_to_its_slot_and_completes_manually() { let store = InMemoryCasts { diff --git a/gateway/src/world/handlers/cast/ranged.rs b/gateway/src/world/handlers/cast/ranged.rs index 808c6ee13..dd2117e43 100644 --- a/gateway/src/world/handlers/cast/ranged.rs +++ b/gateway/src/world/handlers/cast/ranged.rs @@ -1,20 +1,11 @@ -//! The ranged auto-repeat route: Auto Shot and wand Shoot are attack LOOPS, not one-shot casts. -//! -//! Activation arms the durable loop and parks a cast in the client's auto-repeat slot; each real -//! shot belongs to the durable swing tick and its combat-event relay. Cancellation only clears the -//! loop. Both directions update the shared ranged state that melee requests also read. - use super::*; -/// Arm or refuse a ranged auto-repeat loop (`CMSG_CAST_SPELL` for Auto Shot / wand Shoot). pub(super) fn activate( store: &St, session: &mut ProtocolSession, c: &CMSG_CAST_SPELL, ) -> Result { let spell = c.spell; - // The shot's target rides the cast's UNIT block. No current-selection fallback: Auto Shot and - // Shoot are cast ON a target, so the client always sends one. let target = unit_target(c); log::info!( "world[autoshot]: activate spell={spell} target={target} already_repeating={} (account {})", @@ -22,10 +13,6 @@ pub(super) fn activate( session.account_id ); - // Arm the durable loop FIRST, before any success outcome exists. A refusal then answers only - // the raw failure result, and the 5875 client drops its auto-repeat toggle on that — client and - // server stay in lockstep. Acknowledging first and failing after left the client toggled ON - // over a dead loop, so the next press sent a cancel instead of a cast. let Some(actor) = session.actor() else { return refuse(store, session, spell, NO_ACTOR); }; @@ -33,10 +20,7 @@ pub(super) fn activate( return refuse(store, session, spell, &refusal_reason(e)?); } - // The activation ack is `SMSG_SPELL_START` alone: timer 0 (the wind-up is an attack timer, not - // a cast bar — the client animates its own), the ammo block that nocks the projectile, and the - // real unit target. No CAST_RESULT(OK) and no GO: this cast parks in the client's auto-repeat - // slot and never resolves, and each shot's GO comes from the swing-tick relay. + // Auto-repeat activation sends START alone; CAST_RESULT or GO would clear its repeat slot. let caster = actor.guid(); let outbound = vec![Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( Box::new(codec::build_spell_start( @@ -55,7 +39,6 @@ pub(super) fn activate( }) } -/// A refused activation. A Refusal is a handled outcome; a Transport Loss never reaches here. fn refuse( store: &St, session: &mut ProtocolSession, @@ -70,9 +53,6 @@ fn refuse( opcode: OP_CAST_RESULT, body: codec::build_cast_result_failed(spell, codec::cast_failure_reason_for(reason)), }]; - // A refused RETARGET drops the client's toggle on that failure result, so the still-firing OLD - // loop has to go too — otherwise the server keeps shooting a target the client believes it - // stopped. A fresh activation has no older loop, and must not touch unrelated engagement state. if !matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { return Ok(outbound.into()); } @@ -85,7 +65,6 @@ fn refuse( }) } -/// `CMSG_CANCEL_AUTO_REPEAT_SPELL`: the client toggled the loop off, or auto-switched to melee. pub(super) fn cancel( store: &St, session: &mut ProtocolSession, @@ -95,10 +74,7 @@ pub(super) fn cancel( matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat), session.account_id ); - // The durable stop runs ONLY when a ranged loop was armed. A melee press sends CMSG_ATTACKSWING - // and this cancel back to back, and the swing handler has already overwritten the shared - // engagement row to melee — an unconditional stop deleted that just-armed melee row. No inline - // ack either: the engagement's on_delete relay is the one sender of SMSG_CANCEL_AUTO_REPEAT. + // An unconditional stop could erase a melee engagement that just replaced the ranged loop. if matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { stop_loop(store, session, "cancel_auto_repeat")?; } @@ -110,8 +86,6 @@ pub(super) fn cancel( }) } -/// Ask the module to tear the engagement down. A Refusal (nothing armed, a race with the swing -/// tick) must not end the session or reach the client; a Transport Loss ends the session. fn stop_loop( store: &St, session: &mut ProtocolSession, @@ -130,11 +104,6 @@ fn stop_loop( Ok(()) } -/// The `(display_id, inv_type)` ammo block for the activation START. `Some` only when the ranged -/// slot (17) holds a launcher (weapon class 2, subclass 2/3/18 — bow/gun/crossbow) and a live -/// projectile stack is in the bags. A wand fires its own bolt, and a launcher with no live stack -/// omits the block so the durable loop alone decides whether a shot can happen. Inventory type 24 -/// (INVTYPE_AMMO) is hardcoded, matching the GO relay. fn ammo_display(store: &St, self_guid: u64) -> Option<(u32, u32)> { let items = store.player_items(self_guid).ok()?; let launcher = items @@ -167,7 +136,6 @@ mod tests { const AUTO_SHOT: u32 = 75; - /// One owned item: `entry` in inventory `slot`, holding `stack_count`. fn owned(slot: u8, entry: u32, stack_count: u32) -> ItemInstanceView { ItemInstanceView { guid: u64::from(entry), @@ -179,7 +147,6 @@ mod tests { } } - /// A weapon template (class 2). Subclass 2/3/18 is a bow/gun/crossbow; 19 is a wand. fn launcher(entry: u32, subclass: u8) -> ItemTemplateView { ItemTemplateView { entry, @@ -189,7 +156,6 @@ mod tests { } } - /// A projectile template (class 6) with its own display id. fn projectile(entry: u32, display_id: u32) -> ItemTemplateView { ItemTemplateView { entry, @@ -199,7 +165,6 @@ mod tests { } } - /// A store whose spell 75 routes as ranged auto-repeat. fn ranged_store() -> InMemoryCasts { InMemoryCasts { ranged_auto_repeat: vec![AUTO_SHOT], @@ -207,7 +172,6 @@ mod tests { } } - /// A player with an older ranged loop already armed. fn repeating() -> ProtocolSession { let mut session = session(); if let WorldState::InWorld(world) = &mut session.state { @@ -216,7 +180,6 @@ mod tests { session } - /// The single `SMSG_SPELL_START` in the batch. fn spell_start(outbound: &[Outbound]) -> &wow_world_messages::vanilla::SMSG_SPELL_START { outbound .iter() @@ -227,7 +190,6 @@ mod tests { .expect("the batch has no SMSG_SPELL_START") } - /// The ammo block the activation START carries, if any. fn ammo_block(outbound: &[Outbound]) -> Option<(u32, u32)> { spell_start(outbound) .flags @@ -235,8 +197,6 @@ mod tests { .map(|a| (a.ammo_display_id, a.ammo_inventory_type)) } - // ── Activation ─────────────────────────────────────────────────────────── - #[test] fn activation_arms_the_durable_loop_and_acknowledges_with_a_zero_timer_start() { let store = ranged_store(); @@ -337,8 +297,6 @@ mod tests { .is_err()); } - // ── Cancellation ───────────────────────────────────────────────────────── - #[test] fn cancelling_an_active_loop_clears_the_state_and_stops_it_with_no_inline_ack() { let store = ranged_store(); @@ -381,9 +339,6 @@ mod tests { assert!(outbound.is_empty()); } - // ── Ammo display ───────────────────────────────────────────────────────── - - /// Ranged slot 17 holds `launcher`, and the given projectile stacks sit in the bags. fn armed_with(launcher: ItemTemplateView, ammo: Vec<(u8, u32, u32)>) -> InMemoryCasts { let mut items = vec![owned(17, launcher.entry, 1)]; let mut templates = vec![launcher]; diff --git a/gateway/src/world/handlers/channel.rs b/gateway/src/world/handlers/channel.rs index 0089661a7..da6a4676d 100644 --- a/gateway/src/world/handlers/channel.rs +++ b/gateway/src/world/handlers/channel.rs @@ -1,19 +1,10 @@ -//! Chat Channel dispatcher: join, leave, password, the member list and the owner query. Ops are -//! Durable Requests on Realm-core; their success notices come back on the Relay -//! (`stdb::world_view::channel_notice_appeared`). A Refusal rolls back its transaction, so this -//! file answers it to the actor alone. Channel speech is a `CMSG_MESSAGECHAT` kind and lives in -//! `chat.rs`. - use super::super::*; use super::chat::{drop_unrecognised_refusal, ChatActionStore, SpeakerFacts}; use lyracore_shared::channel::{channel_op, notice, ChannelRefusal}; use lyracore_shared::faction::team_for_race; -/// One channel op on its way to the `realm_channel_op` reducer. The Coordinator conveys every field -/// but the speaker's name. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ChannelRequest { - /// As the client typed it. pub(crate) channel_name: String, pub(crate) password: String, pub(crate) target_guid: u64, @@ -23,31 +14,21 @@ pub(crate) struct ChannelRequest { pub(crate) speaker: SpeakerFacts, } -/// How the Module answered one channel op. A timeout, transport or SDK failure stays an `Err` with -/// an unknown durable outcome. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ChannelOutcome { Done, Refused(ChannelRefusal), } -/// One Chat Channel as the Realm-core cache holds it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ChannelRoster { - /// The stored spelling. pub(crate) name: String, - /// Wire channel flags. pub(crate) flags: u8, - /// 0 for none. pub(crate) owner_guid: u64, - /// The owner's name from whichever World Shard holds the Character. Empty when unresolved. pub(crate) owner_name: String, - /// `(guid, raw member flags)` in join order. pub(crate) members: Vec<(u64, u8)>, } -/// A Character [`ChannelActionStore::online_character_by_name`] resolved: its guid, race and -/// canonical spelling, the identity a targeted op conveys to the Module once found. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ResolvedTarget { pub(crate) guid: u64, @@ -56,22 +37,12 @@ pub(crate) struct ResolvedTarget { } pub(crate) trait ChannelActionStore: ChatActionStore { - /// Durable Request on Realm-core. The Coordinator picks the database; handlers never do. fn channel_op(&self, actor: Actor, op: u8, request: ChannelRequest) -> Result; - /// Durable Read of the Realm-core cache. `None` when `team` has no channel by that name. fn channel_roster(&self, team: u32, channel_name: &str) -> Result>; - /// The first `session_online` Character named `name`, realm-wide. `None` when no online - /// Character carries that name. fn online_character_by_name(&self, name: &str) -> Result>; - /// Does `owner_guid` have `other_guid` on its ignore list, read from wherever the owner's - /// contact rows live? fn ignores(&self, owner_guid: u64, other_guid: u64) -> Result; } -/// The read [`ChannelActionStore::online_character_by_name`] and its Fakes share: every op that -/// names a Character resolves it realm-wide and requires it online, the same shape whisper's -/// ONLINE gate uses (`whisper::run`). A channel can only name a Character presently reachable to -/// notify. pub(crate) fn resolve_online_character( store: &St, name: &str, @@ -90,12 +61,9 @@ pub(crate) fn resolve_online_character ProtocolFamily for Channel { @@ -107,7 +75,6 @@ impl ProtocolFamily for Channel { let player = &*session; let msg = request.message()?; let outbound = match msg { - // An empty name is dropped (cm:ChannelHandler.cpp:66-67, cm:ChannelHandler.cpp:88-89). ClientOpcodeMessage::CMSG_JOIN_CHANNEL(join) if join.channel_name.is_empty() => { Vec::new() } @@ -245,9 +212,6 @@ impl ProtocolFamily for Channel { } } -/// The actor and Speaker Facts every channel op needs. `None` when the caller has no live entity -/// to act as (a session at Character Select, or between shards), the same silent drop every -/// channel op takes for that case. fn actor_and_speaker( store: &St, player: &ProtocolSession, @@ -261,18 +225,12 @@ fn actor_and_speaker( Ok(Some((actor, speaker))) } -/// What a Refusal from [`submit_channel_op`] names in its notice: the channel as the client typed -/// it, and who or what to name as `subject_guid`/`target_name`. The actor for an untargeted op; -/// the resolved (or unresolved-sentinel) target for one that names a Character. struct RefusalContext { channel_name: String, subject_guid: u64, target_name: String, } -/// Submit one built `request` as `op` and translate the outcome. Success notices return on the -/// Relay; a Refusal is answered here, from `refusal`. A Refusal this Gateway does not know is -/// logged at debug and dropped. A Transport Loss ends the World Session. fn submit_channel_op( store: &St, player: &ProtocolSession, @@ -296,8 +254,6 @@ fn submit_channel_op( } } -/// Run one op as the player: JOIN, LEAVE, PASSWORD, ANNOUNCEMENTS and MODERATE. None of these -/// name another Character. fn run_op( store: &St, player: &ProtocolSession, @@ -331,14 +287,6 @@ fn run_op( ) } -/// Run one op that names another Character: SET_OWNER, MODERATOR, UNMODERATOR, MUTE, UNMUTE, KICK, -/// BAN, UNBAN and INVITE. The target is resolved realm-wide and must be online -/// (cm:Channel.cpp:192-199, cm:Channel.cpp:262-269, cm:Channel.cpp:348-355, cm:Channel.cpp:416-423, -/// cm:Channel.cpp:678-685). cmangos checks membership, then rights, then the target name, so an -/// unresolved name still reaches the Module: it goes out as `target_guid` 0, the sentinel no real -/// Character ever holds, and the op core runs its own NotMember, NotModerator or NotOwner Gate -/// first and only then answers PLAYER_NOT_FOUND. A target whose Realm Presence is unknown goes out -/// the same way. Any other failed target read ends the World Session. fn run_targeted_op( store: &St, player: &ProtocolSession, @@ -380,8 +328,6 @@ fn run_targeted_op( ) } -/// Answer a read with `answer` when the player is a member of the named channel, else NOT_MEMBER -/// with the name the client typed (cm:Channel.cpp:446-456, cm:Channel.cpp:479-488). fn read_roster( store: &St, player: &ProtocolSession, @@ -408,10 +354,6 @@ fn read_roster( }]) } -/// The notice a Refusal answers to the actor alone, naming the channel as the client typed it. -/// PLAYER_ALREADY_MEMBER names `subject_guid`. The name notices carry `target_name`: the name the -/// client typed when the target could not be resolved, otherwise the target's canonical resolved -/// spelling, the same normalization cmangos applies through `normalizePlayerName`. pub(super) fn refusal_notice( refusal: ChannelRefusal, channel_name: String, @@ -445,13 +387,10 @@ mod tests { CMSG_CHANNEL_UNMODERATOR, CMSG_CHANNEL_UNMUTE, CMSG_JOIN_CHANNEL, CMSG_LEAVE_CHANNEL, }; - /// How a Fake's Durable Request or read fails. #[derive(Clone, Copy)] enum Failure { - /// The Module rejects with a tag this Gateway does not know. Refused, TransportLost, - /// Another World Shard cannot vouch for a Character's absence. PresenceUnknown, } @@ -474,14 +413,9 @@ mod tests { outcome: Option>, roster: Option, roster_failure: bool, - /// What `online_character_by_name` resolves a typed name to. `None` answers - /// PLAYER_NOT_FOUND, as an unknown or offline Character does. online: Option, - /// `online_character_by_name` fails like this instead of resolving. lookup_failure: Option, - /// What `ignores` answers for every pair. ignored: bool, - /// `ignores` fails like this instead of answering. ignore_failure: Option, ignore_reads: Mutex, ops: Mutex>, @@ -686,7 +620,6 @@ mod tests { outcome.outbound } - /// The single raw packet `outbound` holds, as `(opcode, body)`. fn only_raw(outbound: Vec) -> (u16, Vec) { let mut outbound = outbound.into_iter(); match (outbound.next(), outbound.next()) { @@ -760,7 +693,6 @@ mod tests { assert!(store.ops.lock().unwrap().is_empty()); } - /// NOT_MEMBER 0x05 with the typed name and no payload. #[test] fn leaving_a_channel_you_are_not_on_answers_not_member_with_the_typed_name() { let store = store(Some(Ok(ChannelOutcome::Refused(ChannelRefusal::NotMember)))); @@ -793,7 +725,6 @@ mod tests { } } - /// PLAYER_ALREADY_MEMBER 0x17 names the joiner (cm:Channel.cpp:64-72). #[test] fn a_repeat_join_of_a_custom_channel_names_the_joiner() { let store = store(Some(Ok(ChannelOutcome::Refused( @@ -847,8 +778,6 @@ mod tests { assert!(store.ops.lock().unwrap().is_empty()); } - /// cm:Channel.cpp:490-515: the stored name and flags, then each member with raw flag bits. - /// An Orc (race 2) reads the Horde's channels. #[test] fn the_member_list_carries_every_member_with_raw_flag_bits() { let store = InMemoryChannelActions { @@ -914,7 +843,6 @@ mod tests { ); } - /// vm:Channel.cpp:881-890. #[test] fn a_channel_without_an_owner_names_nobody() { let store = InMemoryChannelActions { @@ -939,7 +867,7 @@ mod tests { } const TARGET: u64 = 9; - const TARGET_RACE: u8 = 6; // Tauren + const TARGET_RACE: u8 = 6; fn resolved(ignored: bool) -> InMemoryChannelActions { InMemoryChannelActions { @@ -953,9 +881,6 @@ mod tests { } } - /// Every op that names a Character resolves it through `online_character_by_name` and conveys the - /// resolved guid, race and canonical name. Only INVITE also reads `ignores` - /// (cm:Channel.cpp:192-199 and the other target-lookup sites). #[test] fn every_targeted_op_resolves_the_target_and_conveys_its_op_code() { let cases: [(ClientOpcodeMessage, u8); 9] = [ @@ -991,8 +916,6 @@ mod tests { } } - /// ANNOUNCEMENTS and MODERATE name no Character, so they run through the same untargeted path as - /// JOIN, LEAVE and PASSWORD. #[test] fn announcements_and_moderate_carry_no_target() { let store = store(None); @@ -1007,14 +930,11 @@ mod tests { ); } - /// An unresolved or offline target still reaches the Module, as `target_guid` 0 with the - /// typed name: cmangos checks membership and rights before the target name, so the Module - /// runs those Gates first and PLAYER_NOT_FOUND is its own answer, not a Gateway shortcut. #[test] fn an_unresolved_target_reaches_the_module_as_guid_zero_with_the_typed_name() { let store = InMemoryChannelActions { outcome: Some(Ok(ChannelOutcome::Refused(ChannelRefusal::PlayerNotFound))), - ..store(None) // `online` defaults to `None`. + ..store(None) }; let outbound = handled( Channel::handle(&store, &mut player(), kick("Raiders", "Ghost").into()).unwrap(), @@ -1029,7 +949,6 @@ mod tests { assert_eq!(ops[0].2.target_name, "Ghost"); } - /// A target whose Realm Presence is unknown goes out unresolved, so the Module answers. #[test] fn an_unknown_target_presence_goes_out_unresolved() { let store = InMemoryChannelActions { @@ -1043,7 +962,6 @@ mod tests { assert_eq!(ops[0].2.target_name, "Ghost"); } - /// A lost target lookup is a Transport Loss: no Durable Request goes out. #[test] fn a_lost_target_lookup_ends_the_session() { let store = InMemoryChannelActions { @@ -1057,9 +975,6 @@ mod tests { assert!(store.ops.lock().unwrap().is_empty()); } - /// An unresolved INVITE target carries `target_ignores_actor` false and never reads the - /// ignore list at all: race 0 would otherwise pass the Alliance branch of the team check and - /// the invite would wrongly go through instead of refusing PLAYER_NOT_FOUND. #[test] fn an_unresolved_invite_target_skips_the_ignore_read() { let store = InMemoryChannelActions { @@ -1078,7 +993,6 @@ mod tests { ); } - /// A failed ignore-list read on INVITE is a Transport Loss: no Durable Request goes out. #[test] fn a_failed_ignore_read_ends_the_session() { let store = InMemoryChannelActions { @@ -1091,7 +1005,6 @@ mod tests { assert!(store.ops.lock().unwrap().is_empty()); } - /// PLAYER_ALREADY_MEMBER 0x17 names the resolved target, not the actor. #[test] fn player_already_member_names_the_resolved_target() { let store = InMemoryChannelActions { @@ -1112,7 +1025,6 @@ mod tests { ); } - /// PLAYER_NOT_BANNED 0x16 carries the resolved target's canonical name. #[test] fn player_not_banned_names_the_resolved_target() { let store = InMemoryChannelActions { diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 3b932a88f..e33920247 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -1,15 +1,8 @@ -//! Character selection, world entry, and connection-level ping and Realm Clock replies. - use super::super::*; -/// Character select, and the per-Character reads world entry builds the self CREATE from. pub(crate) trait CharacterStore: Send + Sync { - /// The account's characters for the character-select screen. In production this - /// reads the per-player `game_character` subscription (RLS-restricted to the owner). fn characters(&self, account_id: u64) -> Result>; - /// Create a character for the account (`CMSG_CHAR_CREATE`). Returns the game outcome - /// (success / name-in-use / failed); `Err` only for a Transport Loss. fn create_character( &self, account_id: u64, @@ -20,51 +13,28 @@ pub(crate) trait CharacterStore: Send + Sync { appearance: codec::Appearance, ) -> Result; - /// Delete `character` for the account (`CMSG_CHAR_DELETE`). Returns the game outcome - /// (success/failed); `Err` only for a Transport Loss. Ownership is enforced module-side (the - /// character must belong to `account_id`). fn delete_character( &self, account_id: u64, character: Actor, ) -> Result; - /// Look up a character by guid (any owner) to answer `CMSG_NAME_QUERY` — the queried guid is - /// usually a peer, so this is not account-scoped. fn character_by_guid(&self, guid: u64) -> Result>; - /// Does any World Shard hold this Character? `false` means every configured Shard was readable - /// and had no row. An incomplete, unhealthy, or changing Shard set must return `Err`. fn character_exists_on_any_world_shard(&self, guid: u64) -> Result; - /// The character's learned skill lines as `(skill_line, current, max_rank)` — feeds the self - /// CREATE's SkillInfo block. Empty when no `game_player_skill` rows exist. fn player_skills(&self, character_guid: u64) -> Result>; - /// The EFFECTIVE armor for `guid` (base + worn gear armor) for the self-login CREATE's - /// `UNIT_FIELD_RESISTANCES[0]` — so the character sheet shows real worn armor on relog. Auras aren't - /// folded here (they self-correct via the on_aura relay). Mirrors the module's combat `effective_armor`. fn effective_armor(&self, guid: u64) -> u32; fn effective_magic_resistances(&self, guid: u64) -> [u32; 6]; - /// The character's active spell-modifier auras as raw (family_mask, op, amount, is_pct) rows — - /// the SMSG_SET_FLAT/PCT_SPELL_MODIFIER mirror source. fn spell_modifiers(&self, character_guid: u64) -> Vec<(u32, u8, i32, bool)>; - /// The player's LEARNED spells (`game_player_spell`, beyond the class kit) — chained into the - /// login SMSG_INITIAL_SPELLS so a taught ability (e.g. Auto Shot) reaches the client spellbook. fn player_learned_spells(&self, player_guid: u64) -> Result>; - /// The player's persisted reputation standings (`game_player_reputation`) as `(reputation_index, - /// standing)` pairs — folded into the login `SMSG_INITIALIZE_FACTIONS` so a relog shows - /// the real standing instead of the all-neutral stub. fn player_reputations(&self, player_guid: u64) -> Result>; - /// The player's IMPORTED action-bar rows (`game_player_action`) as `(button, - /// action, action_type)` triples — empty pre-import (the common case today), in which case the - /// login codec falls back to synthesizing the bar from the spellbook (byte-identical to before - /// this method existed). fn player_actions(&self, player_guid: u64) -> Result>; } @@ -73,7 +43,7 @@ pub(crate) struct Character; impl ProtocolFamily for Character { - #[allow(clippy::too_many_lines)] // One match arm per Character opcode. + #[allow(clippy::too_many_lines)] fn handle( store: &St, session: &mut ProtocolSession, @@ -124,7 +94,6 @@ impl ProtocolFami } ClientOpcodeMessage::CMSG_CHAR_DELETE(request) => { let character_guid = request.guid.guid(); - // Realm-core holds Guild leadership. An unreadable answer must prevent deletion. let outcome = match ( Actor::new(character_guid), super::leads_a_guild(store, character_guid), diff --git a/gateway/src/world/handlers/chat.rs b/gateway/src/world/handlers/chat.rs index 0c4354466..07c83b1b4 100644 --- a/gateway/src/world/handlers/chat.rs +++ b/gateway/src/world/handlers/chat.rs @@ -1,14 +1,8 @@ -//! Realm Chat Lines, away status and ignored whispers. The Gateway reads Speaker Facts on the -//! Home Shard; the Module decides the audience on Realm-core. Query owns `CMSG_MESSAGECHAT` -//! and routes its Realm Chat Kinds here. - use super::super::*; use crate::stdb::{classify, DurableFailure}; use lyracore_shared::chat::{chat_kind, language, ChatRefusal}; use wow_world_messages::vanilla::{CMSG_CHAT_IGNORED, SMSG_NOTIFICATION}; -/// What the speaker's Home Shard knows about them. The Coordinator conveys race and chat tag to -/// the Module; `name` stays in the Gateway for lines that must name the speaker to someone else. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SpeakerFacts { pub(crate) race: u8, @@ -16,35 +10,26 @@ pub(crate) struct SpeakerFacts { pub(crate) name: String, } -/// One client line on its way to the `realm_chat` reducer. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct RealmChatRequest { pub(crate) kind: u8, pub(crate) language: u32, - /// CHANNEL only. pub(crate) channel_name: String, - /// IGNORED only. pub(crate) target_guid: u64, pub(crate) message: String, pub(crate) speaker: SpeakerFacts, } -/// What the Gateway read about a whisper's target on whichever World Shard holds it -/// (`world::whisper::target_facts`). #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct WhisperTargetFacts { pub(crate) guid: u64, pub(crate) race: u8, - /// The canonical spelling. pub(crate) name: String, pub(crate) ignores_speaker: bool, - /// 0, `chat_kind::AFK` or `chat_kind::DND`. pub(crate) away_kind: u8, - /// The stored Auto-Reply. Empty means the Module's default text. pub(crate) away_message: String, } -/// One whisper on its way to the `realm_whisper` reducer. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct WhisperRequest { pub(crate) language: u32, @@ -53,8 +38,6 @@ pub(crate) struct WhisperRequest { pub(crate) target: WhisperTargetFacts, } -/// How the Module answered one Realm Chat request. A timeout, transport or SDK failure stays an -/// `Err` with an unknown durable outcome. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ChatOutcome { Delivered, @@ -62,31 +45,19 @@ pub(crate) enum ChatOutcome { } pub(crate) trait ChatActionStore: Send + Sync { - /// Durable Read on the speaker's Home Shard. `None` when the speaker has no live entity. fn speaker_facts(&self, speaker_guid: u64) -> Result>; - /// Durable Request on Realm-core. The Coordinator picks the database; handlers never do. fn realm_chat(&self, actor: Actor, request: RealmChatRequest) -> Result; - /// Durable Request on the speaker's Home Shard: one `/afk` or `/dnd`. fn set_away(&self, actor: Actor, kind: u8, message: String) -> Result<()>; - /// Durable Read across every World Shard: the online Character the typed name reaches, with - /// the facts the Module's whisper Gates need. `None` when no online Character has that name. fn whisper_target( &self, speaker_guid: u64, typed_name: &str, ) -> Result>; - /// Durable Request on Realm-core. fn realm_whisper(&self, actor: Actor, request: WhisperRequest) -> Result; - /// The speaker's GM level, read on its Home Shard. The Chat Flood Limiter never mutes a - /// Character above 0. fn speaker_gm_level(&self, speaker_guid: u64) -> Result; } -/// Durable Requests for Speech, and for the GM dot-command a Say line can carry. pub(crate) trait SpeechStore: Send + Sync { - /// Speak (`CMSG_MESSAGECHAT`, social tier): broadcast a say, yell or `/e` line - /// (`lyracore_shared::chat::broadcast_chat`). A language the speaker's race does not know is a - /// Refusal. fn send_chat( &self, actor: Actor, @@ -95,9 +66,6 @@ pub(crate) trait SpeechStore: Send + Sync { message: String, ) -> Result; - /// Perform an emote (`CMSG_TEXT_EMOTE`, social tier): broadcast the "X dances." line + animation. - /// `target_guid` (0 = untargeted) is the client's selected target — the gateway resolves it to a - /// name so the chat line reads "X waves at ." fn send_emote( &self, actor: Actor, @@ -106,24 +74,11 @@ pub(crate) trait SpeechStore: Send + Sync { target_guid: u64, ) -> Result<()>; - /// GM playtest dot-command for the proof-validated, realm-wide `account_name`: `text` is the - /// raw Say line, STILL carrying its - /// leading `.` — the Say handler intercepts it BEFORE any chat relay/insert and forwards it here - /// verbatim (module-side parsing keeps the command set data-free). A Refusal's reason is relayed - /// back to the SENDER ONLY as a system chat line (never broadcast, never a `game_chat_event` - /// row). Any other `Err` ends the World Session. fn gm_command(&self, account_name: &str, actor: Actor, text: String) -> Result<()>; } -/// cm mangos.sql:4044, sent by cm:ChatHandler.cpp:107-110. const UNKNOWN_LANGUAGE_NOTICE: &str = "You don't know that language"; -/// The wire `chat_kind` for a Chat Kind whose own audience Refusals are silent here: Raid, Raid -/// Leader, Raid Warning, Guild and Officer. `None` for every other `CMSG_MESSAGECHAT_ChatType`, -/// Party and Channel included (they keep their own arms for a Refusal that answers the client). -/// The shared Refusals still answer through `refusal_outbound` below — `UnknownLanguage` gets its -/// vanilla notice whichever of these five kinds sent the line. Each family owns its audience rule -/// on the Module side; this only picks the wire kind so they can share this arm's dispatch. fn silent_refusal_chat_kind(chat_type: &CMSG_MESSAGECHAT_ChatType) -> Option { match chat_type { CMSG_MESSAGECHAT_ChatType::Raid => Some(chat_kind::RAID), @@ -135,7 +90,6 @@ fn silent_refusal_chat_kind(chat_type: &CMSG_MESSAGECHAT_ChatType) -> Option } } -/// Handle Realm Chat Lines and away status. Query routes the matching Chat Kinds here. pub(crate) struct Chat; impl ProtocolFamily for Chat { @@ -173,7 +127,6 @@ impl ProtocolFamily for Chat { message, speaker, })? { - // Speaking from no party is today's "You aren't in a party" answer. Some(ChatRefusal::NotInGroup) => vec![Outbound::One( ServerOpcodeMessage::SMSG_PARTY_COMMAND_RESULT(Box::new( codec::build_party_command_result( @@ -224,10 +177,6 @@ impl ProtocolFamily for Chat { } } -/// One whisper: the target is resolved realm-wide, the Module applies the Gates on Realm-core, and -/// the lines return on the Relay. A name no online Character holds answers -/// `SMSG_CHAT_PLAYER_NOT_FOUND` with the typed name (cm:ChatHandler.cpp:243-266), and so does a -/// target whose Realm Presence is unknown. fn whisper( store: &St, player: &ProtocolSession, @@ -254,7 +203,6 @@ fn whisper( }; match store.realm_whisper(actor, request) { Ok(ChatOutcome::Delivered) => Ok(Vec::new()), - // cm:ChatHandler.cpp:268-275, cm:ChatHandler.cpp:824-828: an empty packet. Ok(ChatOutcome::Refused(ChatRefusal::WrongFaction)) => Ok(vec![Outbound::One( ServerOpcodeMessage::SMSG_CHAT_WRONG_FACTION, )]), @@ -272,8 +220,6 @@ fn player_not_found(name: String) -> Outbound { ))) } -/// `/afk` or `/dnd`. The client prints its own notice, and observers see the `PLAYER_FLAGS` -/// change on the entity Relay, so nothing answers here. fn set_away( store: &St, player: &ProtocolSession, @@ -289,8 +235,6 @@ fn set_away( Ok(Vec::new()) } -/// `CMSG_CHAT_IGNORED`: this player's client dropped a line from `dropped_speaker`, who learns -/// "X is ignoring you." (cm:ChatHandler.cpp:801-815). The notice carries the ignorer's own name. fn chat_ignored( store: &St, player: &ProtocolSession, @@ -307,9 +251,6 @@ fn chat_ignored( Ok(refusal_outbound(player, refusal)) } -/// Send one line through the Realm Chat path. `request` builds the Durable Request from the -/// Speaker Facts. `Ok(None)` means the line went out or was dropped; a Refusal comes back for the -/// calling arm to answer. A Transport Loss is fatal. fn send_line( store: &St, player: &ProtocolSession, @@ -330,9 +271,6 @@ fn send_line( ) } -/// Sort one chat Durable Request's result. `Ok(None)` means the line went out, or was dropped for -/// a Refusal this Gateway does not know; a Refusal it knows comes back for the caller to answer. -/// A Transport Loss is fatal. pub(crate) fn settle( player: &ProtocolSession, line: impl std::fmt::Display, @@ -348,7 +286,6 @@ pub(crate) fn settle( } } -/// The answer every Chat Kind shares. Vanilla answers most chat Refusals with silence. pub(crate) fn refusal_outbound( player: &ProtocolSession, refusal: Option, @@ -371,8 +308,6 @@ pub(crate) fn refusal_outbound( } } -/// A Refusal this Gateway has no answer for drops the request and keeps the World Session. A -/// Transport Loss leaves the outcome unknown, so it ends the session. pub(super) fn drop_unrecognised_refusal( player: &ProtocolSession, request: impl std::fmt::Display, @@ -398,13 +333,10 @@ mod tests { use std::sync::Mutex; use wow_world_messages::vanilla::Language; - /// How a Fake's Durable Request fails. #[derive(Clone, Copy)] enum Failure { - /// The Module rejects with a tag this Gateway does not know. Refused, TransportLost, - /// Another World Shard cannot vouch for a Character's absence. PresenceUnknown, } @@ -562,11 +494,6 @@ mod tests { ); } - /// Every racial language reaches the Module as its wire value (gtker vanilla `language.rs`), - /// so the Module's language Gate judges what the client sent. The addon bridge in - /// `world/mod.rs` only intercepts its own STC-prefixed frames; an addon-language line from any - /// other addon still reaches this dispatcher on Party, Raid, Guild or Officer - /// (`gateway/src/codec/addon.rs`). #[test] fn each_racial_language_reaches_the_module_as_its_wire_value() { for (language, wire) in [ @@ -648,8 +575,6 @@ mod tests { } } - /// Raid, Raid Leader, Raid Warning, Guild and Officer each route to the seam with their own - /// Chat Kind. The Module decides the audience; the Gateway only conveys the request. #[test] fn raid_and_guild_lines_carry_their_own_chat_kind() { for (chat_type, kind) in [ @@ -683,9 +608,6 @@ mod tests { } } - /// `SendAddonMessage` shares the Guild and Officer channels with the addon bridge - /// (`gateway/src/codec/addon.rs`). The addon language forwards to the Module unchanged, which - /// keeps it rather than forcing Universal (`cm:ChatHandler.cpp:369,409`). #[test] fn guild_and_officer_lines_forward_the_addon_language() { for (chat_type, label) in [ @@ -709,9 +631,6 @@ mod tests { } } - /// cmangos answers a raid or guild audience Refusal with silence, the same as every other - /// Chat Kind: a member who cannot speak on it hears nothing back, and neither does anyone else - /// (`cm:ChatHandler.cpp:367-369`, `cm:Guild.cpp:559-561`). #[test] fn every_raid_and_guild_chat_refusal_is_silent() { for refusal in [ @@ -793,7 +712,6 @@ mod tests { ); } - /// cm:Channel.cpp:593-664 answers each refused line with one notice to the speaker alone. #[test] fn a_refused_channel_line_answers_the_channels_notice() { for (refusal, code) in [ @@ -890,7 +808,6 @@ mod tests { ); } - /// cm:ChatHandler.cpp:243-266: the typed name comes back as the client typed it. #[test] fn a_whisper_to_nobody_online_answers_player_not_found() { let store = whisper_store(Ok(None), None); @@ -919,7 +836,6 @@ mod tests { assert!(store.whispers.lock().unwrap().is_empty()); } - /// cm:ChatHandler.cpp:824-828: the opcode alone, with an empty body. #[test] fn a_cross_faction_whisper_answers_wrong_faction() { let store = whisper_store( @@ -975,7 +891,6 @@ mod tests { assert!(outbound.is_empty()); } - /// `/afk` and `/dnd` answer nothing: the client prints its own notice. #[test] fn afk_and_dnd_set_the_away_status_of_the_speaker() { let store = store(None); @@ -1013,7 +928,6 @@ mod tests { assert!(Chat::handle(&lost, &mut player(), afk().into()).is_err()); } - /// cm:ChatHandler.cpp:801-815: the ignorer's name, to the Character whose line was dropped. #[test] fn chat_ignored_sends_the_ignorers_name_to_the_dropped_speaker() { let store = store(None); diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index 8014f5091..cd1b650a4 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -1,26 +1,14 @@ -//! Combat family leftovers: selection, pet commands, the run-speed ack and sheathing. Melee start -//! and stop belong to the melee seam in `melee.rs`; every cast opcode, the `CMSG_CAST_SPELL` routes -//! and both cancellations, belongs to the cast seam in `cast`. - use super::super::*; -/// Selection, pet commands and sheathing. pub(crate) trait CombatStore: Send + Sync { - /// Record the player's current target (`CMSG_SET_SELECTION`, Tier 2 / N3). 0 clears it. fn set_target(&self, actor: Actor, target_guid: u64) -> Result<()>; - /// Relay a pet command-bar action (`CMSG_PET_ACTION`). `data` is the raw packed action - /// (flag<<24 | id): flag 0x07 = command (Stay/Follow/Attack/Dismiss), flag 0x06 = react state - /// (Passive/Defensive/Aggressive). The module decodes + validates (all pet policy lives there). + /// PET_ACTION packs flag << 24 | id; flag 0x07 is command and 0x06 is react state. fn pet_command(&self, actor: Actor, data: u32, target_guid: u64) -> Result<()>; - /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`, the `Z` key). `state` is 0 stowed / - /// 1 melee / 2 ranged; the module range-checks it. Writes `UNIT_FIELD_BYTES_2` byte 0, which is - /// what makes a drawn or stowed weapon visible to OTHER players. fn set_sheathed(&self, actor: Actor, state: u8) -> Result<()>; } -/// A Refusal of a best-effort action is logged and dropped. A Transport Loss ends the World Session. pub(super) fn ignore_refusal(action: &str, account_id: u64, result: Result<()>) -> Result<()> { let Err(error) = result else { return Ok(()); @@ -35,9 +23,6 @@ pub(super) fn ignore_refusal(action: &str, account_id: u64, result: Result<()>) Ok(()) } -/// Combat family leftovers: selection, pet commands, the run-speed ack and sheathing. Each arm is -/// best-effort. The session-fatal desync exits went to the melee seam with the two melee opcodes -/// that owned them. pub(crate) struct Combat; impl ProtocolFamily for Combat { @@ -50,32 +35,18 @@ impl ProtocolFamily for Combat { let actor = conn.self_guid().and_then(Actor::new); match msg { - // Targeting (N3): record the player's selection server-side (foundation for combat). ClientOpcodeMessage::CMSG_SET_SELECTION(s) => { let actor = actor.ok_or_else(|| anyhow!("CMSG_SET_SELECTION before world entry"))?; store.set_target(actor, s.target.guid())? } - // Pet command bar (CMSG_PET_ACTION): pass the raw packed `data` + target through; the module - // decodes stay/follow/attack/dismiss + passive/defensive/aggressive and validates ownership. A - // transient reject (no pet, dead/invalid target) must NOT drop the session — log + ignore, like - // the start_attack path (do NOT route through is_desync_error). ClientOpcodeMessage::CMSG_PET_ACTION(p) => { if let Some(actor) = actor { let result = store.pet_command(actor, p.data, p.target.guid()); ignore_refusal("pet_command", conn.account_id, result)?; } } - // The client's ack to our `SMSG_FORCE_RUN_SPEED_CHANGE` (`.speed`). We don't - // gate on the reply (the movement counter/new_speed aren't cross-checked) — explicitly - // consumed here (rather than falling through to the dispatch tail's `log::debug!` "ignoring" - // line) so a `.speed` never spams the log or risks a future desync-classifier false-positive. ClientOpcodeMessage::CMSG_FORCE_RUN_SPEED_CHANGE_ACK(_) => {} - // Draw / stow weapons. The client sends this on `Z`, on a weapon swap, and when an - // ability auto-draws. It is a pure render-state change: nothing gates on it, so a failure is - // logged and dropped rather than being session-fatal like ATTACKSWING/ATTACKSTOP. gtker - // already parsed the payload into a `SheathState`, so the byte reaching the module is one of - // 0/1/2 — the module re-checks anyway, being the trust boundary for every caller. ClientOpcodeMessage::CMSG_SETSHEATHED(s) => { let state = s.sheathed.as_int(); if let Some(actor) = actor { diff --git a/gateway/src/world/handlers/death.rs b/gateway/src/world/handlers/death.rs index ac3dbe179..43b1287f6 100644 --- a/gateway/src/world/handlers/death.rs +++ b/gateway/src/world/handlers/death.rs @@ -1,33 +1,15 @@ -//! Death family: Release Spirit, corpse reclaim and the resurrection paths. - use super::super::*; -/// Release Spirit, corpse reclaim and resurrection. pub(crate) trait DeathStore: Send + Sync { - /// Revive the caller after death (`CMSG_REPOP_REQUEST` / Release Spirit): the module - /// restores full health in place and clears the dead state (the client leaves the death screen - /// once the restored health replicates). fn repop(&self, actor: Actor) -> Result<()>; - /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`): the module validates the caller - /// is a ghost owning the corpse, in range, past the reclaim delay, then resurrects at 50%. fn reclaim_corpse(&self, actor: Actor, corpse_guid: u64) -> Result<()>; - /// Answer a pending resurrect offer (`CMSG_RESURRECT_RESPONSE`): `accept=true` revives the - /// caller at the offer's frozen `%`; either way the offer is consumed. A failure (no pending offer - /// for the caller) is expected when the offer already lapsed/was answered — per-action, log + ignore. fn resurrect_response(&self, actor: Actor, accept: bool) -> Result<()>; - /// Use the caller's Self-Resurrection Option (`CMSG_SELF_RES`): the module revives the dead - /// caller in place and spends the option. A Refusal (alive, or no option) is expected after a - /// race. Per-action: log and ignore. fn self_resurrect(&self, actor: Actor) -> Result<()>; - /// Spirit-Healer resurrect (`CMSG_SPIRIT_HEALER_ACTIVATE`): a ghost activates the graveyard Spirit - /// Healer to res IN PLACE at 50% health/mana + a Resurrection Sickness debuff. `healer_guid` is the - /// activated healer's guid (passed through to the confirm echo). The module gates on ghost state. fn spirit_healer_res(&self, actor: Actor, healer_guid: u64) -> Result<()>; - /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` for `MSG_CORPSE_QUERY`. fn corpse_location(&self, owner_guid: u64) -> Result>; } diff --git a/gateway/src/world/handlers/duel.rs b/gateway/src/world/handlers/duel.rs index 0700b9f81..62c897422 100644 --- a/gateway/src/world/handlers/duel.rs +++ b/gateway/src/world/handlers/duel.rs @@ -1,5 +1,3 @@ -//! Narrow Duel accept/cancel dispatcher. All client output returns on the Duel event relay. - use super::super::*; pub(crate) trait DuelActionStore: Send + Sync { diff --git a/gateway/src/world/handlers/gameobject.rs b/gateway/src/world/handlers/gameobject.rs index 44b72aa39..2dba5653c 100644 --- a/gateway/src/world/handlers/gameobject.rs +++ b/gateway/src/world/handlers/gameobject.rs @@ -1,4 +1,3 @@ -//! GameObject use resolves its interaction before entering a quest or loot operation. use super::super::*; pub(crate) struct GameObject; diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index f81f75e40..ba232e790 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -1,10 +1,3 @@ -//! Guild family: the guild window reads, CMSG_GUILD_CREATE, the `.guild create` dot-command, the -//! tabard designer, Petitions and their Guild Charters, and the guild steps of world entry and -//! exit. Every guild fact lives on Realm-core, so every read and Durable Request here goes there. -//! The Gateway adds only the Character facts Realm-core cannot read: name, team, Realm Account, -//! whether the Character is live, the actor's GM level and whether it holds a Guild Charter, both -//! from its Home Shard. A guild fee moves through `guild_fee`. - use super::super::guild_fee::{self, GuildFeeStore}; use super::super::*; use crate::stdb::{classify, DurableFailure}; @@ -16,8 +9,6 @@ use wow_world_messages::vanilla::{ }; use wow_world_messages::Guid; -/// Character facts a guild Gate or render needs, read from whichever World Shard holds the -/// Character. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct CharacterFacts { pub(crate) guid: u64, @@ -27,25 +18,17 @@ pub(crate) struct CharacterFacts { pub(crate) level: u8, pub(crate) zone_id: u32, pub(crate) last_logout_micros: u64, - /// A live entity on any World Shard, bots included. pub(crate) online: bool, - /// No live entity, but between two places, such as in a Transfer. The roster lists the - /// member online, so it does not flicker offline across a Shard Boundary. pub(crate) in_transit: bool, - /// 0 when no World Shard retains the Character's Realm Account. pub(crate) realm_account_id: u64, } -/// The Character facts only a durable `game_character` row holds. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct DurableCharacterFacts { pub(crate) last_logout_micros: u64, - /// 0 when no World Shard retains the Character's Realm Account. pub(crate) realm_account_id: u64, } -/// A Character's facts from its durable row and its Realm Presence. Without a durable row there is -/// no Character, only perhaps a live creature, so `read_presence` is not called. pub(crate) fn character_facts( durable: Option, read_presence: impl FnOnce() -> Result>, @@ -71,10 +54,8 @@ pub(crate) fn character_facts( })) } -/// One guild Durable Request. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum GuildRequest { - /// A GM founds a Guild led by `leader_guid`. GmCreate { leader_guid: u64, leader_name: String, @@ -83,138 +64,116 @@ pub(crate) enum GuildRequest { gm_level: u8, name: String, }, - /// The actor entered the world; `actor_name` refreshes its member name snapshot. - SignOn { actor_name: String }, - /// The actor left the world. + SignOn { + actor_name: String, + }, SignOff, - /// A member offers a live Character, resolved realm-wide, a place in its Guild. Invite { target_guid: u64, actor_team: u32, target_team: u32, target_ignores_actor: bool, }, - /// The actor accepts its pending Guild Invite. - Accept { actor_name: String, actor_team: u32 }, - /// The actor declines its pending Guild Invite. - Decline { actor_name: String }, - /// The actor leaves its Guild. + Accept { + actor_name: String, + actor_team: u32, + }, + Decline { + actor_name: String, + }, Leave, - /// A member expels `target_guid` from the actor's Guild. - Remove { target_guid: u64 }, - /// A member raises `target_guid` one Guild Rank. - Promote { target_guid: u64 }, - /// A member lowers `target_guid` one Guild Rank. - Demote { target_guid: u64 }, - /// The Guild Leader passes leadership to `target_guid`. - SetLeader { target_guid: u64 }, - /// The Guild Leader dissolves its Guild. + Remove { + target_guid: u64, + }, + Promote { + target_guid: u64, + }, + Demote { + target_guid: u64, + }, + SetLeader { + target_guid: u64, + }, Disband, - /// CMSG_GUILD_MOTD. - SetMotd { text: String }, - /// CMSG_GUILD_INFO_TEXT. - SetInfo { text: String }, - /// CMSG_GUILD_SET_PUBLIC_NOTE, target already resolved by name. - SetPublicNote { target_guid: u64, text: String }, - /// CMSG_GUILD_SET_OFFICER_NOTE, target already resolved by name. - SetOfficerNote { target_guid: u64, text: String }, - /// CMSG_GUILD_RANK. + SetMotd { + text: String, + }, + SetInfo { + text: String, + }, + SetPublicNote { + target_guid: u64, + text: String, + }, + SetOfficerNote { + target_guid: u64, + text: String, + }, EditRank { rank_id: u32, rights: u32, name: String, }, - /// CMSG_GUILD_ADD_RANK. - AddRank { name: String }, - /// CMSG_GUILD_DEL_RANK. + AddRank { + name: String, + }, DeleteRank, - /// The actor signs the Petition of `charter_item_guid`. SignPetition { charter_item_guid: u64, actor_name: String, actor_team: u32, }, - /// The owner shows its Petition to the live Character `target_guid`. OfferPetition { charter_item_guid: u64, target_guid: u64, target_team: u32, }, - /// The actor declines to sign the Petition of `charter_item_guid`. - DeclinePetition { charter_item_guid: u64 }, - /// The owner renames its Petition. + DeclinePetition { + charter_item_guid: u64, + }, RenamePetition { charter_item_guid: u64, name: String, }, - /// The owner founds the Guild its Petition proposes. - TurnInPetition { charter_item_guid: u64 }, - /// The owner drops its Petition. - ClosePetition { petition_id: u32 }, - /// The actor is a Character no World Shard holds any more: remove every guild trace of it. - /// Sent by the deleted-Character reconciliation only, as a tokenless actor. + TurnInPetition { + charter_item_guid: u64, + }, + ClosePetition { + petition_id: u32, + }, ForgetDeletedCharacter, } -/// What Realm-core did with a guild Durable Request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GuildOutcome { Ran, Refused(GuildRefusal), } -/// Guild reads and requests, in the seam's own vocabulary. Guild reads and `guild_op` go to -/// Realm-core; Character facts are realm-wide reads over the World Shards. Guild fees go through -/// [`GuildFeeStore`]. pub(crate) trait GuildActionStore: GuildFeeStore + Send + Sync { - /// The membership row of `character_guid`, if it is in a Guild. fn guild_member(&self, character_guid: u64) -> Result>; - /// One Guild with its Guild Ranks. fn guild(&self, guild_id: u32) -> Result>; - /// Every member row of one Guild. fn guild_members(&self, guild_id: u32) -> Result>; - /// Facts for one Character from whichever World Shard holds it. fn guild_character_facts(&self, character_guid: u64) -> Result>; - /// Every Character guid that carries `name`, across every World Shard. fn guild_characters_named(&self, name: &str) -> Result>; - /// The actor's GM level, read from THIS handle: the actor's Home Shard. A copy an interrupted - /// Transfer left on another shard is not authority. fn guild_gm_level(&self, actor_guid: u64) -> Result; - /// The unit the actor has selected, 0 for none. fn guild_selected_target(&self, actor_guid: u64) -> u64; - /// Does `owner_guid` have `other_guid` on their ignore list, checked realm-wide? fn guild_ignored_by(&self, owner_guid: u64, other_guid: u64) -> Result; - /// Run one guild op on Realm-core as `actor`. fn guild_op(&self, actor: Actor, request: GuildRequest) -> Result; - /// Does the NPC refuse the actor by faction? The one Gate a read path answers here. fn guild_npc_refuses(&self, npc_guid: u64, actor_guid: u64) -> Result; - /// The open Petition of the Guild Charter `charter_item_guid`. fn guild_petition_of_charter( &self, charter_item_guid: u64, ) -> Result>; - /// The open Petition `owner_guid` owns. fn guild_petition_of_owner(&self, owner_guid: u64) -> Result>; - /// Does a Guild hold `name`, without regard to case? fn guild_name_taken(&self, name: &str) -> Result; - /// Does the actor hold the Guild Charter `charter_item_guid`, read from THIS handle, the - /// actor's Home Shard? fn guild_holds_charter(&self, actor_guid: u64, charter_item_guid: u64) -> Result; - /// Destroy a turned-in Guild Charter on THIS handle, the actor's Home Shard. A Charter - /// already gone is Ok. fn guild_destroy_charter(&self, actor: Actor, charter_item_guid: u64) -> Result<()>; - /// Every Character a Guild, a Petition or a Signature names. Fails while Realm-core cannot - /// answer, so the deleted-Character reconciliation retries instead of skipping anyone. fn guild_character_guids(&self) -> Result>; - /// Does a Guild, a Petition or a Signature name `character_guid`? Fails like - /// [`Self::guild_character_guids`]. fn guild_names_character(&self, character_guid: u64) -> Result; - /// The name of the Guild `character_guid` belongs to, if any. fn guild_name_of_member(&self, character_guid: u64) -> Result>; } -/// A Transport Loss ends the World Session. A Refusal this Gateway has no answer for, or a Realm -/// Presence another World Shard cannot vouch for, comes back for the caller to drop. fn unless_transport_loss(error: anyhow::Error) -> Result { if presence::is_unknown(&error) { return Ok(error); @@ -359,7 +318,6 @@ fn command_result(command: GuildCommand, name: String, result: GuildCommandResul ))) } -/// What mangos answers a guild request from outside any Guild (`cm:GuildHandler.cpp:43,248`). fn not_in_guild() -> Outbound { command_result( GuildCommand::Create, @@ -368,8 +326,6 @@ fn not_in_guild() -> Outbound { ) } -/// Any Guild answers, in any state: the character list asks for the names of its Characters' -/// Guilds before world entry (`cm:Opcodes.cpp:112` STATUS_AUTHED). fn query_outbound( store: &St, guild_id: u32, @@ -382,7 +338,6 @@ fn query_outbound( }]) } -/// The actor's membership and its Guild, when both exist. fn actor_guild( store: &St, player: &ProtocolSession, @@ -396,9 +351,6 @@ fn actor_guild( Ok(store.guild(member.guild_id)?.map(|guild| (member, guild))) } -/// Outside a Guild the client gets no reply (`cm:GuildHandler.cpp:265-266`). One viewer, so lines -/// are read lazily: `roster_packet` stops pulling once the roster body is full, and a member past -/// the cap never pays for a `guild_character_facts` read that would be discarded. fn roster_outbound( store: &St, player: &ProtocolSession, @@ -419,10 +371,6 @@ fn roster_outbound( ))]) } -/// Every roster line of `guild_id`, officer note included; `roster_packet` blanks it per viewer. -/// Reading each member's Character facts is the expensive part of a roster, so a caller that -/// serves more than one viewer of the same Guild (a settings-change relay) must call this once and -/// pass the result to every `roster_packet` call, not call `roster_outbound` per viewer. pub(crate) fn roster_lines( store: &St, guild_id: u32, @@ -435,8 +383,6 @@ pub(crate) fn roster_lines( .collect()) } -/// One roster line, officer note included. A member whose World Shard cannot answer still lists, -/// offline, under its name snapshot. fn roster_line( store: &St, member: codec::GuildMemberView, @@ -477,12 +423,6 @@ fn roster_line( } } -/// SMSG_GUILD_ROSTER from `lines`, blanking each line's officer note unless `sees_officer_notes`. -/// The one place that encodes a roster, so CMSG_GUILD_ROSTER and a settings-change relay can never -/// draw the wire body two different ways. `lines` stays generic over `IntoIterator` rather than a -/// slice: the single-viewer client path feeds it a lazy iterator so a member past the roster's -/// body cap is never read, while a settings-change relay, which renders both note visibilities -/// from one shared read, feeds it `lines.iter().cloned()` over its already-collected Vec. pub(crate) fn roster_packet( guild: &codec::GuildView, lines: impl IntoIterator, @@ -515,7 +455,6 @@ fn info_outbound( ))]) } -/// Distinct Realm Accounts among `members`. A member whose Account is unknown counts as one. fn account_count(members: &[codec::GuildMemberView]) -> u32 { let mut known: Vec = members .iter() @@ -531,8 +470,6 @@ fn account_count(members: &[codec::GuildMemberView]) -> u32 { u32::try_from(known.len() + unknown).unwrap_or(u32::MAX) } -/// CMSG_GUILD_CREATE founds a Guild for a GM only; the Guild Charter is the player path. mangos -/// replies to neither outcome (`cm:GuildHandler.cpp:46-64`). fn create_outbound( store: &St, player: &ProtocolSession, @@ -567,8 +504,6 @@ fn gm_create(leader: &CharacterFacts, gm_level: u8, name: String) -> GuildReques } } -/// Every live Character on `name`, realm-wide; the first live candidate wins, the way -/// `whisper::target_facts` picks the first online one. fn live_character_named( store: &St, name: &str, @@ -583,9 +518,6 @@ fn live_character_named( Ok(None) } -/// CMSG_GUILD_INVITE (`cm:GuildHandler.cpp:66-131`): the actor offers a live Character, resolved -/// realm-wide, a place in its Guild. mangos answers "player not found" before any Guild Gate runs, -/// so this Gateway read happens first here too. fn invite_outbound( store: &St, player: &ProtocolSession, @@ -626,7 +558,6 @@ fn invite_outbound( }) } -/// CMSG_GUILD_ACCEPT (`cm:GuildHandler.cpp:192-211`): every failure is silent, like mangos. fn accept_outbound( store: &St, player: &ProtocolSession, @@ -651,7 +582,6 @@ fn accept_outbound( Ok(Vec::new()) } -/// CMSG_GUILD_DECLINE (`cm:GuildHandler.cpp:214-238`): every failure is silent. fn decline_outbound( store: &St, player: &ProtocolSession, @@ -675,11 +605,6 @@ fn decline_outbound( Ok(Vec::new()) } -/// CMSG_GUILD_LEAVE (`cm:GuildHandler.cpp:383-404`): mangos answers QUIT/0x00 only for an ordinary -/// member's leave, never for a lone Guild Leader's leave-and-disband. The Module refuses a Guild -/// Leader who still has company (`GuildRefusal::LeaderCannotLeave`), so a Guild Leader's Leave can -/// only ever *succeed* by disbanding alone. No member count needs reading here to tell the two -/// outcomes apart, only whether the actor already is the Guild Leader. fn leave_outbound( store: &St, player: &ProtocolSession, @@ -711,7 +636,6 @@ fn leave_outbound( }) } -/// CMSG_GUILD_DISBAND (`cm:GuildHandler.cpp:424-435`). fn disband_outbound( store: &St, player: &ProtocolSession, @@ -733,9 +657,6 @@ fn disband_outbound( }) } -/// The shape CMSG_GUILD_REMOVE, PROMOTE, DEMOTE and LEADER share: resolve `typed_name` against the -/// actor's own Guild roster, then run the Durable Request and map its outcome -/// (`cm:GuildHandler.cpp:146-152,290-296,343-349,467-473`). fn named_member_op( store: &St, player: &ProtocolSession, @@ -772,8 +693,6 @@ where }) } -/// Which client opcode a Refusal reply is being built for: the same [`GuildRefusal`] wire-maps -/// differently depending on which op produced it. #[derive(Clone, Copy, Debug)] enum GuildOpKind { Invite, @@ -785,8 +704,6 @@ enum GuildOpKind { Disband, } -/// GUILD_PERMISSIONS_OR_LEADER (0x08): both `NoPermission` (a missing Rank Right) and `NotLeader` -/// (the actor is not the Guild Leader) answer this exact reply. fn no_permission() -> Outbound { command_result( GuildCommand::Invite, @@ -795,10 +712,6 @@ fn no_permission() -> Outbound { ) } -/// Every membership Refusal's wire reply, derived from `cm:GuildHandler.cpp`'s line-numbered -/// `SendGuildCommandResult` calls (cited on each [`GuildRefusal`] variant in `lyracore_shared`). -/// `typed_name` is the client's own input; `target_name` is the resolved target's name snapshot, -/// used only where mangos itself reads it back off the found Character rather than the packet. fn membership_refusal_outbound( op: GuildOpKind, refusal: GuildRefusal, @@ -865,7 +778,6 @@ fn membership_refusal_outbound( } } -/// CMSG_GUILD_MOTD (`cm:GuildHandler.cpp:488-513`). fn motd_outbound( store: &St, player: &ProtocolSession, @@ -883,11 +795,10 @@ fn motd_outbound( String::new(), GuildCommandResult::GuildPermissionsOrLeader, )], - GuildOutcome::Refused(_) => Vec::new(), // TooLong: the stock client cannot produce it. + GuildOutcome::Refused(_) => Vec::new(), }) } -/// CMSG_GUILD_INFO_TEXT (`cm:GuildHandler.cpp:693-713`). fn info_text_outbound( store: &St, player: &ProtocolSession, @@ -909,9 +820,6 @@ fn info_text_outbound( }) } -/// CMSG_GUILD_SET_PUBLIC_NOTE / SET_OFFICER_NOTE, shared: resolve `target_name` against the -/// actor's own Guild through its member name snapshots (`member_by_name`), same as mangos' -/// `GetMemberSlot` (`cm:GuildHandler.cpp:526-545,564-582`), then run the edit. fn set_note_outbound( store: &St, player: &ProtocolSession, @@ -942,14 +850,10 @@ fn set_note_outbound( target_name, GuildCommandResult::GuildPlayerNotInGuildS, )], - _ => Vec::new(), // TooLong is silent: the stock client cannot produce it. + _ => Vec::new(), }) } -/// The rank-management opcodes (RANK, ADD_RANK, DEL_RANK) share their refusal replies: `NotInGuild` -/// answers the same as every other guild-less request, `NotLeader` answers PERMISSIONS, and every -/// other refusal, including `RanksAtLimit`, is silent (`cm:GuildHandler.cpp:606-611,645-651, -/// 671-673`). fn rank_refusal_outbound(outcome: GuildOutcome) -> Vec { match outcome { GuildOutcome::Refused(GuildRefusal::NotInGuild) => vec![not_in_guild()], @@ -962,7 +866,6 @@ fn rank_refusal_outbound(outcome: GuildOutcome) -> Vec { } } -/// CMSG_GUILD_RANK. fn edit_rank_outbound( store: &St, player: &ProtocolSession, @@ -984,7 +887,6 @@ fn edit_rank_outbound( Ok(rank_refusal_outbound(outcome)) } -/// CMSG_GUILD_ADD_RANK. fn add_rank_outbound( store: &St, player: &ProtocolSession, @@ -997,7 +899,6 @@ fn add_rank_outbound( Ok(rank_refusal_outbound(outcome)) } -/// CMSG_GUILD_DEL_RANK. fn delete_rank_outbound( store: &St, player: &ProtocolSession, @@ -1011,15 +912,11 @@ fn delete_rank_outbound( const GUILD_CREATE_USAGE: &str = "usage: .guild create [] \"\""; -/// Is this Say line a `.guild` dot-command? pub(crate) fn is_guild_dot_command(text: &str) -> bool { text.strip_prefix(".guild") .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) } -/// `.guild create [] ""` (`cm:Level3.cpp:2945-2977`), GM level only. Without a -/// leader name the leader is the actor's selected player, else the actor. `Some(line)` is the -/// system line for the actor; success is silent, like every other dot-command. pub(crate) fn run_guild_dot_command( store: &St, player: &ProtocolSession, @@ -1061,8 +958,6 @@ pub(crate) fn run_guild_dot_command( }) } -/// The leader `.guild create` names: the typed name, else the selected player, else the actor. -/// `Ok(Err(line))` is the system line for a name that resolves to nobody or to more than one. fn guild_create_leader( store: &St, actor_guid: u64, @@ -1085,8 +980,6 @@ fn guild_create_leader( Ok(leader.ok_or_else(|| format!("no player named {}", leader_name.unwrap_or_default()))) } -/// Split `.guild create [] ""` into the optional leader name and the quoted guild -/// name. `None` for any other shape. fn parse_guild_create(text: &str) -> Option<(Option<&str>, &str)> { let rest = text.strip_prefix(".guild")?.trim_start(); let (subcommand, rest) = rest.split_once(char::is_whitespace)?; @@ -1105,9 +998,6 @@ fn parse_guild_create(text: &str) -> Option<(Option<&str>, &str)> { (!name.is_empty() && !name.contains('"')).then_some((leader, name)) } -/// MSG_TABARDVENDOR_ACTIVATE opens the tabard designer (`cm:NPCHandler.cpp:47-67`). This is a read -/// path, so only the faction refusal is answered here, silently. The NPC flag, reach and life Gates -/// run in the Fee Hold when the emblem is saved. fn tabard_window_outbound( store: &St, player: &ProtocolSession, @@ -1122,8 +1012,6 @@ fn tabard_window_outbound( Ok(vec![tabard_designer_window(npc_guid)]) } -/// The tabard designer window for `npc_guid`. Gossip action 11 opens it too -/// (`cm:Player.cpp:11877-11880`). pub(crate) fn tabard_designer_window(npc_guid: u64) -> Outbound { Outbound::One(ServerOpcodeMessage::MSG_TABARDVENDOR_ACTIVATE( MSG_TABARDVENDOR_ACTIVATE { @@ -1132,11 +1020,6 @@ pub(crate) fn tabard_designer_window(npc_guid: u64) -> Outbound { )) } -/// MSG_SAVE_GUILD_EMBLEM (`cm:GuildHandler.cpp:716-771`). Advisory reads of the Realm-core cache -/// answer NO_GUILD and NOT_GUILD_MASTER before any copper moves, and Realm-core decides again inside -/// the fee. mangos checks the NPC first. Here the NPC Gate runs in the Fee Hold, so a non-member at -/// the wrong NPC hears NO_GUILD. The new emblem reaches every online member, the payer included, -/// through the TABARD_CHANGED Guild Event. fn save_emblem_outbound( store: &St, player: &ProtocolSession, @@ -1162,9 +1045,6 @@ fn save_emblem_outbound( }; match guild_fee::pay(store, actor, request) { Ok(outcome) => emblem_result(outcome), - // The decision may have committed. The Hold is finished at the next fee or world - // entry, and the new emblem, if saved, reaches every member as TABARD_CHANGED. - // The client still needs an answer to close its wait. Err(error) => { let error = unless_transport_loss(error)?; log::warn!("world: emblem fee of {actor_guid} left unfinished: {error:#}"); @@ -1185,15 +1065,10 @@ fn emblem_result(outcome: guild_fee::FeeOutcome) -> GuildEmblemResult { Refused(GuildRefusal::NotInGuild) => GuildEmblemResult::NoGuild, Refused(GuildRefusal::NotLeader) => GuildEmblemResult::NotGuildMaster, Refused(GuildRefusal::NotEnoughMoney) => GuildEmblemResult::NotEnoughMoney, - // The NPC Gate. mangos names the value INVALIDVENDOR; the client shows nothing for it - // (`vm:src/game/Handlers/GuildHandler.cpp:686-693`). Refused(_) => GuildEmblemResult::NoMessage, } } -/// CMSG_PETITION_SHOWLIST (`cm:PetitionsHandler.cpp:636-678`). A read path, so only the faction -/// refusal is answered here, silently. The NPC Gates run in the Fee Hold when the Charter is -/// bought. fn petition_showlist_outbound( store: &St, player: &ProtocolSession, @@ -1208,8 +1083,6 @@ fn petition_showlist_outbound( Ok(vec![petition_showlist(npc_guid)]) } -/// The Guild Charter list of the Petitioner `npc_guid`. Gossip action 10 opens it too -/// (`cm:Player.cpp:11873-11876`). pub(crate) fn petition_showlist(npc_guid: u64) -> Outbound { Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOWLIST(Box::new( codec::build_petition_showlist(npc_guid), @@ -1220,11 +1093,6 @@ fn guild_create_result(name: String, result: GuildCommandResult) -> Outbound { command_result(GuildCommand::Create, name, result) } -/// CMSG_PETITION_BUY (`cm:PetitionsHandler.cpp:44-174`). Advisory reads of the Realm-core cache -/// answer in mangos order before any copper moves: a member and an owner who still holds a Charter -/// hear nothing, then a taken and an invalid name. An owner whose Charter is gone gets its old -/// Petition closed first, so it is not stranded. Realm-core decides again inside the fee, and the -/// Charter reaches the client through the item relay. fn petition_buy_outbound( store: &St, player: &ProtocolSession, @@ -1295,7 +1163,6 @@ fn charter_purchase_reply( Refused(GuildRefusal::NotEnoughMoney) => { vec![buy_failed(npc_guid, BuyResult::NotEnoughMoney)] } - // mangos names no vendor here (`cm:PetitionsHandler.cpp:114-118`). Refused(GuildRefusal::CharterUnavailable) => vec![buy_failed(0, BuyResult::CantFindItem)], Refused(GuildRefusal::BagsFull) => vec![Outbound::One( ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(Box::new( @@ -1315,12 +1182,10 @@ fn charter_purchase_reply( name, GuildCommandResult::GuildNameInvalid, )], - // A member, an owner of a Petition, or an NPC that does not serve: mangos is silent. Refused(_) => Vec::new(), } } -/// CMSG_PETITION_SHOW_SIGNATURES (`cm:PetitionsHandler.cpp:176-218`). A member hears nothing. fn show_signatures_outbound( store: &St, player: &ProtocolSession, @@ -1339,14 +1204,12 @@ fn show_signatures_outbound( .collect()) } -/// The signature window of `petition`. fn petition_signatures(petition: &codec::PetitionView) -> Outbound { Outbound::One(ServerOpcodeMessage::SMSG_PETITION_SHOW_SIGNATURES( Box::new(codec::build_petition_show_signatures(petition)), )) } -/// CMSG_PETITION_QUERY (`cm:PetitionsHandler.cpp:220-282`). An unknown Charter is silent. fn petition_query_outbound( store: &St, charter_item_guid: u64, @@ -1358,15 +1221,12 @@ fn petition_query_outbound( .collect()) } -/// The petition query response for `petition`. fn petition_query(petition: &codec::PetitionView) -> Outbound { Outbound::One(ServerOpcodeMessage::SMSG_PETITION_QUERY_RESPONSE(Box::new( codec::build_petition_query_response(petition), ))) } -/// MSG_PETITION_RENAME (`cm:PetitionsHandler.cpp:284-319`): only while the actor holds the -/// Charter. The reply echoes the new name. fn rename_petition_outbound( store: &St, player: &ProtocolSession, @@ -1406,10 +1266,6 @@ fn rename_petition_outbound( }) } -/// CMSG_OFFER_PETITION (`cm:PetitionsHandler.cpp:452-512`): the owner shows its Petition to a live -/// Character. The target sees the signature window through a Guild Event. ALREADY_IN_GUILD_S and -/// ALREADY_INVITED_TO_GUILD_S name the target, which is what the client line reads; mangos writes -/// the offerer's name there. fn offer_petition_outbound( store: &St, player: &ProtocolSession, @@ -1456,8 +1312,6 @@ fn offer_petition_outbound( }) } -/// CMSG_PETITION_SIGN (`cm:PetitionsHandler.cpp:321-422`). A signature, and an attempt by a Realm -/// Account that already signed, reach both the signer and the owner through Guild Events. fn sign_petition_outbound( store: &St, player: &ProtocolSession, @@ -1504,13 +1358,10 @@ fn sign_petition_outbound( actor_facts.name, GuildCommandResult::AlreadyInvitedToGuildS, )], - // Signed or already signed: the Guild Events answer. A full or unknown Petition is silent. _ => Vec::new(), }) } -/// MSG_PETITION_DECLINE (`cm:PetitionsHandler.cpp:424-450`): the owner hears it through a Guild -/// Event; the decliner hears nothing. fn decline_petition_outbound( store: &St, player: &ProtocolSession, @@ -1527,10 +1378,6 @@ fn decline_petition_outbound( Ok(Vec::new()) } -/// CMSG_TURN_IN_PETITION (`cm:PetitionsHandler.cpp:514-634`): only while the actor holds the -/// Charter. Realm-core founds the Guild first; the Charter is destroyed on the Home Shard after. -/// A crash between leaves an inert Charter whose Petition is gone, so it founds nothing twice, and -/// the next world entry destroys it (`destroy_inert_charters`). fn turn_in_petition_outbound( store: &St, player: &ProtocolSession, @@ -1559,10 +1406,7 @@ fn turn_in_petition_outbound( let error = unless_transport_loss(error)?; log::warn!("world: turned-in Charter {charter_item_guid} left inert: {error:#}"); } - // mangos sends both: the founding result from the turn-in handler, since - // `Guild::Create` sends nothing (`cm:PetitionsHandler.cpp:596-597`, - // `cm:Guild.cpp:104-154`), then the turn-in result (`:631-633`). Result 0 is success; - // gtker names the zero value PLAYER_NO_MORE_IN_GUILD. + // Vanilla sends founding then turn-in results; gtker misnames the success value. vec![ guild_create_result(name, GuildCommandResult::PlayerNoMoreInGuild), turn_in_result(PetitionResult::Ok), @@ -1578,13 +1422,10 @@ fn turn_in_petition_outbound( name, GuildCommandResult::GuildNameExistsS, )], - // Not the owner, or no Petition: mangos is silent. GuildOutcome::Refused(_) => Vec::new(), }) } -/// The Guild Projection for `character_guid`: `(guild_id, rank_id)`, or `(0, 0)` outside a Guild or -/// when Realm-core cannot answer. pub(crate) fn guild_projection( store: &St, character_guid: u64, @@ -1599,11 +1440,6 @@ pub(crate) fn guild_projection( } } -/// The guild packets of world entry, sent after the viewer is registered. The self CREATE -/// carried `created`, the Guild Projection read before registration; a membership change that -/// landed in between reached no viewer, so a different current value goes out as a VALUES update, -/// `(0, 0)` after a removal. A fresh login of a member also gets the MOTD -/// (`cm:CharacterHandler.cpp:775-785`). A failed read logs and never fails the entry. pub(crate) fn guild_world_entry( store: &St, character_guid: u64, @@ -1639,8 +1475,6 @@ pub(crate) fn guild_world_entry( outbound } -/// A member entering the world fresh signs on, which tells the other online members. Answers -/// whether SIGNED_ON went out, so the World Session owes a sign-off whatever happens next. pub(crate) fn guild_sign_on( store: &St, character_guid: u64, @@ -1660,8 +1494,7 @@ pub(crate) fn guild_sign_on( run_best_effort(store, actor, GuildRequest::SignOn { actor_name }, "sign-on") } -/// The guild part of leaving the world: a member signs off. Runs before the Account Claim is -/// released, while Realm-core still accepts the actor (`cm:WorldSession.cpp:714-724`). +/// Sign off before releasing the Account Claim, while Realm-core still accepts the Actor. pub(crate) fn guild_world_exit(store: &St, character_guid: u64) { let Some(actor) = Actor::new(character_guid) else { return; @@ -1671,7 +1504,6 @@ pub(crate) fn guild_world_exit(store: &St, charac } } -/// Run one guild op whose failure must not fail the caller. Answers whether it ran. fn run_best_effort( store: &St, actor: Actor, @@ -1692,12 +1524,6 @@ fn run_best_effort( } } -/// Destroy each Guild Charter in `charter_item_guids` that no Petition stands behind any more: its -/// Petition was turned in and the destroy after it failed, or its owner joined a Guild. Such a -/// Charter founds nothing, and as a unique item it blocks the next Charter purchase. Call it at -/// world entry after the Fee Hold re-drive, so a Charter whose purchase was still being decided -/// has its Petition by then. A Fee Hold that is still unfinished, or a Realm-core that cannot -/// answer, destroys nothing. pub(crate) fn destroy_inert_charters( store: &St, character_guid: u64, @@ -1735,9 +1561,6 @@ pub(crate) fn destroy_inert_charters( } } -/// Does `character_guid` lead a Guild? The Home Shard cannot read Realm-core, so the Gateway -/// answers this before it asks the Home Shard to delete the Character -/// (`cm:CharacterHandler.cpp:540-546`). pub(crate) fn leads_a_guild( store: &St, character_guid: u64, @@ -1750,19 +1573,12 @@ pub(crate) fn leads_a_guild( .is_some_and(|guild| guild.leader_guid == character_guid)) } -/// What one pass of the deleted-Character reconciliation did for one Character. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DeletedCharacterGuildCleanup { - /// Some World Shard still holds the Character. Preserved, Forgotten, } -/// Forget `character_guid` on Realm-core once every World Shard proves it is gone. A Character -/// row in any Shard's cache is a positive signal and needs no proof, so it costs no durable -/// snapshot: a Transfer deletes the source row only after the destination holds one, and a row -/// that is stale in a cache starts another pass when its delete arrives. Only then comes the -/// party cleanup's absence check, two durable snapshots of every configured World Shard. pub(crate) fn forget_deleted_character< St: CharacterStore + GuildActionStore + ShardRoutingStore + ?Sized, >( @@ -1787,9 +1603,6 @@ pub(crate) fn forget_deleted_character< } } -/// The guild cleanup the deleted-Character worker owes. A full sweep checks every Character that -/// guild state names: at startup and after a reconnect, when a delete may have gone unseen. A -/// `game_character` delete owes only its own guid. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct GuildCleanup { pub(crate) sweep: bool, @@ -1797,15 +1610,12 @@ pub(crate) struct GuildCleanup { } impl GuildCleanup { - /// Add work that a failed pass still owes. pub(crate) fn merge(&mut self, owed: GuildCleanup) { self.sweep |= owed.sweep; self.deleted.extend(owed.deleted); } } -/// Run the guild cleanup `work` asks for. Every Character is tried; the first failure is -/// returned, so the worker keeps the work and retries it. pub(crate) fn reconcile_deleted_guild_characters< St: CharacterStore + GuildActionStore + ShardRoutingStore + ?Sized, >( @@ -1846,9 +1656,6 @@ pub(crate) fn reconcile_deleted_guild_characters< first_error.map_or(Ok(()), Err) } -/// Resolve a typed member name against a Guild's name snapshots, case-insensitively, as mangos -/// searches its member list (`cm:Guild.h:285-292`). No match and two matches both answer -/// [`MemberMatch::NotInGuild`], so a by-name op never picks one of two homonyms. pub(crate) fn member_by_name(members: &[codec::GuildMemberView], name: &str) -> MemberMatch { let mut found = members .iter() @@ -1865,11 +1672,6 @@ pub(crate) enum MemberMatch { NotInGuild, } -/// Guild data for one settings-changing Guild Event, read once and shared by every recipient's -/// job: the query response, and, for a roster-carrying kind, both roster encodings, so a viewer's -/// own job only has to pick which one its Guild Rank sees and never re-reads the Realm-core cache -/// or the World Shards. A relay is not a reply to a client request, so a Guild that is gone renders -/// nothing here rather than the `not_in_guild()` command result a live request would get. #[derive(Clone, Default)] pub(crate) struct GuildEventSnapshot { pub(crate) query: Option, @@ -1879,7 +1681,6 @@ pub(crate) struct GuildEventSnapshot { } impl GuildEventSnapshot { - /// Read the Guild, and its roster too unless `kind` only needs the query response. pub(crate) fn build( store: &St, guild_id: u32, @@ -1920,13 +1721,10 @@ impl GuildEventSnapshot { } } - /// The query response, or nothing when the Guild is gone. pub(crate) fn query_response(&self) -> Option { self.query.clone() } - /// The roster `rank_id` sees: officer notes shown only with VIEWOFFNOTE, nothing when the - /// Guild is gone or this snapshot carries no roster. pub(crate) fn roster_for_rank(&self, rank_id: u32) -> Option { let rank_rights = self .rank_rights @@ -1967,43 +1765,25 @@ mod tests { const BOB: u64 = 5_090_002; const CAROL: u64 = 5_090_003; - /// An in-memory Realm-core plus World Shards. `guild_op` applies the same Gates the Module - /// applies, so outcomes are observable as rows. #[derive(Default)] struct InMemoryGuildActions { guilds: Mutex>, members: Mutex>, characters: Vec, - /// GM levels as each actor's Home Shard holds them. gm_levels: Vec<(u64, u8)>, - /// `(owner_guid, ignored_guid)`. ignored: Vec<(u64, u64)>, selected: u64, ops: Mutex>, - /// `guild_op` fails like this. op_error: Option anyhow::Error>, - /// NPCs that refuse every actor by faction. refusing_npcs: Vec, - /// Every fee request that reached the Home Shard. fee_requests: Mutex>, - /// What the Home Shard answers a hold; `None` holds. hold_refusal: Option, - /// What Realm-core decides; `None` accepts. fee_refusal: Option, - /// The decision's answer is lost, as when it commits but is late to the cache. fee_decide_error: Option anyhow::Error>, - /// Realm Presence cannot answer, as when a World Shard's subscription is unhealthy. facts_error: Option anyhow::Error>, - /// What the next non-`GmCreate` op answers; `None` runs it. The Gate arithmetic for every - /// op lives on the Module, proved by its own unit and durable tests; this seam only proves - /// the wire mapping from a given outcome, the same shape `GuildFeeStore` below uses for the - /// Fee Hold protocol. refuse_next: Mutex>, - /// Realm-core Petitions. petitions: Vec, - /// Guild Charters every actor holds on its Home Shard. held_charters: Vec, - /// Every Guild Charter destroyed on a Home Shard. destroyed_charters: Mutex>, } @@ -2029,7 +1809,6 @@ mod tests { }); } - /// Gate order after `cm:GuildHandler.cpp:46-64` and `founding_gate`. fn gm_create(&self, leader_guid: u64, gm_level: u8, name: String) -> Result { let refused = |refusal: GuildRefusal| -> Result { Ok(GuildOutcome::Refused(refusal)) @@ -2065,8 +1844,6 @@ mod tests { Ok(GuildOutcome::Ran) } - /// The next non-`GmCreate` `guild_op` call answers this refusal, so a test can pin the - /// Gateway's reply mapping without reproducing the Module's own Gate in this Fake. fn refuse_next_op(&self, refusal: GuildRefusal) { *self.refuse_next.lock().unwrap() = Some(refusal); } @@ -2151,10 +1928,6 @@ mod tests { if let Some(error) = self.op_error { return Err(error()); } - // Every op but GmCreate is a Module Gate this Fake does not reproduce (proved by the - // Module's own unit and durable tests instead): it answers the outcome a test scripts - // through `refuse_next_op`, defaulting to `Ran`, so a seam test proves only the wire - // mapping between a `GuildOutcome` and the client reply. if !matches!(request, GuildRequest::GmCreate { .. }) { if let Some(refusal) = self.refuse_next.lock().unwrap().take() { return Ok(GuildOutcome::Refused(refusal)); @@ -2238,7 +2011,6 @@ mod tests { } } - /// The fee answers the seam maps to result codes. `guild_fee`'s own tests drive the protocol. impl GuildFeeStore for InMemoryGuildActions { fn guild_fee_held(&self, _actor: Actor) -> Result> { Ok(None) @@ -2321,7 +2093,6 @@ mod tests { ReducerCallError::transport_lost("realm_guild_op").into() } - /// Another World Shard cannot vouch for a Character's absence. fn presence_unknown() -> anyhow::Error { presence::PresenceUnknown(anyhow::anyhow!( "World Shard instances has no healthy Coordinator subscription" @@ -2329,7 +2100,6 @@ mod tests { .into() } - /// A Module rejection whose tag this Gateway does not know. fn unknown_refusal() -> anyhow::Error { ReducerCallError::refused("realm_guild_op", "mystery").into() } @@ -2500,8 +2270,6 @@ mod tests { assert!(store.ops.lock().unwrap().is_empty()); } - /// A creature has a live entity and no Character row. Without the row there are no facts, so - /// `.guild create` with a creature selected falls back to the actor. #[test] fn a_live_entity_without_a_character_row_has_no_facts() { let facts = character_facts(None, || panic!("no row, so no presence read")).unwrap(); @@ -2674,7 +2442,6 @@ mod tests { fn founded_with_members() -> InMemoryGuildActions { let mut store = realm(); store.characters[2].online = false; - // A day and a half ago: 1.5 * 86_400_000_000 micros. store.characters[2].last_logout_micros = now_micros() - 129_600_000_000; run_guild_dot_command(&store, &in_world(GM), ".guild create \"Knights\"").unwrap(); store.add_member(1, BOB, 3, "bob note"); @@ -2964,7 +2731,6 @@ mod tests { } } - /// Bob leads Knights; Carol is a member at rank 3. fn led_by_bob( hold_refusal: Option, fee_refusal: Option, @@ -3049,10 +2815,6 @@ mod tests { } } - // Membership: invite, accept, decline, leave, remove, promote, demote, pass leadership, - // disband. The Module's own unit and durable tests prove the Gate arithmetic; these seam - // tests script an outcome through `refuse_next_op` and prove only the wire mapping. - const DAVE: u64 = 5_090_004; fn horde_facts(guid: u64, name: &str) -> CharacterFacts { @@ -3062,8 +2824,6 @@ mod tests { } } - /// `InMemoryGuildActions::add_member` snapshots every member's name as `Snapshot` - /// regardless of its note; by-name ops must type that snapshot, not the Character's real name. fn snapshot_name(guid: u64) -> String { format!("Snapshot{guid}") } @@ -4068,7 +3828,6 @@ mod tests { }; assert_eq!(response.name, "Knights"); - // Bob is rank 3 (Member), without VIEWOFFNOTE. let ServerOpcodeMessage::SMSG_GUILD_ROSTER(bob_roster) = snapshot.roster_for_rank(3).expect("a roster") else { @@ -4079,7 +3838,6 @@ mod tests { "Bob's rank cannot view officer notes" ); - // The Guild Leader is rank 0, which holds every right. let ServerOpcodeMessage::SMSG_GUILD_ROSTER(leader_roster) = snapshot.roster_for_rank(0).expect("a roster") else { @@ -4113,9 +3871,6 @@ mod tests { assert!(snapshot.roster_for_rank(0).is_none()); } - // Petitions: the Guild Charter list, purchase, signature window, query, rename, offer, sign, - // decline and turn-in. The Module's own tests prove the Gates; these prove each reply. - const PETITIONER: u64 = 5_090_020; const CHARTER: u64 = 0x4000_0000_0000_0101; const HORDE_TEAM: u32 = 67; @@ -4130,7 +3885,6 @@ mod tests { } } - /// Bob owns Petition 42 and holds its Guild Charter. fn bobs_petition() -> InMemoryGuildActions { InMemoryGuildActions { petitions: vec![night_watch(BOB)], @@ -4494,7 +4248,6 @@ mod tests { ) ); - // `cm:PetitionsHandler.cpp:480-484`: (INVITE, name, 0x05). store.refuse_next_op(GuildRefusal::AlreadyInvited); let ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result) = only_message(offer(&store, CAROL)) @@ -4584,7 +4337,6 @@ mod tests { GuildCommandResult::AlreadyInGuildS ) ); - // `cm:PetitionsHandler.cpp:365-369`: (INVITE, own name, 0x05). store.refuse_next_op(GuildRefusal::AlreadyInvited); let ServerOpcodeMessage::SMSG_GUILD_COMMAND_RESULT(result) = only_message(sign(&store, CAROL)) diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index c4f29aab2..6a861f060 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -1,14 +1,10 @@ -//! Item-action dispatcher: protocol mapping and feedback for equip, use, and inventory operations. - use super::super::*; use super::quest::{item_started_quest, QuestActionStore}; use lyracore_shared::item::ItemRefusal; -const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 — backpack + equipped slots share this pseudo-bag -const EQUIP_SLOT_END: u8 = 18; // EQUIPMENT_SLOT_END — last equipment slot (main-hand=15, off=16…) +const MAIN_BAG: u8 = 255; +const EQUIP_SLOT_END: u8 = 18; -/// How the Module answered one item Durable Request. A Refusal is an outcome; a timeout, transport, -/// or SDK failure stays an `Err` with an unknown durable result and ends the session. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ItemActionResult { Done, @@ -36,8 +32,6 @@ pub(crate) trait ItemActionStore: Send + Sync { ) -> Result; } -/// Run one durable item request as the session's Actor and render its answer. A session without -/// a Character in the world has nothing to act as, so the client gets an internal-error Refusal. fn act( player: &ProtocolSession, operation: &str, @@ -60,7 +54,6 @@ fn act( ))) } -/// A request the Gateway refuses before any Durable Request, such as a bag position it cannot map. fn refuse(player: &ProtocolSession, operation: &str, refusal: ItemRefusal) -> ProtocolReply { ProtocolReply::from(inventory_action_outbound( player.account_id, @@ -69,7 +62,6 @@ fn refuse(player: &ProtocolSession, operation: &str, refusal: ItemRefusal) -> Pr )) } -/// A Refusal answers the client with its own result code; a completed action answers nothing. fn inventory_action_outbound( account_id: u64, operation: &str, @@ -91,7 +83,6 @@ fn inventory_action_outbound( } } -/// A quest-starting item needs the quest operation as well as inventory operations. pub(crate) struct Item; impl ProtocolFamily for Item { @@ -150,7 +141,6 @@ impl ProtocolFamily for Ite }), _ => Ok(refuse(player, "move_item", ItemRefusal::WrongSlot)), }, - // A quest-starting item opens quest details without consuming the item. ClientOpcodeMessage::CMSG_USE_ITEM(c) => { let Some(slot) = codec::inventory_slot(c.bag_index, c.bag_slot) else { return Ok(refuse(player, "use_item", ItemRefusal::WrongSlot)); @@ -185,15 +175,12 @@ pub(super) mod tests { pub(crate) unequip_result: Option, pub(crate) move_result: Option, pub(crate) use_result: Option, - /// Every durable call fails with a Transport Loss. pub(crate) transport_lost: bool, pub(crate) start_quest: Option<(u64, u32)>, pub(crate) quest_detail: Option, } impl InMemoryItemActions { - /// The Coordinator answers a decoded Refusal or, for a Transport Loss, an error. The Fake - /// answers in those two shapes. fn answer( &self, operation: &str, @@ -249,9 +236,6 @@ pub(super) mod tests { } } - /// The item seam reaches the quest family for one thing only: whether the used item starts a - /// quest. The rest of the quest vocabulary is out of reach from here, and saying so keeps a - /// future route from quietly reading a canned answer. impl QuestActionStore for InMemoryItemActions { fn giver_quest_evals( &self, @@ -390,8 +374,6 @@ pub(super) mod tests { assert!(actions.use_requests.lock().unwrap().is_empty()); } - // ── Equip ──────────────────────────────────────────────────────────────── - #[test] fn equip_from_the_main_bag_requests_the_durable_equip_for_the_actor_and_slot() { let actions = InMemoryItemActions::default(); @@ -424,8 +406,6 @@ pub(super) mod tests { assert_no_durable_requests(&actions); } - // ── Unequip ────────────────────────────────────────────────────────────── - #[test] fn unequip_of_an_equipped_slot_requests_the_durable_unequip() { let actions = InMemoryItemActions::default(); @@ -492,8 +472,6 @@ pub(super) mod tests { assert_no_durable_requests(&actions); } - // ── Same-container move ────────────────────────────────────────────────── - #[test] fn move_maps_the_wire_slots_to_the_durable_move() { let actions = InMemoryItemActions::default(); @@ -541,8 +519,6 @@ pub(super) mod tests { ); } - // ── Same-container swap ────────────────────────────────────────────────── - #[test] fn swap_within_the_main_bag_maps_both_slots_to_the_durable_move() { let actions = InMemoryItemActions::default(); @@ -575,8 +551,6 @@ pub(super) mod tests { assert_no_durable_requests(&actions); } - // ── Ordinary use ───────────────────────────────────────────────────────── - #[test] fn use_from_the_main_bag_requests_the_durable_use_for_the_actor_and_slot() { let actions = InMemoryItemActions::default(); @@ -613,8 +587,6 @@ pub(super) mod tests { ); } - // ── Item-starts-quest ──────────────────────────────────────────────────── - #[test] fn quest_start_use_returns_details_without_requesting_ordinary_use() { let actions = InMemoryItemActions { @@ -648,8 +620,6 @@ pub(super) mod tests { assert!(actions.use_requests.lock().unwrap().is_empty()); } - // ── Player context and error classification ────────────────────────────── - #[test] fn unresolved_player_requests_nothing_and_every_inventory_action_answers_a_refusal() { let actions = InMemoryItemActions::default(); @@ -691,7 +661,6 @@ pub(super) mod tests { use wow_world_messages::vanilla::SMSG_INVENTORY_CHANGE_FAILURE as Failure; use wow_world_messages::Guid; let (item1, item2, bag_type_subclass) = (Guid::new(0), Guid::new(0), 0u8); - // The vanilla 1.12 result each Refusal must reach the client as. let expected = |refusal| match refusal { ItemRefusal::Indestructible => Failure::CantDropSoulbound { item1, @@ -803,6 +772,4 @@ pub(super) mod tests { }; inventory_failure(Item::handle(&refusing, &mut player(), equip(MAIN_BAG).into()).unwrap()); } - - // ── Pass-through ───────────────────────────────────────────────────────── } diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index ef71080c3..85084d680 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1,12 +1,9 @@ -//! Loot-window action dispatch plus the remaining corpse, GameObject, and group-loot handler. - use super::super::*; use super::combat::ignore_refusal; use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; use lyracore_shared::loot::LootRefusal; use wow_world_messages::vanilla::LootMethodError; -/// Durable reads and requests needed by the loot-window lifecycle. pub(crate) trait LootWindowStore: Send + Sync { fn loot_target_money(&self, target_guid: u64) -> Result; fn loot_target_items( @@ -27,33 +24,24 @@ pub(crate) trait LootWindowStore: Send + Sync { ) -> Result; } -/// How the Module answered a loot Durable Request. A Refusal is an outcome; a Transport Loss stays -/// an error and ends the World Session. pub(crate) enum LootWindowRequestStatus { Applied, Refused(LootWindowRefusal), } -/// How the Module answered a Loot Roll or master-loot Durable Request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum LootActionStatus { Applied, Refused(LootRefusal), } -/// A loot Refusal as the vanilla client can hear it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum LootWindowRefusal { - /// The Actor is not in the Loot Source's Loot Tag eligibility set. LootTagIneligible, - /// The Loot Source is on another map, in another instance, or beyond loot range. OutOfRange, - /// A Refusal vanilla has no loot-window code for. It is logged at the Store seam and dropped. Unanswered, } -// Only recognized Module tags reach this conversion. The Coordinator maps untagged legacy -// GameObject and skinning refusals to `Unanswered` and propagates strict-core failures. impl From for LootWindowRefusal { fn from(refusal: LootRefusal) -> Self { match refusal { @@ -80,13 +68,11 @@ impl LootWindowRefusal { } } -/// The target whose loot window is currently open for this world session. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct OpenLootState { pub(crate) target_guid: Option, } -/// The client traffic a loot Refusal earns. An unanswered Refusal stays silent. fn refusal_outbound(refusal: LootWindowRefusal, target_guid: u64) -> Vec { refusal .loot_error() @@ -99,7 +85,6 @@ fn refusal_outbound(refusal: LootWindowRefusal, target_guid: u64) -> Vec ProtocolFamily for LootWindow { @@ -193,7 +177,6 @@ impl ProtocolFamily for LootWindow { let money = store.loot_target_money(target_guid)?; let items = store.loot_target_items(target_guid, viewer.guid())?; if items.is_empty() && money == 0 { - // Skinning an empty corpse is opportunistic: a Refusal still shows the empty window. store.skin_corpse(viewer, target_guid)?; } let (opcode, body) = codec::build_loot_response_raw(target_guid, money, &items); @@ -268,7 +251,6 @@ impl ProtocolFamily for LootWindow { } } -/// Handle loot rolls, general GameObject use and death recovery. pub(crate) struct Loot; impl @@ -283,17 +265,9 @@ impl { let corpse_guid = c.item.guid(); let vote = c.vote.as_int(); - // Unsharded, `loot::run_vote` is exactly the call above (`store.loot_roll`); - // sharded, it routes to realm-core instead, so the Actor it votes as must be the one - // THIS socket authenticated with, never a literal from the packet. let actor = actor.ok_or_else(|| anyhow!("CMSG_LOOT_ROLL before world entry"))?; finish_loot_action(loot::run_vote( store, @@ -344,9 +318,6 @@ impl { if let Some(actor) = session.actor() { super::trainer::settle_per_action( @@ -356,8 +327,6 @@ impl { let tmpl = store.gameobject_template(q.entry_id)?; outbound.push(Outbound::One( @@ -366,19 +335,11 @@ impl { if let Some(actor) = actor { ignore_refusal("repop", session.account_id, store.repop(actor))?; } } - // Corpse location query: the client asks where the player's corpse is to draw the - // map marker + offer "Reclaim Corpse" near it. Reply with the corpse's position, or NotFound. ClientOpcodeMessage::MSG_CORPSE_QUERY => { if let WorldState::InWorld(iw) = &session.state { let loc = store.corpse_location(iw.self_guid)?; @@ -387,29 +348,18 @@ impl { if let Some(actor) = actor { let result = store.reclaim_corpse(actor, r.guid.guid()); ignore_refusal("reclaim_corpse", session.account_id, result)?; } } - // Resurrection accept-prompt response: the dead player answered the SMSG_RESURRECT_REQUEST - // offer. `status` is vanilla's accept(1)/decline(0) byte; the offer's guid is ignored (mirrors - // `reclaim_corpse`'s own-corpse derivation — the module resolves the pending offer from the - // CALLER via `ctx.sender()`, never the wire guid). A failure (no pending offer — already - // answered/lapsed) is expected and silently ignored. ClientOpcodeMessage::CMSG_RESURRECT_RESPONSE(r) => { if let Some(actor) = actor { let result = store.resurrect_response(actor, r.status != 0); ignore_refusal("resurrect_response", session.account_id, result)?; } } - // The death dialog's second button: use the Self-Resurrection Option the Module wrote into - // PLAYER_SELF_RES_SPELL. The revive replicates through the entity VALUES relay. A Refusal - // (already used, already alive) is expected after a race and sends nothing. ClientOpcodeMessage::CMSG_SELF_RES => { if let Some(actor) = actor { ignore_refusal( @@ -419,11 +369,6 @@ impl 0 + cleared ghost - // bits), exactly like reclaim_corpse. A failure (not a ghost) is per-action — log + ignore. ClientOpcodeMessage::CMSG_SPIRIT_HEALER_ACTIVATE(s) => { if let Some(actor) = actor { let result = store.spirit_healer_res(actor, s.guid.guid()); @@ -481,11 +426,9 @@ mod tests { item_take_failure: Option, } - /// How a Store call fails before the Store has an answer to hand the handler. #[derive(Clone, Copy)] enum Failure { TransportLost, - /// The Store returns a Module refusal it does not decode, such as a boundary failure. Refused(&'static str), } @@ -1024,7 +967,6 @@ mod tests { let outcome = run_window(&store, session(), open_window, message).unwrap(); let (next_state, outbound) = outcome; match loot_error { - // An answered Refusal invalidates the Loot Window the client is showing. Some(loot_error) => { assert_loot_error(&outbound, 60, loot_error); assert_eq!(next_state, OpenLootState::default(), "{refusal:?}"); @@ -1383,7 +1325,6 @@ mod tests { assert_transport_loss(&error); } - /// Run one request as the session does: apply the state transition, return the client traffic. fn run( store: &InMemoryLootWindow, state: &mut OpenLootState, @@ -1402,8 +1343,6 @@ mod tests { ClientOpcodeMessage::CMSG_AUTOSTORE_LOOT_ITEM(CMSG_AUTOSTORE_LOOT_ITEM { item_slot }) } - /// `(slot, item_id, count, display_id)` of item `index` in a raw loot window body: the header is - /// 8 guid + 1 method + 4 money + 1 count bytes, then 22 bytes per item. fn window_item(body: &[u8], index: usize) -> (u8, u32, u32, u32) { let base = 14 + index * 22; let word = @@ -1412,7 +1351,6 @@ mod tests { } fn assert_clear_money(outbound: &[Outbound]) { - // A solo looter gets no SMSG_LOOT_MONEY_NOTIFY: the client prints its own copper line. assert!(matches!( outbound, [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_CLEAR_MONEY)] diff --git a/gateway/src/world/handlers/mail.rs b/gateway/src/world/handlers/mail.rs index eff9ca954..b5e25c4e9 100644 --- a/gateway/src/world/handlers/mail.rs +++ b/gateway/src/world/handlers/mail.rs @@ -1,10 +1,3 @@ -//! Mail family: open the mailbox window, answer the client's mail poll, serve a letter's body, mark -//! a mail read or delete it, post a letter with copper and one item attached (at a -//! cash-on-delivery price, or not), take either back out, and return a mail to its sender. -//! -//! Every Refusal is per-action: log, and either answer the packet the client is blocked on or send -//! nothing. A Transport Loss ends the World Session. - use super::super::*; use wow_world_messages::vanilla::SMSG_SEND_MAIL_RESULT_MailResultTwo; @@ -13,7 +6,7 @@ pub(crate) struct Mail; impl ProtocolFamily for Mail { - #[allow(clippy::too_many_lines)] // One arm per Mail opcode. + #[allow(clippy::too_many_lines)] fn handle( store: &St, conn: &mut ProtocolSession, @@ -22,11 +15,7 @@ impl { match mail::open_mailbox(store, conn.actor(), c.mailbox.guid()) { Ok(mails) => { @@ -43,10 +32,6 @@ impl { let unread = match mail::has_unread(store, conn.actor()) { Ok(unread) => unread, @@ -65,15 +50,6 @@ impl { let body = match mail::item_text( store, @@ -97,10 +73,6 @@ impl { let actor = conn.actor(); if let Err(failure) = @@ -113,10 +85,6 @@ impl { let actor = conn.actor(); let ok = match mail::delete(store, actor, c.mailbox_id.guid(), u64::from(c.mail_id)) @@ -135,10 +103,6 @@ impl { let actor = conn.actor(); let ok = match mail::return_to_sender( @@ -161,11 +125,6 @@ impl { let actor = conn.actor(); let ok = @@ -184,11 +143,6 @@ impl { let actor = conn.actor(); let outcome = @@ -215,14 +169,6 @@ impl { let result2 = match mail::send( store, @@ -256,10 +202,6 @@ impl { Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrNotEnoughMoney) } - // A bound item and one that is not the sender's are different mistakes, so - // the client is told which: vanilla has no "soulbound" mail line, and - // `ErrCantSendWrappedCod` is mangoszero's nearest — it renders as a refusal - // about the attachment rather than about the letter. mail::SendRefusal::AttachmentSoulbound(_) => { Some(SMSG_SEND_MAIL_RESULT_MailResultTwo::ErrCantSendWrappedCod) } @@ -278,11 +220,6 @@ impl { let actor = conn.actor(); let made = diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index 0722d9584..464d04c60 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -1,9 +1,3 @@ -//! Meeting Stone dispatcher: join, leave, the queue status query and `MSG_LOOKING_FOR_GROUP`. -//! -//! A JOIN is two Durable Requests: the Home Shard admits the actor at the stone, then the party -//! authority queues it with the Seekers' race and class, read realm-wide. Queue notices arrive on -//! the group event relay; only a refused party JOIN is answered here. - use super::super::*; use crate::stdb::{classify, DurableFailure}; use lyracore_shared::group::GROUP_MAX_MEMBERS; @@ -19,7 +13,6 @@ pub(crate) struct SeekerFacts { pub(crate) class: u8, } -/// A timeout, transport or SDK failure stays an `Err` with an unknown outcome. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MeetingStoneOutcome { Ran, @@ -27,15 +20,10 @@ pub(crate) enum MeetingStoneOutcome { } pub(crate) trait MeetingStoneActionStore { - /// Durable Request on the actor's Home Shard. Writes nothing. fn admit_meeting_stone(&self, actor: Actor, go_guid: u64) -> Result; - /// Durable Read of the Home Shard cache. fn meeting_stone_area(&self, go_guid: u64) -> Result>; - /// Durable Read of the party authority, in join order. `None` outside a party. fn party_members(&self, actor_guid: u64) -> Result>>; - /// One Seeker's race and class, from whichever World Shard holds it. fn seeker_facts(&self, character_guid: u64) -> Result>; - /// Durable Request on the party authority. fn meeting_stone_op( &self, actor: Actor, @@ -43,11 +31,9 @@ pub(crate) trait MeetingStoneActionStore { area_id: u32, seekers: Vec, ) -> Result; - /// Durable Read of the party authority. fn queued_area(&self, character_guid: u64) -> Result>; } -/// Handle Meeting Stone requests, silently outside the world. pub(crate) struct MeetingStone; impl ProtocolFamily for MeetingStone { @@ -97,8 +83,6 @@ enum Action { LookingForGroup, } -/// Only a Transport Loss ends the World Session. A Refusal is logged and answered with nothing, -/// because the client has no message for it. fn recoverable(what: &str, account_id: u64, result: Result) -> Result> { match result { Ok(value) => Ok(Some(value)), @@ -112,8 +96,6 @@ fn recoverable(what: &str, account_id: u64, result: Result) -> Result( store: &St, account_id: u64, @@ -143,7 +125,6 @@ fn join( else { return Ok(Vec::new()); }; - // Only the Gateway knows which areas the client protocol can name. if Area::try_from(area_id).is_err() { log::debug!("world: meeting stone {go_guid} names unknown area {area_id}"); return Ok(Vec::new()); @@ -168,8 +149,6 @@ fn join( .collect()) } -/// The Seeker facts a JOIN conveys: the actor's, or every member's of its Party. The queue refuses -/// a Raid, so a Raid's actor conveys only its own. fn facts_for_join( store: &St, actor: Actor, @@ -185,7 +164,6 @@ fn facts_for_join( Ok(facts) } -/// `None` when the op failed short of a transport loss. fn run_op( store: &St, account_id: u64, @@ -208,8 +186,6 @@ fn run_op( Ok(outcome) } -/// `CMSG_MEETINGSTONE_INFO`, sent after a loading screen: JOINED for a Seeker's area, else NONE. -/// Both cores read a restore map nothing fills; this reports the real state. fn info( store: &St, account_id: u64, @@ -248,25 +224,19 @@ mod tests { const ACTOR: u64 = 7; const STONE: u64 = 900; - /// The Deadmines. const AREA: u32 = 1581; const HUMAN: u8 = 1; const ORC: u8 = 2; const WARRIOR: u8 = 1; const PRIEST: u8 = 5; - /// One `meeting_stone_op` call: actor, op, area and the conveyed facts. type QueueOp = (u64, u8, u32, Vec); - /// Records which database each call is meant for: `home` is the actor's Home Shard and - /// `authority` the party authority. The Coordinator maps both to its one database on an - /// unsharded realm. #[derive(Default)] struct FakeMeetingStones { admission: Option, stone_area: Option, party: Option>, - /// Facts by guid, as the World Shards hold them. facts: HashMap, op_outcome: Option anyhow::Error>>, queued: Option, @@ -422,7 +392,6 @@ mod tests { ); } - /// Each party Refusal answers with cmangos's failure byte. #[test] fn each_party_refusal_answers_its_join_failure_byte() { for (refusal, reason) in [ @@ -460,8 +429,6 @@ mod tests { assert_eq!(store.calls(), [("home", "admit"), ("home", "stone_area")]); } - /// The members stand on different World Shards; the Fake's facts table is that realm-wide - /// read. A member no Shard can name is left out, and the Module gives it class 0. #[test] fn a_party_join_carries_every_members_facts() { const PRIEST_GUID: u64 = 8; @@ -487,7 +454,6 @@ mod tests { ); } - /// A Raid's 40 members are not read for a JOIN the queue refuses anyway. #[test] fn a_raid_join_carries_only_the_actors_facts() { let store = FakeMeetingStones { @@ -533,7 +499,6 @@ mod tests { ); } - /// vmangos answers 0. #[test] fn looking_for_group_answers_zero() { let store = FakeMeetingStones::default(); @@ -564,7 +529,6 @@ mod tests { assert!(store.calls().is_empty()); } - /// A Refusal the Module did not tag keeps the World Session; a Transport Loss ends it. #[test] fn only_a_transport_loss_ends_the_session() { let untagged = FakeMeetingStones { diff --git a/gateway/src/world/handlers/melee.rs b/gateway/src/world/handlers/melee.rs index 91b91d6a0..e65eae102 100644 --- a/gateway/src/world/handlers/melee.rs +++ b/gateway/src/world/handlers/melee.rs @@ -1,13 +1,9 @@ -//! Melee-attack dispatcher: durable engagement, session transition, and client stance messages. - use super::super::*; use crate::stdb::{classify, DurableFailure}; use crate::world::family::{ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession}; pub(crate) trait MeleeActionStore: Send + Sync { fn start_attack(&self, actor: Actor, target_guid: u64) -> Result<()>; - /// Disarm the actor's outgoing engagement row. Also the ranged auto-repeat teardown call; - /// `attack_stop` states the rule the two share. fn stop_attack(&self, actor: Actor) -> Result<()>; } @@ -43,20 +39,17 @@ impl ProtocolFamily for Melee { } } -/// The Character's entity is gone, so no further action can be served. End the World Session so the client can log in again. fn desync_exit(error: anyhow::Error, opcode: &str) -> anyhow::Error { error.context(format!( "Character entity missing on {opcode}: desync ends the World Session" )) } -/// Arm the durable engagement first; session state and client stance follow only on success. fn attack_start( store: &St, session: &mut ProtocolSession, target_guid: u64, ) -> Result { - // Not in the world: no combat state to arm and no attacker guid to name. let Some(actor) = session.actor() else { return Ok(ProtocolReply::default()); }; @@ -64,8 +57,6 @@ fn attack_start( let DurableFailure::Refusal { reason } = classify(&e) else { return Err(e); }; - // A corpse or a friendly target must be answered: with no refusal the client hangs in - // combat stance and never swings. Both are transient per-swing failures, not fatal. let refusal = if reason.contains(lyracore_shared::ERR_ATTACK_TARGET_DEAD) { Some(ServerOpcodeMessage::SMSG_ATTACKSWING_DEADTARGET) } else if reason.contains(lyracore_shared::ERR_ATTACK_FRIENDLY) { @@ -73,10 +64,8 @@ fn attack_start( } else if is_desync_error(&e) { return Err(desync_exit(e, "attackswing")); } else { - // Out of range, retarget races: expected, so ignore. None }; - // Every non-desync refusal stays visible to operators, answered ones included. log::debug!( "world: start_attack ignored (account {}): {e}", session.account_id @@ -98,16 +87,11 @@ fn attack_start( }) } -/// Request durable disengagement first; the session drops its target and the client leaves -/// combat stance after. fn attack_stop( store: &St, session: &mut ProtocolSession, ) -> Result { - // Melee and ranged auto-repeat share one durable engagement row per attacker. The client sends - // CMSG_ATTACKSTOP whenever it leaves melee stance, ranged loop armed or not, so honoring it - // here would delete the auto-shot engagement: one shot, then silence. Only - // CMSG_CANCEL_AUTO_REPEAT_SPELL tears that loop down. + // Ignore melee stop while ranged is armed; both share one durable engagement row. if matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat) { return Ok(ProtocolReply::default()); } @@ -126,8 +110,6 @@ fn attack_stop( if is_desync_error(&e) { return Err(desync_exit(e, "attackstop")); } - // A refused stop still clears the client's stance: the recorded target may already be dead, - // and leaving the Character swinging at nothing is worse than a stale disarm. log::debug!( "world: stop_attack ignored (account {}): {e}", session.account_id @@ -161,16 +143,11 @@ pub(super) mod tests { #[derive(Default)] pub(crate) struct InMemoryMeleeActions { - /// Recorded `start_attack` calls: actor and target. pub(crate) start_requests: Mutex>, - /// The Module's reason when `start_attack` is refused. pub(crate) start_error: Option, pub(crate) stop_requests: Mutex>, - /// The Module's reason when `stop_attack` is refused. pub(crate) stop_error: Option, - /// Every durable call fails with a Transport Loss. pub(crate) transport_lost: bool, - /// The attackers with a live engagement: a started attack adds its actor, a stop removes it. pub(crate) engaged: Mutex>, } diff --git a/gateway/src/world/handlers/member_stats.rs b/gateway/src/world/handlers/member_stats.rs index 0368bbbd8..ae101b031 100644 --- a/gateway/src/world/handlers/member_stats.rs +++ b/gateway/src/world/handlers/member_stats.rs @@ -1,9 +1,3 @@ -//! Member Stats: the Store seam that finds a group member on any World Shard, the Relay tick that -//! decides what one viewer is sent, and the `CMSG_REQUEST_PARTY_MEMBER_STATS` answer. -//! -//! A member inside the viewer's AOI is already created on the client, which draws its frame from -//! the object fields. Member Stats go only to the others (cm:Group.cpp:718-733). - use super::super::*; use crate::codec::{ build_member_stats, build_member_status, full_update_mask, member_status, stats_delta, @@ -12,44 +6,26 @@ use crate::codec::{ use std::collections::HashMap; use std::sync::{Mutex, MutexGuard, PoisonError}; -/// Where one group member is, as the Gateway's Coordinator caches see it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum MemberPresence { - /// A live `game_world_entity` row on some World Shard. Boxed: `MemberStats` carries a 32-slot - /// and a 16-slot aura array plus its pet's own copies, so an unboxed variant would triple the - /// size of every `MemberPresence`, most of which are `InTransit` or `Offline`. Live(Box), - /// No live entity, but the member is between two places: a pending Transfer, or the loading - /// screen of a map change. Reporting it offline would make the frame flicker. InTransit, Offline, } pub(crate) trait MemberStatsStore: Send + Sync { - /// Every other member of `self_guid`'s group, from the party authority's cache: Realm-core on - /// a sharded Realm, the only database otherwise. Empty when `self_guid` has no group. fn group_mates(&self, self_guid: u64) -> Result>; - /// The member's presence, projected from its Realm Presence - /// ([`crate::world::presence::of`]): a live entity is `Live`, `Whereabouts::InTransit` is - /// `InTransit`, and `Whereabouts::Offline` or no Realm Presence at all is `Offline`. fn member_presence(&self, guid: u64) -> Result; } -/// What the Relay last sent one viewer about one group mate. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum MemberSnapshot { Offline, - /// Boxed for the same reason as [`MemberPresence::Live`]. Live(Box), } -/// What one World Session's client holds of each group mate's Member Stats. -/// -/// The Relay and the session share it, and only jobs on the session's writer change it, so it -/// follows the order in which the client reads packets. A packet that sets a frame some other way -/// (`SMSG_GROUP_LIST`, a FULL answer) forgets what it overwrote, and the next tick sends every -/// field of that member again. +/// Only writer jobs may change these snapshots, in the same order as client packets. #[derive(Clone, Default)] pub(crate) struct MemberStatsRecord(Arc>>); @@ -67,12 +43,6 @@ impl MemberStatsRecord { } } -/// One Relay tick for one viewer: the `SMSG_PARTY_MEMBER_STATS` packets it needs, in group order. -/// -/// `snapshots` is the viewer's record of what it holds. It changes only when the tick succeeds, so -/// a failed read never leaves a record of a packet that was not sent. A mate created on the client -/// loses its record, so the tick after it leaves the AOI sends every field again, because the -/// client's party cache may be stale by then. pub(crate) fn member_stats_tick( store: &St, self_guid: u64, @@ -132,8 +102,6 @@ pub(crate) fn member_stats_tick( pub(crate) struct MemberStats; impl ProtocolFamily for MemberStats { - /// Answer on the writer and clear the previous Relay record before the next tick. - /// An unreadable group member sends nothing and leaves the World Session open. fn handle( store: &St, session: &mut ProtocolSession, @@ -168,14 +136,6 @@ impl ProtocolFamily for MemberStats { } } -/// Only the requester's group mates get their state. Any other guid gets the offline answer -/// (cm:GroupHandler.cpp:764-771), as in vmangos (vm:GroupHandler.cpp:782). cmangos answers any -/// guid, which would show a stranger's zone and position to anyone who asks. -/// -/// A mate between two places answers online with `ZONE_OUT`. cmangos finds a teleporting player -/// too (`FindPlayer(guid, false)`, cm:GroupHandler.cpp:764) and flags it `ZONE_OUT` -/// (cm:Group.cpp:54-55). Its entity is gone here, so the answer carries the status alone and the -/// client keeps the other fields it has. fn full_answer( store: &St, self_guid: u64, @@ -203,10 +163,6 @@ fn full_answer( mod tests { use super::*; - // The live-entity/in-transit/absence-gated-offline decision itself is pinned in - // `world::presence_tests`, against `presence::of` — this file no longer runs its own - // discovery, so it no longer needs its own Store Fake for one. - #[test] fn forgetting_one_member_keeps_the_others() { let record = MemberStatsRecord::default(); diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 9b58cfdd1..2c01841c5 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -1,5 +1,3 @@ -//! Protocol Family handlers, their Stores, and shared protocol operations. - use super::*; mod auction; @@ -90,8 +88,6 @@ pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use vendor::{Vendor, VendorActionStore}; pub(crate) use weather::{zone_weather_message, WeatherStore}; -/// Open the bank window for `banker_guid`. Single chokepoint for `CMSG_BANKER_ACTIVATE` and the -/// BANKER gossip option, so the two entry points cannot drift apart. fn show_bank(banker_guid: u64) -> Outbound { Outbound::One(ServerOpcodeMessage::SMSG_SHOW_BANK(codec::build_show_bank( banker_guid, diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 8a03bd8a7..f38d44a5b 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -1,5 +1,3 @@ -//! NPC queries, gossip, Speech, and dot-commands. Other chat requests delegate to Chat. - use super::super::*; use super::quest; use super::taxi::open_taxi_outbound; @@ -8,15 +6,11 @@ use super::vendor::{vendor_has_stock, vendor_open_outbound}; use crate::stdb::{classify, DurableFailure}; use lyracore_shared::chat::broadcast_chat; -/// What the GM reads when a dot-command arrives before the session has a Character. const NO_CHARACTER_FOR_GM_COMMAND: &str = "gm_command: actor_guid unresolved"; -/// NPC and gameobject templates, gossip, area triggers and inspect. pub(crate) trait NpcStore: Send + Sync { - /// Look up a creature template by entry to answer `CMSG_CREATURE_QUERY` (Tier 2 / NPCs). fn creature_template(&self, entry: u32) -> Result>; - /// Resolve the public name of a live pet for an in-world requester. fn pet_name( &self, requester: Actor, @@ -24,53 +18,26 @@ pub(crate) trait NpcStore: Send + Sync { pet_guid: u64, ) -> Result>; - /// Look up a gameobject template by entry to answer `CMSG_GAMEOBJECT_QUERY`. fn gameobject_template(&self, entry: u32) -> Result>; - /// The `type_id` of a spawned GameObject by its live guid. `CMSG_GAMEOBJ_USE` sends questgivers - /// to the quest window, chests to the loot-window lifecycle, and every other type to the general - /// use path. fn gameobject_type(&self, go_guid: u64) -> Result>; - /// Enter an area trigger (`CMSG_AREATRIGGER`): credit any active "explore" quest tied to it. fn enter_areatrigger(&self, actor: Actor, trigger_id: u32) -> Result<()>; - /// Standing-derived reaction gate: does this NPC refuse `player_guid` its - /// interaction WINDOW? Rep-bar factions refuse at Unfriendly-or-below standing; bar-less - /// factions fall back to the FactionTemplate hostility masks. Fail-open on missing data. - /// Gossip, trainer and banker ask here; the migrated families ask the same read through their - /// own traits (`QuestActionStore::giver_refuses_interaction`, - /// `VendorActionStore::vendor_refuses_interaction`). fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result; - /// Bind the Character's home after the Module checks the selected innkeeper. fn bind_home(&self, actor: Actor, innkeeper_guid: u64) -> Result; - /// Does the NPC at `guid` carry the innkeeper flag? Used to display its gossip option. fn npc_is_innkeeper(&self, guid: u64) -> Result; - /// Resolve the `title_text_id` to embed in `SMSG_GOSSIP_MESSAGE` for the NPC at `guid`. - /// Looks up `game_gossip_menu` by creature entry; falls back to 1 (generic greeting). fn npc_gossip_text_id(&self, npc_guid: u64) -> u32; - /// Look up the full weighted greeting (all 8 `npc_text` slots) for a `text_id`. - /// Returns `None` when no imported `game_npc_text` row exists (the gateway falls back to the - /// generic greeting string). fn npc_text_for_id(&self, text_id: u32) -> Option; - /// The imported gossip menu options for the NPC at `guid`, sorted by - /// `option_index`, RAW/unfiltered by condition. Empty when nothing is imported for this creature - /// (the gateway falls back to the flag-derived vendor/innkeeper synthesis). fn gossip_options(&self, npc_guid: u64) -> Result>; - /// Validate a `CMSG_INSPECT` request: `target_guid` must be a real in-world player, on the - /// caller's map, in range, and friendly. `Ok(())` → the gateway replies `SMSG_INSPECT(target_guid)`; - /// `Err` (out of range / hostile / no such target) → silently ignored, matching the other - /// stateless-gate reducers (`enter_areatrigger`, `use_gameobject`). fn inspect(&self, actor: Actor, target_guid: u64) -> Result<()>; - /// NOTIFY-ONLY module chokepoint for a gossip-option click — fired best-effort - /// before the gateway's own gossip handling; failure never blocks the gossip reply. fn gossip_select( &self, actor: Actor, @@ -80,8 +47,6 @@ pub(crate) trait NpcStore: Send + Sync { ) -> Result<()>; } -/// Filter imported gossip options for the menu. The World Session retains their order so a -/// later click resolves against the options the client saw. fn filtered_gossip_options< St: CharacterStore + NpcStore + QuestActionStore + TrainerStore + ?Sized, >( @@ -90,17 +55,10 @@ fn filtered_gossip_options< player_guid: u64, ) -> Result> { use lyracore_shared::constants::{gossip_option, MIN_TALENT_LEVEL}; - // The unlearn-talents row has no condition of its own (cmangos gates it in C++ code at - // GossipHello, not via a `conditions` row — see `gossip_option::UNLEARNTALENTS`'s doc), so it - // needs its own level check here rather than falling through `option_condition_holds`. Below - // level 10 a character literally cannot have a talent point, so the option would be inert even - // if shown. let level = store .character_by_guid(player_guid)? .map(|c| c.level) .unwrap_or(0); - // The module refuses both training and respec for the wrong class, so either option would - // advertise a guaranteed failure. Fail-open: a read error must not hide a working trainer. let serves_class = store.trainer_serves(player_guid, npc_guid).unwrap_or(true); let raw = store.gossip_options(npc_guid)?; Ok(raw @@ -120,9 +78,6 @@ fn filtered_gossip_options< .collect()) } -/// Say, yell or `/e` (a `broadcast_chat` type) through the speaker's Home Shard. The line itself -/// returns on the Relay; a Refusal gets the answer every chat line shares, and only a Transport -/// Loss ends the World Session. A speaker with no Character yet is silent. fn speak_nearby( store: &St, session: &ProtocolSession, @@ -174,7 +129,7 @@ impl< + ?Sized, > ProtocolFamily for Query { - #[allow(clippy::too_many_lines)] // One match arm per query or Speech opcode. + #[allow(clippy::too_many_lines)] fn handle( store: &St, session: &mut ProtocolSession, @@ -182,7 +137,6 @@ impl< ) -> Result { let msg = request.message()?; let mut outbound = Vec::new(); - // `None` until the session has a Character in the world. let actor = session.actor(); match msg { @@ -213,13 +167,6 @@ impl< outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); } - // Name resolution: the client asks for a guid's name to render its plate (else "Unknown"). - // - // Resolved across every connected shard, not just this one. A guid the - // client has met across a database boundary — the sender of a cross-shard whisper, which - // arrives as a GUID because the client resolves whisper names itself — has no row on the - // asking session's shard, and a dropped reply renders the line with nobody's name on it. On a - // single-database gateway `world_stores()` is empty, so this is exactly the one read it was. ClientOpcodeMessage::CMSG_NAME_QUERY(q) => { let guid = q.guid.guid(); match presence::character_anywhere(store, guid)? { @@ -248,12 +195,6 @@ impl< ), } } - // Inspect: validate range + friendly target server-side (the `inspect` - // reducer), then ack with SMSG_INSPECT(target guid) so the client opens the paperdoll — it - // renders the target's equipment from fields the client already has (the visible-item relay - // is the follow-up for full paperdoll correctness). Out of range / hostile / - // no-such-target → the reducer errors and we silently drop the request, same as the other - // stateless gates (CMSG_GAMEOBJ_USE, CMSG_AREATRIGGER). ClientOpcodeMessage::CMSG_INSPECT(i) => { let target_guid = i.guid.guid(); if let Some(actor) = actor { @@ -265,7 +206,6 @@ impl< } } } - // Creature name resolution (the NPC analogue of CMSG_NAME_QUERY). ClientOpcodeMessage::CMSG_CREATURE_QUERY(q) => { match store.creature_template(q.creature)? { Some(c) => outbound.push(Outbound::One( @@ -276,18 +216,9 @@ impl< None => log::debug!("world: creature query for unknown entry {}", q.creature), } } - // Gossip (rank 12, extended with imported gossip menus): the player right-clicked a gossip NPC - // (npc_flags GOSSIP bit). Reply with a title (resolved via the NPC_TEXT round-trip below) + - // either the NPC's IMPORTED menu options (precedence) or the flag-derived vendor/innkeeper - // synthesis (fallback) + the QUEST section. A gossip-FLAGGED questgiver (npc_flags - // GOSSIP|QUESTGIVER, e.g. Marshal McBride) delivers its quests here, not via - // CMSG_QUESTGIVER_HELLO, so fold the same quest menu in (empty for a plain gossip NPC → - // unchanged). ClientOpcodeMessage::CMSG_GOSSIP_HELLO(h) => { let npc = h.guid.guid(); let player_guid = actor.map_or(0, Actor::guid); - // A gossip NPC that dislikes you doesn't open its menu (silent drop — - // vanilla unfriendly NPCs just ignore the click). if player_guid != 0 && store .npc_refuses_interaction(npc, player_guid) @@ -299,11 +230,6 @@ impl< WorldState::InWorld(iw) => quest::gossip_quest_items(store, npc, iw.self_guid)?, WorldState::CharSelect => Vec::new(), }; - // A vendor that ALSO has the gossip bit gets a "browse goods" menu entry (rank-vendor); - // having stock is the is-vendor signal, so no npc_flags read is needed. An innkeeper gets a - // "Make this inn your home." entry (hearthstone bind) — that one DOES need the npc_flags - // read. Both are APPENDED to the imported options rather than replaced by them: a dump menu - // that omits the row would otherwise strand the NPC's stock or its bind. let is_vendor = vendor_has_stock(store, npc)?; let is_innkeeper = store.npc_is_innkeeper(npc)?; let options = codec::gossip_menu_options( @@ -311,8 +237,7 @@ impl< is_vendor, is_innkeeper, ); - // Snapshot what this client is about to look at — the select handler resolves the clicked - // POSITION against this, never against a fresh read (see `GossipMenuSnapshot`). + // Refiltering can change which option a client list position names. session.gossip_menu = Some(GossipMenuSnapshot { npc_guid: npc, options: options.iter().map(|o| (o.row_id, o.action)).collect(), @@ -327,37 +252,23 @@ impl< )), ))); } - // The client resolves a gossip/quest title text id (sent in SMSG_GOSSIP_MESSAGE) → reply with - // the NPC's imported (weighted) text, or the generic greeting when none is - // imported yet. ClientOpcodeMessage::CMSG_NPC_TEXT_QUERY(q) => { let view = store.npc_text_for_id(q.text_id); outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_NPC_TEXT_UPDATE( Box::new(codec::build_npc_text_update(q.text_id, view.as_ref())), ))); } - // The player clicked a gossip option. The click carries a POSITION, resolved against the - // snapshot HELLO took (`GossipMenuSnapshot`), then routed by ACTION: vendor → inventory, - // innkeeper → bind_home, trainer → SMSG_TRAINER_LIST, taxi → the same cohesive - // open operation as CMSG_TAXIQUERYAVAILABLENODES, banker → SMSG_SHOW_BANK, everything - // else including the trailing Farewell → SMSG_GOSSIP_COMPLETE. Submenu navigation is - // deferred (`action_menu_id` stays inert). ClientOpcodeMessage::CMSG_GOSSIP_SELECT_OPTION(c) => { let npc = c.guid.guid(); let player_guid = actor.map_or(0, Actor::guid); - // A click naming an NPC other than the one the open menu belongs to is stale (the client - // sends HELLO before it can show a menu), so it selects nothing. let clicked = session .gossip_menu .as_ref() .filter(|snap| snap.npc_guid == npc) .and_then(|snap| snap.options.get(c.gossip_list_id as usize)) .copied(); - // The module is told the clicked row's `row_id`, not its position: a position is - // per-viewer (a cond-gated row renumbers it), so it identifies nothing to a package. + // A filtered list position is viewer-local; Package hooks need the durable row_id. let option_row_id = clicked.map_or(codec::SYNTHESIZED_ROW_ID, |(row_id, _)| row_id); - // Notify the module (the on_gossip_select hook chokepoint) — best-effort, - // so a module hiccup never blocks the gossip reply below. if let Some(actor) = actor { settle_per_action( "gossip_select", @@ -367,8 +278,6 @@ impl< } use lyracore_shared::constants::gossip_option; match clicked.map(|(_, action)| action) { - // The gossip click does not run the interaction gate — it never has: only the - // direct CMSG_LIST_INVENTORY open does. Some(gossip_option::VENDOR) => { for message in vendor_open_outbound(store, npc)? { outbound.push(message); @@ -423,14 +332,9 @@ impl< outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)); outbound.push(super::guild::petition_showlist(npc)); } - // Plain-GOSSIP/submenu-link, the trailing Farewell, or a click with no live snapshot - // behind it — close the window. _ => outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE)), } } - // Item template resolution: the client queries an item it has encountered - // (it holds the object) for its name/tooltip/icon. Always reply — `build_item_query_response` - // emits a NotFound (`found: None`) for an unknown entry so the client stops re-asking. ClientOpcodeMessage::CMSG_ITEM_QUERY_SINGLE(q) => { let resp = Box::new(codec::build_item_query_response( q.item, @@ -440,7 +344,6 @@ impl< ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(resp), )); } - // Speech and dot-commands share the opcode with Realm Chat Lines and Auto-Replies. ClientOpcodeMessage::CMSG_MESSAGECHAT(c) => { if !matches!( &c.chat_type, @@ -469,8 +372,6 @@ impl< ))); } } - // The GM reads the Module's Refusal text verbatim. Any other failure ends the - // World Session. CMSG_MESSAGECHAT_ChatType::Say if message.starts_with('.') => { let reply = match actor { Some(actor) => { @@ -510,7 +411,6 @@ impl< message, )?); } - // `/e` custom emote: same broadcast path as Say/Yell, EMOTE type. CMSG_MESSAGECHAT_ChatType::Emote => { outbound.extend(speak_nearby( store, @@ -523,10 +423,6 @@ impl< _ => return Err(anyhow!("non-Speech chat request bypassed Chat")), } } - // Social tier: a text emote (/dance, /wave, …) → send_emote (insert a broadcast - // game_emote_event the gateway fans back as SMSG_TEXT_EMOTE + SMSG_EMOTE). The client supplies - // the social-emote id, the animation, and its selected target (0 guid = untargeted); the - // gateway resolves the target guid to a name on relay. A Refusal is dropped. ClientOpcodeMessage::CMSG_TEXT_EMOTE(c) => { if let Some(actor) = actor { settle_per_action( @@ -536,8 +432,6 @@ impl< )?; } } - // /roll is a Group Broadcast: the party authority draws the result and sends it to every - // group member on any shard, or to the roller alone when ungrouped. ClientOpcodeMessage::MSG_RANDOM_ROLL(r) => { let op = party::Op::RandomRoll { min: r.minimum, diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 04af61b6a..049f1457c 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -1,12 +1,3 @@ -//! Quest family: the overhead `!`/`?` status, the quest-giver menu, the details and definition -//! screens, accept, the turn-in round trip, log abandon and party sharing all enter through -//! `Quest::handle`; the gameobject giver, the item-started quest, the world-entry -//! descriptor block and the gossip quest section call the shared builders here rather than -//! reaching for the store. Every quest read and reducer the world session needs lives on -//! `QuestActionStore`; no other Store family carries them. The stdb-tier relays in -//! `subscriptions.rs` (quest-log sync, the shared-quest details screen) sit below the session and -//! render their own copies from the same `codec` builders these functions use. - use super::super::*; use crate::stdb::{classify, DurableFailure}; use crate::world::family::{ @@ -14,36 +5,21 @@ use crate::world::family::{ }; use wow_world_messages::vanilla::QuestItem; -/// The durable reads and reducer calls the quest family needs, in the seam's own vocabulary so it -/// can be exercised without the broad `WorldStore`. pub(crate) trait QuestActionStore: Send + Sync { - /// Every quest `giver_guid` offers or completes for `player_guid` — the input to both the - /// overhead status icon and the menu. Resolves a creature giver or a gameobject giver. fn giver_quest_evals( &self, giver_guid: u64, player_guid: u64, ) -> Result>; - /// A quest's detail view (the details / offer-reward / request-items screens). `None` if the - /// quest is not loaded. fn quest_detail_view(&self, quest_id: u32) -> Result>; - /// Whether standing (Unfriendly or below) makes this giver refuse to talk. fn giver_refuses_interaction(&self, giver_guid: u64, player_guid: u64) -> Result; - /// Open the quest log row for `quest_id` offered by `giver_guid`. The module gates it, so a - /// refusal here is a gameplay answer, not a broken session. fn accept_quest(&self, actor: Actor, giver_guid: u64, quest_id: u32) -> Result<()>; - /// The quest the item in `slot` starts, as `(item instance guid, quest id)`. `None` when the - /// item starts no quest — an item is its own quest giver, hence the instance guid. fn item_start_quest(&self, owner_guid: u64, slot: u8) -> Option<(u64, u32)>; - /// Hand a completed quest in to `giver_guid` for its rewards. The module validates completion - /// and grants money/XP/items; `reward_index` is the player's pick-1-of-N choice reward slot, - /// ignored by quests with no choice rewards. A quest that sends a Reward Letter files it as - /// Escrow in the same transaction. fn turn_in_quest( &self, actor: Actor, @@ -52,27 +28,15 @@ pub(crate) trait QuestActionStore: Send + Sync { reward_index: u32, ) -> Result<()>; - /// The player's active quests as quest-log descriptor slots (the L window), in slot order. - /// Empty if none. The one read behind both abandon's slot→quest resolution and the world-entry - /// descriptor block, so a slot means the same quest in both. fn player_quest_log(&self, player_guid: u64) -> Result>; - /// Abandon an active quest (`CMSG_QUESTLOG_REMOVE_QUEST`). The module deletes the quest-log row; - /// the relay clears the slot. fn abandon_quest(&self, actor: Actor, quest_id: u32) -> Result<()>; - /// Share `quest_id` with the caller's party (`CMSG_PUSHQUESTTOPARTY`). The module validates - /// grouped + actively-on-the-quest and pushes the per-member `QUEST_SHARE`/`QUEST_PUSH_RESULT` - /// events itself; a gameplay `Err` is per-action, not session-fatal. fn push_quest(&self, actor: Actor, quest_id: u32) -> Result<()>; - /// `(taken, rewarded)` for `quest_id` in `player_guid`'s quest log — feeds the - /// QUEST_TAKEN/QUEST_REWARDED gossip option conditions. fn quest_status(&self, player_guid: u64, quest_id: u32) -> (bool, bool); } -/// A Refusal of a best-effort quest request is logged and dropped. A Transport Loss ends the -/// World Session. fn ignore_refusal(result: Result<()>, operation: &str, account_id: u64) -> Result<()> { match result { Err(e) if matches!(classify(&e), DurableFailure::TransportLoss) => Err(e), @@ -84,13 +48,6 @@ fn ignore_refusal(result: Result<()>, operation: &str, account_id: u64) -> Resul } } -/// The quest menu for `giver` (a creature OR a gameobject guid — the evals resolve either) against -/// `self_guid`: vanilla "instant quest" (mangos `SendPreparedQuest`) opens a SINGLE menu-worthy -/// quest's screen directly — accept details for a new quest, the reward screen for a finished -/// turn-in, the "not done yet" request-items screen for one in progress — while a giver with several -/// shows the list. `CMSG_QUESTGIVER_HELLO` (creature giver) and `CMSG_GAMEOBJ_USE` on a -/// `go_type::QUESTGIVER` gameobject (the client never sends HELLO for one) both land here, so the -/// two interactions cannot drift apart. The evals are read ONCE and reused for the whole decision. pub(crate) fn quest_giver_menu( store: &St, giver: u64, @@ -116,8 +73,6 @@ pub(crate) fn quest_giver_menu( .iter() .find(|e| e.quest_id == detail.quest_id && e.role == codec::ROLE_END && e.active); Ok(match turn_in { - // A new quest: DETAILS is raw-encoded because its 1.12 reward triples are incomplete in - // gtker. The turn-in screens below are typed. None => { let (opcode, body) = codec::build_quest_details_raw(giver, &detail); vec![Outbound::Raw { opcode, body }] @@ -135,10 +90,6 @@ pub(crate) fn quest_giver_menu( }) } -/// The details + Accept screen for one quest, offered by `giver`. Clicking a quest in a giver's -/// menu and using an item that starts a quest both land here, so the two routes cannot render -/// different screens for the same quest. A quest whose definition is not loaded renders nothing. -/// RAW-encoded for the same reason the menu is: gtker's 1.12 reward triples are incomplete. pub(crate) fn quest_details_screen( store: &St, giver: u64, @@ -152,10 +103,6 @@ pub(crate) fn quest_details_screen( })) } -/// The screen a `CMSG_USE_ITEM` opens when that item starts a quest — the item instance is its own -/// giver. `None` means the item starts no quest, which is the item family's signal to fall through -/// to the ordinary use path; `Some` means the quest module consumed the action, so the item is -/// never used up by opening its own quest. pub(crate) fn item_started_quest( store: &St, session: &ProtocolSession, @@ -170,9 +117,6 @@ pub(crate) fn item_started_quest( Ok(Some(quest_details_screen(store, item_guid, quest_id)?)) } -/// The quest section of a combined gossip menu for `npc` against `self_guid` — the same evaluation -/// and the same menu-item derivation `quest_giver_menu` uses, so a gossip-flagged questgiver can -/// never show different quest icons than `CMSG_QUESTGIVER_HELLO` would. pub(crate) fn gossip_quest_items( store: &St, npc: u64, @@ -183,8 +127,6 @@ pub(crate) fn gossip_quest_items( )) } -/// `(taken, rewarded)` for `quest_id` in `player_guid`'s quest log — the read behind the -/// QUEST_TAKEN/QUEST_REWARDED gossip option conditions that gate a gossip menu row. pub(crate) fn quest_gate_state( store: &St, player_guid: u64, @@ -193,7 +135,6 @@ pub(crate) fn quest_gate_state( store.quest_status(player_guid, quest_id) } -/// Both opening a turn-in and continuing its progress screen use the current durable evaluation. fn quest_reward_screen( store: &St, giver: u64, @@ -223,7 +164,6 @@ fn quest_reward_screen( Ok(ProtocolReply::from(vec![Outbound::One(screen)])) } -/// Map quest requests and durable outcomes to client replies. pub(crate) struct Quest; impl ProtocolFamily for Quest { @@ -238,7 +178,6 @@ impl ProtocolFamily for Quest { }; let self_guid = actor.guid(); match msg { - // The client polls each nearby questgiver for its overhead icon (`!` available / `?` turn-in). ClientOpcodeMessage::CMSG_QUESTGIVER_STATUS_QUERY(q) => { let giver = q.guid.guid(); let status = codec::quest_giver_status(&store.giver_quest_evals(giver, self_guid)?); @@ -248,9 +187,6 @@ impl ProtocolFamily for Quest { )), )])) } - // Right-click a questgiver → the quest menu (every quest it offers/completes for this - // player). An Unfriendly-or-below giver refuses it and answers nothing at all. A - // QUESTGIVER GameObject reaches the same `quest_giver_menu` helper from session dispatch. ClientOpcodeMessage::CMSG_QUESTGIVER_HELLO(h) => { let giver = h.guid.guid(); let refuses = store.giver_refuses_interaction(giver, self_guid)?; @@ -260,15 +196,9 @@ impl ProtocolFamily for Quest { quest_giver_menu(store, giver, self_guid)? })) } - // Clicked a quest in the menu → its details + Accept button. ClientOpcodeMessage::CMSG_QUESTGIVER_QUERY_QUEST(q) => Ok(ProtocolReply::from( quest_details_screen(store, q.guid.guid(), q.quest_id)?, )), - // The client asks for a quest's full definition (it sends this for any quest id it sees in a - // PLAYER_QUEST_LOG slot but has no data for). Without this reply the client won't - // display/count the quest in its log — so this is what makes the quest-log window entry - // actually appear. RAW-encoded: gtker's typed layout writes the rep Faction fields as u16, - // shifting the title by 4 bytes; the hand-rolled body matches the 5875 layout exactly. ClientOpcodeMessage::CMSG_QUEST_QUERY(q) => { let outbound = store @@ -279,8 +209,6 @@ impl ProtocolFamily for Quest { }); Ok(ProtocolReply::from(outbound)) } - // Clicked Accept → the module opens the quest log row (gated). No SMSG on success: the - // client closes the window itself and the quest-log relay carries the new slot. ClientOpcodeMessage::CMSG_QUESTGIVER_ACCEPT_QUEST(a) => { ignore_refusal( store.accept_quest(actor, a.guid.guid(), a.quest_id), @@ -295,14 +223,8 @@ impl ProtocolFamily for Quest { ClientOpcodeMessage::CMSG_QUESTGIVER_REQUEST_REWARD(c) => { quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) } - // Chose the reward → the module grants money/XP/items (gated on completion). The durable - // turn-in is requested BEFORE any outbound is built, so a refused turn-in can never show a - // "Quest Complete" popup for rewards the player did not get. A refusal instead re-opens - // the current offer when its details remain readable, so the player can correct the choice. ClientOpcodeMessage::CMSG_QUESTGIVER_CHOOSE_REWARD(c) => { match store.turn_in_quest(actor, c.guid.guid(), c.quest_id, c.reward) { - // The popup echoes the definition's XP/money/items, so what it shows matches what - // the module granted. Unreadable details drop it — the turn-in already happened. Ok(()) => Ok(ProtocolReply { outbound: match store.quest_detail_view(c.quest_id)? { Some(detail) => vec![Outbound::One( @@ -334,11 +256,6 @@ impl ProtocolFamily for Quest { Err(e) => Err(e), } } - // Abandon a quest from the log ("Abandon Quest"). The payload is a LOG SLOT (0..19), not a - // quest id — resolve it against the same `player_quest_log` ordering the world-entry block - // reads, then request the durable abandon. A slot that is not currently in the log (stale - // window, typo'd click) resolves to nothing and requests nothing — it cannot abandon an - // arbitrary quest. No SMSG on success: the quest-log relay re-sends the cleared block. ClientOpcodeMessage::CMSG_QUESTLOG_REMOVE_QUEST(r) => { if let Some(s) = store .player_quest_log(self_guid)? @@ -353,9 +270,6 @@ impl ProtocolFamily for Quest { } Ok(ProtocolReply::from(Vec::new())) } - // Share a quest with the party (`CMSG_PUSHQUESTTOPARTY`). The module validates - // grouped + actively-on-the-quest and pushes the per-member `QUEST_SHARE`/`QUEST_PUSH_RESULT` - // events itself (relayed by `subscriptions.rs`'s `on_group_event`); no direct SMSG here. ClientOpcodeMessage::CMSG_PUSHQUESTTOPARTY(p) => { ignore_refusal( store.push_quest(actor, p.quest_id), @@ -381,9 +295,6 @@ mod tests { CMSG_QUEST_QUERY, }; - /// One durable call of the turn-in round trip, recorded in the order the seam made it. The - /// completion popup is built from `Detail`, so a `TurnIn` recorded ahead of it is the proof - /// that no screen can claim completion before the module granted it. #[derive(Clone, Debug, PartialEq, Eq)] enum TurnInCall { TurnIn { @@ -416,12 +327,10 @@ mod tests { abandon_error: Option, push_error: Option, quest_status: (bool, bool), - /// Every durable call and read fails with a Transport Loss. transport_lost: bool, } impl InMemoryQuestActions { - /// The Module's answer to a durable request: a Refusal with `reason`, or success. fn answer(&self, operation: &str, reason: &Option) -> Result<()> { if self.transport_lost { return Err(ReducerCallError::transport_lost(operation).into()); @@ -588,7 +497,6 @@ mod tests { ClientOpcodeMessage::CMSG_QUESTLOG_REMOVE_QUEST(CMSG_QUESTLOG_REMOVE_QUEST { slot }) } - /// One giver↔quest relation. `startable` follows the role, the way the module evaluates it. fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQuestEval { codec::GiverQuestEval { quest_id, @@ -621,8 +529,6 @@ mod tests { } } - /// One quest-log slot. `counts`/`state`/`timer` are irrelevant to slot resolution, so every - /// fixture leaves them at their zero value. fn log_slot(slot: u8, quest_id: u32) -> codec::update_mask::QuestLogSlot { codec::update_mask::QuestLogSlot { slot, @@ -633,8 +539,6 @@ mod tests { } } - /// A giver with exactly one menu-worthy quest whose details are loaded — the "instant quest" - /// fixture the three single-quest screens differ only by eval state on. fn one_quest(role: u8, active: bool, complete: bool) -> InMemoryQuestActions { InMemoryQuestActions { evals: vec![eval(QUEST, role, active, complete)], @@ -643,8 +547,6 @@ mod tests { } } - /// A store where only `QUEST` has a loaded definition — the fixture the details, definition - /// and accept routes differ on by the quest id they ask for. fn loaded_quest() -> InMemoryQuestActions { InMemoryQuestActions { details: vec![detail(QUEST)], @@ -656,7 +558,6 @@ mod tests { reply.outbound } - /// The same client-visible traffic: same order, same messages, same raw bytes. fn same_batch(a: &[Outbound], b: &[Outbound]) -> bool { a.len() == b.len() && a.iter().zip(b).all(|pair| match pair { @@ -675,8 +576,6 @@ mod tests { }) } - // ── Overhead status ────────────────────────────────────────────────────── - #[test] fn a_status_query_answers_one_status_for_the_polled_giver() { let actions = InMemoryQuestActions { @@ -697,8 +596,6 @@ mod tests { ); } - // ── The giver menu ─────────────────────────────────────────────────────── - #[test] fn a_refusing_giver_answers_nothing_and_its_quests_are_never_read() { let actions = InMemoryQuestActions { @@ -771,7 +668,6 @@ mod tests { [Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_QUEST_LIST(l))] if l.npc == Guid::new(GIVER) && l.quest_items.len() == 2 )); - // No single quest to open, so no details screen was even considered. assert!(actions.detail_requests.lock().unwrap().is_empty()); } @@ -791,8 +687,6 @@ mod tests { #[test] fn the_menu_evaluates_the_giver_once_for_the_whole_screen_decision() { - // The screen choice needs both the menu list and the turn-in state; reading the giver twice - // would let those two answers disagree inside one request. let actions = one_quest(codec::ROLE_END, true, true); run_quest(&actions, session(), hello(GIVER)).unwrap(); @@ -806,8 +700,6 @@ mod tests { #[test] fn a_gameobject_giver_opens_the_same_screen_as_a_creature_giver() { - // The client never sends HELLO for a gameobject giver, so `GameObject::handle` calls the menu - // directly. Same state, same screen — only the giver guid in the body differs. let actions = one_quest(codec::ROLE_START, false, false); let creature = outbound(run_quest(&actions, session(), hello(GIVER)).unwrap()); @@ -821,8 +713,6 @@ mod tests { )); } - // ── The details screen and the definition query ────────────────────────── - #[test] fn a_details_request_opens_the_whole_screen_for_the_asking_giver() { let actions = loaded_quest(); @@ -868,8 +758,6 @@ mod tests { assert!(batch.is_empty()); } - // ── Accept ─────────────────────────────────────────────────────────────── - #[test] fn accept_requests_the_durable_accept_for_the_player_giver_and_quest() { let actions = loaded_quest(); @@ -910,8 +798,6 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── The item-started quest ─────────────────────────────────────────────── - #[test] fn an_item_that_starts_a_quest_opens_its_details_with_the_item_as_the_giver() { let actions = InMemoryQuestActions { @@ -945,8 +831,6 @@ mod tests { #[test] fn an_item_starting_an_unloaded_quest_still_belongs_to_the_quest_module() { - // The empty batch is what keeps the item from being consumed: the item family only falls - // through on `None`, and a missing definition is not a reason to eat the item. let actions = InMemoryQuestActions { start_quest: Some((ITEM_GIVER, QUEST + 1)), ..loaded_quest() @@ -987,8 +871,6 @@ mod tests { assert!(same_batch(&from_giver, &from_item)); } - // ── The quest log: abandon and the world-entry descriptor block ────────── - #[test] fn abandon_resolves_the_log_slot_to_a_quest_id_and_requests_the_durable_abandon() { let actions = InMemoryQuestActions { @@ -1010,8 +892,6 @@ mod tests { #[test] fn abandon_of_a_slot_not_in_the_log_requests_nothing() { - // A stale window or a desynced client can send a slot the log doesn't currently hold — that - // must resolve to nothing, so it can never abandon whatever quest a live slot happens to hold. let actions = InMemoryQuestActions { quest_log: vec![log_slot(3, 777)], ..Default::default() @@ -1081,8 +961,6 @@ mod tests { assert!(actions.push_requests.lock().unwrap().is_empty()); } - // ── World Session context and error classification ────────────────────────────── - #[test] fn a_failed_interaction_gate_read_is_session_fatal() { let actions = InMemoryQuestActions { @@ -1098,8 +976,6 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── The turn-in round trip ─────────────────────────────────────────────── - fn complete_quest(quest_id: u32) -> ClientOpcodeMessage { ClientOpcodeMessage::CMSG_QUESTGIVER_COMPLETE_QUEST(Box::new( CMSG_QUESTGIVER_COMPLETE_QUEST { @@ -1119,7 +995,6 @@ mod tests { )) } - /// A finished turn-in whose quest actually pays, so the popup has something to echo. fn rewarded_turn_in() -> InMemoryQuestActions { InMemoryQuestActions { details: vec![codec::QuestDetailView { @@ -1151,7 +1026,6 @@ mod tests { [Outbound::One(ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(r))] if r.npc == Guid::new(GIVER) && r.quest_id == QUEST )); - // Opening the screen is a read. The rewards are only granted once a reward is chosen. assert!(!actions .turn_in_calls .lock() @@ -1180,14 +1054,11 @@ mod tests { let batch = outbound(run_quest(&actions, session(), complete_quest(QUEST + 1)).unwrap()); assert!(batch.is_empty()); - // No screen to build, so the giver is never evaluated. assert!(actions.eval_requests.lock().unwrap().is_empty()); } #[test] fn choosing_a_reward_turns_the_quest_in_before_any_outbound_is_built() { - // The ordering is the whole point: the popup is built from the detail read, so a turn-in - // recorded ahead of it is what stops a refusal from showing a false "Quest Complete". let actions = rewarded_turn_in(); run_quest(&actions, session(), choose_reward(2)).unwrap(); @@ -1213,8 +1084,6 @@ mod tests { )); } - /// Only a granted turn-in can have filed a Reward Letter, so only it asks the session to drive - /// the Character's held letters. #[test] fn only_a_granted_turn_in_asks_for_the_held_letters_to_be_driven() { let granted = run_quest(&rewarded_turn_in(), session(), choose_reward(0)); @@ -1307,8 +1176,6 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── Party sharing ──────────────────────────────────────────────────────── - fn push_to_party(quest_id: u32) -> ClientOpcodeMessage { ClientOpcodeMessage::CMSG_PUSHQUESTTOPARTY(CMSG_PUSHQUESTTOPARTY { quest_id }) } @@ -1356,12 +1223,8 @@ mod tests { assert!(matches!(classify(&error), DurableFailure::TransportLoss)); } - // ── The gossip quest section ──────────────────────────────────────────── - #[test] fn gossip_quest_items_matches_the_giver_menus_items_for_the_same_evaluation() { - // Both reads come from the same eval + the same `codec::quest_menu_items` derivation, so a - // gossip-flagged questgiver's menu icon can never drift from what HELLO would show. let actions = InMemoryQuestActions { evals: vec![ eval(QUEST, codec::ROLE_START, false, false), @@ -1395,8 +1258,6 @@ mod tests { .is_empty()); } - // ── The gossip quest gate ─────────────────────────────────────────────── - #[test] fn quest_gate_state_answers_untaken_for_a_quest_never_seen() { let actions = InMemoryQuestActions::default(); @@ -1427,16 +1288,10 @@ mod reward_letter_durable_tests { use crate::durable_test_support::Standalone; use crate::stdb::Coordinator; - /// The Module's reward letter fixture: Character 1 holds completed quest 509091, shaped like 3645, - /// Membership Card Renewal, at a clone of creature 620. The quest ender sends one Tempered - /// Blade (5090050, max durability 70) after 86,400 s. const TESTER: u64 = 1; const GIVER: u64 = 17_379_390_972_441_394_945; const CARD_QUEST: u32 = 509_091; - /// The turn-in answers on the visibility pipe, whose row callbacks run before its reducer - /// callback, so the escrow index already names the new letter when the session drives it. On a - /// single-database realm the drive delivers it on the same database. #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn the_drive_right_after_a_turn_in_finds_and_delivers_its_reward_letter() { diff --git a/gateway/src/world/handlers/taxi.rs b/gateway/src/world/handlers/taxi.rs index c83529d17..4398661f8 100644 --- a/gateway/src/world/handlers/taxi.rs +++ b/gateway/src/world/handlers/taxi.rs @@ -1,6 +1,3 @@ -//! Taxi query dispatcher. The store operations are cohesive module requests: handlers never read -//! catalogue, discovery, range, or topology tables and therefore cannot fork module policy. - use super::super::*; use crate::world::family::{ ProtocolFamily, ProtocolReply, ProtocolRequest, ProtocolSession, WorldSessionAction, @@ -70,8 +67,6 @@ fn activate_taxi_outbound( )) } -/// The single gateway entry to the module's open operation. Both the direct taxi query and TAXI -/// gossip selection call this exact function. No Character yet gets no window. pub(crate) fn open_taxi_outbound( store: &St, actor: Option, @@ -140,10 +135,7 @@ pub(super) mod tests { pub(crate) map: Mutex>, pub(crate) activation: Mutex, pub(crate) activation_inputs: Mutex>, - /// Every Durable Request fails with this error. pub(crate) fail: Option anyhow::Error>, - /// The one (source, destination) node pair the Module accepts; any other pair answers - /// `ACTIVATE_NO_SUCH_PATH`. `None` accepts every pair. pub(crate) route: Option<(u32, u32)>, } @@ -356,7 +348,6 @@ pub(super) mod tests { } } - /// A taxi answer arrives only after the request commits, so any failure ends the World Session. #[test] fn a_refusal_ends_the_world_session() { let store = InMemoryTaxiActions { diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index 3843abe74..513f5eb61 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -1,47 +1,25 @@ -//! The Trade Session handshake, initiate / begin / cancel. The module owns -//! every gate; ALL statuses, to both parties, ride the `game_trade_event` relay -//! (`stdb::subscriptions::trade_event_outbound`), so no arm here answers synchronously. Reducer -//! Refusals are transient per-action failures (logged, never session-fatal), the vendor-arm -//! convention. A Transport Loss ends the World Session. Outside the world there is no Actor to act -//! as, so the opcodes are dropped silently, like the social family. - use super::super::*; use super::combat::ignore_refusal; -/// The Trade Session handshake. Every status, BeginTrade/OpenWindow to the parties, or a refusal -/// back to the caller, rides the `game_trade_event` relay; these calls answer nothing -/// synchronously, and a Refusal is a per-action failure (log + ignore). pub(crate) trait TradeStore: Send + Sync { - /// `CMSG_INITIATE_TRADE` — propose a Trade Session against the targeted player. fn initiate_trade(&self, actor: Actor, target_guid: u64) -> Result<()>; - /// `CMSG_BEGIN_TRADE` — the proposed target's client answered; the module opens both windows. fn begin_trade(&self, actor: Actor) -> Result<()>; - /// `CMSG_CANCEL_TRADE` — tear the caller's Trade Session down (`TradeCanceled` to both). fn cancel_trade(&self, actor: Actor) -> Result<()>; - /// `CMSG_SET_TRADE_ITEM` — `inv_slot` is the ABSOLUTE inventory slot (the gateway maps the - /// client's (bag, slot) pair, the item-family convention). fn set_trade_item(&self, actor: Actor, trade_slot: u8, inv_slot: u8) -> Result<()>; - /// `CMSG_CLEAR_TRADE_ITEM`. fn clear_trade_item(&self, actor: Actor, trade_slot: u8) -> Result<()>; - /// `CMSG_SET_TRADE_GOLD`, `copper` is the offered amount. fn set_trade_gold(&self, actor: Actor, copper: u32) -> Result<()>; - /// `CMSG_ACCEPT_TRADE` — accept the current offer; dual-accept runs the atomic Trade Commit - /// module-side. fn accept_trade(&self, actor: Actor) -> Result<()>; - /// `CMSG_UNACCEPT_TRADE`, withdraw an accept; partner hears `BackToTrade`. fn unaccept_trade(&self, actor: Actor) -> Result<()>; - /// `CMSG_BUSY_TRADE`, decline a pending proposal as busy; initiator hears `Busy`. fn busy_trade(&self, actor: Actor) -> Result<()>; - /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. fn ignore_trade(&self, actor: Actor) -> Result<()>; } @@ -66,11 +44,8 @@ impl ProtocolFamily for Trade { (ClientOpcodeMessage::CMSG_CANCEL_TRADE, Some(actor)) => { ("cancel_trade", store.cancel_trade(actor)) } - // Offer mutations. The (bag, slot) pair maps onto the module's absolute slots the - // same way the item family does: only the main pseudo-bag (255) is modelled — items inside - // equipped sub-bags are logged + ignored, matching the item-action dispatcher's posture. (ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(c), Some(actor)) => { - const MAIN_BAG: u8 = 255; // INVENTORY_SLOT_BAG_0 + const MAIN_BAG: u8 = 255; if c.bag != MAIN_BAG { log::debug!( "world: set_trade_item from sub-bag {} ignored (account {account_id})", @@ -91,23 +66,19 @@ impl ProtocolFamily for Trade { "set_trade_gold", store.set_trade_gold(actor, c.gold.as_int()), ), - // Accept / unaccept. The accept body's `unknown1` is padding (vmangos skips it; - // bots set 1) — dropped here, the module needs only who accepted. + // CMSG_ACCEPT_TRADE's unknown1 is padding; vanilla ignores it. (ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_), Some(actor)) => { ("accept_trade", store.accept_trade(actor)) } (ClientOpcodeMessage::CMSG_UNACCEPT_TRADE, Some(actor)) => { ("unaccept_trade", store.unaccept_trade(actor)) } - // Proposal declines: the client auto-answers a BeginTrade it can't take, busy - // (already in a dialog) or the initiator is on the ignore list. (ClientOpcodeMessage::CMSG_BUSY_TRADE, Some(actor)) => { ("busy_trade", store.busy_trade(actor)) } (ClientOpcodeMessage::CMSG_IGNORE_TRADE, Some(actor)) => { ("ignore_trade", store.ignore_trade(actor)) } - // Outside the world there is no Actor: the trade opcodes are consumed in silence. ( ClientOpcodeMessage::CMSG_INITIATE_TRADE(_) | ClientOpcodeMessage::CMSG_BEGIN_TRADE diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index 1b972f308..4a326ed77 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -1,35 +1,23 @@ -//! Class-trainer family: open the trainer window and buy spells. Pure code-motion out of -//! `world/mod.rs`. - use super::super::*; use crate::stdb::{classify, DurableFailure}; use lyracore_shared::trainer::TrainerRefusal; use wow_world_messages::vanilla::TrainingFailureReason; -/// Trainer windows, talents, and the action-bar and reputation settings. pub(crate) trait TrainerStore: Send + Sync { - /// Save a reputation-list index, or -1 to clear the watched bar. fn set_watched_faction( &self, actor: Actor, reputation_index: i32, ) -> Result; - /// Does this trainer serve `player_guid`'s class? Gates the window and the "train" gossip - /// option through the same predicate the module buys with. Fail-open on missing data. fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result; - /// The spells a class trainer (`trainer_guid`) teaches, each pre-folded with the player's level + - /// known-state for the `SMSG_TRAINER_LIST` Green/Red/Gray rendering (`CMSG_TRAINER_LIST`). fn trainer_list( &self, player_guid: u64, trainer_guid: u64, ) -> Result>; - /// Buy/learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`). The module gates it - /// (range / level / cost / not-already-known) and a Refusal comes back as an outcome; `Err` means - /// the durable result is unknown. fn buy_trainer_spell( &self, actor: Actor, @@ -37,18 +25,10 @@ pub(crate) trait TrainerStore: Send + Sync { spell_id: u32, ) -> Result; - /// The skill line a trainer offering teaches (`game_trainer_spell.learn_skill_line`), or 0 for an - /// ordinary spell offering. The buy path reads it to tell a SKILL purchase from a SPELL purchase: - /// a riding offering's id is a marker with no Spell.dbc row, so echoing it as a learned spell would - /// push the client something it cannot resolve. 0 on any missing read (fail toward the spell echo, - /// which is the pre-existing behaviour). fn trainer_offer_skill_line(&self, trainer_guid: u64, spell_id: u32) -> u32; - /// Return the `grant_spell_id` for `talent_id` (0 = passive, no ability granted), so the gateway - /// can push `SMSG_LEARNED_SPELL` for ability talents after a successful `learn_talent`. fn talent_grant_spell(&self, talent_id: u32) -> u32; - /// Persist one action-bar button (`CMSG_SET_ACTION_BUTTON`); action 0 clears the slot. fn set_action_button( &self, actor: Actor, @@ -57,8 +37,6 @@ pub(crate) trait TrainerStore: Send + Sync { action_type: u8, ) -> Result<()>; - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`). - /// `reputation_index` is the client's 0..63 rep-array slot, NOT a faction id. fn set_faction_at_war( &self, actor: Actor, @@ -66,40 +44,23 @@ pub(crate) trait TrainerStore: Send + Sync { at_war: bool, ) -> Result; - /// Talent-pane sync after a successful `learn_talent`: `(teach_spell, superseded_prev, - /// points_remaining)` — the rank-spell to relay as LEARNED/SUPERCEDED (the 1.12 TalentFrame - /// derives shown ranks from known rank-spells) and the live PLAYER_CHARACTER_POINTS1 value - /// (earned − spent). `talent_id = 0` → just the points. fn talent_pane_sync(&self, character_guid: u64, talent_id: u32) -> (u32, u32, u32); - /// Sum of the character's spent talent ranks — non-zero gates the login points correction. fn talent_points_spent(&self, character_guid: u64) -> u32; - /// Spend a talent point on `talent_id` (`CMSG_LEARN_TALENT`). The module gates it (points available - /// / max rank / prerequisites); a gameplay `Err` is per-action, not session-fatal. fn learn_talent(&self, actor: Actor, talent_id: u32) -> Result<()>; - /// Quote the Module's current talent reset cost for the confirmation dialog. fn talent_reset_cost(&self, character_guid: u64) -> Option; - /// Confirm a talent reset. The Module checks the trainer, range and price. fn reset_talents(&self, actor: Actor, trainer_guid: u64) -> Result; - /// The rank a trainer offering actually teaches (LearnSpell wrapper → its trigger; a - /// self-contained rank resolves to itself). Mirrors the module's buy-time resolution so - /// SMSG_LEARNED_SPELL books the granted spell, never the wrapper. fn resolve_learn_target(&self, spell_id: u32) -> u32; - /// The KNOWN rank `new_spell` supersedes — Some(prev) drives SMSG_SUPERCEDED_SPELL on a buy. fn superseded_old_rank(&self, new_spell: u32, player_guid: u64) -> Option; - /// A character's live presence `(online, level, class, zone_id)` on THIS Shard only. `None` if - /// the guid doesn't resolve to any character here. fn character_presence(&self, guid: u64) -> Result>; } -/// Settle a Durable Request whose failure the client never hears about: a Refusal is logged and the -/// World Session goes on, and a Transport Loss ends it. pub(super) fn settle_per_action(what: &str, account_id: u64, result: Result<()>) -> Result<()> { match result { Ok(()) => Ok(()), @@ -113,16 +74,12 @@ pub(super) fn settle_per_action(what: &str, account_id: u64, result: Result<()>) } } -/// How the Module answered a trainer purchase. A Refusal is a gameplay answer the client can render; -/// a failed Durable Request is not, so it never reaches here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TrainerBuyOutcome { Learned, Refused(TrainingFailureReason), } -// gtker vanilla carries three failure reasons, so several gameplay Refusals share one. The client -// pre-gates the Learn button on the list's Green state, which is what keeps the collapse cosmetic. impl From for TrainerBuyOutcome { fn from(refusal: TrainerRefusal) -> Self { Self::Refused(match refusal { @@ -137,11 +94,6 @@ impl From for TrainerBuyOutcome { } } -/// Class-trainer family: open the trainer window (`CMSG_TRAINER_LIST` → `SMSG_TRAINER_LIST`, each spell -/// Green/Red/Gray) and learn a spell (`CMSG_TRAINER_BUY_SPELL` → the module buy → -/// `SMSG_TRAINER_BUY_*` + a live `SMSG_LEARNED_SPELL` so it hits the action bar without a relog). -/// Needs the in-world player guid (a trainer is only clicked in-world); in CharSelect the opcodes -/// answer nothing. A buy Refusal is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED`. pub(crate) struct Trainer; impl ProtocolFamily for Trainer { @@ -166,15 +118,12 @@ impl ProtocolFamily f match msg { ClientOpcodeMessage::CMSG_TRAINER_LIST(c) => { let trainer_guid = c.guid.guid(); - // A trainer that dislikes you refuses the window (silent drop). if store .npc_refuses_interaction(trainer_guid, self_guid) .unwrap_or(false) { return Ok(outbound.into()); } - // Wrong class: silent drop, not an empty window — an empty list is indistinguishable - // from a trainer whose offerings were never imported. if !store .trainer_serves(self_guid, trainer_guid) .unwrap_or(true) @@ -182,8 +131,6 @@ impl ProtocolFamily f return Ok(outbound.into()); } let spells = store.trainer_list(self_guid, trainer_guid)?; - // Deliberate simplification: a generic greeting — the per-NPC trainer greeting text is - // a later npc_text slice (same as the vendor's generic gossip line). let list = codec::build_trainer_list( trainer_guid, &spells, @@ -196,37 +143,19 @@ impl ProtocolFamily f ClientOpcodeMessage::CMSG_TRAINER_BUY_SPELL(c) => { let trainer_guid = c.guid.guid(); let spell_id = c.id; - // A Refusal arrives as an outcome. An error leaves the durable result unknown, so it - // ends the session instead of posing as a gameplay answer. match store.buy_trainer_spell(actor, trainer_guid, spell_id)? { TrainerBuyOutcome::Learned => { - // Confirm + push the spell live so it appears on the action bar without a relog. outbound.push(Outbound::One( ServerOpcodeMessage::SMSG_TRAINER_BUY_SUCCEEDED(Box::new( codec::build_trainer_buy_succeeded(trainer_guid, spell_id), )), )); - // RIDING buy: the offering teaches a SKILL, and its trainer-list id is a marker with - // no Spell.dbc row behind it — echoing that as a learned spell would push the client - // an id it cannot resolve. The skill pane already moves on its own, from the live - // `game_player_skill` relay, so a riding purchase needs no spell echo at all. - // Profession offerings keep theirs: the importer synthesizes them with real - // learn-spell ids the client does resolve. + // Riding ids lack Spell.dbc rows; do not send them as learned spells. if store.trainer_offer_skill_line(trainer_guid, spell_id) == lyracore_shared::trainer::RIDING_SKILL_LINE { return Ok(outbound.into()); } - // Book the RESOLVED rank (465), not the wrapper (1875) — the module granted - // the trigger spell; echoing the wrapper put "the spell that teaches Devotion - // Aura" in the player's General tab until relog. - // A RANK UPGRADE (the chain prev is already known) sends SUPERCEDED - // instead — the client REPLACES the old rank's book entry (vanilla) rather - // than stacking "Rank 1" next to "Rank 2". WIRE ORDER: cmangos writes - // old u16 THEN new u16; gtker's field names claim new-first — per the - // field-names-lie precedent we follow cmangos, so `new_spell_id` (the FIRST - // wire slot) carries the OLD rank. If live verify shows the NEW rank - // vanishing instead, swap these two. let resolved = store.resolve_learn_target(spell_id); match store.superseded_old_rank(resolved, self_guid) { Some(old_rank) => { @@ -248,9 +177,6 @@ impl ProtocolFamily f )); } } - // An armor-proficiency purchase widens what this Character may wear, and the - // client only learns that from SMSG_SET_PROFICIENCY. Re-read the spellbook the - // buy just changed and resend the ARMOR mask; the weapon table never moves. if teaches_armor_proficiency(spell_id) || teaches_armor_proficiency(resolved) { @@ -272,15 +198,6 @@ impl ProtocolFamily f } } } - // Spend a talent point (`CMSG_LEARN_TALENT`). The module gates points/prereqs; on success the - // passive aura relay covers stat/buff updates. If this talent also grants a learnable ability - // (`grant_spell_id != 0`), push `SMSG_LEARNED_SPELL` so the action bar is usable without a relog. - // Action-bar persistence: the client sends ONE of these per drag/clear and expects the - // full bar back at login (SMSG_ACTION_BUTTONS). Unhandled until now — every bar change - // was lost on relog (only the creation-seeded buttons survived; user find via a - // talent-learned Consecration vanishing from the bar). `action`+`misc` are the client's - // packed u24 payload (spell id, or item id spilling into misc); best-effort (a failure - // must never drop the session — the button just won't stick). ClientOpcodeMessage::CMSG_SET_ACTION_BUTTON(c) => { let action = c.action as u32 | ((c.misc as u32) << 16); settle_per_action( @@ -308,7 +225,8 @@ impl ProtocolFamily f outbound.push(Outbound::One( ServerOpcodeMessage::SMSG_SUPERCEDED_SPELL( SMSG_SUPERCEDED_SPELL { - new_spell_id: superseded as u16, // cmangos wire order: OLD rides the first slot + // cmangos wire order puts the old spell first. + new_spell_id: superseded as u16, old_spell_id: teach as u16, }, ), @@ -324,9 +242,6 @@ impl ProtocolFamily f outbound.push(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( Box::new(codec::build_talent_points_values(self_guid, remaining)), ))); - // Spell-modifier mirror: the pick may have applied an A_SPELLMOD - // passive — re-send the aggregated totals so the client's cast bars match - // the server's folded timings immediately (idempotent absolute values). for m in codec::build_spell_modifier_msgs(&store.spell_modifiers(self_guid)) { outbound.push(Outbound::One(m)); @@ -341,8 +256,6 @@ impl ProtocolFamily f } } -/// Does this trainer offering teach an armor proficiency? Both the trainer-list wrapper and the -/// passive it resolves to count, so the check lands whichever id the buy path reports. fn teaches_armor_proficiency(spell_id: u32) -> bool { use lyracore_shared::constants::armor_proficiency::*; @@ -355,18 +268,12 @@ fn teaches_armor_proficiency(spell_id: u32) -> bool { ) } -/// Push the Character's ARMOR `SMSG_SET_PROFICIENCY` from its CURRENT spellbook, so the client -/// re-tints its bags without a relog. Read after the buy: the mask states what the Character knows -/// now, not what the purchase was meant to grant, so a buy the Module only half-applied never -/// tints an item the equip Gate would still refuse. fn append_armor_proficiency( outbound: &mut Vec, store: &St, self_guid: u64, ) -> Result<()> { let learned = store.player_learned_spells(self_guid).unwrap_or_default(); - // `character_presence` is the store's existing class read; a proficiency buy happens twice in a - // character's life, so the lookup is not worth a dedicated one. let Some((_, _, player_class, _)) = store.character_presence(self_guid).ok().flatten() else { return Ok(()); }; diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index 741a8fe35..4a4758c19 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -1,5 +1,3 @@ -//! Protocol responses for services whose durable gameplay is not implemented. - use super::super::*; fn word(opcode: u16, value: u32) -> Outbound { @@ -45,7 +43,6 @@ pub(crate) fn unavailable_outbound( let response = match msg { ClientOpcodeMessage::CMSG_GMTICKET_SYSTEMSTATUS => word(0x021b, 0), ClientOpcodeMessage::CMSG_GMTICKET_GETTICKET => word(0x0212, 10), - // Deletion is idempotent when no ticket exists. Creation never claims success. ClientOpcodeMessage::CMSG_GMTICKET_DELETETICKET => word(0x0218, 9), ClientOpcodeMessage::MSG_LIST_STABLED_PETS(_) | ClientOpcodeMessage::CMSG_STABLE_PET(_) @@ -144,14 +141,11 @@ fn battlefield_refusal() -> Vec { replies } -/// Build 5875 layouts that differ from the protocol library's definitions. pub(crate) fn raw_unavailable_outbound(opcode: u32, body: &[u8]) -> Result>> { match opcode { 0x0205 => { anyhow::ensure!(body.len() >= 19, "short CMSG_GMTICKET_CREATE body"); let tail = after_cstring(after_cstring(&body[17..])?)?; - // Benilla omits the optional harassment transcript. Its size headers are - // untrusted; an unavailable service has no reason to decompress it. anyhow::ensure!( tail.is_empty() || (body[0] == 2 && tail.len() >= 8), "invalid CMSG_GMTICKET_CREATE transcript" @@ -232,8 +226,6 @@ fn page_unavailable(page_id: u32) -> Outbound { } } -/// These receipts do not change the authoritative pose. Movement counters and cinematic -/// cameras have no durable state yet; ordinary movement still crosses the Store Seam. pub(crate) fn is_control_receipt(msg: &ClientOpcodeMessage) -> bool { matches!( msg, diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 1dc500b70..672846ce2 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -1,12 +1,6 @@ -//! Vendor family: opening a vendor's inventory window, buy/sell/buyback/repair, and the buyback-tab -//! view. Every vendor opcode enters through `Vendor::handle`; the gossip Browse-goods click -//! and the world-entry ring replay call the shared builders here rather than reaching for the store. - use super::super::*; use crate::stdb::{classify, DurableFailure}; -/// Durable reads and requests the vendor family needs, in the seam's own vocabulary so it can be -/// exercised without the broad `WorldStore`. pub(crate) trait VendorActionStore: Send + Sync { fn vendor_stock(&self, vendor_guid: u64) -> Result>; @@ -15,40 +9,27 @@ pub(crate) trait VendorActionStore: Send + Sync { fn vendor_buy(&self, actor: Actor, vendor_guid: u64, item_entry: u32, count: u32) -> Result<()>; - /// The player's buyback ring, newest-first: `(item_entry, stack_count, price, random_property_id)` per entry (≤12). fn buyback_slots(&self, player_guid: u64) -> Vec<(u32, u32, u32, u32)>; - /// The three enchant ids of a Random Property, zero where it names none. fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3]; - /// Bag slot of the item instance with `item_guid`. Item guids are globally unique, so no - /// owner check is needed here — the module reducer enforces ownership on the repair call. fn vendor_item_slot(&self, item_guid: u64) -> Option; fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()>; fn vendor_sell(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()>; - /// Re-purchase the ring entry at 0-based `slot` from `vendor_guid`. The gateway maps the wire - /// `BuybackSlot` enum via [`BUYBACK_WIRE_SLOT_BASE`] before calling. fn vendor_buyback(&self, actor: Actor, vendor_guid: u64, slot: u8) -> Result<()>; } -/// Wire slot of the first buyback tab entry (`BuybackSlot::Slot1`); the 12 ring entries render at -/// base + i and a client's slot enum parses back with − base. Both directions share this. pub(crate) const BUYBACK_WIRE_SLOT_BASE: u16 = 69; -/// The ring the client can hold. Slots past the ring's end render cleared, so evictions and shifts -/// need no memory of what the tab showed before. const BUYBACK_SLOTS: u16 = 12; -/// Whether a Module Gate refused the call. Anything else leaves the durable outcome unknown. fn is_refusal(error: &anyhow::Error) -> bool { matches!(classify(error), DurableFailure::Refusal { .. }) } -/// The interaction gate fails open on a Refusal — missing standing data must not lock a player -/// out of a vendor — but a Transport Loss ends the session. fn refuses_interaction( store: &St, player: &ProtocolSession, @@ -71,9 +52,6 @@ fn refuses_interaction( } } -/// The one vendor-window-opening implementation, shared by the gated direct open below and the -/// gossip Browse-goods click (`query.rs`), which does not run the gate — matching each path's -/// pre-seam behavior. Replies RAW because gtker's typed SMSG_LIST_INVENTORY is the tbc/wrath shape. pub(crate) fn vendor_open_outbound( store: &St, vendor_guid: u64, @@ -83,8 +61,6 @@ pub(crate) fn vendor_open_outbound( Ok(vec![Outbound::Raw { opcode, body }]) } -/// Stock presence is the is-vendor signal gossip HELLO synthesizes its Browse-goods row from — -/// no `npc_flags` bit for it. pub(crate) fn vendor_has_stock( store: &St, vendor_guid: u64, @@ -104,8 +80,6 @@ impl ProtocolFamily for Vendor { let msg = request.message()?; let actor = player.self_guid().and_then(Actor::new); match msg { - // A refusing NPC answers nothing at all; an empty stock still answers, or the client waits - // forever on the window it asked for. ClientOpcodeMessage::CMSG_LIST_INVENTORY(c) => { let vendor_guid = c.guid.guid(); if refuses_interaction(store, player, actor, vendor_guid)? { @@ -116,8 +90,6 @@ impl ProtocolFamily for Vendor { vendor_guid, )?)) } - // Successful purchases carry no reply — the item/purse subscriptions deliver the row - // changes; only a rejection needs an explicit client-visible message. ClientOpcodeMessage::CMSG_BUY_ITEM(c) => { let vendor_guid = c.vendor.guid(); let item_entry = c.item; @@ -144,9 +116,6 @@ impl ProtocolFamily for Vendor { }; Ok(ProtocolReply::from(outbound)) } - // CMSG_REPAIR_ITEM carries the item INSTANCE guid, but the module's repair takes the - // inventory SLOT; guid 0 means repair-all, routed to the whole-body slot instead of a - // guid lookup. An unmatched guid (already sold / not ours) is a silent no-op. ClientOpcodeMessage::CMSG_REPAIR_ITEM(c) => { let Some(actor) = actor else { return Ok(ProtocolReply::from(Vec::new())); @@ -176,8 +145,6 @@ impl ProtocolFamily for Vendor { Err(e) => Err(e), } } - // CMSG_SELL_ITEM carries the item INSTANCE guid; the module's sell takes the inventory - // SLOT. An unmatched guid (already sold / not ours) is a silent no-op, same as repair. ClientOpcodeMessage::CMSG_SELL_ITEM(c) => { let Some(actor) = actor else { return Ok(ProtocolReply::from(Vec::new())); @@ -186,7 +153,6 @@ impl ProtocolFamily for Vendor { return Ok(ProtocolReply::from(Vec::new())); }; match store.vendor_sell(actor, c.vendor.guid(), slot) { - // Reflect the new ring in the buyback tab immediately. Ok(()) => Ok(ProtocolReply::from(build_buyback_view(store, actor.guid()))), Err(e) if is_refusal(&e) => { log::debug!( @@ -198,9 +164,6 @@ impl ProtocolFamily for Vendor { Err(e) => Err(e), } } - // CMSG_BUYBACK_ITEM carries a wire BuybackSlot enum (69–81); map to the 0-based ring slot - // the module reducer takes. A successful re-buy rebuilds the whole tab so shifted and - // cleared entries appear immediately, but only once there is an actor to render it for. ClientOpcodeMessage::CMSG_BUYBACK_ITEM(c) => { let Some(actor) = actor else { return Ok(ProtocolReply::from(Vec::new())); @@ -226,10 +189,6 @@ impl ProtocolFamily for Vendor { } } -/// Rebuild the buyback-tab view: a synthesized ITEM object per ring entry (fabricated guid -/// 0x4090…|slot — a client-only object, never a real instance) and ONE raw VALUES update carrying -/// all 12 VendorBuyback INV_SLOT pointers + BUYBACK_PRICE/TIMESTAMP arrays (those arrays are -/// gtker-walled past slot 0 → the shared raw encoder). Cleared slots write guid 0 / price 0. pub(crate) fn build_buyback_view( store: &St, self_guid: u64, @@ -239,9 +198,6 @@ pub(crate) fn build_buyback_view( render_buyback_view(store, self_guid, &ring) } -/// World-entry replay of the persisted ring. An empty ring renders NOTHING: the client's descriptor -/// fields start zeroed, so a ring-less login stays byte-identical to one with no buyback tab at all. -/// In-session refreshes always render — a ring that just became empty must clear the tab. pub(crate) fn build_buyback_view_replay( store: &St, self_guid: u64, @@ -291,8 +247,7 @@ fn render_buyback_view( } None => (0, 0), }; - // PLAYER_FIELD_INV_SLOT guid pair for the buyback wire slot (base 486, 2 words/slot); - // BUYBACK_PRICE_1 = 1226, BUYBACK_TIMESTAMP_1 = 1238 (5875 indices via gtker impls). + // Build 5875 fields: INV_SLOT base 486, BUYBACK_PRICE_1 1226, BUYBACK_TIMESTAMP_1 1238. mask.set_u64(486 + wire_slot * 2, fab_guid); mask.set_u32(1226 + i, price); mask.set_u32(1238 + i, 0); @@ -312,7 +267,6 @@ pub(super) mod tests { CMSG_REPAIR_ITEM, CMSG_SELL_ITEM, }; - /// How a Fake call fails: a constructor for the typed error, so every call builds a fresh one. pub(crate) type Failure = fn() -> ReducerCallError; #[derive(Debug, Eq, PartialEq)] @@ -335,7 +289,6 @@ pub(super) mod tests { pub(crate) stock_error: Option, pub(crate) gate_error: Option, pub(crate) buy_error: Option, - /// The player's buyback ring; a successful buyback removes the entry it took. pub(crate) ring: Mutex>, pub(crate) random_properties: Vec<(u32, [u32; 3])>, pub(crate) item_slots: Vec<(u64, u8)>, @@ -836,8 +789,6 @@ pub(super) mod tests { const PLAYER_GUID: u64 = 42; - /// The descriptor half of the tab, rebuilt from the ring independently of the seam so the - /// fabricated guids and the price/timestamp indices are pinned, not just echoed. fn expected_values_update(ring: &[(u32, u32, u32, u32)]) -> (u16, Vec) { let mut mask = codec::update_mask::UpdateMaskValues::new(); for i in 0..12u16 { diff --git a/gateway/src/world/handlers/weather.rs b/gateway/src/world/handlers/weather.rs index 854687c8a..d526b2e5b 100644 --- a/gateway/src/world/handlers/weather.rs +++ b/gateway/src/world/handlers/weather.rs @@ -1,26 +1,9 @@ -//! Weather delivery: the Store seam the world session reads a zone's sky through, and the one -//! place a zone id becomes an `SMSG_WEATHER` message. -//! -//! There is no client opcode here. The client never asks for weather and never reports its zone -//! (`CMSG_ZONEUPDATE` stays unhandled — the zone is the Module's answer, resolved from trusted -//! terrain). Weather reaches a client on exactly two paths: world entry, below, and the live -//! `game_zone_weather` relay in `stdb::world_view`. - use super::super::*; pub(crate) trait WeatherStore: Send + Sync { - /// The zone's current sky, or `None` when the zone has no weather at all. Missing data is not - /// a failure: it means fine weather (story 10), and `Err` is reserved for a Store that could - /// not answer. fn zone_weather(&self, zone_id: u32) -> Result>; } -/// The `SMSG_WEATHER` a client in `zone_id` should be holding right now. -/// -/// Total by design: an absent row, an unresolved zone, and a Store that could not answer all mean -/// fine weather, so no weather problem can fail a world entry or a zone crossing. A read failure -/// is logged once at the point it happens rather than propagated, because the caller's only -/// alternative would be to drop the player's login over the sky. pub(crate) fn zone_weather_message( store: &St, zone_id: u32, diff --git a/gateway/src/world/item_tests.rs b/gateway/src/world/item_tests.rs index 4684a44ae..291beba8d 100644 --- a/gateway/src/world/item_tests.rs +++ b/gateway/src/world/item_tests.rs @@ -1,5 +1,3 @@ -//! Item actions through their dispatcher. - use super::handlers::{InMemoryItemActions, Item}; use super::*; @@ -7,7 +5,6 @@ fn player() -> ProtocolSession { ProtocolSession::in_world(7, 1) } -/// Dispatch one item message and return the packets the session would send for it. fn try_run( actions: &InMemoryItemActions, mut player: ProtocolSession, @@ -16,7 +13,6 @@ fn try_run( Ok(Item::handle(actions, &mut player, ProtocolRequest::Message(msg.into()))?.outbound) } -/// Slot 24 is a backpack slot (>= 23); bag 255 = INVENTORY_SLOT_BAG_0 (main bag). fn autoequip(source_slot: u8) -> CMSG_AUTOEQUIP_ITEM { CMSG_AUTOEQUIP_ITEM { source_bag: 255, @@ -26,8 +22,6 @@ fn autoequip(source_slot: u8) -> CMSG_AUTOEQUIP_ITEM { #[test] fn equip_item_err_sends_smsg_inventory_change_failure() { - // A gameplay refusal reaches the client as an SMSG_INVENTORY_CHANGE_FAILURE and the next - // action is served normally. let actions = InMemoryItemActions { equip_result: Some(ItemActionResult::Refused(ItemRefusal::CannotEquip)), ..Default::default() @@ -48,8 +42,6 @@ fn equip_item_err_sends_smsg_inventory_change_failure() { #[test] fn item_action_before_player_login_answers_a_refusal_without_a_durable_request() { - // An item frame arriving after the handshake but before CMSG_PLAYER_LOGIN (no selected - // player) must not panic or error the session. It makes no request and answers a Refusal. let actions = InMemoryItemActions::default(); let player = ProtocolSession::new(7, "TESTER".into()); let sent = try_run(&actions, player, autoequip(24)) @@ -65,8 +57,6 @@ fn item_action_before_player_login_answers_a_refusal_without_a_durable_request() #[test] fn item_reducer_transport_loss_ends_the_world_session() { - // A Transport Loss comes back as an error, which ends the session, instead of being - // translated into gameplay feedback. let actions = InMemoryItemActions { transport_lost: true, ..Default::default() diff --git a/gateway/src/world/login_queue.rs b/gateway/src/world/login_queue.rs index f4db4720d..8b5cb1b46 100644 --- a/gateway/src/world/login_queue.rs +++ b/gateway/src/world/login_queue.rs @@ -3,21 +3,13 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; -/// Default rate-limiter tick for `LYRACORE_ADMIT_CONCURRENCY` (production). Tests inject a much shorter -/// tick via [`LoginQueue::with_tick`] so the rate limiter can be exercised without real sleeps. const ADMIT_TICK: Duration = Duration::from_secs(1); -/// A queued connection's place in line. Opaque — meaningful only as an argument back into the same -/// [`LoginQueue`] that issued it. pub type Ticket = u64; -/// The result of [`LoginQueue::request`]. #[derive(Debug, PartialEq, Eq)] pub enum Admission { - /// A seat was free (or the queue is unlimited): proceed immediately, no wait. Admitted, - /// No seat was free: `Ticket` is this caller's place in the FIFO line. Poll - /// [`LoginQueue::position`] until it returns `None`. Queued(Ticket), } @@ -27,43 +19,26 @@ struct Waiter { } struct State { - /// Sessions currently holding a seat. active: usize, - /// FIFO line, front = next to admit. waiting: VecDeque, next_ticket: Ticket, - /// Rate-limiter bookkeeping for `admit_concurrency > 0` — see `drain_locked`. tick_started: Instant, admitted_this_tick: usize, } -/// FIFO admission gate for world-session establishment. Cheap to construct and cheap to consult -/// when unconfigured: [`LoginQueue::request`] returns `Admitted` before ever touching the mutex -/// when `max_sessions == 0`, so an unconfigured gateway pays nothing for this existing. pub struct LoginQueue { max_sessions: usize, admit_concurrency: usize, tick: Duration, state: Mutex, - /// Cumulative admissions since construction (immediate + drained-from-queue), for the - /// QUEUESTAT admitted/s rate — read-and-diff by the periodic reporting task, same convention as - /// `MOTION_CALLS` et al. in `stdb/subscriptions.rs`. An instance field, not a global static: a - /// bare `static` would leak counts across unrelated `LoginQueue`s (every test that builds its - /// own queue would pollute every other test's rate). admitted_total: AtomicU64, } impl LoginQueue { - /// `max_sessions == 0` disables the gate entirely (today's behavior, byte for byte — see the - /// module docs). `admit_concurrency == 0` disables the extra per-tick rate limit: seats fill as - /// fast as they free, subject only to `max_sessions`. Uses the production 1s tick; see - /// [`Self::with_tick`] for tests. pub fn new(max_sessions: usize, admit_concurrency: usize) -> Self { Self::with_tick(max_sessions, admit_concurrency, ADMIT_TICK) } - /// Like [`Self::new`] with an injectable tick length, so a test exercising the rate limiter - /// doesn't have to sleep a full second per admission. pub fn with_tick(max_sessions: usize, admit_concurrency: usize, tick: Duration) -> Self { Self { max_sessions, @@ -80,18 +55,11 @@ impl LoginQueue { } } - /// The gate every unconfigured gateway gets: `LYRACORE_MAX_SESSIONS` unset ⇒ this, and every - /// existing test/call site that doesn't care about the admission gate passes this to stay - /// byte-identical to the pre-gate behavior. - /// - /// Test-only: a production gateway builds its queue with [`LoginQueue::from_env`], which already - /// yields an unlimited gate when `LYRACORE_MAX_SESSIONS` is unset. #[cfg(test)] pub fn unlimited() -> Self { Self::new(0, 0) } - /// Read both admission limits. Unset or zero means unlimited; malformed values fail startup. pub fn from_env() -> anyhow::Result { Ok(Self::new( crate::config::max_sessions()?, @@ -99,10 +67,6 @@ impl LoginQueue { )) } - /// Ask for a seat. Immediate [`Admission::Admitted`] when the queue is unlimited, or when the - /// line is empty and a seat happens to be free; otherwise joins the FIFO tail — always, even if - /// capacity is technically free, so a new arrival can never jump an existing line (fairness is - /// unconditional; only [`Self::depart`]/[`Self::position`] ever drain it). pub fn request(&self) -> Admission { if self.max_sessions == 0 { return Admission::Admitted; @@ -122,10 +86,6 @@ impl LoginQueue { Admission::Queued(ticket) } - /// 1-based position of `ticket` in line, or `None` once it has been admitted (the caller should - /// stop polling and proceed). Opportunistically drains the front of the line first, so an - /// admission becomes visible to whichever queued connection next calls this — not only to - /// whoever happens to call [`Self::depart`]. pub fn position(&self, ticket: Ticket) -> Option { let mut s = self.state.lock().unwrap(); self.drain_locked(&mut s); @@ -135,17 +95,11 @@ impl LoginQueue { .map(|i| i + 1) } - /// Leave the line without ever taking a seat — the disconnect-while-queued case. A no-op if - /// `ticket` was already admitted (it is simply not found in `waiting` anymore). pub fn cancel(&self, ticket: Ticket) { let mut s = self.state.lock().unwrap(); s.waiting.retain(|w| w.ticket != ticket); } - /// Release a held seat — a world session ended (socket closed), however it ended. A no-op for - /// the unlimited queue. Immediately tries to drain the line into the freed capacity, subject to - /// the rate limit, so admission is responsive rather than waiting for some queued connection's - /// own next poll. pub fn depart(&self) { if self.max_sessions == 0 { return; @@ -155,18 +109,11 @@ impl LoginQueue { self.drain_locked(&mut s); } - /// Force a drain pass with no state change of its own — a backstop so periodic reporting (or - /// anything else with a spare moment) can keep the line moving even if every queued - /// connection's own poll cadence is slow. pub fn tick(&self) { let mut s = self.state.lock().unwrap(); self.drain_locked(&mut s); } - /// Move FIFO-front tickets from `waiting` into `active`. Always bounded by free capacity. - /// ADDITIONALLY bounded by `admit_concurrency` per `tick` when that throttle is configured: a - /// fresh tick resets the per-tick allowance, so demand beyond it waits for the next tick rather - /// than draining in one burst just because many seats are free right now. fn drain_locked(&self, s: &mut State) { if self.admit_concurrency > 0 { let now = Instant::now(); @@ -191,35 +138,27 @@ impl LoginQueue { } } - /// Current queue depth (sessions NOT yet admitted), for QUEUESTAT. pub fn depth(&self) -> usize { self.state.lock().unwrap().waiting.len() } - /// Sessions currently holding a seat. pub fn active(&self) -> usize { self.state.lock().unwrap().active } - /// How long the longest-waiting queued connection has been in line, for QUEUESTAT. `None` when - /// the queue is empty. pub fn oldest_wait(&self) -> Option { let s = self.state.lock().unwrap(); s.waiting.front().map(|w| w.queued_at.elapsed()) } - /// Cumulative admissions since construction, for the QUEUESTAT admitted/s rate (the caller - /// reads this every N seconds and diffs against its own last reading). pub fn admitted_total(&self) -> u64 { self.admitted_total.load(Ordering::Relaxed) } - /// The configured seat cap (`0` = unlimited) — for the startup log line and QUEUESTAT. pub fn max_sessions(&self) -> usize { self.max_sessions } - /// The configured per-tick admission rate limit (`0` = unlimited) — for the startup log line. pub fn admit_concurrency(&self) -> usize { self.admit_concurrency } @@ -229,19 +168,6 @@ impl LoginQueue { mod tests { use super::*; - // =================================================================================== - // The seat CAP itself: the limit that keeps a login storm from taking the realm - // down, and the release paths that keep seats from leaking. - // =================================================================================== - - /// The property the whole cap exists for: however many connections arrive AT ONCE, no more than - /// `max_sessions` of them ever hold a seat. `request()` decides that under the mutex, so this - /// drives 64 threads at a cap of 4 and asserts the ledger reconciles exactly. - /// - /// Deterministic under any thread interleaving: it asserts only invariants - /// (`admitted <= cap`, `active == admitted`, `depth == arrivals - admitted`), never a - /// particular ordering or timing. The counts are read after every thread has joined, so there - /// is no in-flight state to race with. #[test] fn a_simultaneous_login_storm_never_seats_more_than_the_cap() { const CAP: usize = 4; @@ -287,13 +213,6 @@ mod tests { ); } - /// Session cleanup on the release side. `run_world_session_with_queue` calls `depart()` in its - /// teardown for every session that was admitted — including the ones that ended by error or by - /// a severed socket. If a seat could leak, a realm at its cap would refuse logins forever after - /// enough crashed clients, with no way back short of a restart. - /// - /// A full cycle is driven twice over, so the queue is proven to return to EXACTLY its initial - /// state rather than merely to a working one. #[test] fn every_seat_returns_to_the_pool_when_its_session_ends() { let q = LoginQueue::new(3, 0); @@ -302,7 +221,6 @@ mod tests { assert_eq!(q.request(), Admission::Admitted, "round {round}"); } assert_eq!(q.active(), 3); - // The 4th arrival is the one the cap is for; it gives up rather than waiting. let Admission::Queued(t) = q.request() else { panic!("round {round}: the 4th arrival must queue behind the 3 seats"); }; @@ -321,17 +239,12 @@ mod tests { } } - /// A seat must never be released twice. `depart()` is called from session teardown, and this - /// module's own comment notes the care taken that it runs exactly once per admitted session — but - /// `active` is a `usize`, so a double release would underflow to `usize::MAX` and the cap would - /// be permanently, silently unenforceable. The `saturating_sub` is what prevents that; this - /// pins it. #[test] fn releasing_a_seat_more_often_than_it_was_taken_never_underflows_the_cap() { let q = LoginQueue::new(2, 0); assert_eq!(q.request(), Admission::Admitted); q.depart(); - q.depart(); // the spurious one + q.depart(); q.depart(); assert_eq!( q.active(), @@ -339,15 +252,11 @@ mod tests { "an extra release must clamp at zero, not wrap to usize::MAX — a wrapped counter makes \ `active < max_sessions` false forever and every subsequent login queues behind nothing" ); - // And the cap still works afterwards. assert_eq!(q.request(), Admission::Admitted); assert_eq!(q.request(), Admission::Admitted); assert!(matches!(q.request(), Admission::Queued(_))); } - /// `depart()` on the UNLIMITED queue short-circuits before touching the mutex, so the counters - /// stay at zero. Worth pinning because it is the configuration nearly every deployment runs, - /// and because it means the unlimited path costs no lock at all on the disconnect hot path. #[test] fn the_unlimited_queue_takes_no_lock_and_keeps_no_ledger_on_departure() { let q = LoginQueue::unlimited(); @@ -364,12 +273,6 @@ mod tests { ); } - /// `tick()` is the backstop drain: nothing about it changes state on its own, it just gives the - /// line a chance to move when no queued connection happens to be polling and no session happens - /// to be departing. It is called from the periodic QUEUESTAT reporting, and until now nothing - /// exercised it at all. - /// - /// The scenario is the one it exists for: a seat freed while the only waiter is between polls. #[test] fn tick_drains_the_line_into_free_capacity_without_any_other_event() { let q = LoginQueue::new(1, 0); @@ -379,8 +282,6 @@ mod tests { }; assert_eq!(q.position(t), Some(1)); - // Free the seat WITHOUT letting `depart`'s own drain run, by reaching past it: this is the - // state a gateway is in when a session ends and the waiter is asleep between polls. q.state.lock().unwrap().active = 0; assert_eq!( q.depth(), @@ -404,8 +305,6 @@ mod tests { ); } - /// `tick()` on an empty queue, and on an unlimited one, must be a harmless no-op — it runs on a - /// timer, so it runs far more often than there is anything to do. #[test] fn tick_is_inert_when_there_is_nothing_to_drain() { let q = LoginQueue::new(2, 0); @@ -417,10 +316,6 @@ mod tests { assert_eq!((u.active(), u.depth(), u.admitted_total()), (0, 0, 0)); } - /// The configured limits have to be readable back — the startup log line and QUEUESTAT both - /// report them, and an operator who cannot see the cap the gateway actually applied cannot tell - /// a misread env var from a working one. (`LYRACORE_MAX_SESSIONS` is exactly the kind of - /// silently-ignored setting the shard-map vars are also prone to.) #[test] fn the_configured_limits_are_reported_back_verbatim() { let q = LoginQueue::new(250, 8); @@ -432,9 +327,6 @@ mod tests { assert_eq!(u.admit_concurrency(), 0); } - /// Tickets must never be reused. `cancel` removes a waiter but `next_ticket` only ever - /// increases, so a reconnecting client's stale ticket can never match a live one — which would - /// otherwise let it read (or cancel) a stranger's place in line. #[test] fn a_cancelled_tickets_number_is_never_handed_out_again() { let q = LoginQueue::new(1, 0); @@ -450,7 +342,7 @@ mod tests { "ticket {t} was issued twice — a stale ticket would then address a live waiter" ); seen.push(t); - q.cancel(t); // leave immediately, freeing the number for reuse if it were reusable + q.cancel(t); } assert_eq!( seen, @@ -460,9 +352,6 @@ mod tests { assert_eq!(q.depth(), 0, "every one of them left the line"); } - /// `oldest_wait` feeds the QUEUESTAT "longest wait" figure. `None` on an empty line is what - /// tells the reporter there is nothing to report; a zero would read as "someone is waiting, for - /// no time at all". #[test] fn oldest_wait_is_none_when_nobody_is_in_line() { let q = LoginQueue::new(1, 0); @@ -491,16 +380,12 @@ mod tests { for _ in 0..1000 { assert_eq!(q.request(), Admission::Admitted); } - // No bookkeeping at all — active/depth stay at their initial values because `request` - // short-circuits before touching the mutex. assert_eq!(q.depth(), 0); assert_eq!(q.active(), 0); } #[test] fn cap_zero_is_the_unlimited_shape_too() { - // `LoginQueue::new(0, N)` for any N must behave exactly like `unlimited()` — the cap is - // what disables the gate, not the concurrency knob. let q = LoginQueue::new(0, 5); assert_eq!(q.request(), Admission::Admitted); assert_eq!(q.depth(), 0); @@ -509,7 +394,6 @@ mod tests { #[test] fn fifo_order_and_positions() { let q = LoginQueue::new(1, 0); - // Take the only seat directly (simulates another already-admitted session). assert_eq!(q.request(), Admission::Admitted); let Admission::Queued(a) = q.request() else { @@ -531,7 +415,7 @@ mod tests { #[test] fn position_updates_on_admission_in_fifo_order() { let q = LoginQueue::new(1, 0); - assert_eq!(q.request(), Admission::Admitted); // the only seat + assert_eq!(q.request(), Admission::Admitted); let Admission::Queued(a) = q.request() else { panic!() @@ -547,7 +431,6 @@ mod tests { (Some(1), Some(2), Some(3)) ); - // Free the seat: A (the front) must be admitted, not B or C. q.depart(); assert_eq!( q.position(a), @@ -579,7 +462,6 @@ mod tests { }; assert_eq!(q.depth(), 2); - // A disconnects while waiting: it leaves the line and consumes no seat. q.cancel(a); assert_eq!(q.depth(), 1); assert_eq!( @@ -598,7 +480,6 @@ mod tests { "cancelling a WAITING ticket must never free or grant a seat" ); - // Freeing the real seat now admits B, not a phantom slot for the cancelled A. q.depart(); assert_eq!(q.position(b), None); assert_eq!(q.active(), 1); @@ -608,15 +489,10 @@ mod tests { #[test] fn cancelling_an_already_admitted_ticket_is_a_harmless_noop() { let q = LoginQueue::new(0, 0); - // Unlimited: `request()` never mints a real ticket, but a caller that (incorrectly) still - // calls `cancel` on some arbitrary id must not panic or corrupt state. q.cancel(999); assert_eq!(q.depth(), 0); } - /// Fill every seat via the empty-line fast path in `request()` itself (not the drain limiter): - /// each of the first `cap` calls sees an empty line and free capacity, so all `cap` of them are - /// `Admitted` outright. Returns once `active() == cap`. fn fill_to_capacity(q: &LoginQueue, cap: usize) { for _ in 0..cap { assert_eq!(q.request(), Admission::Admitted); @@ -626,8 +502,6 @@ mod tests { #[test] fn admit_concurrency_throttles_the_drain_rate_even_with_free_capacity() { - // Cap 10, full house, then queue three behind it. A long tick so the test can assert the - // throttle actually bound something before the next tick would roll over. let q = LoginQueue::with_tick(10, 1, Duration::from_secs(3600)); fill_to_capacity(&q, 10); let Admission::Queued(a) = q.request() else { @@ -641,9 +515,6 @@ mod tests { }; assert_eq!(q.depth(), 3); - // Simulate 5 near-simultaneous departures (a raid wipe) — plenty of freed capacity (5 of - // 10 seats), but the per-tick allowance is 1: only the FIRST of these five `depart()` calls - // may actually admit anyone; the rest find `admitted_this_tick` already spent. for _ in 0..5 { q.depart(); } @@ -664,7 +535,6 @@ mod tests { "5 seats freed, only 1 refilled: 10 - 5 + 1 = 6" ); - // Polling again within the SAME tick must not admit more. assert_eq!(q.position(b), Some(1)); } @@ -714,7 +584,7 @@ mod tests { fn admitted_total_counts_both_immediate_and_drained_admissions() { let q = LoginQueue::new(1, 0); assert_eq!(q.admitted_total(), 0); - assert_eq!(q.request(), Admission::Admitted); // immediate: +1 + assert_eq!(q.request(), Admission::Admitted); assert_eq!(q.admitted_total(), 1); let Admission::Queued(a) = q.request() else { diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index d0436f8be..d2d6b77c2 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -1,32 +1,23 @@ -//! Realm loot routing. See `docs/realm-loot-routing.md` for the contract and rationale. +//! Before changing Loot Roll promotion or voting, read docs/realm-loot-routing.md. use crate::world::ShardRoutingStore; use anyhow::Result; use super::{Actor, LootActionStatus, WorldStore}; -/// One unresolved loot roll a world shard has created but not yet had promoted onto realm-core — -/// [`LootRollStore::pending_local_rolls`]'s answer, and [`relay_tick`]'s promotion input. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingLootRoll { - /// This shard's own row id — what [`LootRollStore::clear_promoted_loot_roll`] addresses. pub roll_id: u64, pub corpse_guid: u64, pub slot: u8, pub item_entry: u32, pub random_property_id: u32, - /// Preserved verbatim from the local row so promotion never restarts the 60s clock. pub deadline_micros: i64, - /// Every eligible voter, snapshotted at kill time (`game_loot_roll_vote`'s rows for this roll). pub recipients: Vec, pub promotion_source: spacetimedb_sdk::Identity, } -/// Durable Reads and Durable Requests for Loot Rolls: the vote, the master looter's assignment, and -/// the relay that promotes each roll onto Realm-core and settles its winner. pub(crate) trait LootRollStore: Send + Sync { - /// Promote a world shard's staging roll onto the database THIS handle names. Called on the - /// **realm-core** handle; the Gateway acts as the session owner, not as a Character. #[allow(clippy::too_many_arguments)] fn realm_loot_start( &self, @@ -40,8 +31,6 @@ pub(crate) trait LootRollStore: Send + Sync { source_roll_id: u64, ) -> Result<()>; - /// Cast one player vote on realm-core. The Store returns a typed gameplay answer while keeping - /// failures with an unknown durable result as `Err`. fn realm_loot_vote( &self, corpse_guid: u64, @@ -50,29 +39,15 @@ pub(crate) trait LootRollStore: Send + Sync { vote: u8, ) -> Result; - /// Every UNRESOLVED loot roll this WORLD SHARD has created but not yet had promoted onto - /// realm-core — the relay's promotion queue. Empty on realm-core's own handle: nothing is ever - /// created there directly — only `realm_loot_start` writes it, and that is not this - /// method. fn pending_local_rolls(&self) -> Result>; - /// `settle_loot_roll` — grant a resolved roll's item on THIS world shard, if it holds the - /// matching corpse row. A shard that does not hold the corpse is unaffected: the module's own - /// `withheld` guard makes a wrong-shard call harmless. fn settle_loot_roll(&self, corpse_guid: u64, slot: u8, winner_guid: u64) -> Result<()>; - /// `clear_promoted_loot_roll` — delete a staging roll's rows on THIS world shard, once the relay - /// has promoted it onto realm-core. fn clear_promoted_loot_roll(&self, roll_id: u64) -> Result<()>; - // Same shape as `Coordinator::loot_won_since` (watermark + `(corpse, slot, winner)` triples) — the trait mirrors the read it fronts. #[allow(clippy::type_complexity)] - /// Every `ROLL_WON` `game_group_event` row realm-core has pushed with an id greater than - /// `after_id` — `(corpse_guid, slot, winner_guid)` triples, plus the new high-water mark to - /// poll from next. Called on the **realm-core** handle. fn loot_won_since(&self, after_id: u64) -> Result<(u64, Vec<(u64, u8, u64)>)>; - /// `CMSG_LOOT_ROLL` — record the caller's need/greed/pass vote. fn loot_roll( &self, actor: Actor, @@ -81,8 +56,6 @@ pub(crate) trait LootRollStore: Send + Sync { vote: u8, ) -> Result; - /// `CMSG_LOOT_MASTER_GIVE` — the master looter assigns an above- - /// threshold row to `target_guid`. fn loot_master_give( &self, actor: Actor, @@ -92,15 +65,6 @@ pub(crate) trait LootRollStore: Send + Sync { ) -> Result; } -/// Route `CMSG_LOOT_ROLL` for the session that owns `actor`. -/// -/// Unsharded → the pre-realm-core path, verbatim: `loot_roll` on the player's own connection. Sharded → -/// realm-core, where the roll is authoritative once promoted; `actor` is the Character the gateway -/// authenticated for this socket, never the client's own claim (there isn't one — `CMSG_LOOT_ROLL` -/// carries no actor field at all, only the roll's own `(corpse_guid, slot)` and the vote). -/// -/// A client can vote as soon as `SMSG_LOOT_START_ROLL` arrives, before the next [`relay_tick`]. -/// Realm-core refuses a vote for a roll it does not hold, so pending promotions are flushed first. pub(crate) fn run_vote( store: &St, actor: Actor, @@ -115,11 +79,6 @@ pub(crate) fn run_vote( realm.realm_loot_vote(corpse_guid, slot as u8, actor, vote) } -/// Promote ONE world shard's staging roll onto realm-core, then clear the staging copy — the shared -/// core of [`relay_tick`]'s periodic sweep and [`flush_pending_promotions`]'s synchronous, per-op -/// flush. Best-effort: a failure here is logged and left for the periodic relay to retry, never -/// propagated as an error (the caller's own op — a vote route, a party op — must not fail because a -/// DIFFERENT roll's promotion did). fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLootRoll) { match realm.realm_loot_start( roll.corpse_guid, @@ -149,24 +108,6 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo } } -/// Synchronously promote EVERY connected world shard's pending staging rolls onto realm-core. -/// -/// [`run_vote`] calls it before each vote, so realm-core holds the roll the vote names. `party::run` -/// calls it immediately before dispatching a LEAVE/UNINVITE — the two ops that can shrink a group -/// below 2 members and reach `remove_member`'s disband branch. This is what closes the disband race -/// the periodic [`relay_tick`] alone cannot (see this module's doc): by the time -/// `realm_group_op(LEAVE/UNINVITE, ..)` runs right after this returns, every roll that existed -/// anywhere in the realm at that moment is already on realm-core for `remove_member` to see. -/// -/// Every connected world shard, not just the acting character's own — a party's members, and -/// therefore the corpses their kills stage rolls on, can be split across shards (the premise -/// realm-core's group slice established), so narrowing this to one shard would silently reintroduce -/// the class of bug the group slice fixed for invites. -/// -/// Best-effort per shard, the same posture `party::sync_mirrors` documents: a shard that cannot be -/// reached leaves its own pending rolls unpromoted for THIS call, and the periodic relay retries them -/// on its own cadence — a flush that failed must not turn a vote or LEAVE/UNINVITE that would -/// otherwise succeed into an error. pub(crate) fn flush_pending_promotions( store: &St, realm: &dyn WorldStore, @@ -189,22 +130,7 @@ pub(crate) fn flush_pending_promotions( } } -/// One promotion + settlement pass. No-op on an unsharded store (`realm_store()` answers -/// `Ok(None)`), which is what makes running this on a timer free for a single-database gateway. -/// -/// `won_watermark` advances only after every settlement in the batch succeeds on every Shard. -/// A failure leaves the result available to retry while its Realm-core event still exists. -/// A successful grant removes the original loot row, and the Module ignores rows that are not -/// withheld. These guards cannot distinguish a later loot row with the same corpse GUID and slot. -/// -/// Both directions are BEST-EFFORT, deliberately, the same posture `party::sync_mirrors` documents: -/// realm-core has already committed (a promoted roll exists there, or a roll has already resolved) -/// by the time either loop runs, so a failed relay step must not undo or re-litigate that — it just -/// leaves the affected Shard's local state stale. Settlement retries still need the outcome event. -/// -/// Ordinary promotion latency (a roll NOT caught by [`flush_pending_promotions`]) is bounded by this -/// function's own caller's poll interval, not by anything in here — see this module's doc for why -/// that caller is a poll rather than a persistent callback. +/// Advance the win watermark only after every settlement succeeds, or failed wins are lost. pub(crate) fn relay_tick(store: &St, won_watermark: &mut u64) { let realm = match store.realm_store() { Ok(Some(realm)) => realm, @@ -216,9 +142,6 @@ pub(crate) fn relay_tick(store: &St, won_waterma }; flush_pending_promotions(store, realm.as_ref()); - // SETTLEMENT: every `ROLL_WON` realm-core has pushed since the last tick, fanned to every - // connected world shard — `settle_loot_roll`'s own `withheld` guard makes the wrong shards' calls - // harmless no-ops, so this does not need to know in advance which one holds the corpse. match realm.loot_won_since(*won_watermark) { Ok((new_watermark, wins)) => { let mut settled = true; diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index aeb14f59d..0a0f4de71 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -1,12 +1,3 @@ -//! Realm-wide loot rolls — the routing + relay tests. -//! -//! What EXECUTES here is production `world::loot`, against the same in-memory multi-database -//! topology the group/whisper slices use (`party_tests::party_topology`). What the fakes stand in -//! for is named at each seam: `realm_loot_ops` is exactly the tuple the operator-gated -//! `realm_loot_op` reducer was handed, `pending_rolls` is a world shard's own `game_loot_roll` -//! staging table, `settled_rolls`/`cleared_rolls` are what the corresponding operator reducers were -//! asked to do, and `won_events` stands in for realm-core's `game_group_event` `ROLL_WON` queue. - use super::loot; use super::party; use super::party_tests::{form_split_party, party_topology, GINGER, TRIN, VIM}; @@ -73,10 +64,6 @@ fn loot_promotion_waits_for_realm_core_recovery() { assert_eq!(realm.loot_roll.realm_loot_ops.lock().unwrap().len(), 1); } -// ---- `run_vote` (CMSG_LOOT_ROLL routing) ---- - -/// Unsharded → the shard-local loot path, verbatim: the vote runs on the player's own reducer, and -/// realm-core (there isn't one) never hears about it. #[test] fn an_unsharded_store_routes_the_vote_through_the_players_own_reducer() { let store = WorldFake::default(); @@ -99,9 +86,6 @@ fn an_unsharded_store_routes_the_vote_through_the_players_own_reducer() { ); } -/// Sharded → the vote is authorized by the guid the GATEWAY authenticated for this socket -/// (`self_guid`), never a value the client's packet could supply — `CMSG_LOOT_ROLL` carries no actor -/// field at all. #[test] fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { let (realm, world, _instances, _calls) = party_topology(); @@ -137,8 +121,6 @@ fn a_sharded_store_routes_the_vote_to_realm_core_with_the_authenticated_guid() { ); } -/// A client can vote as soon as `SMSG_LOOT_START_ROLL` arrives, before the next relay tick. The -/// vote must reach realm-core after the roll's promotion, or realm-core has no roll to count it on. #[test] fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { let (realm, world, _instances, _calls) = party_topology(); @@ -174,10 +156,6 @@ fn a_vote_on_a_staged_roll_reaches_realm_core_after_its_promotion() { assert_eq!(*world.loot_roll.cleared_rolls.lock().unwrap(), vec![77]); } -// ---- `relay_tick` (promotion + settlement) ---- - -/// Unsharded → no-op, before even reading a shard's pending rolls: `realm_store()` answers `Ok(None)` -/// and `relay_tick` returns before `world_stores()` is ever consulted. #[test] fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() { let peer = std::sync::Arc::new(WorldFake { @@ -216,9 +194,6 @@ fn an_unsharded_store_does_nothing_even_with_pending_rolls_on_a_connected_peer() ); } -/// A world shard's freshly kill-time-created staging roll is promoted onto realm-core — WITH its -/// original deadline (never restarting the 60s clock) and its full recipient snapshot — and then the -/// staging copy is cleared so it cannot ALSO run its own vote/resolution locally. #[test] fn relay_tick_promotes_a_staging_roll_and_clears_it() { let (realm, world, instances, _calls) = party_topology(); @@ -306,9 +281,6 @@ fn a_refused_promotion_keeps_its_source_identity_and_staging_row_for_retry() { assert_eq!(attempts[0].10, 78); } -/// A resolved roll's winner is settled on EVERY connected world shard — the module's own `withheld` -/// guard (not the relay) is what makes the wrong shards' calls harmless, so the relay fans out rather -/// than trying to guess which shard holds the corpse. #[test] fn relay_tick_settles_every_connected_shard_for_a_resolved_roll() { let (realm, world, instances, _calls) = party_topology(); @@ -330,7 +302,6 @@ fn relay_tick_settles_every_connected_shard_for_a_resolved_roll() { "the watermark must advance past the one event just settled" ); - // A second tick with the SAME advanced watermark must not re-settle the same win. loot::relay_tick(world.as_ref(), &mut watermark); assert_eq!( world.loot_roll.settled_rolls.lock().unwrap().len(), @@ -384,19 +355,10 @@ fn a_failed_settlement_retries_after_the_shard_recovers() { ); } -/// **AC: a disband-capable op promotes every connected shard's pending rolls BEFORE it reaches -/// realm-core.** Without this, `remove_member`'s disband branch (on realm-core) cannot see a roll -/// that is still a local staging copy, and the periodic relay's own 200ms cadence is not fast enough -/// to guarantee it lands first — the exact gap "someone gets kicked right after a kill" falls into. -/// -/// The pending roll is staged on `world`, but the LEAVE is dispatched through `instances` (Vim's own -/// connection) — proving the flush covers EVERY connected shard, not just the caller's own, which -/// matters because a party's members (and therefore the corpses their kills stage rolls on) can be -/// split across shards. #[test] fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reaches_realm_core() { let (realm, world, instances, calls) = party_topology(); - form_split_party(&world, &instances); // Ginger (world) + Vim (instances) — a 2-member party + form_split_party(&world, &instances); *world.loot_roll.pending_rolls.lock().unwrap() = vec![loot::PendingLootRoll { promotion_source: SOURCE, roll_id: 99, @@ -408,8 +370,6 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach random_property_id: 0, }]; - // Vim leaving a 2-member party disbands it (vanilla: a party of one is no party) — the exact - // shape `remove_member`'s disband branch, and therefore `force_resolve_rolls_for_disband`, needs. party::run( instances.as_ref(), Actor::new(VIM).unwrap(), @@ -440,12 +400,6 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach "the staging copy must be cleared once promoted, same as a periodic relay tick would" ); - // ORDERING is the whole point: a promotion that landed AFTER the disband would still be an - // orphaned roll naming a group that no longer exists — the disband-mid-roll defect this flush - // exists to close. - // `rposition` for the disband dispatch: `form_split_party` above already made two of its own - // `realm_group_op` calls (the invite, the accept) BEFORE the LEAVE under test — the disband is - // the LAST one in the log, not the first. let log = calls.lock().unwrap().clone(); let flush_at = log .iter() @@ -461,8 +415,6 @@ fn a_leave_flushes_every_connected_shards_pending_rolls_before_the_disband_reach ); } -/// A plain party op that CANNOT disband anything (Accept, Decline, LootMethod, and the surviving half -/// of an Invite) must not pay for a flush it does not need — only LEAVE/UNINVITE do. #[test] fn a_non_disband_capable_op_never_flushes_pending_rolls() { let (realm, world, _instances, _calls) = party_topology(); @@ -535,8 +487,6 @@ fn loot_master_give_dispatches_the_corpse_slot_and_target() { #[test] fn loot_roll_rejection_is_logged_and_ignored_not_session_fatal() { - // A rejection (no roll open / already voted / not eligible) must not tear the connection down — - // the SAME session keeps working afterward (mirrors take_loot's per-action ignore discipline). let mut s = quest_store(); s.loot_roll.loot_action_refusal = Some(LootRefusal::RollUnavailable); let store = std::sync::Arc::new(s); @@ -548,7 +498,6 @@ fn loot_roll_rejection_is_logged_and_ignored_not_session_fatal() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The session survives: a subsequent CMSG_LOOT still gets a normal reply. CMSG_LOOT { guid: Guid::new(61), } diff --git a/gateway/src/world/loot_window_tests.rs b/gateway/src/world/loot_window_tests.rs index 01b329960..2ddcf7795 100644 --- a/gateway/src/world/loot_window_tests.rs +++ b/gateway/src/world/loot_window_tests.rs @@ -1,5 +1,3 @@ -//! GameObject use: its routing by GameObject type over a World Session, and the general-use path. - use super::death_tests::handle_loot_fake::HandleLootFake; use super::death_tests::{in_world_conn, run, SELF_GUID}; use super::*; @@ -34,7 +32,6 @@ fn questgiver_gameobject_bypasses_the_chest_lifecycle() { } other => panic!("expected quest details from a questgiver GameObject, got {other}"), } - // The loot window would have answered before the status query's reply. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} other => panic!("a questgiver GameObject opened a loot window: {other}"), @@ -100,7 +97,6 @@ fn door_gameobject_use_takes_the_general_path_and_opens_no_loot_window() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // A loot window or a take reply would arrive before the status query's answer. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} other => panic!("a door GameObject opened a loot window: {other}"), diff --git a/gateway/src/world/mail.rs b/gateway/src/world/mail.rs index bae9f6d60..979390170 100644 --- a/gateway/src/world/mail.rs +++ b/gateway/src/world/mail.rs @@ -1,7 +1,3 @@ -//! Mail routing and cross-database escrow driving. -//! Sharded moves are fence → commit → attest → settle; local moves stay one transaction. The -//! turn-in files a Reward Letter as Escrow on every plane, and it is driven the same way. - use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; @@ -10,72 +6,21 @@ use crate::codec::MailView; use crate::stdb::{classify, DurableFailure}; use lyracore_shared::mail as mail_rules; -/// Mail rows, Letter Copy text, and the cross-shard mail Escrow steps. pub(crate) trait MailStore: Send + Sync { - /// Every mail addressed to `recipient_guid`, on the database THIS handle names. - /// - /// Called on the realm-core handle when there is one and on the session's own handle when there - /// is not — the two-plane read, which is why this is one method rather than a realm-only twin. - /// The real refusals are the gates in `world::mail`, which run before this. fn mail_list(&self, recipient_guid: u64) -> Result>; - /// The mail `mail_id`, delivered or not, on the database THIS handle names: the same two-plane - /// routing as [`mail_list`](Self::mail_list), as one primary key read. fn mail_by_id(&self, mail_id: u64) -> Result>; - /// The name of the Realm Account that owns `character_guid`, read on THIS handle only. The - /// Account Character Owner names it when this Shard retains one. Otherwise the Character's - /// local Account names it, unless that Account is a shadow Account, whose name is not a Realm - /// Account's. `None` when this handle cannot name it. `world::mail` asks every World Shard, - /// because the Delivery Delay compares the Realm Accounts of two Characters on any Shards. fn realm_account_name(&self, character_guid: u64) -> Result>; - /// Is `player_guid` in range of the gameobject `mailbox_guid` names, and is it a mailbox at all? - /// - /// Always asked of the session's OWN handle: the mailbox is a gameobject on the shard the player - /// is standing on, and realm-core holds none. A PK lookup plus a map/instance/range check — - /// never a scan of the spatial gameobject table. fn mailbox_in_range(&self, mailbox_guid: u64, player_guid: u64) -> Result; - /// Flip `mail_id`'s read state for `recipient`, on the database THIS handle names. - /// - /// Called on the realm-core handle when there is one and on the session's own handle when there - /// is not — the SAME two-plane routing [`mail_list`](Self::mail_list) takes, because the write - /// and the read must never disagree about which database owns the rows. `Err` when `mail_id` - /// does not exist, is not `recipient`'s or has not arrived yet — the gates ran in - /// `world::mail` before this is ever called, so a refusal here means a crafted id or a Gateway - /// clock that runs ahead of the Module's. fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Delete `mail_id` for `recipient`, on the database THIS handle names — same two-plane - /// routing as [`mail_mark_read`](Self::mail_mark_read). Destroys any attachment the row still - /// carries, as vanilla does after its (client-side) confirmation prompt. `Err` for a mail with - /// a cash on delivery price or one that has not arrived yet. fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Return `mail_id` to whoever sent it, on the database THIS handle names — same two-plane - /// routing as [`mail_delete`](Self::mail_delete). The row is re-addressed IN PLACE: it never - /// leaves the plane that already holds it, so there is no sharded variant and no escrow, unlike - /// [`mail_send`](Self::mail_send) and the takes below. `Err` when `mail_id` does not exist, is - /// not `recipient`'s, is not delivered yet, has no Character sender, or was returned - /// already. `same_account` says whether `recipient` and the mail's sender belong to one - /// Realm Account; the Module turns it into the return's Delivery Delay. fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()>; - /// Write one sent letter on the database THIS handle names, charging the sender the postage - /// plus the attached `money` in the SAME transaction. - /// - /// **The single-database gateway only**, where the purse and the row are on one database. A - /// sharded realm cannot have that transaction and drives [`mail_fence`](Self::mail_fence) and - /// friends instead. - /// - /// Every gate that decides who may write to whom has already run in `world::mail` — realm-core - /// can answer none of them — so `sender` must be the guid the socket authenticated. - /// - /// `cod` is the price the RECIPIENT will owe for the attachment. It costs the sender nothing - /// and is not part of the debit; it only rides the row until somebody takes the item. - /// `same_account` says whether the sender and the recipient belong to one Realm Account; the - /// Module turns it into the letter's Delivery Delay. #[allow(clippy::too_many_arguments)] fn mail_send( &self, @@ -89,64 +34,20 @@ pub(crate) trait MailStore: Send + Sync { same_account: bool, ) -> Result<()>; - /// Credit `mail_id`'s copper to `recipient` and empty the row, in one transaction. The - /// single-database twin of [`mail_send`](Self::mail_send), and refused for a mail that is not - /// the caller's, is not delivered yet, or has nothing left in it. fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Re-create `mail_id`'s attached item in `recipient`'s bags and empty the row's - /// attachment columns, in one transaction. [`mail_take_money`](Self::mail_take_money)'s twin, - /// and refused for a mail that is not the caller's or not delivered yet, one with no - /// attachment, or a full bag — - /// where the refusal rolls the clear back, so the item stays in the letter. fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// Has `payee` room in their bags here for one more item? - /// - /// Asked of the TAKER's own handle, before a sharded item take fences anything: the fence is a - /// one-way move, so a full bag found afterwards would strand the item in an escrow instead of - /// leaving it in the letter. `Err` is the refusal. fn mail_item_room(&self, payee: Actor) -> Result<()>; - /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 1 (Letter Copy) — set COPIED on `mail_id` and file its - /// body as durable item text, on the database that OWNS THE MAIL ROW (realm-core when sharded, - /// this shard's own database otherwise — the same two-plane routing `mail_take_item_fence` - /// takes). `Err` for a mail that is not the caller's, is not delivered, has no body, or is - /// already GRANTED. A replay before GRANTED is set is `Ok` (a no-op on the mail plane), so a - /// retry can still reach the Home Shard grant. fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 2 — store one Plain Letter carrying `item_text_id`, on the - /// PAYEE's own handle. [`mail_item_room`](Self::mail_item_room)'s real Gate: a full bag found - /// here refuses and leaves the mail COPIED with no letter granted. The Plain Letter sells for - /// 0, so a grant lost to that race costs nothing — this is deliberately not an escrow. Also a - /// no-op `Ok` when the payee already holds an item carrying `item_text_id`: the crash-window - /// guard between this call landing and [`mail_mark_letter_granted`](Self::mail_mark_letter_granted) - /// recording that it did. fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()>; - /// `CMSG_MAIL_CREATE_TEXT_ITEM` step 3 — the durable record that the grant landed, on the same - /// database `mail_copy_text` wrote to. Called once [`mail_grant_letter`](Self::mail_grant_letter) - /// returns `Ok`. Unlike the item itself, this bit cannot be destroyed, mailed away, or traded, - /// so it is what refuses a second grant for good. fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()>; - /// The durable text behind `item_text_id`, read from `game_item_text` on the database that - /// OWNS THE MAIL PLANE (same two-plane routing as [`mail_copy_text`](Self::mail_copy_text)). A - /// copied letter's text outlives the mail row that created it, so this answers even after that - /// mail is deleted. - /// - /// The mail plane holds every copied letter's text keyed by a small, sequential id, so this - /// must never be read for a caller who has not proven they may see it — see - /// [`owns_item_with_text`](Self::owns_item_with_text). fn item_text(&self, item_text_id: u32) -> Result>; - /// Does `owner_guid` hold an item carrying `item_text_id` in their own bags, on THIS handle? - /// The ownership Gate `mail::item_text` checks before it answers from `game_item_text`: a - /// client walking `item_text_id` values must not read another player's Letter Copy that way. - /// `hint_item_guid` is `CMSG_ITEM_TEXT_QUERY`'s overloaded second field — often the queried - /// item's own guid when it names an item rather than a mail — so an implementation can try a - /// cheap PK lookup before falling back to a scan of `owner_guid`'s rows. fn owns_item_with_text( &self, owner_guid: u64, @@ -154,18 +55,6 @@ pub(crate) trait MailStore: Send + Sync { hint_item_guid: u64, ) -> Result; - /// **Escrow step 1 (send)** — take the postage plus the attached coin out of `sender`'s - /// purse into a fence keyed by the caller-chosen `escrow_id`, on the database THIS handle names. - /// - /// Always the SENDER's own handle: the purse is `game_world_entity.money`, on the shard they - /// are standing on. `Err` is the atomic affordability refusal — a refused send costs nothing. - /// - /// A COD PAYMENT is fenced through here too, because it is a letter out of a purse like any - /// other: `cod_source_mail_id` names the mail whose price it pays (0 for an ordinary letter), - /// and it rides the fence so a re-drive can settle that price without re-deriving anything. - /// - /// `same_account` says whether the sender and the recipient belong to one Realm Account. The - /// fence resolves the letter's Delivery Delay from it and stores it for the commit. #[allow(clippy::too_many_arguments)] fn mail_fence( &self, @@ -182,16 +71,6 @@ pub(crate) trait MailStore: Send + Sync { same_account: bool, ) -> Result<()>; - /// **Escrow step 2 (send)** — write the mail row and its receipt under `escrow_id`, on the - /// database THIS handle names (realm-core). Idempotent: a replay writes nothing. - /// - /// `cod_source_mail_id` (0 for an ordinary letter) is the mail this one PAYS FOR: its price is - /// settled in the same transaction as the payout row, which is what makes a COD take charge - /// once however the drive is interrupted. - /// - /// `delivery_delay_secs` is the Delivery Delay the fence stored. The letter arrives that long - /// after this commit. `reward` names a Reward Letter's quest giver and Mail Template; `None` is - /// a Character's letter from `sender`. #[allow(clippy::too_many_arguments)] fn mail_commit( &self, @@ -208,9 +87,6 @@ pub(crate) trait MailStore: Send + Sync { reward: Option, ) -> Result<()>; - /// **Escrow step 1 (take)** — take `mail_id`'s copper out of the row into a fence, on the - /// database that OWNS THE ROW. `expect_money` is the amount the caller is about to pay out; a - /// mismatch is refused rather than fenced, because the gateway carries that number across. fn mail_take_money_fence( &self, escrow_id: u64, @@ -219,13 +95,8 @@ pub(crate) trait MailStore: Send + Sync { expect_money: u32, ) -> Result<()>; - /// **Escrow step 2 (take)** — credit `amount` to `payee` and file a receipt under - /// `escrow_id`, on the PAYEE's own handle. Idempotent: a replay credits nothing. fn mail_payout(&self, escrow_id: u64, payee: Actor, mail_id: u64, amount: u32) -> Result<()>; - /// **Escrow step 1 (item take)** — take `mail_id`'s attachment out of the row into a fence, on - /// the database that OWNS THE ROW. `expect_entry` is the item the caller is about to grant; a - /// mismatch is refused rather than fenced, because the gateway carries the snapshot across. fn mail_take_item_fence( &self, escrow_id: u64, @@ -234,9 +105,6 @@ pub(crate) trait MailStore: Send + Sync { expect_entry: u32, ) -> Result<()>; - /// **Escrow step 2 (item take)** — re-create the fenced item in `payee`'s bags and file a - /// receipt under `escrow_id`, on the PAYEE's own handle. Idempotent: a replay grants nothing. - /// `Err` on a full bag, which leaves the fence holding the item for the next re-drive. fn mail_item_payout( &self, escrow_id: u64, @@ -245,18 +113,10 @@ pub(crate) trait MailStore: Send + Sync { item: AttachedItem, ) -> Result<()>; - /// **Escrow step 3** — attest, on the handle HOLDING the fence, that the other database - /// committed. The only thing that licenses step 4. fn mail_confirm_delivery(&self, escrow_id: u64) -> Result<()>; - /// **Escrow step 4** — destroy the fence, on the handle holding it. Delete-last: it refuses - /// while unattested. fn mail_settle(&self, escrow_id: u64) -> Result<()>; - /// Every unfinished mail escrow this database holds for `sender_guid` (the payee, on a payout). - /// - /// The read that makes re-driving possible at all: a fence carries its whole letter, so a drive - /// abandoned by a dead gateway is resumable from the row. fn mail_escrows_of(&self, sender_guid: u64) -> Result>; } @@ -268,7 +128,6 @@ struct EscrowIdRange { static ESCROW_ID_RANGE: std::sync::OnceLock = std::sync::OnceLock::new(); const NO_COD_SOURCE: u64 = 0; -/// A COD payment carries copper only, so it arrives at once (cmangos `MailHandler.cpp:475-477`). const NO_DELIVERY_DELAY: u32 = 0; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SendRefusal { @@ -312,8 +171,6 @@ impl FromReason for SendRefusal { } } -/// Why a mail op did not complete. A Refusal, from a Gateway Gate or the Module, is the client's -/// answer. A Transport Loss leaves the durable outcome unknown and ends the World Session. #[derive(Debug)] pub(crate) enum MailFailure { Refused(R), @@ -321,7 +178,6 @@ pub(crate) enum MailFailure { } impl MailFailure { - /// The Refusal to answer, or the Transport Loss to end the World Session with. pub(crate) fn refusal(self) -> Result { match self { Self::Refused(refusal) => Ok(refusal), @@ -329,7 +185,6 @@ impl MailFailure { } } - /// The Refusal a test expects; a Transport Loss fails the test. #[cfg(test)] pub(crate) fn into_refusal(self) -> R { match self { @@ -355,7 +210,6 @@ impl std::fmt::Display for MailFailure { } } -/// One mail op's Refusal, built from a Gate's reason. pub(crate) trait FromReason { fn from_reason(reason: &str) -> Self; } @@ -381,9 +235,6 @@ fn refused(reason: &str) -> MailFailure { MailFailure::Refused(R::from_reason(reason)) } -/// The mailbox as its owner sees it, on whichever plane holds it. A mail whose delivery instant is -/// still ahead is absent, so the list, the unread poll, the body read and every take skip it -/// (cmangos `MailHandler.cpp:561`, `Player.cpp:3079-3096`). pub(crate) fn mail_of( store: &St, self_guid: u64, @@ -395,7 +246,6 @@ pub(crate) fn mail_of( let now = now_secs(); Ok(mails.into_iter().filter(|m| m.is_delivered(now)).collect()) } -/// Wall-clock seconds: the delivery filter's clock and the base of the list's expiry countdown. pub(crate) fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -467,7 +317,6 @@ pub(crate) fn return_to_sender( Some(realm) => realm.mail_by_id(mail_id)?, None => store.mail_by_id(mail_id)?, }; - // A mail that is not there is refused by the Module, so its Accounts do not matter. let same_account = match mail { Some(mail) => same_realm_account(store, actor.guid(), mail.sender_guid)?, None => false, @@ -478,10 +327,6 @@ pub(crate) fn return_to_sender( }?; Ok(()) } -/// Do two Characters belong to one Realm Account? The Module turns the answer into the Delivery -/// Delay, but it cannot read a Character's Account on another Shard, so the Gateway reads both -/// realm-wide (`docs/architecture.md` §2.3). An Account that no Shard can name counts as another -/// Account, so an item waits. fn same_realm_account( store: &St, a: u64, @@ -497,9 +342,6 @@ fn same_realm_account( }, ) } -/// The Realm Account name the first handle that can name one holds for `guid`: this handle, then -/// every World Shard. Admission refuses two Shards that name different Accounts for one Character, -/// so the first name is the name. fn realm_account_anywhere( store: &St, guid: u64, @@ -595,7 +437,6 @@ pub(crate) fn send< same_account, )?; let held = held_fence(store, sender_guid, escrow_id)?; - // Past the fence, any Refusal is the drive's own and answers as an internal error. drive(store, escrow_id, || { realm.mail_commit( escrow_id, @@ -633,7 +474,6 @@ pub struct AttachedItem { pub enchant_id: u32, pub soulbound: bool, pub random_property_id: u32, - /// A Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`. 0 for every other item. pub item_text_id: u32, } @@ -642,17 +482,10 @@ impl AttachedItem { self.entry == 0 } } -/// How long a fence's escrow row may take to reach the coordinator cache after the fence reducer -/// returned. The call pipe and the subscription travel different connections (`transfer.rs`'s -/// `escrow_after_begin` carries the same lag for a Transfer), so an immediate read can lose that -/// race even though the fence landed. +// Call-pipe completion can precede the Escrow row reaching the subscribed cache. const ESCROW_VISIBLE_WITHIN: std::time::Duration = std::time::Duration::from_secs(2); const ESCROW_POLL: std::time::Duration = std::time::Duration::from_millis(20); -/// The fence a send or a take just filed. The next step takes the attachment, and a send's Delivery -/// Delay, from it, exactly as a re-drive does. Waits out the coordinator cache's lag behind the -/// fence's own call-pipe commit instead of refusing on the first miss. Each read re-acquires the -/// store's own cache guard, so the wait holds none of it while it sleeps. fn held_fence( store: &St, sender_guid: u64, @@ -689,18 +522,10 @@ pub struct HeldEscrow { pub mail_id: u64, pub item: AttachedItem, pub cod: u32, - /// The Delivery Delay the fence resolved, so a re-driven commit keeps it. pub delivery_delay_secs: u32, - /// A Reward Letter's quest giver and Mail Template. `None` for a Character's letter. pub reward: Option, } -/// Drive every letter `self_guid` holds as Escrow on its Home Shard to the mail plane, and every -/// take Realm-core holds for them into their purse or bags. That rescues a send a Gateway -/// abandoned, and it is the only thing that delivers a Reward Letter, which the Module files at -/// turn-in. On a single-database realm the mail plane is the same database: a Character's send files -/// no Escrow there, but a Reward Letter does. pub(crate) fn redrive(store: &St, self_guid: u64) { - // Guid 0 names no Character, so it holds no Escrow. let Some(actor) = Actor::new(self_guid) else { return; }; @@ -717,7 +542,7 @@ pub(crate) fn redrive(store: &St, se }); for held in outgoing { if held.payout { - continue; // A payout fence never lives on a shard; ignore a stray rather than mis-drive it. + continue; } let outcome = drive(store, held.escrow_id, || match &realm { Some(realm) => commit_held(realm.as_ref(), actor, &held), @@ -731,7 +556,7 @@ pub(crate) fn redrive(store: &St, se log_redrive(kind, held.escrow_id, outcome); } let Some(realm) = realm else { - return; // One database: a take is one transaction and files no Escrow. + return; }; let payouts = realm.mail_escrows_of(self_guid).unwrap_or_else(|error| { log::warn!("mail: cannot read payout Escrow for Character {self_guid}: {error:#}"); @@ -752,7 +577,6 @@ pub(crate) fn redrive(store: &St, se } } -/// Commit the letter `held` describes on the mail plane `plane`, fenced by `sender`. fn commit_held(plane: &P, sender: Actor, held: &HeldEscrow) -> Result<()> { plane.mail_commit( held.escrow_id, @@ -880,7 +704,6 @@ fn pay_cod( .map(|e| e.escrow_id) .map(Ok) .unwrap_or_else(next_escrow_id)?; - // The taker pays: `cod_settlement` names them as the payer. store.mail_fence( escrow_id, taker, @@ -892,7 +715,6 @@ fn pay_cod( 0, 0, row.id, - // Copper only: the fence resolves no Delivery Delay whatever the Accounts. false, )?; drive(store, escrow_id, || { @@ -941,7 +763,6 @@ pub(crate) fn install_escrow_id_range(next: u64, end: u64) -> Result<()> { } Ok(()) } -/// The mailbox Gate: the session is in the world and stands at `mailbox_guid`. fn at_mailbox( store: &St, actor: Option, @@ -978,15 +799,6 @@ impl FromReason for CopyLetterRefusal { } } } -/// `CMSG_MAIL_CREATE_TEXT_ITEM`: turn a delivered letter's body into a Plain Letter in the bags. -/// Gates in order — the mailbox, bag room, the Realm-core copy, the Home Shard grant, the Realm-core -/// mark — so a full bag never touches the mail row, matching `take_item`'s ordering. Not an escrow: -/// the Plain Letter sells for 0, so a grant lost to bags filling between the room check and the -/// grant costs nothing. Every durable step is replay-safe, so a retry after an interrupted grant -/// reaches the Home Shard again instead of leaving the mail COPIED with nothing to show for it. The -/// mark is what makes a completed grant refuse a second one for good, even after the player -/// destroys, mails away, or trades the letter — `mail_grant_letter`'s own held-item check only -/// covers the narrow window before this call lands. pub(crate) fn copy_letter( store: &St, actor: Option, @@ -1000,8 +812,6 @@ pub(crate) fn copy_letter( Some(realm) => realm.mail_copy_text(actor, mail_id), None => store.mail_copy_text(actor, mail_id), }?; - // The text id is the mail id narrowed to u32 (`lyracore_shared::mail::item_text_id_for`'s - // non-empty-body case) — the copy above just proved the body is non-empty. let item_text_id = u32::try_from(mail_id).unwrap_or(0); store.mail_grant_letter(actor, item_text_id)?; match store.realm_store()? { @@ -1010,21 +820,6 @@ pub(crate) fn copy_letter( }?; Ok(()) } -/// `CMSG_ITEM_TEXT_QUERY`: the text behind `item_text_id`, for a caller who has PROVEN they may see -/// it — either they hold an item carrying that id, or they own the mail it names. `game_item_text` -/// ids are the mail's own id, small and sequential, so answering it for anyone who merely asks -/// would let a crafted query walk every copied letter on the realm. A caller who proves neither -/// gets empty text, the same answer a stale or foreign id has always produced. -/// -/// `hint_item_guid` is the wire's own overloaded second field, forwarded to -/// [`MailStore::owns_item_with_text`] so it can try a cheap PK lookup before scanning. The -/// ownership scan runs only when the mail check does not already answer the question — most -/// queries are either "read my own undeleted mail" or "reread my own bagged letter," so one lookup -/// usually settles it. -/// -/// A copied letter's text lives in `game_item_text` on the mail plane and outlives the mail that -/// held it; anything else falls back to the caller's own mail body under the same id, which is -/// what a letter still sitting in the mailbox resolves through today. pub(crate) fn item_text( store: &St, actor: Option, diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index e329eecba..8ec741251 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -1,5 +1,3 @@ -//! Mail routing tests through the shared WorldFake topology. - use super::handlers::Mail; use super::party_tests::{character, DORMANT, GINGER, TRIN, VIM}; use super::*; @@ -528,7 +526,6 @@ fn returning_a_mail_carries_its_attachment_and_copper_unchanged() { ..Default::default() }, )]; - // Vim is Ginger's alt here, so the item comes back at once. *world.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); mail::return_to_sender(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) @@ -908,12 +905,10 @@ fn orc(guid: u64, name: &str) -> codec::CharacterView { } } -const GRUG: u64 = 20; // Horde, standing next to Ginger — the faction refusal -const ECHO_WORLD: u64 = 30; // "Echo" on the open world … -const ECHO_INSTANCES: u64 = 31; // … and "Echo" again on the instances shard: the homonym +const GRUG: u64 = 20; +const ECHO_WORLD: u64 = 30; +const ECHO_INSTANCES: u64 = 31; const PURSE: u32 = 500; -/// Ginger, Trin and Dormant are one player's alts, so an item between them arrives at once, as the -/// take and cash on delivery tests need. Vim plays on another Realm Account. const ALTS: &str = "GINGER"; const VIMS_ACCOUNT: &str = "VIM"; fn alts(guids: &[u64]) -> Vec<(u64, String)> { @@ -1426,9 +1421,6 @@ fn a_letter_sent_over_the_wire_is_acked_ok_and_lands_in_the_recipients_mailbox() assert_eq!(store.mail.purses.lock().unwrap()[0].1, PURSE - 30); } -/// `seated_sender`'s sharded twin: the sender's own handle plus a realm-core handle, so -/// `CMSG_SEND_MAIL` drives the fence → commit → confirm → settle escrow instead of the -/// single-database fallback. fn sharded_seated_sender() -> std::sync::Arc { let realm = std::sync::Arc::new(WorldFake { topology: TopologyState { @@ -1730,12 +1722,6 @@ fn a_sharded_send_drives_fence_then_commit_then_confirm_then_settle() { ); } -/// The coordinator's subscribed cache lags the call pipe that carries `mail_fence`: the fence can -/// commit on the sender's own shard before its escrow row is readable back through that same -/// shard's cache. Observed live on a sharded realm: `realm_mail_fence` logged a successful fence, -/// but the read-back that follows it came up empty on 2 of 4 sends, and each stayed HELD until the -/// sender's next mailbox visit re-drove it. `held_fence` must wait the lag out, not refuse a fence -/// that already landed. #[test] fn a_sharded_send_waits_out_a_lagging_escrow_cache_and_still_settles() { let (_realm, world, _instances, calls) = sharded_send(); @@ -2243,8 +2229,6 @@ fn taking_an_item_puts_it_in_the_takers_bags_with_its_state_unchanged() { ); } -/// A Letter Copy mailed on and taken again keeps its text id, so the taker can still read it. The -/// sharded take reads the item from its fence, not from the Mail row. #[test] fn a_taken_letter_keeps_its_text_id() { let letter = mail::AttachedItem { @@ -2298,9 +2282,6 @@ fn a_sharded_item_take_probes_for_room_before_it_fences_anything() { ); } -/// The same coordinator cache lag as a send's fence, on the take side: `mail_take_item_fence` -/// commits on realm-core (the plane that owns the mail row), and the read-back that follows it can -/// still miss there before the subscription catches up. #[test] fn a_sharded_item_take_waits_out_a_lagging_escrow_cache_and_still_settles() { let (realm, world, _calls, mail_id) = delivered_item(); @@ -3045,8 +3026,6 @@ fn a_refused_priced_take_reaches_the_client_as_not_enough_money() { ); } -/// The same mail rows on both planes, as `(the session's store, the store that holds the rows)`: -/// a sharded world handle whose rows live on realm-core, and a single-database store. fn both_planes( mails: Vec<(u64, codec::MailView)>, ) -> [(std::sync::Arc, std::sync::Arc); 2] { @@ -3299,7 +3278,6 @@ fn a_returned_mail_lists_as_returned_and_unread_with_no_price_and_a_fresh_countd }, ); for (store, _) in both_planes(vec![priced]) { - // Vim is Ginger's alt here, so the item comes back at once. *store.mail.realm_accounts.lock().unwrap() = alts(&[GINGER, VIM]); mail::return_to_sender(store.as_ref(), Actor::new(GINGER), MAILBOX, 1).expect("declined"); @@ -3375,7 +3353,6 @@ fn deleting_a_priced_mail_answers_the_internal_error_and_keeps_the_mail() { ); } -/// Every row `recipient` holds on `plane`, delivered or not. fn held_for(plane: &WorldFake, recipient: u64) -> Vec { plane .mail @@ -3387,7 +3364,6 @@ fn held_for(plane: &WorldFake, recipient: u64) -> Vec { .map(|(_, m)| m.clone()) .collect() } -/// The `same_account` answer each mail send, fence and return on `store` carried. fn same_account_seen(store: &WorldFake) -> Vec<(&'static str, bool)> { store.mail.same_account_seen.lock().unwrap().clone() } @@ -3430,7 +3406,6 @@ fn copper_to_another_account_arrives_at_once() { #[test] fn an_item_to_an_alt_on_another_shard_arrives_at_once() { let (_realm, world, instances, _calls) = sharded_send(); - // Vim is Ginger's alt. Only the instances Shard, where Vim lives, names his Realm Account. *instances.mail.realm_accounts.lock().unwrap() = alts(&[VIM]); give_item(&world, GINGER, SWORD_GUID, sword()); @@ -3448,8 +3423,6 @@ fn an_item_to_an_alt_on_another_shard_arrives_at_once() { #[test] fn a_shard_that_holds_a_character_on_a_shadow_account_defers_to_another_shard() { let (_realm, world, instances, _calls) = sharded_send(); - // Ginger came over from the instances Shard. The world Shard holds her on a shadow Account and - // cannot name her Realm Account, and the instances Shard kept her Account Character Owner. *world.mail.realm_accounts.lock().unwrap() = alts(&[TRIN]); *instances.mail.realm_accounts.lock().unwrap() = alts(&[GINGER]); give_item(&world, GINGER, SWORD_GUID, sword()); @@ -3579,7 +3552,6 @@ fn a_cod_letter_to_another_account_waits_and_its_payment_arrives_at_once() { .expect_err("nothing to buy yet"); assert_eq!(purse_of(&instances, VIM), PURSE, "and nothing is charged"); - // An hour later. for (_, m) in realm.mail.mails.lock().unwrap().iter_mut() { m.deliver_secs = mail::now_secs(); } @@ -3594,12 +3566,6 @@ fn a_cod_letter_to_another_account_waits_and_its_payment_arrives_at_once() { ); } -// ------------------------------------------------------------------------------------- -// Letter Copy (`CMSG_MAIL_CREATE_TEXT_ITEM`) -// ------------------------------------------------------------------------------------- - -/// GINGER's seeded mail (id 1, body "left it at the inn") is the copy fixture throughout: a -/// delivered Character mail with a body, so its text id is its own id (`item_text_id_for`). const GINGERS_LETTER_TEXT_ID: u32 = 1; #[test] @@ -3701,15 +3667,12 @@ fn a_letter_already_granted_refuses_a_second_copy_and_grants_no_second_letter() ); } -/// The held-item check in `mail_grant_letter` only guards the crash window before GRANTED is -/// recorded — it is not the durable answer. Once GRANTED lands, destroying, mailing away, or -/// trading the letter must not reopen the grant: the record lives on the mail, not the item. #[test] fn destroying_the_granted_item_does_not_reopen_a_second_grant() { let (_realm, world, _calls) = sharded_mailbox(); mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) .expect("the first copy completes"); - world.mail.granted_letters.lock().unwrap().clear(); // the Plain Letter is gone from the bags + world.mail.granted_letters.lock().unwrap().clear(); let refusal = mail::copy_letter(world.as_ref(), Actor::new(GINGER), MAILBOX, 1) .expect_err("GRANTED survives the item's destruction") @@ -3722,9 +3685,6 @@ fn destroying_the_granted_item_does_not_reopen_a_second_grant() { ); } -/// A grant lost between the room check and the Home Shard call (bags filled, logout, timeout, a -/// Gateway crash) leaves the mail COPIED with its text filed but no letter granted. Clicking again -/// must reach the Home Shard, not bounce off "already copied". #[test] fn retrying_after_an_interrupted_grant_still_grants_the_letter() { let (realm, world, _calls) = sharded_mailbox(); @@ -3866,9 +3826,6 @@ fn a_letter_copy_into_a_full_bag_over_the_wire_answers_equip_error() { ); } -/// `game_item_text` ids are a mail's own id — small and sequential. A caller who neither holds the -/// granted item nor owns the mail must not read it by guessing the id, even after the letter's -/// mail row is long gone. #[test] fn item_text_query_refuses_a_caller_who_neither_owns_the_item_nor_the_mail() { let (realm, world, _calls) = sharded_mailbox(); @@ -3907,9 +3864,6 @@ fn item_text_query_answers_for_an_owner_who_still_holds_the_granted_item() { fn item_text_query_falls_back_to_the_callers_own_undeleted_mail() { let (_realm, world, _calls) = sharded_mailbox(); - // No copy has happened, so `game_item_text` holds nothing — the query still resolves through - // the caller's own delivered mail under the same id, matching what a letter still sitting in - // the mailbox has always done. assert_eq!( mail::item_text( world.as_ref(), @@ -3927,9 +3881,6 @@ fn item_text_query_falls_back_to_the_callers_own_undeleted_mail() { ); } -/// A Reward Letter as the Module files it at turn-in: quest 3645, Membership Card Renewal, from -/// its quest ender with template 99 and item 11423 after 86,400 s (`cdb:` quest_template, -/// mail_loot_template). The quest ender here is creature 7802. const REWARD_ESCROW: u64 = 0x5EED_0009; fn card_renewal_letter(recipient: u64, delivery_delay_secs: u32) -> mail::HeldEscrow { mail::HeldEscrow { @@ -3954,7 +3905,6 @@ fn card_renewal_letter(recipient: u64, delivery_delay_secs: u32) -> mail::HeldEs }), } } -/// File `letter` on `shard` for `owner`, as the turn-in's transaction leaves it: unattested. fn hold_reward_letter(shard: &WorldFake, owner: u64, letter: mail::HeldEscrow) { shard .mail @@ -3982,8 +3932,6 @@ fn reward_letters(plane: &WorldFake, recipient: u64) -> Vec { .map(|(_, m)| m.clone()) .collect() } -/// The mail row the Module writes for the card renewal letter: from creature 7802 and no guid, -/// naming template 99, with HAS_BODY (0x10). fn assert_card_renewal(letter: &codec::MailView) { assert_eq!( ( @@ -4244,8 +4192,6 @@ fn a_reward_letter_a_turn_in_files_is_delivered_on_either_plane() { } } -/// A mail Refusal answers the client and the World Session continues; a Transport Loss mid-drive -/// leaves the letter's outcome unknown and ends the session. #[test] fn a_send_refusal_is_answered_and_a_transport_loss_ends_the_session() { let send = |receiver: &str| { diff --git a/gateway/src/world/melee_tests.rs b/gateway/src/world/melee_tests.rs index e647bdad8..29361978d 100644 --- a/gateway/src/world/melee_tests.rs +++ b/gateway/src/world/melee_tests.rs @@ -1,5 +1,3 @@ -//! Melee attack opcodes through their dispatcher. - use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryMeleeActions; use super::*; @@ -38,7 +36,6 @@ fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { } _ => panic!("expected one SMSG_ATTACKSTART"), } - // Stop echoes the armed target and clears it. match run(&actions, &mut session, CMSG_ATTACKSTOP {}).as_slice() { [Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTOP(a))] => { assert_eq!(a.player.guid(), 1); @@ -46,14 +43,11 @@ fn attackswing_ok_replies_attackstart_and_stop_echoes_then_clears() { } _ => panic!("expected one SMSG_ATTACKSTOP"), } - // A second stop finds no armed target, so there is no second echo. assert!(run(&actions, &mut session, CMSG_ATTACKSTOP {}).is_empty()); } #[test] fn melee_opcodes_at_character_select_answer_nothing_and_do_not_fail() { - // No WorldEntity yet, so the seam has no attacker guid to name and no combat state to change. - // It makes no durable request. let actions = InMemoryMeleeActions::default(); let mut session = ProtocolSession::new(7, "TESTER".into()); @@ -65,9 +59,6 @@ fn melee_opcodes_at_character_select_answer_nothing_and_do_not_fail() { #[test] fn attackswing_desync_error_is_session_fatal() { - // A desync-classified start_attack failure (the player's OWN entity is gone) must PROPAGATE - // as session-fatal so the socket tears down for a clean relog. Which failures are fatal, and - // which answer a refusal instead, belongs to the melee seam's own tests. let actions = InMemoryMeleeActions { start_error: Some("no live entity for guid 1".into()), ..Default::default() diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index de0799b97..3d6e704ad 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -1,10 +1,3 @@ -//! Member Stats against the realm-wide party topology: the Relay tick for one viewer, and the -//! `CMSG_REQUEST_PARTY_MEMBER_STATS` answer. Party membership comes from the same Fake Realm-core -//! the routing tests use, so a mate on `instances` is read from that shard's cache. -//! -//! Expected bodies are written out by hand from cm:GroupHandler.cpp:585-630: packed guid, `u32` -//! mask, then the masked fields. Every guid here is one byte, so the packed guid is `01 `. - use super::party_tests::{ character, form_split_party, party_members, party_topology, BOT, GINGER, TRIN, VIM, }; @@ -15,7 +8,6 @@ use std::sync::atomic::Ordering; type Snapshots = HashMap; -/// A level 20 caster standing in Elwynn Forest. fn caster() -> codec::MemberEntity { codec::MemberEntity { health: 1234, @@ -33,7 +25,6 @@ fn caster() -> codec::MemberEntity { } } -/// The caster with two positive auras (slots 0 and 5) and one negative aura (slot 35). fn caster_with_auras() -> codec::MemberEntity { codec::MemberEntity { auras: vec![ @@ -54,7 +45,6 @@ fn caster_with_auras() -> codec::MemberEntity { } } -/// A Hunter's live pet: Fluffy, with one positive aura. fn hunter_pet() -> codec::MemberPetEntity { codec::MemberPetEntity { guid: 85, @@ -64,7 +54,7 @@ fn hunter_pet() -> codec::MemberPetEntity { max_health: 60, power: 40, max_power: 100, - unit_bytes_0: 0x0200_0000, // power type 2, focus + unit_bytes_0: 0x0200_0000, auras: vec![codec::MemberAuraSlot { slot: 2, spell_id: 400, @@ -72,70 +62,52 @@ fn hunter_pet() -> codec::MemberPetEntity { } } -/// The caster's aura block for [`caster_with_auras`]: `AURAS` (bits 0, 5) then -/// `AURAS_NEGATIVE` (bit 3, slot 35). fn caster_aura_bytes() -> Vec { #[rustfmt::skip] let bytes = vec![ - 0x21, 0x00, 0x00, 0x00, // AURAS mask: slots 0 and 5 - 0x64, 0x00, // spell 100 - 0xC8, 0x00, // spell 200 - 0x08, 0x00, // AURAS_NEGATIVE mask: slot 35 (bit 3) - 0x2C, 0x01, // spell 300 + 0x21, 0x00, 0x00, 0x00, + 0x64, 0x00, + 0xC8, 0x00, + 0x08, 0x00, + 0x2C, 0x01, ]; bytes } -/// The caster's 9 base fields, header and aura blocks excluded. fn caster_base_bytes() -> Vec { vec![ - 0x01, // status ONLINE - 0xD2, 0x04, // current health 1234 - 0xDC, 0x05, // max health 1500 - 0x00, // power type mana - 0x20, 0x03, // current power 800 - 0xE8, 0x03, // max power 1000 - 0x14, 0x00, // level 20 - 0x0C, 0x00, // zone 12 - 0x0B, 0xDD, // x -8949 - 0x7C, 0xFF, // y -132 + 0x01, 0xD2, 0x04, 0xDC, 0x05, 0x00, 0x20, 0x03, 0xE8, 0x03, 0x14, 0x00, 0x0C, 0x00, 0x0B, + 0xDD, 0x7C, 0xFF, ] } -/// [`hunter_pet`]'s scalar pet fields: guid, name, display, health and power. Its aura blocks are -/// a separate concern; a caller appends them (occupied or empty) after this. fn hunter_pet_bytes() -> Vec { #[rustfmt::skip] let bytes = vec![ - 0x55, 0, 0, 0, 0, 0, 0, 0, // PET_GUID 85 - b'F', b'l', b'u', b'f', b'f', b'y', 0x00, // PET_NAME "Fluffy" - 0x6A, 0x02, // PET_MODEL_ID 618 - 0x32, 0x00, // PET_CUR_HP 50 - 0x3C, 0x00, // PET_MAX_HP 60 - 0x02, // PET_POWER_TYPE 2 (focus) - 0x28, 0x00, // PET_CUR_POWER 40 - 0x64, 0x00, // PET_MAX_POWER 100 + 0x55, 0, 0, 0, 0, 0, 0, 0, + b'F', b'l', b'u', b'f', b'f', b'y', 0x00, + 0x6A, 0x02, + 0x32, 0x00, + 0x3C, 0x00, + 0x02, + 0x28, 0x00, + 0x64, 0x00, ]; bytes } -/// The wire bytes for an empty `AURAS`/`PET_AURAS` block where every slot rides the packet (a -/// `None` previous, cm:Group.cpp:306-307, 746-747): mask `0xFFFFFFFF`, then 32 zero values. fn empty_positive_aura_bytes() -> Vec { let mut bytes = vec![0xFF, 0xFF, 0xFF, 0xFF]; bytes.extend(std::iter::repeat_n(0u8, 32 * 2)); bytes } -/// [`empty_positive_aura_bytes`] for the negative block: mask `0xFFFF`, then 16 zero values. fn empty_negative_aura_bytes() -> Vec { let mut bytes = vec![0xFF, 0xFF]; bytes.extend(std::iter::repeat_n(0u8, 16 * 2)); bytes } -/// [`empty_positive_aura_bytes`] with `slot` overlaid to `spell_id`, for a first send where one -/// slot is occupied: mask `0xFFFFFFFF`, then a spell id per slot, `slot`'s the only nonzero one. fn full_positive_aura_bytes(slot: usize, spell_id: u16) -> Vec { let mut bytes = vec![0xFF, 0xFF, 0xFF, 0xFF]; for i in 0..32u16 { @@ -145,41 +117,30 @@ fn full_positive_aura_bytes(slot: usize, spell_id: u16) -> Vec { bytes } -/// A zeroed pet block, PET_GUID through PET_AURAS_NEGATIVE: what a first send writes for a -/// pet-less member, and what a delta writes once for a member whose pet just vanished -/// (cm:GroupHandler.cpp:660-755's own "write zero when there is no charm" branches). fn zeroed_pet_bytes() -> Vec { - let mut bytes = vec![0u8; 8]; // PET_GUID - bytes.push(0); // PET_NAME: an empty CString - bytes.extend([0u8; 2]); // PET_MODEL_ID - bytes.extend([0u8; 2]); // PET_CUR_HP - bytes.extend([0u8; 2]); // PET_MAX_HP - bytes.push(0); // PET_POWER_TYPE - bytes.extend([0u8; 2]); // PET_CUR_POWER - bytes.extend([0u8; 2]); // PET_MAX_POWER - bytes.extend(empty_positive_aura_bytes()); // PET_AURAS - bytes.extend(empty_negative_aura_bytes()); // PET_AURAS_NEGATIVE + let mut bytes = vec![0u8; 8]; + bytes.push(0); + bytes.extend([0u8; 2]); + bytes.extend([0u8; 2]); + bytes.extend([0u8; 2]); + bytes.push(0); + bytes.extend([0u8; 2]); + bytes.extend([0u8; 2]); + bytes.extend(empty_positive_aura_bytes()); + bytes.extend(empty_negative_aura_bytes()); bytes } -/// The pet-less caster's body for guid `g` answering `CMSG_REQUEST_PARTY_MEMBER_STATS`: every -/// base field, plus the two empty aura blocks. Mask 0x7FF, no pet bits: only the explicit FULL -/// answer strips `GROUP_UPDATE_PET` whole for a pet-less member (cm:GroupHandler.cpp:781-786). fn caster_full_body(guid: u8) -> Vec { - let mut body = vec![0x01, guid, 0xFF, 0x07, 0x00, 0x00]; // mask 0x7FF + let mut body = vec![0x01, guid, 0xFF, 0x07, 0x00, 0x00]; body.extend(caster_base_bytes()); body.extend(empty_positive_aura_bytes()); body.extend(empty_negative_aura_bytes()); body } -/// The pet-less caster's body for guid `g` on a Relay first send (opcode `0x007E`): every base -/// field, both aura blocks written in full, and every pet field zeroed. `None` marks -/// `GROUP_UPDATE_FULL` dirty unconditionally, pet fields included even with no pet -/// (cm:Group.cpp:306-307, 746-747), so a relogged member without their old pet still clears the -/// viewer's stale pet frame. fn caster_first_send_body(guid: u8) -> Vec { - let mut body = vec![0x01, guid, 0xFF, 0xFF, 0x1F, 0x00]; // mask 0x1FFFFF (GROUP_UPDATE_FULL) + let mut body = vec![0x01, guid, 0xFF, 0xFF, 0x1F, 0x00]; body.extend(caster_base_bytes()); body.extend(empty_positive_aura_bytes()); body.extend(empty_negative_aura_bytes()); @@ -187,7 +148,6 @@ fn caster_first_send_body(guid: u8) -> Vec { body } -/// The caster's wire values. fn stats() -> codec::MemberStats { codec::MemberStats::from_entity(&caster()) } @@ -207,7 +167,6 @@ fn despawn(shard: &WorldFake, guid: u64) { .retain(|(g, _)| *g != guid); } -/// Ginger and Trin, both on `world`. fn ginger_and_trin() -> ( std::sync::Arc, std::sync::Arc, @@ -226,7 +185,6 @@ fn ginger_and_trin() -> ( (realm, world, instances) } -/// One tick for Ginger, whose client has created only the guids in `created`. fn tick(store: &WorldFake, created: &[u64], snapshots: &mut Snapshots) -> Vec<(u16, Vec)> { member_stats_tick(store, GINGER, |guid| created.contains(&guid), snapshots) .expect("the tick reads") @@ -260,14 +218,14 @@ fn a_live_mate_on_another_shard_is_read_from_that_shards_cache() { let packets = tick(&world, &[GINGER], &mut Snapshots::new()); - let mut expected = vec![0x01, 0x02, 0xFF, 0xFF, 0x1F, 0x00]; // mask 0x1FFFFF (GROUP_UPDATE_FULL) + let mut expected = vec![0x01, 0x02, 0xFF, 0xFF, 0x1F, 0x00]; #[rustfmt::skip] expected.extend([ - 0x05, // status ONLINE | DEAD + 0x05, 0xD2, 0x04, 0xDC, 0x05, 0x00, 0x20, 0x03, 0xE8, 0x03, 0x14, 0x00, - 0x2D, 0x06, // zone 1581 - 0xF0, 0xFF, // x -16 - 0x81, 0xFE, // y -383 + 0x2D, 0x06, + 0xF0, 0xFF, + 0x81, 0xFE, ]); expected.extend(empty_positive_aura_bytes()); expected.extend(empty_negative_aura_bytes()); @@ -309,7 +267,6 @@ fn a_power_type_change_sends_the_type_and_both_power_values() { let (_realm, world, _instances) = ginger_and_trin(); let mut snapshots = Snapshots::new(); tick(&world, &[GINGER], &mut snapshots); - // Bear Form: the same numbers, now rage. place( &world, TRIN, @@ -322,11 +279,7 @@ fn a_power_type_change_sends_the_type_and_both_power_values() { let packets = tick(&world, &[GINGER], &mut snapshots); let body = vec![ - 0x01, 0x03, // packed guid 3 - 0x38, 0x00, 0x00, 0x00, // POWER_TYPE | CUR_POWER | MAX_POWER - 0x01, // rage - 0x20, 0x03, // current power 800 - 0xE8, 0x03, // max power 1000 + 0x01, 0x03, 0x38, 0x00, 0x00, 0x00, 0x01, 0x20, 0x03, 0xE8, 0x03, ]; assert_eq!(packets, vec![(0x007E, body)]); } @@ -371,7 +324,6 @@ fn a_mate_in_transfer_gets_nothing_and_is_never_reported_offline() { realm.party.members_in_transit.lock().unwrap().push(VIM); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); - // Arrival with nothing changed: the record survived the Transfer, so nothing is resent. realm.party.members_in_transit.lock().unwrap().clear(); place(&world, VIM, caster()); assert!(tick(&world, &[GINGER], &mut snapshots).is_empty()); @@ -433,7 +385,6 @@ fn request(store: &WorldFake, self_guid: Option, guid: u64) -> ProtocolRepl request_with(store, &mut session, guid) } -/// Run the answer the way the session writer does: every job, in order. fn answer(outcome: ProtocolReply) -> Vec<(u16, Vec)> { let outbound = outcome.outbound; outbound @@ -468,23 +419,18 @@ fn a_request_for_a_group_mate_returns_one_full_packet() { fn a_request_for_anyone_else_returns_the_offline_full_packet() { let (_realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); - // The bot is live on `world` but in nobody's group: its position must not leak. place(&world, BOT, caster()); assert_eq!( answer(request(&world, Some(GINGER), BOT)), full_offline(BOT as u8) ); - // A mate with no entity anywhere. assert_eq!( answer(request(&world, Some(GINGER), VIM)), full_offline(VIM as u8) ); } -/// cmangos answers a teleporting mate from the player object, flagged ZONE_OUT -/// (cm:GroupHandler.cpp:764, cm:Group.cpp:54-55). The entity is gone here, so the status goes -/// alone: online, zone out. #[test] fn a_request_for_a_mate_between_two_places_answers_online_and_zone_out() { let (realm, world, instances, _) = party_topology(); @@ -504,9 +450,6 @@ fn a_request_for_a_mate_between_two_places_answers_online_and_zone_out() { assert_eq!(answer(request(&world, Some(GINGER), VIM)), online_zone_out); } -/// A FULL answer rewrites the frame behind the Relay's back. The mate crossed while the record -/// said ONLINE, got the in-transit answer, then arrived with nothing else changed: the next tick -/// must still send every field, the online status among them. #[test] fn after_a_full_answer_the_next_tick_sends_every_field() { let (realm, world, instances, _) = party_topology(); @@ -537,7 +480,6 @@ fn after_a_full_answer_the_next_tick_sends_every_field() { fn a_failed_read_answers_nothing_and_is_never_offline() { let (realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); - // Vim has no entity, and one World Shard cannot prove the absence. let unhealthy = WorldFake { topology: TopologyState { shard: "world".into(), @@ -570,7 +512,6 @@ fn a_bot_crossing_between_shards_is_never_reported_offline() { let mut snapshots = Snapshots::new(); tick(&world, &[GINGER], &mut snapshots); - // The Package placed the bot and wrote its Transfer Intent; the Gateway has not claimed it. despawn(&world, BOT); world .social @@ -592,8 +533,6 @@ fn a_stats_request_outside_the_world_sends_nothing() { assert!(request(&world, None, TRIN).outbound.is_empty()); } -/// The raw FULL body survives the session writer and the header cipher, on a single database -/// where the party lives in the shard's own tables. #[test] fn a_stats_request_is_answered_through_the_encrypted_session() { let store = { @@ -664,8 +603,6 @@ fn a_stats_request_that_cannot_be_read_does_not_end_the_session() { let _ = server.join(); } -/// A Relay tick can reach the writer between viewer registration and the world-entry party frame. -/// The client does not know the party yet, so world entry forgets that tick behind the frame. #[test] fn world_entry_forgets_member_stats_sent_before_the_party_frame() { let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); @@ -712,7 +649,6 @@ fn world_entry_forgets_member_stats_sent_before_the_party_frame() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The answer for a stranger is the barrier: the writer runs its queue in order. wow_world_messages::vanilla::CMSG_REQUEST_PARTY_MEMBER_STATS { guid: Guid::new(99), } @@ -721,7 +657,7 @@ fn world_entry_forgets_member_stats_sent_before_the_party_frame() { let mut saw_party_frame = false; loop { let (opcode, _) = read_raw_frame(&mut client, &mut c_dec); - saw_party_frame |= opcode == 0x007D; // SMSG_GROUP_LIST + saw_party_frame |= opcode == 0x007D; if opcode == 0x02F2 { break; } @@ -739,30 +675,26 @@ fn world_entry_forgets_member_stats_sent_before_the_party_frame() { let _ = server.join(); } -/// AC1: a member with two buffs and one debuff sends `AURAS` with two ids and `AURAS_NEGATIVE` -/// with one id, at the right bits. Driven as a delta so the body carries only the aura fields. #[test] fn a_member_with_two_buffs_and_one_debuff_sends_both_aura_blocks() { let (_realm, world, _instances) = ginger_and_trin(); let mut snapshots = Snapshots::new(); - tick(&world, &[GINGER], &mut snapshots); // seed: the plain, aura-less caster + tick(&world, &[GINGER], &mut snapshots); place(&world, TRIN, caster_with_auras()); let packets = tick(&world, &[GINGER], &mut snapshots); - let mut expected = vec![0x01, TRIN as u8, 0x00, 0x06, 0x00, 0x00]; // mask 0x600 + let mut expected = vec![0x01, TRIN as u8, 0x00, 0x06, 0x00, 0x00]; expected.extend(caster_aura_bytes()); assert_eq!(packets, vec![(0x007E, expected)]); } -/// AC2: removing one buff, and only that one, sends only its bit, with id 0. The other buff and -/// the debuff stay in place and stay silent. #[test] fn removing_one_buff_sends_only_its_bit_with_id_zero() { let (_realm, world, _instances) = ginger_and_trin(); place(&world, TRIN, caster_with_auras()); let mut snapshots = Snapshots::new(); - tick(&world, &[GINGER], &mut snapshots); // seed with both buffs and the debuff + tick(&world, &[GINGER], &mut snapshots); let mut one_buff_left = caster_with_auras(); one_buff_left.auras.retain(|aura| aura.slot != 0); @@ -770,16 +702,11 @@ fn removing_one_buff_sends_only_its_bit_with_id_zero() { let packets = tick(&world, &[GINGER], &mut snapshots); let expected = vec![ - 0x01, TRIN as u8, // packed guid - 0x00, 0x02, 0x00, 0x00, // mask 0x200 (AURAS only) - 0x01, 0x00, 0x00, 0x00, // AURAS mask: bit 0, the removed buff, alone - 0x00, 0x00, // spell id 0 + 0x01, TRIN as u8, 0x00, 0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, ]; assert_eq!(packets, vec![(0x007E, expected)]); } -/// AC3: a hunter with a live pet sends the pet block with guid, name, display, health, power and -/// auras. Dismissing the pet sends the pet bits zeroed once, then nothing more. #[test] fn a_live_pet_sends_its_block_then_dismissing_it_zeroes_the_bits_once() { let (_realm, world, _instances) = ginger_and_trin(); @@ -793,32 +720,22 @@ fn a_live_pet_sends_its_block_then_dismissing_it_zeroes_the_bits_once() { }, ); - let mut first_send = vec![0x01, TRIN as u8, 0xFF, 0xFF, 0x1F, 0x00]; // mask FULL, live pet + let mut first_send = vec![0x01, TRIN as u8, 0xFF, 0xFF, 0x1F, 0x00]; first_send.extend(caster_base_bytes()); - first_send.extend(empty_positive_aura_bytes()); // AURAS: the member has none - first_send.extend(empty_negative_aura_bytes()); // AURAS_NEGATIVE: the member has none + first_send.extend(empty_positive_aura_bytes()); + first_send.extend(empty_negative_aura_bytes()); first_send.extend(hunter_pet_bytes()); - first_send.extend(full_positive_aura_bytes(2, 400)); // PET_AURAS: slot 2 - first_send.extend(empty_negative_aura_bytes()); // PET_AURAS_NEGATIVE: the pet has none + first_send.extend(full_positive_aura_bytes(2, 400)); + first_send.extend(empty_negative_aura_bytes()); assert_eq!( tick(&world, &[GINGER], &mut snapshots), vec![(0x007E, first_send)] ); - place(&world, TRIN, caster()); // the pet is dismissed + place(&world, TRIN, caster()); let dismissed = vec![ - 0x01, TRIN as u8, // packed guid - 0x00, 0xF8, 0x0F, 0x00, // mask 0xFF800: every pet bit but PET_AURAS_NEGATIVE - 0, 0, 0, 0, 0, 0, 0, 0, // PET_GUID zeroed - 0x00, // PET_NAME: an empty CString - 0x00, 0x00, // PET_MODEL_ID zeroed - 0x00, 0x00, // PET_CUR_HP zeroed - 0x00, 0x00, // PET_MAX_HP zeroed - 0x00, // PET_POWER_TYPE zeroed - 0x00, 0x00, // PET_CUR_POWER zeroed - 0x00, 0x00, // PET_MAX_POWER zeroed - 0x04, 0x00, 0x00, 0x00, // PET_AURAS: slot 2 cleared, the one differing bit - 0x00, 0x00, // spell id 0 + 0x01, TRIN as u8, 0x00, 0xF8, 0x0F, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, ]; assert_eq!( tick(&world, &[GINGER], &mut snapshots), @@ -831,8 +748,6 @@ fn a_live_pet_sends_its_block_then_dismissing_it_zeroes_the_bits_once() { ); } -/// AC4: a member without a pet gets a FULL packet with no pet bits (already the shape -/// `caster_full_body` carries; this test names the mask directly, for AC traceability). #[test] fn a_member_without_a_pet_gets_a_full_answer_with_no_pet_bits() { let (_realm, world, instances, _) = party_topology(); @@ -850,8 +765,6 @@ fn a_member_without_a_pet_gets_a_full_answer_with_no_pet_bits() { ); } -/// AC5: a member inside the viewer's AOI is already created on the client and gets nothing, auras -/// and a pet included. #[test] fn a_created_mate_with_auras_and_a_pet_still_gets_nothing() { let (_realm, world, _instances) = ginger_and_trin(); diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index add708e78..6c484774c 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -1,16 +1,3 @@ -//! World listener (TCP 8085): handshake, header crypto, char enum, login, movement, -//! disconnect. -//! -//! Like `logon`, the protocol is a transport- and storage-agnostic state machine over a -//! blocking `Read + Write` stream, driven by `wow_world_messages` (vanilla) codecs and -//! `wow_srp`'s vanilla header cipher. Storage/coordination is behind the [`WorldStore`] trait -//! so the handshake + crypto can be unit-tested end-to-end without a database or a real -//! socket (see the tests below, which drive a full handshake with `wow_srp`'s client side). -//! -//! Per-connection protocol state lives in [`WorldConn`] — none of it is game state, so a -//! gateway restart costs only a client reconnect: K is read back -//! from `game_session` and the same handshake re-runs. - use crate::accept::{classify_accept_error, AcceptBackoff, AcceptOutcome}; use crate::codec::auth_session::{read_auth_session, CMSG_AUTH_SESSION_OPCODE}; use crate::read_deadline::{ @@ -92,78 +79,23 @@ pub use store::{InteractionOutcome, WorldSessionToken, WorldStore}; pub(crate) use store::{SessionStore, ShardRoutingStore}; pub(crate) use transfer::TransferStore; -/// One unit of outbound traffic for the single writer thread. A `Batch` is written contiguously so -/// the login sequence + self-spawn can never be spliced by an async peer event mid-sequence. +/// A Batch must stay contiguous so peer Relays cannot interrupt world entry. pub enum Outbound { One(ServerOpcodeMessage), Batch(Vec), - /// A pre-serialized packet body sent under a RAW opcode — the escape hatch past gtker's - /// `pub(crate)` update-mask wall (auras 1..47, multi-field item descriptors). `body` is the - /// packet body WITHOUT the 4-byte server header; the writer frames it exactly like the gtker path - /// (`[size:u16 BE][opcode:u16 LE]`, `size = 2 + body.len()`, then encrypts the header via the same - /// `EncrypterHalf`), so the header-cipher stream stays in lockstep with the typed sends. Built by - /// `codec::build_values_update_raw` from the hand-rolled `update_mask::UpdateMaskValues`. Raw { opcode: u16, body: Vec, }, - /// DEFERRED relay work. The shared AOI dispatch runs on ONE coordinator pump per shard - /// that every session now depends on, and the SDK forbids parallel `advance_one_message` - /// callers — so the pump must never encode. It pushes this instead: a closure that does the - /// per-viewer gating, cache reads and encoding LATER, on this session's own writer thread. - /// - /// Ordering is preserved because a job travels the same queue as everything else: whatever the - /// job produces is written exactly where the job was enqueued, so a CREATE queued before a - /// VALUES still reaches the wire first. - /// - /// The writer runs it inside `catch_unwind`, and it may produce zero packets (the common case - /// for a row a gate rejects — a job is enqueued per candidate viewer, and rejecting a candidate - /// costs one dequeue instead of pump time). + /// SDK advancement is serial per shard; defer encoding to each World Session's writer. Job(RelayJob), } -/// One unit of deferred relay work — see [`Outbound::Job`]. Returns the packets to write, in order; -/// nested jobs are not expanded (nothing produces one, and honouring them would make the queue's -/// ordering guarantee depend on recursion depth). pub type RelayJob = Box Vec + Send>; -/// Queued-but-unwritten `Outbound` items past which the two N²-shaped relays stop enqueueing -/// (see [`SessionTx`] and `subscriptions::shed_motion_at_depth`). -/// -/// Sizing, so this is a reasoned number and not a vibe. One `Outbound` is one packet (a `Batch` is -/// one item carrying several), and a writer keeping up with its socket holds this in the single -/// digits — the queue only grows while the socket is refusing bytes. The two costs it sits between: -/// * **Too low → shedding legitimate bursts.** Entering a crowded AOI box queues one -/// `CREATE_OBJECT` per peer in one go (~100 at the 5×5 box's practical occupancy, and every one -/// of them matters — a dropped CREATE means an invisible player until the next re-entry). Login -/// queues its own burst on top. 512 is several times that headroom. -/// * **Too high → the memory path this bounds.** At 371 co-located clients each session receives -/// ~740 peer-motion packets/s (~2 heartbeats/s × ~370 movers), so 512 items ≈ 0.7 s of crowd -/// movement, and at ~50 bytes of body plus `Vec`/enum overhead ≈ 50 KB per session — ~18 MB -/// across 371 sessions, bounded, instead of a queue that grows for as long as the socket stalls. -/// -/// So: a session that is more than ~0.7 s behind on peer movement is not "bursting", it is losing, -/// and the newest heartbeat is worth more than the 512 stale ones ahead of it. pub const EGRESS_SHED_DEPTH: usize = 512; -/// The session's egress sender plus a live count of items **queued but not yet written**. -/// -/// The channel stays `std::sync::mpsc` UNBOUNDED on purpose. ~110 of the send sites live in -/// `subscriptions.rs` inside SpacetimeDB SDK `on_insert`/`on_update` callbacks running on the SDK's -/// own pump thread: a blocking send from there (what a bounded channel gives you on a full queue) -/// would stall the pump that feeds every table callback for this connection and deadlock it. So -/// instead of bounding the channel, we MEASURE it and let only the traffic that can safely be lost -/// shed itself — see `subscriptions::shed_motion_at_depth` for which traffic that is and why. -/// -/// The counter is the entire new hot-path cost: one `Relaxed` `fetch_add` per enqueue here, one -/// `fetch_sub` per item the writer takes off the queue ([`spawn_writer`]). Nothing is logged, -/// nothing is locked — the standing rule at `subscriptions.rs:208` (a `log::info!` on the relay -/// path made an intermittent failure vanish across six consecutive runs) still holds. -/// -/// Every enqueue goes through [`SessionTx::send`], so `depth()` is the true queue length rather -/// than an estimate: the wrapper is what makes that invariant hold without touching ~180 call -/// sites, because `tx.send(..)` resolves to this inherent method with the same signature -/// `Sender::send` had. +/// Blocking sends from SDK callbacks can deadlock the pump; keep this channel unbounded. #[derive(Clone)] pub struct SessionTx { tx: Sender, @@ -184,17 +116,13 @@ impl SessionTx { })); } - /// Close both socket directions even when the writer is blocked or its queue is full. pub(crate) fn close(&self) { if let Some(close) = &self.close_socket { close(); } } - /// Enqueue one outbound unit. Same signature as `mpsc::Sender::send`, so existing call sites - /// read identically; the only addition is the depth accounting. Incremented BEFORE the send so - /// the depth can never read low while an item is on the queue, and rolled back when the send - /// fails (a dead writer half means nothing was queued and nothing will be drained). + // Count before sending so depth cannot read low; undo the count if the send fails. pub fn send(&self, out: Outbound) -> std::result::Result<(), SendError> { self.depth.fetch_add(1, Ordering::Relaxed); match self.tx.send(out) { @@ -206,24 +134,16 @@ impl SessionTx { } } - /// Items queued but not yet handed to the socket. pub fn depth(&self) -> usize { self.depth.load(Ordering::Relaxed) } - /// The shared depth counter, for an observer that must NOT keep the channel alive (holding a - /// `SessionTx` clone would stop the writer's `rx.recv()` from ever returning `Err`). - /// - /// Test-only today: the shed path's production observer reads [`SessionTx::depth`] directly, and - /// the only consumer that needs a detached handle is `world::tests`' fake store. #[cfg(test)] pub fn depth_handle(&self) -> Arc { self.depth.clone() } } -/// One session's egress: the counted sender, the writer's receiver, and the shared depth the writer -/// decrements as it drains. Test seam too — a test can pre-load the depth to drive the shed path. pub fn session_channel() -> (SessionTx, Receiver, Arc) { let (tx, rx) = channel::(); let depth = Arc::new(AtomicUsize::new(0)); @@ -240,8 +160,6 @@ pub fn session_channel() -> (SessionTx, Receiver, Arc) { #[cfg(test)] impl SessionTx { - /// Test-only: a sender whose queue already claims `depth` items, for driving the shed path - /// without queueing half a thousand real packets. pub fn with_depth(depth: usize) -> (Self, Receiver) { let (tx, rx, d) = session_channel(); d.store(depth, Ordering::Relaxed); @@ -249,10 +167,6 @@ impl SessionTx { } } -/// A stream that can be split into an independent write handle and shut down to unblock a blocked -/// reader. Implemented for the production `TcpStream` and (test-only) `UnixStream`; both share the -/// underlying fd across clones, so the writer thread can own a clone while the reader keeps the -/// original. pub trait DuplexStream: Read + Write + IoDeadline + Send + Sized + 'static { fn try_clone(&self) -> std::io::Result; fn shutdown_both(&self) -> std::io::Result<()>; @@ -277,116 +191,52 @@ impl DuplexStream for std::os::unix::net::UnixStream { } } -/// Shared session material for one account, read from `game_session` (+ `game_account`). #[derive(Clone, Debug)] pub struct WorldSession { pub account_id: u64, - /// The 40-byte SRP6 session key K shared with the logon listener via `game_session`. pub session_key: [u8; 40], } -/// How an ITEM-target enchanting cast routes, derived from the spell's effect kind (not a spell id). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EnchantRoute { - /// E_ENCHANT_ITEM — apply this enchant id to the targeted item. Enchant(u32), - /// E_DISENCHANT — disenchant the targeted item into reagents. Disenchant, } -/// Activity floor for motion diagnostics. Counts below this leave short windows unclassified; -/// even high activity can legitimately have no peer audience. pub const MOVE_ACTIVITY_FLOOR: u64 = 100; -/// Accepted movements handed to shard-local shared batches. Fan-out diagnostics use this -/// process-wide count as their activity denominator. pub static MOVE_SUBMITTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -/// The connection's world-phase sub-state. Encodes the in-world invariant in the TYPE: the relay -/// subscriptions and combat/loot targets exist only while in-world, so the -/// dispatch arms match on the state instead of guarding scattered `Option`s. `CMSG_PLAYER_LOGIN` -/// moves `CharSelect → InWorld`; logout / socket teardown moves back, dropping `InWorld` and its -/// shared-view registration via `PlayerSubscriptions`. -// `InWorld` is ~400 bytes against `CharSelect`'s zero, which is exactly what -// `clippy::large_enum_variant` flags — deliberately NOT boxed. There is exactly ONE `WorldState` -// per connection (it lives in `WorldConn`, one per session thread), so boxing saves ~400 bytes per -// logged-OUT session and buys an extra pointer hop on every in-world dispatch arm, which is the -// hot path. The memory this enum is sized against is the per-session egress queue, orders of -// magnitude larger (see `EGRESS_SHED_DEPTH`). #[allow(clippy::large_enum_variant)] pub enum WorldState { - /// Character-select: post-handshake, pre-`CMSG_PLAYER_LOGIN`, or after a logout. CharSelect, - /// In the world with a selected character. InWorld(InWorld), } -/// The gossip menu one client is looking at. `options` is indexed by the `gossip_list_id` the client -/// echoes back; past the end is the trailing Farewell line, or a stale click. pub struct GossipMenuSnapshot { pub npc_guid: u64, - /// `(game_gossip_option.row_id, action)` per menu position. `row_id` is - /// `codec::SYNTHESIZED_ROW_ID` for the vendor/innkeeper lines the gateway added itself. pub options: Vec<(u32, u32)>, } -/// State that exists only while the player is in the world (the `InWorld` variant payload). pub struct InWorld { - /// The selected character's guid (names `SMSG_ATTACKSTART`/`SMSG_ATTACKSTOP`). pub self_guid: u64, - /// Shared-view lifetime guard. Its RAII `Drop` unregisters this viewer on logout or socket - /// teardown, so a relogin cannot inherit the old routing entry. pub subs: PlayerSubscriptions, - /// The guid being melee auto-attacked (combat C1), so `CMSG_ATTACKSTOP` can name it. The - /// authoritative engagement lives in `game_melee_attack`; this is protocol state. pub attacking_target: Option, pub open_loot: OpenLootState, - /// A ranged auto-repeat (Auto Shot / wand Shoot) is armed. Melee and ranged share one - /// `game_melee_attack` row keyed by attacker, so the melee-stop `CMSG_ATTACKSTOP` the client - /// sends when switching to ranged would collaterally kill the auto-shot. While this is set, - /// `CMSG_ATTACKSTOP` is ignored — the ranged loop is stopped only by - /// `CMSG_CANCEL_AUTO_REPEAT_SPELL`. pub ranged_repeat: bool, } -/// Established per-connection protocol state after a successful handshake, owned by the reader -/// thread. The `EncrypterHalf` is NOT here — it moves to the single writer thread, which is the -/// sole writer of the socket (the header cipher is a stateful stream, so exactly one writer may -/// advance it). NOT game state: the cipher is re-derivable from K on reconnect. +/// One writer must own the stateful header cipher; concurrent writes corrupt its stream. pub struct WorldConn { pub(crate) protocol: ProtocolSession, - /// Retained before world entry so failed routing or admission is also cleaned up. session_claim: Option, - /// Decrypts inbound CMSG headers (reader-thread only). pub decrypt: DecrypterHalf, - /// Per-connection movement coalescing. Lives directly on `WorldConn` (not - /// `InWorld`) so it survives a world-port/relogin boundary uneventfully — it only ever sees - /// traffic while `InWorld` movement is being dispatched, but there's no correctness reason to - /// tear it down on a state transition, and keeping it here avoids re-plumbing it through - /// `enter_world`. move_coalesce: CoalesceState, - /// Automatic client requests announce each missing feature once per World Session. unavailable_notices: std::collections::HashSet, - /// The Store every message of this session runs against. store: RoutedStore, - /// The shared session key K, kept from the world handshake so a session that lands on a NON- - /// realm shard can call `establish_session` THERE — the module's `account_by_identity` - /// gate on `player_login` needs the account row on that database bound to this connection's - /// identity, and the logon tier only ever bound it on the realm database. - /// - /// NOT game state, and not a new secret: it is the same K the header cipher on this very socket - /// is already keyed by, read from `game_session` moments earlier in `world_handshake`. `None` - /// for sessions built by tests that never handshake. session_key: Option<[u8; 40]>, - /// The Character that signed on to its Guild in this World Session. `leave_world` signs it - /// off even when a failed world-port left the state at CharSelect. guild_signed_on: Option, - /// Consecutive movement packets dropped because the coordinator cache has no live entity. - /// Reset by the first movement whose entity is present. See - /// [`MOVE_DESYNC_TOLERANCE`] for why the tolerance is bounded rather than unconditional. move_desync_drops: u32, - /// The Chat Flood Limiter. It lives on the connection, as cmangos keeps the mute on its - /// session, so a relog does not clear it. chat_flood: chat_flood::ChatFloodLimiter, } @@ -403,29 +253,10 @@ impl std::ops::DerefMut for WorldConn { } } -/// How many CONSECUTIVE desynced movement packets a session may drop before the desync is treated -/// as real and the session ends. -/// -/// The tail of a cross-map port is a handful of packets: the client stops sending movement the -/// instant `SMSG_TRANSFER_PENDING` puts it on the loading screen, so only what was already in -/// flight (hundreds of ms at ~10 packets/s) can land on the despawned entity. A desync that keeps -/// answering past that is the OTHER kind — the entity is gone for good (a schema-change publish tore -/// down the coordinator subscription; the row was deleted under this socket) — and for that one, -/// closing the socket IS the fix: the client shows "Disconnected" and re-materialises everything -/// from durable state on relog. Swallowing it forever would trade the loading-screen hang for a -/// worse one: a player walking around a frozen world, invisible to peers, never disconnected, with -/// no error and no recourse — which is exactly what `is_desync_error`'s session-fatal treatment -/// exists to prevent. 32 is ~3 s of a moving client, an order of magnitude more than any port tail. const MOVE_DESYNC_TOLERANCE: u32 = 32; -/// The cooldown one World Session's `CMSG_WHO` answers respect — a second request inside this -/// window is dropped, unanswered, matching vm:MiscHandler.cpp:230's concurrent-request drop for -/// the one Gateway-side realm-wide scan `/who` runs. const WHO_THROTTLE: Duration = Duration::from_secs(1); -/// The Store a World Session talks to: the listener's handle until world entry pins the -/// Character's Home Shard. Re-resolved from durable state on every world entry (login and -/// world-port), so a gateway restart mid-session re-derives it on the client's relog. pub(crate) struct RoutedStore { default: Arc, home: Option>, @@ -439,8 +270,6 @@ impl RoutedStore { } } - /// The pinned Home Shard, else the listener's handle. A clone, so the caller can hold it - /// while it mutates the session. pub(crate) fn current(&self) -> Arc { self.home.as_ref().unwrap_or(&self.default).clone() } @@ -455,18 +284,7 @@ impl RoutedStore { } impl WorldConn { - /// Re-pin this session to the shard that owns `character_guid`'s location. Asked of the - /// handle currently in use, so a `None` answer means "this handle already owns it" and the - /// current pin is KEPT — clearing it would silently drop the session back to the default shard. - /// Called at every world entry (login and world-port), never in between: routing is derived - /// from durable state, so nothing here survives a gateway restart that shouldn't. - /// Returns the transfer's error rather than swallowing it: a half-moved character must - /// fail its login loudly, never proceed into the world on whichever shard happened to answer. - /// Both are recoverable — the escrow holds and the next login re-drives it — but only one of - /// them is honest to the player. fn route_home(&mut self, character_guid: u64) -> Result<()> { - // For a session that actually crosses a database boundary this RUNS the escrowed transfer - // before answering. let resolved = self.store.current().settle_home_shard(character_guid)?; if let Some(h) = resolved { log::info!( @@ -476,22 +294,13 @@ impl WorldConn { ); self.store.pin(h); } - // Bind this account's identity on the shard we ended up on. On the realm shard the logon - // tier already did it; on an instance shard the character arrived through - // `import_character_blob`, which created only a SHADOW account row with no identity — and - // `world::player_login` resolves its caller through `account_by_identity`, so without this - // the arriving player cannot log in at all. Idempotent, and re-run on every world entry - // because a gateway restart establishes a fresh bound identity. if let (Some(h), Some(key)) = (&self.store.home, &self.session_key) { h.bind_shard_session(self.account_id, key)?; } Ok(()) } - /// Leave the world and release the matching durable Account claim, including failed entry. - /// A Guild member signs off first, while Realm-core still accepts the Account Claim. The - /// Character that signed on this session signs off even when a failed world-port left the - /// state at CharSelect. + /// Sign off from the Guild before releasing the Account Claim. fn leave_world(&mut self) -> Result<()> { let previous = std::mem::replace(&mut self.state, WorldState::CharSelect); let signed_on = self.guild_signed_on.take(); @@ -514,29 +323,13 @@ impl WorldConn { } } -/// How often a queued connection re-checks whether it has been admitted. Cheap — one mutex -/// lock, no I/O — so this can be short without costing anything measurable even under a full queue. const QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(250); -/// How often a queued connection actually WRITES a refreshed `AUTH_WAIT_QUEUE` to the wire (as -/// opposed to just checking, above). Bounded below by the spec's "every 5-10s or on position -/// change" — resending on every poll tick would spam the socket for no client-visible benefit, -/// since the position number is the only thing that can change. This is ALSO how promptly a clean -/// disconnect while queued is noticed (the write attempt is what surfaces the broken pipe), so the -/// test build uses a much shorter interval — real production behavior is unaffected either way, -/// since a queued connection holds no seat and wastes nothing by lingering briefly after a hangup. #[cfg(not(test))] const QUEUE_RESEND_INTERVAL: Duration = Duration::from_secs(5); #[cfg(test)] const QUEUE_RESEND_INTERVAL: Duration = Duration::from_millis(50); -/// Run the world auth handshake over `stream` (gateway-translation.md §3), gated by the unlimited -/// [`LoginQueue`] (`LoginQueue::unlimited()`) — see [`world_handshake_with_queue`] for the -/// admission gate itself. On success returns the established per-connection crypto + account id; on -/// a clean auth failure sends the failure response and returns `Ok(None)`; on a clean disconnect -/// also returns `Ok(None)`. -/// Test-only: the listener in [`run`] supplies the process-wide [`LoginQueue`] and the deadline it -/// started at accept, so an unqueued handshake exists purely for tests. #[cfg(test)] pub fn world_handshake( stream: &mut S, @@ -545,22 +338,6 @@ pub fn world_handshake( world_handshake_with_queue(stream, store, &LoginQueue::unlimited()) } -/// Run the world auth handshake over `stream` (gateway-translation.md §3), gated by `queue`. -/// On success returns the established per-connection crypto + account id; on a clean auth failure -/// sends the failure response and returns `Ok(None)`; on a clean disconnect also returns `Ok(None)`. -/// -/// Sequence: plaintext `SMSG_AUTH_CHALLENGE` (server seed) -> read plaintext -/// `CMSG_AUTH_SESSION` -> look up K -> validate the client proof digest via `wow_srp` (which -/// also yields the header cipher) -> **admission gate** (queue here if the world is full, -/// resending `AUTH_WAIT_QUEUE` with the caller's position until a seat opens) -> **encrypted** -/// `SMSG_AUTH_RESPONSE` (AUTH_OK). From the AUTH_OK packet on, every header is encrypted — and so is -/// every `AUTH_WAIT_QUEUE` resend, since the cipher already exists by the time the gate is consulted. -/// -/// A seat taken from `queue` is held for the lifetime of the returned session; the caller (normally -/// [`run_world_session_with_queue`]) MUST call `queue.depart()` exactly once when that session ends. -/// The final `AUTH_OK` write is the only failure mode inside this function after admission; it -/// departs its own seat before propagating the error. A caller that never sees `Ok(Some(..))` never -/// owes a `depart()` call. #[cfg(test)] pub fn world_handshake_with_queue( stream: &mut S, @@ -577,17 +354,12 @@ fn world_handshake_with_queue_and_deadline, ) -> Result> { - // 1. Plaintext SMSG_AUTH_CHALLENGE with a fresh, single-use server seed (gateway-local - // RNG — protocol state, not game state). let seed = ProofSeed::new(); let server_seed = seed.seed(); SMSG_AUTH_CHALLENGE { server_seed }.write_unencrypted_server(&mut deadline.io(stream))?; - // 2. Read the plaintext CMSG_AUTH_SESSION (account, client_seed, client_proof). Hand-decoded: - // the typed decoder sizes a buffer from a client field and unwraps the addon zlib. let session = match read_auth_session(&mut deadline.io(stream)) { Ok(claim) => claim, - // Clean client disconnect before sending the session. Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), Err(e) if is_io_deadline(&e) => { return Err(anyhow!( @@ -597,22 +369,17 @@ fn world_handshake_with_queue_and_deadline return Err(anyhow!("world auth read error: {e}")), }; - // 3. Map account -> K (read from game_session via the privileged coordinator connection). let username = session.username.to_uppercase(); let Some(WorldSession { account_id, session_key, }) = store.lookup_session(&username)? else { - // No session for this account: the client never authenticated (or it expired). No - // cipher yet, so the rejection goes out plaintext. SMSG_AUTH_RESPONSE::AuthUnknownAccount .write_unencrypted_server(&mut deadline.io(stream))?; return Ok(None); }; - // 4. Validate the client proof digest against K. Success also constructs the header - // cipher keyed by K (a mismatch == the client does not actually hold K -> reject). let ns = NormalizedString::new(&username).map_err(|e| anyhow!("bad username: {e}"))?; let crypto = match seed.into_server_header_crypto( &ns, @@ -622,34 +389,23 @@ fn world_handshake_with_queue_and_deadline c, Err(_) => { - // Bad digest. No cipher established, so the failure goes out plaintext. SMSG_AUTH_RESPONSE::AuthFailed.write_unencrypted_server(&mut deadline.io(stream))?; return Ok(None); } }; let (mut encrypt, decrypt) = crypto.split(); - // The peer has proven it holds K. Queue waits and the World Session that follows are - // authenticated traffic, so neither keeps the pre-auth socket timeouts or watchdog. deadline .finish(stream) .map_err(|e| anyhow!("clear world pre-auth I/O deadline: {e}"))?; - // 4.5. Admission gate. `queue.request()` on an unlimited queue (the default — - // `LYRACORE_MAX_SESSIONS` unset) returns `Admitted` immediately with no bookkeeping: a no-op for - // every gateway that hasn't opted in. Otherwise a full house queues the connection here, BEFORE - // it can reach CMSG_CHAR_ENUM or CMSG_PLAYER_LOGIN's `subscribe_player_events` — the expensive - // part measured at 38% of the writer at 500 sessions. if let Admission::Queued(ticket) = queue.request() { if !wait_for_admission(&mut *stream, &mut encrypt, queue, ticket)? { - // Clean disconnect while queued: leave the line (no seat was ever taken) and end the - // session exactly like the pre-auth disconnect path above — no AUTH_OK is ever sent. queue.cancel(ticket); return Ok(None); } } - // 5. SMSG_AUTH_RESPONSE = AUTH_OK + zeroed billing. From here on every header is encrypted. if let Err(e) = (SMSG_AUTH_RESPONSE::AuthOk { billing_flags: 0, billing_rested: 0, @@ -657,9 +413,6 @@ fn world_handshake_with_queue_and_deadline( } } -/// Corruption-diagnosis probe: cheap FNV-1a-64 over a byte slice — not cryptographic, just enough to tell "same -/// bytes" from "different bytes" across two independently-captured rings (this one and -/// the wire harness's) without paying for anything stronger than a multiply-xor per byte. fn fnv1a64(bytes: &[u8]) -> u64 { let mut hash: u64 = 0xcbf29ce484222325; for &b in bytes { @@ -724,10 +474,6 @@ fn fnv1a64(bytes: &[u8]) -> u64 { hash } -/// One traced frame: the wire-visible `(opcode, declared size)` pair plus a checksum of the body -/// bytes — the same triple the wire harness's own crash ring reports (opcode, -/// declared_body_len, actual_body_len), plus the one field that ring can't have (it only ever -/// sees bytes after the client's decoder already trusts them): whether the CONTENT matches. #[derive(Clone, Copy)] struct WriterTraceEntry { opcode: u16, @@ -735,19 +481,6 @@ struct WriterTraceEntry { checksum: u64, } -/// Corruption-diagnosis probe: per-session black box for [`spawn_writer`] — the last [`WriterTrace::CAPACITY`] -/// frames handed to the socket, gated behind `LYRACORE_WRITER_TRACE=1` (see -/// `config::writer_trace_enabled`). Recorded from the CONSTRUCTED frame just before the write -/// (unencrypted representation for the typed `One`/`Batch` arms — cheap only because tracing is -/// off by default — matched byte-for-byte against `Outbound::Raw`'s own `(opcode, body)`, since -/// `Raw` never touches gtker's codec at all), not a tee of the physical socket call: a *partial* -/// write cannot silently masquerade as success here (`write_all` is complete-or-`Err` on a -/// blocking socket, and this connection's writer treats every `Err` as session-fatal — see the -/// dump call site), so recording pre-write and recording post-write carry the same information -/// for every frame that reaches this ring. What this DOES catch: the gateway constructing wrong -/// bytes for a frame (a content/data bug) — diff this ring's `(opcode, size)` sequence against -/// the harness's `(opcode, declared_body_len)` sequence at the corruption offset: if they still -/// agree past the last frame this ring saw, the corruption is downstream of the writer. struct WriterTrace { ring: std::collections::VecDeque, } @@ -772,8 +505,6 @@ impl WriterTrace { }); } - /// Record one [`Outbound`] just before it is handed to the writer's match arm. `Batch` - /// expands to one entry per contained message — each is its own frame on the wire. fn record(&mut self, out: &Outbound) { match out { Outbound::One(m) => self.record_typed(m), @@ -785,33 +516,20 @@ impl WriterTrace { Outbound::Raw { opcode, body } => { self.push(*opcode, (2 + body.len()) as u16, fnv1a64(body)); } - // Jobs are expanded before the trace sees them (`spawn_writer`), so this arm is - // unreachable for a real frame; it exists so a future caller cannot silently skip the - // ring by wrapping a packet in a job. Outbound::Job(_) => {} } } - /// `write_unencrypted_server` is the exact plaintext framing gtker's `write_encrypted_server` - /// builds internally before encrypting only the 4 header bytes (traits/vanilla.rs) — so this - /// reproduces `[size_be:2][opcode_le:2][body]` without needing to invert the header cipher, - /// at the cost of one extra serialization pass. That cost is real but exists ONLY behind - /// `LYRACORE_WRITER_TRACE=1` — see its doc comment for why production pays nothing for this. fn record_typed(&mut self, m: &ServerOpcodeMessage) { let mut buf = Vec::new(); if m.write_unencrypted_server(&mut buf).is_err() || buf.len() < 4 { - return; // can't happen on a real message; never worth crashing the trace over + return; } let size = u16::from_be_bytes([buf[0], buf[1]]); let opcode = u16::from_le_bytes([buf[2], buf[3]]); self.push(opcode, size, fnv1a64(&buf[4..])); } - /// Dump the ring to `/tmp/gw-writer-crash/.txt` on abnormal session end (a write - /// error — the only ending `spawn_writer` treats as abnormal; a clean disconnect drops `tx` - /// and the loop just ends via `rx.recv()` returning `Err`, which calls nothing here). Mirrors - /// the wire harness's own crash-dump shape so the two files sit next to each other for a - /// diff. Best-effort: a dump failing must never be why the writer thread panics. fn dump(&self, account_id: u64, reason: &str) { self.dump_to( std::path::Path::new("/tmp/gw-writer-crash"), @@ -848,19 +566,6 @@ impl WriterTrace { } } -/// The single writer thread: the SOLE owner of the `EncrypterHalf` and a write-clone of the -/// socket. It drains the channel and writes each `Outbound` in dequeue order, so the header -/// cipher's keystream advances deterministically and no two writers ever interleave. On a write -/// error it shuts the socket down to unblock the reader's blocking read. -/// -/// `account_id` exists ONLY to name the `LYRACORE_WRITER_TRACE=1` crash dump — every other -/// behavior here is byte-for-byte what it was before that flag existed. -/// -/// Returns `Err` (rather than panicking) when the OS refuses the thread: at the session counts this -/// gateway is built for, thread/FD exhaustion is a REACHABLE operational state, and the honest -/// response is to fail THIS connection — the caller `?`s it exactly like the socket clone above it, -/// so the session closes cleanly and its queue seat is released — instead of unwinding a session -/// thread and taking whatever it holds with it. fn spawn_writer( mut wsock: S, mut encrypt: EncrypterHalf, @@ -874,15 +579,6 @@ fn spawn_writer( .spawn(move || { let mut trace = trace_enabled.then(WriterTrace::new); while let Ok(out) = rx.recv() { - // The other half of the depth counter (`SessionTx::send` adds): this item is off the - // queue, so it no longer counts against `EGRESS_SHED_DEPTH` — decrement BEFORE the - // (possibly slow, possibly blocking) write, so a session stuck inside one `write_all` - // is not charged for the packet it is actively pushing. - // - // Cannot underflow, and that matters because a wrapped `usize` would shed every - // peer-motion packet for the rest of the session: the increment happens strictly - // BEFORE `tx.send`, so an item can only be received after it has been counted, and - // this thread is the only decrementer. `depth >= queued` therefore holds always. depth.fetch_sub(1, Ordering::Relaxed); let batch: Vec = match out { @@ -907,43 +603,20 @@ fn spawn_writer( } let res = match out { Outbound::One(m) => { - log::debug!("OUT {m}"); // SMSG variant name → crash attribution (tail 'OUT SMSG_*') + log::debug!("OUT {m}"); m.write_encrypted_server(&mut wsock, &mut encrypt) } Outbound::Batch(v) => v.into_iter().try_for_each(|m| { log::debug!("OUT {m}"); m.write_encrypted_server(&mut wsock, &mut encrypt) }), - // Expanded above; a job can never reach here. Outbound::Job(_) => Ok(()), Outbound::Raw { opcode, body } => { log::debug!("OUT RAW opcode=0x{opcode:04X} ({} body bytes)", body.len()); - // Packet lint (testing-hardening §3.2): every hand-rolled body is checked - // against the root-caused 5875 crash classes; a hit is a loud ERROR the - // suite greps for — never a drop (the frame still sends: prod behavior is - // observability, the WALL is the suite failing on the grep). for v in packet_lint::lint_raw(opcode, &body) { log::error!("packet-lint VIOLATION opcode=0x{opcode:04X}: {v}"); } - // Same framing gtker's write_encrypted_server emits: a `size` of - // opcode(2) + body, big-endian, then the LE opcode, the 4 bytes encrypted - // through the SAME header cipher (so the stream never desyncs), then the body. - // size counts opcode(2) + body, NOT the 2 size bytes — matches gtker's - // server framing. - // - // Hardening: a body that overflows the u16 size field would - // silently WRAP via `as u16` — the header would then declare a small size - // while `write_all` below still sends the full (large) body, so the client's - // reader consumes the wrong number of bytes for this frame and every LATER - // header on this connection decrypts from the wrong stream offset (a real - // desync, indistinguishable from the crowd-scale corruption class investigated - // earlier). This used - // to be a `debug_assert!`, which compiles out entirely in the release profile - // the capacity benchmark and any live deploy actually run — i.e. the one guard - // against this class was absent exactly where it would matter. No current - // builder produces a body anywhere near this size, so this arm is not known to - // fire; it exists so that if one ever does, the connection dies cleanly - // instead of corrupting itself and every packet after it. + // Size counts the two-byte opcode and body, excluding itself. if body.len() > u16::MAX as usize - 2 { log::error!( "packet-lint VIOLATION opcode=0x{opcode:04X}: raw body is {} bytes — \ @@ -978,17 +651,11 @@ fn spawn_writer( .map_err(|e| anyhow!("spawn world writer thread for account {account_id}: {e}")) } -/// Drive one world connection, gated by the unlimited [`LoginQueue`] — see -/// [`run_world_session_with_queue`] for the admission gate itself. Every existing call site -/// (tests, and anything that doesn't care about it) stays byte-identical to the pre-queue behavior. -/// Test-only, for the same reason as [`world_handshake`]: production supplies the listener's shared -/// queue and the deadline started at accept. #[cfg(test)] pub fn run_world_session(stream: S, store: Arc) -> Result<()> { run_world_session_with_queue(stream, store, &LoginQueue::unlimited()) } -/// A successful handshake owns one seat until its writer has stopped. struct AdmissionSeat<'a> { queue: &'a LoginQueue, } @@ -999,19 +666,6 @@ impl Drop for AdmissionSeat<'_> { } } -/// Drive one world connection: handshake (synchronous, sole writer), then split into a reader -/// (this thread, owns `DecrypterHalf`) and a writer thread (owns `EncrypterHalf` + a socket clone) -/// bridged by an mpsc channel. The reader handles requests and pushes responses; the per-player -/// subscription callbacks (on the SDK pump thread) push peer spawn/move/destroy. On disconnect the -/// reader tears down the subscriptions, logs the player out (removes the entity → peers see -/// `DESTROY`), drops its sender (ending the writer), and joins it. -/// -/// `world_handshake_with_queue` may block here (before any of the above starts) if the world -/// is full — the client sees native "Position in queue" screens and this thread just waits, which -/// costs nothing but ITS OWN thread (the listener in `run` spawns one `spawn_blocking` task per -/// accepted socket, so a queued connection never holds up anyone else's accept or handshake). Once -/// admitted (`Ok(Some(..))`), this connection holds a seat in `queue` for the rest of the function — -/// released by its [`AdmissionSeat`] exactly once, no matter which branch got there. #[cfg(test)] pub fn run_world_session_with_queue( stream: S, @@ -1047,13 +701,6 @@ fn run_world_session_with_queue_and_deadline( let writer = spawn_writer(wsock, encrypt, rx, depth, conn.account_id)?; let result = (|| -> Result<()> { - // Frames are read raw (header hand-decrypted) so an addon chat frame with the bridge's - // own prefix can go to the bridge before typed parsing. Every other frame, addon frames - // from other prefixes included, is re-framed unencrypted and handed to the gtker parser - // (one memcpy per packet). - // Addon-bridge rate limit: token bucket per connection, 2 tokens/s, burst 20. - // Excess frames drop with one log line per offense window; module handlers stay - // unthrottled (they trust this edge like every other opcode). let mut addon_tokens: f32 = 20.0; let mut addon_refill_at = std::time::Instant::now(); let mut addon_drop_logged_at: Option = None; @@ -1063,7 +710,7 @@ fn run_world_session_with_queue_and_deadline( Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), Err(e) => return Err(anyhow!("world read error: {e}")), }; - // `hdr.size` counts the u32 opcode + body. + // Client size counts the four-byte opcode and body. let body_len = (hdr.size as usize).saturating_sub(4); let mut body = vec![0u8; body_len]; stream @@ -1089,14 +736,11 @@ fn run_world_session_with_queue_and_deadline( conn.account_id ); } - continue; // bridge frames never reach the normal chat path or other players + continue; } if !codec::addon::typed_addon_chat_type(chat_type) { - continue; // no addon traffic reaches this chat type; drop, as before + continue; } - // A PARTY, RAID, GUILD or OFFICER frame from another addon's prefix: fall - // through below so the typed reader and the ordinary chat path deliver it to - // real players, addon language and all. } } if hdr.opcode == CMSG_AUCTION_LIST_ITEMS_OPCODE { @@ -1113,7 +757,6 @@ fn run_world_session_with_queue_and_deadline( continue; } if hdr.opcode == CMSG_AUTH_SESSION_OPCODE { - // Auth is over, and the typed decoder would size a buffer from the body. return Err(anyhow!("world read error: CMSG_AUTH_SESSION after auth")); } if hdr.opcode == 0x0125 { @@ -1157,16 +800,11 @@ fn run_world_session_with_queue_and_deadline( .map_err(|e| anyhow!("world read error: {e}"))? } }; - // Every in-world message is dispatched against the player's HOME shard; until - // CMSG_PLAYER_LOGIN resolves one (and always, with a single-entry shard map) this is - // the handle the listener passed in. let store = conn.store.current(); dispatch(&tx, &*store, &mut conn, hdr.opcode, msg.into())?; } })(); - // Movement-coalescing measurement: how much did coalescing actually cut? Logged once per - // connection at disconnect, not per-packet — a live run's tail shows the ratio directly. log::debug!( "world: movement coalescing account={} received={} forwarded={}", conn.account_id, @@ -1174,22 +812,16 @@ fn run_world_session_with_queue_and_deadline( conn.move_coalesce.forwarded(), ); - // Teardown: if still in-world, drop the relay subs (stops callbacks; peers get DESTROY via the - // entity delete) and delete the entity ONLY if THIS session still owns it — a stale socket whose - // player already re-logged on a newer session declines, so we don't vanish the live player. - // Teardown is already ending the session, so a `logout` failure is logged + swallowed (not fatal). if let Err(e) = conn.leave_world() { log::warn!("logout for account {} failed: {e:#}", conn.account_id); } drop(tx); let _ = writer.join(); - // The seat covers relay shutdown and releases exactly once after the writer stops. + // Release the seat exactly once, after the writer and Relays stop. drop(seat); result } -/// Does the Character hold a GM level on its Home Shard? A failed read counts as no: the only -/// effect is that a flooding game master is muted like any other speaker. fn is_game_master(store: &St, character_guid: u64) -> bool { match store.speaker_gm_level(character_guid) { Ok(gm_level) => gm_level > 0, @@ -1200,9 +832,6 @@ fn is_game_master(store: &St, character_guid: u64) } } -/// The speaker's race, for the Chat Flood Limiter's language check. `None` when the read fails or -/// the speaker has no live entity here: the line still counts as usual, and the Module's own -/// language Gate stays the fallback authority. fn speaker_race(store: &St, character_guid: u64) -> Option { match store.speaker_facts(character_guid) { Ok(facts) => facts.map(|facts| facts.race), @@ -1213,11 +842,6 @@ fn speaker_race(store: &St, character_guid: u64) - } } -/// Route one bridge-prefixed addon chat frame: parse the `STC` v1 envelope and forward to the -/// module's `client_command` reducer as the player. The caller already checked the prefix; a -/// malformed envelope past that point still drops silently-with-a-debug-line (a truncated or -/// hand-edited frame is not session-fatal), and so does a frame outside the world or a Refusal. -/// A Transport Loss ends the World Session. fn handle_addon_message( store: &St, conn: &WorldConn, @@ -1249,26 +873,11 @@ fn handle_addon_message( } } -/// Does this reducer error mean the player DESYNCED from the module — their `game_world_entity` row is -/// gone? This happens when a schema-change publish tears down the gateway's coordinator subscription -/// mid-session (the gateway keeps the old, now-stale view, so `entity_by_owner` resolves nothing), or the -/// entity was deleted out from under this socket. Unlike a TRANSIENT per-action failure (a dead target, -/// an out-of-range cast, a retarget race), a desync means NO further action can ever be served on this -/// session — so callers propagate it as session-FATAL to force a clean disconnect: `run_world_session` -/// unwinds, closes the socket, and the client shows "Disconnected" and re-materializes everything on -/// relog from durable state, instead of the player silently hanging (can't attack, can't log out). Matched -/// on the module's `entity_by_owner` failure text ("… not in world" / "no live entity …"). -/// -/// ONE caller does not propagate it immediately: `forward_movement` tolerates -/// [`MOVE_DESYNC_TOLERANCE`] consecutive desynced movement packets first, because the tail of a -/// cross-map port lands on a legitimately despawned entity and killing the socket there hangs the -/// client on a loading screen. Past that bound it is fatal like everything else. fn is_desync_error(e: &anyhow::Error) -> bool { let s = e.to_string().to_ascii_lowercase(); s.contains("not in world") || s.contains("no live entity") } -/// Flush movement and apply the Chat Flood Limiter before selecting one opcode owner. fn dispatch( tx: &SessionTx, store: &dyn WorldStore, @@ -1301,22 +910,8 @@ fn dispatch( return complete_reply(tx, store, conn, reply); } let msg = request.message()?; - // MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events - // come back on this player's game_movement_event subscription and are re-emitted (same - // opcode + verbatim MovementInfo) to other players by their own subscription callbacks. - // - // Every inbound movement packet is classified + coalesced by `CoalesceState` - // before it ever reaches the module — a STATE-CHANGE (or a heartbeat whose flags/heading - // drifted) forwards immediately, byte-identical to before this item; a pure same-vector - // heartbeat is HELD until the cadence window elapses (or something else flushes it), dropping - // the sub-yard same-vector intermediates the item targets. See `coalesce.rs` for the decision - // table and unit tests, and `docs/` for the item's write-up. if let Some(opcode) = codec::relayed_move_opcode(&msg) { - // 058: a landing may hurt. The MODULE applies the damage inside movement_update (it parses - // fall_time from the raw MovementInfo body); this sends only the "You fall and lose N - // health" flavor line — computed from the SAME lyracore_shared::env curve, so the line and the - // health drop can never disagree. gtker types the wire's u32-ms fall time as f32 (a - // field-TYPE lie; cmangos reads u32) — reinterpret the raw bits. + // gtker types fall_time as f32, but vanilla stores u32 milliseconds; reinterpret the bits. if let ClientOpcodeMessage::MSG_MOVE_FALL_LAND(mv) = &msg { if let WorldState::InWorld(iw) = &conn.state { let fall_time_ms = if mv.info.flags.get_on_transport().is_some() { @@ -1406,10 +1001,7 @@ fn complete_reply( }) } -/// A root or unroot receipt carries where the client stopped and its new flags. Peers still hold the -/// last relayed run, so the receipt is relayed to them as a stop. A root receipt drops any steering -/// flags so the Module's root gate accepts it. The typed reader skips the build-5875 transport tail, -/// so a receipt on a transport is not relayed. +/// For receipt and transport constraints, see docs/benilla-compatibility.md#root-receipts. fn root_receipt_stop(msg: &ClientOpcodeMessage, self_guid: Option) -> Option { let (guid, info, rooted) = match msg { ClientOpcodeMessage::CMSG_FORCE_MOVE_ROOT_ACK(ack) => (ack.guid, &ack.info, true), @@ -1419,8 +1011,7 @@ fn root_receipt_stop(msg: &ClientOpcodeMessage, self_guid: Option) -> Optio if Some(guid.guid()) != self_guid { return None; } - // The build-5875 encoder refuses a transport flag whose tail the typed reader skipped. The - // library's `clear_*` flag methods mask with `reverse_bits`, so steering is cleared on the bytes. + // The library clears flags with reverse_bits, so clear steering in the encoded bytes. let mut bytes = codec::movement_info_to_bytes(info).ok()?; if rooted { const FORWARD_BACKWARD_STRAFE: u8 = 0xF; @@ -1435,7 +1026,6 @@ fn forward_movement( opcode: u32, info: &MovementInfo, ) -> Result<()> { - // The shared-call path names the mover by guid instead of by connection. let self_guid = match &conn.state { WorldState::InWorld(iw) => iw.self_guid, _ => 0, @@ -1462,26 +1052,17 @@ fn forward_movement( }; conn.move_desync_drops = 0; store.movement_update(actor, opcode, info)?; - // AOI: recenter this player's grid-scoped entity subscription if they crossed a cell. No-op - // when AOI is disabled (no tracker) or the player stayed in-cell. Same-map (the tracker holds - // the login map; teleport/zone changes re-anchor in a later phase). if let WorldState::InWorld(iw) = &mut conn.state { iw.subs.aoi_update(info.position.x, info.position.y); } Ok(()) } -/// Push one outbound unit to the writer; a closed channel means the writer/socket is gone, so end -/// the session cleanly. fn send(tx: &SessionTx, out: Outbound) -> Result<()> { tx.send(out) .map_err(|_| anyhow!("world writer gone (client disconnected)")) } -// =========================================================================================== -// Async listener (bridges accepted sockets to the blocking handler) -// =========================================================================================== - pub async fn run( cfg: GatewayConfig, coordinator: Coordinator, @@ -1490,7 +1071,6 @@ pub async fn run( let listener = TcpListener::bind(&cfg.world_bind).await?; log::info!("world listening on {}", cfg.world_bind); - // gateway::run validates admission limits before the Coordinator connects or either listener starts. if login_queue.max_sessions() > 0 { log::info!( "world: login queue ARMED — max_sessions={} admit_concurrency={}", @@ -1507,29 +1087,17 @@ pub async fn run( ); } - // Dump the motion-relay counters every 10s from a background task, so a failing - // run is localised WITHOUT touching the relay hot path (a log there masks the race entirely). let queuestat_queue = login_queue.clone(); tokio::spawn(async move { use std::sync::atomic::Ordering::Relaxed; let (mut pc, mut ps, mut pd) = (0u64, 0u64, 0u64); let mut idle_logged = false; let mut prev_sub = 0u64; - // The AOI churn counter, read the same way MOTION_* is above — one atomic, one - // 10s delta, no hot-path log line. let mut prev_recenters = 0u64; - // Trailing fan-out baseline + collapsed-window run, for the fan-out-collapse warning at the - // bottom of the loop. Task-local: this is the only place it lives, and nothing on the relay - // path reads it. let mut fan = crate::stdb::subscriptions::FanoutHealth::default(); - // QUEUESTAT baseline — same "diff against last reading" convention as MOTIONSTAT. let mut prev_admitted = queuestat_queue.admitted_total(); loop { tokio::time::sleep(std::time::Duration::from_secs(10)).await; - // QUEUESTAT: printed only while the line is NONEMPTY — an unconfigured or - // uncontended gateway must not print this every 10s forever, same rule as AOISTAT. - // Also serves as a drain backstop (`LoginQueue::tick`) in case every queued - // connection's own poll cadence is somehow slower than this. queuestat_queue.tick(); let depth = queuestat_queue.depth(); let admitted = queuestat_queue.admitted_total(); @@ -1550,13 +1118,7 @@ pub async fn run( crate::stdb::subscriptions::MOTION_DROPPED.load(Relaxed), ); let sub = MOVE_SUBMITTED.load(Relaxed); - // Only on CHANGE, plus one line on the transition to idle — a long-lived server with - // nobody moving must not print this forever. if c != pc || s != ps || d != pd { - // The ratios are FORMATTED here, not left to the operator's - // arithmetic — 371 co-located clients under-delivered peer movement by 37 % and - // this line said nothing, because raw totals climb just as fast when each one is - // reaching fewer observers than it should. log::info!( "{}", crate::stdb::subscriptions::motionstat_line( @@ -1575,10 +1137,6 @@ pub async fn run( log::info!("MOTIONSTAT calls={c} sent={s} dropped={d} — relay went idle"); idle_logged = true; } - // AOI subscription-churn counter — recenters (cell crossings → box - // re-subscribes) across every session in the last 10s. Only printed on change, same - // rule as MOTIONSTAT above; a server with nobody crossing a cell boundary must not - // print this every 10s forever. let recenters = crate::stdb::aoi::AOI_RECENTERS.load(Relaxed); let d_recenters = recenters.saturating_sub(prev_recenters); if d_recenters > 0 { @@ -1589,8 +1147,6 @@ pub async fn run( } prev_recenters = recenters; let submitted_delta = sub.saturating_sub(prev_sub); - // Compare per-viewer fan-out with its recent baseline. Coordinator watchdogs measure - // incoming motion separately, before audience selection and writer work. let fanout = crate::stdb::subscriptions::fanout_ratio(c.saturating_sub(pc), submitted_delta); let (next_fan, fanout_warn) = @@ -1615,9 +1171,6 @@ pub async fn run( (pc, ps, pd) = (c, s, d); } }); - // The loop that `Error: Too many open files (os error 24)` came out of on 2026-08-07, - // taking every session on the realm with it. A transient accept errno now costs ONE connection. - // See `crate::accept` for which errnos are fatal and why the list is shaped that way. let mut backoff = AcceptBackoff::new(); let mut capacity_was_full = false; loop { @@ -1652,7 +1205,6 @@ pub async fn run( } }, }; - // The budget begins at accept, before this socket can wait for a blocking-pool thread. let mut deadline = PreAuthDeadline::after(WORLD_AUTH_DEADLINE); let Some(task_permit) = cfg.blocking_task_capacity.try_admit() else { if !capacity_was_full { @@ -1667,12 +1219,6 @@ pub async fn run( capacity_was_full = false; let coord = coordinator.clone(); let queue = login_queue.clone(); - // wow_world_messages uses blocking std::io codecs, so run the per-connection state - // machine on a blocking task with the socket in blocking mode (mirrors `logon`). - // Per-SOCKET calls (a dup and an fcntl on the fd we just accepted), so they fail for the - // same reasons accept does — EMFILE above all. Drop the one connection, keep the realm. - // The session clears both socket timeouts and cancels the watchdog after client proof, - // before any legitimate wait in the login queue. let std_sock = match sock.into_std().and_then(|s| { s.set_nonblocking(false)?; Ok(s) @@ -1689,8 +1235,6 @@ pub async fn run( } tokio::task::spawn_blocking(move || { let _task_permit = task_permit; - // `queue` gates admission INSIDE the handshake — a queued connection just blocks - // this one `spawn_blocking` thread, never the accept loop above. if let Err(e) = run_world_session_with_queue_and_deadline( std_sock, Arc::new(coord), diff --git a/gateway/src/world/npc_tests.rs b/gateway/src/world/npc_tests.rs index 4a8c6e574..7c4c38ae4 100644 --- a/gateway/src/world/npc_tests.rs +++ b/gateway/src/world/npc_tests.rs @@ -1,10 +1,5 @@ -//! NPC and query opcodes over an encrypted World Session: item and NPC text queries, inspect, and -//! gossip menus. - use super::*; -/// A restrictive imported equipment template. The query response is cached by the client and is -/// shared by inventory, vendor, and quest-reward screens, so one socket test pins that definition. fn human_warrior_sword_template() -> codec::ItemTemplateView { codec::ItemTemplateView { entry: 910_261, @@ -23,12 +18,12 @@ fn human_warrior_sword_template() -> codec::ItemTemplateView { damage_min: 9.0, damage_max: 17.0, delay_ms: 2_400, - required_skill: 43, // Swords + required_skill: 43, required_skill_rank: 150, required_reputation_faction: 72, required_reputation_rank: 5, - allowed_class: 0x01, // Warrior - allowed_race: 0x01, // Human + allowed_class: 0x01, + allowed_race: 0x01, ..Default::default() } } @@ -74,8 +69,6 @@ fn item_query_preserves_imported_eligibility_through_the_encrypted_world_session template.required_reputation_rank ); - // These are client-local eligibility terms, deliberately limited to the fields this cached - // definition contains. They prove the fixture is restrictive rather than an all-bits mask. let client_can_use = |class: u32, race: u32, skill: Option<(u32, u32)>| { found.allowed_class.as_int() & class != 0 && found.allowed_race.as_int() & race != 0 @@ -83,10 +76,10 @@ fn item_query_preserves_imported_eligibility_through_the_encrypted_world_session id == u32::from(found.required_skill.as_int()) && rank >= found.required_skill_rank }) }; - assert!(client_can_use(0x01, 0x01, Some((43, 150)))); // Human Warrior with Swords 150 - assert!(!client_can_use(0x80, 0x01, Some((43, 150)))); // excluded Mage - assert!(!client_can_use(0x01, 0x02, Some((43, 150)))); // excluded Orc - assert!(!client_can_use(0x01, 0x01, None)); // missing sword proficiency + assert!(client_can_use(0x01, 0x01, Some((43, 150)))); + assert!(!client_can_use(0x80, 0x01, Some((43, 150)))); + assert!(!client_can_use(0x01, 0x02, Some((43, 150)))); + assert!(!client_can_use(0x01, 0x01, None)); drop(client); server.join().unwrap(); @@ -109,8 +102,6 @@ fn inspect_in_range_friendly_target_replies_smsg_inspect_with_the_target_guid() server.join().unwrap(); } -/// `enter_world` for a test that needs to see how the session ended: the server thread hands back -/// its verdict instead of unwrapping it. fn enter_world_unjoined( store: std::sync::Arc, guid: u64, @@ -132,8 +123,6 @@ fn enter_world_unjoined( (client, c_enc, c_dec, server) } -/// A Transport Loss leaves the inspect gate's outcome unknown, so it ends the World Session. A -/// Refusal (above) only drops the request. #[test] fn inspect_transport_loss_ends_the_world_session() { let mut s = quest_store(); @@ -158,8 +147,6 @@ fn inspect_transport_loss_ends_the_world_session() { ); } -/// Say, yell and `/e` lines go to the speaker's Home Shard. A lost transport leaves the line's -/// fate unknown, so it ends the World Session. #[test] fn a_say_lost_to_a_transport_loss_ends_the_world_session() { let mut s = quest_store(); @@ -180,8 +167,6 @@ fn a_say_lost_to_a_transport_loss_ends_the_world_session() { ); } -/// The Gateway finds a mistyped GM Account itself. The GM reads the reason as a system line and -/// the World Session goes on. #[test] fn a_gm_command_for_an_unknown_account_answers_a_system_line_and_keeps_the_session() { let mut s = quest_store(); @@ -213,8 +198,6 @@ fn a_gm_command_for_an_unknown_account_answers_a_system_line_and_keeps_the_sessi #[test] fn inspect_refused_target_sends_no_reply() { - // CMSG_PLAYED_TIME (below) always replies as long as `character_by_guid` resolves the caller's - // own guid, so give the store a character row for guid 1 (quest_store() has none). let store = std::sync::Arc::new({ let base = quest_store(); WorldFake { @@ -226,18 +209,14 @@ fn inspect_refused_target_sends_no_reply() { } }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); - // guid 0 is the mock store's stand-in for "out of range / no such target" — the gate rejects it - // and the handler drops the request silently (mirrors CMSG_GAMEOBJ_USE/CMSG_AREATRIGGER). CMSG_INSPECT { guid: Guid::new(0) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Sentinel: a follow-up request with a guaranteed reply. If the refused CMSG_INSPECT had - // wrongly produced an SMSG_INSPECT, it would arrive first and this match would fail. CMSG_PLAYED_TIME {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_PLAYED_TIME(_) => {} // no SMSG_INSPECT was sent for the refused target + ServerOpcodeMessage::SMSG_PLAYED_TIME(_) => {} other => { panic!("expected SMSG_PLAYED_TIME (no SMSG_INSPECT for refused target), got {other}") } @@ -306,8 +285,6 @@ fn gossip_select_on_a_petitioner_option_opens_the_charter_list() { #[test] fn gossip_select_on_a_vendor_opens_the_inventory_window() { - // Option 0 on a stocked NPC is "browse goods" → the RAW SMSG_LIST_INVENTORY, same as the - // direct CMSG_LIST_INVENTORY path. let mut s = quest_store(); s.vendor.vendor_stock = vec![codec::VendorItemView { item_entry: 4540, @@ -343,7 +320,6 @@ fn gossip_select_on_a_vendor_opens_the_inventory_window() { #[test] fn gossip_select_on_an_innkeeper_binds_home_and_completes() { - // A non-vendor innkeeper's "Make this inn your home." is option 0 → bind_home + GOSSIP_COMPLETE. let mut s = quest_store(); s.npc.innkeeper = true; let store = std::sync::Arc::new(s); @@ -390,7 +366,6 @@ fn gossip_select_on_an_innkeeper_binds_home_and_completes() { #[test] fn gossip_select_of_any_other_option_completes_without_binding() { - // Farewell (option 1 on an innkeeper NPC) → GOSSIP_COMPLETE only; no bind, no vendor window. let mut s = quest_store(); s.npc.innkeeper = true; let store = std::sync::Arc::new(s); @@ -417,9 +392,6 @@ fn gossip_select_of_any_other_option_completes_without_binding() { #[test] fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { - // The 217 acceptance criterion: an Elwynn-innkeeper-shaped NPC with 3 imported options (chat, - // browse goods, make-home) renders them VERBATIM (real dump text, not the hardcoded fallback - // strings) — the vendor/innkeeper flags are ignored entirely once options are imported. use lyracore_shared::constants::gossip_option; let mut s = quest_store(); s.npc.gossip_opts = vec![ @@ -431,7 +403,6 @@ fn gossip_hello_renders_imported_options_verbatim_with_a_trailing_farewell() { gossip_option::INNKEEPER, ), ]; - // Fallback signals present too — must be ignored while options are imported. s.vendor.vendor_stock = vec![codec::VendorItemView { item_entry: 1, ..Default::default() @@ -561,7 +532,6 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { always.row_id = 4002; vec![gated, always] }; - // Viewer A has not taken quest 60 → the gated row is hidden, so "Stay here." renders at 0. let mut a = quest_store(); a.npc.gossip_opts = menu(); let a = std::sync::Arc::new(a); @@ -583,7 +553,6 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { drop(client); server.join().unwrap(); - // Viewer B HAS taken it → the gated row renders first and pushes "Stay here." to position 1. let mut b = quest_store(); b.npc.gossip_opts = menu(); b.quest.quest_log = vec![(60, false)].into(); @@ -630,11 +599,8 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { s.npc.gossip_opts = vec![gated, inn]; let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - // Rendered while the quest is untaken: the gated line is hidden, "Stay here." is position 0. let rendered = gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); assert_eq!(rendered.gossips[0].message, "Stay here."); - // The player accepts quest 60 elsewhere (another window, a party member's turn-in) — a fresh - // filter would now put the gated line at 0 and push "Stay here." to 1. store.quest.quest_log.lock().unwrap().push((60, false)); CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(90), @@ -687,7 +653,6 @@ fn a_select_with_no_open_menu_just_closes_the_window() { let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); gossip_hello(&mut client, &mut c_enc, &mut c_dec, 90); - // Same position, a DIFFERENT npc — the open menu is 90's, so this selects nothing. CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(91), gossip_list_id: 0, @@ -788,9 +753,6 @@ fn an_imported_menu_missing_its_bind_row_still_offers_the_hearth() { .load(std::sync::atomic::Ordering::SeqCst)); } -/// A `quest_store()` fixture whose logged-in character (guid 1) reports `level`, so -/// `filtered_gossip_options`' level gate has something to read (`quest_store()` itself leaves -/// `characters` empty, which reads as level 0 — every below-10 test can lean on that default). fn quest_store_at_level(level: u8) -> WorldFake { let base = quest_store(); WorldFake { @@ -805,9 +767,6 @@ fn quest_store_at_level(level: u8) -> WorldFake { #[test] fn gossip_hello_hides_unlearn_talents_below_level_10() { - // The imported "I wish to unlearn my talents." row (reclassified by the importer to - // `UNLEARNTALENTS`, since the raw dump column never carries it) must not render for a character - // who cannot yet have a talent point. use lyracore_shared::constants::gossip_option; let mut s = quest_store_at_level(5); s.npc.gossip_opts = vec![ @@ -873,7 +832,6 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal } other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), } - // Selecting the option quotes the cost without changing talents. CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(90), gossip_list_id: 1, @@ -913,15 +871,12 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal #[test] fn gossip_hello_shows_a_quest_gated_option_once_the_quest_is_taken() { - // The socket-level contract: the player's quest state reaches the gossip filter through the - // quest seam's `quest_gate_state`, and the gated row is assembled into the gossip message the - // client actually receives. The hidden case is covered by the position-alignment test below. use lyracore_shared::constants::{gossip_condition, gossip_option}; let mut s = quest_store(); s.npc.gossip_opts = vec![opt(0, "About that favor...", gossip_option::GOSSIP)]; s.npc.gossip_opts[0].cond_type = gossip_condition::QUEST_TAKEN; s.npc.gossip_opts[0].cond_value1 = 60; - s.quest.quest_log = vec![(60, false)].into(); // taken, not yet turned in + s.quest.quest_log = vec![(60, false)].into(); let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); CMSG_GOSSIP_HELLO { @@ -942,20 +897,16 @@ fn gossip_hello_shows_a_quest_gated_option_once_the_quest_is_taken() { #[test] fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() { - // The sharpest trap (danger-zones-adjacent): 3 imported options where the MIDDLE one is - // quest-gated and hidden. HELLO sends only 2 lines (positions 0,1); a SELECT of position 1 MUST - // route to the THIRD raw option (innkeeper), not the hidden middle one — proving - // `filtered_gossip_options` re-derives the IDENTICAL list rather than indexing the raw rows. use lyracore_shared::constants::{gossip_condition, gossip_option}; let mut s = quest_store(); s.npc.innkeeper = true; s.npc.gossip_opts = vec![ - opt(0, "Chat.", gossip_option::GOSSIP), // raw index 0 -> rendered index 0 - opt(0, "Hidden favor.", gossip_option::GOSSIP), // raw index 1 -> HIDDEN (quest-gated) - opt(0, "Stay here.", gossip_option::INNKEEPER), // raw index 2 -> rendered index 1 + opt(0, "Chat.", gossip_option::GOSSIP), + opt(0, "Hidden favor.", gossip_option::GOSSIP), + opt(0, "Stay here.", gossip_option::INNKEEPER), ]; s.npc.gossip_opts[1].cond_type = gossip_condition::QUEST_TAKEN; - s.npc.gossip_opts[1].cond_value1 = 60; // never taken in this store + s.npc.gossip_opts[1].cond_value1 = 60; let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); CMSG_GOSSIP_HELLO { @@ -965,14 +916,12 @@ fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() .unwrap(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_GOSSIP_MESSAGE(m) => { - assert_eq!(m.gossips.len(), 3, "2 visible + Farewell: {:?}", m.gossips); // hidden option excluded + assert_eq!(m.gossips.len(), 3, "2 visible + Farewell: {:?}", m.gossips); assert_eq!(m.gossips[0].message, "Chat."); assert_eq!(m.gossips[1].message, "Stay here."); } other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), } - // Click rendered position 1 ("Stay here.") — must bind home, NOT be swallowed by the hidden - // middle option that was never actually sent to the client. CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(90), gossip_list_id: 1, @@ -1041,7 +990,7 @@ fn npc_text_query_ships_the_imported_8_slot_view() { assert_eq!(u.texts[0].probability, 0.6); assert_eq!(u.texts[3].texts[0], "Watch yourself."); assert_eq!(u.texts[3].probability, 0.4); - assert_eq!(u.texts[1].probability, 0.0); // untouched slot stays silent + assert_eq!(u.texts[1].probability, 0.0); } other => panic!("expected SMSG_NPC_TEXT_UPDATE, got {other}"), } @@ -1096,8 +1045,6 @@ fn gossip_training_selection_sends_the_profession_list_type() { #[test] fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_serve() { use lyracore_shared::constants::gossip_option; - // Level 20 matters: the respec option is independently hidden below level 10, so at the default - // fixture level this would pass without the class gate doing any work. let mut s = quest_store_at_level(20); s.npc.gossip_opts = vec![ opt(0, "Well met, traveler.", gossip_option::GOSSIP), @@ -1128,7 +1075,6 @@ fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_s !lines.contains(&"I wish to unlearn my talents."), "the respec option must be hidden too: {lines:?}" ); - // The NPC is not silenced — it still talks, and still sells. assert!( lines.contains(&"Well met, traveler."), "plain gossip lines survive: {lines:?}" @@ -1147,7 +1093,6 @@ fn gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_s #[test] fn gossip_keeps_the_train_and_unlearn_options_for_a_class_the_trainer_serves() { use lyracore_shared::constants::gossip_option; - // Same level as its counterpart, so the only difference between the two tests is the gate. let mut s = quest_store_at_level(20); s.npc.gossip_opts = vec![ opt(0, "Well met, traveler.", gossip_option::GOSSIP), @@ -1159,7 +1104,7 @@ fn gossip_keeps_the_train_and_unlearn_options_for_a_class_the_trainer_serves() { ), opt(1, "I'd like to browse your goods.", gossip_option::VENDOR), ]; - let store = std::sync::Arc::new(s); // trainer_refuses_class stays false (derive-Default) + let store = std::sync::Arc::new(s); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store, 1); CMSG_GOSSIP_HELLO { guid: Guid::new(90), diff --git a/gateway/src/world/packet_lint.rs b/gateway/src/world/packet_lint.rs index 80c21f210..59afaaa76 100644 --- a/gateway/src/world/packet_lint.rs +++ b/gateway/src/world/packet_lint.rs @@ -1,33 +1,14 @@ -//! Outbound packet lint (testing-hardening §3.2): the 5875 client's ROOT-CAUSED crash/wedge -//! classes are byte-recognizable, so the raw-frame chokepoint checks every hand-rolled body -//! against them and logs a loud `packet-lint VIOLATION` line — turning each hard-won client-crash -//! diagnosis (danger-zones §1) into a permanent regression wall the suite can grep for. -//! -//! Always on: it inspects only `Outbound::Raw` frames, and of those only the two opcodes with a -//! known crash class — a handful of branch instructions next to the header encryption. -//! The TYPED path's equivalent guard lives at TEST time instead (`codec::values` serializes every -//! `build_*_values` builder and asserts the mask never carries bit 2) — gtker's builders can't be -//! cheaply re-parsed per send, and `player_values`' dirty_reset is the single producer there. - use lyracore_shared::values_mask::parse_values_updates; -/// The `OBJECT_FIELD_TYPE` descriptor index — re-sending it inside a VALUES partial overwrites the -/// client's cached object type and null-derefs on the next player-frame dispatch (the 2026-06-16 -/// combat crash; `codec::values::build_health_values` carries the full story). const OBJECT_FIELD_TYPE: u16 = 2; -/// SMSG_UPDATE_OBJECT / SMSG_SET_FACTION_STANDING raw opcodes (the two linted frames). const UPDATE_OBJECT: u16 = 0x00A9; const SET_FACTION_STANDING: u16 = 0x0124; -/// Lint one raw outbound frame. Returns one message per violation (empty = clean). pub fn lint_raw(opcode: u16, body: &[u8]) -> Vec<&'static str> { let mut hits = Vec::new(); match opcode { UPDATE_OBJECT => { - // Only VALUES blocks are parsed (the decoder stops at a CREATE block, which - // legitimately carries TYPE in its full mask) — so any bit-2 hit here is a real - // partial-update violation. for u in parse_values_updates(body) { if u.fields.iter().any(|&(idx, _)| idx == OBJECT_FIELD_TYPE) { hits.push( @@ -37,9 +18,7 @@ pub fn lint_raw(opcode: u16, body: &[u8]) -> Vec<&'static str> { } } SET_FACTION_STANDING => { - // Layout (build_set_faction_standing_raw): count u32, then per faction - // reputation_index u32 + standing u32. An index >= 64 addresses past the client's - // fixed rep array, the McBride ERROR crash class. + // Faction-standing slots are u32 indices into a fixed 64-entry client array. if body.len() >= 4 { let count = u32::from_le_bytes(body[0..4].try_into().unwrap()) as usize; for i in 0..count { @@ -68,9 +47,9 @@ mod tests { fn values_frame(fields: &[(u16, u32)]) -> Vec { let mut b = Vec::new(); b.extend_from_slice(&1u32.to_le_bytes()); - b.push(0); // has_transport - b.push(0); // VALUES - b.push(0x01); // packed guid: 1 low byte + b.push(0); + b.push(0); + b.push(0x01); b.push(0x09); let max_block = fields.iter().map(|(f, _)| f / 32).max().unwrap() as u8 + 1; b.push(max_block); @@ -91,10 +70,8 @@ mod tests { #[test] fn type_bit_in_a_values_partial_is_flagged() { - // The exact 2026-06-16 crash shape: TYPE (2) re-sent next to a health write (22). let bad = values_frame(&[(2, 0x9), (22, 41)]); assert_eq!(lint_raw(0x00A9, &bad).len(), 1); - // The correct partial — health alone — is clean. let good = values_frame(&[(22, 41)]); assert!(lint_raw(0x00A9, &good).is_empty()); } @@ -103,11 +80,11 @@ mod tests { fn faction_standing_index_past_the_rep_array_is_flagged() { let mut bad = Vec::new(); bad.extend_from_slice(&1u32.to_le_bytes()); - bad.extend_from_slice(&72u32.to_le_bytes()); // the McBride bug: faction ID as index + bad.extend_from_slice(&72u32.to_le_bytes()); bad.extend_from_slice(&3175u32.to_le_bytes()); assert_eq!(lint_raw(0x0124, &bad).len(), 1); let mut good = bad.clone(); - good[4..8].copy_from_slice(&19u32.to_le_bytes()); // Stormwind's real rep-index + good[4..8].copy_from_slice(&19u32.to_le_bytes()); assert!(lint_raw(0x0124, &good).is_empty()); } diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 41cda5ce5..ae3cdb5c8 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -1,43 +1,3 @@ -//! Realm-wide party state — the GROUP slice of realm-core's social & economy plane, part of the -//! elastic world-sharding design. -//! -//! # What moved, and why here -//! -//! `game_group` / `game_group_member` / `game_group_invite` are authoritative on **realm-core**. -//! None of the three is coupled to space — the spec's partition rule — and all three silently became -//! shard-local the moment a second database existed: `group_invite` resolved its target inside the -//! CALLING database, so a player inside Deadmines could not invite one standing in Elwynn, and the -//! escrowed transfer's interim membership mirror was a snapshot taken at `begin_transfer`, so a party -//! SPLIT across the boundary never saw itself. Both were observed live (2026-07-25), not theorised. -//! -//! This module is the ROUTING half of the fix and the only place that decides which database a party -//! op runs against. Everything in it is generic over the Store families, so the decisions execute under -//! test against the same in-memory shard topology the cross-database transfer uses — the seam the -//! transfer-transport test harness built. The database-specific halves are thin: `Coordinator`'s -//! trait impl (which database a handle names) and the module's `realm_group_op` (the rules, unchanged). -//! -//! # The three planes -//! -//! 1. **Realm-core** owns membership. Ops go there through the operator-gated `realm_group_op`, and -//! it is where invites, rosters and the leadership/disband rules live. One authority, so an invite -//! across a shard boundary is not a special case — it is the only case. -//! 2. **Each world shard** keeps a MIRROR of the roster, refreshed by [`sync_mirrors`] after every op -//! and by [`on_world_entry`] when a character arrives. It exists because ~fifty in-world reads -//! (kill-XP split, quest credit, loot rules, the party's dungeon binding) resolve -//! membership locally on the hot path and must not become cross-database calls. Same relationship -//! `game_account`/`game_session` have had with realm-core from the start. -//! 3. **A single-database gateway** has no realm-core to route to ([`ShardRoutingStore::realm_store`] -//! answers `None`), so every op takes the pre-realm-core path: the player's own connection, the -//! player-facing reducer, the shard's own tables. Byte-identical, and pinned by -//! `an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard`. -//! -//! # What did NOT move -//! -//! `/say`-range chat, `/yell` and targeted emotes stay on the world shards as AOI-scoped events: -//! they ARE spatial, which is the same rule that moved membership off them. Party (`/p`) chat is a -//! Realm Chat Line: Realm-core reads its own membership in the transaction that writes the line, so -//! the mirror plays no part in who hears it. - use crate::world::{CharacterStore, SessionStore, ShardRoutingStore, SocialStore}; use std::time::{Duration, Instant}; @@ -50,10 +10,7 @@ use lyracore_shared::group::{ COMMAND_RESULT_WINDOW_MICROS, }; -/// Party membership: Realm-core group ops, roster reads, World Shard mirrors and companion commands. pub(crate) trait PartyStore: Send + Sync { - /// Admit and claim one Group Intent against current World Shard state. Refusals include a - /// consumed intent or suppressed action. Transport failures remain distinct. fn claim_bot_invite_intent(&self, intent_id: u64) -> Result; fn claim_party_command_intent(&self, intent_id: u64, claim_token: u64) -> Result<()>; @@ -89,13 +46,8 @@ pub(crate) trait PartyStore: Send + Sync { fn entity_partition(&self, guid: u64) -> Option<(u32, u64)>; - /// Acknowledged World Shard admission for one automatic group action. A later controller - /// selection cannot undo admission or membership already committed on Realm-core. fn admit_sessionless_group_action(&self, character_guid: u64) -> Result; - /// `realm_group_op` — run one party op against the database this handle names: realm-core - /// when sharded, the only shard otherwise. The op byte and argument slots are - /// `lyracore_shared::group::realm_op`. fn realm_group_op( &self, op: u8, @@ -106,9 +58,7 @@ pub(crate) trait PartyStore: Send + Sync { arg_c: u64, ) -> Result; - /// [`Self::realm_group_op`] that returns only after this handle's Coordinator cache holds the - /// commit, so a roster read right after it sees the op. It waits on the Coordinator pump, so its - /// caller must run on another thread. Fakes may reuse the ordinary op. + /// Wait off-pump for the committed roster; this call depends on the Coordinator pump. fn realm_group_op_visible( &self, op: u8, @@ -119,32 +69,18 @@ pub(crate) trait PartyStore: Send + Sync { arg_c: u64, ) -> Result; - /// Realm-core LEAVE for a deleted Character. Production returns after its Coordinator cache - /// has the committed roster; Fakes may reuse the ordinary party operation. fn deleted_character_party_leave(&self, character: Actor) -> Result; - /// The party `character_guid` is in, as THIS handle's database sees it: authoritative on - /// realm-core, a mirror on a world shard. `None` = not in a party there. fn group_roster(&self, character_guid: u64) -> Result>; - /// Bounded party projection used by companion-command authority. A roster longer than a Raid, - /// or an otherwise unreadable projection, is an infrastructure failure, never proof of - /// membership. fn party_command_group_roster(&self, character_guid: u64) -> Result>; - /// [`group_roster`](Self::group_roster) keyed by the group — the read the mirror push needs for a - /// party the acting character has just left. fn group_roster_by_id(&self, group_id: u64) -> Result>; - /// Realm-core's durable order for a complete party roster, including a disbanded party. fn group_roster_revision(&self, group_id: u64) -> Result; - /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a - /// World Shard it is the mirror's revision, kept after a disband; on Realm-core it is the - /// authoritative one. fn held_roster_revision(&self, group_id: u64) -> Result>; - /// Realm-core's ordered locator for one Character. World Shards never supply this fact. fn realm_character_partition( &self, character_guid: u64, @@ -159,40 +95,22 @@ pub(crate) trait PartyStore: Send + Sync { fn party_cleanup_group_roster_by_id(&self, group_id: u64) -> Result>; - /// Every Character with authoritative Realm-core party membership. fn party_member_guids(&self) -> Result>; - /// Every party id held by this store. Realm-core rows are authoritative; World Shard rows are - /// mirrors that reconnect reconciliation must also inspect for stale parties. fn party_group_ids(&self) -> Result>; - /// `sync_group_mirror` — replace this shard's mirror of one party with realm-core's roster. - /// An empty `roster.members` is the disband tombstone. fn sync_group_mirror(&self, roster: &GroupRoster) -> Result<()>; - // The SINGLE-DATABASE party path (`world::party::run`'s `None` arm): the player-facing - // reducers, acting as the session's Character. - - /// `CMSG_GROUP_INVITE` (name gateway-resolved). A `GroupRefusal` arrives as an outcome. fn group_invite(&self, actor: Actor, target_guid: u64) -> Result; - /// `CMSG_GROUP_ACCEPT`. fn group_accept(&self, actor: Actor) -> Result; - /// `CMSG_GROUP_DECLINE`. fn group_decline(&self, actor: Actor) -> Result; - /// `CMSG_GROUP_DISBAND` (the client's "Leave Party"). fn group_leave(&self, actor: Actor) -> Result; - /// `CMSG_GROUP_UNINVITE` (name gateway-resolved) or `CMSG_GROUP_UNINVITE_GUID` — the leader or - /// an Assistant kicks a member. fn group_uninvite(&self, actor: Actor, target_guid: u64) -> Result; - /// `CMSG_LOOT_METHOD` — the leader sets the party's loot method/ - /// threshold/master. `loot_setting`/`loot_threshold` are the gateway-decoded `GroupLootSetting`/ - /// `ItemQuality` wire bytes, passed straight through (the module adopted the wire ordering - /// verbatim — zero translation). fn group_loot_method( &self, actor: Actor, @@ -202,25 +120,16 @@ pub(crate) trait PartyStore: Send + Sync { ) -> Result; } -/// One group, as the database that holds it sees it. Read from realm-core it is the authority; read -/// from a world shard it is that shard's mirror. Names and online flags are deliberately NOT in it — -/// realm-core has no character rows to resolve either from, so they are filled at render time from -/// the shards ([`render_list`]). #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct GroupRoster { pub group_id: u64, - /// Realm-core's order for this complete roster. World Shards retain the value after disband. pub roster_revision: u64, pub leader_guid: u64, pub loot_method: u8, pub loot_threshold: u8, pub master_looter_guid: u64, pub kind: GroupKind, - /// Members in join order (member-row id), which is the order leadership succeeds in, each with - /// its Raid Slot. pub members: Vec, - /// One ordered partition projection per member. Realm-core supplies both revisions; the - /// Gateway confirms the location against the World Shard that currently holds the Character. pub partitions: Vec, } @@ -406,9 +315,6 @@ fn append_departed_partitions(roster: &mut GroupRoster, previous: Option<&GroupR } impl GroupRoster { - /// The empty roster for a group that no longer exists — what [`sync_mirrors`] pushes to make a - /// shard forget a disbanded party. `members` empty is the disband signal `sync_group_mirror` - /// reads. The roster revision remains authoritative; the other fields are left at zero. pub fn disbanded(group_id: u64, roster_revision: u64) -> Self { Self { group_id, @@ -417,7 +323,6 @@ impl GroupRoster { } } - /// Member guids in join order. pub fn member_guids(&self) -> Vec { self.members.iter().map(|member| member.guid).collect() } @@ -426,9 +331,6 @@ impl GroupRoster { self.members.iter().any(|member| member.guid == guid) } - /// The list this roster renders as. Names are blank: [`render_list`] reads them from the - /// shards, since realm-core holds none. It carries no Target Icons: the Gateway reads none, - /// and only a LIST event from the party authority carries them. pub fn list_payload(&self) -> RosterPayload { RosterPayload { leader: self.leader_guid, @@ -457,53 +359,48 @@ fn roster_or_disbanded(realm: &dyn WorldStore, group_id: u64) -> Result; ThrottledBroadcast::COUNT]); impl GroupBroadcastCooldowns { - /// Admit one party op at `now`, or refuse it because the last op of the same throttled kind - /// was inside [`GROUP_BROADCAST_THROTTLE`]. An op with no throttle is always admitted. Every - /// admitted op starts its kind's cooldown, including one the party authority then refuses. pub(crate) fn admit_at(&mut self, op: Op, now: Instant) -> bool { let Some(kind) = op.broadcast_throttle() else { return true; @@ -549,22 +439,16 @@ impl GroupBroadcastCooldowns { } } -/// `realm_group_op`'s argument slots after the actor: `(op, target_guid, arg_a, arg_b, arg_c)`. type RealmOpArgs = (u8, u64, u8, u8, u64); -/// The class of a Character accepting an invite. Realm-core holds no Character rows, so an ACCEPT -/// conveys it for the Seeker row a queued party gains. 0 when no World Shard names the Character. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct AcceptorFacts { class: u8, } impl AcceptorFacts { - /// What `realm_group_op` receives for every op but ACCEPT. const NONE: Self = Self { class: 0 }; - /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0 rather than losing - /// the accept: the Module treats an unknown class as a seat with no role. fn of(store: &St, guid: u64) -> Self { match presence::character_anywhere(store, guid) { Ok(Some(character)) => Self { @@ -578,7 +462,6 @@ impl AcceptorFacts { } } - /// [`Self::of`] for an ACCEPT, [`Self::NONE`] for any other op. fn for_op( store: &St, guid: u64, @@ -592,7 +475,6 @@ impl AcceptorFacts { } impl Op { - /// The argument slots for this op. Only ACCEPT reads `acceptor`. fn realm_args(self, acceptor: AcceptorFacts) -> RealmOpArgs { match self { Op::Invite(target) => (realm_op::INVITE, target, 0, 0, 0), @@ -617,7 +499,6 @@ impl Op { Op::ReadyCheckStart => (realm_op::READY_CHECK_START, 0, 0, 0, 0), Op::ReadyCheckAnswer(state) => (realm_op::READY_CHECK_ANSWER, 0, state, 0, 0), Op::TargetIcon { icon, target } => (realm_op::TARGET_ICON, target, icon, 0, 0), - // Bit patterns, so the floats reach the other members unchanged. Op::MinimapPing { x, y } => ( realm_op::MINIMAP_PING, u64::from(x.to_bits()), @@ -631,11 +512,6 @@ impl Op { } } - /// The cooldown this op shares with its own kind, or `None` when it runs unthrottled. A Ready - /// Check start, a minimap ping and a `/roll` each write one event row for every member on the - /// party authority, which on a sharded Realm is the one Realm-core database. A Ready Check - /// answer writes one row. A Target Icon is left alone because a leader marks several targets - /// in quick succession before a pull. pub(crate) fn broadcast_throttle(self) -> Option { match self { Op::ReadyCheckStart => Some(ThrottledBroadcast::ReadyCheck), @@ -645,8 +521,6 @@ impl Op { } } - /// A Group Broadcast changes no roster, so it needs no roster read, no loot-roll flush and no - /// mirror push. fn is_group_broadcast(self) -> bool { matches!( self, @@ -659,11 +533,6 @@ impl Op { } } -/// Run `op` as `actor` through `realm_group_op` on `authority`, the database that holds the -/// party: Realm-core when sharded, the home shard otherwise. It returns before the Coordinator -/// cache holds the commit, so it is only for an op that pushes no Group mirror after it: a Group -/// Broadcast, a Target Icon request, a declined invite, or an op on a single database. An ACCEPT -/// changes a roster, so it never comes here. fn run_on_authority( authority: &A, actor: Actor, @@ -673,10 +542,6 @@ fn run_on_authority( authority.realm_group_op(code, actor, target, arg_a, arg_b, arg_c) } -/// [`run_on_authority`] that returns only after the Coordinator cache holds the commit, so the -/// mirror push after it reads the op's own roster. Every op that changes a roster uses it, for a -/// World Session and for a session-less Character alike. It waits on the Coordinator pump, so the -/// caller must run on its own thread. `acceptor` is read only for an ACCEPT. fn run_on_authority_visible( authority: &A, actor: Actor, @@ -687,9 +552,6 @@ fn run_on_authority_visible( authority.realm_group_op_visible(code, actor, target, arg_a, arg_b, arg_c) } -/// What one party op answered. A [`GroupRefusal`] is a gameplay answer the client renders, so it -/// arrives as `Ok`; a timeout, transport failure, or untagged reducer error stays `Err` and ends the -/// session, because the durable outcome is then unknown. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PartyOutcome { Ran, @@ -808,9 +670,6 @@ fn same_authority(left: &GroupRoster, right: &GroupRoster) -> bool { && left_members == right_members } -/// Claim, certify on Realm-core, compare the target mirror, apply once, then finalize at source. -/// The Realm-core Durable Request is the authority point; a later party change cannot recall an -/// already applied order. Every retry before target application repeats this operation. pub(crate) fn run_party_command_intent( source: &St, intent: &PartyCommandIntent, @@ -973,11 +832,6 @@ impl From for PartyOutcome { } } -/// `self_guid`'s own Group roster, read from the authority: Realm-core when sharded, this handle's -/// own tables otherwise. Shared by [`resolve_roster_member_by_name`] and -/// [`resolve_roster_members_by_name`] so a caller resolving more than one name reads the roster -/// once rather than once per name, which would let two names resolve against two different -/// snapshots of a roster another op changed in between. fn own_group_roster( store: &St, self_guid: u64, @@ -988,7 +842,6 @@ fn own_group_roster( } } -/// Resolve a typed name against `roster`'s members: `None` for a name matching nobody there. fn resolve_in_roster( store: &St, roster: &GroupRoster, @@ -1004,12 +857,6 @@ fn resolve_in_roster( Ok(None) } -/// Resolve a typed name against `self_guid`'s OWN roster, for `CMSG_GROUP_CHANGE_SUB_GROUP`. -/// cmangos's Swap Subgroup matches a typed name against the group's own member list this way -/// (cm:GroupHandler.cpp:919-936); its Change Subgroup instead resolves the name realm-wide and -/// refuses afterward when the result is not a member. LyraCore applies the member-list rule to -/// both opcodes, so neither can reach a namesake standing outside the Raid, unlike -/// [`presence::resolve_by_name`]. `None` for no Group, or a name matching no member. pub(crate) fn resolve_roster_member_by_name< St: CharacterStore + PartyStore + ShardRoutingStore + ?Sized, >( @@ -1023,10 +870,6 @@ pub(crate) fn resolve_roster_member_by_name< resolve_in_roster(store, &roster, name) } -/// [`resolve_roster_member_by_name`] for `CMSG_GROUP_SWAP_SUB_GROUP`'s two names, resolved against -/// ONE roster read rather than two: reading the roster separately per name could resolve the pair -/// against two different snapshots if another op changed the roster in between, letting a swap -/// name a member who had already left. `(None, None)` for no Group. pub(crate) fn resolve_roster_members_by_name< St: CharacterStore + PartyStore + ShardRoutingStore + ?Sized, >( @@ -1044,8 +887,6 @@ pub(crate) fn resolve_roster_members_by_name< )) } -/// Route admission to the World Shard that reports the live entity. The acknowledged operation -/// checks current Session ownership and consent there, even if that presence read was stale. fn admit_sessionless_answer( store: &St, guid: u64, @@ -1063,9 +904,6 @@ fn admit_sessionless_answer( @@ -1104,17 +942,6 @@ fn answer_for_session_less< } } -/// Run one party op as the session's Character, `actor`. -/// -/// Unsharded → the pre-realm-core path, verbatim: the player's own connection calls the player-facing -/// reducer on the player's own shard, and nothing else happens. A raid op, a leadership op or a -/// Group Broadcast has no player-facing reducer, so it calls `realm_group_op` on that same shard. -/// -/// Sharded → realm-core runs the op, then every connected world shard's mirror is refreshed. The -/// op returns only after the Coordinator cache holds its commit, so the refresh reads the op's own -/// roster. The refresh never fails the op (see [`sync_mirrors`]); an op that changes membership -/// retries it first ([`sync_membership_mirrors`]). A Group Broadcast runs on Realm-core and nothing -/// else happens. pub(crate) fn run< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -1128,8 +955,6 @@ pub(crate) fn run< return Ok(GroupRefusal::WrongFaction.into()); } } - // Vanilla passes the lead only to an online member (cm:GroupHandler.cpp:352-356). The party - // authority runs its rules without presence on both planes, so the Gateway answers it here. if let Op::SetLeader(target) = op { if !presence::live_anywhere(store, target) { return Ok(GroupRefusal::TargetOffline.into()); @@ -1147,9 +972,6 @@ pub(crate) fn run< master, threshold, } => store.group_loot_method(actor, setting, master, threshold), - // A raid op, a leadership op or a Group Broadcast has no player-facing reducer. With one - // database, the home shard holds the party, so it runs the same `realm_group_op` - // Realm-core would. Op::RaidConvert | Op::SetLeader(_) | Op::SetAssistant { .. } @@ -1165,23 +987,11 @@ pub(crate) fn run< if op.is_group_broadcast() { return run_on_authority(realm.as_ref(), actor, op); } - // The two gates realm-core cannot run for itself, because the directory database holds neither - // characters nor live entities: does the target EXIST, and is it ONLINE. The gateway is the only - // party that can answer them across a boundary — which is precisely the bug being fixed — and it - // answers with the module's own Refusals, so the client-facing code is the same on both planes. - // - // Each gate is the module's own read, unioned across the shards — EXISTS is a `game_character` - // row ([`presence::of`]), ONLINE is a `game_world_entity` row ([`presence::live_anywhere`]). - // Reading the session flag for the second would silently refuse every session-less Character; - // see [`presence::live_anywhere`]. if let Op::Invite(target) = op { if let Some(refusal) = invite_gate(store, target)? { return Ok(refusal.into()); } } - // The group this character was in BEFORE the op — the only way to reach the party they may have - // just left (their membership row is gone by the time we look again, but the members still in it - // need their mirrors updated too). let before = realm.group_roster(self_guid)?; if matches!(op, Op::Leave | Op::Uninvite(_)) { crate::world::loot::flush_pending_promotions(store, realm.as_ref()); @@ -1192,9 +1002,6 @@ pub(crate) fn run< { return Ok(PartyOutcome::Refused(refusal)); } - // Nobody is at the keyboard of a session-less Character, so nobody answers its dialog. Done - // BEFORE the mirror push, so the ONE push that follows already carries the bot as a member — - // which is what the shard's own party reads (kill-XP split, `/p`, follow-the-leader) need. if let Op::Invite(target) = op { answer_for_session_less(store, realm.as_ref(), target); } @@ -1209,11 +1016,6 @@ pub(crate) fn run< Ok(PartyOutcome::Ran) } -/// The mirror push after an op that changes who is in a Group. The Instance Pool starts and cancels -/// Instance Removals from this push, so a lost push would send home a Character whom Realm-core -/// shows back in its Group. Each touched Group gets [`sync_group_mirrors_required`]'s retries. The -/// op has already committed, so a push that still fails is logged, not returned: the next op or -/// world entry repairs it, as [`sync_mirrors`] documents. fn sync_membership_mirrors( store: &St, realm: &dyn WorldStore, @@ -1245,11 +1047,6 @@ fn sync_membership_mirrors( } } -/// Whether a successful op changed nothing, so no mirror needs a push: converting a Raid again, -/// repeating a promotion or a demotion, a Change Subgroup into the Subgroup a member already -/// holds, or a Swap Subgroup between two members of one Subgroup. The answer comes from the roster -/// read before the op, not after it: a stale `before` costs at most a missed push, which the next -/// op or world entry repairs, as [`sync_mirrors`] documents. fn op_changed_nothing(op: Op, before: Option<&GroupRoster>) -> bool { let Some(before) = before else { return false; @@ -1262,7 +1059,6 @@ fn op_changed_nothing(op: Op, before: Option<&GroupRoster>) -> bool { .map(|member| member.slot.subgroup()) }; match op { - // A Raid never converts back. Op::RaidConvert => before.kind == GroupKind::Raid, Op::SetAssistant { target, promote } => before .members @@ -1277,8 +1073,6 @@ fn op_changed_nothing(op: Op, before: Option<&GroupRoster>) -> bool { } } -/// The invite gates realm-core cannot run for itself: does the target exist anywhere, and is it in -/// the world anywhere. `None` means the invite may proceed. fn invite_gate( store: &St, target: u64, @@ -1292,11 +1086,6 @@ fn invite_gate( Ok(None) } -/// Vanilla's default refuses a party across factions (cm:GroupHandler.cpp:80, -/// `AllowTwoSide.Interaction.Group = 0`). Neither the Module nor Realm-core can read both races, so -/// the Gateway answers it on both planes, the way mail applies `same_team`. It runs only for a live -/// target: vanilla looks the target up among online players first, so a missing or offline target -/// keeps its own Refusal. fn cross_faction_invite( store: &St, inviter: u64, @@ -1317,11 +1106,6 @@ fn cross_faction_invite( store: &St, intent_id: u64, @@ -1341,25 +1125,11 @@ pub(crate) fn run_bot_invite_intent( } } -/// -/// There is no `account_id` here on purpose: a bot has no per-account connection for a reducer to -/// authenticate as, on EITHER topology. So this never takes [`run`]'s unsharded arm (which needs -/// exactly that connection to resolve the acting character via `ctx.sender()`) — it always uses the -/// guid-based `realm_group_op`, targeting realm-core when the gateway is sharded and this database's -/// OWN `game_group`/`game_group_member` otherwise. Unsharded that is still correct, not a special -/// case: with one database, "realm-core" and "the world shard" are the same physical database, so -/// writing there directly cannot diverge from itself — there is no mirror to contradict. -/// -/// Otherwise this mirrors [`run`]'s sharded arm: same existence/online gates, same -/// [`answer_for_session_less`] (a bot target still has no client to answer its own dialog — true of -/// the INVITER here too, which is the whole point), same [`sync_mirrors`] push so the shard's own -/// party reads (kill-XP split, `/p`, follow-the-leader) see the new member immediately. pub(crate) fn run_bot_invite( store: &St, inviter_guid: u64, target_guid: u64, ) -> Result { - // Guid 0 names no Character, so it can act in no party. let Some(inviter) = Actor::new(inviter_guid) else { return Ok(GroupRefusal::ActorUnavailable.into()); }; @@ -1385,20 +1155,6 @@ pub(crate) fn run_bot_invite( Ok(PartyOutcome::Ran) } -/// Run a SERVER-DRIVEN leave with no client behind it — a bot leader whose party has run out of -/// shared work parts ways with it. -/// -/// The same authority wall as [`run_bot_invite`], for the same reason: membership is authoritative -/// on realm-core, and a module-side `leave_group_for` would write this shard's own member rows for -/// the next `sync_group_mirror` push to put straight back. -/// -/// No existence or online gate. Those two answer "may this target be invited"; a leave names only -/// the actor, and `leave_group_for` refuses a Character that is in no party — which is also what a -/// decision the previous tick already got executed looks like. -/// -/// The pending-roll flush is [`run`]'s, verbatim, and belongs here for the reason it gives: a LEAVE -/// can shrink a group below two members and reach realm-core's disband branch, which force-resolves -/// live loot rolls that the periodic relay may not have promoted yet. pub(crate) fn run_bot_leave(store: &St, leaver_guid: u64) -> Result { let Some(leaver) = Actor::new(leaver_guid) else { return Ok(GroupRefusal::ActorUnavailable.into()); @@ -1497,7 +1253,6 @@ pub(crate) enum DeletedCharacterPartyCleanup { const DELETED_CHARACTER_LEAVE_ATTEMPTS: usize = 3; -/// Remove a deleted Character from its realm-core party after every World Shard confirms absence. pub(crate) fn cleanup_deleted_character( store: &St, character_guid: u64, @@ -1505,7 +1260,6 @@ pub(crate) fn cleanup_deleted_character( let Some(realm) = store.realm_store()? else { return Ok(DeletedCharacterPartyCleanup::AlreadyClean); }; - // Guid 0 names no Character, so no party holds it. let Some(character) = Actor::new(character_guid) else { return Ok(DeletedCharacterPartyCleanup::AlreadyClean); }; @@ -1541,8 +1295,6 @@ pub(crate) fn cleanup_deleted_character( } } -/// Recheck authoritative party members after startup or a Coordinator reconnect. Row-delete -/// callbacks are not replayed, so this closes cleanup attempts deferred while a Shard was down. pub(crate) fn reconcile_deleted_character_parties( store: &St, ) -> Result<()> { @@ -1598,15 +1350,8 @@ pub(crate) fn reconcile_deleted_character_parties( store: &St, realm: &dyn WorldStore, @@ -1650,19 +1395,6 @@ pub(crate) fn sync_group_mirrors_required( } } -/// Push realm-core's roster for every party this op could have touched onto every connected world -/// shard. -/// -/// Two groups at most: the one the character is in NOW and the one they were in BEFORE (a leave, a -/// kick, or an accept that moved them). A group that realm-core no longer has is pushed as -/// [`GroupRoster::disbanded`], which is how a shard learns to drop it. -/// -/// **Best-effort, deliberately.** The authoritative write has already committed on realm-core by the -/// time this runs, so a failed mirror push must not turn a party op that DID happen into an error -/// the client renders as a failure. The cost of a miss is bounded and self-healing: that shard's -/// local reads (XP split, loot rules) use a stale roster until the next op or the next world entry -/// re-pushes it, and the party FRAME — which is what the player sees — is rendered from realm-core -/// through the relay, not from the mirror. pub(crate) fn sync_mirrors( store: &St, realm: &dyn WorldStore, @@ -1690,8 +1422,6 @@ pub(crate) fn sync_mirrors( for group_id in touched { let roster = match roster_or_disbanded(realm, group_id) { Ok(r) => r, - // Gone from the authority = disbanded. Push the tombstone rather than skipping, or the - // shards keep a party that no longer exists and its members stay grouped locally. Err(e) => { log::warn!( "party: could not read realm-core group {group_id} ({e:#}) — shard mirrors unchanged" @@ -1721,18 +1451,6 @@ pub(crate) fn sync_mirrors( } } -/// World entry (login, and every cross-shard arrival): put the party the player is actually in onto -/// the shard they just entered, and re-render their party frame. -/// A Party member also gets its Target Icons again, after the frame. -/// -/// **This is what carries a party across a shard boundary now that the escrowed transfer's blob -/// mirror is gone.** The -/// blob could only carry the membership the character had when it stepped into the portal; this -/// reads the authority at the moment of arrival, so a party formed — or joined, or left — while the -/// player was on the loading screen is what lands. -/// -/// Unsharded → returns immediately, before any read: the shard's own tables already are the -/// authority and the login path is unchanged. pub(crate) fn on_world_entry< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + ?Sized, >( @@ -1751,10 +1469,7 @@ pub(crate) fn on_world_entry< Ok(()) } -/// A Party client clears its marks on every `SMSG_GROUP_LIST` (vm:Group.cpp:1343-1360). The list -/// above carries no Target Icons, so ask the party authority for the full list, as the client's -/// own `0xFF` request does. The answer rides the group event relay onto the same session writer -/// and so lands after the list. A failure costs only the marks, so it logs. +// GROUP_LIST clears client raid marks; request the full icon list after sending it. fn request_target_icons(store: &St, character: Actor) { let realm = match store.realm_store() { Ok(Some(realm)) => realm, @@ -1780,12 +1495,6 @@ fn request_target_icons(store: &St, character: A } } -/// The mirror half of [`on_world_entry`], without a client: put the party realm-core says -/// `self_guid` is in onto the shard `store` names, and clear a mirror row of a party they have -/// left. Answers the roster it pushed so the caller can render a frame for a player. Transfer -/// settlement uses the strict sibling below before it drops an arrival fence. -/// -/// Unsharded → `Ok(None)` before any read: the shard's own tables already are the authority. pub(crate) fn sync_arrival_mirror( store: &St, self_guid: u64, @@ -1797,20 +1506,10 @@ pub(crate) fn sync_arrival_mirror( .group_roster(self_guid)? .map(|roster| certify_roster_partitions(store, realm.as_ref(), roster)) .transpose()?; - // A mirror on THIS shard that still has the arriving character in a party realm-core no longer - // has them in is the ONE staleness "re-syncs on the next op or world entry" does not cover by - // itself: with no roster to push there was nothing to overwrite it with, and the ops of the party - // they left never name them again — so the stale membership row survived every arrival, forever. - // The shard then runs that character's kill-XP split, quest credit, loot rules and - // dungeon binding against a party they are not in. Re-push the AUTHORITY's version of the group - // the mirror thinks they are in (a tombstone if it is gone) — the same repair `sync_mirrors` - // applies to the group an actor was in BEFORE an op. if let Some(stale) = store.group_roster(self_guid)? { if roster.as_ref().is_none_or(|r| r.group_id != stale.group_id) { let repair = roster_or_disbanded(realm.as_ref(), stale.group_id)?; let repair = certify_roster_partitions(store, realm.as_ref(), repair)?; - // Best-effort, like every other mirror write: this is a cache repair, and failing the - // world entry over it would be strictly worse than arriving with a stale roster. if let Err(e) = store.sync_group_mirror(&repair) { log::warn!( "party: could not clear guid {self_guid} from shard {}'s stale mirror of group \ @@ -1824,16 +1523,11 @@ pub(crate) fn sync_arrival_mirror( let Some(roster) = roster else { return Ok(None); }; - // The shard the player just entered. `store` is already the session's home-shard handle (every - // world-entry caller runs on the session's routed Store), so this is the one mirror that must exist - // before the player takes a single action here. store.sync_group_mirror(&roster)?; Ok(Some(roster)) } -/// Reconcile the same authoritative roster as [`sync_arrival_mirror`], but make every destination -/// write part of Transfer settlement. The arrival fence stays up when realm-core or the mirror is -/// unavailable, and a later Transfer retry repeats this operation before release. +/// Keep the arrival fence until the authoritative Group mirror reaches the destination. pub(crate) fn sync_transfer_arrival_mirror( store: &St, character_guid: u64, @@ -1868,18 +1562,6 @@ pub(crate) fn sync_transfer_arrival_mirror( store: &St, self_guid: u64, @@ -1888,9 +1570,6 @@ pub(crate) fn render_list( store: St, ) -> std::io::Result> { @@ -29,7 +20,6 @@ impl RosterRevisionRelay { .name("roster-revision-relay".into()) .spawn(move || loop { worker.wait_for_dirty(); - // A panic in one pass must not end the relay for the life of the process. let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { worker.push_dirty(&store); })); @@ -43,7 +33,6 @@ impl RosterRevisionRelay { Ok(relay) } - /// Safe on an SDK callback thread: one short lock, no calls. pub(crate) fn mark_dirty(&self, group_id: u64) { self.dirty .lock() @@ -62,8 +51,6 @@ impl RosterRevisionRelay { } } - /// Push every dirty party to the World Shards whose mirror is stale. A party marked during the - /// pass waits for the next one. pub(crate) fn push_dirty(&self, store: &St) { let parties = std::mem::take(&mut *self.dirty.lock().unwrap_or_else(PoisonError::into_inner)); @@ -107,7 +94,6 @@ fn push_party( } }; for shard in shards { - // A failed read cannot prove the mirror current, and a repeated push is idempotent. let mirrored = shard.held_roster_revision(group_id).unwrap_or(None); if !mirror_is_stale(realm_revision, mirrored) { continue; @@ -128,8 +114,6 @@ fn push_party( } } -/// A newer mirrored revision means this Gateway's Realm-core cache lags, and the shard would refuse -/// the older push anyway. fn mirror_is_stale(realm_revision: u64, mirrored: Option) -> bool { mirrored.is_none_or(|mirrored| mirrored < realm_revision) } diff --git a/gateway/src/world/party_mirror_tests.rs b/gateway/src/world/party_mirror_tests.rs index b83c83656..03e407cd6 100644 --- a/gateway/src/world/party_mirror_tests.rs +++ b/gateway/src/world/party_mirror_tests.rs @@ -1,14 +1,9 @@ -//! The Roster Revision Relay against the realm-wide party topology. Each test changes the Fake -//! Realm-core party directly, as a Module rule that no `party::run` drives would, then runs one -//! worker pass and reads both World Shard mirrors. - use super::party_tests::{ form_split_party, party_topology, party_topology_with, GINGER, TRIN, VIM, }; use super::*; use crate::world::party_mirror::RosterRevisionRelay; -/// The split party's id after `form_split_party`. fn split_party_id(realm: &WorldFake) -> u64 { realm .group_roster(GINGER) @@ -17,7 +12,6 @@ fn split_party_id(realm: &WorldFake) -> u64 { .group_id } -/// Add `guid` to `group_id` on Realm-core and advance its Roster Revision, with no party op. fn join_on_realm(realm: &WorldFake, group_id: u64, guid: u64) { let mut party = realm.party.party.lock().unwrap(); party.members.push((group_id, guid)); diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 7cd609a6a..d1d85f076 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1,27 +1,11 @@ -//! Realm-wide party state — the routing tests. -//! -//! What EXECUTES here is production `world::party`, against the same in-memory multi-database -//! topology the cross-database transfer tests use. What the fakes stand in for is named at each -//! seam: `FakeParty` models realm-core's authority (module reducer bodies cannot run in a gateway -//! test — there is no `ReducerContext`), and each shard's `mirror` is exactly what -//! `sync_group_mirror` wrote there. -//! -//! A child module of `world::tests` so it can reach `WorldFake` without widening anything. - use super::*; use lyracore_shared::group::{realm_op, GroupRefusal, RosterMember, RosterPayload}; -pub(super) const GINGER: u64 = 1; // in the open world, on `world` -pub(super) const VIM: u64 = 2; // inside the dungeon, on `instances` -pub(super) const TRIN: u64 = 3; // in the open world — the third member -pub(super) const DORMANT: u64 = 4; // has a character row on `world`, but is offline -/// A PLAYERBOT: a live `game_world_entity` on `world` whose character row never ran `player_login`, -/// so `game_character.online` stays false for its whole life. The module's own invite gate reads the -/// ENTITY ("a session-less playerbot's live entity counts"); the session flag would refuse it. +pub(super) const GINGER: u64 = 1; +pub(super) const VIM: u64 = 2; +pub(super) const TRIN: u64 = 3; +pub(super) const DORMANT: u64 = 4; pub(super) const BOT: u64 = 5; -/// A second PLAYERBOT, resident on the OTHER shard (`instances`) — the same session-less shape as -/// [`BOT`], on the far side of the boundary. The invite is authoritative on realm-core, so a bot -/// standing on a different database than the inviting player is reachable in principle; this pins it. const FAR_BOT: u64 = 6; fn topology_after_vim_is_deleted() -> ( @@ -86,7 +70,6 @@ fn two_member_topology_after_vim_is_deleted() -> ( (realm, world, deleted_from_instances) } -/// Party members in join order, every one in Subgroup 0. pub(super) fn party_members(guids: &[u64]) -> Vec { guids .iter() @@ -108,15 +91,13 @@ pub(super) fn character(guid: u64, name: &str) -> codec::CharacterView { } } -/// Realm-core owns the party. Ginger and Vim live on separate World Shards, -/// each connected to the authority and both World Stores. pub(super) fn party_topology_with( mirror_error: Option<&str>, accept_error: Option<&str>, ) -> ( - std::sync::Arc, // realm-core - std::sync::Arc, // the open-world shard - std::sync::Arc, // the instances shard + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, ShardCallLog, ) { let calls: ShardCallLog = Default::default(); @@ -147,9 +128,6 @@ pub(super) fn party_topology_with( character(BOT, "Botty"), ], session: SessionState { - // `live_guids` = `game_world_entity`; `offline_guids` = `game_character.online == false`. - // The bot is in BOTH, which is the production shape a playerbot has (spawned straight into - // the entity table, never logged in) and the case the two gates disagree about. live_guids: vec![GINGER, TRIN, BOT], ..Default::default() }, @@ -205,9 +183,6 @@ pub(super) fn party_topology() -> ( party_topology_with(None, None) } -/// Form the split party the live run could not: Ginger (open world) invites Vim (inside Deadmines), -/// Vim accepts. `pub(super)` (`loot_tests` reuses it — a disband-capable op needs a real -/// party to disband). pub(super) fn form_split_party(world: &WorldFake, instances: &WorldFake) { party::run(world, Actor::new(GINGER).unwrap(), party::Op::Invite(VIM)) .expect("the invite crosses"); @@ -399,7 +374,6 @@ fn a_stale_target_mirror_cannot_grant_command_authority() { .is_empty()); } -/// A local roster of `extra + 2` members: Ginger leads, and the bot takes Companion Orders. fn store_with_roster_of(extra: usize) -> WorldFake { let mut members = vec![GINGER, BOT]; members.extend((0..extra).map(|offset| 80_000 + offset as u64)); @@ -424,10 +398,6 @@ fn store_with_roster_of(extra: usize) -> WorldFake { } } -/// A Companion Order covers the whole Raid, but only the Group leader issues it. The leader may -/// order a bot in another Subgroup and name a member in a third, so the order reaches target -/// application with every member certified. A member of the Raid who is not the leader gets -/// `NotLeader`. #[test] fn a_companion_order_in_a_ten_member_raid_reaches_target_application() { let store = store_with_roster_of(8); @@ -477,8 +447,6 @@ fn a_companion_order_in_a_ten_member_raid_reaches_target_application() { assert_eq!(certified, expected); } -/// A roster longer than any Raid is a damaged cache, not a gameplay answer: the intent stays -/// pending. #[test] fn a_roster_longer_than_a_raid_is_not_sent_for_command_authority() { let store = store_with_roster_of(lyracore_shared::group::RAID_MAX_MEMBERS); @@ -819,26 +787,18 @@ fn equal_numeric_intents_from_distinct_modules_have_distinct_receipts() { ); } -/// **AC: an invite works across a shard boundary.** -/// -/// The live failure was not the party logic — it was the target RESOLUTION: `/invite` looks a typed -/// name up in `game_character` on ONE database, so a player inside Deadmines had no row on the open -/// world's database and the invite died as `BadPlayerName` before any rule ran. #[test] fn an_invite_resolves_a_target_standing_on_another_shard() { let (_realm, world, instances, _calls) = party_topology(); - // The pre-realm-core read, run against the shard the inviter is on: Vim is simply not there. assert_eq!( world.character_guid_by_name("Vim").unwrap(), None, "the fixture must reproduce the live shape — Vim's row lives on the instances shard" ); - // The realm-core read, from the same handle: the union finds them. assert_eq!( presence::resolve_by_name(world.as_ref(), "Vim").unwrap(), Some(VIM) ); - // …and it still resolves a name on the asking shard itself, from either side. assert_eq!( presence::resolve_by_name(instances.as_ref(), "Ginger").unwrap(), Some(GINGER) @@ -849,7 +809,6 @@ fn an_invite_resolves_a_target_standing_on_another_shard() { ); } -/// **AC: an invite works across a shard boundary** — the op itself, end to end. #[test] fn a_cross_shard_invite_and_accept_form_one_party_on_realm_core() { let (realm, world, instances, calls) = party_topology(); @@ -868,7 +827,6 @@ fn a_cross_shard_invite_and_accept_form_one_party_on_realm_core() { "the inviter leads and joins first, the acceptor second (join order)" ); drop(party_state); - // The op ran on REALM-CORE, not on either world shard — the whole point of the slice. let ops = calls.lock().unwrap().clone(); assert!( ops.iter() @@ -883,11 +841,6 @@ fn a_cross_shard_invite_and_accept_form_one_party_on_realm_core() { ); } -/// **AC: a SPLIT party sees each other's frames**, without the `begin_transfer` snapshot. -/// -/// Both members render an `SMSG_GROUP_LIST` naming the other with its ONLINE flag set — and the name -/// and the liveness each come from the shard that actually holds them, which no single database -/// could answer and which realm-core (no character rows) cannot answer either. #[test] fn a_split_party_renders_both_members_from_either_side_of_the_boundary() { let (realm, world, instances, _calls) = party_topology(); @@ -928,8 +881,6 @@ fn a_split_party_renders_both_members_from_either_side_of_the_boundary() { assert!(list.members[0].is_online); } -/// **AC: world shards read membership through the gateway rather than owning copies** — the -/// write-through mirror, and the fan-out that keeps every shard's copy honest. #[test] fn every_world_shard_mirrors_the_authoritative_roster_after_a_party_op() { let (realm, world, instances, _calls) = party_topology(); @@ -957,7 +908,6 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { "precondition: the party is mirrored" ); - // Two members: one leaving disbands the party (vanilla — a party of one is no party). party::run( instances.as_ref(), Actor::new(VIM).unwrap(), @@ -978,14 +928,9 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { } } -/// The leaver's own shard is not the only one that has to hear about it: the mirror push has to -/// cover the group the actor was in BEFORE the op, or the members still in it keep a roster listing -/// someone who left. The actor's membership row is gone from the authority by then, which is why the -/// group id is captured up front. #[test] fn leaving_a_party_re_pushes_the_roster_of_the_group_the_leaver_left() { let (realm, world, instances, _calls) = party_topology(); - // Three members, so the party SURVIVES the leave and there is a remaining roster to compare. form_split_party(&world, &instances); party::run( world.as_ref(), @@ -1167,8 +1112,6 @@ fn repeated_deleted_character_cleanup_is_harmless() { ); } -/// The deleted Character's LEAVE names its cause, so the party authority also drops the Target -/// Icons on it: the World Shard's delete sweep cannot reach Realm-core's icon rows. #[test] fn a_deleted_characters_leave_tells_the_authority_it_was_deleted() { let (realm, world, _instances, _calls) = topology_after_vim_is_deleted(); @@ -1431,11 +1374,6 @@ fn deleted_character_cleanup_flushes_pending_loot_before_leaving_realm_core() { ); } -/// **The invariant this batch has broken five times: unset config changes NOTHING.** -/// -/// A single-database gateway has no realm-core to route to, so every op takes the pre-realm-core -/// path — the player's own connection, the player-facing reducer, that database's own tables — and neither -/// the realm plane nor the mirror is touched at all. #[test] fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { let calls: ShardCallLog = Default::default(); @@ -1450,7 +1388,7 @@ fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { live_guids: vec![VIM], ..Default::default() }, - ..Default::default() // no `realm`, no `peers` — the unconfigured gateway + ..Default::default() }); assert!( store.realm_store().unwrap().is_none(), @@ -1509,15 +1447,10 @@ fn an_unsharded_gateway_runs_every_party_op_on_the_players_own_shard() { ); } -/// World ENTRY is what carries a party across the boundary now that the character-transfer blob's -/// party mirror is gone: the arriving shard gets realm-core's roster pushed onto it, and the player -/// gets their frame back. #[test] fn world_entry_pushes_the_authoritative_roster_onto_the_shard_the_player_arrives_on() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - // A fresh instances shard: the character arrived through a transfer, so it has NO party rows — - // exactly the state `import_character_blob` leaves now that membership does not ride the blob. instances.party.mirror.lock().unwrap().clear(); let (tx, rx) = crate::world::SessionTx::with_depth(0); @@ -1536,8 +1469,6 @@ fn world_entry_pushes_the_authoritative_roster_onto_the_shard_the_player_arrives assert_eq!(list.members[0].name, "Ginger"); } -/// A character in no party must not have a roster pushed for them — and an unsharded gateway must -/// not read anything at world entry at all. #[test] fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() { let (_realm, world, _instances, calls) = party_topology(); @@ -1570,16 +1501,6 @@ fn world_entry_is_a_no_op_for_an_ungrouped_character_and_on_a_single_database() ); } -/// **The mirror's self-healing claim, in the direction it does not hold by construction.** -/// -/// This module promises that a shard which misses a push -/// "re-syncs on the next op or world entry". For a member who is still IN the party that is true — -/// the arriving roster is pushed. For a member the authority DROPPED while that shard was -/// unreachable it was not: with no roster to push, world entry returned before touching anything and -/// the stale membership row survived every arrival. The shard then ran that character's gameplay — -/// kill-XP split, quest credit, loot method and round-robin, `/p` chat, the dungeon binding — against -/// a party they are not in, and no future op could ever repair it (the ops of a party they left never -/// name them again). #[test] fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_dropped() { let (realm, world, instances, _calls) = party_topology(); @@ -1594,8 +1515,6 @@ fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_droppe .expect("the third accepts"); let group_id = realm.group_roster(VIM).unwrap().unwrap().group_id; - // Vim leaves while the instances shard is unreachable: the authority commits, that shard's push - // is dropped on the floor (the documented best-effort ceiling), so its mirror still lists Vim. { let mut p = realm.party.party.lock().unwrap(); p.members.retain(|(_, g)| *g != VIM); @@ -1605,7 +1524,6 @@ fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_droppe "precondition: the instances mirror still has Vim in the party they already left" ); - // Vim comes back to that shard. This is the "or world entry" half of the self-healing promise. let (tx, _rx) = crate::world::SessionTx::with_depth(0); party::on_world_entry(&tx, instances.as_ref(), Actor::new(VIM).unwrap()) .expect("world entry is fine for a loner"); @@ -1628,9 +1546,6 @@ fn world_entry_clears_a_mirror_that_still_lists_a_character_the_authority_droppe ); } -/// The two gates realm-core cannot run for itself. Both are refused BEFORE the authority is touched, -/// and both answer the module's own Refusals so `social::party_result_for` classifies them -/// identically on either plane. #[test] fn an_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { let (realm, world, _instances, _calls) = party_topology(); @@ -1661,9 +1576,7 @@ fn an_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { ); } -/// An Orc, for the faction Gate. Race 2 is Horde; every other fixture Character is a Human. const GRUNT: u64 = 8; -/// An Orc with a character row but no live entity. const SLEEPING_GRUNT: u64 = 9; fn orc(guid: u64, name: &str) -> codec::CharacterView { @@ -1673,10 +1586,6 @@ fn orc(guid: u64, name: &str) -> codec::CharacterView { } } -/// Vanilla's default refuses a party across factions (cm:GroupHandler.cpp:80, -/// `AllowTwoSide.Interaction.Group = 0`), after the target lookup and before anything else. The -/// Gateway reads both races realm-wide, so an Orc standing on another Shard is refused before the -/// authority is touched. #[test] fn a_cross_faction_invite_is_refused_before_realm_core() { let calls: ShardCallLog = Default::default(); @@ -1749,7 +1658,6 @@ fn a_cross_faction_invite_is_refused_before_realm_core() { ); } -/// The unsharded plane refuses the same invite before the player-facing reducer runs. #[test] fn an_unsharded_gateway_refuses_a_cross_faction_invite_too() { let calls: ShardCallLog = Default::default(); @@ -1781,18 +1689,6 @@ fn an_unsharded_gateway_refuses_a_cross_faction_invite_too() { ); } -/// The moved ONLINE gate has to be the module's gate, not a lookalike. -/// -/// The module refuses an invite when the target has no `game_world_entity` row, and says so in its -/// own comment: *"a session-less playerbot's live entity counts"*. `game_character.online` is a -/// different fact — it is set by `player_login` and cleared by logout, and a playerbot runs neither -/// (its spawn reducer inserts the entity directly), so every bot in the tree has a live entity and -/// `online == false` for its whole life. -/// -/// Gating on the session flag therefore refuses `/invite ` on a MULTI-DATABASE gateway while -/// the single-database plane still accepts it — the moved gate answering differently than the module -/// did, which is a behaviour change wearing a refactor's clothes. The playerbot real-player-simulation -/// runs are driven by exactly this opcode. #[test] fn a_playerbot_is_invitable_because_the_online_gate_reads_the_entity_not_the_session_flag() { let (realm, world, _instances, _calls) = party_topology(); @@ -1825,11 +1721,6 @@ fn a_playerbot_is_invitable_because_the_online_gate_reads_the_entity_not_the_ses ); } -// =========================================================================================== -// Somebody has to ANSWER a bot's invite -// =========================================================================================== - -/// A bot on a World Shard accepts a Realm-core invite with its own Character guid. #[test] fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { use lyracore_shared::group::realm_op; @@ -1855,8 +1746,6 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { party_state.invites.is_empty(), "the pending invite must be CONSUMED; a leftover row is the hung dialog this fixes" ); - // IMPERSONATION (the hazard this batch already hit once): `realm_group_op` takes the actor as an - // ARGUMENT, so the gateway could trivially accept as somebody else. The bot acts as ITSELF. assert_eq!( party_state.ops.clone(), vec![ @@ -1869,10 +1758,8 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { #[test] fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_session_less() { - /// The seeded `init` character: a row on EVERY database, `online = false` in the seed. const SEEDED: u64 = 1; let (realm, world, instances, _calls) = party_topology(); - // `instances` carries the stale seed copy (offline)… let mut i_chars = instances.characters.clone(); i_chars.push(character(SEEDED, "Tester")); let far = std::sync::Arc::new(WorldFake { @@ -1892,7 +1779,6 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se }, ..Default::default() }); - // …while `world` holds the real, LOGGED-IN character and its live entity. let mut w_chars = world.characters.clone(); w_chars.push(character(SEEDED, "Tester")); let home = std::sync::Arc::new(WorldFake { @@ -1915,7 +1801,6 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se for shard in [&home, &far] { *shard.topology.peers.lock().unwrap() = vec![home.clone(), far.clone()]; } - // …and end to end: an inviter on the far shard must leave that player's dialog alone. party::run( far.as_ref(), Actor::new(VIM).unwrap(), @@ -1942,12 +1827,6 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se ); } -/// **AC: the bot's membership reaches the shard it stands on.** -/// -/// The bot's own in-world behaviour — follow-the-leader (the playerbot simulation), the -/// kill-XP split, `/p` — all read the SHARD's mirror, not realm-core. The answer therefore has to -/// happen before the mirror push of the op that caused it, or the bot is a member the shard does not -/// know about until the party's next op (and a bot party has no next op — the human does everything). #[test] fn the_bots_new_membership_is_mirrored_onto_its_own_shard_by_the_same_op() { let (realm, world, instances, _calls) = party_topology(); @@ -1992,11 +1871,6 @@ fn the_bots_new_membership_is_mirrored_onto_its_own_shard_by_the_same_op() { ); } -/// **AC: a bot on ANOTHER shard than the inviting player is reachable.** -/// -/// Free, now that the invite is authoritative on realm-core: the answer is a realm-core op too, so the -/// boundary never enters into it. Asserted once so a future change that resolves the bot against the -/// inviter's own database is caught. #[test] fn a_bot_standing_on_another_shard_answers_the_invite_too() { let (realm, world, _instances, _calls) = party_topology(); @@ -2014,10 +1888,6 @@ fn a_bot_standing_on_another_shard_answers_the_invite_too() { ); } -/// **AC: a real player's invite is NOT answered for them.** -/// -/// The counter-case, and the one that must never regress: Trin has a client, so Trin's dialog is -/// Trin's to answer. Auto-accepting for a human would be a party the player never agreed to join. #[test] fn a_real_players_invite_dialog_is_left_for_their_own_client_to_answer() { use lyracore_shared::group::realm_op; @@ -2047,12 +1917,6 @@ fn a_real_players_invite_dialog_is_left_for_their_own_client_to_answer() { ); } -/// **AC: a refusal DECLINES explicitly rather than being ignored.** -/// -/// An ignored refusal leaves the invite row standing (every accept gate in the module returns `Err`, -/// which rolls its transaction back) — and a hanging dialog is indistinguishable, from the player's -/// side, from the bug this whole issue is about. So the bot says no out loud: the invite is consumed -/// and the inviter gets `SMSG_GROUP_DECLINE` off the DECLINE event. #[test] fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hanging() { use lyracore_shared::group::{event_kind, realm_op}; @@ -2072,7 +1936,6 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin vec![ (realm_op::INVITE, GINGER, BOT, 0, 0, 0), (realm_op::ACCEPT, BOT, 0, 1, 0, 0), - // …and the decline is the bot's own too, not the inviter's. (realm_op::DECLINE, BOT, 0, 0, 0, 0), ], "a refused accept must be followed by an explicit decline" @@ -2088,11 +1951,6 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin ); } -/// The answer is not a new tick and not a poll: it runs INSIDE the invite op, so a bot is in the party -/// by the time `/invite` returns. Pinned because the alternative shape the issue suggested — a poll on -/// the playerbots goal tick — would have read the SHARD's `game_group_invite`, which a sharded -/// deployment never writes (the invite lives on realm-core), and would have been a no-op that a source -/// scan could not tell from a fix. #[test] fn the_bot_answers_within_the_invite_op_itself_with_no_second_call() { let (realm, world, _instances, calls) = party_topology(); @@ -2119,9 +1977,6 @@ fn the_bot_answers_within_the_invite_op_itself_with_no_second_call() { ); } -/// `realm_group_op` packs seven ops into six argument slots, and the packing is a WIRE contract with -/// the module (`lyracore_shared::group::realm_op`). A slot swap is silent — a loot-method change would -/// arrive as a kick of the master looter — so every op's packing is pinned as it is SENT. #[test] fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { use lyracore_shared::group::realm_op; @@ -2153,28 +2008,18 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { assert_eq!( realm.party.party.lock().unwrap().ops.clone(), vec![ - // INVITE: the target rides `target_guid`, nothing else is used. (realm_op::INVITE, GINGER, VIM, 0, 0, 0), - // ACCEPT: the acceptor's class in arg_a, the fixture's Warrior. (realm_op::ACCEPT, VIM, 0, 1, 0, 0), - // LOOT_METHOD: setting in arg_a, MASTER in target_guid, threshold in arg_b — - // CMSG_LOOT_METHOD's own field order. (realm_op::LOOT_METHOD, GINGER, VIM, 2, 4, 0), - // RAID_CONVERT: the actor alone. (realm_op::RAID_CONVERT, GINGER, 0, 0, 0, 0), - // UNINVITE: the kicked member rides `target_guid`. (realm_op::UNINVITE, GINGER, VIM, 0, 0, 0), ] ); } -/// -/// World entry delivers the Realm roster, and in-world party operations use Realm-core. #[test] fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() { let (realm, _world, _instances, calls) = party_topology(); - // The session's own shard, wired into the same realm + peer set as the topology's `world`, - // plus what `run_world_session` needs to handshake and enter the world as Ginger. let session_shard = std::sync::Arc::new(WorldFake { topology: TopologyState { shard: "world".into(), @@ -2196,9 +2041,6 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() ..Default::default() }); *session_shard.topology.peers.lock().unwrap() = vec![session_shard.clone()]; - // Ginger is ALREADY in a party on realm-core when they log in — a party formed while they were - // on the loading screen, which is exactly what the deleted character-transfer blob mirror could - // never carry. { let mut p = realm.party.party.lock().unwrap(); p.next_group_id = 5; @@ -2213,15 +2055,12 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() run_world_session(server_end, server_store.clone()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // Missing roster delivery must fail at the read deadline. CMSG_PLAYER_LOGIN { guid: Guid::new(GINGER), } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The realm-wide party slice appends the party frame right after world entry. A frame that - // times out or does not decode is missing; the assertions below say what. let mut frames = drain_world_entry(&mut client, &mut c_dec); frames.extend(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).ok()); let mut roster_named: Option = None; @@ -2242,10 +2081,6 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() "world entry must push the authoritative roster onto the shard the player entered" ); - // …and EVERY party op typed in-world goes to realm-core, not to this shard's own tables — each - // one attributed to the character this socket authenticated as. - // - // Each operation must carry the Character guid authenticated by this socket. use wow_world_messages::vanilla::{ CMSG_GROUP_ACCEPT, CMSG_GROUP_DECLINE, CMSG_GROUP_DISBAND, CMSG_GROUP_INVITE, CMSG_GROUP_RAID_CONVERT, CMSG_GROUP_UNINVITE, CMSG_LOOT_METHOD, @@ -2275,11 +2110,6 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() CMSG_GROUP_UNINVITE { name: "vim".into() } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The BARRIER: an invite for a name no shard can resolve never reaches `party::run`, so it adds - // no op — but it always answers `SMSG_PARTY_COMMAND_RESULT`, and the dispatch is sequential on - // one thread, so seeing ITS reply proves all seven above have been dispatched. (No `join`: the - // session thread outlives the socket by design, and waiting on it would reintroduce the hang the - // deadline above removes.) CMSG_GROUP_INVITE { name: "Nobodyatall".into(), } @@ -2291,7 +2121,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() break; } Ok(_) => {} - Err(_) => break, // the deadline fired — the assertions below say what was missing + Err(_) => break, } } drop(client); @@ -2308,19 +2138,14 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() "the invite ran against the session shard's own party tables — the shard-local behaviour \ realm-wide party routing removes. Calls were {log:?}" ); - // Realm-core derives authorization from the actor argument. Verify each operation - // carries the authenticated Character and the correct argument slots, including Refusals. use lyracore_shared::group::realm_op; assert_eq!( realm.party.party.lock().unwrap().ops.clone(), vec![ - // World entry asks for the Party's Target Icons after the list. (realm_op::TARGET_ICON, GINGER, 0, 0xFF, 0, 0), (realm_op::INVITE, GINGER, VIM, 0, 0, 0), (realm_op::ACCEPT, GINGER, 0, 1, 0, 0), (realm_op::DECLINE, GINGER, 0, 0, 0, 0), - // CMSG_LOOT_METHOD's own field order: setting in arg_a, MASTER in target_guid, - // threshold in arg_b. (realm_op::LOOT_METHOD, GINGER, VIM, 2, 4, 0), (realm_op::RAID_CONVERT, GINGER, 0, 0, 0, 0), (realm_op::LEAVE, GINGER, 0, 0, 0, 0), @@ -2331,12 +2156,8 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() ); } -/// A failed mirror push must not turn a party op that DID commit into a failure the client renders -/// as one: realm-core has already accepted the change, and the party frame is relayed from there, -/// not from the mirror. #[test] fn a_shard_that_refuses_the_mirror_does_not_fail_the_party_op() { - // The instances shard rejects `sync_group_mirror` (a database that went away mid-op). let (realm, world, instances, _calls) = party_topology_with(Some("instances is unreachable"), None); @@ -2368,25 +2189,11 @@ fn a_shard_that_refuses_the_mirror_does_not_fail_the_party_op() { ); } -// =========================================================================================== -// Bot-initiated (serendipity) invites go through the SAME authority a player's own -// CMSG_GROUP_INVITE does, so a `sync_group_mirror` push never contradicts a bot-formed party. -// =========================================================================================== - -/// **AC: bot-formed parties are created through the same authority as player parties.** -/// -/// `run_bot_invite` — not `invite_core` — is what a playerbot's serendipity pick now runs through. -/// The bot has no client and no account connection, so this must reach realm-core the same guid-based -/// way [`answer_for_session_less`] already does, and must NOT touch either shard's own `game_group`/ -/// `game_group_member` tables directly (the exact shard-local write realm-wide party routing already -/// removed once). #[test] fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { use lyracore_shared::group::realm_op; let (realm, world, instances, calls) = party_topology(); - // BOT (on `world`) invites FAR_BOT (on `instances`) — the far side of the boundary, same shape - // `a_bot_standing_on_another_shard_answers_the_invite_too` pins for the player-initiated case. party::run_bot_invite(world.as_ref(), BOT, FAR_BOT).expect("a bot-initiated invite must land"); let party_state = realm.party.party.lock().unwrap(); @@ -2432,8 +2239,6 @@ fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { } } -/// Two Gateways observe the same subscribed row. The World Shard claim is the only arbitration -/// point, so exactly one consumer may reach Realm-core even when both callbacks run concurrently. #[test] fn two_relay_consumers_execute_one_bot_invite() { use lyracore_shared::group::{bot_op, realm_op}; @@ -2490,9 +2295,6 @@ fn two_relay_consumers_execute_one_bot_invite() { assert!(world.party.bot_invite_intents.lock().unwrap().is_empty()); } -/// **AC: one intent table, two party ops.** The row's `op` byte is the whole dispatch, so an INVITE -/// and a LEAVE written to the same table must reach two different ops on the party authority. A -/// dispatch that ignored the byte would silently re-invite a bot that asked to leave. #[test] fn the_intent_op_byte_picks_the_party_op_that_runs() { use lyracore_shared::group::{bot_op, realm_op}; @@ -2545,9 +2347,6 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { ); } -/// A module newer than this gateway can write an op byte this build has never heard of. Running it -/// as an invite would form a party nobody asked for, so it is refused by name — and the claim has -/// already consumed the row, so it is refused once rather than every second. #[test] fn an_unknown_intent_op_is_refused_rather_than_run_as_an_invite() { const INTENT_ID: u64 = 73; @@ -2572,17 +2371,9 @@ fn an_unknown_intent_op_is_refused_rather_than_run_as_an_invite() { ); } -/// **The regression test the issue asks for.** A bot party must survive the next -/// `sync_group_mirror` push that touches its group id — the failure mode was that realm-core had -/// never heard of the group, so the mirror read that as "this party does not exist" and tombstoned -/// it. Simulated here as a SECOND, independent push (a later world entry or another member's op would -/// trigger exactly this) rather than the op's own immediate push, so it is not just re-testing the -/// invite path above. #[test] fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { let (realm, world, _instances, _calls) = party_topology(); - // A second BOT as the target (not TRIN, a real player) so the invite auto-accepts and actually - // forms a party in one call — a pending invite has no mirror to survive anything. party::run_bot_invite(world.as_ref(), BOT, FAR_BOT).expect("forms the party"); let group_id = realm .party @@ -2598,8 +2389,6 @@ fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { "precondition: the party is already mirrored by the invite's own push" ); - // The next push that touches this exact group id — nobody's `self_guid`, `before` names the group - // directly, matching how `on_world_entry`/`sync_mirrors` reach a group that isn't the actor's own. party::sync_mirrors( world.as_ref(), realm.as_ref(), @@ -2616,14 +2405,6 @@ fn a_bots_party_survives_the_next_sync_group_mirror_push_that_touches_it() { ); } -/// **The counterfactual, proving the mechanism above is real.** A group that realm-core has never -/// heard of — modelling the bug's PRE-fix shape, where a bot wrote this shard's -/// `game_group`/`game_group_member` rows directly and realm-core's authority never gained a matching -/// row — IS wiped by the next push -/// that touches its id. This is not a hypothetical: the world shard's own `game_group.group_id` and -/// realm-core's run independent `#[auto_inc]` counters, so a shard-local-only id colliding with some -/// unrelated REAL realm-core party's id was exactly how the live bug manifested — any op on that real -/// party pushed realm-core's (different) roster for the same number over the bot's local rows. #[test] fn a_shard_local_only_group_realm_core_never_heard_of_is_wiped_by_the_next_push() { let (realm, world, _instances, _calls) = party_topology(); @@ -2661,8 +2442,6 @@ fn a_shard_local_only_group_realm_core_never_heard_of_is_wiped_by_the_next_push( ); } -/// Same existence/online gate a player's own invite uses (`presence`/`live_anywhere`) — a bot invite -/// must not skip it just because there is no client waiting on the `SMSG_PARTY_COMMAND_RESULT`. #[test] fn a_bot_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { let (realm, world, _instances, _calls) = party_topology(); @@ -2682,10 +2461,6 @@ fn a_bot_invite_to_a_missing_or_offline_target_never_reaches_realm_core() { ); } -/// **AC, unsharded half.** A bot has no per-account connection, on EITHER topology — so unlike -/// [`run`], `run_bot_invite` must not fall back to the account-based player reducers when there is no -/// realm-core to route to. Unsharded, this database is its own authority: the guid-based -/// `realm_group_op` still runs, against the ONE database there is. #[test] fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { use lyracore_shared::group::realm_op; @@ -2697,7 +2472,7 @@ fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { ..Default::default() }, party: PartyState { - is_realm: true, // this database IS its own authority — nothing else to route to + is_realm: true, ..Default::default() }, characters: vec![character(BOT, "Botty"), character(TRIN, "Trin")], @@ -2709,7 +2484,7 @@ fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { offline_guids: vec![BOT], ..Default::default() }, - ..Default::default() // no `realm`, no `peers` — the unconfigured gateway + ..Default::default() }); assert!( store.realm_store().unwrap().is_none(), @@ -2920,10 +2695,6 @@ fn unsharded_bot_invitations_leave_a_suppressed_target_unanswered() { assert_eq!(state.ops, vec![(realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0)]); } -// ---- Raids ---- - -/// Decode one sent `SMSG_GROUP_LIST`. With a loot block it must end with the extra byte 0 the 1.12 -/// servers send after the loot threshold. fn group_list(packet: Outbound) -> wow_world_messages::vanilla::SMSG_GROUP_LIST { let Outbound::Raw { opcode, body } = packet else { panic!("expected a raw SMSG_GROUP_LIST") @@ -2956,7 +2727,6 @@ fn mirror_calls(calls: &ShardCallLog) -> usize { .count() } -/// **AC: the leader converts; every member gets a raid list and every shard mirrors the Raid.** #[test] fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() { let (realm, world, instances, _calls) = party_topology(); @@ -3022,8 +2792,6 @@ fn a_leader_converts_the_party_on_realm_core_and_every_shard_mirrors_the_raid() assert!(list.members.iter().all(|member| member.flags == 0)); } -/// Converting a Raid again changes nothing on Realm-core, so it must not cost a mirror push to -/// every World Shard each time a client repeats the opcode. #[test] fn converting_a_raid_again_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); @@ -3047,7 +2815,6 @@ fn converting_a_raid_again_pushes_no_mirror() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC: a non-leader's convert changes nothing and sends nothing.** #[test] fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { let (realm, world, instances, calls) = party_topology(); @@ -3074,8 +2841,6 @@ fn a_member_who_does_not_lead_cannot_convert_and_no_mirror_is_pushed() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC: a single-database Gateway converts through `realm_group_op` on its only shard.** A raid op -/// has no player-facing reducer, so the home shard is the party authority there. #[test] fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { let calls: ShardCallLog = Default::default(); @@ -3115,9 +2880,6 @@ fn an_unsharded_gateway_converts_through_realm_group_op_on_its_own_shard() { assert_eq!(state.kind_of(5), GroupKind::Raid); } -/// **AC: with Subgroup 0 full, the next joiner lands in Subgroup 1, and every member's list shows -/// its flags byte as 1.** The placement is the Module's rule; the Gateway must carry it to every -/// mirror and every list. #[test] fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { let (realm, world, instances, _calls) = party_topology(); @@ -3174,14 +2936,11 @@ fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { } } -/// **AC: a member on another shard renders online in a LIST pushed from Realm-core.** #[test] fn the_realm_core_list_relay_renders_a_member_on_another_shard_online() { let (realm, world, instances, _calls) = party_topology(); form_split_party(&world, &instances); - // The production Realm-core Coordinator reads every World Shard. *realm.topology.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; - // What the Module writes on Realm-core: no names, no presence. let payload = RosterPayload { leader: GINGER, loot_method: 3, @@ -3227,8 +2986,6 @@ fn the_realm_core_list_relay_renders_a_member_on_another_shard_online() { ); } -/// A name the payload carries is kept; a blank one is read from the shards; a member no shard can -/// name stays in the list, blank and offline, because a missing row reads as "they left". #[test] fn a_list_keeps_payload_names_and_lists_a_member_no_shard_can_name() { let (_realm, world, _instances, _calls) = party_topology(); @@ -3269,8 +3026,6 @@ fn a_list_keeps_payload_names_and_lists_a_member_no_shard_can_name() { ); } -/// **AC: `CMSG_GROUP_RAID_CONVERT` in, `SMSG_PARTY_COMMAND_RESULT(Invite, "", Success)` out** -/// (cm:GroupHandler.cpp:488), and a refused convert sends nothing (cm:GroupHandler.cpp:483-484). #[test] fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { use wow_world_messages::vanilla::{ @@ -3302,8 +3057,6 @@ fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { GroupKind::Raid ); - // Another member now leads, so the session's convert is refused. The barrier invite's reply - // must be the next packet: the refusal sent nothing. store.party.party.lock().unwrap().groups[0].1 = 2; CMSG_GROUP_RAID_CONVERT {} .write_encrypted_client(&mut client, &mut c_enc) @@ -3326,9 +3079,6 @@ fn raid_convert_answers_the_leader_with_success_and_a_refusal_with_silence() { let _ = server.join(); } -// ---- Leadership and Assistants ---- - -/// Ginger leads a Party of Ginger (world), Vim (instances) and Trin (world). fn party_of_three() -> ( std::sync::Arc, std::sync::Arc, @@ -3347,7 +3097,6 @@ fn party_of_three() -> ( (realm, world, instances, calls) } -/// The kinds `guid` received since event `from`. fn events_for(realm: &WorldFake, from: usize, guid: u64) -> Vec { realm.party.party.lock().unwrap().events[from..] .iter() @@ -3356,9 +3105,6 @@ fn events_for(realm: &WorldFake, from: usize, guid: u64) -> Vec { .collect() } -/// **AC: the leader passes the lead to an online member. Every member hears the new leader, then -/// gets a list naming it, and every shard mirrors the new leader.** The target is live on the far -/// shard, which only the Gateway can see from here. #[test] fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { use lyracore_shared::group::event_kind::{LIST, SET_LEADER}; @@ -3390,8 +3136,6 @@ fn the_leader_passes_the_lead_to_a_member_live_on_the_far_shard() { } } -/// **AC: passing the lead to an offline member changes nothing.** Realm-core cannot see presence, -/// so the Gateway refuses before it calls the authority, and a live target on either shard passes. #[test] fn the_lead_passes_only_to_a_member_live_on_some_shard() { let (realm, world, _instances, calls) = party_of_three(); @@ -3433,8 +3177,6 @@ fn the_lead_passes_only_to_a_member_live_on_some_shard() { ); } -/// The two leadership ops pack their arguments into `realm_group_op`'s slots as the shared contract -/// names them: the member in `target_guid`, and promote or demote in `arg_a`. #[test] fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { let (realm, world, _instances, _calls) = party_of_three(); @@ -3470,8 +3212,6 @@ fn leadership_ops_reach_realm_core_in_their_declared_argument_slots() { ); } -/// **AC: the raid leader promotes a member, and every list shows its flags byte with `0x80`; -/// demoting clears it.** Each change resyncs every mirror. #[test] fn a_promoted_assistant_shows_0x80_in_every_list_and_every_mirror() { let (realm, world, instances, _calls) = party_of_three(); @@ -3512,7 +3252,6 @@ fn a_promoted_assistant_shows_0x80_in_every_list_and_every_mirror() { } } -/// Repeating a promotion changes nothing on Realm-core, so it costs no mirror push. #[test] fn repeating_a_promotion_pushes_no_mirror() { let (_realm, world, _instances, calls) = party_of_three(); @@ -3535,8 +3274,6 @@ fn repeating_a_promotion_pushes_no_mirror() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC: a single-database Gateway runs both leadership ops through `realm_group_op` on its own -/// shard**, after the same presence Gate. #[test] fn an_unsharded_gateway_runs_leadership_ops_on_its_own_shard() { let calls: ShardCallLog = Default::default(); @@ -3584,7 +3321,6 @@ fn an_unsharded_gateway_runs_leadership_ops_on_its_own_shard() { assert!(state.slots[&VIM].is_assistant()); } -/// A `SET_LEADER` row as the relay decodes it. fn set_leader_row(leader: u64) -> crate::stdb::bindings::GroupEvent { crate::stdb::bindings::GroupEvent { id: 1, @@ -3598,7 +3334,6 @@ fn set_leader_row(leader: u64) -> crate::stdb::bindings::GroupEvent { } } -/// The one `SMSG_GROUP_SET_LEADER` name the relay sends for `row`, or `None` for no packet. fn relayed_leader_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, @@ -3616,9 +3351,6 @@ fn relayed_leader_name( } } -/// **AC: every member gets `SMSG_GROUP_SET_LEADER` with the new leader's name.** The relay reads -/// the name from whichever shard holds the leader. A leader no shard can name sends nothing rather -/// than a blank "is now the group leader" line. #[test] fn the_set_leader_relay_names_the_leader_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); @@ -3631,8 +3363,6 @@ fn the_set_leader_relay_names_the_leader_from_the_far_shard() { assert_eq!(relayed_leader_name(&realm, &set_leader_row(404)), None); } -/// An `INVITE` row as the relay decodes it: `other_guid` is the INVITER (`invite_core_on`'s -/// `push_event` call in `module/src/group.rs`). fn invite_row(inviter: u64) -> crate::stdb::bindings::GroupEvent { crate::stdb::bindings::GroupEvent { id: 1, @@ -3646,7 +3376,6 @@ fn invite_row(inviter: u64) -> crate::stdb::bindings::GroupEvent { } } -/// The one `SMSG_GROUP_INVITE` name the relay sends for `row`, or `None` for no packet. fn relayed_invite_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, @@ -3664,12 +3393,6 @@ fn relayed_invite_name( } } -/// **AC: the invite dialog names the inviter, even across a shard boundary, and still reaches the -/// target when no shard can name them.** `push_event` writes `other_name` on whichever database -/// the invite op runs on. On a sharded Realm that is Realm-core, which holds no `game_character` -/// rows, so the relay reads the inviter's name from the World Shard caches instead. The pending -/// `game_group_invite` row already committed, and the popup is the target's only way to learn of -/// it, so an unresolvable name sends the popup blank rather than dropping the packet. #[test] fn the_invite_relay_names_the_inviter_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); @@ -3686,8 +3409,6 @@ fn the_invite_relay_names_the_inviter_from_the_far_shard() { ); } -/// A `DECLINE` row as the relay decodes it: `other_guid` is the DECLINER (`decline_invite_on`'s -/// `push_event` call in `module/src/group.rs`). fn decline_row(decliner: u64) -> crate::stdb::bindings::GroupEvent { crate::stdb::bindings::GroupEvent { id: 1, @@ -3701,7 +3422,6 @@ fn decline_row(decliner: u64) -> crate::stdb::bindings::GroupEvent { } } -/// The one `SMSG_GROUP_DECLINE` name the relay sends for `row`, or `None` for no packet. fn relayed_decline_name( realm: &WorldFake, row: &crate::stdb::bindings::GroupEvent, @@ -3719,10 +3439,6 @@ fn relayed_decline_name( } } -/// **AC: the inviter's "declined" line names the decliner, even across a shard boundary.** Same -/// defect as INVITE, same fix: the relay resolves the name from the World Shard caches rather than -/// trusting `other_name`. Same fallback as INVITE too: a decliner no shard can name still gets a -/// line sent, blank rather than dropped, for the same consistency reason. #[test] fn the_decline_relay_names_the_decliner_from_the_far_shard() { let (realm, world, instances, _calls) = party_topology(); @@ -3738,7 +3454,6 @@ fn the_decline_relay_names_the_decliner_from_the_far_shard() { ); } -/// Decode one hand-written client frame: size (u16 BE, opcode plus body), opcode (u32 LE), body. fn client_frame(opcode: u32, body: &[u8]) -> ClientOpcodeMessage { let mut framed = u16::try_from(body.len() + 4) .unwrap() @@ -3749,8 +3464,6 @@ fn client_frame(opcode: u32, body: &[u8]) -> ClientOpcodeMessage { ClientOpcodeMessage::read_unencrypted(&mut framed.as_slice()).expect("a client frame") } -/// The three bodies as cmangos reads them: a u64 member guid, and for the Assistant opcode one -/// flag byte after it (cm:GroupHandler.cpp:252-253, 346-347, 529-532). #[test] fn leadership_opcodes_decode_from_the_vanilla_layout() { let guid = 0x0102_0304_0506_0708u64; @@ -3778,7 +3491,6 @@ fn leadership_opcodes_decode_from_the_vanilla_layout() { } } -/// A session on a shard that reaches `realm`, entered as Ginger. fn realm_session( realm: &std::sync::Arc, ) -> ( @@ -3800,7 +3512,6 @@ fn realm_session( enter_world(store, GINGER) } -/// Send `CMSG_GROUP_UNINVITE_GUID`, then the barrier. fn kick_by_guid( client: &mut UnixStream, c_enc: &mut EncrypterHalf, @@ -3815,9 +3526,6 @@ fn kick_by_guid( barrier(client, c_enc, c_dec) } -/// Send an invite for a name no shard knows, which is always answered. Returns the first -/// `SMSG_PARTY_COMMAND_RESULT` the session sends back, past the party frame world entry sent. The -/// dispatch is sequential, so a result naming the barrier proves that no op before it answered. fn barrier( client: &mut UnixStream, c_enc: &mut EncrypterHalf, @@ -3837,9 +3545,6 @@ fn barrier( } } -/// **AC: an Assistant who names the leader in the raid frame's "Remove from group" gets -/// `SMSG_PARTY_COMMAND_RESULT(Leave, "", NotLeader)`, and nothing changes** -/// (cm:GroupHandler.cpp:274-276). #[test] fn a_guid_kick_of_the_leader_answers_not_leader() { use wow_world_messages::vanilla::{PartyOperation, PartyResult}; @@ -3873,10 +3578,6 @@ fn a_guid_kick_of_the_leader_answers_not_leader() { let _ = server.join(); } -/// **AC: set leader and set Assistant reach the party authority as the session's own character, -/// and neither answers the client, whatever the outcome** (cm:GroupHandler.cpp:344-362, 527-545). -/// The last set leader is refused, because Ginger no longer leads; the barrier's answer is still -/// the first packet. #[test] fn set_leader_and_set_assistant_run_as_the_session_and_answer_nothing() { use wow_world_messages::vanilla::{CMSG_GROUP_ASSISTANT_LEADER, CMSG_GROUP_SET_LEADER}; @@ -3931,8 +3632,6 @@ fn set_leader_and_set_assistant_run_as_the_session_and_answer_nothing() { let _ = server.join(); } -/// A guid kick naming the sender is dropped without an answer, as cmangos drops it -/// (cm:GroupHandler.cpp:255-260). It never reaches the party authority. #[test] fn a_guid_kick_of_yourself_answers_nothing() { let realm = std::sync::Arc::new(WorldFake { @@ -3961,8 +3660,6 @@ fn a_guid_kick_of_yourself_answers_nothing() { let _ = server.join(); } -/// **AC: the leader removes a member by guid.** No name is looked up, so a member no shard can name -/// is still removed, and a kick that ran answers nothing: the next packet is the barrier's. #[test] fn a_guid_kick_removes_a_member_no_shard_can_name() { let realm = std::sync::Arc::new(WorldFake { @@ -3997,9 +3694,6 @@ fn a_guid_kick_removes_a_member_no_shard_can_name() { drop(client); let _ = server.join(); } -/// The `realm_group_op` argument slots [`party::Op::ChangeSubgroup`] and [`party::Op::SwapSubgroup`] -/// declare: the mover in `target_guid` and the destination Subgroup in `arg_a` for a move, one -/// member in `target_guid` and the other in `arg_c` for a swap. #[test] fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { use lyracore_shared::group::realm_op; @@ -4041,8 +3735,6 @@ fn subgroup_ops_reach_realm_core_in_their_declared_argument_slots() { ); } -/// **AC 1, 9: the leader moves a member to another Subgroup; every shard mirrors the new slot with -/// its Assistant bit kept, and the Roster Revision advances.** #[test] fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot() { let (realm, world, instances, _calls) = party_topology(); @@ -4093,7 +3785,6 @@ fn a_leader_moves_a_member_to_another_subgroup_and_every_shard_mirrors_the_slot( } } -/// Moving into the Subgroup a member already holds pushes no mirror to either shard. #[test] fn changing_into_the_same_subgroup_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); @@ -4120,7 +3811,6 @@ fn changing_into_the_same_subgroup_pushes_no_mirror() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC 2: an Assistant may move a member; a plain member may not, and nothing changes.** #[test] fn an_assistant_can_move_a_member_a_plain_member_cannot() { let (realm, world, instances, _calls) = party_topology(); @@ -4187,9 +3877,6 @@ fn an_assistant_can_move_a_member_a_plain_member_cannot() { ); } -/// An Assistant's rights reach both a roster change and a Group Broadcast from the same session: -/// the Subgroup move lands on the authoritative roster, and the Target Icon mark reaches the party -/// authority as the Assistant's own action. #[test] fn an_assistant_moves_a_member_and_marks_a_target_icon() { let (realm, world, instances, _calls) = party_topology(); @@ -4252,7 +3939,6 @@ fn an_assistant_moves_a_member_and_marks_a_target_icon() { ); } -/// **AC 4: a Raid has only 8 Subgroups, 0 to 7.** #[test] fn a_move_to_subgroup_eight_is_refused() { let (realm, world, instances, _calls) = party_topology(); @@ -4282,7 +3968,6 @@ fn a_move_to_subgroup_eight_is_refused() { assert_eq!(realm.group_roster(GINGER).unwrap().unwrap(), before); } -/// **AC 5: in a Party, both ops are refused and change nothing.** #[test] fn subgroup_ops_in_a_party_are_refused_and_change_nothing() { let (realm, world, instances, _calls) = party_topology(); @@ -4313,9 +3998,6 @@ fn subgroup_ops_in_a_party_are_refused_and_change_nothing() { assert_eq!(realm.group_roster(GINGER).unwrap().unwrap(), before); } -/// A Raid whose Subgroup 0 and Subgroup 1 each hold 5 members: `GINGER` leads Subgroup 0, -/// `VIM` leads Subgroup 1. AC 3 and AC 6 need Subgroups already full, which is a different -/// scenario from a member joining one with room. fn seed_raid_with_two_full_subgroups(realm: &WorldFake) { let mut p = realm.party.party.lock().unwrap(); let group_id = 20; @@ -4331,7 +4013,6 @@ fn seed_raid_with_two_full_subgroups(realm: &WorldFake) { p.raids.push(group_id); } -/// **AC 3: a move into a full Subgroup is refused, changes nothing, and pushes no mirror.** #[test] fn a_move_into_a_full_subgroup_is_refused() { let (realm, world, _instances, calls) = party_topology(); @@ -4354,8 +4035,6 @@ fn a_move_into_a_full_subgroup_is_refused() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC 6: swapping two members of two full Subgroups needs no capacity Gate, and sends exactly -/// one list per member.** #[test] fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member() { let (realm, world, instances, _calls) = party_topology(); @@ -4399,7 +4078,6 @@ fn swapping_members_of_two_full_subgroups_succeeds_and_sends_one_list_per_member } } -/// **AC 7: swapping two members of one Subgroup succeeds and pushes no mirror.** #[test] fn swapping_members_of_one_subgroup_pushes_no_mirror() { let (_realm, world, instances, calls) = party_topology(); @@ -4430,9 +4108,6 @@ fn swapping_members_of_one_subgroup_pushes_no_mirror() { assert_eq!(mirror_calls(&calls), mirrors_before); } -/// **AC 8: a name outside the actor's OWN roster resolves to nothing, even when a Character with -/// that name exists elsewhere on the realm.** cmangos matches Change/Swap Subgroup names against -/// the member list alone (cm:GroupHandler.cpp:919-936), never realm-wide. #[test] fn resolve_roster_member_by_name_ignores_a_namesake_outside_the_roster() { let (_realm, world, instances, _calls) = party_topology(); @@ -4452,17 +4127,10 @@ fn resolve_roster_member_by_name_ignores_a_namesake_outside_the_roster() { ); } -// ---- Group Broadcasts ---- - -/// `realm_group_op`'s slots after the actor: `(op, target_guid, arg_a, arg_b, arg_c)`. type RealmArgs = (u8, u64, u8, u8, u64); -/// One packet as the client receives it: `(opcode, body)`. type Wire = (u16, Vec); -/// Every Group Broadcast op and the `realm_group_op` slots it must fill: the op byte, the target -/// slot, `arg_a`, `arg_b` and `arg_c`. Pinned by hand against `realm_op`'s slot table. The ping's -/// floats go as bit patterns: 0.5 is `0x3F00_0000` and -0.25 is `0xBE80_0000`. fn broadcast_ops() -> [(party::Op, RealmArgs); 6] { [ (party::Op::ReadyCheckStart, (11, 0, 0, 0, 0)), @@ -4492,9 +4160,6 @@ fn broadcast_ops() -> [(party::Op, RealmArgs); 6] { ] } -/// **AC: none of the broadcast ops advances the Roster Revision or pushes a mirror.** On a sharded -/// Realm each one is one `realm_group_op` call on Realm-core and nothing else: no roster read, no -/// loot-roll flush, no World Shard write. #[test] fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { let (realm, world, instances, calls) = party_topology(); @@ -4530,8 +4195,6 @@ fn a_group_broadcast_runs_once_on_realm_core_and_pushes_no_mirror() { } } -/// **AC: a single-database Gateway runs all five on its only shard.** No player-facing reducer -/// exists for them, so the home shard's `realm_group_op` runs each one. #[test] fn an_unsharded_gateway_runs_every_group_broadcast_on_its_own_shard() { let calls: ShardCallLog = Default::default(); @@ -4568,7 +4231,6 @@ fn an_unsharded_gateway_runs_every_group_broadcast_on_its_own_shard() { } } -/// Send one hand-written client frame over the encrypted session. fn send_client_frame( client: &mut UnixStream, encrypter: &mut EncrypterHalf, @@ -4583,8 +4245,6 @@ fn send_client_frame( client.write_all(body).unwrap(); } -/// A barrier: the reply to an invite of an unknown name must be the next packet, so nothing the -/// frames before it sent reached the client. fn assert_nothing_sent_before_the_barrier( client: &mut UnixStream, encrypter: &mut EncrypterHalf, @@ -4601,9 +4261,6 @@ fn assert_nothing_sent_before_the_barrier( } } -/// Client bytes in, `realm_group_op` slots out, for every opcode (cmangos: GroupHandler.cpp -/// 395-415 ping, 417-442 roll, 444-471 icons, 547-583 ready check). The actor's own packets ride -/// the group event relay, so the session answers none of them directly. #[test] fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_values() { let s = quest_store(); @@ -4617,17 +4274,11 @@ fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_valu let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); let skull_on: Vec = [&[0x07][..], &0xF130_0000_0000_0042u64.to_le_bytes()].concat(); let frames: [(u32, Vec); 6] = [ - // MSG_RAID_READY_CHECK, start. (0x0322, vec![]), - // MSG_RAID_READY_CHECK, answer "ready". (0x0322, vec![0x01]), - // MSG_RAID_TARGET_UPDATE, skull on a creature. (0x0321, skull_on), - // MSG_RAID_TARGET_UPDATE, the list request. (0x0321, vec![0xFF]), - // MSG_MINIMAP_PING at (0.5, -0.25). (0x01D5, vec![0, 0, 0, 0x3F, 0, 0, 0x80, 0xBE]), - // MSG_RANDOM_ROLL with inverted bounds, min 100 and max 1. (0x01FB, vec![100, 0, 0, 0, 1, 0, 0, 0]), ]; for (opcode, body) in &frames { @@ -4644,7 +4295,6 @@ fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_valu (13, 1, 0xF130_0000_0000_0042, 7, 0, 0), (13, 1, 0, 0xFF, 0, 0), (14, 1, 0x3F00_0000, 0, 0, 0xBE80_0000), - // The Module normalizes the bounds; the Gateway passes what the client sent. (15, 1, 100, 0, 0, 1), ] ); @@ -4652,8 +4302,6 @@ fn every_group_broadcast_opcode_reaches_the_party_authority_with_its_client_valu let _ = server.join(); } -/// cmangos answers a refused Ready Check, mark or ping with silence. A ninth icon index reaches -/// the Module, which refuses it. #[test] fn a_refused_group_broadcast_sends_nothing() { let store = std::sync::Arc::new(quest_store()); @@ -4675,9 +4323,6 @@ fn a_refused_group_broadcast_sends_nothing() { let _ = server.join(); } -/// A second Ready Check start, minimap ping or `/roll` inside its one-second cooldown never -/// reaches the party authority, and one kind does not hold back another. A Ready Check answer and -/// a Target Icon have no cooldown. #[test] fn a_repeated_fan_out_broadcast_inside_its_cooldown_is_dropped() { let s = quest_store(); @@ -4691,19 +4336,14 @@ fn a_repeated_fan_out_broadcast_inside_its_cooldown_is_dropped() { let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); let skull_on: Vec = [&[0x07][..], &900u64.to_le_bytes()].concat(); let frames: [(u32, &[u8]); 10] = [ - // MSG_MINIMAP_PING, twice. (0x01D5, &[0; 8]), (0x01D5, &[0; 8]), - // MSG_RANDOM_ROLL 1-100, twice. (0x01FB, &[1, 0, 0, 0, 100, 0, 0, 0]), (0x01FB, &[1, 0, 0, 0, 100, 0, 0, 0]), - // MSG_RAID_READY_CHECK start, twice. (0x0322, &[]), (0x0322, &[]), - // MSG_RAID_READY_CHECK answer "ready", twice. (0x0322, &[0x01]), (0x0322, &[0x01]), - // MSG_RAID_TARGET_UPDATE skull, twice. (0x0321, &skull_on), (0x0321, &skull_on), ]; @@ -4727,8 +4367,6 @@ fn a_repeated_fan_out_broadcast_inside_its_cooldown_is_dropped() { let _ = server.join(); } -/// The cooldown admits an op at its start, drops the same kind 999 ms later, and admits it again -/// one full second after the start. #[test] fn a_group_broadcast_cooldown_lasts_exactly_one_second() { let mut cooldowns = party::GroupBroadcastCooldowns::default(); @@ -4739,7 +4377,6 @@ fn a_group_broadcast_cooldown_lasts_exactly_one_second() { assert!(cooldowns.admit_at(roll, t0 + std::time::Duration::from_secs(1))); } -/// One sent packet as `(opcode, body)`, the way the client receives it. fn wire(packet: &Outbound) -> Wire { match packet { Outbound::One(message) => { @@ -4766,10 +4403,6 @@ fn group_event(kind: u8, other_guid: u64, payload: &str) -> crate::stdb::binding } } -/// **Each Group Broadcast kind renders its vanilla packet**, pinned byte for byte against the -/// cmangos writers: ready check (GroupHandler.cpp:562-563, 576-579), the partial icon update -/// (Group.cpp:595-600), the full icon list (Group.cpp:648-666), the ping (GroupHandler.cpp:410-413) -/// and the roll (GroupHandler.cpp:434-438). #[test] fn the_relay_renders_each_group_broadcast_kind() { use lyracore_shared::group::event_kind; @@ -4778,15 +4411,7 @@ fn the_relay_renders_each_group_broadcast_kind() { let star = 901u64.to_le_bytes(); let none = 0u64.to_le_bytes(); let vim = VIM.to_le_bytes(); - // cm:Group.cpp:648-666: the update type, then only the held icons, in icon order. - let full_list: Vec = [ - &[0x01][..], // update type: full - &[0x00], // star - &star, - &[0x07], // skull - &skull, - ] - .concat(); + let full_list: Vec = [&[0x01][..], &[0x00], &star, &[0x07], &skull].concat(); let cases: [(u8, &str, Wire); 7] = [ (event_kind::READY_CHECK, "", (0x0322, vec![])), ( @@ -4837,9 +4462,6 @@ fn the_relay_renders_each_group_broadcast_kind() { } } -/// **Each Meeting Stone kind renders its vanilla packet**: SETQUEUE is `u32 area, u8 status` -/// (cm:LFG/LFGHandler.cpp:146-152), MEMBER_ADDED the added guid, and COMPLETE and IN_PROGRESS are -/// empty (cm:LFG/LFGMgr.cpp:288-309). #[test] fn the_relay_renders_each_meeting_stone_kind() { use lyracore_shared::meeting_stone::event_kind; @@ -4871,7 +4493,6 @@ fn the_relay_renders_each_meeting_stone_kind() { } } -/// A QUEUE payload the client could not read, or an area it has no id for, reaches no client. #[test] fn the_relay_drops_a_meeting_stone_queue_it_cannot_encode() { use lyracore_shared::meeting_stone::event_kind; @@ -4886,8 +4507,6 @@ fn the_relay_drops_a_meeting_stone_queue_it_cannot_encode() { } } -/// A Dwarf Priest stands on `instances`, so the acceptor's facts differ from the party fixture's -/// Human Warrior and cross a shard when the asking session is on `world`. fn dwarf_priest_topology() -> ( std::sync::Arc, std::sync::Arc, @@ -4966,8 +4585,6 @@ fn accepts(realm: &WorldFake) -> Vec<(u8, u64, u64, u8, u8, u64)> { .collect() } -/// A client's Realm-core ACCEPT conveys the acceptor's class in `arg_a`, so a queued party can give -/// the joiner a Seeker row. #[test] fn a_client_accept_carries_the_acceptors_class() { let (realm, world, instances) = dwarf_priest_topology(); @@ -4988,8 +4605,6 @@ fn a_client_accept_carries_the_acceptors_class() { assert_eq!(accepts(&realm), [(realm_op::ACCEPT, VIM, 0, 5, 0, 0)]); } -/// A playerbot's automatic accept reads its facts from the Shard that holds it, here not the -/// inviter's. #[test] fn a_playerbot_accept_carries_its_class_from_its_own_shard() { let (realm, world, _instances) = dwarf_priest_topology(); @@ -5004,7 +4619,6 @@ fn a_playerbot_accept_carries_its_class_from_its_own_shard() { assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 0, 0)]); } -/// An acceptor no World Shard names conveys class 0, and the accept still runs. #[test] fn an_accept_nobody_can_name_conveys_zero_class() { let (realm, world, _instances, _calls) = party_topology(); @@ -5015,7 +4629,6 @@ fn an_accept_nobody_can_name_conveys_zero_class() { assert_eq!(accepts(&realm), [(realm_op::ACCEPT, 99, 0, 0, 0, 0)]); } -/// A payload that does not decode reaches no client. #[test] fn the_relay_drops_a_group_broadcast_it_cannot_decode() { use lyracore_shared::group::event_kind; @@ -5036,9 +4649,6 @@ fn the_relay_drops_a_group_broadcast_it_cannot_decode() { } } -/// **AC: in a Party the icons survive a list.** The Party client clears its marks on every -/// `SMSG_GROUP_LIST`, so the LIST job sends the list and then the icons, in that order -/// (vm:Group.cpp:1343-1360, 1403-1408). A list without icons sends the list alone. #[test] fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { use lyracore_shared::group::{event_kind, TargetIcon}; @@ -5077,9 +4687,6 @@ fn a_party_list_with_target_icons_sends_the_list_then_the_icons() { ); } -/// **A Party member who zones in gets its Target Icons again.** World entry sends a list, and the -/// Party client clears its marks on every list, so the Gateway asks the party authority for the full -/// icon list after the list. A Raid keeps its marks and asks for nothing. #[test] fn world_entry_asks_for_a_partys_target_icons_after_the_list() { let (realm, world, instances, _calls) = party_topology(); @@ -5111,7 +4718,6 @@ fn world_entry_asks_for_a_partys_target_icons_after_the_list() { ); } -/// A lost broadcast must not end the session: nothing waits on a ping, and the roster is unchanged. #[test] fn a_group_broadcast_lost_in_transport_keeps_the_session() { let s = { @@ -5141,7 +4747,6 @@ fn a_group_broadcast_lost_in_transport_keeps_the_session() { let _ = server.join(); } -/// Whether `shard`'s mirror of Group `group_id` lists `member`. fn mirror_lists(shard: &WorldFake, group_id: u64, member: u64) -> bool { shard .party @@ -5153,8 +4758,6 @@ fn mirror_lists(shard: &WorldFake, group_id: u64, member: u64) -> bool { .is_some_and(|roster| roster.has_member(member)) } -/// A three-member party with Vim inside the dungeon on `instances`. From here on Realm-core's -/// Coordinator cache lags every call-pipe commit. fn lagging_three_member_party() -> (std::sync::Arc, std::sync::Arc, u64) { let (realm, world, instances, _) = party_topology(); form_split_party(&world, &instances); @@ -5173,10 +4776,6 @@ fn lagging_three_member_party() -> (std::sync::Arc, std::sync::Arc AwayStatus { use lyracore_shared::chat::chat_tag; match lyracore_shared::chat::chat_tag_for(player_flags) { @@ -69,20 +42,12 @@ pub(crate) fn away_from_player_flags(player_flags: u32) -> AwayStatus { } } -/// The Auto-Reply a whisperer gets from a Character with an Away Status. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AutoReply { - /// `Afk` or `Dnd`, never `None`. pub away: AwayStatus, - /// The stored text. Empty when the Shard holds none, which the Module answers with the default. pub message: String, } -/// The Auto-Reply of the Character `presence` describes, or `None` when it has no Away Status. -/// -/// Takes the Realm Presence the caller already read, so one decision reads it once. The Away -/// Status comes from the live entity's `PLAYER_FLAGS`; the text is read on the Shard that -/// answered with that entity, because the Auto-Reply lives beside it on the Home Shard. pub(crate) fn auto_reply( store: &St, presence: &RealmPresence, @@ -114,8 +79,6 @@ pub(crate) fn auto_reply( })) } -/// A durable `game_character` row's identity and session flag, from one Shard — [`of`]'s existence -/// signal, before it knows whether the Character is in world, in transit, or offline. #[derive(Clone, Debug, PartialEq)] pub(crate) struct CharacterIdentity { pub guid: u64, @@ -128,9 +91,6 @@ pub(crate) struct CharacterIdentity { } impl CharacterIdentity { - /// A guid with no known durable row — [`of`]'s fallback when a positive signal (a live entity, - /// or a between-places match) fires for a Character no connected Shard's `game_character` - /// happened to name, such as a bot whose Transfer Intent exists before its row does. fn unknown(guid: u64) -> Self { Self { guid, @@ -143,8 +103,6 @@ impl CharacterIdentity { } } - /// Finish into a [`RealmPresence`] that keeps this identity's own level and zone — the - /// `InTransit` and `Offline` cases, which have no live entity to read fresher ones from. fn stationary(self, whereabouts: Whereabouts) -> RealmPresence { RealmPresence { guid: self.guid, @@ -158,7 +116,6 @@ impl CharacterIdentity { } } - /// Finish into a [`RealmPresence`] with level and zone read fresh from a live entity. fn in_world(self, shard_name: String, entity: codec::MemberEntity) -> RealmPresence { let level = u8::try_from(entity.level).unwrap_or(u8::MAX); let zone_id = entity.zone_id; @@ -180,15 +137,6 @@ impl CharacterIdentity { } } -/// `guid`'s Realm Presence from whichever connected Shard holds it. -/// -/// A live entity on any connected Shard wins outright, whatever that Shard's own health — a -/// stale-but-connected cache still answers from its last-known state, same as `entity_in_world`. -/// Failing that, a pending Transfer (Realm-core's own signal, or a Shard's between-places match) -/// answers `InTransit`. Only once neither fires does the answer become a negative claim — -/// `Offline`, or `None` for a guid no Shard ever named — and a negative claim needs every -/// configured Shard to vouch first (see the module doc), and fails with [`PresenceUnknown`] when -/// one cannot. pub(crate) fn of( store: &St, guid: u64, @@ -204,9 +152,6 @@ pub(crate) fn of( return Ok(Some(identity.stationary(Whereabouts::InTransit))); } - // Both `InTransit` (via a Shard's own between-places match) and `Offline` are negative claims - // from here on: no configured Shard may be unreachable, or a stale cache could be hiding the - // Character. store .every_shard_vouches_for_absence() .map_err(PresenceUnknown)?; @@ -224,9 +169,6 @@ pub(crate) fn of( }) } -/// A negative claim [`of`] could not make: a configured World Shard did not vouch for absence, -/// or Realm-core could not answer whether a Transfer is pending. A caller answers as it would -/// for a Character it cannot see, without ending this World Session. #[derive(Debug)] pub(crate) struct PresenceUnknown(pub(crate) anyhow::Error); @@ -238,13 +180,10 @@ impl std::fmt::Display for PresenceUnknown { impl std::error::Error for PresenceUnknown {} -/// Is `error`, or any cause it wraps, a [`PresenceUnknown`]? pub(crate) fn is_unknown(error: &anyhow::Error) -> bool { error.chain().any(|cause| cause.is::()) } -/// `result` with a [`PresenceUnknown`] read as `None`, the answer for a Character nobody can see. -/// Every other error passes through. pub(crate) fn unknown_as_absent(result: Result>) -> Result> { match result { Err(error) if is_unknown(&error) => { @@ -255,14 +194,6 @@ pub(crate) fn unknown_as_absent(result: Result>) -> Result( store: &St, guid: u64, @@ -278,7 +209,6 @@ pub(crate) fn character_identity_anywhere( store: &St, guid: u64, @@ -293,8 +223,6 @@ fn live_entity_anywhere( }) } -/// Realm-core's own pending-Transfer signal for `guid`. `false` on an unsharded Gateway, which has -/// no Realm-core to ask (`realm_store()` answers `Ok(None)`). fn realm_transfer_pending(store: &St, guid: u64) -> Result { let Some(realm) = store.realm_store()? else { return Ok(false); @@ -304,10 +232,6 @@ fn realm_transfer_pending(store: &St, guid: u64) .is_some_and(|partition| partition.transfer_pending)) } -/// The union of every connected Shard's in-world players, deduplicated by guid — `/who`'s ultimate -/// source. Scans each Shard exactly once: `world_stores()` already includes a handle for this -/// Shard's own database once there is more than one connected, so this handle is asked directly -/// only when `world_stores()` is empty (unsharded). pub(crate) fn in_world_characters( store: &St, ) -> Result> { @@ -332,9 +256,6 @@ pub(crate) fn in_world_characters( Ok(out) } -/// Resolve a typed Character name to a guid across every connected Shard: this handle first, then -/// every `world_stores()` handle, first hit wins. `world_stores()` is empty on an unsharded -/// Gateway, so this is the one cache read it always was. pub(crate) fn resolve_by_name( store: &St, name: &str, @@ -350,10 +271,6 @@ pub(crate) fn resolve_by_name( Ok(None) } -/// [`resolve_by_name`] without the first-hit short-circuit: every guid `name` resolves to, across -/// every connected Shard. Character names are unique per Shard, not per Realm, so the same name -/// can name two different Characters at once; a caller that needs one picks among the candidates -/// (see [`super::whisper`]). Deduped, because `world_stores()` includes the asking Shard. pub(crate) fn resolve_all_by_name( store: &St, name: &str, @@ -372,8 +289,6 @@ pub(crate) fn resolve_all_by_name( Ok(guids) } -/// [`resolve_by_name`] inverted: the Character row for `guid` from whichever connected Shard holds -/// it. Same first-hit-wins union, same unsharded short-circuit. pub(crate) fn character_anywhere( store: &St, guid: u64, @@ -389,11 +304,6 @@ pub(crate) fn character_anywhere( store: &St, guid: u64, diff --git a/gateway/src/world/presence_tests.rs b/gateway/src/world/presence_tests.rs index addc164dd..6133d5bb5 100644 --- a/gateway/src/world/presence_tests.rs +++ b/gateway/src/world/presence_tests.rs @@ -1,19 +1,6 @@ -//! Realm Presence's multi-shard union — the routing tests for reads that moved out of `party.rs` -//! and out of `handlers/member_stats.rs`'s own `locate_member`. -//! -//! What EXECUTES here is production `world::presence`, against the same in-memory multi-database -//! topology `party_tests::party_topology` builds (Ginger in the open world on `world`, Vim inside -//! the dungeon on `instances`, plus an offline character and a playerbot on each side), and small -//! bespoke topologies where a test needs to set a field `party_topology` does not expose. -//! -//! A live entity is `member_entities` alone (`place`, below) — not `live_guids`, which models a -//! different, party-eligibility presence and stays independent of it on purpose (see `live_entity` -//! in `test_support/world_fake/social.rs`). - use super::party_tests::{character, party_topology, BOT, DORMANT, GINGER, VIM}; use super::*; -/// Give `shard` a live Member Stats entity for `guid` — the one signal `live_entity` reads. fn place(shard: &WorldFake, guid: u64, entity: codec::MemberEntity) { shard .social @@ -23,16 +10,12 @@ fn place(shard: &WorldFake, guid: u64, entity: codec::MemberEntity) { .push((guid, entity)); } -/// **AC 6 (part 1): `presence::of` answers for a Character on another Shard**, reporting -/// `session_online` from the character row and `Whereabouts::InWorld` from the live entity rather -/// than from wherever the asking handle happens to sit. #[test] fn presence_of_crosses_the_shard_boundary_and_tells_in_world_apart_from_session_online() { let (_realm, world, instances, _calls) = party_topology(); place(&instances, VIM, codec::MemberEntity::default()); place(&world, BOT, codec::MemberEntity::default()); - // Vim lives on `instances`; asked from `world`, the union crosses the boundary. let vim = presence::of(world.as_ref(), VIM) .unwrap() .expect("Vim resolves from the open-world handle"); @@ -44,17 +27,12 @@ fn presence_of_crosses_the_shard_boundary_and_tells_in_world_apart_from_session_ ); assert!(vim.session_online, "Vim is not in offline_guids"); - // Dormant has a character row on `world` but no live entity and is session-offline — - // existence alone still resolves, and it reads Offline with session_online false. let dormant = presence::of(world.as_ref(), DORMANT) .unwrap() .expect("a character row with no live entity still exists"); assert_eq!(dormant.whereabouts, presence::Whereabouts::Offline); assert!(!dormant.session_online); - // The playerbot is a live entity with no session (`game_character.online` never flips for - // one) — `Whereabouts::InWorld` and `session_online` must disagree for it, in the direction - // the invite gate needs (README Decision 26: bots stay listed and stay unwhisperable). let bot = presence::of(world.as_ref(), BOT) .unwrap() .expect("the playerbot resolves"); @@ -66,8 +44,6 @@ fn presence_of_crosses_the_shard_boundary_and_tells_in_world_apart_from_session_ assert!(!bot.session_online, "a playerbot never runs player_login"); } -/// **AC 6 (part 2): reads AFK and DND from PLAYER_FLAGS.** A Character with no live entity reads -/// no Away Status at all. #[test] fn presence_of_reads_away_status_from_the_live_entitys_player_flags() { let store = std::sync::Arc::new(WorldFake { @@ -102,9 +78,6 @@ fn presence_of_reads_away_status_from_the_live_entitys_player_flags() { ); } -/// A hit with a live entity beats a hit without one, so a Character caught between the two halves -/// of a Transfer reads as in world — the source Shard's frozen row (no entity) and the -/// destination's fresh one (a live entity) both exist for the same guid at once. #[test] fn a_live_hit_beats_a_hit_without_one_regardless_of_shard_order() { let calls: ShardCallLog = Default::default(); @@ -115,7 +88,6 @@ fn a_live_hit_beats_a_hit_without_one_regardless_of_shard_order() { ..Default::default() }, characters: vec![character(GINGER, "Ginger")], - // No live entity: the frozen source row has none. ..Default::default() }); let destination = std::sync::Arc::new(WorldFake { @@ -141,8 +113,6 @@ fn a_live_hit_beats_a_hit_without_one_regardless_of_shard_order() { ); } -/// A minimal two-shard topology this file builds directly (rather than `party_topology`, which -/// does not expose `world_shard_set_error`): a realm handle, and one world Shard holding `guid`. fn one_shard_topology( guid: u64, live: bool, @@ -173,10 +143,6 @@ fn one_shard_topology( (realm, world) } -/// A live entity on any connected Shard is live even while another Shard is down — the positive -/// InWorld signal needs no absence proof, unlike Offline. Migrated from -/// `handlers::member_stats::tests`, which drove this through `locate_member` directly before it -/// folded into `presence::of`. #[test] fn a_live_entity_on_any_connected_shard_is_live_even_while_another_shard_is_down() { let (_realm, world) = one_shard_topology(GINGER, true); @@ -199,8 +165,6 @@ fn a_live_entity_on_any_connected_shard_is_live_even_while_another_shard_is_down ); } -/// A pending Transfer on Realm-core is in transit, even when the World Shards cannot vouch for -/// absence — `realm_transfer_pending` is checked before the health-gated absence path. #[test] fn a_pending_transfer_on_realm_core_is_in_transit() { let (realm, world) = one_shard_topology(GINGER, false); @@ -249,9 +213,6 @@ fn a_live_entity_still_answers_presence_during_realm_core_loss() { )); } -/// A Character between places on any connected Shard is in transit — the session-less bot -/// Transfer Intent case `character_in_transit` models, here on a PEER Shard rather than the -/// asking one. #[test] fn a_character_between_places_on_any_shard_is_in_transit() { let (_realm, world) = one_shard_topology(BOT, false); @@ -273,8 +234,6 @@ fn a_character_between_places_on_any_shard_is_in_transit() { assert_eq!(presence.whereabouts, presence::Whereabouts::InTransit); } -/// Offline needs every configured Shard healthy — an unreadable Shard or Realm-core is an `Err`, -/// never a false Offline. #[test] fn offline_needs_every_configured_shard_healthy() { let (_realm, world) = one_shard_topology(DORMANT, false); @@ -306,19 +265,11 @@ fn offline_needs_every_configured_shard_healthy() { assert_eq!(presence.whereabouts, presence::Whereabouts::Offline); } -/// A frozen pre-Transfer copy (`begin_transfer` persists with `set_offline: false`) keeps -/// `session_online` true with no live entity anywhere. It must not read as present -/// (`Whereabouts::InWorld`): the Shard's own between-places signal — modelling its online row with -/// no entity — reads it as `InTransit` instead, decoupled from `session_online`. #[test] fn a_frozen_pre_transfer_copy_reads_online_but_not_in_world() { let store = std::sync::Arc::new(WorldFake { characters: vec![character(GINGER, "Ginger")], social: SocialState { - // Not in `offline_guids`: session-online. No live entity. `members_between_places` is this - // Shard's own signal for it, the same one `character_in_transit` reads in production from - // `game_character.online` with no entity present — exactly the frozen source row - // `begin_transfer` leaves behind. members_between_places: vec![GINGER].into(), ..Default::default() }, diff --git a/gateway/src/world/quest_tests.rs b/gateway/src/world/quest_tests.rs index fb7ee0210..3a428e9a9 100644 --- a/gateway/src/world/quest_tests.rs +++ b/gateway/src/world/quest_tests.rs @@ -1,16 +1,7 @@ -//! Quest traffic over the world session. Which screen a quest opens, and which durable request it -//! makes, is decided and proved at the `Quest::handle` seam (`handlers/quest.rs`). What is -//! left here is only what the seam cannot see: that a quest opcode reaches the seam through the -//! full handshake + login + cipher, and that the bodies it answers with survive the encrypted -//! frame. - use super::*; #[test] fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { - // The socket-level contract: the subscribed reducer callback has already queued the complete - // item insertion when turn_in_quest returns, so the session's completion presentation must sit - // behind CREATE, its inventory pointer and gain feedback on the one writer queue. let mut s = quest_store(); let detail = detail_view(1234, "A Threat Within"); s.quest.quest_details = vec![detail.clone()]; @@ -254,7 +245,6 @@ fn assert_quest_entry(worldport: bool, cross_shard: bool) { .unwrap(); let mut frames = drain_world_entry(&mut client, &mut c_dec); if worldport { - // Drain all login traffic before the map acknowledgement. wow_world_messages::vanilla::CMSG_PING { sequence_id: 647, round_time_in_ms: 0, @@ -300,7 +290,6 @@ fn assert_create_quest_progress(frames: &[ServerOpcodeMessage]) { }) }) .expect("world entry must create the Character"); - // Reframe the received CREATE descriptor as VALUES for the independent field decoder. let values = wow_world_messages::vanilla::SMSG_UPDATE_OBJECT { has_transport: 0, objects: vec![Object::Values { @@ -333,9 +322,6 @@ fn assert_create_quest_progress(frames: &[ServerOpcodeMessage]) { #[test] fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_cipher() { - // The socket-level contract: dispatch routes HELLO to the quest module, and the raw-encoded - // DETAILS screen it returns crosses the encrypted frame intact. Which screen a giver opens is - // decided at the `Quest::handle` seam and proved there. const OP_QUEST_DETAILS: u16 = 0x0188; let mut s = quest_store(); s.quest.quest_evals = vec![eval(1234, codec::ROLE_START, false, false)]; @@ -360,8 +346,6 @@ fn quest_hello_reaches_the_quest_module_and_its_raw_details_body_survives_the_ci #[test] fn quest_query_answers_the_raw_definition_body_through_the_cipher() { - // The socket-level contract for the client's cold-cache definition query: the hand-rolled 5875 - // body crosses the encrypted frame intact. Which quests answer at all is proved at the seam. const OP_QUEST_QUERY_RESPONSE: u16 = 0x005D; let mut s = quest_store(); s.quest.quest_details = vec![detail_view(1234, "A Threat Within")]; diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs index 6f20ce07d..568ca1b1f 100644 --- a/gateway/src/world/routing.rs +++ b/gateway/src/world/routing.rs @@ -1,4 +1,3 @@ -//! One opcode has one owning Protocol Family, independent of declaration order. use super::handlers::*; use super::social::Social; use super::*; diff --git a/gateway/src/world/session_tests.rs b/gateway/src/world/session_tests.rs index c4756b69f..ac3e9e317 100644 --- a/gateway/src/world/session_tests.rs +++ b/gateway/src/world/session_tests.rs @@ -1,7 +1,3 @@ -//! Session-level socket tests: the handshake and its deadlines, the admission queue, world entry -//! and world-port re-entry, movement and its coalescing, logout, and the order a World Session -//! closes in. - use super::*; #[derive(Clone)] @@ -84,17 +80,14 @@ fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { .expect("handshake should succeed"); assert_eq!(conn.account_id, 42); assert_eq!(conn.account_name, "TESTER"); - // Prove the inbound cipher works: read one encrypted client message. match ClientOpcodeMessage::read_encrypted(&mut s, &mut conn.decrypt).unwrap() { ClientOpcodeMessage::CMSG_CHAR_ENUM => {} other => panic!("expected encrypted CMSG_CHAR_ENUM, got {other}"), } }); - // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- let (mut c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - // --- client: the AUTH_OK response is the first ENCRYPTED packet --- match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthOk { .. })); @@ -102,7 +95,6 @@ fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), } - // --- client: send an encrypted CMSG the server decrypts --- CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -111,9 +103,6 @@ fn handshake_succeeds_and_traffic_is_encrypted_both_ways() { server.join().unwrap(); } -/// The typed decoder would size a buffer from the addon field and unwrap the zlib. The gateway -/// never reads the addon list, so a 4 GiB claim over garbage bytes changes nothing: the proof is -/// checked and AUTH_OK goes out. #[test] fn an_auth_session_with_an_absurd_addon_size_still_completes_the_handshake() { let store = std::sync::Arc::new(tester_store(42)); @@ -131,7 +120,6 @@ fn an_auth_session_with_an_absurd_addon_size_still_completes_the_handshake() { client_seed.into_client_header_crypto(&ns("TESTER"), K, server_seed); let (_c_enc, mut c_dec) = crypto.split(); - // Hand-built frame: the fixed fields, then a 4 GiB decompressed-size claim over garbage. let mut body = Vec::new(); body.extend_from_slice(&5875u32.to_le_bytes()); body.extend_from_slice(&1u32.to_le_bytes()); @@ -156,8 +144,6 @@ fn an_auth_session_with_an_absurd_addon_size_still_completes_the_handshake() { server.join().unwrap().unwrap(); } -/// A peer that reads the challenge and never answers must give its blocking thread back at the -/// total pre-auth deadline. #[test] fn a_silent_world_connection_is_closed_at_the_pre_auth_deadline() { let store = tester_store(42); @@ -272,7 +258,6 @@ fn world_challenge_write_uses_the_absolute_pre_auth_deadline() { ); } -/// Once client proof succeeds, authenticated World Session traffic has no pre-auth deadline. #[test] fn post_auth_world_traffic_has_no_pre_auth_deadline() { let store = std::sync::Arc::new(tester_store(42)); @@ -305,7 +290,6 @@ fn post_auth_world_traffic_has_no_pre_auth_deadline() { fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { let store = std::sync::Arc::new(tester_store(42)); let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); - // Occupy the only seat directly — exactly what an already-connected world session holds. assert_eq!(queue.request(), Admission::Admitted); let (mut client, server_end) = world_session_socket_pair(); @@ -313,7 +297,6 @@ fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { let server_queue = queue.clone(); let server = std::thread::spawn(move || { let mut s = server_end; - // Blocks in the queue's wait loop until `queue.depart()` (below) frees the one seat. let (conn, _encrypt) = world_handshake_with_queue(&mut s, server_store.clone(), &server_queue) .unwrap() @@ -321,11 +304,8 @@ fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { assert_eq!(conn.account_id, 42); }); - // --- client: drive the shared challenge→proof→AUTH_SESSION prefix (`drive_auth`) --- let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - // --- client: the FIRST encrypted response must be AuthWaitQueue at position 1, not AuthOk — - // proving the handshake actually queued instead of bypassing the gate --- match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { assert!( @@ -336,8 +316,6 @@ fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { other => panic!("expected encrypted SMSG_AUTH_RESPONSE, got {other}"), } - // Free the one seat — mirrors what `run_world_session_with_queue`'s teardown does on a real - // disconnect. The queued handshake must notice on its next poll and proceed to admission. queue.depart(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { @@ -358,7 +336,7 @@ fn queued_handshake_sends_wait_queue_then_admits_once_a_seat_frees() { fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { let store = std::sync::Arc::new(tester_store(7)); let queue = std::sync::Arc::new(LoginQueue::new(1, 0)); - assert_eq!(queue.request(), Admission::Admitted); // occupy the only seat + assert_eq!(queue.request(), Admission::Admitted); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); @@ -375,8 +353,6 @@ fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { let (_c_enc, mut c_dec) = drive_auth(&mut client, "TESTER", K); - // Read (and discard) the first AuthWaitQueue so we know the server has actually queued us - // before hanging up — a hangup racing the very first send would prove nothing about `cancel`. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthWaitQueue { .. })); @@ -384,11 +360,9 @@ fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { other => panic!("expected AuthWaitQueue, got {other}"), } - drop(client); // hang up while still queued + drop(client); server.join().unwrap(); - // The seat is still held by the ORIGINAL occupant (never touched); the queued-then-cancelled - // connection must have left NO trace in the line. assert_eq!( queue.depth(), 0, @@ -403,17 +377,6 @@ fn disconnecting_while_queued_leaves_the_line_without_taking_a_seat() { #[test] fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { - // The stateless-gateway invariant, now realm-scoped. The session key K lives in - // `game_session` — on realm-core when it is configured, on the world DB when it is not — and - // the gateway keeps NOTHING about it. So killing the gateway mid-session and starting a fresh - // one must let the same account re-handshake with no re-logon, against a brand-new process - // that has never seen this client. - // - // Modelled here as two handshakes against two INDEPENDENT store instances that share only the - // realm-held session row: `store_before` is the gateway that gets killed, `store_after` is the - // replacement. Each handshake mints its own server seed and its own client seed, so nothing - // from the first run can be smuggled into the second — if any handshake input were - // gateway-local rather than realm state, the second run could not succeed. let realm_session = || WorldSession { account_id: 42, session_key: K, @@ -442,12 +405,10 @@ fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { }; assert_eq!(handshake_once(store_before), 42); - // ---- the gateway is killed here; every byte of its in-process state is gone ---- - let store_after = WorldFake { session: SessionState { username: "TESTER".into(), - session: Some(realm_session()), // re-READ from the realm, not carried over + session: Some(realm_session()), ..Default::default() }, ..Default::default() @@ -461,11 +422,6 @@ fn a_restarted_gateway_completes_the_handshake_from_realm_state_alone() { #[test] fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() { - // The other half of the stateless-gateway invariant: "resume from realm state" must not - // degrade into "resume from anything". A store that cannot answer (realm-core unreachable → - // `lookup_session` yields no session) rejects the handshake plaintext instead of establishing - // a session on an unverified key. `CoordinatorStore` reaches this state by way of - // `Coordinator::realm_core()`'s Err. let store = WorldFake { session: SessionState { username: "TESTER".into(), @@ -498,8 +454,6 @@ fn a_gateway_that_cannot_reach_the_session_store_rejects_rather_than_guessing() #[test] fn bad_proof_is_rejected() { - // The store hands out a session key, but the client computes its proof against a - // DIFFERENT key, so the server's digest check must fail. let store = WorldFake { session: SessionState { username: "TESTER".into(), @@ -534,7 +488,6 @@ fn bad_proof_is_rejected() { .write_unencrypted_client(&mut client) .unwrap(); - // The failure is sent plaintext (no cipher was established). match ServerOpcodeMessage::read_unencrypted(&mut client).unwrap() { ServerOpcodeMessage::SMSG_AUTH_RESPONSE(r) => { assert!(matches!(*r, SMSG_AUTH_RESPONSE::AuthFailed)); @@ -551,7 +504,7 @@ fn unknown_account_is_rejected_cleanly() { let store = WorldFake { session: SessionState { username: "TESTER".into(), - session: None, // account exists nowhere / no session + session: None, ..Default::default() }, ..Default::default() @@ -580,9 +533,6 @@ fn unknown_account_is_rejected_cleanly() { #[test] fn login_replays_a_pending_package_system_message() { - // A Package `on_login` hook emits its System Message INSIDE `player_login` — before the - // session is in the viewer registry, so the live insert relay has nobody to address. World - // entry must replay the parked row right after the entry sequence. let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { @@ -621,9 +571,6 @@ fn login_replays_a_pending_package_system_message() { #[test] fn player_login_emits_sequence_then_self_create() { - // CMSG_PLAYER_LOGIN must yield the full login sequence (in order), then the self - // CREATE_OBJECT2 at the correct position/guid, then the current zone's weather — the clock - // (SMSG_LOGIN_SETTIMESPEED) before the sky, so the client has both before it renders. let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { @@ -632,7 +579,6 @@ fn player_login_emits_sequence_then_self_create() { ..base.session }, weather: WeatherState { - // Elwynn Forest (the fixture's zone) is raining hard. zone_weather: vec![( 12, codec::ZoneWeatherView { @@ -656,7 +602,6 @@ fn player_login_emits_sequence_then_self_create() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The 11-message login sequence, the self CREATE_OBJECT2, then the zone's weather. let mut tags = Vec::new(); let mut create_guid = None; let mut weather = None; @@ -715,8 +660,6 @@ fn player_login_emits_sequence_then_self_create() { ], ); assert_eq!(create_guid, Some(1)); - // Elwynn's stored rain, synchronized rather than faded in: an arriving client must not spend - // a transition rendering the sky it happened to have last. let weather = weather.expect("world entry sends the zone's weather"); assert_eq!(weather.weather_type, WeatherType::Rain); assert_eq!(weather.grade, 0.8); @@ -729,21 +672,14 @@ fn player_login_emits_sequence_then_self_create() { #[test] fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { - // MSG_MOVE_WORLDPORT_ACK must re-run the SAME enter_world path as - // CMSG_PLAYER_LOGIN — rebuilding the entity (now on the NEW map the module's teleport_player - // durably wrote to the character row) and re-subscribing with a FRESH `created` dedup set — a - // reused dedup set from the old map would suppress the initial sweep of pre-existing entities - // through the CREATE path, leaving the new map looking empty until something moved or spawned — - // a stale created-set is exactly what would leave entities invisible on cross-map arrival. let mut ported = warrior_entity(); - ported.map_id = 1; // Kalimdor — simulates teleport_player's durable cross-map write + ported.map_id = 1; ported.x = 100.0; ported.y = 200.0; let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { session: SessionState { - // A real cross-map teleport has despawned the old-map entity before its ack arrives. entity_in_world: false, login_entity: Some(warrior_entity()), worldport_entity: Some(ported), @@ -767,7 +703,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Drain world entry (map 0 — not the point of this test). drain_world_entry(&mut client, &mut c_dec); let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); @@ -776,7 +711,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Re-entry skips SMSG_LOGIN_VERIFY_WORLD because resending it reloads the current map. let mut create_guid = None; for message in drain_world_entry(&mut client, &mut c_dec) { match message { @@ -800,8 +734,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { "the self entity is re-created (despawn+rebuild), not a stale row" ); - // subscribe_player_events must have fired TWICE: once at login (map 0), once at the world-port - // re-entry (map 1) — a fresh `created` set is built for the new map each time, never reused. let calls = store.session.subscribed.lock().unwrap().clone(); assert_eq!( calls.len(), @@ -819,7 +751,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { subscription placement is the observable)" ); - // The old map's open window cannot authorize a targetless request after re-entry. CMSG_LOOT_MONEY {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -830,7 +761,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { store.loot_window.money_looted.lock().unwrap().is_empty(), "world-port re-entry must start with no open loot target" ); - // The login ends the Away Status; the world-port rides the reducer that keeps it. assert_eq!( store.session.login_entries.lock().unwrap().as_slice(), [codec::WorldEntry::FreshLogin, codec::WorldEntry::WorldPort] @@ -904,9 +834,6 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen #[test] fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index() { - // A persisted `game_player_reputation` row must land in the login - // SMSG_INITIALIZE_FACTIONS at its STORED reputation_index slot (0..63), never faction_id — the - // guardrail that also gates the live SET_FACTION_STANDING relay (McBride ERROR). let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { @@ -915,8 +842,6 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( ..base.session }, character: CharacterState { - // Stormwind's rep-index is 19 (Faction.dbc ReputationListID), NOT its faction id (72) — - // exercising the exact index/id distinction the guardrail protects. reputations: vec![(19, 3175, false)], ..base.character }, @@ -935,7 +860,6 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Drain the whole world entry so the server side doesn't see a broken pipe. let mut factions = None; for message in drain_world_entry(&mut client, &mut c_dec) { if let ServerOpcodeMessage::SMSG_INITIALIZE_FACTIONS(m) = message { @@ -948,8 +872,6 @@ fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index( factions[19].standing, 3175, "slot 19 (the stored reputation_index) must carry the standing" ); - // Stormwind's faction_id (72) is itself past the 64-slot array — proof that indexing by faction_id - // (the McBride bug) would panic/crash here rather than silently landing on the wrong slot. for (i, f) in factions.iter().enumerate() { if i != 19 { assert_eq!(f.standing, 0, "slot {i} should remain the Neutral/0 stub"); @@ -1004,8 +926,6 @@ fn login_with_resident_items_and_reputation_emits_no_gain_feedback() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // World entry, which carries the resident item's CREATE. The item and standing are snapshots - // in those frames, not live insert callbacks, so neither feedback packet is lawful. for message in drain_world_entry(&mut client, &mut c_dec) { assert!( !matches!( @@ -1065,8 +985,6 @@ fn login_fills_a_resident_suffix_items_enchantment_slots_after_the_entry_batch() .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // World entry, which carries the item's CREATE, then the raw update gtker's typed reader - // rejects. drain_world_entry(&mut client, &mut c_dec); let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); assert_eq!(opcode, 0x00A9); @@ -1123,7 +1041,7 @@ fn inbound_movement_is_recorded_under_its_opcode() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - drop(client); // server reads the heartbeat, then EOF + drop(client); server.join().unwrap(); let moves = store.session.moves.lock().unwrap(); @@ -1184,8 +1102,6 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { fall_time: 0.0, }, }; - // The first packet is a transfer tail. Once the coordinator cache sees the entity again, the - // next state transition must resume normal batched submission. beat(1) .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -1390,7 +1306,6 @@ fn a_movement_desync_that_never_heals_still_ends_the_session() { let base = tester_store(7); WorldFake { session: SessionState { - // The entity is gone and is NEVER coming back — not a teleport tail, a real desync. entity_in_world: false, ..base.session }, @@ -1412,10 +1327,6 @@ fn a_movement_desync_that_never_heals_still_ends_the_session() { orientation: 1.5, fall_time: 0.0, }; - // State transitions, so every one forwards immediately (never coalesced). Far more than a - // cross-map port's in-flight tail, and spread over more time than any loading screen's worth of - // queued packets — a session still serving these has stopped detecting desyncs altogether. - // Writes are best-effort: the socket SHOULD close partway through, which is the whole point. for i in 0..200u32 { let ok = if i % 2 == 0 { MSG_MOVE_START_FORWARD_Client { info: info(i) } @@ -1454,7 +1365,6 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree let base = tester_store(7); WorldFake { session: SessionState { - // The world-port ack is only actionable after teleport_player removed the old entity. entity_in_world: false, login_entity: Some(warrior_entity()), relay_view: Some(view.clone()), @@ -1467,7 +1377,7 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree }, topology: TopologyState { settle_error: Some("instances shard unreachable".into()), - settle_ok_calls: 1, // the LOGIN routes fine; the world-port's settle is the one that fails + settle_ok_calls: 1, ..base.topology }, ..base @@ -1482,7 +1392,6 @@ fn a_world_port_whose_transfer_cannot_be_driven_aborts_the_clients_loading_scree .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); drain_world_entry(&mut client, &mut c_dec); - // The client finished loading the dungeon map and acks — this is where the transfer runs. MSG_MOVE_WORLDPORT_ACK {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -1536,10 +1445,8 @@ fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() let base = tester_store(7); WorldFake { session: SessionState { - // The world-port ack is only actionable after teleport_player removed the old entity. entity_in_world: false, login_entity: Some(warrior_entity()), - // Routing succeeds; the world entry on the far side is what fails. worldport_login_error: Some("character 1 is stranded on map 36".into()), ..base.session }, @@ -1564,8 +1471,6 @@ fn a_world_port_whose_world_entry_fails_also_aborts_the_clients_loading_screen() .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Drain whatever the (partial) re-entry emitted and require an abort somewhere in it — the - // client must not be left loading. `read_encrypted` returns Err once the socket closes. let mut aborted = None; while let Ok(msg) = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { if let ServerOpcodeMessage::SMSG_TRANSFER_ABORTED(m) = msg { @@ -1616,28 +1521,21 @@ fn movement_state_changes_forward_immediately_and_in_order_never_delayed() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Same flags + heading as the run-start -> a pure heartbeat -> held pending (nowhere near the - // 150ms window in real wall-clock terms, since these all write back-to-back with no sleep). MSG_MOVE_HEARTBEAT_Client { info: mk(RUN, 0.0, 5.0, 200), } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // A second same-vector heartbeat SUPERSEDES the first (rule 3: the pending slot IS the drop - // mechanism) — x=5.0 must never reach the module. MSG_MOVE_HEARTBEAT_Client { info: mk(RUN, 0.0, 10.0, 300), } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // A turn: different flags -> a STATE CHANGE. Must flush the pending x=10.0 heartbeat FIRST, - // then forward the turn itself, both undelayed. MSG_MOVE_START_TURN_LEFT_Client { info: mk(TURN, 5.0, 10.0, 400), } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // A stop: another STATE CHANGE, no pending left to flush — forwards immediately alone. MSG_MOVE_STOP_Client { info: mk(STOPPED, 5.0, 10.0, 500), } @@ -1653,14 +1551,13 @@ fn movement_state_changes_forward_immediately_and_in_order_never_delayed() { opcodes, vec![ lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD, - lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT, // the FLUSHED x=10.0 heartbeat + lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT, lyracore_shared::opcodes::movement::MSG_MOVE_START_TURN_LEFT, lyracore_shared::opcodes::movement::MSG_MOVE_STOP, ], "run-start, [flushed heartbeat], turn, stop — in that exact order; the turn and stop must \ never be held, and the superseded x=5.0 intermediate must never appear at all" ); - // The flushed heartbeat carries the LATEST pending position (x=10.0), not the dropped x=5.0 one. assert!( (moves[1].1 - 10.0).abs() < 0.01, "flushed heartbeat must carry the superseding x=10.0, not the dropped x=5.0" @@ -1693,8 +1590,6 @@ fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { MSG_MOVE_START_FORWARD_Client { info: mk(0.0, 100) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // Three same-vector heartbeats, sent back-to-back (well inside the 150ms window): each - // supersedes the last in the pending slot, so only the FINAL one (x=30.0) may ever ship. MSG_MOVE_HEARTBEAT_Client { info: mk(10.0, 200), } @@ -1717,11 +1612,9 @@ fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the query itself was still answered normally + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} other => panic!("expected SMSG_QUESTGIVER_STATUS, got {other}"), } - // Safe to inspect now: the reply we just read could only have been produced AFTER the query's - // dispatch ran (flush, then the query handler) on the single-threaded reader/dispatch loop. { let moves = store.session.moves.lock().unwrap(); assert_eq!( @@ -1742,15 +1635,10 @@ fn non_movement_opcode_flushes_pending_heartbeat_before_being_handled() { #[test] fn desync_error_classifies_entity_missing_as_fatal_but_not_transient() { - // The module's `entity_by_owner` failures (the player's entity is gone — a desync, e.g. a - // schema-change publish dropped the gateway's read subscription) must be session-FATAL so the - // handler forces a CLEAN disconnect instead of leaving a silent zombie (can't attack / can't logout). assert!(is_desync_error(&anyhow!("attacker not in world"))); assert!(is_desync_error(&anyhow!("caster not in world"))); assert!(is_desync_error(&anyhow!("no live entity for guid 5"))); - assert!(is_desync_error(&anyhow!("Player NOT IN WORLD"))); // case-insensitive - // TRANSIENT per-action failures are NOT desync — they stay swallowed (the player keeps playing, - // never disconnected). A false positive here would drop players on every dead-target swing. + assert!(is_desync_error(&anyhow!("Player NOT IN WORLD"))); assert!(!is_desync_error(&anyhow!("target is dead"))); assert!(!is_desync_error(&anyhow!( "target out of range (35.0 yd > 30 yd)" @@ -1788,8 +1676,6 @@ fn the_writer_drains_the_egress_depth_back_to_zero_b2() { #[test] fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { - // combat_until_ms=0 (default, never in combat) → CMSG_LOGOUT_REQUEST must reply - // Success/Instant + LOGOUT_COMPLETE and the Store must release Account ownership. let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { @@ -1812,7 +1698,6 @@ fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // First message: SMSG_LOGOUT_RESPONSE(Success, Instant) match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_LOGOUT_RESPONSE(r) => { use wow_world_messages::vanilla::{LogoutResult, LogoutSpeed}; @@ -1826,7 +1711,6 @@ fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { } other => panic!("expected SMSG_LOGOUT_RESPONSE, got {other}"), } - // Second message: SMSG_LOGOUT_COMPLETE match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_LOGOUT_COMPLETE => {} other => panic!("expected SMSG_LOGOUT_COMPLETE, got {other}"), @@ -1839,7 +1723,6 @@ fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { drop(client); server.join().unwrap(); - // Releasing Account ownership removes the live Character. assert!( store .session @@ -1855,17 +1738,12 @@ fn logout_while_out_of_combat_succeeds_and_clears_open_loot() { #[test] fn logout_while_in_combat_is_denied() { - // combat_until_ms=u64::MAX → CMSG_LOGOUT_REQUEST must reply FailureInCombat. The session - // stays alive (verified by sending a second request and getting a second denial), meaning the - // entity was NOT removed during the handler — the player cannot escape combat by logging out. - // Note: socket teardown (drop below) still calls leave_world/logout as cleanup; that is correct - // and separate from the CMSG gate. let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { session: SessionState { login_entity: Some(warrior_entity()), - combat_until_ms: u64::MAX, // always in combat + combat_until_ms: u64::MAX, ..base.session }, ..base @@ -1873,7 +1751,6 @@ fn logout_while_in_combat_is_denied() { }); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); - // First request: must be denied. CMSG_LOGOUT_REQUEST {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -1890,9 +1767,6 @@ fn logout_while_in_combat_is_denied() { other => panic!("expected SMSG_LOGOUT_RESPONSE(denial) on first request, got {other}"), } - // Second request: still denied (session is still alive, entity still in-world). - // If the first denial had accidentally removed the entity / transitioned to CharSelect, - // this second request would either hang or produce a different message. CMSG_LOGOUT_REQUEST {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -1915,10 +1789,6 @@ fn logout_while_in_combat_is_denied() { #[test] fn played_time_replies_with_the_durable_total_plus_the_live_session_span() { - // CMSG_PLAYED_TIME -> SMSG_PLAYED_TIME. The character row carries a durable - // 3600s total plus a session_start_micros stamped in the recent past, so the reply must be - // strictly greater than the durable floor (the live span gets folded in) and sane (not absurdly - // large — bounds the test against a unit mixup, e.g. treating micros as millis). let durable_secs: u32 = 3600; let session_started_secs_ago: u64 = 5; let now_micros = std::time::SystemTime::now() @@ -1976,9 +1846,6 @@ fn played_time_replies_with_the_durable_total_plus_the_live_session_span() { #[test] fn force_run_speed_change_ack_is_swallowed_with_no_reply_and_no_session_teardown() { - // The client's ack to our `.speed`-triggered SMSG_FORCE_RUN_SPEED_CHANGE must be - // consumed cleanly (no reply, no desync/disconnect) — proven by a sentinel opcode right after it - // still getting its normal reply on the SAME session. use wow_world_messages::vanilla::{ MovementInfo, MovementInfo_MovementFlags, Vector3d, CMSG_FORCE_RUN_SPEED_CHANGE_ACK, }; @@ -2008,11 +1875,11 @@ fn force_run_speed_change_ack_is_swallowed_with_no_reply_and_no_session_teardown .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { - ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} // the ack produced no reply of its own + ServerOpcodeMessage::SMSG_QUESTGIVER_STATUS(_) => {} other => panic!("expected the sentinel (ack swallowed), got {other}"), } drop(client); - server.join().unwrap(); // the session ran to a clean close, not a desync teardown + server.join().unwrap(); } #[test] @@ -2022,8 +1889,6 @@ fn reducer_transport_loss_ends_an_admitted_session_and_frees_one_queue_seat() { WorldFake { session: SessionState { login_entity: Some(warrior_entity()), - // The same dead transport makes leave-world cleanup unreachable. Teardown is best-effort, - // but the client session and its admission seat must not wait for that reducer. logout_transport_lost: true, ..base.session }, @@ -2305,8 +2170,6 @@ fn closing_a_world_session_interrupts_a_full_socket_without_draining_its_queue() writer.join().unwrap(); } -/// A refused addon-bridge command is dropped and the World Session continues; a Transport Loss -/// leaves its outcome unknown and ends the session. #[test] fn an_addon_command_refusal_is_dropped_and_a_transport_loss_ends_the_session() { let conn = in_world_conn(42, 1); diff --git a/gateway/src/world/shard_routing_durable_tests.rs b/gateway/src/world/shard_routing_durable_tests.rs index 25d1c1dcd..a31522b1c 100644 --- a/gateway/src/world/shard_routing_durable_tests.rs +++ b/gateway/src/world/shard_routing_durable_tests.rs @@ -186,7 +186,6 @@ fn a_first_kalimdor_transfer_arrives_and_recovers_after_import_or_source_finish( ); } - // A fresh Coordinator reads the committed state as a replacement Gateway would. let replacement = runtime.block_on(Coordinator::connect(&config)).unwrap(); login_on_kalimdor(replacement, &runtime, guid); let character_query = format!("SELECT guid FROM game_character WHERE guid = {guid}"); diff --git a/gateway/src/world/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index 34880eb60..777000cc1 100644 --- a/gateway/src/world/shard_routing_tests.rs +++ b/gateway/src/world/shard_routing_tests.rs @@ -1,32 +1,15 @@ -//! Multi-shard routing tests through the shared WorldFake topology. - use super::*; -// =========================================================================================== -// Cross-database Transfer and Shard routing -// Requirement: reducer calls and subscriptions never target a shard other than the player's home -// shard. The `WorldFake` pair below stands for two DATABASES sharing one ordered call log, so -// a test can read off exactly which database served every player-scoped call of a whole live -// session. -// =========================================================================================== - pub(super) type ShardCallLog = std::sync::Arc>>; -/// A two-database topology: `world` (the default handle the listener hands every session — where -/// accounts, sessions, and the character list live) and `instances` (the shard that owns this -/// character's location, i.e. what `home_shard` resolves to). Both write to one shared call log. pub(super) fn sharded_stores() -> (std::sync::Arc, ShardCallLog) { sharded_stores_with_home_entity(true) } -/// Build the routed topology with an explicit answer for whether the old-map entity still exists. -/// A genuine world-port ack follows `teleport_player`, which has removed it; ordinary routed -/// sessions remain in-world and use the default constructor above. fn sharded_stores_with_home_entity( home_entity_in_world: bool, ) -> (std::sync::Arc, ShardCallLog) { let calls: ShardCallLog = Default::default(); - // The character's post-world-port entity, for the re-entry test below. let mut ported = warrior_entity(); ported.map_id = 1; let home = std::sync::Arc::new(WorldFake { @@ -105,7 +88,6 @@ fn auctioneer_lookup_uses_the_sessions_pinned_home_shard() { ); } -/// One heartbeat at a fixed position — the movement half of the routed traffic. fn heartbeat(timestamp: u32) -> wow_world_messages::vanilla::MSG_MOVE_HEARTBEAT_Client { use wow_world_messages::vanilla::{MovementInfo, MovementInfo_MovementFlags, Vector3d}; wow_world_messages::vanilla::MSG_MOVE_HEARTBEAT_Client { @@ -123,8 +105,6 @@ fn heartbeat(timestamp: u32) -> wow_world_messages::vanilla::MSG_MOVE_HEARTBEAT_ } } -/// Drive a full session (char-select → login → movement → an attack → disconnect) and return the -/// ordered `(shard, call)` log. pub(super) fn drive_routed_session( store: std::sync::Arc, calls: ShardCallLog, @@ -136,20 +116,16 @@ pub(super) fn drive_routed_session( }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // Character select — REALM-scoped, before any character (and therefore any shard) is chosen. CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - // Enter the world: this is where the session pins itself to the character's home shard. CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); drain_world_entry(&mut client, &mut c_dec); - // In-world traffic: a movement heartbeat (flushed by the following non-movement opcode) and a - // melee swing — one subscription-driven path and one reducer path. heartbeat(100) .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -160,7 +136,7 @@ pub(super) fn drive_routed_session( .unwrap(); let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); - drop(client); // EOF → teardown runs `logout` + drop(client); server.join().unwrap(); let log = calls.lock().unwrap().clone(); log @@ -168,9 +144,6 @@ pub(super) fn drive_routed_session( #[test] fn every_player_scoped_call_after_login_targets_the_home_shard_only() { - // Requirement: once the session resolves the character's home shard, EVERY reducer call and the - // per-player subscription run against that database — the login itself, the AOI/relay - // subscription, movement, combat, and the teardown logout. Nothing leaks back to the default. let (store, calls) = sharded_stores(); let log = drive_routed_session(store, calls); @@ -186,7 +159,7 @@ fn every_player_scoped_call_after_login_targets_the_home_shard_only() { ); for expected in [ "player_login", - "subscribe_player_events", // the per-player connection + AOI subscriptions + "subscribe_player_events", "movement_update", "start_attack", "logout", @@ -201,9 +174,6 @@ fn every_player_scoped_call_after_login_targets_the_home_shard_only() { #[test] fn a_single_entry_shard_map_never_routes_and_keeps_every_call_on_the_one_database() { - // The safety property: with no second shard to resolve to — which is what a single-entry - // (default/unconfigured) shard map always answers — the session never swaps handles, so the - // whole flow is served by the database the listener handed it, byte-identically to before. let (store, calls) = sharded_stores(); let single = std::sync::Arc::new(WorldFake { session: SessionState { @@ -219,7 +189,7 @@ fn a_single_entry_shard_map_never_routes_and_keeps_every_call_on_the_one_databas topology: TopologyState { shard: "world".into(), calls: calls.clone(), - home: None, // ← a single-entry shard map: "you are already on the right shard" + home: None, ..Default::default() }, characters: store.characters.clone(), @@ -247,16 +217,6 @@ fn a_single_entry_shard_map_never_routes_and_keeps_every_call_on_the_one_databas #[test] fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { - // Home-shard routing at the session level. `pool-b` stands for the shard a character was - // just re-homed to; the mock swaps its answer between the two logins the way a shard-map - // edit (or a realm-core index re-home) landing between them does. - // - // TWO claims are being pinned here, and they are different claims: - // 1. NEW ENTRANTS follow the flip — the second session's login, subscription, movement, - // combat and logout all run on `pool-b`. - // 2. RESIDENTS DO NOT — the first session's traffic stays on `instances` for its whole life, - // because routing is resolved once per world ENTRY and the pin is never revisited. - // Nothing moves a live session. let calls: ShardCallLog = Default::default(); let resolutions: std::sync::Arc = Default::default(); let instances = std::sync::Arc::new(WorldFake { @@ -293,7 +253,7 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { calls: calls.clone(), home_shard_calls: resolutions.clone(), home: Some(instances), - home_after_flip: Some(pool_b), // the routing flips between the two sessions + home_after_flip: Some(pool_b), ..Default::default() }, session: SessionState { @@ -316,7 +276,6 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { ..Default::default() }); - // Session 1 — the RESIDENT. Enters before the flip. let resident = drive_routed_session(world.clone(), calls.clone()); assert!( resident @@ -336,7 +295,6 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { re-resolve a live session mid-flight" ); - // Session 2 — the NEXT ENTRANT. Same stores, same character, post-flip. calls.lock().unwrap().clear(); let entrant = drive_routed_session(world, calls.clone()); assert!( @@ -365,9 +323,6 @@ fn a_routing_flip_re_routes_the_next_entrant_and_leaves_the_resident_alone() { #[test] fn a_world_port_keeps_the_pin_when_the_home_shard_still_owns_the_new_map() { - // A world-port re-resolves routing (the new map may belong to another shard). When the shard - // the session is ALREADY on still owns the destination it answers "no swap needed" — which must - // KEEP the pin, not silently drop the session back to the default database. let (store, calls) = sharded_stores_with_home_entity(false); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); @@ -407,8 +362,6 @@ fn a_world_port_keeps_the_pin_when_the_home_shard_still_owns_the_new_map() { #[test] fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard() { - // The gate reads the live entity through the handler's `store`, which `RoutedStore` has - // already routed home. If either stops holding, the stray ack re-runs the world entry. let calls: ShardCallLog = Default::default(); let home = std::sync::Arc::new(WorldFake { topology: TopologyState { @@ -418,7 +371,6 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard }, session: SessionState { login_entity: Some(warrior_entity()), - // The live entity IS in the world on the home shard — the ack is spurious. entity_in_world: true, ..Default::default() }, @@ -438,8 +390,6 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard session_key: K, }), login_entity: Some(warrior_entity()), - // The default handle has NO entity for this guid (it lives on `instances`) — a - // default-shard read here would wrongly answer "absent" and re-enter. entity_in_world: false, ..Default::default() }, @@ -465,11 +415,10 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard .unwrap(); drain_world_entry(&mut client, &mut c_dec); - // The stray ack: no teleport despawned the entity — it must be dropped, not answered. MSG_MOVE_WORLDPORT_ACK {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - drop(client); // EOF right after — the session loop consumes the ack, then tears down + drop(client); server.join().unwrap(); assert_eq!( @@ -488,8 +437,6 @@ fn a_spurious_worldport_ack_is_ignored_on_a_session_pinned_off_the_default_shard #[test] fn a_spurious_worldport_ack_is_ignored_on_the_default_shard() { - // The single-database twin of the test above — the `entity_in_world: true` ignore path was - // untested before these two. let store = std::sync::Arc::new({ let base = tester_store(7); WorldFake { @@ -537,7 +484,6 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // Enter the world (pins to "instances"), then log out back to character select. CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -545,10 +491,9 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { CMSG_LOGOUT_REQUEST {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); // SMSG_LOGOUT_RESPONSE - ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); // SMSG_LOGOUT_COMPLETE + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); - // Back at character select: this must be served by the REALM (default) database again. CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -575,22 +520,6 @@ fn a_logout_to_character_select_releases_the_home_shard_pin() { #[test] fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { - // Requirement: `create_character` always writes to the DEFAULT/realm shard, even when the - // start position routes to a different one under `LYRACORE_SHARD_MAP` — a deliberate decision (see - // the doc comment on `CharacterStore::create_character` for `Coordinator`): create-then- - // transfer-on-first-login, not create-directly-on-the-owning-shard. That decision rides the - // SAME `route_home`/`settle_home_shard` machinery every other login already uses — prove it - // end to end for a guid the CREATE call ITSELF produced, not one hardcoded - // independently of it (the tautology the first version of this test was caught on: a bare - // `CMSG_PLAYER_LOGIN { guid: Guid::new(1) }` logs into `sharded_stores()`'s pre-seeded - // "Tester" fixture whether or not the preceding CREATE ever ran). - // - // So this drives the REAL 1.12 flow instead: CREATE, then re-ENUMERATE — `SMSG_CHAR_CREATE` - // carries no guid, the client is expected to learn it from the next `CMSG_CHAR_ENUM` — and - // pick "Newbie"'s guid out of THAT reply before logging in with it. `WorldFake:: - // create_character` now actually records the character (see its doc comment) so this guid is - // genuinely create-produced, and `sharded_stores()`'s single connected `home` shard - // (`instances`) stands in for a start map that routes off `world`. let (store, calls) = sharded_stores(); let (mut client, server_end) = world_session_socket_pair(); @@ -600,8 +529,6 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // The creation itself must land on the default (`world`) shard — the chosen behaviour, not an - // accident of this test. CMSG_CHAR_CREATE { name: "Newbie".into(), race: Race::Orc, @@ -622,7 +549,6 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { other => panic!("expected SMSG_CHAR_CREATE, got {other}"), } - // Learn the new character's guid the way a real client does: re-enumerate. CMSG_CHAR_ENUM {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -644,7 +570,6 @@ fn a_freshly_created_characters_first_login_transfers_off_the_default_shard() { other => panic!("expected SMSG_CHAR_ENUM, got {other}"), }; - // That same character's FIRST login, in the SAME session, right after creation. CMSG_PLAYER_LOGIN { guid: Guid::new(new_guid), } diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index e6b216200..56910b078 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -1,5 +1,3 @@ -//! Contact lists, realm-wide presence, and party requests. - use super::party::PartyOutcome; use super::{ party, presence, who, Actor, Outbound, ProtocolFamily, ProtocolReply, ProtocolRequest, @@ -15,88 +13,37 @@ use wow_world_messages::vanilla::opcodes::{ClientOpcodeMessage, ServerOpcodeMess use wow_world_messages::vanilla::{MSG_RAID_TARGET_UPDATE_Client, PartyOperation, PartyResult}; use wow_world_messages::Guid; -/// Per-Shard presence reads and contact lists, behind presence, `/who` and whispers. pub(crate) trait SocialStore: Send + Sync { - /// This Shard's durable Character row for `guid`: identity plus the session flag. `None` if - /// this Shard holds no `game_character` row for it. - /// [`presence::of`](super::presence::of) unions it across every connected Shard. fn character_identity(&self, guid: u64) -> Result>; - /// This Shard's live `game_world_entity` row for `guid`, if any — the Member Stats columns, - /// plus level and zone, current unlike the durable row (`persist_entity` only refreshes it on - /// logout, cross-map teleport or Transfer). `None` if `guid` has no live entity here. fn live_entity(&self, guid: u64) -> Option; - /// Does this Shard show `guid` between two places: its own Character row reading online with - /// no live entity here (a map-change loading screen, or a human Transfer's frozen source - /// copy — `begin_transfer` persists with `set_offline: false`), or a Transfer Intent naming a - /// session-less bot mid-crossing. fn character_in_transit(&self, guid: u64) -> bool; - /// This Shard's stored Auto-Reply for `guid`. `None` when this Shard holds none. - /// [`presence::auto_reply`] asks the Shard that holds the live entity. fn auto_reply_text(&self, guid: u64) -> Result>; - /// Does every configured World Shard vouch that it is reachable and healthy enough to trust a - /// negative read from? [`presence::of`] asks this before answering `Whereabouts::Offline` or - /// `None` — an unreachable or stale-cached Shard could be hiding the Character. A Store - /// without a Shard topology answers `Ok(())`. fn every_shard_vouches_for_absence(&self) -> Result<()>; - /// Every in-world player Character on this Shard — the per-Shard input - /// [`presence::in_world_characters`](super::presence::in_world_characters) unions, and `/who`'s - /// ultimate source. A player is "in world" iff their guid appears in `game_world_entity` with - /// `entry == 0` (player entity); dead players are included (ghosts are in world). Bots are - /// included: they have no session, but they do have a live entity. fn in_world_players(&self) -> Result>; - /// `game_area.name` for `zone_id` — `/who`'s search-string match against a zone name. Empty - /// when the catalogue holds no row for it (unimported, or an id the client sends that the - /// imported DBC lacks). fn zone_name(&self, zone_id: u32) -> String; - /// `self_guid`'s friend guids and ignore guids for `CMSG_FRIEND_LIST`. `self_guid` is always - /// the CALLING World Session's own guid, never a peer's, so a Store may answer this from - /// whatever cheap per-connection state it keeps for its own connected sessions (the Coordinator - /// reads its Gateway-side `Viewer`). A caller that needs a friend's PRESENCE composes it - /// separately with `world::social::friend_views`, over `presence::of`; a caller that needs to - /// know whether an ARBITRARY (possibly unconnected) Character ignores another uses - /// [`SocialStore::ignored_guids`] instead, never this method. fn contact_lists(&self, self_guid: u64) -> Result<(Vec, Vec)>; - /// `owner_guid`'s ignore guids, read directly off this Shard's durable contact rows — realm-wide - /// safe for ANY owner, including one with no live World Session on this Gateway process at all - /// (a whisper sender or a guild-invite target usually is not). `whisper::ignored_anywhere` fans - /// this out across every connected Shard. Unlike `contact_lists`, this never reads a `Viewer`. fn ignored_guids(&self, owner_guid: u64) -> Result>; - /// Resolve a typed contact name to a character guid on THIS Shard (case-insensitive, like the - /// Module's `character_by_name`): `presence::resolve_by_name`'s per-Shard primitive. `None` - /// if this Shard has no character with that name. fn character_guid_by_name(&self, name: &str) -> Result>; - // Contact ops answer a [`ContactOutcome`]: a typed Refusal is a gameplay answer the - // client renders, and only a failure with an unknown durable result stays `Err`. - - /// `CMSG_ADD_FRIEND` (the name is already resolved to `target_guid` by the gateway). - /// `target_race` is the target's own Speaker Fact, read realm-wide by the gateway: the Module's - /// Enemy Gate needs it and holds no Characters of its own to read it from. fn add_friend(&self, actor: Actor, target_guid: u64, target_race: u8) -> Result; - /// `CMSG_DEL_FRIEND`. fn del_friend(&self, actor: Actor, target_guid: u64) -> Result; - /// `CMSG_ADD_IGNORE` (the name is already resolved to `target_guid` by the gateway). fn add_ignore(&self, actor: Actor, target_guid: u64) -> Result; - /// `CMSG_DEL_IGNORE`. fn del_ignore(&self, actor: Actor, target_guid: u64) -> Result; } -/// What one contact-list op answered. A [`ContactRefusal`] is a gameplay answer `SMSG_FRIEND_STATUS` -/// renders; a Transport Loss or an untagged Refusal stays `Err` and ends the session, because the -/// durable outcome is then unknown. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ContactOutcome { Done, @@ -121,7 +68,7 @@ impl< + ?Sized, > ProtocolFamily for Social { - #[allow(clippy::too_many_lines)] // One match arm per social or party opcode. + #[allow(clippy::too_many_lines)] fn handle( store: &St, session: &mut ProtocolSession, @@ -130,10 +77,6 @@ impl< let msg = request.message()?; let mut outbound = Vec::new(); match msg { - // /who panel: filtered realm-wide (`who::respond`), same team only, every filter the - // client sent applied. Silently dropped outside the world (no requester Character to read - // a team from), for an unresolvable requester (never guess a team), and while this - // session's `WHO_THROTTLE` cooldown is running (vm:MiscHandler.cpp:230). ClientOpcodeMessage::CMSG_WHO(request) => { if let Some(me) = session.self_guid() { if session.admit_who() { @@ -147,9 +90,6 @@ impl< } } } - // Friends / ignore list: opening the social pane's friends tab requests BOTH - // lists off the one opcode — vanilla answers with SMSG_FRIEND_LIST + SMSG_IGNORE_LIST. - // Silently dropped outside the world (no character to scope the lists to). ClientOpcodeMessage::CMSG_FRIEND_LIST => { if let WorldState::InWorld(iw) = &session.state { let (friend_guids, ignored) = store.contact_lists(iw.self_guid)?; @@ -167,10 +107,6 @@ impl< ))); } } - // Add a friend/ignore by typed name: resolved realm-wide the same way `/whisper`'s target - // is, then the module re-validates self/duplicate/cap/team server-side. Either way the - // client gets an SMSG_FRIEND_STATUS its system message reads the result code off. The - // contact ops are silently dropped outside the world: there is no Character to act as. ClientOpcodeMessage::CMSG_ADD_FRIEND(c) => { if let Some(actor) = session.actor() { let (result, guid, online) = resolve_add_contact(store, actor, &c.name, false)?; @@ -185,7 +121,6 @@ impl< outbound.push(Outbound::Raw { opcode, body }); } } - // Remove a friend/ignore by guid (the client already has it from the list row). ClientOpcodeMessage::CMSG_DEL_FRIEND(c) => { if let Some(actor) = session.actor() { let (result, guid) = resolve_del_contact(store, actor, c.guid.guid(), false)?; @@ -200,15 +135,6 @@ impl< outbound.push(Outbound::Raw { opcode, body }); } } - // Party/group. The invite/uninvite names resolve gateway-side (the add_friend - // convention); outcomes echo as SMSG_PARTY_COMMAND_RESULT. The cross-player packets - // (SMSG_GROUP_INVITE/LIST/DECLINE/DESTROYED) ride the game_group_event relay. - // - // The op itself goes through `world::party`, which decides WHICH DATABASE - // runs it — realm-core when the gateway is multi-database, the player's own shard otherwise. - // Every arm below is otherwise unchanged, including which failures the client is told about. - // Silently dropped outside the world: with no in-world character there is no `self_guid` to - // act as, and none of these opcodes is reachable at character select. ClientOpcodeMessage::CMSG_GROUP_INVITE(c) => { let result = match (session.actor(), presence::resolve_by_name(store, &c.name)?) { (Some(me), Some(guid)) => { @@ -250,11 +176,6 @@ impl< )); } } - // `CMSG_LOOT_METHOD`: the leader sets the party's loot method/ - // threshold/master. No ack packet — vanilla itself sends none for this opcode (cmangos's - // `HandleLootMethodOpcode` only calls `group->SendUpdate()`); the module's own reducer - // echoes via the EXISTING `SMSG_GROUP_LIST` roster relay. A rejection (not the leader, bad - // method/threshold/master) is per-action — log + ignore, matching group_accept/decline. ClientOpcodeMessage::CMSG_LOOT_METHOD(c) => { let op = party::Op::LootMethod { setting: c.loot_setting.as_int(), @@ -277,8 +198,6 @@ impl< promote: c.set_assistant, }, )?, - // The raid frame's "Remove from group" names the member by guid, so no name lookup runs - // (cm:GroupHandler.cpp:250-296). Naming yourself gets no answer (lines 255-260). ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(c) if session.self_guid() != Some(c.guid.guid()) => { @@ -289,17 +208,12 @@ impl< )?) } ClientOpcodeMessage::CMSG_GROUP_UNINVITE_GUID(_) => {} - // `CMSG_GROUP_CHANGE_SUB_GROUP` / `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant - // drags a raid member to another Subgroup, or swaps two. No ack packet: the - // `SMSG_GROUP_LIST` roster relay is the client-visible result, and cmangos is silent for - // every refusal here (cm:GroupHandler.cpp:492-525, cm:GroupHandler.cpp:901-944). ClientOpcodeMessage::CMSG_GROUP_CHANGE_SUB_GROUP(c) => { change_subgroup(store, session, &c.name, c.group_number)? } ClientOpcodeMessage::CMSG_GROUP_SWAP_SUB_GROUP(c) => { swap_subgroup(store, session, &c.name, &c.swap_with_name)? } - // Group Broadcasts. Every member's packet rides the group event relay, the actor's too. ClientOpcodeMessage::MSG_RAID_READY_CHECK(c) => { let op = match c.answer { None => party::Op::ReadyCheckStart, @@ -323,8 +237,6 @@ impl< } } -/// Run a leave or a kick by guid. Only a Refusal answers, as `SMSG_PARTY_COMMAND_RESULT(Leave, "", -/// result)`; an op that ran is heard through the relay. fn run_answering_refusal< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -349,7 +261,6 @@ fn run_answering_refusal< } } -/// Raid conversion answers success with a party command result. A Refusal is silent. fn raid_convert< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -379,9 +290,6 @@ fn raid_convert< } } -/// Run a party op whose outcome the client hears only through the relay. A Refusal is logged at -/// debug and sends nothing. cmangos sends nothing for set leader and set Assistant, whatever the -/// outcome (cm:GroupHandler.cpp:344-362, 527-545). fn run_unanswered< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -401,10 +309,6 @@ fn run_unanswered< Ok(()) } -/// `CMSG_GROUP_CHANGE_SUB_GROUP`: the leader or an Assistant moves `name` to `subgroup`. The name -/// resolves against the actor's OWN roster rather than realm-wide, so a namesake standing outside -/// the Raid cannot be reached — the same avoided-homonym rule cmangos applies to Swap Subgroup -/// alone (cm:GroupHandler.cpp:919-936), extended here to both opcodes. fn change_subgroup< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -433,10 +337,6 @@ fn change_subgroup< } } -/// `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant swaps `name` and `swap_with_name`. Same -/// roster-only name resolution as [`change_subgroup`] (cm:GroupHandler.cpp:901-944, -/// cm:GroupHandler.cpp:919-936), with both names resolved against the SAME roster read so the -/// pair cannot straddle two different snapshots of it. fn swap_subgroup< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -465,10 +365,6 @@ fn swap_subgroup< } } -/// Run one Group Broadcast for the session's Character. cmangos answers every refusal of these -/// opcodes with silence, so a Refusal only logs. A transport failure only logs too: a broadcast -/// changes no roster and nothing waits on it, so a lost ping or roll must not end the session. -/// A throttled op sent again inside its cooldown is dropped the same way. pub(super) fn run_group_broadcast< St: CharacterStore + PartyStore + SessionStore + ShardRoutingStore + SocialStore + ?Sized, >( @@ -499,8 +395,6 @@ pub(super) fn run_group_broadcast< } } -/// `MSG_RAID_TARGET_UPDATE` as a Target Icon op. `RequestIcons` is the list request, and -/// `Unknown0` to `Unknown8` are indexes 0 to 8. The Module refuses index 8. fn target_icon_op(update: &MSG_RAID_TARGET_UPDATE_Client) -> party::Op { use MSG_RAID_TARGET_UPDATE_Client as Update; let (icon, target) = match *update { @@ -521,7 +415,6 @@ fn target_icon_op(update: &MSG_RAID_TARGET_UPDATE_Client) -> party::Op { } } -/// The `PartyResult` code one party outcome renders as. fn party_result(outcome: PartyOutcome) -> PartyResult { match outcome { PartyOutcome::Ran => PartyResult::Success, @@ -529,12 +422,6 @@ fn party_result(outcome: PartyOutcome) -> PartyResult { } } -/// Map each [`GroupRefusal`] onto the vanilla `PartyResult` the client renders ("X is already in a -/// group" etc.). Vanilla has no code for an offline or self-named target, a stale invite, a raid -/// op in a Party, a temporarily unavailable actor, or either Subgroup move Refusal, since cmangos -/// answers a refused Change or Swap Subgroup with silence rather than a code, so all of those read -/// as BadPlayerName, a visible line that does not crash the client. The intent claim never reaches -/// a client; it is listed so a new Refusal cannot be forgotten here. fn party_result_for(refusal: GroupRefusal) -> PartyResult { match refusal { GroupRefusal::AlreadyInGroup => PartyResult::AlreadyInGroup, @@ -561,8 +448,6 @@ fn party_result_for(refusal: GroupRefusal) -> PartyResult { } } -/// The `FriendResult` code one contact Refusal renders as. The friends and ignore lists have -/// separate code families for the same three conditions, so the list decides the answer. fn friend_result_for(refusal: ContactRefusal, is_ignore: bool) -> FriendResult { match (refusal, is_ignore) { (ContactRefusal::AddSelf, false) => FriendResult::SelfX, @@ -572,21 +457,13 @@ fn friend_result_for(refusal: ContactRefusal, is_ignore: bool) -> FriendResult { (ContactRefusal::ListFull, false) => FriendResult::ListFull, (ContactRefusal::ListFull, true) => FriendResult::IgnoreFull, (ContactRefusal::NotOnList, true) => FriendResult::IgnoreNotFound, - // The friend-list remove of a row that is not there, and an actor the module could not - // reach, both read as the one "no such entry" line vanilla has. (ContactRefusal::NotOnList, false) | (ContactRefusal::ActorUnavailable, _) => { FriendResult::NotFound } - // Ignore has no faction rule (only `resolve_add_contact`'s friend arm can produce this), - // but the wire still has one code for it. (ContactRefusal::Enemy, _) => FriendResult::Enemy, } } -/// The `SMSG_FRIEND_STATUS` trailing fields for a Realm Presence that is online — the ONLINE/ -/// ADDED_ONLINE case. Away Status reads `None` off any Whereabouts but `InWorld` (a Character -/// between places carries no live `PLAYER_FLAGS` to read one from). `pub(crate)` because the -/// Account Claim Relay (`stdb::world_view`) shares it for the login/logout notice. pub(crate) fn friend_online_fields(presence: &presence::RealmPresence) -> codec::FriendOnline { let away = match &presence.whereabouts { presence::Whereabouts::InWorld { away, .. } => *away, @@ -602,12 +479,6 @@ pub(crate) fn friend_online_fields(presence: &presence::RealmPresence) -> codec: } } -/// Hydrate friend guids into `FriendView`s for `CMSG_FRIEND_LIST`: online means `session_online` -/// and the same team as `own_team`, carrying level/class/zone/Away Status from Realm Presence -/// (cm:SocialMgr.cpp:108-139). A friend whose Realm Presence read fails — an unreachable Shard — -/// degrades that one row to offline rather than failing the whole list or ending the World -/// Session. Lives over the `WorldStore` seam, not behind any one Store's own `contact_lists`, so a -/// Store Fake exercises the exact composition a Coordinator serves. pub(crate) fn friend_views( store: &St, own_team: u32, @@ -644,11 +515,6 @@ pub(crate) fn friend_views( .collect() } -/// Resolve a typed contact name realm-wide, call the module's add reducer (`add_friend`/ -/// `add_ignore`), and translate the outcome into what `SMSG_FRIEND_STATUS` needs: the result code, -/// the OTHER party's guid, and — ONLINE/ADDED_ONLINE only — the trailing presence fields. An -/// unknown name never reaches the module (guid 0, `NotFound`/`IgnoreNotFound`); everything else -/// (self/duplicate/cap/team) is the module's own typed Refusal. fn resolve_add_contact( store: &St, actor: Actor, @@ -672,12 +538,6 @@ fn resolve_add_contact( store: &St, actor: Actor, @@ -741,8 +596,6 @@ fn resolve_del_contact( mod tests { use super::*; - /// The six conditions vanilla has a code for keep their own code; the rest share the one - /// visible fallback line rather than being swallowed. #[test] fn each_group_refusal_renders_its_own_party_result() { for refusal in GroupRefusal::ALL { @@ -760,8 +613,6 @@ mod tests { assert_eq!(party_result(PartyOutcome::Ran), PartyResult::Success); } - /// The friends and ignore lists carry separate code families for the same conditions, so the - /// same Refusal must not answer both lists with one code. #[test] fn each_contact_refusal_renders_the_code_of_the_list_it_names() { for refusal in ContactRefusal::ALL { diff --git a/gateway/src/world/social_tests.rs b/gateway/src/world/social_tests.rs index a9ab608f5..4452da846 100644 --- a/gateway/src/world/social_tests.rs +++ b/gateway/src/world/social_tests.rs @@ -1,10 +1,5 @@ -//! Friends and ignore lists over an encrypted World Session. - use super::*; -/// Read one `SMSG_FRIEND_STATUS` frame — RAW always, now that `ONLINE`/`ADDED_ONLINE` carry -/// trailing fields gtker 0.3 cannot type (see `codec::build_friend_status_raw`). Returns the -/// result, the other party's guid, and the trailing bytes (empty unless online). fn read_friend_status( client: &mut UnixStream, dec: &mut DecrypterHalf, @@ -22,8 +17,6 @@ fn read_friend_status( #[test] fn add_friend_by_name_then_friend_list_carries_online_presence() { - // CMSG_ADD_FRIEND "Buddy" -> SMSG_FRIEND_STATUS AddedOnline (guid 2, resolved by - // name); a follow-up CMSG_FRIEND_LIST then carries them online with their level/class/zone. let mut s = quest_store(); s.characters = vec![codec::CharacterView { guid: 2, @@ -38,7 +31,7 @@ fn add_friend_by_name_then_friend_list_carries_online_presence() { CMSG_ADD_FRIEND { name: "buddy".into(), - } // case-insensitive match + } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); @@ -65,7 +58,6 @@ fn add_friend_by_name_then_friend_list_carries_online_presence() { other => panic!("expected SMSG_IGNORE_LIST, got {other}"), } - // Removing it replies Removed and the friend list empties out. CMSG_DEL_FRIEND { guid: Guid::new(2) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -76,9 +68,6 @@ fn add_friend_by_name_then_friend_list_carries_online_presence() { server.join().unwrap(); } -/// A friend on ANOTHER Shard resolves. Vim lives on `instances`; Ginger (this Gateway's own -/// Shard) adds them by name and gets ADDED_ONLINE with Vim's real presence, byte-exact against -/// the raw encoder. #[test] fn add_friend_on_another_shard_answers_added_online_with_presence() { let mut s = quest_store(); @@ -97,13 +86,11 @@ fn add_friend_on_another_shard_answers_added_online_with_presence() { characters: vec![codec::CharacterView { guid: 2, name: "Vim".into(), - race: 1, // same team as Ginger + race: 1, class: 4, ..Default::default() }], social: SocialState { - // Vim standing IN the instance: a live entity, not just a durable row, so the ADDED_ONLINE - // fields below come off `Whereabouts::InWorld`. member_entities: std::sync::Mutex::new(vec![( 2, codec::MemberEntity { @@ -126,19 +113,15 @@ fn add_friend_on_another_shard_answers_added_online_with_presence() { let (result, guid, trailer) = read_friend_status(&mut client, &mut c_dec); assert_eq!(result, FriendResult::AddedOnline); assert_eq!(guid, 2); - let mut expected = vec![1u8]; // status ONLINE - expected.extend(33u32.to_le_bytes()); // area - expected.extend(22u32.to_le_bytes()); // level - expected.extend(4u32.to_le_bytes()); // class + let mut expected = vec![1u8]; + expected.extend(33u32.to_le_bytes()); + expected.extend(22u32.to_le_bytes()); + expected.extend(4u32.to_le_bytes()); assert_eq!(trailer, expected); drop(client); server.join().unwrap(); } -/// A friend standing in another Shard's instance shows ONLINE with real level/class/zone through -/// `CMSG_FRIEND_LIST`, and AFK/DND status follow the live entity's `PLAYER_FLAGS` — exercised -/// through the actual socket dispatch, so it runs the SAME `world::social::friend_views` -/// composition a Coordinator serves, not a Store Fake's own reimplementation of it. #[test] fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { let mut s = quest_store(); @@ -148,7 +131,7 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { race: 1, ..Default::default() }]; - s.social.contacts = std::sync::Mutex::new(vec![(1, 2, false)]); // Ginger already friends Vim + s.social.contacts = std::sync::Mutex::new(vec![(1, 2, false)]); let store = std::sync::Arc::new(s); let peer = std::sync::Arc::new(WorldFake { topology: TopologyState { @@ -158,7 +141,7 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { characters: vec![codec::CharacterView { guid: 2, name: "Vim".into(), - race: 1, // same team as Ginger + race: 1, class: 4, ..Default::default() }], @@ -198,9 +181,7 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { } other => panic!("expected SMSG_FRIEND_LIST, got {other}"), } - // CMSG_FRIEND_LIST always answers with BOTH lists; leaving the SMSG_IGNORE_LIST reply unread - // and dropping the client closes the socket with a queued write still in flight, which Linux - // reports to the server as a reset rather than a clean EOF. + // Drain both list replies before closing, or Linux can reset the queued writer. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_IGNORE_LIST(l) => assert!(l.ignored.is_empty()), other => panic!("expected SMSG_IGNORE_LIST, got {other}"), @@ -209,9 +190,6 @@ fn friend_list_shows_a_friend_in_another_shards_instance_online_afk_or_dnd() { server.join().unwrap(); } -/// An enemy-faction friend answers FRIEND_ENEMY. The companion half — the same target is a legal -/// ignore — is a Module rule this Store Fake cannot express; it is pinned durably in -/// `module/tests/friends.rs`. #[test] fn add_friend_enemy_faction_answers_friend_enemy() { let mut s = quest_store(); @@ -219,13 +197,13 @@ fn add_friend_enemy_faction_answers_friend_enemy() { codec::CharacterView { guid: 1, name: "Ginger".into(), - race: 1, // Human, Alliance + race: 1, ..Default::default() }, codec::CharacterView { guid: 2, name: "Grunt".into(), - race: 2, // Orc, Horde + race: 2, ..Default::default() }, ]; @@ -261,7 +239,6 @@ fn add_friend_unknown_name_replies_not_found() { server.join().unwrap(); } -/// The ignore list has its own "unknown name" code. #[test] fn add_ignore_unknown_name_replies_ignore_not_found() { let store = std::sync::Arc::new(quest_store()); @@ -356,8 +333,6 @@ fn add_ignore_maps_already_and_full_errors_to_the_ignore_variants() { } } -/// Every contact Refusal the Module can send reaches the client as exactly one `FriendResult`, and -/// the friends and ignore lists get their own code family for the same condition. #[test] fn every_contact_refusal_reaches_the_client_as_one_friend_result() { for refusal in ContactRefusal::ALL { @@ -400,7 +375,6 @@ fn every_contact_refusal_reaches_the_client_as_one_friend_result() { } } -/// The contact half of the same rule: a Transport Loss left the list in an unknown state. #[test] fn an_add_friend_transport_loss_is_not_answered_as_a_refusal() { let mut s = quest_store(); @@ -474,13 +448,11 @@ fn del_ignore_round_trips_added_then_unknown_is_ignore_not_found() { let (result, ..) = read_friend_status(&mut client, &mut c_dec); assert_eq!(result, FriendResult::IgnoreAdded); - // Removing the just-added contact: IgnoreRemoved. CMSG_DEL_IGNORE { guid: Guid::new(3) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); let (result, ..) = read_friend_status(&mut client, &mut c_dec); assert_eq!(result, FriendResult::IgnoreRemoved); - // Removing it again: no longer on the list -> IgnoreNotFound. CMSG_DEL_IGNORE { guid: Guid::new(3) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index e242c06e7..55782cdce 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -1,7 +1,3 @@ -//! `WorldStore`: the umbrella over every Store family a World Session reaches, plus the shard -//! routing and session families. Each other family trait sits beside the handler that calls it, so -//! a handler names only the families it needs and a family Fake implements only its own trait. - use super::*; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -11,15 +7,12 @@ pub struct WorldSessionToken { pub request_nonce: u128, } -/// A completed Character request. Infrastructure failures remain an outer `Err`. #[derive(Debug, PartialEq, Eq)] pub enum InteractionOutcome { Done, Refused(String), } -/// Every Store family a World Session reaches. The blanket impl below supplies it, so a Store -/// implements the families and never this trait. pub trait WorldStore: ShardRoutingStore + SessionStore @@ -92,72 +85,34 @@ impl WorldStore for T where { } -/// Shard routing: the handle that serves a Character, a location, Realm-core or every World Shard. pub(crate) trait ShardRoutingStore: Send + Sync { - /// The database this handle targets — routing identity, for logs and for the tests that assert - /// no call ever escapes the player's home shard. fn shard_name(&self) -> &str; - /// Put `character_guid` on the shard that owns its location, running the escrowed transfer if - /// it is somewhere else, then answer with that shard's handle, or `None` when this handle - /// already serves it. - /// Called at every world entry; `Err` fails the login rather than letting a half-moved - /// character into the world on either side. fn settle_home_shard( &self, character_guid: u64, ) -> Result>>; - /// The handle for the shard the Shard Map gives `(map_id, instance_id)`, asked of the handle - /// that currently HOLDS the character (so a live dungeon run stays on its pool member). - /// `None` = this handle already serves that location, which is what a single-database gateway - /// always answers. - /// - /// [`settle_home_shard`](Self::settle_home_shard) asks the same question from the character's - /// own row; this asks it about a location nobody is at yet, which is what a session-less - /// crossing needs before it has anything to route from. fn shard_for_location( &self, map_id: u32, instance_id: u64, ) -> Option>; - /// Bind this shard's per-player connection identity to the account (`establish_session`), so - /// `player_login` can resolve the caller here. A no-op on the realm shard, where the logon tier - /// already did it. Called at world entry whenever the session's home shard is not the realm. fn bind_shard_session(&self, account_id: u64, session_key: &[u8; 40]) -> Result<()>; - /// Realm-core authority, or `Ok(None)` when this Realm has one Shard and this Store owns it. - /// An unavailable configured Realm-core is an infrastructure failure. Callers must never - /// substitute a World Shard for that authority. fn realm_store(&self) -> Result>>; - /// Every connected WORLD shard's handle (realm-core excluded — it owns no gameplay reads). The - /// fan-out set for the roster mirror; empty on a single-database gateway, which is what makes the - /// mirror push a no-op there. fn world_stores(&self) -> Vec>; - /// Every configured World Shard for command receipt and holder admission. Missing or unhealthy - /// members are an infrastructure error because absence cannot be certified on a partial set. fn party_command_worlds(&self) -> Result>>; } -/// The World Session lifecycle: handshake lookup, Account Claim, world entry, movement and logout reads. pub(crate) trait SessionStore: Send + Sync { - /// Look up the shared session key K (+ account id) for an (already uppercased) account - /// name. `None` when no live session exists for that account (reject the handshake). fn lookup_session(&self, account_name: &str) -> Result>; - /// Undelivered private System Messages addressed to this character, oldest-first. A Package - /// `on_login` hook emits its message INSIDE `player_login`, before the session is in the - /// viewer registry, so the live insert relay has nobody to address — world entry replays what - /// is still parked in the shard cache. fn pending_system_messages(&self, self_guid: u64) -> Vec; - /// Enter the world as `character`: calls the `player_login` reducer and - /// returns the live entity to spawn (from the resulting `game_world_entity` row). Errors if - /// the character isn't the caller's. `entry` picks the reducer: a world-port keeps the Away - /// Status, a fresh login ends it. fn player_login( &self, account_id: u64, @@ -165,20 +120,8 @@ pub(crate) trait SessionStore: Send + Sync { entry: codec::WorldEntry, ) -> Result; - /// Enqueue an accepted inbound movement on this shard's shared movement batch. The live store - /// serializes `info` once and preserves the mover, opcode, position, orientation, and timestamp - /// in the queued entry. Relayed peer events arrive back through the shared dispatch. fn movement_update(&self, actor: Actor, opcode: u32, info: &MovementInfo) -> Result<()>; - /// Subscribe this player's connection to its per-player views (nearby `game_world_entity`, - /// addressed `game_movement_event`) and push the resulting peer-spawn / movement-relay / destroy - /// SMSG onto `tx`. The returned guard tears the subscription + callbacks down on - /// drop. Called once, at `CMSG_PLAYER_LOGIN`, when `self_guid` is known. - /// - /// `arrival` is the entity the login batch was just built from, so the viewer's map, partition, - /// zone and AOI anchor cannot drift from what the client was told. Its zone in particular seeds - /// the weather routing with the zone world entry already sent weather for, so the first live - /// crossing is a real crossing rather than the viewer learning its own starting zone. fn subscribe_player_events( &self, account_id: u64, @@ -187,34 +130,21 @@ pub(crate) trait SessionStore: Send + Sync { tx: SessionTx, ) -> Result; - /// Forward a parsed addon-bridge command to the module's `client_command` reducer ON - /// THE PLAYER'S CONNECTION — the handler runs with exactly the player's reducer authority. fn client_command(&self, actor: Actor, cmd: String, payload: String) -> Result<()>; - /// Is `guid`'s live entity currently in the world? The WORLDPORT_ACK gate: a cross-map - /// transfer despawns the entity until the ack rebuilds it, so - /// ABSENT = a transfer is genuinely pending; PRESENT = the ack is spurious (double-send or - /// crafted) and must be ignored — honoring it would tear down and rebuild a live player - /// (visible blink, gateway combat-bookkeeping reset) at zero cost to the client. fn entity_in_world(&self, guid: u64) -> bool; - /// The live entity's max health (0 if not in world) — the fall-damage flavor line folds - /// the shared curve against it. fn entity_max_health(&self, guid: u64) -> u32; - /// Acquire Realm-core ownership and fence every configured World Shard before returning. fn claim_session(&self, account_id: u64, character_guid: u64) -> Result; - /// Bind subsequent requests to this World Session. `None` keeps the current handle. fn bind_session( &self, token: WorldSessionToken, ) -> Result>>; - /// Close the matching Shard fences before releasing the Realm-core claim. Stale cleanup is inert. + /// Close the matching World Shard fences before releasing the Realm-core claim. fn release_session(&self, token: WorldSessionToken) -> Result<()>; - /// Return the `combat_until_ms` timestamp for `player_guid`'s entity row (0 if the entity is not - /// found). Used by the logout handler to deny `CMSG_LOGOUT_REQUEST` while the player is in combat. fn player_combat_until_ms(&self, player_guid: u64) -> u64; } diff --git a/gateway/src/world/taxi_tests.rs b/gateway/src/world/taxi_tests.rs index 5287d8b5b..d825158e3 100644 --- a/gateway/src/world/taxi_tests.rs +++ b/gateway/src/world/taxi_tests.rs @@ -1,5 +1,3 @@ -//! Taxi opcodes through their dispatcher, and taxi gossip over an encrypted World Session. - use super::family::{ProtocolFamily, ProtocolSession}; use super::handlers::InMemoryTaxiActions; use super::*; @@ -21,7 +19,6 @@ fn run(actions: &InMemoryTaxiActions, msg: impl Into) -> Ve try_run(actions, msg).unwrap() } -/// The one typed packet a request answered with. fn only_message(mut sent: Vec) -> ServerOpcodeMessage { match (sent.pop(), sent.is_empty()) { (Some(Outbound::One(message)), true) => message, @@ -122,7 +119,6 @@ fn taxi_dispatch_propagates_arming_transport_loss_after_acceptance() { #[test] fn taxi_status_query_returns_the_persisted_bit_without_opening() { - // A status answer, not a node map, shows the query did not open the taxi. let actions = InMemoryTaxiActions::default(); *actions.status.lock().unwrap() = Some(codec::TaxiNodeStatusView { npc_guid: 90, @@ -159,8 +155,6 @@ fn direct_taxi_query_opens_the_taxi_map() { #[test] fn taxi_gossip_opens_the_same_map_as_the_direct_query() { - // The gossip arm lives in the query handler, so this stays a socket test. It must answer - // with exactly the packet the direct query builds. use lyracore_shared::constants::gossip_option; let direct = only_message(run( &taxi_map_actions(), @@ -192,7 +186,6 @@ fn taxi_gossip_opens_the_same_map_as_the_direct_query() { #[test] fn activate_taxi_gameplay_refusal_replies_and_does_not_fail_the_next_query() { - // The Module accepts only 255 -> 256, so the reply also shows the node ids arrived intact. let actions = InMemoryTaxiActions { route: Some((255, 256)), ..Default::default() @@ -212,7 +205,6 @@ fn activate_taxi_gameplay_refusal_replies_and_does_not_fail_the_next_query() { other => panic!("expected SMSG_ACTIVATETAXIREPLY, got {other}"), } - // A gameplay refusal is a normal packet result, not a world-session failure. let query = CMSG_TAXINODE_STATUS_QUERY { guid: Guid::new(90), }; diff --git a/gateway/src/world/test_support/entry.rs b/gateway/src/world/test_support/entry.rs index 138b42846..ead0ccdb9 100644 --- a/gateway/src/world/test_support/entry.rs +++ b/gateway/src/world/test_support/entry.rs @@ -1,17 +1,10 @@ -//! World entry as a socket test reads it. - use super::*; use std::os::unix::net::UnixStream; use wow_world_messages::vanilla::{ClientMessage, CMSG_AUTH_SESSION, CMSG_PLAYER_LOGIN}; use wow_world_messages::Guid; -/// More frames than any world entry batch carries. Reaching it means the batch never ended. const WORLD_ENTRY_FRAME_LIMIT: usize = 64; -/// Read one world entry batch: the login sequence, any item CREATEs and the self CREATE, up to and -/// including the zone's `SMSG_WEATHER`, which always closes the batch. A fresh entry and a -/// world-port re-entry end the same way. Panics when no `SMSG_WEATHER` arrives within -/// [`WORLD_ENTRY_FRAME_LIMIT`] frames. pub(crate) fn drain_world_entry( client: &mut S, dec: &mut DecrypterHalf, @@ -29,8 +22,6 @@ pub(crate) fn drain_world_entry( panic!("no SMSG_WEATHER within {WORLD_ENTRY_FRAME_LIMIT} frames: the world entry batch never ended"); } -/// The client side of every real world-session test has a bounded read. A missing server packet is -/// a test failure, never an indefinitely blocked test process. pub(crate) const WORLD_SESSION_READ_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); @@ -52,13 +43,6 @@ pub(crate) fn ns(s: &str) -> NormalizedString { NormalizedString::new(s).unwrap() } -/// Drive the shared prefix of every world handshake: read the plaintext `SMSG_AUTH_CHALLENGE`, -/// derive the client-side proof + cipher pair for `key` with `wow_srp`, and send -/// `CMSG_AUTH_SESSION`. Lower-level than [`client_handshake`] — it does not read whatever comes -/// back, so a call site can assert on that itself (an `AuthOk`, an `AuthWaitQueue`, a plaintext -/// rejection...). Returns the cipher pair `into_client_header_crypto` derived, split; a `key` that -/// does not match what the server holds still produces a (mismatched, useless) pair here — the -/// send happens regardless — so a rejection-path call site is free to bind them as `_`. pub(crate) fn drive_auth( client: &mut S, username: &str, @@ -81,10 +65,6 @@ pub(crate) fn drive_auth( (enc, dec) } -/// Drive the client side of the world handshake against a server running `run_world_session` -/// (or `world_handshake`): read the plaintext challenge, send `CMSG_AUTH_SESSION` with a -/// valid proof, read the encrypted AUTH_OK. Returns the client's cipher halves for the -/// post-handshake encrypted traffic. pub(crate) fn client_handshake( client: &mut S, username: &str, @@ -116,9 +96,6 @@ pub(crate) fn auth_session( } } -/// A store for a logged-in TESTER (account `account_id`), with no character/scenario state beyond -/// the session itself — the shape every handshake/login/logout/movement test overlays with its own -/// fields via `..`. `quest_store()` is the sibling for tests that also need a login entity. pub(crate) fn tester_store(account_id: u64) -> WorldFake { WorldFake { trainer: TrainerState { @@ -138,14 +115,12 @@ pub(crate) fn tester_store(account_id: u64) -> WorldFake { } } -/// Human/Warrior entity matching the seed, as the gateway would read it back after -/// `player_login`. pub(crate) fn warrior_entity() -> codec::EntityView { codec::EntityView { watched_faction_index: None, guid: 1, map_id: 0, - instance_id: 0, // the open world + instance_id: 0, zone_id: 12, x: -8949.95, y: -132.493, @@ -164,7 +139,7 @@ pub(crate) fn warrior_entity() -> codec::EntityView { level: 1, faction_template: 1, target_guid: 0, - unit_bytes_0: 1 | (1 << 8) | (1 << 24), // human(1) warrior(1) male(0) rage(1) + unit_bytes_0: 1 | (1 << 8) | (1 << 24), display_id: 49, native_display_id: 49, unit_flags: 0, @@ -180,7 +155,6 @@ pub(crate) fn warrior_entity() -> codec::EntityView { money: 0, unit_bytes_1: 0, unit_bytes_2: 0, - // L1 Human Warrior base attributes (cmangos curve) — non-zero so the CREATE exercises them. strength: 23, agility: 20, stamina: 22, @@ -193,11 +167,10 @@ pub(crate) fn warrior_entity() -> codec::EntityView { sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, - npc_flags: 0, // a player is not an NPC - owner_guid: 0, // not a summon - effective_armor: 40, // agility 20 * 2 (base; no gear in the fixture → effective == base) + npc_flags: 0, + owner_guid: 0, + effective_armor: 40, magic_resistances: [0; 6], - // No hearthstone bind recorded for the test entity; fall back to login position. home_map: 0, home_zone: 0, home_x: 0.0, @@ -208,8 +181,6 @@ pub(crate) fn warrior_entity() -> codec::EntityView { } } -/// A store configured for an in-world TESTER (account 7, char guid 1) with a login entity — the -/// base fixture every socket test that needs a logged-in player builds on, quest or not. pub(crate) fn quest_store() -> WorldFake { let base = tester_store(7); WorldFake { @@ -221,11 +192,6 @@ pub(crate) fn quest_store() -> WorldFake { } } -/// Spin up a world session over a socket pair, handshake as TESTER, enter the world as `guid`, and -/// drain the world entry batch. Draining one -/// message too few manifests as the CLIENT closing with unread bytes still queued — which the kernel -/// reports back to the SERVER thread's next read as ECONNRESET, not a clean EOF. -/// Returns the client socket + encrypted halves + the server join handle for the test to drive. pub(crate) fn enter_world( store: std::sync::Arc, guid: u64, diff --git a/gateway/src/world/test_support/family.rs b/gateway/src/world/test_support/family.rs index 1926880be..7e6b28cef 100644 --- a/gateway/src/world/test_support/family.rs +++ b/gateway/src/world/test_support/family.rs @@ -1,10 +1,5 @@ -//! Pieces the handler-level family tests share: a session connection to run a handler against, and -//! typed reply flattening. A family test owns its Fake and its `run` helper. - use super::*; -/// A connection for `account_id` in the world as `self_guid`. The session's routed Store is -/// never read here: every handler under test takes its family Fake as an argument. pub(crate) fn in_world_conn(account_id: u64, self_guid: u64) -> WorldConn { let username = NormalizedString::new("TESTER").unwrap(); let server_seed = ProofSeed::new(); @@ -30,7 +25,6 @@ pub(crate) fn in_world_conn(account_id: u64, self_guid: u64) -> WorldConn { } } -/// Flatten typed replies while preserving their protocol order. pub(crate) fn outbound_messages(outbound: Vec) -> Vec { let mut sent = Vec::new(); for out in outbound { @@ -44,8 +38,6 @@ pub(crate) fn outbound_messages(outbound: Vec) -> Vec { impl NpcStore for $fake { diff --git a/gateway/src/world/test_support/mod.rs b/gateway/src/world/test_support/mod.rs index 9deb32354..6b4b93188 100644 --- a/gateway/src/world/test_support/mod.rs +++ b/gateway/src/world/test_support/mod.rs @@ -1,6 +1,3 @@ -//! Shared test support for World Session tests: [`WorldFake`], the multi-shard Fake every Store -//! family is implemented on, and its shard topology. - mod entry; mod family; mod topology; diff --git a/gateway/src/world/test_support/topology.rs b/gateway/src/world/test_support/topology.rs index e34e1b6d6..a99b5bbbc 100644 --- a/gateway/src/world/test_support/topology.rs +++ b/gateway/src/world/test_support/topology.rs @@ -2,58 +2,20 @@ use super::*; #[derive(Default)] pub(crate) struct TopologyState { - /// Multi-shard routing: the database this handle stands for. `""` (derive-Default) is the - /// single-shard world every other test runs in, where nothing routes. pub(crate) shard: String, - /// The handle `home_shard()` hands back — the character's home shard. `None` (default) = "you - /// are already on the right shard", i.e. the single-entry shard map / pre-sharding behavior. pub(crate) home: Option>, - /// Home-shard reassignment: when set, every `home_shard()` resolution AFTER the first - /// answers THIS shard instead of `home` — the mock's stand-in for a routing change landing - /// between two logins (a shard-map edit, or the realm-core index re-homing a character). `None` - /// (default): every resolution answers `home`, byte-identical to before this field existed. pub(crate) home_after_flip: Option>, - /// The one location this handle routes elsewhere, and where to: `(map_id, instance_id, shard)`. - /// `None` (derive-Default) = "this handle serves every location", the single-database answer - /// `shard_for_location` gives. Keyed by location on purpose — a caller that asks about the - /// wrong place gets `None` and the crossing silently becomes a no-op, which is what the tests - /// must be able to tell apart from a crossing that ran. pub(crate) location_shard: Option<(u32, u64, std::sync::Arc)>, - /// How many times `home_shard()` has been asked — drives `home_after_flip`, and is itself the - /// assertion that routing is resolved ONCE PER WORLD ENTRY and never mid-session. SHARED - /// between a store and the handles it routes to (like `calls`), so a re-resolution asked of the - /// *pinned* handle — which is what a mid-session re-route would actually look like, since - /// `route_home` asks whichever handle the session currently holds — is counted too. pub(crate) home_shard_calls: std::sync::Arc, - /// SHARED between a store and its `home` handle: `(shard, call)` for every instrumented - /// player-scoped call, in order. The routing test asserts nothing lands on the wrong database. pub(crate) calls: std::sync::Arc>>, - /// Realm-core authority when configured. `None` models a single-Shard Realm; `realm_error` - /// models an outage without changing which Store owns the realm-wide state. pub(crate) realm: Option>, - /// The connected WORLD shards `world_stores()` fans the roster mirror out to (and the - /// cross-shard name/presence lookups walk). Empty = single database. Behind a `Mutex` only so - /// the topology can be wired up AFTER every handle exists — production reads the shared - /// `ShardSet`, which has the same shape and the same "includes this handle" membership. pub(crate) peers: std::sync::Mutex>>, - /// When set, the configured World Shard set is incomplete or unhealthy. pub(crate) world_shard_set_error: Option, - /// A configured Realm-core outage, mutable so a recovery test can restore the same authority. pub(crate) realm_error: std::sync::Mutex>, - /// When set, `settle_home_shard` fails with this message (a transfer that could not be - /// driven — an unreachable destination shard, a refused import). pub(crate) settle_error: Option, - /// How many `settle_home_shard` calls SUCCEED before `settle_error` starts firing. 0 - /// (derive-Default) = the very first one fails, i.e. the login-time failure the transfer - /// test drives. - /// 1 = the login routes fine and the WORLD-PORT's settle is the one that cannot be driven — - /// the case that hung a real client on its loading screen forever. pub(crate) settle_ok_calls: usize, - /// How many times `settle_home_shard` has been asked (drives `settle_ok_calls`). pub(crate) settle_calls: std::sync::atomic::AtomicUsize, - /// Accounts `bind_shard_session` was called for, per shard. pub(crate) bound_sessions: std::sync::Mutex>, - /// Whether this character still had an addressable viewer at each transfer-resolution call. pub(crate) viewer_present_at_settle: std::sync::Mutex>, } @@ -62,8 +24,6 @@ impl ShardRoutingStore for WorldFake { &self.topology.shard } - // The Fake enforces the Module's escrow guards so transfer ordering affects outcomes. - fn settle_home_shard( &self, character_guid: u64, @@ -135,7 +95,6 @@ impl ShardRoutingStore for WorldFake { } impl WorldFake { - /// Record one player-scoped call against THIS handle's shard. pub(crate) fn rec(&self, what: &str) { self.topology .calls diff --git a/gateway/src/world/test_support/world_fake/auction.rs b/gateway/src/world/test_support/world_fake/auction.rs index f42a7f445..e98007dc8 100644 --- a/gateway/src/world/test_support/world_fake/auction.rs +++ b/gateway/src/world/test_support/world_fake/auction.rs @@ -3,7 +3,6 @@ use crate::world::Actor; #[derive(Default)] pub(crate) struct AuctionState { - /// The auctioneer's house and faction verdict returned to the focused auction seam. pub(crate) auction_interaction: Option, } diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index 9aeee90e8..b73b34323 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -2,15 +2,10 @@ use super::super::*; #[derive(Default)] pub(crate) struct CastState { - /// Imported item templates the query path resolves by entry. Keeping this keyed fixture here - /// makes socket tests exercise the same durable read shape as the Coordinator cache. pub(crate) item_templates: Vec, - /// The caller's owned items, read by world entry. pub(crate) player_items_fixture: Vec, } -/// Cast behaviour is tested through `InMemoryCasts`. Here every cast operation succeeds and every -/// spell reads as an unknown, ordinary one; only the two item reads world entry makes are live. impl CastStore for WorldFake { fn cast_item_target(&self, _actor: Actor, _spell_id: u32, _slot: u8) -> Result<()> { Ok(()) @@ -76,8 +71,6 @@ impl CastStore for WorldFake { Ok(()) } - // Shared with the character, vendor and query paths, so these two keep real fixtures. - fn player_items(&self, _owner_guid: u64) -> Result> { if let Some(state) = &self.benilla_gameplay { return Ok(state.lock().unwrap().inventory.clone()); diff --git a/gateway/src/world/test_support/world_fake/character.rs b/gateway/src/world/test_support/world_fake/character.rs index 6e2b0726b..1f8c63088 100644 --- a/gateway/src/world/test_support/world_fake/character.rs +++ b/gateway/src/world/test_support/world_fake/character.rs @@ -2,27 +2,14 @@ use super::super::*; #[derive(Default)] pub(crate) struct CharacterState { - /// When set, `character_by_guid` cannot answer from this Shard. pub(crate) character_read_error: Option, - /// Recorded `delete_character` calls: (account_id, character_guid). pub(crate) deleted: std::sync::Mutex>, - /// When set, `delete_character` returns this outcome instead of `Success`. pub(crate) delete_outcome: Option, pub(crate) created_characters: std::sync::Mutex>, - /// The Nth guid `create_character` assigns, offset well above every hand-seeded fixture - /// guid in this file (the highest is 100, in the transfer tests) so it can never collide. pub(crate) next_created_guid: std::sync::atomic::AtomicU64, - /// Reputation standings `player_reputations` returns — `(reputation_index, standing)` pairs folded - /// into the login SMSG_INITIALIZE_FACTIONS (restoring persisted standings instead of the - /// all-neutral stub). pub(crate) reputations: Vec<(i32, i32, bool)>, - /// Imported action-bar rows `player_actions` returns — `(button, action, action_type)` triples. - /// Empty by default (the pre-import fallback path). pub(crate) player_actions: Vec<(u8, u32, u8)>, - /// The character's spellbook, as `player_learned_spells` reports it. Empty by default; a - /// proficiency test seeds the passive the trainer buy is meant to have granted. pub(crate) learned_spells: Vec, - /// How many times a caller asked for the two-snapshot durable absence check. pub(crate) durable_absence_checks: std::sync::atomic::AtomicUsize, } @@ -127,7 +114,6 @@ impl CharacterStore for WorldFake { } fn effective_armor(&self, _guid: u64) -> u32 { - // No gear/auras in the test store → effective == the login entity's armor. self.session .login_entity .as_ref() @@ -140,7 +126,7 @@ impl CharacterStore for WorldFake { } fn spell_modifiers(&self, _character_guid: u64) -> Vec<(u32, u8, i32, bool)> { - Vec::new() // no modifier packets in the harness (login stays byte-identical) + Vec::new() } fn player_learned_spells(&self, _player_guid: u64) -> Result> { diff --git a/gateway/src/world/test_support/world_fake/chat.rs b/gateway/src/world/test_support/world_fake/chat.rs index 4ecb85975..5256de913 100644 --- a/gateway/src/world/test_support/world_fake/chat.rs +++ b/gateway/src/world/test_support/world_fake/chat.rs @@ -2,13 +2,9 @@ use super::super::*; #[derive(Default)] pub(crate) struct ChatState { - /// What `speaker_facts` answers for every speaker. `None` models a speaker with no live entity. pub(crate) speaker_facts: Option, - /// Every Character's GM level, as `speaker_gm_level` reads it. pub(crate) gm_level: u8, - /// Recorded `realm_chat` requests, with the speaker guid the session authenticated. pub(crate) realm_chats: std::sync::Mutex>, - /// Recorded `realm_whisper` requests, with the speaker guid the session authenticated. pub(crate) realm_whispers: std::sync::Mutex>, } @@ -38,8 +34,6 @@ impl ChatActionStore for WorldFake { whisper::target_facts(self, speaker_guid, typed_name) } - /// The Module's `realm_whisper`, modelled: it records what the Gateway conveyed before it - /// answers, because the speaker guid is the whole authorization of the call. fn realm_whisper(&self, actor: Actor, request: WhisperRequest) -> Result { self.rec("realm_whisper"); self.chat diff --git a/gateway/src/world/test_support/world_fake/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs index 284825f46..d8093d81a 100644 --- a/gateway/src/world/test_support/world_fake/combat.rs +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -3,8 +3,6 @@ use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct CombatState { - /// When set, `set_target` fails as a Transport Loss before the reducer can complete. This - /// models the call pipe whose transport dies while an admitted World Session is in flight. pub(crate) set_target_transport_lost: bool, } diff --git a/gateway/src/world/test_support/world_fake/death.rs b/gateway/src/world/test_support/world_fake/death.rs index c80e7de67..3df1c45b2 100644 --- a/gateway/src/world/test_support/world_fake/death.rs +++ b/gateway/src/world/test_support/world_fake/death.rs @@ -1,7 +1,5 @@ use super::super::*; -/// Death requests are tested against `HandleLootFake`. A World Session on this Fake answers -/// every one with success and no effect. impl DeathStore for WorldFake { fn repop(&self, _actor: Actor) -> Result<()> { Ok(()) diff --git a/gateway/src/world/test_support/world_fake/guild.rs b/gateway/src/world/test_support/world_fake/guild.rs index 48826de5d..3166e4a87 100644 --- a/gateway/src/world/test_support/world_fake/guild.rs +++ b/gateway/src/world/test_support/world_fake/guild.rs @@ -2,15 +2,10 @@ use super::super::*; #[derive(Default)] pub(crate) struct GuildState { - /// Realm-core Guilds the guild query answers from. pub(crate) guilds: Vec, - /// Realm-core member rows. Guild ops are recorded in `calls` as `guild_op:`. pub(crate) guild_memberships: Vec, - /// A Fee Hold left on this Home Shard. Fee steps are recorded in `calls` as `guild_fee_`. pub(crate) guild_fee_hold: std::sync::Mutex>, - /// Realm-core Petitions. pub(crate) guild_petitions: Vec, - /// A Character whose guild lookup fails, as when one Realm-core read errors. pub(crate) guild_lookup_error_for: Option, } @@ -187,7 +182,6 @@ impl GuildActionStore for WorldFake { } } -/// Realm-core refuses every fee: the socket tests only watch a leftover hold finish. impl crate::world::guild_fee::GuildFeeStore for WorldFake { fn guild_fee_held(&self, _actor: Actor) -> Result> { Ok(self.guild.guild_fee_hold.lock().unwrap().clone()) diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index 767ac87fe..86328e81f 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -1,6 +1,5 @@ use super::super::*; -/// Item actions shared by the family and Benilla World Session tests. impl ItemActionStore for WorldFake { fn destroy_item(&self, _actor: Actor, _slot: u8, _count: u32) -> Result { Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) diff --git a/gateway/src/world/test_support/world_fake/loot_roll.rs b/gateway/src/world/test_support/world_fake/loot_roll.rs index b0c3bc7a4..646970bb9 100644 --- a/gateway/src/world/test_support/world_fake/loot_roll.rs +++ b/gateway/src/world/test_support/world_fake/loot_roll.rs @@ -3,16 +3,10 @@ use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct LootRollState { - /// Recorded `loot_roll` calls: (corpse_guid, loot_slot, vote). pub(crate) loot_rolls: std::sync::Mutex>, - /// Recorded `loot_master_give` calls: (corpse_guid, loot_slot, target_guid). pub(crate) loot_master_gives: std::sync::Mutex>, - /// Typed gameplay Refusal returned by loot-roll and master-loot action tests. pub(crate) loot_action_refusal: Option, - /// When set, loot-roll and master-loot requests fail as a Transport Loss. pub(crate) loot_action_transport_lost: bool, - /// Records every `realm_loot_op` reducer argument in wire order. The Realm-core handle owns the - /// recorder so a test can distinguish authority routing from a Shard-local request. #[allow(clippy::type_complexity)] pub(crate) realm_loot_ops: std::sync::Mutex< Vec<( @@ -29,25 +23,11 @@ pub(crate) struct LootRollState { u64, )>, >, - /// When set, `realm_loot_start` is refused with this reason. pub(crate) realm_loot_op_refusal: Option, - /// This WORLD SHARD's staging rolls `pending_local_rolls` answers — the relay's promotion - /// INPUT. `Mutex`-wrapped (like `mirror`) so a test can set it AFTER the fixture - /// is wrapped in an `Arc` — every existing party/whisper topology builder hands back `Arc`s. - /// Empty (derive-Default) = nothing to promote, byte-identical to before this field existed. pub(crate) pending_rolls: std::sync::Mutex>, - /// Recorded `settle_loot_roll` calls on THIS shard — `(corpse_guid, slot, winner_guid)`. pub(crate) settled_rolls: std::sync::Mutex>, - /// When set, `settle_loot_roll` fails as a Transport Loss. pub(crate) settle_loot_roll_transport_lost: bool, - /// Recorded `clear_promoted_loot_roll` calls on THIS shard — the roll ids the relay told - /// this shard's staging copy to forget after a successful promotion. pub(crate) cleared_rolls: std::sync::Mutex>, - /// This REALM-CORE handle's fixture `ROLL_WON` queue — `(corpse_guid, slot, winner_guid)` - /// triples, in the order they "arrived". `loot_won_since(after_id)` answers every entry whose - /// 1-based INDEX exceeds `after_id`, and the new watermark is the queue's length — the same - /// shape the real `game_group_event.id` high-water mark has, without needing a fake event table. - /// `Mutex`-wrapped for the same after-`Arc`-construction reason as `pending_rolls`. pub(crate) won_events: std::sync::Mutex>, } diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs index 1d9e261d6..0950582ae 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -2,16 +2,10 @@ use super::super::*; #[derive(Default)] pub(crate) struct LootWindowState { - /// The lootable copper `loot_target_money` reports for any target (default 0). pub(crate) corpse_money: u32, - /// Recorded `loot_money` targets — CMSG_LOOT_MONEY must drive the TRACKED guid. pub(crate) money_looted: std::sync::Mutex>, - /// Recorded `take_loot` calls as (target guid, slot): a take must act on the open target. pub(crate) items_taken: std::sync::Mutex>, - /// Recorded `use_gameobject` target guids. pub(crate) gameobjects_used: std::sync::Mutex>, - /// Per-VIEWER corpse loot fixture for `corpse_loot(corpse_guid, viewer_guid)`, keyed by viewer - /// guid. Empty by default, so a test that never sets it sees an empty window. pub(crate) corpse_loot_by_viewer: std::collections::HashMap>, } diff --git a/gateway/src/world/test_support/world_fake/mail.rs b/gateway/src/world/test_support/world_fake/mail.rs index d9161528a..7c66253b5 100644 --- a/gateway/src/world/test_support/world_fake/mail.rs +++ b/gateway/src/world/test_support/world_fake/mail.rs @@ -1,12 +1,9 @@ use super::super::*; -/// The Module refusing a mail reducer with `reason`, as the Fake's reducer edge reports it. fn refused(reason: impl std::fmt::Display) -> anyhow::Error { crate::stdb::ReducerCallError::refused("mail", &reason.to_string()).into() } -/// The Module's Delivery Delay rule (`module/src/mail.rs::delivery_delay_secs`): an item sent to -/// another Realm Account waits one hour, and everything else arrives at once. pub(crate) fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { if has_item && !same_account { 3_600 @@ -15,7 +12,6 @@ pub(crate) fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { } } -/// A mail's `deliver_secs` when it arrives `delay_secs` from now. 0 means from creation. pub(crate) fn delivered_after(delay_secs: u32) -> i64 { if delay_secs == 0 { 0 @@ -26,62 +22,23 @@ pub(crate) fn delivered_after(delay_secs: u32) -> i64 { #[derive(Default)] pub(crate) struct MailState { - /// The mail rows on THIS database, as `(recipient_guid, row)`. The realm handle owns them on a - /// sharded gateway and a world shard's staying empty is how a test tells "the mailbox read went - /// to the authority" from "it quietly went back to being shard-local"; on a single-database - /// gateway the one handle owns them instead. Same fixture either way — that is the point. pub(crate) mails: std::sync::Mutex>, - /// Gameobject guids that ARE a mailbox within reach on this shard. Empty (derive-Default) - /// refuses every mailbox, which is the wrong-map / out-of-range / not-a-mailbox arm. pub(crate) mailboxes: Vec, - /// `game_world_entity.money` per guid, on THIS database — the purse the postage comes out of. - /// A guid with no row here cannot pay, which is also the module's answer for a character with no - /// live entity on the shard being asked. pub(crate) purses: std::sync::Mutex>, - /// Letters written on THIS database: `(sender, recipient, subject, body, attached money)`. - /// Recorded separately from `mails` because WHICH call wrote the row is what tells the two - /// planes apart — `mail_send` on one database, `mail_commit` on the mail plane of a sharded one. #[allow(clippy::type_complexity)] pub(crate) sent_mail: std::sync::Mutex>, - /// The escrow ledger on THIS database, as `(the guid the fence is filed under, the row)`. A - /// letter's fence is filed under its SENDER and lives on their shard; a take's is filed under - /// the PAYEE and lives on the plane holding the mail row. #[allow(clippy::type_complexity)] pub(crate) mail_escrows: std::sync::Mutex>, - /// How many `mail_escrows_of` reads answer empty before a just-filed fence shows up. The same - /// cross-connection lag `escrow_reads_before_visible` dials in for `escrowed_transfer`, made - /// dialable for the mail escrow read `held_fence` polls. pub(crate) mail_escrow_reads_before_visible: std::sync::atomic::AtomicUsize, - /// The Realm Account name THIS Shard holds per Character guid. A Character missing here is - /// one this Shard cannot name: absent, or on a shadow Account. pub(crate) realm_accounts: std::sync::Mutex>, - /// The `same_account` each mail send, fence and return carried, in call order. pub(crate) same_account_seen: std::sync::Mutex>, - /// `game_mail_escrow.delivered` per escrow id: the attestation that licenses the settle. Kept - /// beside the fence rather than in it so the fake cannot settle one it never attested. pub(crate) attested: std::sync::Mutex>, - /// Delivery/payout receipts on THIS database, `(escrow_id, recipient or payee)` — the - /// idempotency key that makes a replayed commit or payout a no-op. pub(crate) mail_receipts: std::sync::Mutex>, - /// `game_item_text` on THIS database: `(item_text_id, text)`. Filed by `mail_copy_text`, read - /// back by `item_text` — the mail plane's half of a Letter Copy. pub(crate) item_texts: std::sync::Mutex>, - /// Plain Letters `mail_grant_letter` granted on THIS database: `(payee_guid, item_text_id)`. - /// Not `mail_items` — a Letter Copy mints a fresh item, it never moves one out of a mail row. pub(crate) granted_letters: std::sync::Mutex>, - /// `game_mail.item_text_id` per mail id: the attached Plain Letter's text id. Kept beside - /// `mails` because `MailView` does not carry it. pub(crate) mail_item_text_ids: std::sync::Mutex>, - /// The mail-escrow step to fail on THIS database — a gateway killed before that step's - /// transaction committed. `transfer`'s `kill_at` for the mail drive, and a `Mutex` because a - /// re-drive test has to bring the database back up before driving again. pub(crate) mail_kill_at: std::sync::Mutex>, - /// `game_item_instance` on THIS database: `item_guid -> (owner_guid, snapshot)`. The mail - /// attachment path deletes from here at send and inserts at take, which is the whole "a fenced - /// item is in nobody's bags" property. pub(crate) mail_items: std::sync::Mutex>, - /// This shard's bags have no room — the fixture behind the full-bag refusal on a take. - /// Atomic so a test can flip it AFTER the fixture is wrapped in an `Arc`, like `purses`. pub(crate) bags_full: std::sync::atomic::AtomicBool, } @@ -126,9 +83,6 @@ impl MailStore for WorldFake { Ok(self.mail.mailboxes.contains(&mailbox_guid)) } - /// Models the module's `apply_mark_read`: the row lookup scoped to `recipient_guid` IS the - /// authorization, so a mail that exists but belongs to someone else, or has not arrived yet, - /// fails the same way a nonexistent id does. fn mail_mark_read(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_mark_read"); @@ -146,8 +100,6 @@ impl MailStore for WorldFake { } } - /// Models the module's `apply_delete`: same merged not-found/not-yours/not-arrived refusal as - /// mark-read, and a priced mail is refused. fn mail_delete(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_delete"); @@ -166,11 +118,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models the module's `apply_return`: the SAME row, re-addressed to whoever sent it, with - /// whatever it still carries (or nothing) travelling unchanged — except the cash-on-delivery - /// price, which is dropped, because the row is going back to whoever set it. Only a delivered - /// Character mail with a sender goes back, and only once. An item going back to another - /// Account waits its Delivery Delay. fn mail_return(&self, recipient: Actor, mail_id: u64, same_account: bool) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_return"); @@ -198,9 +145,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models the module's `apply_send`: the postage plus the attached coin leave the purse and the - /// row is written, in ONE call — the single-database plane's one transaction. The id is - /// per-database, as the module's `auto_inc` is. #[allow(clippy::too_many_arguments)] fn mail_send( &self, @@ -239,9 +183,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models the module's `apply_take_item`: the COD debit, the grant, the clear and the seller's - /// payout row are ONE transaction, so a full bag or a price the taker cannot pay leaves the - /// letter exactly as it was, and a second take finds an empty one. fn mail_take_item(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); let (item, settlement) = { @@ -280,8 +221,7 @@ impl MailStore for WorldFake { .map_err(|_| refused(lyracore_shared::mail::COD_NOT_AFFORDABLE))?; } if let Err(e) = self.store_snapshot(recipient_guid, &item) { - // The fake cannot roll back, so it undoes the one write it made — the real module gets - // this from the transaction, and asserting on it is the point of the full-bag test. + // The Fake lacks rollback, so undo this write to match the Module's transaction. if let Some(s) = &settlement { self.credit(s.payer_guid, s.copper); } @@ -329,11 +269,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_text::apply_copy_text`: sets COPIED and files the body as item text, on the - /// database that owns the mail row. Refused for a mail that is not the caller's, is not - /// delivered, has no body, or is already GRANTED — the same Gates the plan function pins. A - /// replay before GRANTED is set is `Ok`, and the text insert is skipped when the id already has - /// a row (a returned mail keeps its id, so a second recipient's copy can reuse it). fn mail_copy_text(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_copy_text"); @@ -362,11 +297,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `items::grant_letter_item`: one Plain Letter, refused by the same full-bag fixture - /// every other grant uses. A no-op when the payee already holds an item carrying `item_text_id` - /// — the crash-window guard for a retry between this call landing and `mail_mark_letter_granted` - /// recording that it did, not the durable "already got one" record (that is GRANTED, on the mail - /// row). fn mail_grant_letter(&self, payee: Actor, item_text_id: u32) -> Result<()> { let payee_guid = payee.guid(); self.rec("mail_grant_letter"); @@ -395,8 +325,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_text::apply_mark_letter_granted`: sets GRANTED on the mail row. Once this lands, - /// `mail_copy_text` refuses for good, whether or not the granted item still exists. fn mail_mark_letter_granted(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_mark_letter_granted"); @@ -411,7 +339,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models the Coordinator's `item_text`: a PK read of `game_item_text` on THIS database. fn item_text(&self, item_text_id: u32) -> Result> { self.rec("item_text"); Ok(self @@ -424,9 +351,6 @@ impl MailStore for WorldFake { .map(|(_, text)| text.clone())) } - /// Models the Coordinator's `owns_item_with_text`: does `owner_guid` hold a granted letter - /// carrying `item_text_id`? The fake has no item-guid fixture to answer `hint_item_guid`'s fast - /// path distinctly, so both routes resolve through the same `granted_letters` lookup. fn owns_item_with_text( &self, owner_guid: u64, @@ -444,8 +368,6 @@ impl MailStore for WorldFake { .any(|&(guid, id)| guid == owner_guid && id == item_text_id)) } - /// Models the module's `apply_take_money`: the credit and the clear are one transaction, so a - /// second take finds an empty row. fn mail_take_money(&self, recipient: Actor, mail_id: u64) -> Result<()> { let recipient_guid = recipient.guid(); self.rec("mail_take_money"); @@ -467,7 +389,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_fence`: the whole cost leaves the purse into a fence row here. fn mail_fence( &self, escrow_id: u64, @@ -488,11 +409,9 @@ impl MailStore for WorldFake { self.mail_kill("mail_fence")?; let escrows = self.mail.mail_escrows.lock().unwrap(); if escrows.iter().any(|(_, e)| e.escrow_id == escrow_id) { - return Ok(()); // replay — the purse must not be debited twice for one letter + return Ok(()); } drop(escrows); - // The attachment before the debit: it is the refusal that can still fire, and nothing has - // been written when it does. let item = self.detach(sender_guid, item_guid)?; self.debit(sender_guid, money.saturating_add(postage))?; let delivery_delay_secs = delivery_delay_secs(!item.is_empty(), same_account); @@ -517,7 +436,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_commit`: the row plus a receipt, idempotent on the escrow id. fn mail_commit( &self, escrow_id: u64, @@ -539,7 +457,6 @@ impl MailStore for WorldFake { if receipts.iter().any(|(id, _)| *id == escrow_id) { return Ok(()); } - // A COD payment pays only a price its payer still owes on a delivered mail. if cod_source_mail_id != 0 { let now = crate::world::mail::now_secs(); let owed = self.mail.mails.lock().unwrap().iter().any(|(to, m)| { @@ -569,7 +486,6 @@ impl MailStore for WorldFake { cod, &item, cod_source_mail_id != 0, - // A payment arrives at once whatever the commit carries. if cod_source_mail_id != 0 { 0 } else { @@ -577,7 +493,6 @@ impl MailStore for WorldFake { }, ); if let Some(header) = reward { - // Models `crate::world::mail::Letter::reward`: from the quest giver, naming its Mail Template. let (sender_kind, sender_guid, sender_entry) = header.giver.sender().columns(); let mut mails = self.mail.mails.lock().unwrap(); if let Some((_, m)) = mails.iter_mut().max_by_key(|(_, m)| m.id) { @@ -588,9 +503,6 @@ impl MailStore for WorldFake { m.check_flags = lyracore_shared::mail::CHECK_MASK_HAS_BODY; } } - // The price stops being owed in the SAME call that delivers the payment for it — the - // module clears it inside the commit's transaction, which is what makes a COD take charge - // once however the drive is interrupted. if cod_source_mail_id != 0 { if let Some((_, m)) = self .mail @@ -606,7 +518,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_take_fence`: the copper leaves the ROW into a fence here. fn mail_take_money_fence( &self, escrow_id: u64, @@ -667,7 +578,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_take_item_fence`: the ATTACHMENT leaves the row into a fence. fn mail_take_item_fence( &self, escrow_id: u64, @@ -742,8 +652,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_item_payout`: the grant plus a receipt, idempotent on the escrow - /// id, and refused by a full bag — which leaves the fence holding the item. fn mail_item_payout( &self, escrow_id: u64, @@ -768,7 +676,6 @@ impl MailStore for WorldFake { Ok(()) } - /// Models `mail_escrow::apply_payout`: the credit plus a receipt, idempotent on the escrow id. fn mail_payout(&self, escrow_id: u64, payee: Actor, _mail_id: u64, amount: u32) -> Result<()> { let payee_guid = payee.guid(); self.rec("mail_payout"); @@ -808,7 +715,6 @@ impl MailStore for WorldFake { } } - /// Models `mail_escrow::apply_settle`, delete-last included: it REFUSES while unattested. fn mail_settle(&self, escrow_id: u64) -> Result<()> { self.rec("mail_settle"); self.mail_kill("mail_settle")?; @@ -821,7 +727,7 @@ impl MailStore for WorldFake { .find(|(id, _)| *id == escrow_id) .map(|(_, done)| *done); match attested { - None => Ok(()), // already settled, or this call reached the wrong database + None => Ok(()), Some(false) => Err(refused(format!( "mail escrow {escrow_id}: delivery not attested — refusing to destroy the fence" ))), @@ -853,7 +759,7 @@ impl MailStore for WorldFake { ) .is_ok() { - return Ok(Vec::new()); // the coordinator cache has not caught up yet + return Ok(Vec::new()); } Ok(self .mail @@ -868,8 +774,6 @@ impl MailStore for WorldFake { } impl WorldFake { - /// One mail-escrow step boundary. `Err` is the gateway dying before this step committed: the - /// call never lands, and nothing after it in the drive runs either. pub(crate) fn mail_kill(&self, step: &str) -> Result<()> { if self.mail.mail_kill_at.lock().unwrap().as_deref() == Some(step) { log::info!( @@ -881,8 +785,6 @@ impl WorldFake { Ok(()) } - /// Take `copper` out of a purse on THIS database, or refuse and take nothing — the module's - /// `charge_postage`, including its "no live entity here" arm for a guid with no purse row. pub(crate) fn debit(&self, guid: u64, copper: u32) -> Result<()> { let mut purses = self.mail.purses.lock().unwrap(); match purses.iter_mut().find(|(g, _)| *g == guid) { @@ -901,8 +803,6 @@ impl WorldFake { } } - /// The module's `detach_item`: the mailable verdict, then the DELETE. `item_guid` 0 is a letter - /// with no attachment. pub(crate) fn detach( &self, sender_guid: u64, @@ -924,8 +824,6 @@ impl WorldFake { } } - /// The module's `store_instance_state`: one new row carrying the recorded state, or the item - /// module's own full-bag refusal. pub(crate) fn store_snapshot( &self, owner_guid: u64, @@ -944,8 +842,6 @@ impl WorldFake { Ok(()) } - /// Every item `owner` holds on this database — the assertion surface for "it left the bags", - /// "it arrived unchanged" and "it never arrived twice". pub(crate) fn bags_of(&self, owner: u64) -> Vec { self.mail .mail_items @@ -957,10 +853,6 @@ impl WorldFake { .collect() } - /// The module's `insert_letter` for a Character's letter: the row both write paths reach, so a - /// letter written by the single-database send and one written by the escrow's commit cannot - /// differ. It has HAS_BODY with a body and COPIED without one, like `Letter::from_character`, - /// and only COD_PAYMENT when it pays a price. It arrives `delay_secs` from now. #[allow(clippy::too_many_arguments)] pub(crate) fn write_mail( &self, @@ -1014,7 +906,6 @@ impl WorldFake { } } - /// The text id of the letter attached to mail `mail_id`, 0 for any other attachment. pub(crate) fn attached_text_id(&self, mail_id: u64) -> u32 { self.mail .mail_item_text_ids @@ -1025,7 +916,6 @@ impl WorldFake { .map_or(0, |(_, text_id)| *text_id) } - /// Take the attached letter's text id off mail `mail_id`, as the take clears the attachment. pub(crate) fn take_attached_text_id(&self, mail_id: u64) -> u32 { let mut ids = self.mail.mail_item_text_ids.lock().unwrap(); let Some(at) = ids.iter().position(|(id, _)| *id == mail_id) else { diff --git a/gateway/src/world/test_support/world_fake/meeting_stone.rs b/gateway/src/world/test_support/world_fake/meeting_stone.rs index 916b8d07f..8a533c16d 100644 --- a/gateway/src/world/test_support/world_fake/meeting_stone.rs +++ b/gateway/src/world/test_support/world_fake/meeting_stone.rs @@ -1,7 +1,5 @@ use super::super::*; -/// The Meeting Stone family is tested against its own Fake in `handlers/meeting_stone.rs`. Here no -/// stone exists and nobody is queued. impl MeetingStoneActionStore for WorldFake { fn admit_meeting_stone(&self, _actor: Actor, _go_guid: u64) -> Result { Ok(MeetingStoneOutcome::Refused( diff --git a/gateway/src/world/test_support/world_fake/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs index 473e33556..cc666eab2 100644 --- a/gateway/src/world/test_support/world_fake/melee.rs +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -1,7 +1,5 @@ use super::super::*; -/// Melee behaviour is tested through `InMemoryMeleeActions`. The shard routing tests read the -/// `start_attack` entry in the topology call log, so that one call stays recorded. impl MeleeActionStore for WorldFake { fn start_attack(&self, _actor: Actor, target_guid: u64) -> Result<()> { self.rec("start_attack"); diff --git a/gateway/src/world/test_support/world_fake/member_stats.rs b/gateway/src/world/test_support/world_fake/member_stats.rs index 204f4c280..14d7affd1 100644 --- a/gateway/src/world/test_support/world_fake/member_stats.rs +++ b/gateway/src/world/test_support/world_fake/member_stats.rs @@ -2,13 +2,9 @@ use super::super::*; #[derive(Default)] pub(crate) struct MemberStatsState { - /// How many `member_presence` reads reached this handle. pub(crate) member_presence_reads: std::sync::atomic::AtomicUsize, } -/// Member Stats over the same party state the routing tests use: Realm-core's `party` when this -/// handle has a realm, its own `mirror` on a single database. Presence reads this shard and its -/// peers, every World Shard, like `Coordinator::member_presence`. impl MemberStatsStore for WorldFake { fn group_mates(&self, self_guid: u64) -> Result> { let roster = match &self.topology.realm { @@ -39,11 +35,6 @@ impl MemberStatsStore for WorldFake { self.member_stats .member_presence_reads .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Aura slots and a live pet are Member Stats' own overlay in production - // (`Coordinator::with_member_shard_stats`, keyed by `ShardId`) — this Fake has no - // `AuraIndex` to key into, so `entity` carries whatever `member_entities`/`live_entity`'s - // fallback already gave it (empty auras, no pet, unless a test seeded `member_entities` - // with its own). Ok( match presence::of(self, guid)?.map(|presence| presence.whereabouts) { Some(presence::Whereabouts::InWorld { entity, .. }) => { diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index dbe4f62de..2b0137999 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -1,5 +1,3 @@ -//! [`WorldFake`]: one sub-state per Store family, each in its family's file with its impl. - mod auction; mod bank; mod cast; @@ -55,19 +53,12 @@ pub(crate) use self::weather::*; use super::TopologyState; use crate::world::codec; -/// The Fake behind World Session socket tests. It implements every Store family, so one handle -/// serves a whole session, and several handles wired through `topology` model a sharded Realm. -/// Each family keeps its state in its own field, defined beside that family's impl. #[derive(Default)] pub(crate) struct WorldFake { pub(crate) benilla_gameplay: Option>, - /// The Characters this Shard holds. Several families read it. pub(crate) characters: Vec, - /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. pub(crate) npc_refuses: bool, - /// When set, Durable Requests in several families fail with this error: a Refusal tag, or any - /// other text for a failure with an unknown durable result. pub(crate) trade_error: Option, pub(crate) topology: TopologyState, pub(crate) session: SessionState, diff --git a/gateway/src/world/test_support/world_fake/npc.rs b/gateway/src/world/test_support/world_fake/npc.rs index cdc02e509..a263ab606 100644 --- a/gateway/src/world/test_support/world_fake/npc.rs +++ b/gateway/src/world/test_support/world_fake/npc.rs @@ -3,23 +3,12 @@ use crate::stdb::ReducerCallError; #[derive(Default)] pub(crate) struct NpcState { - /// `npc_is_innkeeper` flag for the gossip bind-home routing. pub(crate) innkeeper: bool, - /// Whether `bind_home` ran (the innkeeper gossip select). pub(crate) home_bound: std::sync::atomic::AtomicBool, - /// Imported gossip menu options `gossip_options` returns for ANY npc_guid — empty - /// by default (the pre-import fallback path). pub(crate) gossip_opts: Vec, - /// Recorded `gossip_select` notifications as `(option_id, option_row_id)` — the clicked POSITION - /// and the stable row identity the module is told about. pub(crate) gossip_selects: std::sync::Mutex>, - /// The `npc_text_for_id` view `npc_text_for_id` returns for ANY text_id — `None` by default (the - /// generic-greeting fallback), settable per-test for the 8-slot pin coverage. pub(crate) npc_text_view: Option, - /// Spawned GameObject type returned for the `CMSG_GAMEOBJ_USE` questgiver classification. pub(crate) gameobject_type: Option, - /// When set, `inspect` fails with this error: a Refusal the client never hears about, or a - /// Transport Loss that ends the World Session. pub(crate) inspect_error: Option anyhow::Error>, } @@ -50,7 +39,7 @@ impl NpcStore for WorldFake { } fn npc_refuses_interaction(&self, _npc_guid: u64, _player_guid: u64) -> Result { - Ok(self.npc_refuses) // default false — every existing fixture NPC keeps interacting + Ok(self.npc_refuses) } fn bind_home(&self, _actor: Actor, _innkeeper_guid: u64) -> Result { @@ -70,7 +59,7 @@ impl NpcStore for WorldFake { } fn npc_gossip_text_id(&self, _npc_guid: u64) -> u32 { - 1 // generic fallback for tests + 1 } fn npc_text_for_id(&self, _text_id: u32) -> Option { @@ -88,9 +77,6 @@ impl NpcStore for WorldFake { if let Some(reason) = &self.trade_error { return Err(ReducerCallError::refused("gw_inspect", reason).into()); } - // Mirrors the module gate's own-map/in-range/friendly checks with a fixed stub: any nonzero - // guid "passes" (in range + friendly) so a test can drive both the ack and the ignore path via - // `trade_error`; a 0 guid stands in for "no such target". if target_guid == 0 { return Err(ReducerCallError::refused("gw_inspect", "no such inspect target").into()); } diff --git a/gateway/src/world/test_support/world_fake/party.rs b/gateway/src/world/test_support/world_fake/party.rs index 1f52e082e..a31d1f81d 100644 --- a/gateway/src/world/test_support/world_fake/party.rs +++ b/gateway/src/world/test_support/world_fake/party.rs @@ -3,59 +3,28 @@ use super::super::*; #[derive(Default)] pub(crate) struct PartyState { pub(crate) group_invites: std::sync::Mutex>, - /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). pub(crate) group_loot_methods: std::sync::Mutex>, - /// Unclaimed bot invite intent ids on this World Shard. Two concurrent consumers share this - /// collection, matching the Module table both Gateways call into. pub(crate) bot_invite_intents: std::sync::Mutex>, - /// Current consent returned by the owned admission seam, independent of presence reads. pub(crate) sessionless_admission: std::sync::Mutex>, pub(crate) sessionless_admission_unavailable: std::sync::Mutex>, pub(crate) suppress_after_admission: std::sync::Mutex>, pub(crate) bot_intent_claim_refusals: std::sync::Mutex>, - /// The AUTHORITATIVE party state, when this handle is the realm-core one. Shared with - /// nobody — a realm handle owns exactly one of these, and every shard reads its own `mirror`. pub(crate) party: std::sync::Arc>, - /// Realm-core's Coordinator cache lags a call-pipe commit: after `realm_group_op`, roster reads - /// answer from `stale_party` until a `realm_group_op_visible` returns. The production shape of - /// a call pipe, which subscribes no group table. pub(crate) cache_lags: std::sync::atomic::AtomicBool, - /// The party as the lagging cache still shows it. pub(crate) stale_party: std::sync::Mutex>, - /// True when this handle is realm-core, so `group_roster` answers from `party` (the - /// authority) instead of `mirror` (this shard's cache of it). pub(crate) is_realm: bool, - /// What `sync_group_mirror` wrote onto THIS shard, latest per group. The invalidation - /// story, made observable. pub(crate) mirror: std::sync::Mutex>, - /// The Roster Revision each `sync_group_mirror` accepted on THIS shard, kept after the disband - /// tombstone, as the Module's mirror keeps its `game_group_roster_revision` row. pub(crate) mirror_revisions: std::sync::Mutex>, - /// When set, `sync_group_mirror` fails with this message — a world shard that cannot be - /// mirrored (an unreachable database), which must not fail a party op realm-core already took. pub(crate) mirror_error: Option, - /// How many mirror writes fail before this Shard accepts one. pub(crate) mirror_failures: std::sync::atomic::AtomicUsize, - /// When set, `realm_group_op(ACCEPT, …)` fails with this message. INJECTED because a real - /// one cannot be staged synchronously: every accept-time refusal the module has (already grouped, - /// party full, the inviter no longer leads) needs the party to change BETWEEN the invite and the - /// accept, and the gateway's bot answer runs in the same call as the invite. The failure is still - /// reachable in production — a concurrent op on another socket — and what it must not do is leave - /// the invite dialog hanging. pub(crate) party_accept_error: Option, - /// How many Realm-core LEAVE calls fail before one reaches the party state. pub(crate) party_leave_failures: std::sync::atomic::AtomicUsize, - /// When set, every Group Broadcast op fails as a lost connection would. pub(crate) group_broadcast_error: bool, - /// Return a connection failure after the next Realm-core LEAVE commits. pub(crate) party_leave_commit_then_error: std::sync::atomic::AtomicBool, - /// Fail one `group_roster` read by its one-based call number. pub(crate) group_roster_error_on_read: std::sync::Mutex>, - /// Number of `group_roster` reads on this handle. pub(crate) group_roster_reads: std::sync::atomic::AtomicUsize, - /// When set, this handle cannot enumerate its mirrored party ids. pub(crate) party_group_ids_error: std::sync::Mutex>, pub(crate) party_command_claims: std::sync::Mutex>, pub(crate) party_command_finishes: @@ -74,12 +43,9 @@ pub(crate) struct PartyState { )>, >, pub(crate) entity_partitions: std::sync::Mutex>, - /// Characters Realm-core reports in a pending Transfer. Read on the realm handle only. pub(crate) members_in_transit: std::sync::Mutex>, } -/// The Fake's reducer edge for a party op: the Module answers a Refusal as the bare tag, and -/// anything else — a timeout, a dead transport — is a failure with an unknown durable outcome. pub(crate) fn faked_party(operation: &str, error: &str) -> Result { match lyracore_shared::group::GroupRefusal::parse_tag(error) { Some(refusal) => Ok(refusal.into()), @@ -92,11 +58,6 @@ impl PartyStore for WorldFake { &self, character_guid: u64, ) -> Result> { - // `members_in_transit` is Member Stats' own per-guid fixture (`presence::of`'s - // `realm_transfer_pending` reads this trait method on the realm handle, same as - // `Coordinator::member_presence` did before the two merged). Checked first so it can name - // one guid as pending without disturbing `realm_partition`, which every other test that - // exercises a real Transfer already drives. if self .party .members_in_transit @@ -134,12 +95,6 @@ impl PartyStore for WorldFake { }) } - // Group: a minimal in-memory party — enough for the dispatch tests to drive - // invite-result mapping and the GROUP_LIST build without a live module. - // - // Each of these records the SHARD it ran on (`rec`), so a test can tell the - // single-database path (the op lands on the player's own shard, here) apart from the realm-core - // one (it lands in `FakeParty::ops` and never reaches these at all). fn group_invite(&self, _actor: Actor, target_guid: u64) -> Result { self.rec("group_invite"); if let Some(e) = &self.trade_error { @@ -385,9 +340,7 @@ impl PartyStore for WorldFake { Ok(PartyOutcome::Ran) } - /// The module's `realm_group_op`, modelled: the rules the ROUTING depends on, applied to the - /// authority this handle owns. - #[allow(clippy::too_many_lines)] // One arm per realm group op, as the Module's reducer has. + #[allow(clippy::too_many_lines)] fn realm_group_op( &self, op: u8, @@ -449,7 +402,6 @@ impl PartyStore for WorldFake { None => { p.next_group_id += 1; let g = p.next_group_id; - // The vanilla defaults a freshly-formed party gets: GROUP loot, Uncommon. p.groups.push((g, inviter, 3, 2, 0)); p.members.push((g, inviter)); g @@ -587,8 +539,6 @@ impl PartyStore for WorldFake { return Ok(p.change_subgroup(actor_guid, target_guid, arg_a)) } realm_op::SWAP_SUBGROUP => return Ok(p.swap_subgroup(actor_guid, target_guid, arg_c)), - // Group Broadcasts. Who hears each one is the Module's rule; the routing needs only - // the op on the authority, and a Refusal for a member of no group. realm_op::READY_CHECK_START..=realm_op::RANDOM_ROLL if self.party.group_broadcast_error => { @@ -608,7 +558,6 @@ impl PartyStore for WorldFake { Ok(PartyOutcome::Ran) } - /// The visibility receipt: the op commits, then the Coordinator cache holds it. fn realm_group_op_visible( &self, op: u8, @@ -783,8 +732,6 @@ impl PartyStore for WorldFake { .insert(roster.group_id, roster.roster_revision); let mut mirror = self.party.mirror.lock().unwrap(); mirror.retain(|r| r.group_id != roster.group_id); - // An empty roster is the disband tombstone — the shard forgets the party rather than - // keeping an empty one, which is what the module's own `sync_group_mirror` does. if !roster.members.is_empty() { mirror.push(roster.clone()); } @@ -797,36 +744,16 @@ impl PartyStore for WorldFake { } } -/// Realm-core's authoritative party state, as far as the gateway's routing can see it — -/// the module's `realm_group_op` rules, modelled at the granularity the ROUTING depends on: -/// which database the op lands on, who ends up in which group, and who gets notified. -/// -/// Same instrument, and same limits, as [`FakeShardDb`]: the module's own reducer bodies cannot run -/// in a gateway test (no `ReducerContext`), so what executes here is the gateway's production -/// routing (`world::party`) against a faithful stand-in for the authority. The rules themselves are -/// the module's to test. #[derive(Default, Clone)] pub(crate) struct FakeParty { pub(crate) next_group_id: u64, - /// group_id → (leader, loot_method, loot_threshold, master_looter). pub(crate) groups: Vec<(u64, u64, u8, u8, u64)>, - /// (group_id, character_guid), in join order — the order realm-core's roster read returns. pub(crate) members: Vec<(u64, u64)>, - /// (target_guid, inviter_guid) — at most one pending per target, newest wins. pub(crate) invites: Vec<(u64, u64)>, - /// Groups their leader converted to a Raid. pub(crate) raids: Vec, - /// Each Raid member's Raid Slot; a member absent here holds the Party default. pub(crate) slots: std::collections::HashMap, - /// group_id → how many times [`Self::push_list`] fired for it — this Fake's stand-in for the - /// Roster Revision. A group absent here has never had a list pushed. Bumped exactly where a - /// real accepted change would advance the revision, so [`crate::world::party::roster_unchanged`]'s - /// no-mirror-push optimization is exercised against a real signal rather than a constant. pub(crate) revisions: std::collections::HashMap, - /// Every op that reached the AUTHORITY: `(op, actor, target, arg_a, arg_b, arg_c)`. The - /// assertion that a party op ran on realm-core rather than on the player's shard. pub(crate) ops: Vec<(u8, u64, u64, u8, u8, u64)>, - /// Every notification the authority pushed: `(recipient_guid, kind)` — the relay's input. pub(crate) events: Vec<(u64, u8)>, } @@ -854,7 +781,6 @@ impl FakeParty { .collect() } - /// The Module's own placement rule, `RaidSlot::for_raid_joiner`, over this Fake's slots. pub(crate) fn joining_slot(&self, group_id: u64) -> RaidSlot { if self.kind_of(group_id) == GroupKind::Party { return RaidSlot::default(); @@ -896,7 +822,6 @@ impl FakeParty { .map_or(0, |group| group.1) } - /// The leader, or an Assistant. pub(crate) fn manages(&self, group_id: u64, guid: u64) -> bool { self.leader_of(group_id) == guid || self @@ -905,8 +830,6 @@ impl FakeParty { .is_some_and(|slot| slot.is_assistant()) } - /// The Module's `change_subgroup_on`, modelled: leader-or-Assistant, Raid-only, capacity - /// decided by the shared [`RaidSlot::moved_to_subgroup`] rule. pub(crate) fn change_subgroup( &mut self, actor_guid: u64, @@ -952,8 +875,6 @@ impl FakeParty { } } - /// The Module's `swap_subgroup_on`, modelled: same gates as [`Self::change_subgroup`], the - /// shared [`RaidSlot::swapped_with`] rule, and no capacity Gate. pub(crate) fn swap_subgroup( &mut self, actor_guid: u64, @@ -985,8 +906,6 @@ impl FakeParty { PartyOutcome::Ran } - /// Hand the lead to `leader` and announce it to every member, as the Module does, before the - /// list. pub(crate) fn set_leader(&mut self, group_id: u64, leader: u64) { if let Some(entry) = self.groups.iter_mut().find(|(g, ..)| *g == group_id) { entry.1 = leader; @@ -1011,8 +930,6 @@ impl FakeParty { } } - /// Drop `guid` from its party, applying the module's own disband rule (a party of one is no - /// party) — the half `sync_mirrors` has to observe to push the right tombstone. pub(crate) fn remove_member(&mut self, guid: u64) { use lyracore_shared::group::event_kind as kind; let Some(group_id) = self.group_of(guid) else { @@ -1038,8 +955,6 @@ impl FakeParty { self.raids.retain(|g| *g != group_id); } else { if self.leader_of(group_id) == guid { - // The Module's succession: a Raid's first Assistant in join order, else the - // longest-standing member. let raid = self.kind_of(group_id) == GroupKind::Raid; let heir = remaining .iter() @@ -1054,8 +969,6 @@ impl FakeParty { } impl WorldFake { - /// Read realm-core's party the way its Coordinator cache shows it: stale after a lagging - /// call-pipe commit, current otherwise. pub(crate) fn realm_cache(&self, read: impl FnOnce(&FakeParty) -> R) -> R { if let Some(stale) = &*self.party.stale_party.lock().unwrap() { return read(stale); diff --git a/gateway/src/world/test_support/world_fake/quest.rs b/gateway/src/world/test_support/world_fake/quest.rs index 060971a0d..5879bfca9 100644 --- a/gateway/src/world/test_support/world_fake/quest.rs +++ b/gateway/src/world/test_support/world_fake/quest.rs @@ -2,25 +2,14 @@ use super::super::*; #[derive(Default)] pub(crate) struct QuestState { - /// Quest-giver evals returned by `giver_quest_evals` (the menu/status input). pub(crate) quest_evals: Vec, - /// Quest details `quest_detail_view(id)` resolves from (matched by `quest_id`). pub(crate) quest_details: Vec, - /// The player's quest-log slots `player_quest_log` returns (drives the login descriptor block). pub(crate) quest_log_slots: Vec, pub(crate) quest_log_read_error: Option, pub(crate) quest_log_after_subscribe: Option, String>>, - /// Recorded `turn_in_quest` dispatches: (actor, giver, quest, reward_index) — so the - /// choose-reward socket test asserts the player's pick reached the store unchanged. pub(crate) turned_in: std::sync::Mutex>, - /// A Reward Letter a successful `turn_in_quest` files as Escrow for the Character, as - /// `gw_turn_in_quest` does in its own transaction. pub(crate) turn_in_reward_letter: Option, - /// The caller's quest log for `quest_status`, as `(quest_id, rewarded)` pairs — a quest id present - /// here is "taken"; `rewarded` distinguishes active vs. turned-in. Absent = never seen. - /// Behind a `Mutex` so a test can change the log WHILE a gossip window is open — the - /// HELLO→SELECT race the menu snapshot exists to close. pub(crate) quest_log: std::sync::Mutex>, } @@ -66,7 +55,6 @@ impl QuestActionStore for WorldFake { Ok(()) } - /// No socket test drives an item-started quest — the route is proved at the quest seam. fn item_start_quest(&self, _owner_guid: u64, _slot: u8) -> Option<(u64, u32)> { None } diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs index 33538ad00..7ac668c57 100644 --- a/gateway/src/world/test_support/world_fake/session.rs +++ b/gateway/src/world/test_support/world_fake/session.rs @@ -1,78 +1,37 @@ use super::super::*; use crate::stdb::ReducerCallError; -/// One recorded `movement_update`: (opcode, x, y, z, orientation, timestamp). pub(crate) type MoveRecord = (u32, f32, f32, f32, f32, u32); #[derive(Default)] pub(crate) struct SessionState { pub(crate) movement_world: Option>, - /// WORLDPORT_ACK gate: true = entity present -> a spurious ack is ignored; - /// false (derive-Default) = absent -> a genuine transfer is pending. pub(crate) entity_in_world: bool, - /// An in-session controllable cache answer for movement desync regression tests. pub(crate) entity_presence: Option>, pub(crate) entity_presence_checks: std::sync::atomic::AtomicUsize, pub(crate) username: String, pub(crate) session: Option, pub(crate) login_entity: Option, pub(crate) moves: std::sync::Mutex>, - /// Recorded `client_command` calls: (actor guid, command, payload). pub(crate) client_commands: std::sync::Mutex>, - /// When set, every `client_command` fails with the error this builds from the reducer name. pub(crate) client_command_error: Option ReducerCallError>, - /// Optional item row whose subscribed relay is queued during a successful turn-in. The paired - /// sender is retained only for that test and consumed by `turn_in_quest`, so it cannot keep the - /// session writer alive during teardown. pub(crate) turn_in_reward_item: Option, pub(crate) turn_in_tx: std::sync::Mutex>, - /// Override for `player_combat_until_ms`: 0 = out of combat (default), non-zero = in combat until - /// this ms-epoch deadline (use u64::MAX for "always in combat" in tests). pub(crate) combat_until_ms: u64, - /// Tracks whether `logout` was called (entity removal path taken). pub(crate) logout_called: std::sync::atomic::AtomicBool, - /// When set, teardown cannot reach the database after a session-fatal Transport Loss. - /// The world session must still close and relinquish its admission seat. pub(crate) logout_transport_lost: bool, - /// The `WorldEntry` of every `player_login`, in order. pub(crate) login_entries: std::sync::Mutex>, - /// Parked private System Messages world entry replays (a Package `on_login` hook's output). pub(crate) pending_system_messages: Vec, - /// Recorded `player_login` call count — the WORLDPORT_ACK test distinguishes the - /// initial `CMSG_PLAYER_LOGIN` call from a world-port RE-entry call, since both dispatch through - /// this one trait method (`enter_world` is shared by both call sites). pub(crate) login_calls: std::sync::atomic::AtomicU32, - /// When set, every `player_login` call AFTER the first returns THIS entity instead of - /// `login_entity` — simulates the character row having moved to a new map (`teleport_player`'s - /// durable write) between the initial login and the `MSG_MOVE_WORLDPORT_ACK`. `None` (default): - /// every call keeps returning `login_entity`, byte-identical to before this field existed. pub(crate) worldport_entity: Option, - /// When set, every `player_login` AFTER the first FAILS with this message — the world entry - /// behind a world-port that routed fine (the stranding guard, a refused re-login on the - /// destination shard). The client is mid-loading-screen for it either way. pub(crate) worldport_login_error: Option, - /// Recorded `subscribe_player_events` calls: (self_guid, login_map, login_x, login_y) — the - /// WORLDPORT_ACK test asserts this fires AGAIN (a fresh `created` dedup set) at the new - /// map/position rather than reusing the old subscription. pub(crate) subscribed: std::sync::Mutex>, - /// The egress DEPTH counter of the live session `subscribe_player_events` was handed — so a test - /// can read the real queue depth of a real `run_world_session` (the writer thread's decrement has - /// no other reachable seam: it lives inside the spawned writer loop). Deliberately the depth - /// `Arc` and NOT the `SessionTx` itself: holding a sender clone here would keep the writer's - /// `rx.recv()` alive forever and hang every `enter_world` test's `server.join()`. pub(crate) session_depth: std::sync::Mutex>>, - /// Guids with a LIVE entity on this shard — the per-guid `entity_in_world` answer a - /// realm-wide party frame's online flags are built from. Empty = the single `entity_in_world` - /// flag above decides, as it did before. pub(crate) live_guids: Vec, - /// When set, every `movement_update` fails with a Transport Loss. pub(crate) movement_transport_lost: bool, - /// Optional real shared view used by world-port ordering tests. pub(crate) relay_view: Option>, - /// With `relay_view`, the Member Stats a Relay tick delivered between viewer registration and - /// the world-entry party frame. pub(crate) member_stats_before_party_frame: Option, } @@ -185,9 +144,6 @@ impl SessionStore for WorldFake { if let Some(present) = &self.session.entity_presence { return present.load(std::sync::atomic::Ordering::SeqCst); } - // `live_guids` is the per-guid answer the realm-wide party frame needs ("is this member - // live on THIS shard"). Empty by default, so the single flag above is still the answer - // every test written before realm-wide party routing set. self.session.entity_in_world || self.session.live_guids.contains(&guid) } diff --git a/gateway/src/world/test_support/world_fake/social.rs b/gateway/src/world/test_support/world_fake/social.rs index 01cfbd21c..8ffc18d41 100644 --- a/gateway/src/world/test_support/world_fake/social.rs +++ b/gateway/src/world/test_support/world_fake/social.rs @@ -2,31 +2,16 @@ use super::super::*; #[derive(Default)] pub(crate) struct SocialState { - /// Friend/ignore rows: `(owner_guid, target_guid, is_ignore)`. `add_friend`/ - /// `add_ignore`/`del_friend`/`del_ignore` mutate it; `contact_lists` reads it scoped to the caller. pub(crate) contacts: std::sync::Mutex>, - /// This Shard's stored Auto-Replies, by Character guid. pub(crate) auto_replies: std::sync::Mutex>, - /// Seeded characters that are nevertheless OFFLINE, so the invite gate's "player not - /// online" arm can be driven. Empty = every seeded character is online, as before. pub(crate) offline_guids: Vec, - /// Raw `PLAYER_FLAGS` per guid, for `presence_row`'s Away Status. Empty = every guid reads - /// `AwayStatus::None`, as a Character with no live entity does in production. pub(crate) away_flags: std::collections::HashMap, - /// `game_area.name` per zone id, for `/who`'s search-string match. Empty = every zone name - /// reads "", the "unimported catalogue" case. pub(crate) zone_names: std::collections::HashMap, - /// When set, `contact_lists` fails with this message on THIS shard — the - /// unreachable-database arm of the realm-wide ignore-list union. pub(crate) contact_lists_error: Option, - /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. pub(crate) member_entities: std::sync::Mutex>, - /// Characters THIS shard shows between two places: an online Session with no entity, or a - /// bot named by a Transfer Intent. pub(crate) members_between_places: std::sync::Mutex>, } -/// [`faked_party`] for the friends and ignore lists. pub(crate) fn faked_contact(operation: &str, error: &str) -> Result { match lyracore_shared::social::ContactRefusal::parse_tag(error) { Some(refusal) => Ok(refusal.into()), @@ -36,28 +21,22 @@ pub(crate) fn faked_contact(operation: &str, error: &str) -> Result Result> { - Ok(self.characters.iter().find(|c| c.guid == guid).map(|c| { - presence::CharacterIdentity { + Ok(self + .characters + .iter() + .find(|c| c.guid == guid) + .map(|c| presence::CharacterIdentity { guid: c.guid, name: c.name.clone(), race: c.race, class: c.class, level: c.level, zone_id: c.zone_id, - // `offline_guids` drives the invite gate's "player not online" arm; a seeded - // character is session-online unless listed there, mirroring `character_presence`. session_online: !self.social.offline_guids.contains(&guid), - } - })) + })) } fn live_entity(&self, guid: u64) -> Option { - // `member_entities` alone: Member Stats' own tests despawn a guid here while it stays in - // `live_guids` (a party-eligibility signal, not a Member Stats one) to pin the case where a - // group mate's entity is gone but the party frame's own bookkeeping has not caught up — - // falling back to `live_guids` or the blanket `entity_in_world` flag would read that guid - // live again and silently defeat the pin. `in_world_players`'s bulk /who scan has its own, - // separate fallback for a guid this fixture never gave a precise entity to. self.social .member_entities .lock() @@ -80,9 +59,6 @@ impl SocialStore for WorldFake { } fn every_shard_vouches_for_absence(&self) -> Result<()> { - // A Realm Presence "gone" claim spans every configured Shard, not just this handle — each - // Fake instance models one Shard's own connection, so the peer set is checked too, the - // same reach `Coordinator::world_shards_for_absence` has from any one of its own handles. for peer in self.topology.peers.lock().unwrap().iter() { if let Some(error) = &peer.topology.world_shard_set_error { return Err(anyhow!(error.clone())); @@ -95,12 +71,6 @@ impl SocialStore for WorldFake { } fn in_world_players(&self) -> Result> { - // Test store: every seeded character the fake considers in-world (`entity_in_world`, - // the BLANKET flag included) is listed, so CMSG_WHO tests can assert a response without - // wiring `live_guids` by hand — unlike `live_entity`, which `presence::of` uses for one - // guid at a time and which deliberately does not trust that blanket flag. - // `live_entity` supplies level/zone from `member_entities`/`live_guids` when a test seeded - // one for this guid, else the durable row stands in. Ok(self .characters .iter() @@ -232,7 +202,6 @@ impl SocialStore for WorldFake { } impl WorldFake { - /// Drop one contact row, or refuse when the owner does not hold it. pub(crate) fn remove_contact( &self, target_guid: u64, @@ -253,9 +222,6 @@ impl WorldFake { Ok(ContactOutcome::Done) } - /// `guid`'s Away Status from `away_flags` (raw `PLAYER_FLAGS`), `AwayStatus::None` when unset — - /// mirroring "a Character with no live entity has `AwayStatus::None`" for every guid a test - /// never seeds. pub(crate) fn away(&self, guid: u64) -> presence::AwayStatus { self.social .away_flags diff --git a/gateway/src/world/test_support/world_fake/speech.rs b/gateway/src/world/test_support/world_fake/speech.rs index aa99192e2..1d6a7060f 100644 --- a/gateway/src/world/test_support/world_fake/speech.rs +++ b/gateway/src/world/test_support/world_fake/speech.rs @@ -2,25 +2,13 @@ use super::super::*; #[derive(Default)] pub(crate) struct SpeechState { - /// What `send_chat` answers; `None` delivers. pub(crate) send_chat_outcome: Option, - /// When set, `send_chat` fails with this error: a Transport Loss ends the World Session. pub(crate) send_chat_error: Option anyhow::Error>, - /// When set, `gm_command` fails with this error: a Refusal the GM reads, or a Transport Loss. pub(crate) gm_command_error: Option anyhow::Error>, - /// Recorded `gm_command` dispatches — the dot-command divert test asserts the - /// RIGHT raw text (still carrying its leading `.`) reached the reducer call, and that a NON-dot - /// Say never reaches this vec at all. pub(crate) gm_commands: std::sync::Mutex>, - /// Current Realm-core Alpha Test Tools answer for the next command. `None` leaves the older - /// fixed-outcome fixture in place; tests that set it model the production Store's fresh read. pub(crate) gm_alpha_test_tools: Option>, - /// The authority conveyed with each command when `gm_alpha_test_tools` is in use. pub(crate) gm_authority_results: std::sync::Mutex>, - /// Home Shard gameplay accepted by the focused Alpha Test Tools Fake. The command parser and - /// effects belong to Module tests, so this only records the visible Store outcome. pub(crate) gm_gameplay_changes: std::sync::Mutex>, - /// Recorded `send_chat` lines: (chat_type, language, message). pub(crate) chats: std::sync::Mutex>, } @@ -35,8 +23,6 @@ impl SpeechStore for WorldFake { if let Some(error) = self.speech.send_chat_error { return Err(error()); } - // Recorded per SHARD like every other player-scoped call, so the partition rule (say/ - // yell stay shard-local and range-scoped) is assertable rather than merely stated. self.rec("send_chat"); self.speech .chats diff --git a/gateway/src/world/test_support/world_fake/taxi.rs b/gateway/src/world/test_support/world_fake/taxi.rs index 9dc758929..61021d7a4 100644 --- a/gateway/src/world/test_support/world_fake/taxi.rs +++ b/gateway/src/world/test_support/world_fake/taxi.rs @@ -4,7 +4,6 @@ use wow_world_messages::vanilla::Vector3d; #[derive(Default)] pub(crate) struct TaxiState { - /// The taxi map the open operation returns, for the TAXI gossip socket tests. pub(crate) taxi_map: Option, pub(crate) taxi_error: Option anyhow::Error>, pub(crate) activation: codec::TaxiActivationResult, @@ -13,7 +12,6 @@ pub(crate) struct TaxiState { pub(crate) flight_tx: Mutex>, } -/// Taxi state for World Session tests. Arming publishes a flight spline to its passenger. impl TaxiActionStore for WorldFake { fn taxi_node_status( &self, diff --git a/gateway/src/world/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs index cded7ec7e..382e939d9 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -8,11 +8,7 @@ pub(crate) struct TrainerState { pub(crate) reputation_at_war: std::sync::Mutex>, pub(crate) talent_reset_quote: Option, pub(crate) reset_talents_refusal: Option, - /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the - /// trait method reads the negation. pub(crate) trainer_refuses_class: bool, - /// Recorded `reset_talents` dispatches: (actor guid, trainer_guid) — the unlearn-talents - /// gossip select. pub(crate) reset_talents_calls: std::sync::Mutex>, } @@ -45,7 +41,7 @@ impl TrainerStore for WorldFake { } fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { - Ok(!self.trainer.trainer_refuses_class) // default true — every existing fixture trainer serves + Ok(!self.trainer.trainer_refuses_class) } fn trainer_list( @@ -98,7 +94,7 @@ impl TrainerStore for WorldFake { } fn talent_points_spent(&self, _character_guid: u64) -> u32 { - 0 // login stays byte-identical in every existing harness test + 0 } fn learn_talent(&self, _actor: Actor, _talent_id: u32) -> Result<()> { @@ -118,11 +114,11 @@ impl TrainerStore for WorldFake { } fn resolve_learn_target(&self, spell_id: u32) -> u32 { - spell_id // self-contained ranks (no wrapper table in the Fake) + spell_id } fn trainer_offer_skill_line(&self, _trainer_guid: u64, _spell_id: u32) -> u32 { - 0 // every offering reads as an ordinary spell purchase + 0 } fn superseded_old_rank(&self, _new_spell: u32, _player_guid: u64) -> Option { @@ -130,19 +126,13 @@ impl TrainerStore for WorldFake { } fn character_presence(&self, guid: u64) -> Result> { - Ok(self - .characters - .iter() - .find(|c| c.guid == guid) - // `offline_guids` drives the invite gate's "player not online" arm. Empty by - // default, so a seeded character is online exactly as it always was. - .map(|c| { - ( - !self.social.offline_guids.contains(&guid), - c.level, - c.class, - c.zone_id, - ) - })) + Ok(self.characters.iter().find(|c| c.guid == guid).map(|c| { + ( + !self.social.offline_guids.contains(&guid), + c.level, + c.class, + c.zone_id, + ) + })) } } diff --git a/gateway/src/world/test_support/world_fake/transfer.rs b/gateway/src/world/test_support/world_fake/transfer.rs index 2616038ae..ed4cd13a0 100644 --- a/gateway/src/world/test_support/world_fake/transfer.rs +++ b/gateway/src/world/test_support/world_fake/transfer.rs @@ -2,24 +2,12 @@ use super::super::*; #[derive(Default)] pub(crate) struct TransferState { - /// The fake DATABASE this handle talks to, for the cross-database transfer tests. `None` - /// (the default) leaves every transfer trait method at its "this store does not shard" default, - /// so every other test in this file is untouched. pub(crate) xdb: Option>, - /// How many `escrowed_transfer` reads answer `None` before the row shows up — the - /// cross-connection lag between a reducer's reply and the coordinator cache, made dialable. pub(crate) escrow_reads_before_visible: std::sync::atomic::AtomicUsize, - /// The transfer step to fail at, simulating a gateway killed before that step's - /// transaction committed. `None` = nothing fails. pub(crate) kill_at: Option, - /// The REALM-CORE character→shard index this handle's `publish_shard_index` writes. In - /// production that write goes to a third database (`realm_core()`); here it is just a map, so a - /// test can assert the drive published the destination it settled on. pub(crate) realm_index: std::sync::Mutex>, - /// Realm-core's ordered transfer phase for cross-Shard caller tests. pub(crate) realm_partition: std::sync::Mutex>, - /// When set, `publish_shard_index` fails with this message — an unreachable realm-core. pub(crate) publish_error: Option, } @@ -70,14 +58,6 @@ impl TransferStore for WorldFake { }) } - /// `plan_begin`: replay on a matching escrow, refuse without a source copy, refuse a second - /// escrow for the same character, otherwise freeze + serialize. - /// - /// The `escrowed_guid` lookup is the OUT-row with the IN-row as a fallback, exactly as - /// `module/src/transfer/mod.rs`'s `begin_transfer` computes it — and that fallback is load-bearing - /// now the transfer id IS the character guid: a database holding an unreleased ARRIVAL in-row - /// for this character answers `BeginPlan::Replay` to a genuine new transfer, i.e. reports - /// success while freezing nothing. A mock that only looked at `out_rows` could not see it. fn begin_transfer( &self, _character: Actor, @@ -93,7 +73,7 @@ impl TransferStore for WorldFake { if existing != plan.character_guid { return Err(anyhow!("transfer id collision")); } - return Ok(()); // BeginPlan::Replay + return Ok(()); } let Some(c) = db.get(plan.character_guid) else { return Err(anyhow!("no such character: {}", plan.character_guid)); @@ -125,8 +105,6 @@ impl TransferStore for WorldFake { Ok(()) } - /// `import_character_blob`: replay on the in-row PK, refuse to land on a LIVE character, - /// otherwise materialise the row + its payload at the escrow's destination. fn import_character_blob( &self, transfer_id: u64, @@ -146,7 +124,7 @@ impl TransferStore for WorldFake { return Err(anyhow!("bot Transfer arrival identity is invalid")); } } - // Drop each `in_rows` guard before `db.live()`, which locks the same non-reentrant Mutex. + // Drop each in_rows guard before db.live(), which takes the same non-reentrant mutex. let replayed = lk(&db.in_rows).get(&transfer_id).copied(); if let Some(existing) = replayed { if existing != guid { @@ -177,8 +155,6 @@ impl TransferStore for WorldFake { if db.live(guid) { return Err(anyhow!("character {guid} is already live on this shard")); } - // The destination rides IN the blob, exactly as the real `ExportBlob`'s `dest_*` fields do: - // cross-database the blob is the only thing that reaches this side. lk(&db.characters).insert(guid, arriving); lk(&db.in_rows).insert(transfer_id, guid); lk(&db.arrival_sources).insert( @@ -199,7 +175,6 @@ impl TransferStore for WorldFake { Ok(()) } - /// `confirm_import`: the SOURCE-side attestation. Refuses without a local escrow. fn confirm_import(&self, transfer_id: u64) -> Result<()> { let db = self.xstep("confirm_import")?; let out = lk(&db.out_rows); @@ -212,13 +187,11 @@ impl TransferStore for WorldFake { Ok(()) } - /// `plan_finish`: refuses while the in-row (the attestation) is absent — the guard that makes - /// "zero durable copies" unreachable — then cascade-deletes the source copy, escrow last. fn finish_transfer(&self, transfer_id: u64) -> Result<()> { let db = self.xstep("finish_transfer")?; let mut out = lk(&db.out_rows); let Some(escrow) = out.get(&transfer_id).cloned() else { - return Ok(()); // FinishPlan::AlreadyDone + return Ok(()); }; if !lk(&db.in_rows).contains_key(&transfer_id) { return Err(anyhow!( @@ -231,7 +204,6 @@ impl TransferStore for WorldFake { Ok(()) } - /// `release_transfer`: refuses on a shard that is the SOURCE; replay-safe otherwise. fn release_transfer(&self, transfer_id: u64) -> Result<()> { let Some(_) = self.transfer.xdb.as_ref() else { return Ok(()); @@ -663,9 +635,6 @@ impl TransferStore for WorldFake { _ => {} } lk(&db.instance_partitions).insert(instance_id, (map_id, party_id)); - // The module's own shape: a mirror of an instance that is ALREADY here joins it (early - // return) instead of spawning a second population. `HashSet::insert` reports that for free, - // and the count is what the second-party-member test asserts against. if lk(&db.instances).insert(instance_id) { lk(&db.populated).push(instance_id); } @@ -682,12 +651,6 @@ impl TransferStore for WorldFake { } } -// The cross-database transfer fixtures. `TransferStore` above runs on them through `xdb`, and -// `transfer_tests.rs` and the world-port tests build them directly. - -/// One character's durable state, reduced to what a transfer has to preserve: where it is, and a -/// PAYLOAD marker standing for the character-owned rows (gear/spells/skills/quest log). If the -/// payload does not arrive, the character arrived NAKED — the failure a manifest-only blob has. #[derive(Clone, Debug, PartialEq)] pub(crate) struct FakeChar { pub(crate) map_id: u32, @@ -704,25 +667,17 @@ pub(crate) struct FakeEscrow { pub(crate) blob: Vec, } -/// One SpacetimeDB database, as far as the transfer protocol can see it. #[derive(Default)] pub(crate) struct FakeShardDb { pub(crate) characters: std::sync::Mutex>, - /// transfer_id → the source escrow (`game_transfer_out`). pub(crate) out_rows: std::sync::Mutex>, - /// transfer_id → character guid (`game_transfer_in`): on the DESTINATION the arrival copy's - /// fence, on the SOURCE the gateway's `confirm_import` attestation. pub(crate) in_rows: std::sync::Mutex>, - /// Exact bot Transfer identity attached to a destination fence. pub(crate) bot_arrivals: std::sync::Mutex< std::collections::HashMap, >, - /// Realm locator predecessor attached to each destination fence. pub(crate) arrival_sources: std::sync::Mutex>, pub(crate) instances: std::sync::Mutex>, pub(crate) instance_partitions: std::sync::Mutex>, - /// Every instance id this database actually SPAWNED a population for — one entry per - /// spawn, so "the second party member re-created the dungeon" is visible as a duplicate. pub(crate) populated: std::sync::Mutex>, pub(crate) evicted: std::sync::Mutex>, } @@ -746,8 +701,6 @@ impl FakeShardDb { pub(crate) fn get(&self, guid: u64) -> Option { lk(&self.characters).get(&guid).cloned() } - /// A character is LIVE here iff it is durable AND neither escrow row fences it — the module's - /// `login_allowed` predicate, which is what `player_login` actually gates on. pub(crate) fn live(&self, guid: u64) -> bool { self.has(guid) && !lk(&self.out_rows) @@ -760,9 +713,6 @@ impl FakeShardDb { } } -/// The blob a fake `begin_transfer` produces. Carries the destination and the payload, exactly as -/// the real `ExportBlob` carries `dest_*` + `character_row` + `payload`: cross-database the blob is -/// the ONLY thing that reaches the far side. pub(crate) fn fake_blob(guid: u64, dest_map: u32, dest_instance: u64, payload: &str) -> Vec { format!("{guid}|{dest_map}|{dest_instance}|{payload}").into_bytes() } @@ -781,8 +731,6 @@ pub(crate) fn parse_blob(blob: &[u8]) -> (u64, FakeChar) { } impl WorldFake { - /// Record a transfer step and honour an injected kill. `Err` means "the gateway died - /// before this step's transaction committed", which is exactly a truncated drive. pub(crate) fn xstep(&self, what: &str) -> Result<&std::sync::Arc> { let db = self .transfer @@ -796,8 +744,6 @@ impl WorldFake { Ok(db) } - /// The realm-core index publish. Recorded in the shared call log so its POSITION in the - /// drive is assertable, not just its effect. pub(crate) fn publish_shard_index( &self, character_guid: u64, @@ -807,10 +753,6 @@ impl WorldFake { if let Some(e) = &self.transfer.publish_error { return Err(anyhow!("{e}")); } - // Through `xstep`, like every other step of the drive — NOT a bare `rec`. Every other - // transfer method routes its "gateway killed here" injection through it, and this one - // originally did not, so `kill_at = "publish_shard_index"` was silently inert and the - // crash matrix reported a PASS for a boundary it never killed at. self.xstep("publish_shard_index")?; self.transfer .realm_index diff --git a/gateway/src/world/test_support/world_fake/vendor.rs b/gateway/src/world/test_support/world_fake/vendor.rs index e9a41f179..63092e495 100644 --- a/gateway/src/world/test_support/world_fake/vendor.rs +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -3,15 +3,10 @@ use crate::world::Actor; #[derive(Default)] pub(crate) struct VendorState { - /// Vendor stock the seam's `vendor_stock` read returns (empty by default). pub(crate) vendor_stock: Vec, - /// The player's buyback ring as `(item_entry, stack_count, price)`; empty by default, so a - /// fixture login replays no buyback tab. pub(crate) buyback_ring: Vec<(u32, u32, u32, u32)>, } -/// Vendor behaviour is tested through `InMemoryVendorActions`; here only the reads the session and -/// the gossip menu make are live. impl VendorActionStore for WorldFake { fn vendor_stock(&self, _vendor_guid: u64) -> Result> { Ok(self.vendor.vendor_stock.clone()) diff --git a/gateway/src/world/test_support/world_fake/weather.rs b/gateway/src/world/test_support/world_fake/weather.rs index f59887164..e0d4c1ab2 100644 --- a/gateway/src/world/test_support/world_fake/weather.rs +++ b/gateway/src/world/test_support/world_fake/weather.rs @@ -2,9 +2,6 @@ use super::super::*; #[derive(Default)] pub(crate) struct WeatherState { - /// Canned `game_zone_weather` rows, keyed by zone. A zone absent here has no row, which the - /// Module defines as fine weather — the default, so a store that says nothing about weather - /// behaves exactly like today's weatherless world. pub(crate) zone_weather: Vec<(u32, codec::ZoneWeatherView)>, } diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 8c9cf2437..d4b387a7c 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -1,7 +1,3 @@ -//! World Session socket tests. Each `_tests.rs` file drives a real encrypted World Session -//! against [`WorldFake`] for one Store family, and `session_tests.rs` covers the session itself. -//! This module holds the imports those files share and the helpers more than one of them uses. - use super::handlers::{ AuctionInteraction, ChatOutcome, SpeakerFacts, WhisperRequest, WhisperTargetFacts, }; @@ -20,88 +16,25 @@ use std::rc::Rc; use wow_world_base::shared::friend_result_vanilla_tbc::FriendResult; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; use wow_world_messages::vanilla::{ - BuyBankSlotResult, - BuyResult, - BuybackSlot, - Class, - ClientMessage, - Gender, - GroupLootSetting, - ItemQuality, - Language, - Level, - MSG_AUCTION_HELLO_Client, - Map, - Object, - Race, - RollVote, - SheathState, - SpellCastTargets, - SpellCastTargets_SpellCastTargetFlags, - SpellCastTargets_SpellCastTargetFlags_Unit, - Talent, - TrainingFailureReason, - WeatherType, - WorldResult, - CMSG_ACTIVATETAXI, - CMSG_ADD_FRIEND, - CMSG_ADD_IGNORE, - CMSG_ATTACKSTOP, - CMSG_ATTACKSWING, - CMSG_AUCTION_LIST_BIDDER_ITEMS, - CMSG_AUCTION_LIST_ITEMS, - CMSG_AUCTION_LIST_OWNER_ITEMS, - CMSG_AUTOBANK_ITEM, - CMSG_AUTOEQUIP_ITEM, - CMSG_AUTOSTORE_BANK_ITEM, - CMSG_BANKER_ACTIVATE, - CMSG_BUYBACK_ITEM, - CMSG_BUY_BANK_SLOT, - CMSG_BUY_ITEM, - CMSG_CANCEL_AURA, - CMSG_CANCEL_AUTO_REPEAT_SPELL, - CMSG_CANCEL_CAST, - CMSG_CAST_SPELL, - CMSG_CHAR_CREATE, - CMSG_CHAR_DELETE, - CMSG_CHAR_ENUM, - CMSG_DEL_FRIEND, - CMSG_DEL_IGNORE, - CMSG_FRIEND_LIST, - CMSG_GAMEOBJ_USE, - CMSG_GOSSIP_HELLO, - CMSG_GOSSIP_SELECT_OPTION, - CMSG_INSPECT, - CMSG_ITEM_QUERY_SINGLE, - CMSG_LEARN_TALENT, - CMSG_LIST_INVENTORY, - CMSG_LOGOUT_REQUEST, - CMSG_LOOT, - CMSG_LOOT_MASTER_GIVE, - CMSG_LOOT_METHOD, - CMSG_LOOT_MONEY, - CMSG_LOOT_ROLL, - CMSG_NPC_TEXT_QUERY, - CMSG_PLAYED_TIME, - CMSG_PLAYER_LOGIN, - CMSG_QUESTGIVER_CHOOSE_REWARD, - CMSG_QUESTGIVER_HELLO, - CMSG_QUESTGIVER_STATUS_QUERY, - CMSG_QUEST_QUERY, - // Item guid → slot resolution (vendor sell / armorer repair). - CMSG_RECLAIM_CORPSE, - CMSG_RESURRECT_RESPONSE, - CMSG_SETSHEATHED, - CMSG_SET_SELECTION, - CMSG_SPIRIT_HEALER_ACTIVATE, - CMSG_TAXINODE_STATUS_QUERY, - CMSG_TAXIQUERYAVAILABLENODES, - CMSG_TRAINER_BUY_SPELL, - CMSG_TRAINER_LIST, - CMSG_WHO, - // Cross-map teleport: the client's world-port-finished ack. - MSG_MOVE_WORLDPORT_ACK, - SMSG_WEATHER, + BuyBankSlotResult, BuyResult, BuybackSlot, Class, ClientMessage, Gender, GroupLootSetting, + ItemQuality, Language, Level, MSG_AUCTION_HELLO_Client, Map, Object, Race, RollVote, + SheathState, SpellCastTargets, SpellCastTargets_SpellCastTargetFlags, + SpellCastTargets_SpellCastTargetFlags_Unit, Talent, TrainingFailureReason, WeatherType, + WorldResult, CMSG_ACTIVATETAXI, CMSG_ADD_FRIEND, CMSG_ADD_IGNORE, CMSG_ATTACKSTOP, + CMSG_ATTACKSWING, CMSG_AUCTION_LIST_BIDDER_ITEMS, CMSG_AUCTION_LIST_ITEMS, + CMSG_AUCTION_LIST_OWNER_ITEMS, CMSG_AUTOBANK_ITEM, CMSG_AUTOEQUIP_ITEM, + CMSG_AUTOSTORE_BANK_ITEM, CMSG_BANKER_ACTIVATE, CMSG_BUYBACK_ITEM, CMSG_BUY_BANK_SLOT, + CMSG_BUY_ITEM, CMSG_CANCEL_AURA, CMSG_CANCEL_AUTO_REPEAT_SPELL, CMSG_CANCEL_CAST, + CMSG_CAST_SPELL, CMSG_CHAR_CREATE, CMSG_CHAR_DELETE, CMSG_CHAR_ENUM, CMSG_DEL_FRIEND, + CMSG_DEL_IGNORE, CMSG_FRIEND_LIST, CMSG_GAMEOBJ_USE, CMSG_GOSSIP_HELLO, + CMSG_GOSSIP_SELECT_OPTION, CMSG_INSPECT, CMSG_ITEM_QUERY_SINGLE, CMSG_LEARN_TALENT, + CMSG_LIST_INVENTORY, CMSG_LOGOUT_REQUEST, CMSG_LOOT, CMSG_LOOT_MASTER_GIVE, CMSG_LOOT_METHOD, + CMSG_LOOT_MONEY, CMSG_LOOT_ROLL, CMSG_NPC_TEXT_QUERY, CMSG_PLAYED_TIME, CMSG_PLAYER_LOGIN, + CMSG_QUESTGIVER_CHOOSE_REWARD, CMSG_QUESTGIVER_HELLO, CMSG_QUESTGIVER_STATUS_QUERY, + CMSG_QUEST_QUERY, CMSG_RECLAIM_CORPSE, CMSG_RESURRECT_RESPONSE, CMSG_SETSHEATHED, + CMSG_SET_SELECTION, CMSG_SPIRIT_HEALER_ACTIVATE, CMSG_TAXINODE_STATUS_QUERY, + CMSG_TAXIQUERYAVAILABLENODES, CMSG_TRAINER_BUY_SPELL, CMSG_TRAINER_LIST, CMSG_WHO, + MSG_MOVE_WORLDPORT_ACK, SMSG_WEATHER, }; use wow_world_messages::Guid; @@ -178,14 +111,6 @@ mod wire_corruption_tests; use shard_routing_tests::ShardCallLog; -/// Write one request that answers its actor nothing, then a sentinel request with a guaranteed -/// reply, and block for that reply. This is more than pacing: `enter_world` drains the world entry -/// batch, which ends before the Guild MOTD event `guild_world_entry` sends a fresh-login -/// Guild member (see its own doc comment), so one packet can still be unread in the client's -/// kernel buffer. Reading for a sentinel discards it along the way. Dropping the client with it -/// still queued would close with unread bytes, which the kernel reports to the server as a reset, -/// not a clean EOF (`enter_world`'s doc comment names this exact failure shape). The sentinel is -/// CMSG_PLAYED_TIME, which replies only when the store has a Character row for the caller. fn sync( client: &mut UnixStream, enc: &mut EncrypterHalf, @@ -212,7 +137,6 @@ fn position(calls: &[String], what: &str) -> usize { .unwrap_or_else(|| panic!("`{what}` never ran: {calls:?}")) } -/// A minimal quest detail (everything zero/empty but the id + title) — enough for the build_* codecs. fn detail_view(quest_id: u32, title: &str) -> codec::QuestDetailView { codec::QuestDetailView { quest_id, @@ -233,7 +157,6 @@ fn detail_view(quest_id: u32, title: &str) -> codec::QuestDetailView { } } -/// One giver↔quest eval with the four booleans the menu/status + complete-branch read. fn eval(quest_id: u32, role: u8, active: bool, complete: bool) -> codec::GiverQuestEval { codec::GiverQuestEval { quest_id, @@ -257,14 +180,8 @@ fn imported_auction_interaction() -> AuctionInteraction { } } -/// `SMSG_LOOT_RESPONSE`, read raw because the loot window is an `Outbound::Raw` packet. const OP_LOOT_RESPONSE: u16 = 0x0160; -/// Read one encrypted server frame RAW: decrypt the 4-byte header via the client's `DecrypterHalf`, -/// return `(opcode, body)`. Needed where the wire contract is an `Outbound::Raw` packet (the 5-byte -/// CAST_RESULT ack, the loot/vendor windows) or where the exact opcode ORDER across raw+typed sends -/// is the assertion — gtker's typed reader rejects some of the hand-rolled bodies (it would consume -/// the frame but error), so the bytes are read and pinned directly. fn read_raw_frame(client: &mut S, dec: &mut DecrypterHalf) -> (u16, Vec) { let h = dec.read_and_decrypt_server_header(&mut *client).unwrap(); let mut body = vec![0u8; (h.size as usize).saturating_sub(2)]; @@ -288,8 +205,6 @@ fn open_loot_window( body } -/// Open `npc`'s gossip window and drain the menu, so a following `CMSG_GOSSIP_SELECT_OPTION` has the -/// snapshot it resolves against — a click with nothing open selects nothing. fn gossip_hello( client: &mut UnixStream, enc: &mut EncrypterHalf, @@ -307,7 +222,6 @@ fn gossip_hello( } } -/// A shorthand imported option builder for the gossip tests. fn opt(icon: u32, text: &str, action: u32) -> codec::GossipOptionView { codec::GossipOptionView { icon, diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index b2f491350..8692d7a6f 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -1,6 +1,3 @@ -//! Trade requests and durable outcomes through the Trade Store. The Relay sends trade status; -//! these tests check the Fake's Trade Session and the absence of direct replies. - use super::handlers::{Trade, TradeStore}; use super::*; use crate::stdb::ReducerCallError; @@ -39,14 +36,10 @@ enum Decline { IgnoresInitiator, } -/// One Trade Session. The Module owns every gate and the accept reset; this keeps just enough to show -/// which seat did what, with which arguments. #[derive(Default)] struct Session { phase: Phase, - /// `(seat, trade slot)` to the absolute inventory slot offered. items: BTreeMap<(u64, u8), u8>, - /// Offered copper per seat. gold: BTreeMap, accepted: BTreeSet, } @@ -54,7 +47,6 @@ struct Session { #[derive(Default)] struct TradeFake { session: Mutex, - /// Answered once by the next request, in place of success. failure: Mutex>, } @@ -193,14 +185,12 @@ impl TradeStore for TradeFake { } } -/// Handle `msg` as `seat`, a Character of Account 7. fn run(store: &TradeFake, seat: u64, msg: impl Into) { let mut session = ProtocolSession::in_world(7, seat); let reply = Trade::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap(); assert!(reply.outbound.is_empty()); } -/// Initiating a trade with a targeted player names the wire's target. #[test] fn initiate_trade_proposes_to_the_wire_target_guid() { let store = TradeFake::default(); @@ -214,7 +204,6 @@ fn initiate_trade_proposes_to_the_wire_target_guid() { ); } -/// The same flow works with the seats swapped: Character 2 initiates against Character 1. #[test] fn initiate_trade_proposes_from_the_other_side_too() { let store = TradeFake::default(); @@ -228,9 +217,6 @@ fn initiate_trade_proposes_from_the_other_side_too() { ); } -/// The handshake round trip on one Trade Session: A proposes, B's client answers `CMSG_BEGIN_TRADE` -/// (the vanilla auto-reply to `BeginTrade`), then B cancels. Each step reads the phase the Fake -/// reached, so the steps also pin their order: a begin before the proposal opens nothing. #[test] fn the_handshake_flow_proposes_then_opens_then_cancels() { let store = TradeFake::default(); @@ -257,8 +243,6 @@ fn the_handshake_flow_proposes_then_opens_then_cancels() { assert_eq!(store.phase(), Phase::Cancelled { by: 2 }); } -/// Offer mutations carry the wire's arguments: set item (main bag to absolute slot), clear item, -/// and gold (the `Gold` wire type decoded back to copper). #[test] fn offer_mutations_carry_the_wire_arguments() { let store = TradeFake::default(); @@ -286,8 +270,6 @@ fn offer_mutations_carry_the_wire_arguments() { assert_eq!(store.gold(), [(1, 1_2345)]); } -/// Items inside an equipped sub-bag are out of scope: logged and ignored, never forwarded with a -/// bag-local slot number that would alias a main-bag slot. #[test] fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { let store = TradeFake::default(); @@ -303,9 +285,6 @@ fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { assert!(store.items().is_empty()); } -/// The wire half of the full loop: A initiates, offers an item and accepts; B answers, offers gold -/// and accepts. Each seat's offer lands under its own seat. The swap itself (items, gold, -/// atomicity) is the Module's Trade Commit, tested there. #[test] fn the_full_loop_records_each_seats_offer_and_both_accepts() { let store = TradeFake::default(); @@ -344,8 +323,6 @@ fn the_full_loop_records_each_seats_offer_and_both_accepts() { assert_eq!(store.accepted(), [1, 2]); } -/// After an accept, a further offer mutation and an explicit unaccept both reach the Store for the -/// acting seat. The reset itself (both flags cleared, `BackToTrade` to both) is the Module's rule. #[test] fn unaccept_and_post_accept_mutations_reach_the_acting_seat() { let store = TradeFake::default(); @@ -365,9 +342,6 @@ fn unaccept_and_post_accept_mutations_reach_the_acting_seat() { assert!(store.accepted().is_empty()); } -/// The proposed target's client answers a `BeginTrade` it cannot take with `CMSG_BUSY_TRADE` -/// (already in a dialog) or `CMSG_IGNORE_TRADE` (initiator ignored). Each declines as the -/// declining side, with its own reason. #[test] fn decline_opcodes_decline_with_their_own_reason_for_the_declining_side() { for (reason, decline) in [ @@ -384,8 +358,6 @@ fn decline_opcodes_decline_with_their_own_reason_for_the_declining_side() { } } -/// Either side can cancel: the initiator's own `CMSG_CANCEL_TRADE` cancels as themselves, the -/// counterpart of the handshake test's cancel by the target. #[test] fn cancel_trade_cancels_for_the_initiating_side_too() { let store = TradeFake::default(); diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 042c95ce3..6ec36d040 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -1,5 +1,3 @@ -//! Trainer and talent replies through the Character, Npc, and Trainer Stores. - use super::handlers::{CharacterStore, Trainer, TrainerBuyOutcome, TrainerStore}; use super::*; use crate::stdb::ReducerCallError; @@ -8,27 +6,19 @@ use lyracore_shared::constants::armor_proficiency::{ }; use lyracore_shared::trainer::TrainerRefusal; -/// What the Module would answer a trainer window, a buy and a talent pick, set per test. #[derive(Default)] struct TrainerFake { npc_refuses: bool, refuses_class: bool, spells: Vec, - /// `learn_skill_line` per offering id. An offering absent here teaches an ordinary spell. skill_lines: std::collections::HashMap, buy_refusal: Option, - /// A failed Durable Request on the buy. buy_error: Option anyhow::Error>, - /// A failed Durable Request on the talent pick. talent_error: Option anyhow::Error>, - /// The known previous rank a buy of a non-stacking chain replaces. superseded: Option, - /// The spellbook after the buy. learned_spells: Vec, - /// `(level, class)` of the buying Character. level_and_class: Option<(u8, u8)>, talent_grant: u32, - /// `(rank spell taught, rank spell it replaces, points left)`. talent_pane: (u32, u32, u32), } @@ -206,7 +196,6 @@ impl TrainerStore for TrainerFake { } } -/// Handle `msg` as Character 1 of Account 7 and return the replies in order. fn drive( store: &TrainerFake, msg: impl Into, @@ -268,9 +257,6 @@ fn trainer_buy_success_replies_succeeded_then_pushes_the_learned_spell() { assert_eq!(learned.id, 1234); } -/// A RIDING purchase teaches a skill, not a spell. Its trainer-list id is a marker with no Spell.dbc -/// row, so the buy confirms and stops: a learned-spell push would hand the client an id it cannot -/// resolve. The skill pane moves from the `game_player_skill` relay. #[test] fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { let store = TrainerFake { @@ -285,8 +271,6 @@ fn a_riding_buy_confirms_without_echoing_the_offering_as_a_learned_spell() { ); } -/// A Warrior buying Plate Mail at 40. The book update alone leaves the client tinting every plate -/// piece red, so the buy also refreshes the ARMOR mask from the post-buy spellbook. #[test] fn a_proficiency_buy_pushes_the_refreshed_armor_mask_after_the_learned_spell() { let store = TrainerFake { @@ -316,8 +300,6 @@ fn a_proficiency_buy_pushes_the_refreshed_armor_mask_after_the_learned_spell() { ); } -/// An ordinary ability purchase changes nothing about what the Character may wear, so it must not -/// resend a proficiency mask. #[test] fn an_ordinary_trainer_buy_pushes_no_proficiency_mask() { let sent = run(&TrainerFake::buying_as(40, 1), buy(1234)); @@ -334,9 +316,6 @@ fn an_ordinary_trainer_buy_pushes_no_proficiency_mask() { ); } -/// A buy whose previous rank is known sends SMSG_SUPERCEDED_SPELL, so the client replaces the old -/// rank's book entry. The first wire slot carries the OLD rank (cmangos order), as the talent -/// rank-upgrade path does. #[test] fn trainer_buy_rank_upgrade_supersedes_the_previous_rank_spell() { let store = TrainerFake { @@ -389,8 +368,6 @@ fn every_module_refusal_has_one_client_failure_reason() { } } -/// A Transport Loss leaves the durable result unknown, so it must not reach the client as a -/// gameplay Refusal that says the purchase did not happen. The handler fails; the session ends. #[test] fn a_trainer_transport_loss_is_not_answered_as_a_refusal() { let store = TrainerFake { @@ -401,8 +378,6 @@ fn a_trainer_transport_loss_is_not_answered_as_a_refusal() { .expect_err("a lost trainer Durable Request must end the World Session"); } -/// A refused talent pick is per-action: the client hears nothing and the session goes on. A lost -/// transport leaves the pick's outcome unknown and ends the session. #[test] fn a_refused_talent_pick_keeps_the_session_and_a_transport_loss_ends_it() { let refused = TrainerFake { @@ -421,8 +396,6 @@ fn a_refused_talent_pick_keeps_the_session_and_a_transport_loss_ends_it() { drive(&lost, pick_talent()).expect_err("a lost talent pick must end the World Session"); } -/// An ability talent (`grant_spell_id != 0`) pushes the granted spell so the new button works -/// without a relog, then the points push. #[test] fn learn_talent_with_a_grant_spell_pushes_learned_spell() { let store = TrainerFake { @@ -443,8 +416,6 @@ fn learn_talent_with_a_grant_spell_pushes_learned_spell() { ); } -/// A passive pick still refreshes the TalentFrame: SMSG_LEARNED_SPELL for the rank spell the Module -/// taught (the pane derives shown ranks from known rank spells), then the points push. #[test] fn learn_talent_passive_pushes_rank_spell_and_points() { let store = TrainerFake { @@ -465,8 +436,6 @@ fn learn_talent_passive_pushes_rank_spell_and_points() { ); } -/// Rank N>1 replaces the previous rank's spell in the book. SMSG_SUPERCEDED_SPELL carries the OLD -/// rank in its first wire slot (cmangos order), as the trainer rank-upgrade path does. #[test] fn learn_talent_rank_upgrade_supersedes_the_previous_rank_spell() { let store = TrainerFake { @@ -535,8 +504,6 @@ fn direct_profession_training_request_sends_the_profession_list_type() { assert_eq!(list.spells[0].spell, 4036); } -/// The class gate removes the training service, not the creature: the NPC still talks, which the -/// gossip test `gossip_hides_the_train_and_unlearn_options_for_a_class_the_trainer_does_not_serve` covers. #[test] fn trainer_list_is_silently_dropped_for_a_player_the_trainer_does_not_serve() { let store = TrainerFake { diff --git a/gateway/src/world/transfer.rs b/gateway/src/world/transfer.rs index 03218eb1f..f323193a5 100644 --- a/gateway/src/world/transfer.rs +++ b/gateway/src/world/transfer.rs @@ -1,25 +1,14 @@ -//! The Gateway drives escrowed cross-database Transfers and resumes interrupted crossings. - use anyhow::{anyhow, Result}; use super::{party, Actor, WorldStore}; -/// The escrowed cross-shard Transfer steps, and the instance lease a crossing needs. pub(crate) trait TransferStore: Send + Sync { - /// The escrow row this shard holds for `character_guid`, if any — the transfer's identity, its - /// destination and the serialized character. `None` = not mid-transfer here. fn escrowed_transfer(&self, character_guid: u64) -> Option; - /// Where this shard's durable row says the character is going (`world::teleport_player` wrote - /// the destination there before despawning the entity). `None` = this shard has no row for it. fn character_destination(&self, character_guid: u64) -> Option; - /// `begin_transfer` — freeze + serialize + delete the live entity of `character`, the Character - /// `plan` names, in one transaction. fn begin_transfer(&self, character: Actor, plan: &TransferPlan) -> Result<()>; - /// Materialise the arrival copy from the carried blob. A session-less crossing binds its exact - /// source intent identity to the destination fence in the same transaction as the import. fn import_character_blob( &self, transfer_id: u64, @@ -28,17 +17,12 @@ pub(crate) trait TransferStore: Send + Sync { bot_arrival: Option<&BotTransferIntent>, ) -> Result<()>; - /// `confirm_import` — attest, on the SOURCE, that the destination copy is durable. fn confirm_import(&self, transfer_id: u64) -> Result<()>; - /// `finish_transfer` — delete-last: destroy the source copy and clear the escrow. fn finish_transfer(&self, transfer_id: u64) -> Result<()>; - /// `release_transfer` — drop a migrated blank arrival fence. New human and session-less - /// arrivals refuse because their exact crossing-specific reducer owns release. fn release_transfer(&self, transfer_id: u64) -> Result<()>; - /// Release one exact human arrival fence. fn release_player_transfer_arrival( &self, transfer_id: u64, @@ -46,26 +30,18 @@ pub(crate) trait TransferStore: Send + Sync { source: RealmLocatorPredecessor, ) -> Result<()>; - /// Exact destination fence identity used by the Realm locator recovery compare-and-set. fn transfer_arrival(&self, transfer_id: u64) -> Option; - /// Reconcile the arriving Character's authoritative party mirror before its destination fence - /// drops. A single-database store and a test store without realm-wide parties have no work. fn sync_transfer_arrival(&self, character_guid: u64) -> Result<()>; - /// Publish Realm's pending partition before the source is frozen. fn sync_transfer_pending(&self, character_guid: u64) -> Result<()>; - /// Record Realm-core's pending phase before the source is frozen and return its predecessor - /// locator revision. Single-database stores have no remote phase. fn begin_shard_index_transfer( &self, plan: &TransferPlan, bot_intent: Option<(&BotTransferIntent, u64)>, ) -> Result; - /// Settle a player crossing against the predecessor returned by - /// [`begin_shard_index_transfer`](Self::begin_shard_index_transfer). fn finish_player_shard_index_transfer( &self, plan: &TransferPlan, @@ -74,7 +50,6 @@ pub(crate) trait TransferStore: Send + Sync { source_revision: u64, ) -> Result<()>; - /// Resume a Realm pending phase from the destination Character after source finish. fn finish_pending_shard_index_transfer( &self, character_guid: u64, @@ -83,7 +58,6 @@ pub(crate) trait TransferStore: Send + Sync { arrival: &TransferArrival, ) -> Result<()>; - /// Bind this claimed intent to the Realm locator it is about to move from. fn bind_bot_transfer_locator( &self, intent: &BotTransferIntent, @@ -91,21 +65,14 @@ pub(crate) trait TransferStore: Send + Sync { claim_token: u64, ) -> Result<()>; - /// Compare-and-set Realm-core's locator for one exact session-less crossing. fn publish_bot_shard_index(&self, intent: &BotTransferIntent) -> Result<()>; - /// The source-side lease for a named instance: its map and owning party. Portal admission - /// creates this before a cross-Shard Transfer, so it remains the authority if party membership - /// changes while the Character is in Escrow. fn instance_partition(&self, instance_id: u64) -> Option<(u32, u64)>; - /// `ensure_instance` — mirror an instance id onto this shard, spawning its population once. fn ensure_instance(&self, instance_id: u64, map_id: u32, party_id: u64) -> Result<()>; - /// `evict_instance_population` — drop an instance's population here, keeping the lease row. fn evict_instance_population(&self, instance_id: u64) -> Result<()>; - /// Persist the exact claimed intent's destination-ready witness before release. fn mark_bot_transfer_arrival_ready( &self, intent_id: u64, @@ -114,10 +81,8 @@ pub(crate) trait TransferStore: Send + Sync { claim_token: u64, ) -> Result<()>; - /// Whether the current destination fence belongs to this exact source intent. fn bot_transfer_arrival_matches(&self, transfer_id: u64, intent: &BotTransferIntent) -> bool; - /// Release only the destination fence identified by this intent. A newer fence is untouched. fn release_bot_transfer_arrival( &self, transfer_id: u64, @@ -125,10 +90,6 @@ pub(crate) trait TransferStore: Send + Sync { ) -> Result<()>; } -/// Where a character is going. Derived from the character's own durable row on the source shard — -/// `world::teleport_player` writes the DESTINATION map/instance/position there before it despawns -/// the entity for a cross-map hop, so by the time the client acks the loading screen the row -/// already describes where it belongs. #[derive(Clone, Copy, Debug, PartialEq)] pub struct TransferPlan { pub transfer_id: u64, @@ -141,9 +102,6 @@ pub struct TransferPlan { pub dest_o: f32, } -/// An escrow row read back off a source shard: the transfer's identity, its destination, and the -/// serialized character. The `blob` is opaque to the gateway — it is produced by `begin_transfer` -/// and consumed by `import_character_blob`, and the gateway only carries it. #[derive(Clone, Debug, PartialEq)] pub struct EscrowedTransfer { pub transfer_id: u64, @@ -153,7 +111,6 @@ pub struct EscrowedTransfer { pub blob: Vec, } -/// The destination fence that licenses one exact Realm locator settlement. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TransferArrival { pub character_guid: u64, @@ -165,7 +122,6 @@ pub struct TransferArrival { pub bot_controller_generation: u64, } -/// Realm locator state captured before the source Character was frozen. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RealmLocatorPredecessor { pub map_id: u32, @@ -173,7 +129,6 @@ pub struct RealmLocatorPredecessor { pub revision: u64, } -/// One durable session-less crossing read from a World Shard. #[derive(Clone, Debug, PartialEq, Eq)] pub struct BotTransferIntent { pub id: u64, @@ -190,16 +145,10 @@ pub struct BotTransferIntent { pub source_locator_revision: u64, } -/// The transfer id for a character. See the module doc: the guid IS the id. pub fn transfer_id_for(character_guid: u64) -> u64 { character_guid } -/// The ten step boundaries `LYRACORE_TRANSFER_ABORT_AFTER` can name, in drive order. These are the -/// `WorldStore` method names — the same vocabulary the headless crash-matrix test already speaks — -/// so a step name is greppable straight to the call it follows. The list is also the drive's own -/// sequence: `an_unset_transfer_abort_injection_changes_nothing` asserts the two are equal, so a -/// step added to the drive without a crash point here is a hole in the gateway-kill recovery matrix. pub const ABORT_STEPS: [&str; 10] = [ "sync_transfer_pending", "begin_transfer", @@ -213,9 +162,6 @@ pub const ABORT_STEPS: [&str; 10] = [ "evict_instance_population", ]; -/// Bot crossings add source binding and arrival-ready witnesses around the shared Transfer. Keep a -/// distinct sequence so the established human matrix remains exact while the session-less process -/// restart caller can stop after every committed phase it owns. pub const BOT_ABORT_STEPS: [&str; 12] = [ "bind_bot_transfer_locator", "sync_transfer_pending", @@ -231,45 +177,17 @@ pub const BOT_ABORT_STEPS: [&str; 12] = [ "evict_instance_population", ]; -/// Deliberate, injected death — the `kill -9` half of the gateway-kill recovery requirement (a -/// scripted kill at each transfer step must recover with the character whole on exactly one shard -/// and the client able to reconnect). -/// -/// **Why `abort()` and not a `panic!` or an `Err`.** That requirement is only meaningful if the -/// process leaves behind *exactly* the durable state a real `kill -9` at this boundary would leave. That rules out -/// the two cheaper options: -/// -/// * a returned `Err` is a CLEAN failure: `run_transfer`'s caller unwinds the world session's normal -/// error path (log, respond to the client, drop the session and its despawn/cleanup handlers) and -/// the gateway keeps serving. State a crash would never have produced gets written. -/// * a `panic!` unwinds ONE thread. The driver runs on the world session's own thread, so the -/// process survives, every `Drop` on that thread's stack runs, and a `catch_unwind` anywhere up -/// the stack turns the "crash" into an ordinary error. -/// -/// `std::process::abort()` raises SIGABRT: the whole process dies at once, no unwinding, no `Drop`, -/// no `atexit`, uncatchable. Self-sending SIGKILL proper would need a `libc` dependency; SIGABRT is -/// the faithful stand-in available from `std` alone. Nothing is flushed either, hence the explicit -/// `log::logger().flush()` first — otherwise the loud line announcing the injection is lost. #[cfg(not(test))] fn die_by_injection() -> ! { log::logger().flush(); std::process::abort() } -/// Note: under `cargo test` the same site panics instead, because a `cargo test` process that -/// aborts takes the whole test binary with it and proves nothing. The env gate, the step names and -/// the ordering are identical in both builds — only the manner of death differs, and the manner of -/// death is exactly the part that cannot be asserted in-process anyway. #[cfg(test)] fn die_by_injection() -> ! { panic!("LYRACORE_TRANSFER_ABORT_AFTER: injected abort"); } -/// One crash point. `abort_after` is the (already read) value of `LYRACORE_TRANSFER_ABORT_AFTER`. -/// -/// Note: unconfigured, this is `None != Some(step)` and a return — no allocation, no formatting, -/// no log, no behaviour change. The one env read that produces `abort_after` happens once per -/// transfer, and a transfer happens once per loading screen. #[inline] fn abort_point(abort_after: Option<&str>, step: &str, transfer_id: u64) { if abort_after != Some(step) { @@ -282,42 +200,8 @@ fn abort_point(abort_after: Option<&str>, step: &str, transfer_id: u64) { die_by_injection() } -/// Drive one escrowed transfer of `plan.character_guid` from `src` to `dst` to completion. -/// -/// Re-entrant: calling it again after ANY step (including after full completion) re-runs the -/// sequence with every landed step replaying as a no-op. That is how a gateway killed mid-transfer -/// recovers — it simply calls this again at the client's next world entry. -/// -/// # The ordering, and what each step's failure costs -/// -/// | crash after | source | destination | recovery | -/// |---|---|---|---| -/// | 1 begin | frozen, escrowed | nothing | re-run: begin replays, import proceeds | -/// | 3 import | frozen, escrowed | durable + fenced | re-run: import replays, confirm proceeds | -/// | 4 confirm | frozen, attested | durable + fenced | re-run, or the SOURCE reaper rolls forward | -/// | 5 finish | gone | durable + fenced | re-run: `settle` prepares and releases the arrival copy; the realm-core index is repaired on entry | -/// | 6 publish | gone | durable + fenced | re-run prepares and releases the arrival copy with the index correct | -/// | 7 mirror | gone | durable + fenced | re-run repeats the party mirror and then releases | -/// | 8 release | gone | LIVE | done; step 9 is cleanup | -/// -/// No reachable point has zero durable copies, and no reachable point has the character LIVE on -/// both databases: the source is frozen from step 1 (its escrow row fences it) and destroyed at 5, -/// and the destination is fenced from 3 until 8. -/// -/// # Fault injection (the gateway-kill recovery requirement) -/// -/// A log watcher cannot target the interval after a named database commit reliably. -/// `LYRACORE_TRANSFER_ABORT_AFTER=` makes each crash point deterministic: the named step runs, -/// commits, and then the process aborts (see -/// [`die_by_injection`]). The accepted names are [`ABORT_STEPS`]. Unset — the only configuration any -/// real run has, costs one `env::var` per transfer and ten `Option<&str>` compares that all miss. pub fn run_transfer(src: &dyn WorldStore, dst: &dyn WorldStore, plan: &TransferPlan) -> Result<()> { - // Deliberate simplification: ONE env read, threaded down as a plain `Option<&str>` rather than - // re-read at each step — which also lets the tests drive every crash point without mutating - // process-global env underneath a parallel test runner. let abort_after = std::env::var("LYRACORE_TRANSFER_ABORT_AFTER").ok(); - // A typo'd step name would silently never fire, and the crash matrix would report a PASS for a - // crash that never happened. Say so — only ever reached when the operator opted in. if let Some(step) = abort_after.as_deref() { if !ABORT_STEPS.contains(&step) { log::error!( @@ -329,14 +213,10 @@ pub fn run_transfer(src: &dyn WorldStore, dst: &dyn WorldStore, plan: &TransferP run_transfer_injected(src, dst, plan, abort_after.as_deref()) } -/// How long the escrow row may take to reach the coordinator cache after `begin_transfer` returned. -/// The reducer call answers over a call-pipe connection; the row arrives over the coordinator's -/// own socket, so an immediate read can lose that race and refuse a transfer the module already -/// froze. Three seconds is far above any observed lag and far below a loading screen. +// Call-pipe completion can precede the Escrow row reaching the Coordinator cache. const ESCROW_VISIBLE_WITHIN: std::time::Duration = std::time::Duration::from_secs(3); const ESCROW_POLL: std::time::Duration = std::time::Duration::from_millis(20); -/// The escrow `begin_transfer` just opened, waiting out the cross-connection lag. fn escrow_after_begin(src: &dyn WorldStore, plan: &TransferPlan) -> Result { let deadline = std::time::Instant::now() + ESCROW_VISIBLE_WITHIN; loop { @@ -356,8 +236,6 @@ fn escrow_after_begin(src: &dyn WorldStore, plan: &TransferPlan) -> Result TransferPlan { transfer_id: e.transfer_id, character_guid, dest_map_id: e.dest_map_id, dest_instance_id: e.dest_instance_id, - // The escrow already carries the destination position inside its blob; these are only - // used by a FRESH `begin_transfer`, which this resume path never reaches (begin - // replays). dest_x: 0.0, dest_y: 0.0, dest_z: 0.0, diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 74ea076b2..a1a2503e0 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -1,24 +1,8 @@ -//! Cross-database Transfer tests through the shared WorldFake topology. - use super::shard_routing_tests::{drive_routed_session, sharded_stores, ShardCallLog}; use super::*; -// =========================================================================================== -// Cross-database Transfer and Shard routing -// -// `FakeShardDb` is a faithful re-implementation of the MODULE's escrow guards -// (`module/src/transfer/mod.rs`'s `plan_begin`/`plan_import`/`plan_finish` + `release_transfer`'s -// source check), so these tests exercise the one thing the module cannot check for itself: the -// ORDER the gateway drives two databases in, because each database can only see its own ledger. -// Two `FakeShardDb`s stand for two SpacetimeDB databases — the same shape `sharded_stores` -// uses for routing. -// - fn no_hang(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { let h = std::thread::spawn(f); - // Poll `is_finished` rather than shipping the result through a channel, so a body that PANICS - // still propagates its own panic message (via `resume_unwind`) instead of being reported as a - // hang. The hang is the only thing this wrapper is allowed to rename. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); while !h.is_finished() { assert!( @@ -35,8 +19,6 @@ fn no_hang(secs: u64, f: impl FnOnce() -> T + Send + 'static) } } -/// A store handle over a `FakeShardDb`, with an optional injected failure at one named step — how -/// "the gateway was killed here" is simulated (that step's transaction never commits). fn xstore( shard: &str, db: std::sync::Arc, @@ -60,9 +42,6 @@ fn xstore( const XGUID: u64 = 1; -/// A fresh two-database topology: the character is resident on `world`, and its durable row already -/// names the instance destination — which is what `teleport_player` writes before it despawns the -/// entity for a cross-map hop, i.e. the state the WORLDPORT_ACK handler finds. #[allow(clippy::type_complexity)] fn xdb_pair( kill_at: Option<&str>, @@ -88,9 +67,6 @@ fn xdb_pair( (src, dst, src_db, dst_db, calls) } -/// -/// This test asserts the property directly: hold a guard, take the same mutex again, and the -/// process must come back with a failure rather than never coming back at all. #[test] fn a_re_entrant_lock_on_the_fake_shard_db_fails_instead_of_hanging() { let db = FakeShardDb::with_character( @@ -102,7 +78,6 @@ fn a_re_entrant_lock_on_the_fake_shard_db_fails_instead_of_hanging() { }, ); let _held = lk(&db.in_rows); - // `live()` reads `in_rows`. With `lock()` this call never returns. let hit = no_hang(5, { let db = db.clone(); move || { @@ -129,7 +104,6 @@ fn a_re_entrant_lock_on_the_fake_shard_db_fails_instead_of_hanging() { #[test] fn a_character_moves_whole_between_two_databases_with_its_rows() { - // Wall-clock net: a wedged driver must FAIL, not hang the suite. no_hang(30, || { let (src, dst, src_db, dst_db, calls) = xdb_pair(None); super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID) @@ -166,22 +140,13 @@ fn a_character_moves_whole_between_two_databases_with_its_rows() { assert_eq!( log, vec![ - // The speculative fence-clear on the SOURCE, before anything else: the transfer id is - // the character guid, so an arrival in-row left here by an earlier hop would make - // `begin_transfer` replay into a no-op (see - // `a_second_transfer_of_the_same_character_is_never_swallowed_as_a_replay`). It costs - // one no-op reducer call on a fresh transfer and is the same cheap release the - // already-home path makes. ("world".to_string(), "release_transfer".to_string()), - // Realm-core's pending phase is mirrored before the source can disappear. ("world".to_string(), "sync_transfer_pending".to_string()), ("world".to_string(), "begin_transfer".to_string()), ("instances".to_string(), "ensure_instance".to_string()), ("instances".to_string(), "import_character_blob".to_string()), ("world".to_string(), "confirm_import".to_string()), ("world".to_string(), "finish_transfer".to_string()), - // realm-core learns where the character settled HERE — after the escrow's own - // transaction committed, before the arrival copy goes live. ("world".to_string(), "publish_shard_index".to_string()), ("instances".to_string(), "sync_transfer_arrival".to_string()), ("instances".to_string(), "release_transfer".to_string()), @@ -192,10 +157,6 @@ fn a_character_moves_whole_between_two_databases_with_its_rows() { }); } -/// `begin_transfer` answers over a call-pipe connection while the escrow row travels over the -/// coordinator's own socket, so the first read after the reply can miss a row the module already -/// wrote. The driver waits the lag out instead of refusing a character it has just frozen — the -/// refusal was 5 of 5 crossings on a loaded realm, each leaving a character stuck in escrow. #[test] fn an_escrow_row_that_lags_the_reducer_reply_is_waited_for_not_refused() { no_hang(30, || { @@ -219,15 +180,6 @@ fn an_escrow_row_that_lags_the_reducer_reply_is_waited_for_not_refused() { }); } -/// The realm-core character→shard index is written BY THE TRANSFER, not left for -/// a future login's probe to discover. -/// -/// Before this, `set_character_shard` had exactly one caller in the whole gateway — the login -/// self-heal — so a completed cross-database transfer updated the SOURCE database's copy of the -/// index (transactionally, inside `finish_transfer`) and nothing else. The copy world entry -/// actually reads is realm-core's, and it learned about the move at the character's next login, by -/// probing every shard. The requirement that realm-core's index be correct without relying on that -/// probe was unmet, and looked correct only because the probe masked it. #[test] fn a_completed_transfer_publishes_the_destination_to_the_realm_core_index() { no_hang(30, || { @@ -251,14 +203,11 @@ fn a_completed_transfer_publishes_the_destination_to_the_realm_core_index() { fn a_resumed_transfer_publishes_the_escrow_destination_not_the_callers_plan() { no_hang(30, || { let (src, dst, _src_db, _dst_db, _calls) = xdb_pair(None); - // Open the escrow against the destination the durable row names (map 36 / instance 7). let opened = src .character_destination(XGUID) .expect("the durable row names a destination"); src.begin_transfer(Actor::new(XGUID).unwrap(), &opened) .expect("the escrow opens"); - // Now drive with a plan naming somewhere ELSE. `begin_transfer` answers `Replay` — the row - // on disk is the authority and the plan is ignored — so the transfer settles at 36/7. let stale = super::transfer::TransferPlan { dest_map_id: 0, dest_instance_id: 0, @@ -279,8 +228,6 @@ fn a_resumed_transfer_publishes_the_escrow_destination_not_the_callers_plan() { }); } -/// The write is a REQUIRED step of the drive, not a best-effort side call: an unreachable -/// realm-core fails the transfer rather than silently leaving the directory wrong. #[test] fn a_transfer_whose_index_publish_fails_does_not_report_success() { no_hang(30, || { @@ -317,9 +264,6 @@ fn a_transfer_whose_index_publish_fails_does_not_report_success() { this fix exists to remove. Got: {err:#}" ); - // …and the failure is RECOVERABLE, which is what makes propagating it safe: the character - // is already whole at the destination, only fenced, so a fresh driver with a working - // realm-core finishes the job. Nothing is lost and nothing is duplicated. assert!(!src_db.has(XGUID) && dst_db.has(XGUID) && !dst_db.live(XGUID)); let src2 = xstore("world", src_db.clone(), calls.clone(), None); let dst2 = xstore("instances", dst_db.clone(), calls, None); @@ -332,23 +276,11 @@ fn a_transfer_whose_index_publish_fails_does_not_report_success() { #[test] fn a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy() { - // Wall-clock net: a wedged driver must FAIL, not hang the suite. no_hang(30, || { - // Headless half: kill the driver at every step boundary, then let a fresh STATELESS - // driver re-run — the character ends whole on exactly one shard, every time. - // - // Driven off `ABORT_STEPS` itself rather than a literal copy of it. The index-publish fix - // added step 5b (`publish_shard_index`) to `ABORT_STEPS` and to the drive, but the literal - // list here was not updated — so the one boundary that fix introduced was the one boundary - // this matrix did not kill at, while that fix's own PR reported the matrix as covering it. - // A hand-copied list of the thing under test can only ever drift in the direction that - // loses coverage. for kill_at in super::transfer::ABORT_STEPS { let (src, dst, src_db, dst_db, _) = xdb_pair(Some(kill_at)); let first = super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID); if kill_at == "evict_instance_population" { - // The eviction is deliberately best-effort: the character is already whole by then, so - // failing the player's login over a performance wart would be strictly worse. first.expect("an eviction failure must not fail the transfer"); } else { assert!( @@ -357,7 +289,6 @@ fn a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy() { ); } - // INVARIANT AT THE CRASH POINT. assert!( src_db.has(XGUID) || dst_db.has(XGUID), "ZERO durable copies after a kill at {kill_at} — the character was lost" @@ -367,9 +298,6 @@ fn a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy() { "the character is LIVE on both databases after a kill at {kill_at} — a dupe" ); - // A brand-new driver with NO memory of the interrupted attempt: it re-derives the plan from - // durable state alone (the escrow row, or the character row's own destination), which is - // the whole of gateway-restart recovery. let calls: ShardCallLog = Default::default(); let src2 = xstore("world", src_db.clone(), calls.clone(), None); let dst2 = xstore("instances", dst_db.clone(), calls.clone(), None); @@ -402,10 +330,6 @@ fn a_gateway_kill_at_every_transfer_step_recovers_to_exactly_one_whole_copy() { }); } -/// `LYRACORE_TRANSFER_ABORT_AFTER=` must let the named step COMMIT and then kill the driver -/// before the next one — that is the only way the live crash-recovery matrix can aim at a specific -/// crash boundary in a drive that completes in ~17ms. In a `cargo test` build the injected death is -/// a panic rather than `process::abort()` (see `transfer::die_by_injection`), so it is observable here. #[test] fn an_injected_abort_stops_the_driver_after_the_named_step_and_before_the_next() { for (i, step) in super::transfer::ABORT_STEPS.iter().enumerate() { @@ -423,7 +347,6 @@ fn an_injected_abort_stops_the_driver_after_the_named_step_and_before_the_next() outcome.as_ref().map(|r| r.is_ok()) ); - // The step named must have RUN (its transaction committed), and nothing after it may have. let log = calls.lock().unwrap().clone(); let names: Vec<&str> = log.iter().map(|(_, n)| n.as_str()).collect(); assert_eq!( @@ -442,7 +365,6 @@ fn an_injected_abort_stops_the_driver_after_the_named_step_and_before_the_next() i + 1 ); - // And the same invariant the live matrix asserts against the two real databases. assert!( src_db.has(XGUID) || dst_db.has(XGUID), "ZERO durable copies after an injected abort at {step} — the character was lost" @@ -454,9 +376,6 @@ fn an_injected_abort_stops_the_driver_after_the_named_step_and_before_the_next() } } -/// The unconfigured default must be indistinguishable from having no injection point at all: same -/// shard calls, same order, same result. (This repo has shipped three "unconfigured is -/// byte-identical" violations already; this is the guard against a fourth.) #[test] fn an_unset_transfer_abort_injection_changes_nothing() { assert_eq!( @@ -493,10 +412,6 @@ fn an_unset_transfer_abort_injection_changes_nothing() { #[test] fn the_driver_never_attests_an_import_that_did_not_commit() { - // `confirm_import` files the in-row that licenses `finish_transfer` to CASCADE-DELETE the - // source copy. Attesting before the destination copy is durable is the one unrecoverable - // ordering bug in the protocol — and it is the GATEWAY's to prevent, because the source - // database cannot see the destination. let (src, dst, src_db, dst_db, calls) = xdb_pair(Some("import_character_blob")); let err = super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID) .expect_err("a failed import must abort the drive"); @@ -520,8 +435,6 @@ fn the_driver_never_attests_an_import_that_did_not_commit() { #[test] fn a_transfer_is_never_finished_before_the_destination_copy_is_durable() { - // The module's own guard, driven through the gateway: `finish_transfer` refuses while the - // in-row is absent, so even a driver that skipped `confirm_import` cannot destroy the source. let (_, _, src_db, dst_db, _) = xdb_pair(None); let calls: ShardCallLog = Default::default(); let src = xstore("world", src_db.clone(), calls.clone(), None); @@ -541,8 +454,6 @@ fn a_transfer_is_never_finished_before_the_destination_copy_is_durable() { #[test] fn the_arrival_copy_is_fenced_until_the_source_copy_is_destroyed() { - // Delete-last, observed from the outside: at every prefix of the drive there is at most ONE - // live copy, and the destination only goes live after the source copy is gone. let (_, _, src_db, dst_db, _) = xdb_pair(None); let calls: ShardCallLog = Default::default(); let src = xstore("world", src_db.clone(), calls.clone(), None); @@ -586,15 +497,6 @@ fn the_arrival_copy_is_fenced_until_the_source_copy_is_destroyed() { assert!(dst_db.live(XGUID)); } -/// The regression this test exists for: the SECOND party member walking -/// into a dungeon whose instance the first member already opened. -/// -/// Live, this was the case that broke — the first player transferred perfectly, repeatedly, and the -/// party member behind her hung on the loading screen forever with `run_transfer` never entered. -/// The driver half of that is here: two characters whose durable rows name the SAME instance both -/// have to land on the instances shard, in that one instance, with the destination mirroring it -/// once and spawning its population once. A second `ensure_instance` that re-created the dungeon -/// would be a party playing in two copies of Deadmines. #[test] fn a_second_party_member_transfers_into_the_instance_the_first_one_opened() { no_hang(30, || { @@ -609,9 +511,6 @@ fn a_second_party_member_transfers_into_the_instance_the_first_one_opened() { payload: "leader-gear".into(), }, ); - // The second member resolved to the SAME instance id at the portal — that is what the - // module's party-first resolution (and the `game_instance_binding` each member carries in - // their blob) is for. Both rows sit on the world shard; both are owed a transfer. lk(&src_db.characters).insert( MEMBER, FakeChar { @@ -673,9 +572,6 @@ fn a_second_party_member_transfers_into_the_instance_the_first_one_opened() { #[test] fn the_instance_is_mirrored_before_the_character_arrives_in_it() { - // Ordering, not just presence: `player_login`'s stranding guard DIVERTS a character whose - // `pending_instance_id` names an instance that does not exist on this shard — so an import - // that landed before the mirror would put the player outside the dungeon they walked into. let (src, dst, _, _, calls) = xdb_pair(None); super::transfer::settle_transfer(src.as_ref(), dst.as_ref(), XGUID).unwrap(); let log = calls.lock().unwrap().clone(); @@ -695,8 +591,6 @@ fn the_instance_is_mirrored_before_the_character_arrives_in_it() { #[test] fn an_open_world_destination_mirrors_and_evicts_nothing() { - // Zoning OUT: instance 0 is the open world, which is not an instance and must never be - // "mirrored" (the module refuses id 0) or evicted (that would tear down the open world). let calls: ShardCallLog = Default::default(); let src_db = FakeShardDb::with_character( XGUID, @@ -722,10 +616,6 @@ fn an_open_world_destination_mirrors_and_evicts_nothing() { #[test] fn a_character_already_on_its_home_shard_is_not_transferred_but_is_unfenced() { - // The steady state (every login that does not cross a boundary), PLUS the one crash window - // that leaves an arrival fence behind with no escrow anywhere to re-drive from: killed between - // `finish_transfer` and `release_transfer`. Without the speculative release the character would - // be fenced out of its own login forever. let calls: ShardCallLog = Default::default(); let db = FakeShardDb::with_character( XGUID, @@ -735,7 +625,7 @@ fn a_character_already_on_its_home_shard_is_not_transferred_but_is_unfenced() { payload: "gear+spells".into(), }, ); - lk(&db.in_rows).insert(XGUID, XGUID); // the orphaned arrival fence + lk(&db.in_rows).insert(XGUID, XGUID); let home = xstore("instances", db.clone(), calls.clone(), None); assert!(!db.live(XGUID), "precondition: the character is fenced"); @@ -783,14 +673,6 @@ fn a_normal_resident_does_not_need_transfer_arrival_repair() { #[test] fn a_second_transfer_of_the_same_character_is_never_swallowed_as_a_replay() { - // - // Reachable state: the character hopped world -> instances and the driver died between - // `finish_transfer` and `release_transfer`, so the instances shard holds the character AND an - // unreleased arrival in-row under id == guid. Now it has to hop OUT again (its location is - // owned by another shard: a shard-map edit, or a diverted - // instance re-entry). Without the fence being cleared first, `begin_transfer` on the instances - // shard replays into `Ok(())` while escrowing NOTHING — and the character is stuck on a shard - // it can never leave, failing its own login on every attempt, with no operator recourse. let calls: ShardCallLog = Default::default(); let src_db = FakeShardDb::with_character( XGUID, @@ -800,7 +682,7 @@ fn a_second_transfer_of_the_same_character_is_never_swallowed_as_a_replay() { payload: "gear+spells".into(), }, ); - lk(&src_db.in_rows).insert(XGUID, XGUID); // the previous hop's unreleased fence + lk(&src_db.in_rows).insert(XGUID, XGUID); let dst_db = FakeShardDb::empty(); let src = xstore("instances", src_db.clone(), calls.clone(), None); let dst = xstore("world", dst_db.clone(), calls.clone(), None); @@ -837,10 +719,6 @@ fn a_second_transfer_of_the_same_character_is_never_swallowed_as_a_replay() { #[test] fn a_failed_transfer_fails_the_login_instead_of_entering_the_world_anyway() { - // A half-moved character must never be let into the world on whichever shard happened to - // answer. Both outcomes are recoverable (the escrow holds and the next login re-drives it), but - // only refusing is honest — and entering anyway is how a character ends up live on the shard - // that is about to have its copy destroyed. let (store, _) = sharded_stores(); let failing = std::sync::Arc::new(WorldFake { topology: TopologyState { @@ -866,7 +744,6 @@ fn a_failed_transfer_fails_the_login_instead_of_entering_the_world_anyway() { CMSG_PLAYER_LOGIN { guid: Guid::new(1) } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // No login sequence arrives; the session ends with the transfer's error. let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); drop(client); let outcome = server.join().unwrap(); @@ -879,10 +756,6 @@ fn a_failed_transfer_fails_the_login_instead_of_entering_the_world_anyway() { #[test] fn entering_the_world_binds_this_accounts_identity_on_the_shard_it_landed_on() { - // A character that arrived via `import_character_blob` has only a SHADOW account row on the - // destination, with no identity bound — and `world::player_login` resolves its caller through - // `account_by_identity`. Without this bind the arriving player cannot log in at all, on a - // database the logon tier never touched. let (store, calls) = sharded_stores(); let home = store .topology @@ -910,9 +783,6 @@ fn entering_the_world_binds_this_accounts_identity_on_the_shard_it_landed_on() { #[test] fn a_resumed_transfer_reuses_the_escrowed_destination_not_the_character_row() { - // Resume authority: once an escrow exists, ITS destination is the one the destination shard may - // already hold an imported copy for. Re-deriving from the (frozen) character row instead would - // drive the second half of the transfer at a different place than the first half imported into. let calls: ShardCallLog = Default::default(); let src_db = FakeShardDb::with_character( XGUID, @@ -928,7 +798,7 @@ fn a_resumed_transfer_reuses_the_escrowed_destination_not_the_character_row() { transfer_id: XGUID, character_guid: XGUID, dest_map_id: 36, - dest_instance_id: 42, // ← the escrow's destination, deliberately NOT the row's + dest_instance_id: 42, blob: fake_blob(XGUID, 36, 42, "gear+spells"), }, ); @@ -948,17 +818,6 @@ fn a_resumed_transfer_reuses_the_escrowed_destination_not_the_character_row() { ); } -// =========================================================================================== -// Session-less crossings — the intent row a Package writes and the coordinator relay executes. -// -// A bot is a real character with no session, so nothing drove it through the escrowed transfer: -// the driver was always the client's own loading-screen ack. These tests drive `run_bot_transfer` -// against the same two `FakeShardDb`s the player-side tests above use, so the transfer they assert -// is the real one, module guards and all. -// =========================================================================================== - -/// The bot's guid. Deliberately not [`XGUID`]: these tests share the party fixture's numbering, -/// where 5 is the playerbot. const BOT_GUID: u64 = super::party_tests::BOT; const SOURCE_MODULE: spacetimedb_sdk::Identity = spacetimedb_sdk::Identity::from_byte_array([7; 32]); @@ -980,10 +839,6 @@ fn bot_intent() -> super::transfer::BotTransferIntent { } } -/// A source shard holding a session-less character that has ALREADY been placed at -/// `(dest_map, dest_instance)`, plus the shard the Shard Map serves that destination from. This is -/// the state `transfer::emit_bot_transfer_intent` leaves behind: the durable row names the -/// destination, the live entity is gone, and the intent row is on its way to the gateway. #[allow(clippy::type_complexity)] fn bot_pair( dest_map: u32, @@ -1033,7 +888,6 @@ fn bot_pair( (src, src_db, dst_db, calls) } -/// **AC: an intent row moves a session-less character to the shard that serves the destination.** #[test] fn an_intent_row_crosses_a_session_less_character_to_the_destination_shard() { let (src, src_db, dst_db, _calls) = bot_pair(36, 7); @@ -1135,9 +989,6 @@ fn a_bot_abort_at_every_transfer_step_recovers_from_durable_witnesses() { ); let mut resumed = intent.clone(); - // A real restarted worker receives these two fields from its retained source intent. This - // lower-rung Fake supplies the same durable resume input explicitly; the process caller - // must query the row instead. resumed.source_locator_revision = 3; resumed.arrival_ready = index >= super::transfer::BOT_ABORT_STEPS @@ -1548,7 +1399,6 @@ fn a_ready_intent_completes_after_the_bot_has_crossed_onward() { .expect("the first crossing settles"); assert!(!src_db.has(BOT_GUID) && dst_db.live(BOT_GUID)); - // The next crossing has already removed the Character from this intent's first destination. lk(&dst_db.characters).remove(&BOT_GUID); let ready = super::transfer::BotTransferIntent { arrival_ready: true, @@ -1578,8 +1428,6 @@ fn an_old_ready_intent_does_not_release_a_newer_arrival_fence() { .expect("the first crossing settles"); assert!(!src_db.has(BOT_GUID) && dst_db.live(BOT_GUID)); - // A later crossing has brought the same Character back behind a new fence. Transfer ids reuse - // the Character guid, so only the destination's exact intent identity distinguishes this row. lk(&dst_db.in_rows).insert(BOT_GUID, BOT_GUID); let new_source = spacetimedb_sdk::Identity::from_byte_array([8; 32]); lk(&dst_db.bot_arrivals).insert(BOT_GUID, (new_source, 92, 5, 5_000)); @@ -1674,8 +1522,6 @@ fn a_blank_migrated_arrival_fence_is_not_adopted() { assert!(!dst_db.live(BOT_GUID)); } -/// **AC: the return crossing uses the same row.** The instance shard writes an intent naming the -/// open world, and the identical relay drives it back. #[test] fn the_same_intent_row_carries_the_bot_back_out() { let (src, src_db, dst_db, _calls) = bot_pair(0, 0); @@ -1685,9 +1531,6 @@ fn the_same_intent_row_carries_the_bot_back_out() { assert_eq!(dst_db.get(BOT_GUID).unwrap().map_id, 0); } -/// **AC: a stale intent is refused with a log line, not a crash.** The module places a bot before -/// it records the crossing, so a row whose destination the character row does not name describes a -/// move that never happened — a dead bot teleports nowhere. #[test] fn an_intent_the_character_row_does_not_name_is_refused() { let (src, src_db, dst_db, calls) = bot_pair(0, 0); @@ -1711,7 +1554,6 @@ fn an_intent_the_character_row_does_not_name_is_refused() { ); } -/// **AC: a dead bot is refused.** Its character row is gone, so there is nothing to escrow. #[test] fn an_intent_for_a_character_this_shard_does_not_hold_is_refused() { let (src, src_db, dst_db, calls) = bot_pair(36, 7); @@ -1730,9 +1572,6 @@ fn an_intent_for_a_character_this_shard_does_not_hold_is_refused() { .any(|(_, c)| c == "begin_transfer")); } -/// A realm with one Shard: the module's teleport WAS the whole crossing, so the relay has nothing -/// to do and must not treat that as a failure. This is the common dev and test topology, and the -/// same Package code runs on it. #[test] fn a_destination_this_shard_already_serves_is_a_completed_crossing() { let calls: ShardCallLog = Default::default(); @@ -1751,12 +1590,6 @@ fn a_destination_this_shard_already_serves_is_a_completed_crossing() { assert!(calls.lock().unwrap().is_empty(), "and no step may run"); } -/// **AC: group membership survives a mirror failure during the crossing.** -/// -/// The bot is invited on the open-world shard and answers for itself (it has no client), then -/// crosses. Realm-core owns the membership the whole time; what this pins is that the shard the bot -/// ARRIVES on can read its party, which is what its kill-XP split, quest credit and loot rules run -/// against. #[test] fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { use super::party_tests::{character, GINGER}; @@ -1820,7 +1653,6 @@ fn the_bots_arrival_fence_survives_a_party_mirror_failure_and_retry() { }, characters: vec![character(GINGER, "Ginger"), character(BOT_GUID, "Botty")], session: SessionState { - // The production shape of a playerbot: a live entity that never logged in. live_guids: vec![GINGER, BOT_GUID], ..Default::default() }, diff --git a/gateway/src/world/vendor_tests.rs b/gateway/src/world/vendor_tests.rs index 69e1c5bab..db5453d26 100644 --- a/gateway/src/world/vendor_tests.rs +++ b/gateway/src/world/vendor_tests.rs @@ -1,5 +1,3 @@ -//! Vendor replies through the Protocol Family and encrypted World Session. - use super::handlers::{InMemoryVendorActions, Vendor}; use super::*; @@ -15,8 +13,6 @@ fn run(actions: &InMemoryVendorActions, msg: impl Into) -> #[test] fn buy_item_err_sends_smsg_buy_failed() { - // When `buy_item` returns Err (e.g. "not enough money"), the gateway must send SMSG_BUY_FAILED - // with the matching BuyResult code so the player gets an on-screen error. let actions = InMemoryVendorActions { buy_error: Some(|| { crate::stdb::ReducerCallError::refused( @@ -50,10 +46,6 @@ fn buy_item_err_sends_smsg_buy_failed() { #[test] fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { - // The ring survives logout, so world entry rebuilds the tab: one fabricated item CREATE per - // entry, then the raw descriptor update. (An EMPTY ring emits nothing — every other login test - // reads the login sequence and then EOF, which is that case.) This stays a socket test because - // the replay's place in the world entry sequence is the fact under test. let store = std::sync::Arc::new({ let base = quest_store(); WorldFake { @@ -71,8 +63,6 @@ fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { other => panic!("expected a fabricated buyback item CREATE, got {other}"), } } - // The descriptor update is a hand-rolled partial VALUES mask gtker cannot decode; the frame is - // consumed either way, and EOF after it proves nothing else was sent. let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec); assert!(ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).is_err()); drop(client); @@ -81,9 +71,6 @@ fn login_replays_a_persisted_buyback_ring_after_the_login_sequence() { #[test] fn buyback_maps_the_wire_slot_enum_to_zero_based_ring_slots() { - // BuybackSlot rides as 69..=81 on the wire; the ring is 0-based, so Slot1 (69) takes entry 0 - // and Slot13 (81) takes entry 12. The ring holds 13 entries so both ends name a real one, and - // the later slot goes first so the earlier take does not shift it. let ring: Vec<(u32, u32, u32, u32)> = (0..13).map(|i| (100 + i, 1, 10, 0)).collect(); let actions = InMemoryVendorActions { ring: std::sync::Mutex::new(ring.clone()), diff --git a/gateway/src/world/weather_tests.rs b/gateway/src/world/weather_tests.rs index 032656df4..9f5364e46 100644 --- a/gateway/src/world/weather_tests.rs +++ b/gateway/src/world/weather_tests.rs @@ -1,8 +1,5 @@ -//! Zone weather: the world-entry packet from a Store, and the live relay across World Sessions. - use super::*; -/// A Store with one zone's sky, or one that cannot answer. struct WeatherFake { rows: Vec<(u32, codec::ZoneWeatherView)>, error: Option<&'static str>, @@ -21,7 +18,6 @@ impl WeatherStore for WeatherFake { } } -/// The weather packet a client entering `zone_id` receives. fn run(store: &WeatherFake, zone_id: u32) -> SMSG_WEATHER { match zone_weather_message(store, zone_id, WeatherChangeType::Instant) { ServerOpcodeMessage::SMSG_WEATHER(weather) => *weather, @@ -29,8 +25,6 @@ fn run(store: &WeatherFake, zone_id: u32) -> SMSG_WEATHER { } } -/// Story 10: a zone the Module has no weather row for is fine weather. World entry still sends the -/// packet, because a client told nothing keeps rendering whatever sky it arrived with. #[test] fn a_zone_with_no_weather_row_is_fine_weather() { let weather = run( @@ -46,7 +40,6 @@ fn a_zone_with_no_weather_row_is_fine_weather() { assert_eq!(weather.change, WeatherChangeType::Instant); } -/// A Store that cannot answer the weather question is a degraded sky, never a failed login. #[test] fn a_weather_read_failure_is_fine_weather() { let weather = run( @@ -78,8 +71,6 @@ fn a_zone_with_a_weather_row_gets_that_sky_and_no_other_zone_does() { assert_eq!(run(&store, 40).weather_type, WeatherType::Fine); } -/// Drive one World Session through the handshake and world entry, and hand back the client end -/// parked on the first packet that comes AFTER world entry. fn world_session_in_world( store: std::sync::Arc, character_guid: u64, @@ -98,7 +89,6 @@ fn world_session_in_world( (client, c_dec, server) } -/// One `game_zone_weather` row, as the Module writes it and the relay reads it. fn zone_weather_row( zone_id: u32, weather_type: u8, @@ -112,18 +102,6 @@ fn zone_weather_row( } } -/// Stories 5 and 6 end to end, over the real cipher: two World Sessions on real socket pairs share -/// one shard's relay, one standing in Elwynn Forest and one in Westfall. Forced Elwynn rain must -/// decode as valid rain on the Elwynn client and must not reach the Westfall client at all. -/// -/// Both characters stand on the SAME position, so nothing but the zone can separate them — the -/// spatial index has no say in weather. -/// -/// Westfall's own snow is a sentinel rather than a bare timeout. A session's writer is FIFO, so an -/// Elwynn row that leaked into Westfall's routing would arrive on that socket BEFORE the snow; the -/// assertion therefore fails deterministically if the zone filter goes away, instead of resting on -/// a socket that happened to stay quiet. The bounded silence afterwards closes the mirror case: -/// Westfall's snow must not reach Elwynn either. #[test] fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { const ELWYNN: u32 = 12; @@ -152,7 +130,6 @@ fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { let (mut westfall_client, mut westfall_dec, westfall_server) = world_session_in_world(session_store(westfall_entity), 2); - // Elwynn is forced to heavy rain; Westfall drifts into light snow of its own. crate::codec::weather::subscriptions::relay_zone_weather( &view, 0, @@ -187,8 +164,6 @@ fn forced_elwynn_rain_reaches_only_the_client_standing_in_elwynn() { other => panic!("the Westfall client must receive Westfall's snow, got {other}"), } - // Neither zone's sky is echoed to the other. A short deadline is enough: both sockets have - // already delivered a packet enqueued after the one being tested for. let quiet = std::time::Duration::from_millis(250); for (name, client, dec) in [ ("Elwynn", &mut elwynn_client, &mut elwynn_dec), diff --git a/gateway/src/world/whisper.rs b/gateway/src/world/whisper.rs index e918ccef2..620eceb75 100644 --- a/gateway/src/world/whisper.rs +++ b/gateway/src/world/whisper.rs @@ -1,11 +1,3 @@ -//! Whisper target facts: the realm-wide reads a whisper needs before the Module can decide it. -//! -//! A whisper is a set of Realm Chat Lines (`handlers::chat`). The Module applies every whisper -//! rule on Realm-core, which holds no Characters, contact rows or live entities. So the Gateway -//! reads the target on whichever World Shard holds it and conveys the facts in the Durable -//! Request. Sharded and unsharded Gateways run this same path; on an unsharded Gateway -//! `world_stores()` is empty and every read is one cache read. - use crate::world::{ShardRoutingStore, SocialStore}; use anyhow::Result; @@ -13,17 +5,6 @@ use super::presence::{self, AwayStatus}; use super::WhisperTargetFacts; use lyracore_shared::chat::chat_kind; -/// The online Character `typed_name` reaches, with the facts the Module's whisper Gates need. -/// `None` when no online Character carries that name. -/// -/// Names are unique per Shard, not per Realm, so the name can resolve to several Characters. The -/// target is the first one whose `session_online` is set: `/w` addresses a logged-in Character. -/// The session flag, not a live entity, so a bot stays unwhisperable (bots never log in) and a -/// Character on a loading screen or in Transfer stays whisperable. -/// -/// A candidate whose Realm Presence cannot be read is skipped; if no other candidate is online, -/// that read's error is the answer, so the caller never reports a Character missing when a Shard -/// only failed to answer. pub(crate) fn target_facts( store: &St, speaker_guid: u64, @@ -70,21 +51,6 @@ fn away_kind_of(away: AwayStatus) -> u8 { } } -/// Does `owner_guid` have `other_guid` on their ignore list, on whichever Shard holds their -/// contact rows? -/// -/// `game_character_contact` is character-owned and travels with the Character, so exactly one -/// connected database has the rows, and which one is not knowable from here. The union is the -/// answer, in the same shape [`presence::resolve_by_name`] uses. `world_stores()` is empty on a -/// single-database Gateway, so this is one cache read there. -/// -/// A peer Shard that cannot answer contributes `false`, never `true`. Reading an `Err` as -/// "ignored" would drop the line and tell every sender "X is ignoring you" for as long as one peer -/// Shard is down. The other direction costs one whisper that reaches someone who ignores the -/// sender, and that client still drops it and answers `CMSG_CHAT_IGNORED`. -/// -/// The session's own handle propagates its `Err` instead: the whole session already reads through -/// that database, so a failure there is not "one Shard is down". pub(crate) fn ignored_anywhere( store: &St, owner_guid: u64, diff --git a/gateway/src/world/whisper_tests.rs b/gateway/src/world/whisper_tests.rs index fe0c757f9..5a5b70606 100644 --- a/gateway/src/world/whisper_tests.rs +++ b/gateway/src/world/whisper_tests.rs @@ -1,11 +1,3 @@ -//! Whisper target facts over the multi-shard Fake, and the whisper arm end to end. -//! -//! What executes here is production `world::whisper::target_facts` against the in-memory -//! multi-database topology the group tests use (`party_tests::party_topology`: Ginger in the open -//! world on `world`, Vim inside the dungeon on `instances`, plus an offline Character and a -//! playerbot). The Module's whisper rules are pinned in `module/src/realm_chat.rs`; the dispatch -//! arm's Refusal answers are pinned in `handlers/chat.rs`. - use super::party_tests::{character, party_topology, BOT, DORMANT, GINGER, TRIN, VIM}; use super::*; @@ -20,8 +12,6 @@ fn facts(guid: u64, name: &str) -> WhisperTargetFacts { } } -/// The live failure this path exists for: `/w Vim` from the open world, with Vim's row on the -/// instances database. The name resolves realm-wide, case-insensitively. #[test] fn a_whisper_target_on_another_shard_resolves_realm_wide() { let (_realm, world, instances, _calls) = party_topology(); @@ -50,7 +40,6 @@ fn an_unknown_name_reaches_nobody() { ); } -/// No offline whispering in vanilla: the gate is `game_character.online`, the session flag. #[test] fn an_offline_character_reaches_nobody() { let (_realm, world, _instances, _calls) = party_topology(); @@ -60,8 +49,6 @@ fn an_offline_character_reaches_nobody() { ); } -/// A playerbot has a live entity and never logs in, so its session flag stays false. Whisper -/// reads the session flag, so a bot stays unwhisperable, as it was before Realm Presence. #[test] fn a_playerbot_reaches_nobody_because_the_gate_reads_the_session_flag() { let (_realm, world, _instances, _calls) = party_topology(); @@ -75,8 +62,6 @@ fn a_playerbot_reaches_nobody_because_the_gate_reads_the_session_flag() { ); } -/// The ignore verdict is read from the Shard that holds the target's contact rows, which is not -/// the Shard the whisper was typed on. #[test] fn the_ignore_verdict_is_read_from_whichever_shard_holds_the_target() { let (_realm, world, instances, _calls) = party_topology(); @@ -114,8 +99,6 @@ fn an_ignore_of_somebody_else_is_not_the_speakers() { ); } -/// A peer Shard that cannot answer contributes no ignore. Reading its `Err` as "ignored" would -/// drop every whisper on the Realm and tell every sender they are ignored while it is down. #[test] fn an_unreachable_shard_does_not_make_a_whisper_look_ignored() { let (_realm, world, _instances, _calls) = party_topology(); @@ -137,8 +120,6 @@ fn an_unreachable_shard_does_not_make_a_whisper_look_ignored() { ); } -/// An AFK target on another Shard: the Away Status comes from its live entity's PLAYER_FLAGS -/// (0x02) and the Auto-Reply from that same Shard. #[test] fn an_afk_target_conveys_its_auto_reply_from_its_own_shard() { let (_realm, world, instances, _calls) = party_topology(); @@ -171,8 +152,6 @@ fn an_afk_target_conveys_its_auto_reply_from_its_own_shard() { ); } -/// DND (0x04) with no stored text conveys an empty Auto-Reply; the Module answers with the -/// default "Do not Disturb". #[test] fn a_dnd_target_without_stored_text_conveys_an_empty_reply() { let (_realm, world, instances, _calls) = party_topology(); @@ -192,7 +171,6 @@ fn a_dnd_target_without_stored_text_conveys_an_empty_reply() { ); } -/// A stored row without the flag is not an Away Status: PLAYER_FLAGS is the state observers see. #[test] fn a_reply_row_without_the_away_flag_is_ignored() { let (_realm, world, instances, _calls) = party_topology(); @@ -215,8 +193,6 @@ fn a_reply_row_without_the_away_flag_is_ignored() { ); } -/// Names are unique per Shard, not per Realm. A logged-out homonym on the sender's own Shard must -/// not shadow the Vim who is online inside the dungeon. #[test] fn a_homonym_on_the_senders_own_shard_does_not_shadow_the_online_target() { const HOMONYM: u64 = 9; @@ -275,8 +251,6 @@ fn a_homonym_on_the_senders_own_shard_does_not_shadow_the_online_target() { ); } -/// A candidate whose Realm Presence cannot be read is not reported missing: the read error is the -/// answer, and the arm logs it and answers `SMSG_CHAT_PLAYER_NOT_FOUND`. #[test] fn an_unreadable_candidate_is_an_error_not_a_missing_character() { let (_realm, world, _instances, _calls) = party_topology(); @@ -298,8 +272,6 @@ fn an_unreadable_candidate_is_an_error_not_a_missing_character() { assert!(error.to_string().contains("unreachable"), "got {error}"); } -/// Say, yell and emotes stay on the player's own Shard: they are spatial, which is the rule that -/// moved the whisper, which is not, onto Realm-core. #[test] fn say_yell_and_emotes_stay_on_the_players_own_shard_when_sharded() { let (realm, world, _instances, calls) = party_topology(); @@ -329,8 +301,6 @@ fn say_yell_and_emotes_stay_on_the_players_own_shard_when_sharded() { assert!(realm.chat.realm_whispers.lock().unwrap().is_empty()); } -/// A whisper line names its sender by guid and the client resolves the name over -/// `CMSG_NAME_QUERY`. Answered from the asking Shard alone, a cross-shard sender renders nameless. #[test] fn a_name_query_resolves_a_character_on_another_shard() { let (_realm, world, instances, _calls) = party_topology(); @@ -355,10 +325,6 @@ fn a_name_query_resolves_a_character_on_another_shard() { .is_none()); } -/// End to end over a real socket, through `run_world_session`'s own dispatch. The speaker guid -/// the Module acts on is an argument, so it must be the guid this socket entered the world with: -/// anything else is impersonation. An unresolvable name answers `SMSG_CHAT_PLAYER_NOT_FOUND` with -/// the typed name. #[test] fn a_real_session_whispers_across_shards_as_its_own_character() { let (realm, _world, instances, _calls) = party_topology(); @@ -423,8 +389,6 @@ fn a_real_session_whispers_across_shards_as_its_own_character() { } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // The successful whisper answers nothing (its lines return on the Relay), so the next reply is - // the second whisper's. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() { ServerOpcodeMessage::SMSG_CHAT_PLAYER_NOT_FOUND(m) => assert_eq!(m.name, "Nobodyatall"), other => panic!("expected SMSG_CHAT_PLAYER_NOT_FOUND, got {other}"), diff --git a/gateway/src/world/who.rs b/gateway/src/world/who.rs index aee714f3a..bde4e4b7a 100644 --- a/gateway/src/world/who.rs +++ b/gateway/src/world/who.rs @@ -1,6 +1,3 @@ -//! `/who`: a pure filter over [`presence::in_world_characters`], plus the `SMSG_WHO` response -//! builder `CMSG_WHO` (`social.rs`) drives. Behavior pinned against cm:MiscHandler.cpp:71-268. - use crate::world::{GuildActionStore, ShardRoutingStore, SocialStore}; use anyhow::Result; @@ -9,18 +6,12 @@ use crate::codec; use lyracore_shared::faction; use wow_world_messages::vanilla::CMSG_WHO; -/// More than this many zones gets no answer (cm:MiscHandler.cpp:90-91). const MAX_ZONES: usize = 10; -/// More than this many search strings gets no answer (cm:MiscHandler.cpp:107-108). const MAX_SEARCH_STRINGS: usize = 4; -/// A maximum level at or above this means no upper bound (cm:MiscHandler.cpp:133-136). const NO_UPPER_BOUND_AT: u8 = 100; -/// One in-world Character as the filter judges it. Borrowed, so [`matches`] can be exercised with -/// rows written by hand from the cited behavior, with no Store involved. pub(crate) struct WhoCandidate<'a> { pub name: &'a str, - /// Empty outside a Guild. pub guild_name: &'a str, pub race: u8, pub class: u8, @@ -29,7 +20,6 @@ pub(crate) struct WhoCandidate<'a> { pub zone_name: &'a str, } -/// `CMSG_WHO`'s filters, unpacked once per request. pub(crate) struct WhoFilter<'a> { pub requester_team: u32, pub min_level: u8, @@ -42,7 +32,6 @@ pub(crate) struct WhoFilter<'a> { pub search_strings: &'a [String], } -/// Case-insensitive substring match; an empty filter matches everything (cm:MiscHandler.cpp:206-222). fn name_matches(filter: &str, value: &str) -> bool { filter.is_empty() || value @@ -50,10 +39,6 @@ fn name_matches(filter: &str, value: &str) -> bool { .contains(&filter.to_ascii_lowercase()) } -/// Does any NON-EMPTY search string match the guild name, the Character name, or the zone name? -/// An empty list, or a list holding only empty strings, matches every row -/// (cm:MiscHandler.cpp:228-243, vm:MiscHandler.cpp:187-202) — this is an ANY across the strings a -/// client actually typed, not a rule that every string must itself find something. fn search_strings_match(strings: &[String], candidate: &WhoCandidate) -> bool { let real_strings = strings.iter().filter(|s| !s.is_empty()); let mut any_real = false; @@ -69,8 +54,6 @@ fn search_strings_match(strings: &[String], candidate: &WhoCandidate) -> bool { !any_real } -/// Does `candidate` pass every `/who` rule the requester sent? One clause per cited rule -/// (cm:MiscHandler.cpp:156-244). pub(crate) fn matches(filter: &WhoFilter, candidate: &WhoCandidate) -> bool { faction::team_for_race(candidate.race) == filter.requester_team && candidate.level >= filter.min_level @@ -83,13 +66,11 @@ pub(crate) fn matches(filter: &WhoFilter, candidate: &WhoCandidate) -> bool { && search_strings_match(filter.search_strings, candidate) } -/// More than 10 zones or more than 4 search strings gets no answer at all -/// (cm:MiscHandler.cpp:90-91, cm:MiscHandler.cpp:107-108). fn is_oversized(request: &CMSG_WHO) -> bool { request.zones.len() > MAX_ZONES || request.search_strings.len() > MAX_SEARCH_STRINGS } -/// A wire maximum level at or above 100 means no upper bound (cm:MiscHandler.cpp:133-136). +/// A wire maximum level of 100 or more means no upper bound. fn effective_max_level(wire_max_level: u8) -> u8 { if wire_max_level >= NO_UPPER_BOUND_AT { u8::MAX @@ -98,15 +79,6 @@ fn effective_max_level(wire_max_level: u8) -> u8 { } } -/// Build the RAW `SMSG_WHO` reply for `request` — `(opcode, body)` for -/// [`Outbound::Raw`](super::Outbound::Raw) — or `None` when the request itself is oversized (see -/// [`is_oversized`]). `requester_race` decides the team every listed Character must share. Source -/// is [`presence::in_world_characters`], so bots stay listed, as today. -/// -/// A zone name is looked up only when the request carries a search string, since it is the only -/// rule that reads one — the common case (no search string) then costs no zone lookups at all. -/// The Guild name is two keyed reads of the Realm-core cache per row. When Realm-core cannot -/// answer, every row lists with an empty Guild name rather than the reply failing. pub(crate) fn respond( store: &St, requester_race: u8, @@ -175,8 +147,6 @@ mod tests { use super::*; use lyracore_shared::faction::{TEAM_ALLIANCE, TEAM_HORDE}; - /// A Human Warrior in Elwynn (zone 12), Alliance, level 10 — the baseline every rule test - /// starts from and narrows by overriding one field. fn baseline() -> (WhoFilter<'static>, WhoCandidate<'static>) { let filter = WhoFilter { requester_team: TEAM_ALLIANCE, @@ -184,8 +154,8 @@ mod tests { max_level: 60, name: "", guild_name: "", - race_mask: 1 << 1, // Human - class_mask: 1 << 1, // Warrior + race_mask: 1 << 1, + class_mask: 1 << 1, zones: &[], search_strings: &[], }; @@ -212,19 +182,17 @@ mod tests { let (mut filter, mut candidate) = baseline(); filter.requester_team = TEAM_HORDE; assert!(!matches(&filter, &candidate)); - candidate.race = 2; // Orc + candidate.race = 2; filter.requester_team = TEAM_ALLIANCE; assert!(!matches(&filter, &candidate)); } - /// The team rule is symmetric: a Horde requester matches a Horde row the same way an Alliance - /// one matches an Alliance row, not just "excludes the other side" as seen from Alliance. #[test] fn a_horde_requester_matches_a_horde_candidate() { let (mut filter, mut candidate) = baseline(); filter.requester_team = TEAM_HORDE; - filter.race_mask = 1 << 2; // Orc - candidate.race = 2; // Orc + filter.race_mask = 1 << 2; + candidate.race = 2; assert!(matches(&filter, &candidate)); } @@ -243,7 +211,7 @@ mod tests { #[test] fn a_maximum_level_at_or_above_100_removes_the_upper_bound() { let (mut filter, mut candidate) = baseline(); - filter.max_level = u8::MAX; // `respond` maps a wire 100+ to this + filter.max_level = u8::MAX; candidate.level = 60; assert!(matches(&filter, &candidate)); } @@ -251,14 +219,14 @@ mod tests { #[test] fn class_mask_is_a_bit_test() { let (mut filter, candidate) = baseline(); - filter.class_mask = 1 << 2; // Paladin only + filter.class_mask = 1 << 2; assert!(!matches(&filter, &candidate)); } #[test] fn race_mask_is_a_bit_test() { let (mut filter, candidate) = baseline(); - filter.race_mask = 1 << 3; // Dwarf only + filter.race_mask = 1 << 3; assert!(!matches(&filter, &candidate)); } @@ -308,8 +276,6 @@ mod tests { assert!(matches(&filter, &candidate)); } - /// Any ONE non-empty search string matching is enough — the rest may miss - /// (cm:MiscHandler.cpp:228-243, vm:MiscHandler.cpp:187-202). Not every string must match. #[test] fn any_non_empty_search_string_matching_is_enough() { let (mut filter, candidate) = baseline(); @@ -329,8 +295,6 @@ mod tests { assert!(!matches(&filter, &candidate)); } - /// When every search string is empty, every row passes — the same "empty filter matches all" - /// rule as a blank name or guild filter. #[test] fn every_string_empty_matches_every_row() { let (mut filter, candidate) = baseline(); @@ -346,7 +310,6 @@ mod tests { assert_eq!(effective_max_level(255), u8::MAX); } - /// **AC 5 (zones): oversized zone or string lists get no answer.** #[test] fn more_than_ten_zones_is_oversized() { assert!(is_oversized(&CMSG_WHO { @@ -362,7 +325,6 @@ mod tests { ); } - /// **AC 5 (search strings): oversized zone or string lists get no answer.** #[test] fn more_than_four_search_strings_is_oversized() { assert!(is_oversized(&CMSG_WHO { diff --git a/gateway/src/world/who_tests.rs b/gateway/src/world/who_tests.rs index 2b772635e..b05bfee38 100644 --- a/gateway/src/world/who_tests.rs +++ b/gateway/src/world/who_tests.rs @@ -1,17 +1,7 @@ -//! `/who`'s Store-Fake tests: the multi-shard listing and the 49-cap/oversized-request rules that -//! need real Realm Presence rows. `who.rs`'s own `#[cfg(test)]` module covers every filter rule -//! against hand-written candidates; this file covers what only a Store can exercise. -//! -//! What EXECUTES here is production `world::who`, against the same in-memory multi-database -//! topology `party_tests::party_topology` builds, and a plain single-shard `WorldFake` for the -//! unsharded case. - use super::party_tests::{character, party_topology}; use super::*; use wow_world_messages::vanilla::{Level, CMSG_WHO}; -/// Every filter wide open — level 0 through "no upper bound", every race and class bit set, no -/// zone or name restriction. What a client sends with nothing filtered in the `/who` panel. fn wide_open_who() -> CMSG_WHO { CMSG_WHO { minimum_level: Level::new(0), @@ -25,9 +15,6 @@ fn wide_open_who() -> CMSG_WHO { } } -/// Decode a RAW `SMSG_WHO` body ([`codec::build_who_response_raw`]'s layout) into -/// `(listed_players, online_players, names)`, in wire order — enough for these tests without -/// duplicating a full reader. fn decode_who(body: &[u8]) -> (u32, u32, Vec) { let listed_players = u32::from_le_bytes(body[0..4].try_into().unwrap()); let online_players = u32::from_le_bytes(body[4..8].try_into().unwrap()); @@ -39,13 +26,11 @@ fn decode_who(body: &[u8]) -> (u32, u32, Vec) { rest = &rest[name_end + 1..]; let guild_end = rest.iter().position(|&b| b == 0).unwrap(); rest = &rest[guild_end + 1..]; - rest = &rest[16..]; // level, class, race, zone: u32 each + rest = &rest[16..]; } (listed_players, online_players, names) } -/// **AC 1: a requester on a World Shard sees a Character standing in an instance on the Instance -/// Pool, and the reverse.** #[test] fn who_lists_a_character_on_the_instance_pool_from_the_world_shard_and_back() { let (_realm, world, instances, _calls) = party_topology(); @@ -78,7 +63,6 @@ fn who_lists_a_character_on_the_instance_pool_from_the_world_shard_and_back() { ); } -/// **AC 4: fifty matches list 49 and report 50.** #[test] fn fifty_matches_list_forty_nine_and_report_fifty() { let characters: Vec<_> = (0..50u64) @@ -103,8 +87,6 @@ fn fifty_matches_list_forty_nine_and_report_fifty() { assert_eq!(online_players, 50, "the match count is uncapped"); } -/// **AC 8: an unsharded Gateway answers `/who` from its one database with the same filters** — no -/// `world_stores()` fan-out, but the level-range rule still applies exactly as it would sharded. #[test] fn an_unsharded_gateway_answers_who_with_the_same_filters() { let store = std::sync::Arc::new(WorldFake { @@ -147,9 +129,6 @@ fn an_unsharded_gateway_answers_who_with_the_same_filters() { assert_eq!(names, ["High"]); } -/// A search string matches a zone name read from the Store — proves the wiring from -/// `store.zone_name` through to the filter, not just the pure rule `who.rs`'s own tests pin. A -/// zone the Fake was never told about stays blank and fails the match. #[test] fn a_search_string_matches_a_zone_name_the_store_resolves() { let store = std::sync::Arc::new(WorldFake { @@ -184,8 +163,6 @@ fn a_search_string_matches_a_zone_name_the_store_resolves() { "the seeded zone name must resolve and match: {names:?}" ); - // The same request against a Store that never learned the zone's name: the blank lookup must - // fail the match rather than matching everything by accident. let blank = std::sync::Arc::new(WorldFake { characters: vec![codec::CharacterView { guid: 1, @@ -227,9 +204,6 @@ fn who_reply_lists_every_online_player_with_guild_level_and_zone() { ..Default::default() }]; s.characters = vec![ - // The requester. Human like Alpha/Bravo (so the team gate passes them), but a class - // outside the request's `class_mask` — the requester is not exempt from its own filters, - // so this keeps the assertions below at exactly the two matches. codec::CharacterView { guid: 1, name: "Tester".into(), @@ -265,15 +239,13 @@ fn who_reply_lists_every_online_player_with_guild_level_and_zone() { maximum_level: Level::new(60), player_name: String::new(), guild_name: String::new(), - race_mask: 1 << 1, // Human - class_mask: 1 << 1, // Warrior + race_mask: 1 << 1, + class_mask: 1 << 1, zones: Vec::new(), search_strings: Vec::new(), } .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); - // RAW-encoded (codec::build_who_response_raw): gtker's typed reader assumes the wrong 5875 - // layout (see that builder's doc comment), so this reads the cmangos body by hand. let (opcode, body) = read_raw_frame(&mut client, &mut c_dec); assert_eq!(opcode, codec::social::SMSG_WHO_OPCODE); let online_players = u32::from_le_bytes(body[4..8].try_into().unwrap()); @@ -287,7 +259,7 @@ fn who_reply_lists_every_online_player_with_guild_level_and_zone() { assert_eq!(std::str::from_utf8(&rest[..guild_end]).unwrap(), guild); rest = &rest[guild_end + 1..]; assert_eq!(u32::from_le_bytes(rest[0..4].try_into().unwrap()), level); - rest = &rest[16..]; // level, class, race, zone: u32 each + rest = &rest[16..]; } assert!(rest.is_empty(), "exactly two listed rows"); drop(client); diff --git a/gateway/src/world/wire_corruption_tests.rs b/gateway/src/world/wire_corruption_tests.rs index 31598dd03..f090e3956 100644 --- a/gateway/src/world/wire_corruption_tests.rs +++ b/gateway/src/world/wire_corruption_tests.rs @@ -1,45 +1,5 @@ -//! "SMSG_COMPRESSED_MOVES corrupts at crowd scale" — regression pins. -//! -//! A child module of `world::tests` for the same reason as its siblings — it reaches -//! `WorldFake`/`client_handshake`/`world_handshake` and drives the ACTUAL `spawn_writer` with a -//! REAL `wow_srp` cipher pair, without widening anything. - use super::*; -// =========================================================================================== -// "SMSG_COMPRESSED_MOVES corrupts at crowd scale" — regression pins. -// -// Root-cause finding: the gateway never constructs `SMSG_COMPRESSED_MOVES` — a full-repo grep for -// `compressed_moves`/`COMPRESSED_MOVES`/`flate2` and a `git log -S "COMPRESSED_MOVES" --all` both -// come up empty. A direct probe of `wow_world_messages` 0.3's own codec for that opcode (encode via -// `write_into_vec`, decode via `ServerOpcodeMessage::read_unencrypted`) round-trips cleanly through -// at least 3000 synthetic movers; it only silently truncates its u16 size field past ~9000 movers, -// an order of magnitude past the reported corruption range. So "compressed moves corrupts" is not -// an encode defect in that opcode. -// -// The likelier mechanism: `SMSG_COMPRESSED_MOVES` and `SMSG_COMPRESSED_UPDATE_OBJECT` are the ONLY -// two decoders in the entire vanilla message set with an internal `.unwrap()` (on a flate2 -// `read_to_end`) instead of a graceful `Result` — every other decoder in `wow_world_messages` 0.3 -// returns `Err` on garbage input. `wow_world_messages`' own framing reads exactly the header's -// declared `size` bytes into an in-memory buffer BEFORE dispatching to any per-opcode decoder, so a -// decode-internal panic on THIS opcode cannot itself desync the socket — but if an EARLIER frame's -// declared size was ever wrong for any reason, every later header decrypts from the wrong stream -// offset, and the first opcode whose decoder panics instead of erroring is deterministically where -// the crash surfaces — regardless of what actually caused the desync. That makes this decoder the -// "weakest link" any unrelated corruption converges on, not necessarily the origin. -// -// What follows pins the two real, high-volume movement paths — `SMSG_MONSTER_MOVE` typed sends and -// the `Outbound::Raw` peer-motion relay — through the ACTUAL `spawn_writer` (the single writer -// thread that owns the header cipher) with a REAL `wow_srp` cipher pair, at the exact scale -// reported (100 known-good, 150/300/500 the reported-bad range). If the writer ever -// mis-declared one frame's size, this is where it would show up: as a decode failure on THIS test, -// not as an unrelated panic on an opcode the gateway doesn't send. -// =========================================================================================== - -/// Push `n` movers' worth of both hot movement paths through a real `spawn_writer` + real cipher -/// pair, then decode every frame back and demand: no decode error, and exactly `n` of each opcode, -/// in order. A stream desync from a wrong size field would manifest here as either a decode `Err` -/// or a frame decoding to the wrong `ServerOpcodeMessage` variant. fn movement_burst_over_a_real_cipher(n: usize) { let store = std::sync::Arc::new(WorldFake { session: SessionState { @@ -69,12 +29,7 @@ fn movement_burst_over_a_real_cipher(n: usize) { let (tx, rx, depth) = session_channel(); let writer = spawn_writer(s, encrypt, rx, depth, 1).expect("spawn the writer thread"); - // The reader MUST run concurrently with the writer, not after `writer.join()` — a Unix domain - // socket's kernel send buffer is finite, and a burst past it makes the writer's `write_all` - // block waiting for a reader that (if it only starts after the writer finishes) never comes: - // a real deadlock, caught empirically writing this test (it hung indefinitely at n=500 with - // both threads idle, not merely slow). Draining while the writer produces mirrors what a real - // socket pair does in production — neither side ever waits on a queue nobody is draining. + // Read concurrently with the writer; joining first deadlocks once the socket buffer fills. let reader = std::thread::spawn(move || { let mut monster_moves = 0usize; let mut heartbeats = 0usize; @@ -154,19 +109,11 @@ fn movement_burst_over_a_real_cipher(n: usize) { #[test] fn writer_thread_survives_movement_bursts_from_100_to_500_movers_without_desync() { - // 100 is the report's own known-good control; 150/300/500 span and exceed the reported-bad - // range (150+) and the natural live-validation target (a 300-mage raid, the bug report's own - // repro line). for n in [100usize, 150, 300, 500] { movement_burst_over_a_real_cipher(n); } } -/// Crash probe (writer-side black box): [`WriterTrace::record`] must recover the SAME `(opcode, -/// size)` pair for the typed `Outbound::One` path as the wire actually carries — pinned against a -/// real gtker message rather than trusting the `write_unencrypted_server` re-derivation blind. -/// `SMSG_MONSTER_MOVE`'s body length is data-dependent (spline point count), so this also proves -/// the ring doesn't hard-code a size. #[test] fn writer_trace_records_the_typed_path_opcode_and_size() { let m = codec::build_monster_move( @@ -206,9 +153,6 @@ fn writer_trace_records_the_typed_path_opcode_and_size() { assert_eq!(entry.checksum, expected_checksum); } -/// A `Batch` of N messages is N frames on the wire (the writer sends each contiguously) — the ring -/// must expand it to N entries, not collapse it to one, or a diff against the harness's per-frame -/// dump would silently misalign after the first batch. #[test] fn writer_trace_expands_a_batch_into_one_entry_per_message() { let make = |i: u32| { @@ -239,9 +183,6 @@ fn writer_trace_expands_a_batch_into_one_entry_per_message() { ); } -/// `Outbound::Raw`'s entry must reflect the EXACT body handed to it — `size` is `2 + body.len()` -/// (matching `spawn_writer`'s own framing, not gtker's), and the checksum is over the raw body -/// bytes with no re-serialization in between (Raw never touches gtker's codec). #[test] fn writer_trace_records_the_raw_path_verbatim() { let body = vec![0xAAu8, 0xBB, 0xCC, 0xDD, 0xEE]; @@ -256,8 +197,6 @@ fn writer_trace_records_the_raw_path_verbatim() { assert_eq!(entry.checksum, fnv1a64(&body)); } -/// The ring is bounded at [`WriterTrace::CAPACITY`] — a long-lived session must not grow this -/// unboundedly; pushing past capacity drops the OLDEST entry (a FIFO ring, oldest-first on dump). #[test] fn writer_trace_ring_caps_at_capacity_and_drops_oldest() { let mut trace = WriterTrace::new(); @@ -265,7 +204,6 @@ fn writer_trace_ring_caps_at_capacity_and_drops_oldest() { trace.push(i as u16, 0, 0); } assert_eq!(trace.ring.len(), WriterTrace::CAPACITY); - // The oldest 5 pushes (opcodes 0..5) must have been evicted; the ring now starts at 5. assert_eq!(trace.ring.front().unwrap().opcode, 5); assert_eq!( trace.ring.back().unwrap().opcode, @@ -273,15 +211,12 @@ fn writer_trace_ring_caps_at_capacity_and_drops_oldest() { ); } -/// `dump` writes a real file under `/tmp/gw-writer-crash/.txt` — the whole point of the -/// black box is that it survives the process exiting the way a real crash does. Uses a distinctive -/// account id to avoid colliding with a real session's dump from a live repro run on the same box. #[test] fn writer_trace_dump_writes_a_file_with_the_traced_frames() { let mut trace = WriterTrace::new(); trace.push(0x00EE, 31, 0xDEAD_BEEF_0000_0001); trace.push(0x0130, 6, 0xDEAD_BEEF_0000_0002); - const ACCOUNT: u64 = 0x0FFF_FFFF_F209_0000; // won't collide with a real bench account id + const ACCOUNT: u64 = 0x0FFF_FFFF_F209_0000; let dir = std::env::temp_dir().join(format!( "lyracore-writer-trace-{}-{}", std::process::id(), @@ -341,7 +276,6 @@ fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { let (tx, rx, depth) = session_channel(); let writer = spawn_writer(s, encrypt, rx, depth, 1).expect("spawn the writer thread"); - // 2 (opcode) + this body must exceed u16::MAX to hit the guard. let oversized_body = vec![0xABu8; u16::MAX as usize]; tx.send(Outbound::Raw { opcode: 0x00A9, @@ -350,14 +284,10 @@ fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { .unwrap(); drop(tx); - // The guard must make the writer end the connection cleanly, not hang and not panic. writer .join() .expect("the writer thread must not panic on an oversized raw body"); - // Nothing valid was ever written for this frame: the client sees EOF, not a bogus small-size - // frame followed by the oversized body. `read_encrypted` reads a 4-byte header first, so with - // zero bytes on the wire this must fail (EOF), never `Ok`. match ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec) { Err(_) => {} Ok(m) => panic!( @@ -367,52 +297,6 @@ fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { } } -// =========================================================================================== -// Movement-only load stayed clean at 500 co-located movers (the test above), but the real repro — -// a mage-boss raid pull, `--cast-spell 133` — crashes at 150 co-located CASTERS. That narrows the -// corrupt frame to the SPELL/COMBAT relay plane, not movement. This mirrors -// `movement_burst_over_a_real_cipher` above (the same rig) for the frames -// that plane actually emits per cast, enumerated by reading every `Outbound::One`/`Outbound::Raw` -// send site on the cast/combat path (`gateway/src/stdb/subscriptions.rs`'s `on_cast`/`on_combat` -// listeners, and `world/mod.rs`'s synchronous `CMSG_CAST_SPELL` handler): -// -// SMSG_SPELL_START - cast-begin (subscriptions.rs on_cast; world/mod.rs sync ack) -// SMSG_SPELL_GO - cast visual/finalize (subscriptions.rs on_cast + on_combat ranged; -// world/mod.rs sync ack) -// SMSG_SPELLNONMELEEDAMAGELOG - floating spell/ranged damage number (subscriptions.rs on_cast, -// on_combat ranged, on_impact projectile-land) -// SMSG_SPELL_COOLDOWN - cooldown swipe, only for spells that have one (subscriptions.rs -// on_cast) -// SMSG_ATTACKERSTATEUPDATE - melee swing (subscriptions.rs on_combat, non-ranged non-spell-swing) -// SMSG_SPELL_FAILURE/SMSG_SPELL_DELAYED - interrupt/pushback signals (subscriptions.rs on_cast) — -// rarer than the per-cast steady state above, not swept here -// Outbound::Raw{0x0130} SMSG_CAST_RESULT - the caster-only cast ACK (subscriptions.rs on_cast, -// world/mod.rs sync handler). DELIBERATELY EXCLUDED from this rig's -// round-trip below: gtker's own typed `SMSG_CAST_RESULT` decoder has -// the Success/Failure branch condition INVERTED from the real 1.12 -// protocol (see `codec::combat::build_cast_result_ok`'s doc comment — -// this is why the gateway sends it via `Outbound::Raw` at all). A -// 5-byte OK body (spell_id + 0x00) makes gtker's decoder try to read -// a Success-only "reason" byte that was never sent, which errors — -// correctly, for the real client, but as an artifact of THIS crate's -// own decoder, not of anything the gateway did wrong. Asserting this -// opcode round-trips through `ServerOpcodeMessage::read_encrypted` -// here would just fail on that pre-existing, unrelated gap and add -// noise to this hunt, not signal. -// -// Same rig as the movement test: real `spawn_writer`, real `wow_srp` cipher pair, a concurrently -// draining reader (the same deadlock trap applies), interleaved with the SAME movement heartbeat -// relay the movement test above already pinned clean — because the real repro is combat+cast ON -// TOP OF movement, not combat alone. -// =========================================================================================== - -/// Push `n` casters' worth of one full per-cast sequence (SPELL_START → SPELL_GO → -/// SPELLNONMELEEDAMAGELOG → SPELL_COOLDOWN, mirroring a single boss-target Fireball rotation) PLUS one -/// boss-swing ATTACKERSTATEUPDATE and one peer-motion heartbeat, through a real `spawn_writer` + real -/// cipher pair, then decode every frame back and demand: no decode error, and exactly `n` of each -/// opcode, in order. A stream desync from a wrong size field on ANY of these opcodes would manifest -/// here as a decode `Err` or a wrong-variant decode — precisely the crash shape this file guards -/// against, but attributable to the SPELL/COMBAT plane instead of movement. fn combat_cast_burst_over_a_real_cipher(n: usize) { let store = std::sync::Arc::new(WorldFake { session: SessionState { @@ -442,12 +326,11 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { let (tx, rx, depth) = session_channel(); let writer = spawn_writer(s, encrypt, rx, depth, 1).expect("spawn the writer thread"); - const SPELL_ID: u32 = 133; // Fireball — the crash repro's own `--cast-spell 133` - const BOSS_GUID: u64 = 0xF130_0000_0000_0001; // one shared raid target, mirrors raid-mode BENCH_ARGS - const FRAMES_PER_CASTER: usize = 6; // START + GO + DAMAGE_LOG + COOLDOWN + ATTACKERSTATEUPDATE + heartbeat + const SPELL_ID: u32 = 133; + const BOSS_GUID: u64 = 0xF130_0000_0000_0001; + const FRAMES_PER_CASTER: usize = 6; - // The reader MUST run concurrently with the writer — see `movement_burst_over_a_real_cipher`'s - // comment on the exact same deadlock this rig hit empirically at n=500 there. + // Read concurrently with the writer to avoid deadlock on a full socket buffer. let reader = std::thread::spawn(move || { let mut starts = 0usize; let mut goes = 0usize; @@ -482,7 +365,7 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { for i in 0..n { let caster = 0x1000_0000_0000_0000u64 + i as u64; - let damage = 40 + (i as u32 % 60); // varies the body, like a real crit/resist spread would + let damage = 40 + (i as u32 % 60); let start = codec::build_spell_start(caster, SPELL_ID, 0, 0, None); tx.send(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_START( @@ -501,7 +384,7 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { caster, SPELL_ID, damage, - 2, /* fire */ + 2, i % 5 == 0, 0, 0, @@ -517,8 +400,6 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { ))) .expect("writer thread must still be alive mid-burst"); - // The boss swinging back at whichever caster it's leashed onto this tick — real raid load is - // casts AND incoming melee at once, not casts in isolation. let swing = codec::build_attacker_state_update(BOSS_GUID, caster, damage, 0, 0, 0); tx.send(Outbound::One( ServerOpcodeMessage::SMSG_ATTACKERSTATEUPDATE(Box::new(swing)), @@ -576,14 +457,6 @@ fn combat_cast_burst_over_a_real_cipher(n: usize) { #[test] fn writer_thread_survives_combat_cast_bursts_from_100_to_300_casters_without_desync() { - // 100 = clean per the movement test's own control range; 150 = the discriminator's OWN reported - // crash threshold ("crashes at 150" combat+cast, vs. clean at 500 movement-only); 200/300 span and - // exceed it (300 is the natural raid-repro target — `BENCH_ARGS="--class mage --boss - // --cast-spell 133"`, a 300-mage raid). If this ever goes red, the failure message above names - // the exact opcode and frame-count offset where the SPELL/COMBAT plane desynced — the root - // cause, headlessly. If it stays green through all four, the corrupt frame is NOT produced by any - // of these send paths, and the next probe is the byte-level capture (the wire harness's - // `/tmp/wire-crash/` dump) taken at the moment of a LIVE repro. for n in [100usize, 150, 200, 300] { combat_cast_burst_over_a_real_cipher(n); } diff --git a/gateway/tests/provision_cli.rs b/gateway/tests/provision_cli.rs index 93852f273..0c850947d 100644 --- a/gateway/tests/provision_cli.rs +++ b/gateway/tests/provision_cli.rs @@ -4,7 +4,6 @@ use std::process::{Command, Output, Stdio}; fn run_gateway(args: &[&str], stdin: &[u8]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) .args(args) - // Isolate the child from inherited Realm topology and credentials. .env_remove("LYRACORE_COORDINATOR_TOKEN") .env_remove("LYRACORE_DATABASE") .env_remove("LYRACORE_SPACETIMEDB_URL") @@ -24,7 +23,6 @@ fn run_gateway(args: &[&str], stdin: &[u8]) -> Output { fn run_gateway_against_unreachable_loopback(args: &[&str], stdin: &[u8]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) .args(args) - // An unreachable loopback port keeps the connection failure independent of local nodes. .env("LYRACORE_COORDINATOR_TOKEN", "functional-test-token") .env("LYRACORE_DATABASE", "spacetime-core") .env("LYRACORE_SPACETIMEDB_URL", "http://127.0.0.1:0") @@ -99,8 +97,6 @@ fn valid_password_stdin_reaches_provisioning_without_disclosing_the_password() { assert!(!stderr.contains("invalid password")); } -/// The SRP6 challenge is answered from Realm-core, and characters belong to the World Shard's -/// account row, so a provisioned account must exist on both databases. #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn provision_writes_the_account_to_the_world_shard_and_realm_core() { diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 909290cb3..5f5b8640e 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -1,5 +1,3 @@ -//! Compare the Module schema with generated bindings for every Coordinator subscription. -//! See `docs/binding-schema-parity.md` for the comparison and its platform scope. #![cfg(target_os = "linux")] include!(concat!(env!("OUT_DIR"), "/bindings.rs")); @@ -7,21 +5,10 @@ include!(concat!(env!("OUT_DIR"), "/bindings.rs")); use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9Builder; use spacetimedb_lib::{AlgebraicType, ProductType, SpacetimeType, SumType}; -// --------------------------------------------------------------------------------------------- -// Sentinel: builds ONE real, validly-typed instance of a binding struct so we can read its real -// field types back (via `&inst.field`) and its real field order back (via its derived `Debug`). -// --------------------------------------------------------------------------------------------- - trait Sentinel: Sized { fn sentinel() -> Self; } -/// One concrete-type impl per primitive actually used across the subscribed tables' bindings. -/// (NOT a `impl Sentinel for T` blanket: rustc's coherence check treats a foreign -/// type's `Default`-ness as subject to change in a future upstream release, so a blanket-over- -/// `Default` plus a concrete override for `spacetimedb_lib::Timestamp` — which does NOT derive -/// `Default` today — is rejected as a potential future conflict (E0119) even though there's no -/// ACTUAL overlap right now. Enumerating concrete types sidesteps that.) macro_rules! sentinel_via_default { ($($t:ty),+ $(,)?) => { $(impl Sentinel for $t { @@ -49,7 +36,6 @@ sentinel_via_default!( spacetimedb_lib::Identity, ); -/// `Timestamp` is the one field type in play that does NOT derive `Default` — special-cased. impl Sentinel for spacetimedb_lib::Timestamp { fn sentinel() -> Self { spacetimedb_lib::Timestamp::UNIX_EPOCH @@ -62,7 +48,6 @@ impl Sentinel for spacetimedb_lib::ScheduleAt { } } -/// Containers can construct an empty sentinel without constructing their contents. impl Sentinel for Option { fn sentinel() -> Self { None @@ -74,7 +59,6 @@ impl Sentinel for Vec { } } -/// Derive a schema from the binding field's real type and value. trait BindingFieldShape { fn binding_field_shape(&self, ts: &mut RawModuleDefV9Builder) -> AlgebraicType; } @@ -322,17 +306,6 @@ fn field_shape(value: &T, ts: &mut RawModuleDefV9Builder) value.binding_field_shape(ts) } -/// Extract the top-level `field_name:` identifiers from a derived `Debug` output of the shape -/// `StructName { field1: v1, field2: v2, .. }`, ignoring any `word:`-shaped text that belongs to -/// a NESTED derived `Debug` one level down (e.g. `Timestamp`'s own single field prints the same -/// `name: value` way inside its own `{ .. }`). Brace/paren/bracket-depth aware; only records -/// identifiers seen at depth 1 (directly inside the outermost struct's braces). -/// -/// Not string-literal aware: a Sentinel value whose Debug output embeds -/// `word: ` inside a quoted string would inject a spurious identifier here. Every current sentinel -/// is empty/zero/None so this cannot fire today, and an injection would surface as a LOUD -/// order-mismatch failure, not a silent pass; keep Sentinel impls free of colon/comma-bearing -/// string values (or teach this parser about `"` first). fn top_level_debug_fields(debug: &str) -> Vec { let open = debug .find('{') @@ -361,7 +334,6 @@ fn top_level_debug_fields(debug: &str) -> Vec { while j < chars.len() && chars[j] == ' ' { j += 1; } - // A field is `ident:` but not `ident::` (no nested paths appear at this depth). if chars.get(j) == Some(&':') && chars.get(j + 1) != Some(&':') { names.push(ident); } @@ -372,24 +344,15 @@ fn top_level_debug_fields(debug: &str) -> Vec { names } -/// The binding side's manifest-derived shape for one table, built by the `binding_shape!` macro. struct BindingShape { field_names: Vec<&'static str>, field_shapes: Vec, debug_order: Vec, } -/// Builds a `BindingShape` for `$Ty { field, field, .. }` — the field list is written ONCE (no -/// types, no values); `Sentinel::sentinel()` infers each field's real type from the real struct -/// literal, and `field_shape(&inst.field, ..)` reads that same real type back via `SpacetimeType`. macro_rules! binding_shape { ($Ty:path, { $($f:ident),+ $(,)? }) => {{ - // A `path` fragment substituted directly as `$Ty { .. }` hits the classic macro_rules - // "found `{`, expected an operator" parse error (an interpolated path is inserted as an - // opaque already-parsed node, and Rust's grammar can't then extend it into a struct - // literal). Binding it to a fresh local type alias first sidesteps that: `__BindingTy` - // below is a plain identifier, not a substituted fragment, so `__BindingTy { .. }` parses - // as an ordinary struct literal. + // A path fragment is opaque in macro_rules; use an alias before constructing the struct. type __BindingTy = $Ty; let inst = __BindingTy { $( $f: Sentinel::sentinel() ),+ }; let debug_order = top_level_debug_fields(&format!("{inst:?}")); @@ -400,19 +363,6 @@ macro_rules! binding_shape { }}; } -/// Compares one table's module `AlgebraicType` (real, auto-derived) against the binding manifest -/// (real field types, believed order) for field COUNT, NAME-per-index, and TYPE-per-index. -/// -/// `renames` is the escape hatch ("where the sdk renames, the manifest line takes -/// an explicit rename list"): `(module_field_name, binding_field_name)` pairs for fields where the -/// `spacetime` codegen normalizes the Rust IDENTIFIER (this is a cosmetic/tooling difference, not -/// a schema drift — BSATN row decode is positional, not name-keyed, so a renamed-but-same- -/// position-and-type field cannot break live decode). As of this test, real (not synthetic) -/// instances of this: module's `data0`/`data1` (`GameObjectTemplate`) and `p0`/`p1` (`SpellEffect`) -/// come out of the `spacetime generate` (CLI 2.6.1) codegen as `data_0`/`data_1`/`p_0`/`p_1` — -/// the generator inserts `_` before a bare trailing digit that doesn't already have one (compare -/// `spellid_1` on `ItemTemplate`, which module already spells with the underscore and which the -/// binding leaves untouched). fn check(table: &str, binding: BindingShape, renames: &[(&str, &str)]) { assert_eq!( binding.debug_order, binding.field_names, @@ -475,9 +425,6 @@ fn check(table: &str, binding: BindingShape, renames: &[(&str, .map(|(_, b)| *b) .unwrap_or(m_name); if expected_b_name != m_name { - // A rename entry may ONLY paper over the SDK's cosmetic trailing-digit underscore - // normalization (data0 -> data_0). Without this guard, a PAIR of same-typed swapped - // fields could hide behind two compensating bogus renames. assert_eq!( expected_b_name.replace('_', ""), m_name.replace('_', ""), @@ -499,9 +446,6 @@ fn check(table: &str, binding: BindingShape, renames: &[(&str, } } -/// `#[test] fn () { check::(table, binding_shape!(Binding { .. })); }` — one -/// manifest entry per gateway-SUBSCRIBED table, each its own `#[test]` so one table's drift -/// doesn't mask another's in a single `cargo test` run. macro_rules! parity_test { ($name:ident, $table:literal, $ModuleTy:ty, $BindingTy:path, { $($f:ident),+ $(,)? }) => { #[test] @@ -521,12 +465,6 @@ macro_rules! parity_test { }; } -// --------------------------------------------------------------------------------------------- -// The manifest: one line per gateway-SUBSCRIBED table (`stdb/connection.rs`'s coordinator -// subscription list). `MANIFEST_TABLES` below MUST list the same table names — the completeness -// guard cross-checks the two. -// --------------------------------------------------------------------------------------------- - parity_test!(parity_game_realm, "game_realm", lyracore_module::Realm, bindings::realm_type::Realm, { id, name, address, realm_type, flags, population, timezone, }); @@ -571,9 +509,6 @@ parity_test!(parity_game_map_region, "game_map_region", lyracore_module::MapRegi parity_test!(parity_game_region_assignment, "game_region_assignment", lyracore_module::RegionAssignment, bindings::region_assignment_type::RegionAssignment, { key, map_id, region_id, shard, epoch, updated_micros, }); -// Party state, authoritative on realm-core and mirrored onto each world shard. -// `game_group_event` earns its entry twice over — it is the one table with an -// END-appended `recipient_guid`, and the gateway decodes it on two different connections. parity_test!(parity_game_group, "game_group", lyracore_module::Group, bindings::group_type::Group, { group_id, leader_guid, loot_method, loot_threshold, rr_cursor, master_looter_guid, group_type, }); @@ -587,7 +522,6 @@ parity_test!(parity_game_group_member_partition, "game_group_member_partition", character_guid, group_id, membership_revision, member_active, map_id, instance_id, locator_revision, state, }); -// Guild state, authoritative on Realm-core. parity_test!(parity_game_guild, "game_guild", lyracore_module::guild::Guild, bindings::guild_type::Guild, { guild_id, name_key, name, leader_guid, team, motd, info, emblem_style, emblem_color, border_style, border_color, background_color, created_micros, @@ -602,7 +536,6 @@ parity_test!(parity_game_guild_member, "game_guild_member", lyracore_module::gui parity_test!(parity_game_guild_event, "game_guild_event", lyracore_module::guild::GuildEvent, bindings::guild_event_type::GuildEvent, { id, guild_id, recipient_guid, kind, subject_guid, other_guid, strings, created_at, }); -// The Fee Hold on the payer's Home Shard and the fee decision on Realm-core. parity_test!(parity_game_guild_fee_hold, "game_guild_fee_hold", lyracore_module::guild::fee::GuildFeeHold, bindings::guild_fee_hold_type::GuildFeeHold, { payer_guid, operation_id, kind, copper, npc_guid, emblem_style, emblem_color, border_style, border_color, background_color, charter_item_guid, charter_name, created_micros, @@ -610,7 +543,6 @@ parity_test!(parity_game_guild_fee_hold, "game_guild_fee_hold", lyracore_module: parity_test!(parity_game_guild_fee_decision, "game_guild_fee_decision", lyracore_module::guild::fee::GuildFeeDecision, bindings::guild_fee_decision_type::GuildFeeDecision, { operation_id, payer_guid, kind, accepted, refusal, petition_id, decided_micros, }); -// Petitions and their Signatures on Realm-core. parity_test!(parity_game_guild_petition, "game_guild_petition", lyracore_module::guild::petition::GuildPetition, bindings::guild_petition_type::GuildPetition, { petition_id, charter_item_guid, owner_guid, owner_name, team, name, created_micros, }); @@ -632,8 +564,6 @@ parity_test!(parity_game_corpse_loot_eligible, "game_corpse_loot_eligible", lyra parity_test!(parity_game_group_event, "game_group_event", lyracore_module::GroupEvent, bindings::group_event_type::GroupEvent, { id, recipient_identity, kind, other_guid, other_name, created_at, payload, recipient_guid, }); -// The private per-recipient trade-status relay — the `game_group_event` shape minus the -// name/payload columns (no trade status carries either). parity_test!(parity_game_trade_event, "game_trade_event", lyracore_module::TradeEvent, bindings::trade_event_type::TradeEvent, { id, recipient_identity, kind, other_guid, created_at, recipient_guid, payload, }); @@ -642,14 +572,9 @@ parity_test!(parity_game_duel_event, "game_duel_event", lyracore_module::DuelEve initiator_guid, challenged_guid, winner_guid, loser_guid, map_id, instance_id, flag_x, flag_y, flag_z, flag_orientation, created_at, winner_name, loser_name, }); -// A bot's serendipity invite DECISION, picked up by the coordinator's -// `world::party::run_bot_invite` relay (`stdb/subscriptions.rs`) — not a client-facing table, but -// the gateway decodes it off the wire the same as everything else here. parity_test!(parity_game_bot_invite_intent, "game_bot_invite_intent", lyracore_module::BotInviteIntent, bindings::bot_invite_intent_type::BotInviteIntent, { id, inviter_guid, target_guid, created_at, op, }); -// A session-less character's Shard crossing, picked up by the coordinator's -// `world::transfer::run_bot_transfer` relay — the transfer twin of the row above. parity_test!(parity_game_bot_transfer_intent, "game_bot_transfer_intent", lyracore_module::BotTransferIntent, bindings::bot_transfer_intent_type::BotTransferIntent, { id, bot_guid, destination_map, destination_instance, reason, created_at, controller_generation, claim_token, claim_until_micros, arrival_ready, source_module_identity, @@ -666,21 +591,16 @@ parity_test!(parity_game_party_command_dispatch_lane, "game_party_command_dispat parity_test!(parity_game_party_command_receipt, "game_party_command_receipt", lyracore_module::PartyCommandReceipt, bindings::party_command_receipt_type::PartyCommandReceipt, { id, receipt_key, source_identity, intent_id, bot_guid, outcome, retain_until_micros, }); -// Auto-Replies on each Home Shard. A drifted column here hands a whisperer the wrong text. parity_test!(parity_game_character_away, "game_character_away", lyracore_module::CharacterAway, bindings::character_away_type::CharacterAway, { character_guid, kind, message, }); parity_test!(parity_game_system_message_event, "game_system_message_event", lyracore_module::SystemMessageEvent, bindings::system_message_event_type::SystemMessageEvent, { id, recipient_identity, recipient_guid, message, created_at, }); -// Realm Chat Lines, written on Realm-core with their whole audience. A drifted column here -// mis-decodes every party line on every Gateway. parity_test!(parity_game_realm_chat_event, "game_realm_chat_event", lyracore_module::RealmChatEvent, bindings::realm_chat_event_type::RealmChatEvent, { id, kind, speaker_guid, language, chat_tag, channel_name, message, recipients, ignorable, created_at, }); -// Chat Channels on Realm-core. The index reads channels and members; a drifted notice column -// mis-encodes every Channel Notice on every Gateway. parity_test!(parity_game_chat_channel, "game_chat_channel", lyracore_module::ChatChannel, bindings::chat_channel_type::ChatChannel, { channel_id, team, name_key, name, builtin_id, flags, password, owner_guid, announcements, moderated, @@ -695,18 +615,12 @@ parity_test!(parity_game_chat_channel_notice_event, "game_chat_channel_notice_ev id, notice, channel_name, subject_guid, actor_guid, old_flags, new_flags, channel_flags, text, recipients, created_at, }); -// The Meeting Stone Queue. A drifted stone column names the wrong dungeon area; a drifted Seeker -// column answers `CMSG_MEETINGSTONE_INFO` wrongly. parity_test!(parity_game_meeting_stone, "game_meeting_stone", lyracore_module::MeetingStone, bindings::meeting_stone_type::MeetingStone, { entry, min_level, max_level, area_id, }); parity_test!(parity_game_meeting_stone_seeker, "game_meeting_stone_seeker", lyracore_module::MeetingStoneSeeker, bindings::meeting_stone_seeker_type::MeetingStoneSeeker, { character_guid, area_id, team, class, group_id, queued_at, }); -// Private (no per-player subscriber to decode these — every wire-visible roll transition still -// rides `game_group_event`, unchanged). Subscribed so the gateway's loot-roll relay -// (`world::loot::relay_tick`) can promote a world shard's staging roll onto realm-core and read -// realm-core's votes back — a drifted column here breaks that relay silently, not a client packet. parity_test!(parity_game_loot_roll, "game_loot_roll", lyracore_module::LootRoll, bindings::loot_roll_type::LootRoll, { id, corpse_guid, slot, item_entry, deadline_micros, resolved, random_property_id, promotion_source, @@ -714,9 +628,6 @@ parity_test!(parity_game_loot_roll, "game_loot_roll", lyracore_module::LootRoll, parity_test!(parity_game_loot_roll_vote, "game_loot_roll_vote", lyracore_module::LootRollVote, bindings::loot_roll_vote_type::LootRollVote, { id, roll_id, voter_guid, voted, vote, rolled, }); -// The guid-range trio. `game_guid_allocator` + `game_guid_range` are subscribed on EVERY -// deployment (a database without a range refuses to create characters), the registry only when -// sharded. Drift here is silent and expensive: a mis-decoded `base` would install the wrong range. parity_test!(parity_game_guid_allocator, "game_guid_allocator", lyracore_module::GuidAllocator, bindings::guid_allocator_type::GuidAllocator, { id, high_water, }); @@ -749,8 +660,6 @@ parity_test!(parity_game_world_entity, "game_world_entity", lyracore_module::Wor bank_bag_slots, mount_display_id, zone_id, sheet_ranged_ap, sheet_ranged_dmg_min, sheet_ranged_dmg_max, }); -// The live sky, one row per zone. Gateway-subscribed: world entry and zone entry read it, and its -// updates are the weather relay. parity_test!(parity_game_zone_weather, "game_zone_weather", lyracore_module::ZoneWeather, bindings::zone_weather_type::ZoneWeather, { zone_id, weather_type, intensity, changed_at_micros, }); @@ -758,11 +667,6 @@ parity_test!(parity_game_hunter_pet_protocol, "game_hunter_pet_protocol", lyraco pet_id, owner_guid, live_pet_guid, creature_entry, name, name_timestamp, level, pet_xp, next_level_xp, happiness, loyalty_level, }); -// `game_config` became gateway-subscribed so the startup instance-hosting check can read -// `hosts_instances` back instead of guessing. The generated binding was STALE when that subscription -// landed — a later change END-appended `hosts_instances` to the module table and -// `server_config_type.rs` had never been regenerated, so this manifest line is the guard that made -// the drift a red test. parity_test!(parity_game_config, "game_config", lyracore_module::ServerConfig, bindings::server_config_type::ServerConfig, { id, xp_rate, nav_enabled, hosts_instances, bots_idle, vmap_enabled, nav_coverage_enabled, }); @@ -829,8 +733,7 @@ parity_test!(parity_game_spell_effect, "game_spell_effect", lyracore_module::Spe radius_yd, chain_targets, trigger_spell, effect_mechanic, p_0, p_0_kind, p_1, script_id, enters_combat, }, renames: { - // spacetime generate (CLI 2.6.1) inserts `_` before a bare trailing digit; module's own - // field names (p0/p0_kind/p1) don't have it. Cosmetic — BSATN decode is positional. + // CLI 2.6.1 inserts underscores before trailing digits; BSATN decodes fields positionally. p0 => p_0, p0_kind => p_0_kind, p1 => p_1, }); parity_test!(parity_game_item_instance, "game_item_instance", lyracore_module::ItemInstance, bindings::item_instance_type::ItemInstance, { @@ -909,9 +812,6 @@ parity_test!(parity_game_player_action, "game_player_action", lyracore_module::P parity_test!(parity_game_trainer_spell, "game_trainer_spell", lyracore_module::TrainerSpell, bindings::trainer_spell_type::TrainerSpell, { id, trainer_entry, spell_id, cost, required_level, learn_skill_line, learn_skill_cap, }); -// The SOURCE-side escrow row. The only module->gateway data flow the cross-database transfer -// adds, and the one binding in this tree that was hand-authored rather than generated — so this -// parity check is doing more work here than anywhere else in the file. parity_test!(parity_game_transfer_out, "game_transfer_out", lyracore_module::TransferOut, bindings::transfer_out_type::TransferOut, { transfer_id, character_guid, dest_map_id, dest_instance_id, dest_x, dest_y, dest_z, dest_o, blob, created_micros, cross_database, @@ -927,9 +827,6 @@ parity_test!(parity_game_instance, "game_instance", lyracore_module::GameInstanc parity_test!(parity_game_spell_chain, "game_spell_chain", lyracore_module::SpellChain, bindings::spell_chain_type::SpellChain, { spell_id, prev_spell, first_spell, rank, req_spell, }); -// Generated even though the gateway does not currently subscribe to or read it. The binding was -// stale once and therefore earns an explicit regression guard outside the subscribed-table -// manifest below; recreating the old two-field binding must make this test fail to compile. parity_test!(parity_game_spell_group_rule, "game_spell_group_rule", lyracore_module::SpellGroupRule, bindings::spell_group_rule_type::SpellGroupRule, { group_id, rule, rank_is_comparable, }); @@ -940,10 +837,6 @@ parity_test!(parity_game_gameobject, "game_gameobject", lyracore_module::GameObj guid, template_entry, map_id, x, y, z, orientation, state, created_at, respawn_at_micros, instance_id, grid_x, grid_y, cell, rotation_0, rotation_1, rotation_2, rotation_3, }); -// The AOI-index fix pinned these two when they still rode the per-player AOI box; the -// shared-connection model moved them onto the coordinator's `SELECT *` list, so they are now in -// `MANIFEST_TABLES` like every other coordinator-subscribed table and the completeness guard asks -// for them by itself. parity_test!(parity_game_entity_motion, "game_entity_motion", lyracore_module::EntityMotion, bindings::entity_motion_type::EntityMotion, { guid, map_id, instance_id, grid_x, grid_y, opcode, movement_info, seq, cell, }); @@ -959,7 +852,6 @@ parity_test!(parity_game_gameobject_template, "game_gameobject_template", lyraco entry, type_id, display_id, name, data_0, data_1, gather_skill_line, respawn_secs, gather_gray, lock_id, size, }, renames: { - // Same `spacetime generate` (CLI 2.6.1) trailing-digit normalization as SpellEffect above. data0 => data_0, data1 => data_1, }); parity_test!(parity_game_talent, "game_talent", lyracore_module::Talent, bindings::talent_type::Talent, { @@ -1089,9 +981,6 @@ parity_test!(parity_game_faction_template, "game_faction_template", lyracore_mod parity_test!(parity_game_encounter_equip, "game_encounter_equip", lyracore_module::EncounterEquip, bindings::encounter_equip_type::EncounterEquip, { creature_guid, instance_id, main_hand, off_hand, ranged, }); -// The per-player connections subscribe these two for the say/yell + emote relays, so they stay in -// the manifest for the same reason every coordinator table is — a column added module-side without -// the matching binding edit corrupts every row the relay decodes. parity_test!(parity_game_chat_event, "game_chat_event", lyracore_module::ChatEvent, bindings::chat_event_type::ChatEvent, { id, sender_guid, chat_type, language, message, created_at, target_guid, }); @@ -1100,12 +989,8 @@ parity_test!(parity_game_emote_event, "game_emote_event", lyracore_module::Emote grid_x, grid_y, }); -/// Every table name that has a `parity_test!` line above. The completeness guard cross-checks -/// this against the Coordinator reads and registered Protocol Family Relay declarations. const MANIFEST_TABLES: &[&str] = &[ "game_realm", - // Shared-connection model: moved from the per-player AOI box onto the coordinator's global - // subscription. "game_entity_motion", "game_creature_spline", "game_taxi_passenger_spline", @@ -1247,16 +1132,9 @@ const MANIFEST_TABLES: &[&str] = &[ "game_area", ]; -// --------------------------------------------------------------------------------------------- -// Completeness guard: a new coordinator subscription without a parity manifest line must fail -// loudly (catches the FORGOTTEN pair, not just a mistyped one). -// --------------------------------------------------------------------------------------------- - const CONNECTION_RS: &str = include_str!("../src/stdb/connection.rs"); const RELAYS_RS: &str = include_str!("../src/stdb/relays.rs"); -/// Only executable string literals in the Coordinator query function count. Comments and tests -/// cannot make an unsubscribed table appear covered. fn extract_select_star_tables(source: &str) -> Vec { use syn::visit::Visit; struct Queries(Vec); diff --git a/importer/Cargo.toml b/importer/Cargo.toml index cd2eaf39f..3a33858a7 100644 --- a/importer/Cargo.toml +++ b/importer/Cargo.toml @@ -9,9 +9,6 @@ homepage.workspace = true publish.workspace = true description = "LyraCore content ETL: extracts a world slice from the operator's own client/world data." -# Native-only ETL tool: parse the operator's own cmangos classic-db dump and load a content slice -# into the live SpacetimeDB module. NEVER a dependency of the wasm module (licensing firewall: this -# ships the EXTRACTOR; the operator supplies the data, which never enters the repo — see .gitignore). [[bin]] name = "lyracore-importer" path = "src/main.rs" @@ -21,23 +18,14 @@ anyhow = "1" blake3 = "1" flate2 = "1" lyracore-shared.workspace = true -# The Package Delta artifact and the claim conflict tracer: the importer parses each enabled -# Package's generated artifacts so a refusal names the FILE, and re-emits them in canonical form for -# the module's apply reducer, which parses them again as its own trust boundary. lyracore-package-delta.workspace = true serde_json = "1" wow-adt = "=0.7.0" wow-m2 = "=0.7.0" -# Stream 1 (client DBC): read DBFilesClient/*.dbc out of the operator's OWN client MPQs (wow-mpq) and -# parse them (gtker wow_dbc, same crate family as the gateway's wow_world_messages/wow_srp). Both are -# pure parsers shipping ZERO Blizzard data, and native-only (NEVER deps of the wasm `module`). Pin -# wow-mpq exactly so the PatchChain API + format support can't drift under a minor bump. wow-mpq = "=0.7.0" wow-wmo = "=0.7.0" wow_dbc = { version = "0.3", features = ["vanilla"] } -# The Spell.dbc importer maps numeric aura columns through the typed `AuraMod` enum. -# Those types live in wow_world_base; wow_dbc 0.3 pins it to =0.2.0, so we depend on the SAME version -# (same type instance) to name the variants by hand. Pure parser, ships ZERO Blizzard data. +# Match wow_dbc's wow_world_base version so AuraMod has the same Rust type. wow_world_base = { version = "=0.2.0", features = ["vanilla"] } [dev-dependencies] diff --git a/importer/scripts/classic-db.lock b/importer/scripts/classic-db.lock index c9e196409..fb5eb2bdd 100644 --- a/importer/scripts/classic-db.lock +++ b/importer/scripts/classic-db.lock @@ -1,19 +1,3 @@ -# classic-db.lock — the pin importer/scripts/pull-classic-db.sh reads by default. -# -# WHY A LOCKFILE: cmangos/classic-db publishes no release tags, so a bare `master` changes under you -# silently — two people who "ran the same import" can end up with different worlds, and a regression -# in a row count looks like a bug in this repo. Pinning a commit makes the pull reproducible; the -# checksum makes a change of SHAPE at that same commit an abort rather than a silent re-import. -# Pass CLASSIC_DB_REF=master (plus --skip-verify) to float deliberately instead. -# -# BUMP PROCEDURE (one line, see importer/scripts/pull-classic-db.sh's header for the full walkthrough): -# CLASSIC_DB_REF=master importer/scripts/pull-classic-db.sh --skip-verify -# then copy its printed "resolved commit" + "sha256" into the two values below and commit this file. -# The lockfile ships (it is our own metadata: a commit id and a digest, not cmangos' data); the pulled -# classic-db content itself NEVER does — see the pull script's licensing-firewall note and this repo's -# .gitignore ("/.import/"). -# -# CLASSIC_DB_SHA256 is the digest of the decompressed SQL bytes written to -# .import/classic-db-full.sql. The EventAI source profile checks the same bytes before apply. +# CLASSIC_DB_SHA256 covers the decompressed SQL bytes in .import/classic-db-full.sql. CLASSIC_DB_SHA="cd0c426a3b2ff56dd518bf009025299468e60fdb" CLASSIC_DB_SHA256="d2083bcd2670451279cbf93af138eadae04c6d183a4cd0ff0357047e4a565de6" diff --git a/importer/scripts/import-class-spells.sh b/importer/scripts/import-class-spells.sh index 3ab5db088..836abc42b 100755 --- a/importer/scripts/import-class-spells.sh +++ b/importer/scripts/import-class-spells.sh @@ -1,239 +1,57 @@ #!/usr/bin/env bash -# import-class-spells.sh — THE curated class-spell import: the hand-picked spell ids that make all -# NINE classes playable, read out of the client's own Spell.dbc into game_spell / game_spell_effect, -# plus the trainer offerings that let a character buy them. -# -# It has two halves, and they are curated to different depths: -# 1. The six classes with a Human option (Warrior, Paladin, Rogue, Priest, Mage, Warlock) get their -# full level 1-20 RANK WINDOWS — every rank a character can train on the way to 20. -# 2. Hunter, Shaman and Druid get deliberately narrow STARTER KITS — the rank-1 ids their default -# rotations and kits actually cast. Widening these to full 1-20 windows is a follow-up. -# Per-class comments below about Human racials or Human character-creation kits are exactly that: -# race-specific notes inside a nine-class import, not a scope limit on the file. -# -# WHEN YOU RUN IT: importer/scripts/import-world.sh already calls it at the right point of a full -# world import, and `lyracore import` drives that for you — running this by hand is the advanced path, -# for re-applying the class spells alone without redoing the whole ETL. NEEDS: a running node with the -# target database published, an extracted client Data directory (for Spell.dbc), and a built importer. -# -# Run from the checkout root; build the importer first: cargo build --bin lyracore-importer -# Usage: importer/scripts/import-class-spells.sh [path-to-client/Data] -# DB=lyracore-world-1 importer/scripts/import-class-spells.sh (a shard other than the default) -# -# [V] on an id below means: measured or guessed against ONE operator's client, not verified against -# yours. Confirm it against your own Spell.dbc before relying on it, and correct a wrong id rather -# than working around it. The safety net is loud, not silent: a typo'd id trips the importer's -# --only "requested N ids but matched M" WARNING instead of importing nothing or the wrong rank. -# -# WHY a script: the imported rows are DB DATA. An additive `spacetime publish` preserves -# them, but a DESTRUCTIVE `-c` reprovision wipes them — re-run this afterward. The module -# spine that makes them usable (trainer.rs resolves the LearnSpell wrapper -> learns the -# rank; cast.rs gates cast on caster.level >= spell.spell_level) ships in the module; this -# script only supplies the DATA. -# -# Each spell needs BOTH its trainer LearnSpell WRAPPER id AND its castable RANK id (the -# wrapper's game_spell_effect.trigger_spell) — the importer can't auto-follow the chain. -# Import is surgical (per-id delete+reload) and ADDITIVE (preserves the seed + 50xxx test -# fixtures). For a dry-run + coverage report, drop `--apply` from the command below. +# Spell imports need both LearnSpell wrapper IDs and castable rank IDs. set -euo pipefail DBC="${1:-../wowclient/Data}" -# WHICH DATABASE. Set DB to target a shard other than the default. `import-world.sh` passes it -# through explicitly, so a multi-shard import puts each shard's curated spells on that shard — this -# script writing to the importer's default while the ETL wrote elsewhere was a real, silent bug. DB="${DB:-lyracore}" -# The advanced world path targets a named loopback node by default. Keep this explicit so its curated -# spell pass cannot silently use a different CLI default than the dump and DBC passes. SPACETIME_SERVER="${SPACETIME_SERVER:-http://127.0.0.1:3000}" -# wrappers + ranks, per class (Parry 3128/3127 is shared Warrior<->Paladin, deduped to Warrior). -# Tier 2b combat-state-machine kit added (re-imported through the updated importer so the reclassification + -# flags land): Heroic Strike (78) + Cleave (845) → E_NEXT_SWING (on-next-swing QUEUE); Overpower (7384, -# spell_level 12) → SPELL_ATTR_REQ_OVERPOWER react-window flag; Revenge (6572) → SPELL_ATTR_REQ_REVENGE flag. -# 78/6572 were already imported (this just re-runs them); 845/7384 are NEW self-contained ranks (no -# LearnSpell wrapper → the rank id IS the trainer learn target, like Kick 1766). All four are in -# the Warrior createinfo kit (game_createinfo_spell) so they're castable; Overpower's L12 is enforced by the cast level-gate. -# Tier 3a STANCE system added: Battle Stance (2457, L1 — the baseline switch-back), Defensive Stance (71, -# L10 — −10% taken/dealt, +threat) and Berserker Stance (2458, L30 — imported for the full switch set). All -# three reclassify the inert ModShapeshift marker to E_SET_STANCE (p0 = 0-based stance id) and are -# self-contained (no LearnSpell wrapper → the rank id IS the learn target). Each carries Stances=0 (usable in -# any stance — they're the switches themselves); the per-ability Stances usability mask (Charge/Thunder -# Clap/Overpower Battle-only, Rend Battle/Defensive, Hamstring Battle/Berserker) is imported from the DBC and -# gates in resolve_cast_at. All three are in the Warrior createinfo kit → castable at login. -# Sunder Armor (7386, spell_level 10 — the iconic stacking armor debuff: A_MOD_RESISTANCE −armor, max_stacks 5) -# added: it is in the Warrior createinfo kit (seed::CREATEINFO_KIT) but had been surviving only on a stale manual import, -# so a -c reprovision + this canonical ETL dropped it (cast → graceful 'unknown spell' Err at L10). Self-contained -# rank (no LearnSpell wrapper → the rank id IS the trainer learn target, like Cleave 845), so it rides IDS_WARRIOR -# for BOTH --only (header import) and --trainer (the L10 learn/re-learn path). -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Battle Shout -# R2 5242 [V] / R3 6192 [V], Sunder Armor R2 7405 [V], Demoralizing Shout R2 6190 [V], Thunder Clap -# R2 8198 [V]. +# Parry 3128/3127 is shared with Paladin and imported once here. IDS_WARRIOR="1343,1423,1606,1716,1738,2688,3128,6549,6674,6343,772,284,1715,100,2687,3127,6546,6673,78,6572,845,7384,7386,2457,71,2458,5242,6192,7405,6190,8198" -# 635 Holy Light R1 + 20154 Seal of Righteousness R1 added: these are the Human Paladin CREATEINFO -# baseline (in the Paladin createinfo kit). They ride the --only set so their game_spell headers import (the -# baseline rows must exist for the spellbook/cast-gate). -# 20154 + 21084 are HEADER-ONLY (TRAIN_PALADIN excludes them): the cmangos npc_trainer data never offers -# either directly — the SoR upgrade path is wrapper 21083 (→21084, req L6), already in this list. Offering -# them directly duplicated "Seal of Righteousness (Rank 1)" in the trainer window (BOTH DBC rows carry -# rank text "Rank 1") and let a paladin buy a second seal identical to the createinfo one. -# 7328 Redemption R1 added: the paladin-healer bots' combat rez — E_RESURRECT maps clean. -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Holy Light -# R3 647 [V]. Seal of Righteousness's next rank-up (20287) and Judgement are ALREADY present below -# (no change needed there) — Judgement's own rank line is deliberately left as-is. -# Consecration 26573 (L20): self-contained rank (no LearnSpell wrapper → the id IS the -# learn target). eff1 is the E_PERSISTENT_AREA ground-AoE (importer name-rescue of the ground -# A_PERIODIC_DAMAGE); its own tick_ground_areas damages hostiles in the 8yd Holy zone for 8s. +# Both 20154 and 21084 have Rank 1 text; direct trainer offerings duplicate the seal. IDS_PALADIN="1873,1875,1878,1911,1937,5572,5584,10294,10321,19741,20437,21083,639,465,633,1022,1152,498,853,10290,20271,21084,19740,20287,21082,635,20154,7328,647,26573" -# shellcheck disable=SC2034 # reference list only (documents which ids TRAIN_PALADIN excludes from -# IDS_PALADIN above — see the header-only note on 20154/21084 just above); not consumed by this script. +# shellcheck disable=SC2034 TRAIN_PALADIN="1873,1875,1878,1911,1937,5572,5584,10294,10321,19741,20437,21083,639,465,633,1022,1152,498,853,10290,20271,19740,20287,21082,635,7328,647,26573" -# Kick R1 (1766, L12) added: a self-contained ability (no LearnSpell wrapper → trainer to_learn falls -# back to 1766). The interrupt is E_INTERRUPT (importer reclassifies raw effect 68); eff1 E_DAMAGE works -# regardless. Its learn path is the OVR_ROGUE override binding further down, not a dump offering. -# Garrote R1 (703, L14) + Feint R1 (1966) added: both self-contained (no LearnSpell -# wrapper → the rank id IS the learn target). Garrote = REQ_STEALTH|REQ_BEHIND opener with an -# A_PERIODIC_DAMAGE bleed (period now imports correctly via the EffectAmplitude .to_bits() fix); Feint = -# E_REDUCE_THREAT (one-time current-threat drop). Both are in the Rogue createinfo kit → castable at login. -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Gouge R2 1998? -# [V] (rank uncertain — CONFIRM this one in particular before relying on it). Slice and Dice R1 (5171) -# is ALREADY present below (no change needed there). IDS_ROGUE="1789,2592,5167,1762,1780,5278,6763,652,1424,2984,5175,1784,53,921,1757,1776,5277,6760,6770,674,2983,5171,1766,703,1966,1998" -# 2139 Counterspell added: the mage-dps bots' interrupt (it stays latent against creatures until -# creature casting is interruptible). -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Fireball -# R3 145 [V] / R4 3140 [V], Frostbolt R3 7322 [V] / R4 8406 [V], Frost Nova R2 6131 [V]. -# Blink 1953 (L20): self-contained rank (no LearnSpell wrapper → the id IS the learn -# target, like Kick 1766). Its eff1 is the E_BLINK forward-teleport (importer name-rescue); eff2 the -# root/snare A_IMMUNITY. Rides --only (header) AND --trainer (the L20 learn offering) via IDS_MAGE. -# Flamestrike 2120 (L16): the first client-castable CLICKED-GROUND spell — eff0 the -# initial dest-anchored AoE nuke (importer forces its E_DAMAGE to T_AREA_ENEMY), eff1 the burning -# patch (name-rescued to E_PERSISTENT_AREA, anchored at the cast's DEST — the same ground-anchoring -# the Paladin's Consecration uses). IDS_MAGE="1472,1142,5507,1173,1249,2141,2136,1168,1191,5146,1174,1194,5565,1459,116,5504,143,587,118,205,5143,7300,122,5505,2139,145,3140,7322,8406,6131,1953,2120" -# 588 Inner Fire R1 + 527 Dispel Magic R1 added: NOT createinfo (trainer-taught); they -# are NOT Human Priest createinfo, so they were demoted to the trainer. Their game_spell headers already -# import via --only here; adding them to the --trainer list gives the (now non-baseline) spells a learn -# path so a fresh Priest can buy them. 1243 PW:Fortitude (also demoted) was already in this list. -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Heal R1 2054 -# [V], Smite R3 6060 [V]. Lesser Heal R3 (2053) is ALREADY present below (no change needed there). IDS_PRIEST="1255,1258,2056,1275,2851,1265,6073,1259,2013,2057,8093,1243,589,2052,591,17,586,139,594,2006,2053,8092,588,527,2054,6060" -# 1-20 rank-window extension — [V] every id, confirm each against your own Spell.dbc: Shadow Bolt -# R3 705 [V] / R4 1088 [V], Corruption R2 6222 [V]. Immolate R2 (707) is ALREADY present below -# (no change needed there). IDS_WARLOCK="1374,1393,6221,1381,1476,1296,5783,1375,1383,6203,7662,348,702,172,695,1454,980,5782,707,696,6201,1120,688,697,705,1088,6222" -# NON-HUMAN class STARTER lists — [V] every id, confirm each against your own Spell.dbc (a typo'd id -# fails LOUD via the --only "requested N but matched M" WARNING, it does not import the wrong rank -# quietly). Deliberately narrow: exactly the rank-1 ids the bot default rotations/kits cast — the -# 1-20 rank-window widening for these three classes is a follow-up. -# HUNTER: Arcane Shot R1 3044 [V], Serpent Sting R1 1978 [V], Raptor Strike R1 2973 [V] — NOTE: -# 2973 is imported/trainer-offered but wired to NO rotation/kit row (the bot's melee filler is the -# frame's melee arming, not this spell); it would import as instant E_DAMAGE (the on-next-swing -# rescue is name-scoped to Heroic Strike/Cleave). Kept for player training; wire it to bots only -# after an on-next-swing rescue for it exists. +# Raptor Strike 2973 imports as instant damage until its on-next-swing effect is mapped. IDS_HUNTER="3044,1978,2973" -# SHAMAN: Healing Wave R1 331 [V], Ancestral Spirit R1 2008 [V] (E_RESURRECT via the importer name -# rescue), Lightning Bolt R1 403 [V], Flame Shock R1 8050 [V], Lightning Shield R1 324 [V]. IDS_SHAMAN="331,2008,403,8050,324" -# DRUID: the form switches Bear Form 5487 [V] + Cat Form 768 [V] (both name-rescued to E_SET_STANCE -# with p0 = our stance id 3/4; Dire Bear 9634 is L40 — outside the curated window, rides a later -# widening), Growl 6795 [V] (the bear taunt), Maul R1 6807 [V] (its Stances mask must import as our -# Bear|DireBear bits 0x28 — see the importer's translate_stance_mask), Healing Touch R1 5185 [V], -# Rejuvenation R1 774 [V], Rebirth R1 20484 [V] (E_RESURRECT by name), Mark of the Wild R1 1126 [V], -# Wrath R1 5176 [V], Moonfire R1 8921 [V]. +# Maul 6807 needs the Bear/Dire Bear stance mask 0x28. IDS_DRUID="5487,768,6795,6807,5185,774,20484,1126,5176,8921" -# Createinfo rank-1s that carry SCRIPT/special effects and so need the importer's -# reclassification — most baselines are plain damage/heal and map fine, so ONLY these are re-imported: -# Rogue Sinister Strike R1 (1752, combo gen) + Eviscerate R1 (2098, finisher). A level-1 rogue starts with -# these, so without this their combo loop is inert. Add others here if a later phase finds an inert baseline. -# Createinfo R1 abilities for EVERY human class (the spells a fresh Human knows at creation, per the cmangos -# playercreateinfo_spell) MUST import here so their game_spell headers exist for the spellbook + cast-gate — -# a baseline id with no header casts as a graceful unknown-spell Err on a clean -c rebuild. Mage Fireball/ -# Frost Armor (133,168 — Frost Armor's eff2 mis-firing chill is neutered E_TRIGGER->A_FLAG by name), Rogue -# Sinister Strike/Eviscerate (1752,2098 — the combo loop), Priest Smite/Lesser Heal (585,2050), Warlock -# Shadow Bolt/Demon Skin (686,687). NOT trainer offerings (createinfo-known), so they ride --only here, not a -# --trainer list. (Summon Imp 688 + Immolate 348 were DEMOTED off the Warlock baseline to trainer-learnable — -# they now live in IDS_WARLOCK, not here; the Imp creature_template 416 is force-included by the creature ETL.) IDS_BASELINE="1752,2098,133,168,585,2050,686,687" -# Hidden TRIGGERED spells that are NOT learnable / NOT a trainer offering, but MUST be imported so a -# learnable spell's effect can resolve them at runtime. Arcane Missiles (5143, in IDS_MAGE) is a CHANNEL -# whose per-tick A_PERIODIC_TRIGGER effect casts 7268 ('Arcane Missile' — the per-bolt E_DAMAGE) each tick; -# without 7268 in game_spell the channel ticks would have nothing to resolve. Bloodrage (2687, in -# IDS_WARRIOR) has effect_index=2 as E_TRIGGER with trigger_spell=29131 (the periodic 10-rage energize -# trickle); without 29131 in game_spell the trickle no-ops and Bloodrage delivers only its instant 10 rage. -# These ride ONLY the global --only set below (NOT any --trainer list), so they import without ever -# appearing as a trainer offering. +# Arcane Missiles triggers 7268; Bloodrage triggers 29131. IDS_TRIGGERED="7268,29131" -# PET abilities — cast by a SUMMONED creature (not the player), so importer-only (--only) and NOT a -# trainer/baseline offering (a player never learns them). The Warlock Imp (creature_template 416, summoned -# by Summon Imp 688 in IDS_WARLOCK) casts Firebolt 3110 via a game_creature_spell rotation row keyed to -# 416 — pass_cast runs it with ZERO new engine code. CRITICAL: 3110 is a 0-mana pet spell, and the Imp is -# built with power=0/max_power=0, so the cost must import as 0 (the cost gate now exempts creatures anyway, -# but 3110 is genuinely free). Like IDS_BASELINE/IDS_TRIGGERED, this rides ONLY the global --only set. +# Imp Firebolt 3110 must retain its zero mana cost. IDS_PET="3110" -# Warrior ids ABOVE the 1-10 window: Slam (1464, spell_level 30) + Shield Wall (871, L28). -# Both are trainer-taught into game_player_spell (the module cast-gate reads those rows), but a fresh warrior can't reach -# their level in the alpha. Imported here ONLY so their game_spell headers exist — book/script consistency: -# an id in CASTABLE with no row would cast as a graceful 'unknown spell' Err. NOT for play → they ride ONLY the -# global --only set below, never a --trainer list (like IDS_BASELINE/IDS_TRIGGERED/IDS_PET). IDS_WARRIOR_ABOVE_WINDOW="1464,871" -# Human RACIAL spells (per-race createinfo, race_spells(1) in constants.rs): Sword Spec(20597), -# The Human Spirit(20598), Diplomacy(20599), Perception(20600), Mace Spec(20598→20864). Header import so -# their game_spell/effect rows exist — the passive ones (SPELL_ATTR_PASSIVE) are applied at login by -# apply_racial_passives, Perception is the active racial. Createinfo-known, not trained → --only only. IDS_RACIAL_HUMAN="20597,20598,20599,20600,20864" IDS="${IDS_WARRIOR},${IDS_PALADIN},${IDS_ROGUE},${IDS_MAGE},${IDS_PRIEST},${IDS_WARLOCK},${IDS_HUNTER},${IDS_SHAMAN},${IDS_DRUID},${IDS_BASELINE},${IDS_TRIGGERED},${IDS_PET},${IDS_WARRIOR_ABOVE_WINDOW},${IDS_RACIAL_HUMAN}" -# Class-trainer CREATURE-TEMPLATE entries (Northshire Valley starting-area human class trainers, all -# already spawned on map 0 with the TRAINER npc_flag). The offerings key to these entries; every spawned -# trainer of the same entry serves the same list (like vendors). Each class's IDS_* list (the SAME data -# already passed via --only) IS the offering source — no second data list. required_level + cost are -# DBC-DERIVED in the importer (the spell's spell_level + a level-keyed formula), so NO cmangos value is -# shipped. To also serve the Stormwind/Goldshire trainers of a class, add a second `--trainer =…` -# with the same IDS_* (cheap; the per-entry surgical delete keeps re-imports idempotent). -# Every same-class trainer entry serves the same list — the Goldshire anchor + the Northshire -# starters + the Stormwind roster. This is not redundancy: a service-coverage audit found all of them -# spawned with the TRAINER flag and teaching NOTHING, so a fresh character could not train anywhere -# before walking to Goldshire. -TRAINERS_WARRIOR="913 911 914 5479 5480" # Lyria(GS) Llane Beshere(NS) Ander Germaine/Wu Shen/Ilsa Corbin(SW) -TRAINERS_PALADIN="927 925 928 5491 5492" # Wilhelm(GS) Sammuel(NS) Grayson/Arthur/Katherine(SW) -TRAINERS_ROGUE="917 915 918 13283" # Keryn(GS) Jorik Kerridan(NS) Osborne/Tony Romano(SW) -TRAINERS_PRIEST="377 375 376 5484 5489 11397" # Josetta(GS) Anetta(NS) Laurena/Benjamin/Joshua/Nara(SW) -TRAINERS_MAGE="328 198 331 5497 5498" # Zaldimar(GS) Khelden Bremen(NS) Dumas/Jennea/Elsharin(SW) -# shellcheck disable=SC2034 # kept for the trainer roster's documentation shape; warlock has no -# `bind` override call below — see "Warlock's full tree is in the dump — no overrides" further down. -TRAINERS_WARLOCK="906 459 461 5495 5496" # Maximillian(GS) Drusilla(NS) Demisette/Ursula/Sandahl(SW) -# Non-Human class trainers — [V] all three creature entries, confirm them against your own dump. A -# wrong entry means the bot trainer pass finds zero offerings for that class: a graceful no-op, never -# a wrong-class grant. These three entries are DUPLICATED in the bot package's class-trainer table — -# change one, change both, or bots will train against a creature the world does not offer. -# These trainers stand in their own starting areas (not Northshire); the offerings still import fine -# (a game_trainer_spell row is keyed by creature-template entry, wherever that template is spawned). -TRAINERS_HUNTER="3596 5515 5516 5517" # Ayanna(Dolanaar)[V] + SW dwarven-district trio (Einris/Ulfir/Thorfin) -TRAINERS_SHAMAN="3062" # Canaga Earthcaller (Valley of Trials) [V] -TRAINERS_DRUID="3033 5504 5505 5506" # Mardant(Shadowglen)[V] + SW Park trio (Sheldras/Theridran/Maldryn) -# INTENTIONALLY UNBOUND (and annotated as known-empty in import-world.sh's coverage audit, so they -# don't read as gaps): 2485 Larimaine Purdue (portal trainer — teleport spells aren't in the curated -# lists yet), 4732 Randal Hunter (riding — no mount system yet), 2879 Karrina Mekenda (pet trainer — -# no hunter pet system yet), 11867 Woo Ping (weapon master — his offerings import from the dump's own -# npc_trainer rows, so he needs no curated binding here). +TRAINERS_WARRIOR="913 911 914 5479 5480" +TRAINERS_PALADIN="927 925 928 5491 5492" +TRAINERS_ROGUE="917 915 918 13283" +TRAINERS_PRIEST="377 375 376 5484 5489 11397" +TRAINERS_MAGE="328 198 331 5497 5498" +# shellcheck disable=SC2034 +TRAINERS_WARLOCK="906 459 461 5495 5496" +# These starting trainer IDs also occur in the bot Package's class-trainer table. +TRAINERS_HUNTER="3596 5515 5516 5517" +TRAINERS_SHAMAN="3062" +TRAINERS_DRUID="3033 5504 5505 5506" TRAINER_ARGS=() bind() { local ids="$1"; shift; for e in "$@"; do TRAINER_ARGS+=(--trainer "${e}=${ids}"); done; } -# The --dump ETL imports the dump's FULL class trees for in-box (human) trainers, with the -# dump's real costs/reqlevels — these curated bindings are the OVERRIDE layer, carrying ONLY the -# "specials" verified ABSENT from cmangos npc_trainer (cmangos delivers them via spell_learn / -# level-up instead): stances/Cleave/Overpower/Sunder/Shield Wall/Slam (warrior), SoR/Consecration -# (paladin), Kick/Garrote/Feint (rogue), Inner Fire/Dispel (priest), Flamestrike/Blink (mage). -# Warlock's full tree is in the dump — no overrides. The full IDS_* lists still ride --only above -# (headers + effects must import for the ranks to be castable). NON-HUMAN trainers are map-1 -# (outside the --dump box → the ETL adds nothing for them) and keep their full curated bindings. +# Dump offerings supply full class trees; curated bindings supply missing spells. OVR_WARRIOR="845,7384,7386,71,2457,2458,871,1464" OVR_PALADIN="20154,21084,26573" OVR_ROGUE="1766,703,1966" diff --git a/importer/scripts/import-manifest-smoke.sh b/importer/scripts/import-manifest-smoke.sh index 876e425ba..c83ca2b90 100755 --- a/importer/scripts/import-manifest-smoke.sh +++ b/importer/scripts/import-manifest-smoke.sh @@ -1,17 +1,4 @@ #!/usr/bin/env bash -# import-manifest-smoke.sh — source-smoke test for importer/scripts/import-manifest.sh. -# -# Run it from the checkout root, any time, with nothing set up: -# bash importer/scripts/import-manifest-smoke.sh # prints "[smoke] OK" when it passes -# -# Offline: no live node, no dump, no built importer. Run it after editing the manifest. -# -# WHAT IT ASSERTS: every KEY the two real consumers read is actually DEFINED after sourcing the -# manifest. Those consumers are importer/scripts/import-world.sh's `chk` floors, and the data-gate -# probes of the wire test harness, which lives in its own repository and is pinned here by release. -# The failure it exists to catch is quiet: a renamed or deleted manifest key reads as an EMPTY STRING -# in a `chk`/gate comparison rather than as an error, so the assertion it backs stops enforcing -# anything while the import still prints OK. set -uo pipefail cd "$(dirname "$0")/../.." || exit 1 @@ -19,7 +6,7 @@ cd "$(dirname "$0")/../.." || exit 1 source importer/scripts/import-manifest.sh fail=0 -need() { # key-name +need() { if [ -z "${!1:-}" ]; then echo " FAIL $1 is unset/empty"; fail=1; else echo " ok $1=${!1}"; fi } @@ -49,8 +36,6 @@ for k in GATE_SPELL_PALADIN GATE_SPELL_SHADOWBOLT GATE_CREATURE_TEST GATE_HEALER need "$k" done -# Cross-check: every FLOOR_/GATE_ key the manifest itself DEFINES is covered by the lists above (a new -# key added to the manifest without updating this smoke test would otherwise go unchecked silently). defined_keys="$(grep -oE '^(FLOOR|GATE)_[A-Z0-9_]+=' importer/scripts/import-manifest.sh | tr -d '=')" checked_keys="FLOOR_CLASS_TRAINERS FLOOR_TRAINER_OFFERINGS_CLASS FLOOR_TRAINER_OFFERINGS_PROFESSION \ FLOOR_GATHER_NODE_GO FLOOR_TERRAIN_CHUNKS FLOOR_NAV_CHUNKS FLOOR_TRAINER_COVERAGE FLOOR_VENDOR_COVERAGE FLOOR_QUESTGIVER_COVERAGE FLOOR_INNKEEPER_FARLEY FLOOR_QUEST_GIVER_RELATIONS \ @@ -96,11 +81,6 @@ for profile in full-world full-eastern full-kalimdor full-instances alliance-eas case " $WORLD_PROFILES " in *" $profile "*) ;; *) echo " FAIL missing canonical profile $profile"; fail=1 ;; esac done -# The manifest's MAP!=0 tail re-points the BOX-DEPENDENT floors for a continent shard other -# than map 0. Re-source it with MAP=1 in a SUBSHELL (so the map-0 values above stay intact for anything -# sourcing this script) and assert (a) every key is still defined — an override block that typo'd a key -# name would otherwise leave a `chk` comparing against an empty string — (b) a box-dependent floor -# actually dropped, and (c) a map-INDEPENDENT floor did NOT move (those must match on every shard). echo "[smoke] MAP=1 continent-shard floor overrides:" map1_out="$( MAP=1 bash -c ' @@ -115,7 +95,7 @@ map1_out="$( )" echo "$map1_out" | grep -q '^UNSET' && { echo "$map1_out" | grep '^UNSET' | sed 's/^/ FAIL /'; fail=1; } m1() { echo "$map1_out" | grep "^$1=" | cut -d= -f2; } -chk_eq() { # label expected actual +chk_eq() { if [ "$2" != "$3" ]; then echo " FAIL $1: got $3, want $2"; fail=1; else echo " ok $1: $3"; fi } chk_eq "FLOOR_TERRAIN_CHUNKS drops for a non-map-0 shard" 100 "$(m1 FLOOR_TERRAIN_CHUNKS)" @@ -124,15 +104,9 @@ chk_eq "FLOOR_SPELL_TOTAL is map-INDEPENDENT (unchanged)" "$FLOOR_SPELL_TOTAL" " chk_eq "FLOOR_AREAS is map-INDEPENDENT (unchanged)" "$FLOOR_AREAS" "$(m1 FLOOR_AREAS)" chk_eq "FLOOR_TAXI_NODES is map-INDEPENDENT" "$FLOOR_TAXI_NODES" "$(m1 FLOOR_TAXI_NODES)" chk_eq "FLOOR_START_ITEMS_CLASSES is map-INDEPENDENT" "$FLOOR_START_ITEMS_CLASSES" "$(m1 FLOOR_START_ITEMS_CLASSES)" -# And the map-0 defaults are untouched when MAP is unset/0 (the byte-parity guarantee for the canonical run). chk_eq "MAP unset keeps the map-0 terrain floor" 2500 "$FLOOR_TERRAIN_CHUNKS" chk_eq "MAP unset keeps the map-0 live-creature floor" 2500 "$FLOOR_LIVE_CREATURES" -# import-world.sh's `chk0`/`chk36` FENCE the assertions that name a specific map-0 or map-36 fixture. -# Their failure mode is the dangerous direction — a wrapper that stops delegating to `chk` does not go -# red, it silently stops enforcing Elwynn/Deadmines and the canonical run still prints "[world] OK". -# Nothing else runs those two lines offline, so pin their semantics here by extracting the definitions -# verbatim out of import-world.sh and exercising both arms. echo "[smoke] import-world.sh chk0/chk36 map fences:" fence_out="$( bash -c ' @@ -165,13 +139,6 @@ chk_eq "chk0 SKIPS Human checks for alliance-kalimdor" no "$(ran profile- chk_eq "chk36 RUNS instance checks for alliance-single" yes "$(ran profile-single-instance)" chk_eq "chk36 RUNS instance checks for instances" yes "$(ran profile-instances)" -# The fresh-shard one-continent guard helpers (db_spatial_probe/db_imported_probe/db_never_imported/ -# foreign_spatial_maps, marker-delimited in import-world.sh so they can be extracted verbatim here, -# same trick as the chk0/chk36 pin above). Exercising them for real needs a live node and two -# differently-populated databases, so this pins BOTH directions offline instead, with a stub -# `spacetime`: a never-imported shard whose only spatial rows are `init`'s map-0 demo fixtures must NOT -# read as foreign content, while a database that genuinely holds real map-0 terrain/nav content must -# still be flagged for a map-1 run. echo "[smoke] import-world.sh one-continent guard helpers:" guard_out="$( bash -c ' @@ -219,16 +186,13 @@ guard_out="$( echo "family_reload_never_imported=$(db_never_imported && echo yes || echo no)" ' )" -g() { echo "$guard_out" | grep "^$1=" | cut -d= -f2 | sed 's/ *$//'; } # foreign_spatial_maps trails a space +g() { echo "$guard_out" | grep "^$1=" | cut -d= -f2 | sed 's/ *$//'; } chk_eq "fresh shard: db_never_imported is TRUE (terrain/nav empty)" yes "$(g fresh_never_imported)" chk_eq "fresh shard: map-0 fixture rows still show up in the raw probe" 0 "$(g fresh_pre_foreign)" chk_eq "contaminated shard: db_never_imported is FALSE (real terrain/nav)" no "$(g contaminated_never_imported)" chk_eq "contaminated shard: foreign map 0 still reported" 0 "$(g contaminated_pre_foreign)" chk_eq "family-reload blind spot: db_never_imported is FALSE (row-count fallback catches it)" no "$(g family_reload_never_imported)" -# import-world.sh does not enable `set -e`; a raw importer|grep|tail pipeline therefore used to keep -# running after the importer failed. Exercise the marker-delimited status-capturing helper with both -# outcomes, then pin the standalone DBC call site's explicit exit. echo "[smoke] standalone DBC import failure propagation:" checked_step_out="$( bash -c ' @@ -251,9 +215,6 @@ dbc_callsite="$(sed -n '/run_checked_step "standalone DBC catalogue import"/,/-- case "$dbc_callsite" in *"--apply || exit 1"*) dbc_callsite_fatal=yes ;; *) dbc_callsite_fatal=no ;; esac chk_eq "standalone DBC call exits import-world.sh on failure" yes "$dbc_callsite_fatal" -# The map-0 restore is equally load-bearing: a denied reducer call must stop the import before the -# verification stage. Its postcondition is independently checked across the catalogue and all three -# spatial anchors (template, spawn, live entity). echo "[smoke] taxi fixture restore failure propagation and anchor checks:" taxi_restore_out="$( bash -c ' @@ -299,8 +260,6 @@ chk_eq "complete taxi fixture satisfies every anchor" 0 "$(ta success_fail)" chk_eq "taxi verification still runs every anchor after one miss" 7 "$(ta missing_live_checked)" chk_eq "missing live flight master fails verification" 1 "$(ta missing_live_fail)" -# The Compatibility Manifest checks source values and dependency paths before apply. The shell -# verification makes one post-import query and checks the native definition subject invariant. echo "[smoke] import-world.sh EventAI catalogue verification:" eventai_out="$( bash -c ' diff --git a/importer/scripts/import-manifest.sh b/importer/scripts/import-manifest.sh index e15c78728..5d8b621b4 100644 --- a/importer/scripts/import-manifest.sh +++ b/importer/scripts/import-manifest.sh @@ -1,43 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 # file-wide: this file's entire job is to `source`-export FLOOR_*/GATE_* -# constants for import-world.sh and wire-suite.sh to read (see below) — nothing in THIS file reads -# them, so ShellCheck sees every single one as "unused" when it's the intended, documented shape. -# import-manifest.sh — the single source of truth for the numeric constants that decide whether a -# world import SUCCEEDED. Nothing here runs anything: it is a data file that other scripts `source`. -# -# YOU DO NOT RUN THIS FILE. You edit it, when a floor below is wrong for YOUR dump. It is sourced by -# importer/scripts/import-world.sh (which turns each FLOOR_* into a post-import assertion) and by the -# wire test harness, which lives in its own repository and is pinned here by release. -# -# TUNING IT IS EXPECTED, NOT EXCEPTIONAL. These numbers were measured against one operator's dump and -# one operator's client. Yours will differ. When an import prints a FAIL for a family that plainly did -# land, lower the floor here; when your real counts are far above a floor, raise it, because a floor -# far below reality catches nothing. -# -# [V] on a value below means: measured or guessed against ONE operator's dump/client, not verified -# against yours. Confirm it against your own data before relying on it; correct a wrong id rather than -# loosening the check around it. -# -# NOT a dedup of one idea — TWO DISJOINT CONSTANT CLASSES live here, kept in their own sections: -# -# FLOOR_* row-count MINIMUMS `import-world.sh`'s `chk` assertions compare an ETL run's actual -# counts against (e.g. "at least 100 gather-node GOs landed"). One FLOOR_* per `chk` line -# in `importer/scripts/import-world.sh`. The surrounding prose/rationale for each floor -# stays in `import-world.sh` next to its `chk` call; this file carries only the numbers. -# -# GATE_* PRESENCE PROBE ids the wire suite's data-gate section uses to detect -# "is this a mock-seed sandbox or a fully-imported node" (e.g. "does game_spell contain id -# 635 at all") — a specific row's EXISTENCE, not a count minimum. Different question, -# different shape (an id to look up, not a threshold to compare against) — hence the -# separate section, not a merge into FLOOR_*. -# -# KEY=value only (shell-sourceable, no command substitution) — `source importer/scripts/import-manifest.sh` -# from either consumer. See the source-smoke test (import-manifest-smoke.sh) for the "every consumed -# key is defined here" check both consumers rely on. -# -# PROFILE VOCABULARY: WORLD_PROFILE selects one canonical WorldImportProfile in the importer. These -# names and planned maps intentionally carry no rectangles or samples: world_import_scope.rs is the -# sole catalogue for spatial scope. The map lists are only the script's post-import ownership check. +# shellcheck disable=SC2034 WORLD_PROFILES="full-world full-eastern full-kalimdor full-instances alliance-eastern alliance-kalimdor alliance-single instances" PROFILE_FULL_EASTERN_MAPS="0 369 449" PROFILE_FULL_KALIMDOR_MAPS="1 450" @@ -48,185 +10,96 @@ PROFILE_ALLIANCE_KALIMDOR_MAPS="1" PROFILE_ALLIANCE_SINGLE_MAPS="0 1 36 369" PROFILE_INSTANCES_MAPS="36" -# MAP-AWARENESS: every count floor below is tuned to the MAP-0 Elwynn/Westfall box. A continent import -# for another map (`MAP=1` — Kalimdor on its own world shard) would fail ~40 of them for content that -# legitimately isn't there, which is noise, not a regression signal — so the tail of the FLOOR_* -# section re-points the BOX-DEPENDENT floors at conservative presence values when `MAP` is set to -# anything but 0. Map-INDEPENDENT floors (the DBC tables, Spell.dbc, the world-wide dump globals) are -# NOT overridden: they must land identically on every shard. +# Count floors are source-dependent estimates tuned to the map-0 Elwynn/Westfall scope. set -uo pipefail -# ===================================================================================================== -# FLOOR_* — one per `chk` line in importer/scripts/import-world.sh -# ===================================================================================================== +FLOOR_CLASS_TRAINERS=6 +FLOOR_TRAINER_OFFERINGS_CLASS=2000 +FLOOR_TRAINER_OFFERINGS_PROFESSION=80 +FLOOR_GATHER_NODE_GO=100 +FLOOR_TERRAIN_CHUNKS=2500 +FLOOR_NAV_CHUNKS=3000 +FLOOR_INNKEEPER_FARLEY=1 +FLOOR_QUEST_GIVER_RELATIONS=450 +FLOOR_QUESTS_L6_10=20 +FLOOR_QUESTS_L10_20=1 +FLOOR_QUESTS_CHAINED=1 +FLOOR_LIVE_CREATURES=2500 +FLOOR_CONTINENT_SPAWNS=1500 +FLOOR_VENDORS=280 +FLOOR_TRAINER_COVERAGE=70 +FLOOR_VENDOR_COVERAGE=125 +FLOOR_QUESTGIVER_COVERAGE=190 +FLOOR_SENTINEL_HILL_VENDOR=1 +FLOOR_START_ITEMS_CLASSES=6 +FLOOR_HUMAN_START_POSITIONS=6 +FLOOR_DWARF_START_POSITIONS=5 +FLOOR_GNOME_START_POSITIONS=4 +FLOOR_NIGHT_ELF_START_POSITIONS=5 +FLOOR_DWARF_START_ITEMS_CLASSES=5 +FLOOR_GNOME_START_ITEMS_CLASSES=4 +FLOOR_NIGHT_ELF_START_ITEMS_CLASSES=5 +FLOOR_DUN_MOROGH_START=1 +FLOOR_LOCH_MODAN_CONTENT=1 +FLOOR_TELDRASSIL_START=1 +FLOOR_DARKSHORE_CONTENT=1 +FLOOR_CORRIDOR_QUEST_LEVEL_BAND=1 +FLOOR_CORRIDOR_GAMEOBJECTS=1 +FLOOR_CASTER_CAST_ROWS=60 +FLOOR_GEOMANCER_CAST_SPELL=1 +FLOOR_ROGUE_WIZARD_CAST_SPELL=1 +FLOOR_IMP_FIREBOLT_CAST_ROW=1 +FLOOR_IMP_FIREBOLT_SPELL=1 +FLOOR_DEFIAS_CASTER_CAST_ROW=1 +FLOOR_ROTATION_ROWS=20 +FLOOR_GEOMANCER_ROTATION_NUKE=1 +FLOOR_FROST_ARMOR_ROTATION=1 +FLOOR_AREAS=1000 +FLOOR_AREA_TRIGGERS=400 +FLOOR_GRAVEYARDS=50 +FLOOR_TAXI_NODES=1 +FLOOR_TAXI_PATHS=1 +FLOOR_TAXI_PATH_NODES=1 +FLOOR_GRAVEYARD_ZONE_RESOLVE=1 +FLOOR_AREATRIGGER_TELEPORTS=50 +FLOOR_DEADMINES_PORTAL_ROUNDTRIP=2 +FLOOR_DEADMINES_CREATURE_SPAWNS=100 +FLOOR_DEADMINES_GAMEOBJECTS=3 +# Sneed 643 is summoned when Shredder 642 dies and may have no placed spawn. +FLOOR_DEADMINES_BOSSES=6 +FLOOR_DEADMINES_NAMED_LOOT=2 +FLOOR_PICKPOCKET_LOOT=1 +FLOOR_SKINNING_LOOT=1 +FLOOR_GAMEOBJECT_CHEST_LOOT=1 +FLOOR_FISHING_LOOT=1 +FLOOR_CREATURES_WITH_PICKPOCKET=1 +FLOOR_CREATURES_WITH_SKIN=1 +FLOOR_SPELL_TOTAL=8000 +FLOOR_SPELL_CHAIN=300 +FLOOR_SPELL_LEARN=5 +FLOOR_SPELL_PROC_EVENT=100 -FLOOR_CLASS_TRAINERS=6 # 6 class trainers spawned {328,377,906,913,917,927} -FLOOR_TRAINER_OFFERINGS_CLASS=2000 # class trainer offerings (learn_skill_line=0) — the dump's own class trees are the primary source; a re-wipe regression fails this -FLOOR_TRAINER_OFFERINGS_PROFESSION=80 # profession learn offerings (learn_skill_line>0) -FLOOR_GATHER_NODE_GO=100 # gather-node GO spawns -FLOOR_TERRAIN_CHUNKS=2500 # [V] terrain chunks (ground-z heightmap) — tune after your first real Westfall import -FLOOR_NAV_CHUNKS=3000 # nav chunks (walkability/LoS grid) — the full canonical box rasterized 4482 on a real import -FLOOR_INNKEEPER_FARLEY=1 # innkeeper Farley(295) spawned -FLOOR_QUEST_GIVER_RELATIONS=450 # [V] quest-giver relations — an estimate; tighten against your own dump's count -FLOOR_QUESTS_L6_10=20 # L6-10 quests (quest_level band) -FLOOR_QUESTS_L10_20=1 # L10-20 quests (Westfall quest_level band) — presence only -FLOOR_QUESTS_CHAINED=1 # [V] chained quests (next_quest_id>0) — presence only; - # the McBride 783->7->15->21->54 chain (already verified via the - # working prev_quest_id import) should populate this symmetrically. - # NOTE: no FLOOR_QUESTS_TIMED — timed (limit_time>0) quests are only - # coverage-printed, not floor-gated: plausible this Elwynn/Westfall - # slice has zero (escort quests, the usual timed pairing, are - # deliberately out of scope), so a floor here would risk a false - # failure on a legitimate import. -FLOOR_LIVE_CREATURES=2500 # [V] live creature entities — an estimate; tighten against your own dump's count -FLOOR_CONTINENT_SPAWNS=1500 # [V] game_creature_spawn rows carrying THIS run's primary map id: - # proves the continent's spatial rows landed under the RIGHT map — a map-1 - # shard whose spawns all read map_id 0 would pass every other floor. 1500 is - # deliberately well under the ~2200-3500 the widened map-0 box yields (a floor - # that fails on a legitimate import is the worse failure) — tighten to your dump. -FLOOR_VENDORS=280 # [V] vendors (npc_vendor) — an estimate; tighten against your own dump's count -FLOOR_TRAINER_COVERAGE=70 # spawned trainer-flagged NPCs that actually teach (measured 72 of 75; 3 are annotated known-empty) -FLOOR_VENDOR_COVERAGE=125 # spawned vendor-flagged NPCs with vendor rows (measured 127 of 128 via npc_vendor_template flattening; the Brigadier General is annotated) -FLOOR_QUESTGIVER_COVERAGE=190 # spawned questgiver-flagged NPCs with quest relations (measured 194 of 228; the quiet ones may be off-level content) -FLOOR_SENTINEL_HILL_VENDOR=1 # Sentinel Hill trainer/vendor spawned [V SENTINEL_HILL_VENDOR_ENTRY, set separately] -FLOOR_START_ITEMS_CLASSES=6 # Human start-items (all 6 classes) -FLOOR_HUMAN_START_POSITIONS=6 # playercreateinfo: Human Warrior/Paladin/Rogue/Priest/Mage/Warlock -FLOOR_DWARF_START_POSITIONS=5 # playercreateinfo: Dwarf Warrior/Paladin/Hunter/Rogue/Priest -FLOOR_GNOME_START_POSITIONS=4 # playercreateinfo: Gnome Warrior/Rogue/Mage/Warlock -FLOOR_NIGHT_ELF_START_POSITIONS=5 # playercreateinfo: Night Elf Warrior/Hunter/Rogue/Priest/Druid -FLOOR_DWARF_START_ITEMS_CLASSES=5 # Dwarf creation loadout families; no Human fallback -FLOOR_GNOME_START_ITEMS_CLASSES=4 # Gnome creation loadout families; no Human fallback -FLOOR_NIGHT_ELF_START_ITEMS_CLASSES=5 # Night Elf creation loadout families; no Human fallback -FLOOR_DUN_MOROGH_START=1 # Sten Stoutarm (658), map 0 -FLOOR_LOCH_MODAN_CONTENT=1 # Mountaineer Kadrell (1340), map 0 -FLOOR_TELDRASSIL_START=1 # Conservator Ilthalaine (2079), map 1 -FLOOR_DARKSHORE_CONTENT=1 # Gwennyth Bly'Leggonde (10219), map 1 -FLOOR_CORRIDOR_QUEST_LEVEL_BAND=1 # each named corridor giver has a level 1-20 quest -FLOOR_CORRIDOR_GAMEOBJECTS=1 # each planned open-world map retains GameObjects -FLOOR_CASTER_CAST_ROWS=60 # [V] caster-mob cast rows — an estimate; tighten against your own dump's count -FLOOR_GEOMANCER_CAST_SPELL=1 # 476 Geomancer cast spell present -FLOOR_ROGUE_WIZARD_CAST_SPELL=1 # 474 Rogue Wizard cast spell present -FLOOR_IMP_FIREBOLT_CAST_ROW=1 # Imp(416) Firebolt cast row -FLOOR_IMP_FIREBOLT_SPELL=1 # Imp Firebolt(3110) in game_spell -FLOOR_DEFIAS_CASTER_CAST_ROW=1 # [V] Defias caster cast row (Pillager 589?/Conjurer 449?) -FLOOR_ROTATION_ROWS=20 # rotation rows (game_creature_spell) -FLOOR_GEOMANCER_ROTATION_NUKE=1 # 476 Geomancer rotation nuke row -FLOOR_FROST_ARMOR_ROTATION=1 # 474/476 Frost Armor rotation row -FLOOR_AREAS=1000 # areas imported (game_area) — tuned against a real 1.12.1 client: AreaTable imports 1081 rows -FLOOR_AREA_TRIGGERS=400 # area triggers imported (game_area_trigger) — tuned against a real 1.12.1 client: 432 rows -FLOOR_GRAVEYARDS=50 # [V] graveyards imported (game_graveyard) — same caveat as FLOOR_AREAS -FLOOR_TAXI_NODES=1 # client-derived rows below the reserved 5090000 fixture floor -FLOOR_TAXI_PATHS=1 # directed client-derived routes below the fixture floor -FLOOR_TAXI_PATH_NODES=1 # ordered client-derived path points below the fixture floor -FLOOR_GRAVEYARD_ZONE_RESOLVE=1 # zone-12 graveyard_zone links resolve to a real game_graveyard row -FLOOR_AREATRIGGER_TELEPORTS=50 # [V] areatrigger_teleport portals imported (game_areatrigger_teleport) - # — an unverified estimate; - # this is a WORLD-WIDE table (every dungeon/instance/battleground portal in - # the dump, not just Deadmines), so 50 is a conservative floor well under a - # real vanilla dump's count — tighten/widen against your own dump. -FLOOR_DEADMINES_PORTAL_ROUNDTRIP=2 # Deadmines' entrance(78)+exit(119) — read off a real dump, areatrigger_teleport rows BOTH - # present — proves the round-trip (walk in AND walk back out) imports, not - # just one direction. Confirm both trigger ids against YOUR dump before - # trusting them; correct a wrong id rather than lowering this floor. -FLOOR_DEADMINES_CREATURE_SPAWNS=100 # [V] map-36 creature spawns (game_creature_spawn.map_id=36, from the - # --include-map 36 slice) — a real classic-db Deadmines carries - # several hundred placed spawns; 100 is a conservative floor so a - # scope/parse regression (or forgetting --include-map 36) fails loud - # without flaking on a legitimately leaner dump. Tighten to your dump. -FLOOR_DEADMINES_GAMEOBJECTS=3 # [V] map-36 gameobject spawns (game_gameobject.map_id=36) — the - # instance doors (Rhahk'Zor's door, the iron clad door), the Defias - # Cannon, ship objects, chests; 3 is a bare-presence floor (doors + - # cannon alone clear it). Tighten to your dump. -FLOOR_DEADMINES_BOSSES=6 # [V] DISTINCT boss template entries among the map-36 PLACED spawns — - # Rhahk'Zor 644, Sneed's Shredder 642, Gilnid 1763, Mr. Smite 646, - # Captain Greenskin 647, Edwin VanCleef 639. Sneed 643 is deliberately - # NOT counted: cmangos-era dumps script-summon him when the Shredder - # (642) dies, so a CORRECT dump typically has no placed 643 row — whether - # he actually shows up is an encounter-scripting concern, not an import - # floor. If your dump DOES place 643 the count reads 7 and still - # passes. A wrong [V] id here FAILS LOUD — deliberate, the - # SENTINEL_HILL_VENDOR precedent: correct the id, don't loosen the floor. -FLOOR_DEADMINES_NAMED_LOOT=2 # [V] BOTH named Deadmines drops present in game_creature_loot — - # Cruel Barb ~5191 (VanCleef) AND Smite's Mighty Hammer ~5196 (Smite); - # both item ids are 1.12-from-knowledge and UNVERIFIED — confirm them - # against your dump's creature_loot_template before trusting them, then - # tighten. Floor 2 = both - # (the DEADMINES_PORTAL_ROUNDTRIP precedent — 1 would pass vacuously). -FLOOR_PICKPOCKET_LOOT=1 # [V] pickpocket loot rows (game_pickpocket_loot) -FLOOR_SKINNING_LOOT=1 # [V] skinning loot rows (game_skinning_loot) -FLOOR_GAMEOBJECT_CHEST_LOOT=1 # [V] gameobject (chest) loot rows (game_gameobject_loot) -FLOOR_FISHING_LOOT=1 # [V] fishing loot rows (game_fishing_loot) -FLOOR_CREATURES_WITH_PICKPOCKET=1 # [V] creatures with a pickpocket table (creature_template.pickpocket_loot_id>0) -FLOOR_CREATURES_WITH_SKIN=1 # [V] creatures with a skin table (creature_template.skin_loot_id>0) -FLOOR_SPELL_TOTAL=8000 # [V] total game_spell rows after the full Spell.dbc import — the true - # count is client-build-dependent (~10-12k non-zero rows); 8000 - # is a conservative minimum so a partial-parse regression fails loud without being - # so tight it flakes on a legitimately smaller client build. -FLOOR_SPELL_CHAIN=300 # [V] game_spell_chain rows — an unverified estimate; - # a real classic-db dump's spell_chain table - # covers every multi-rank spell family (weapon skills, class ranked spells, - # ranked talents-as-spells) — a conservative floor well under the real count so - # a parse/column regression fails loud without flaking on a smaller client build. -FLOOR_SPELL_LEARN=5 # game_spell_learn rows — measured on a real import: the - # reduced-scope generator emits 9 today, and widening that - # generator is separate work, not an import regression. - # spell_learn_spell (auto-taught dependents) is a much - # smaller table than spell_chain, so 5 is a presence floor - # — tighten it against your own dump's real count. -FLOOR_SPELL_PROC_EVENT=100 # [V] game_spell_proc_event rows — the classic-db proc overlay - # (PPM, internal cooldowns, procEx, school/family filters). - # An unverified estimate: cmangos hand-authors one row per - # scripted proc, which in 1.12 is the talent procs, the class - # shields and the weapon procs — a few hundred. 100 is a - # conservative floor that still fails loud if the table is - # absent from the dump or its columns drifted. Tighten it - # against your own dump's real count. - -# ----------------------------------------------------------------------------------------------------- -# MAP != 0 — the continent-shard overrides -# ----------------------------------------------------------------------------------------------------- -# Every floor re-pointed here is BOX-DEPENDENT: its number was measured against (or estimated for) the -# Elwynn/Westfall rectangle, so on a `MAP=1` Kalimdor shard it measures content that was never in the -# slice. Left alone, a legitimate map-1 import reports ~40 FAILs for absent Elwynn content and the real -# signal drowns — so they drop to PRESENCE floors: enough to catch "a whole family silently imported -# nothing" (the thing floors exist for), not enough to pretend we know a Durotar spawn count. -# -# `[V]` all of it, and deliberately loose: the FIRST real map-1 import is the measurement. Record its -# counts and tighten these — the map-0 floors got their numbers exactly that way. Floors NOT overridden -# (on purpose, they must match on every shard): FLOOR_START_ITEMS_CLASSES, FLOOR_SPELL_*, FLOOR_AREAS, -# FLOOR_AREA_TRIGGERS, FLOOR_GRAVEYARDS, FLOOR_AREATRIGGER_TELEPORTS, FLOOR_DEADMINES_PORTAL_ROUNDTRIP -# and the four loot-family presence floors — all DBC-sourced or world-wide dump globals, imported -# regardless of `--map`/`--box`. The map-0-ONLY named-fixture floors (Goldshire trainers, Farley, -# Sentinel Hill, the Elwynn casters, the map-36 slice) aren't overridden either: `import-world.sh` -# doesn't RUN those assertions off map 0. -# SLICE covers both cases: another continent, and a caller-chosen map-0 box (which is what a region -# shard is). Either way the corridor-tuned counts below are wrong. MAP!=0 is kept as a fallback so -# sourcing this file without import-world.sh still behaves sanely. +# Custom slices use presence floors because corridor counts do not apply. if [ "${SLICE:-0}" = 1 ] || [ "${MAP:-0}" != 0 ]; then - FLOOR_TRAINER_OFFERINGS_CLASS=1 # [V] the curated non-Human bindings alone land ~30; a starting-zone box adds its own trainers - FLOOR_TRAINER_OFFERINGS_PROFESSION=1 # [V] - FLOOR_GATHER_NODE_GO=1 # [V] - FLOOR_TERRAIN_CHUNKS=100 # [V] a starting-zone box rasterizes hundreds of cells; 100 catches a dead terrain pass - FLOOR_NAV_CHUNKS=1 # [V] only cells WITH obstruction emit a row, so this is presence-only - FLOOR_QUEST_GIVER_RELATIONS=1 # [V] - FLOOR_QUESTS_L6_10=1 # [V] every starting zone has 6-10 content; count is box-width dependent - FLOOR_LIVE_CREATURES=100 # [V] - FLOOR_CONTINENT_SPAWNS=100 # [V] map-scoped spawn rows — the "landed under the right map id" check - FLOOR_VENDORS=1 # [V] - FLOOR_TRAINER_COVERAGE=1 # [V] - FLOOR_VENDOR_COVERAGE=1 # [V] - FLOOR_QUESTGIVER_COVERAGE=1 # [V] - FLOOR_CASTER_CAST_ROWS=1 # [V] + FLOOR_TRAINER_OFFERINGS_CLASS=1 + FLOOR_TRAINER_OFFERINGS_PROFESSION=1 + FLOOR_GATHER_NODE_GO=1 + FLOOR_TERRAIN_CHUNKS=100 + FLOOR_NAV_CHUNKS=1 + FLOOR_QUEST_GIVER_RELATIONS=1 + FLOOR_QUESTS_L6_10=1 + FLOOR_LIVE_CREATURES=100 + FLOOR_CONTINENT_SPAWNS=100 + FLOOR_VENDORS=1 + FLOOR_TRAINER_COVERAGE=1 + FLOOR_VENDOR_COVERAGE=1 + FLOOR_QUESTGIVER_COVERAGE=1 + FLOOR_CASTER_CAST_ROWS=1 fi -# ===================================================================================================== -# GATE_* — presence-probe ids the wire suite's data-gate section keys on (existence, not a count -# floor). Verified against the wire suite's CURRENT data-gate block — that suite lives in its own -# repository, which this checkout pins by release, so nothing here can edit it. If you change an id -# below, the harness's matching probe has to move with it. -# ===================================================================================================== +# GATE_* IDs must match the checks in the separately pinned Headless Client suite. -GATE_SPELL_PALADIN=635 # curated paladin kit imported? (the suite's HAS_SPELL_635 probe) -GATE_SPELL_SHADOWBOLT=686 # Shadow Bolt — the general "world import ran" probe (HAS_SPELL_686) -GATE_CREATURE_TEST=103 # creature_template entry 103 (HAS_CREATURE_103) -GATE_HEALER_ENTRY=6491 # spirit healer creature entry (HAS_HEALER) +GATE_SPELL_PALADIN=635 +GATE_SPELL_SHADOWBOLT=686 +GATE_CREATURE_TEST=103 +GATE_HEALER_ENTRY=6491 diff --git a/importer/scripts/import-world-sql-test.sh b/importer/scripts/import-world-sql-test.sh index b721b7237..c98f59059 100644 --- a/importer/scripts/import-world-sql-test.sh +++ b/importer/scripts/import-world-sql-test.sh @@ -1,24 +1,4 @@ #!/usr/bin/env bash -# import-world-sql-test.sh — OFFLINE behavioural test for import-world.sh's SQL chokepoint (#440). -# -# Run it from the checkout root, any time, with nothing set up: -# bash importer/scripts/import-world-sql-test.sh # prints "[test] OK" when it passes -# -# In the style of scripts/install-script-test.sh: everything runs against STUBBED tools on PATH in a -# scratch directory. `spacetime` and `cargo` are shell shims; `target/debug/lyracore-importer` (the -# importer binary import-world.sh execs directly by relative path) is a shim too. No real ETL runs, -# no network, no live node — see that script's own header for why a full import can't be exercised -# here (tens of minutes, needs a real dump/client/node). -# -# WHAT THIS PINS: #440 was three defects that together turned a SUCCESSFUL import into ~50 false -# "table is empty" FAILs — (a) every `spacetime sql`/`spacetime call` queried the CLI's ambient -# default server instead of the node the import actually targets, (b) a FAILED query and a -# LEGITIMATE zero-row result both read as a count of 0, so one dead connection produced a wall of -# fake FAILs instead of one honest error, and (c) an undeclared `python3` dependency crashed the -# service-coverage audit mid-output on a minimal host. This test stubs `spacetime` to fail (proving a -# single "cannot reach" abort, not a FAIL wall), stubs it to succeed with canned counts (proving the -# assertions actually read them, not always 0), checks every logged invocation carries --server, and -# exercises the python3-missing degrade path. set -uo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" @@ -26,7 +6,7 @@ passed=0 failed=0 ok() { passed=$((passed + 1)); printf ' ok %s\n' "$1"; } no() { failed=$((failed + 1)); printf ' FAIL %s\n' "$1"; } -check() { # check +check() { local desc="$1" shift if "$@"; then ok "$desc"; else @@ -36,7 +16,7 @@ check() { # check } contains() { grep -Fq -- "$2" "$1"; } not_contains() { ! grep -Fq -- "$2" "$1"; } -count_matches() { grep -c -- "$2" "$1" 2>/dev/null || true; } # never fails the -e-less caller on "0 matches" +count_matches() { grep -c -- "$2" "$1" 2>/dev/null || true; } sandbox="$(mktemp -d "${TMPDIR:-/tmp}/lyracore-import-world-sql-test.XXXXXX")" trap 'rm -rf "$sandbox"' EXIT @@ -46,7 +26,6 @@ mkdir -p "$stubs" stub_log="$sandbox/stub.log" : >"$stub_log" -# --- the scratch checkout: import-world.sh's own `cd "$(dirname "$0")/../.."` lands here ----------- checkout="$sandbox/checkout" mkdir -p "$checkout/importer/scripts" "$checkout/target/debug" cp "$repo_root/importer/scripts/import-world.sh" "$checkout/importer/scripts/import-world.sh" @@ -56,9 +35,6 @@ cp "$repo_root/importer/scripts/import-class-spells.sh" "$checkout/importer/scri chmod +x "$checkout/importer/scripts/import-world.sh" "$checkout/importer/scripts/import-class-spells.sh" under_test="$checkout/importer/scripts/import-world.sh" -# --- stubs ---------------------------------------------------------------------------------------- -# The importer binary import-world.sh execs directly by relative path (`./target/debug/lyracore-importer`, -# never resolved through PATH) — no real ETL, no dump, no client data; every family "imports" instantly. cat >"$checkout/target/debug/lyracore-importer" <<'STUB' #!/bin/sh echo "lyracore-importer $*" >>"$STUB_LOG" @@ -74,13 +50,6 @@ exit 0 STUB chmod +x "$stubs/cargo" -# `spacetime`, driven by two env knobs the test sets per scenario: -# STUB_MODE=fail — every `sql`/`call` fails (a dead/unreachable/wrong server) -# STUB_MODE=succeed — `sql` returns STUB_ROW_COUNT canned numeric rows (mimics a real table dump); -# a query that is plainly the one-continent map_id/COUNT(*) probe answers with -# a single map-0 row instead, so the preflight/re-assert guards don't misread -# the filler as foreign-continent contamination and abort somewhere this test -# isn't exercising. cat >"$stubs/spacetime" <<'STUB' #!/bin/sh echo "spacetime $*" >>"$STUB_LOG" @@ -124,7 +93,7 @@ chmod +x "$stubs/spacetime" out="$sandbox/out.txt" -run_import() { # run_import [extra env assignments already exported by the caller] +run_import() { ( cd "$sandbox" || exit 99 env -i \ @@ -142,10 +111,6 @@ run_import() { # run_import [extra env assignments already exported by the calle code=$? } -# ===================================================================================================== -# 1. spacetime UNREACHABLE — the exact #440 repro: a query that FAILS must produce ONE clear abort, -# never a wall of per-table FAILs that blame the data. -# ===================================================================================================== echo "import-world.sh — spacetime unreachable (#440 repro)" : >"$stub_log" STUB_MODE=fail run_import @@ -159,10 +124,6 @@ check "the abort names the server it tried" contains "$out" "http://127.0.0.1:30 check "the abort explains a failure is not a zero-row result" contains "$out" "not the same as zero rows" [ "$code" -ne 0 ] || cat "$out" -# ===================================================================================================== -# 2. spacetime REACHABLE, canned counts — the assertions must actually READ what the query returned, -# not silently collapse a real answer to 0. -# ===================================================================================================== echo echo "import-world.sh — spacetime reachable, canned row counts" : >"$stub_log" @@ -174,9 +135,6 @@ check " ...and floors it clears print ok, not FAIL" \ contains "$out" "ok gather-node GO spawns: 150" check "the run reaches its own final verdict line" contains "$out" '[world]' -# ===================================================================================================== -# 3. every spacetime sql/call invocation carries --server (defect a) -------------------------------- -# ===================================================================================================== echo echo "import-world.sh — every spacetime invocation is server-pinned" unserved="$(grep -E '^spacetime (sql|call) ' "$stub_log" | grep -vc -- '--server' || true)" @@ -191,9 +149,6 @@ STUB_MODE=succeed SPACETIME_SERVER="http://example.invalid:9000" run_import check "an overridden SPACETIME_SERVER reaches spacetime verbatim" \ grep -q -- '--server http://example.invalid:9000' "$stub_log" -# ===================================================================================================== -# 4. canonical profiles stay one importer argument, never a copied rectangle ------------------------- -# ===================================================================================================== echo echo "import-world.sh — canonical profile argument" : >"$stub_log" @@ -223,7 +178,6 @@ check "the Kalimdor profile runs its Teldrassil named check" \ check "the Kalimdor profile runs its Darkshore named check" \ contains "$out" 'Darkshore follow-on correct-map quest-giver spawn' -# Full profiles keep map ownership and load client catalogues in the dump pass. echo echo "import-world.sh: full profile ownership and catalogue passes" for profile in full-world full-eastern full-kalimdor full-instances; do @@ -273,9 +227,6 @@ for profile in full-eastern full-kalimdor full-instances; do check "$profile does not call the importer after an ownership failure" not_contains "$stub_log" 'lyracore-importer ' done -# ===================================================================================================== -# 5. python3 absent — the coverage audit must degrade explicitly, never crash mid-output (defect c) -- -# ===================================================================================================== echo echo "import-world.sh — python3 absent from PATH" no_python="$sandbox/no-python-path" diff --git a/importer/scripts/import-world.sh b/importer/scripts/import-world.sh index f02aee57d..61a3c6dd9 100755 --- a/importer/scripts/import-world.sh +++ b/importer/scripts/import-world.sh @@ -1,95 +1,14 @@ #!/usr/bin/env bash -# import-world.sh — THE world import. It takes a cmangos world dump plus your extracted client data -# and turns an empty LyraCore database into a populated, playable world, then ASSERTS that it worked. -# -# WHEN YOU RUN IT: once per database, after publishing the module — and again whenever you change the -# box, bump the dump, or reprovision destructively. NEEDS, all four: -# 1. a running SpacetimeDB node with the target database published (and claimed by you) -# 2. the world dump at $DUMP — get it with importer/scripts/pull-classic-db.sh -# 3. an extracted client Data directory at $DBC (the 1.12.1 DBCs and terrain MPQs) -# 4. a Rust toolchain — it builds the importer itself -# `lyracore import` drives this, and the puller and the class-spell pass, for you. Running this -# script by hand is the advanced path: several shards, a non-default box, or a second continent. -# -# WHAT IT IMPORTS. Canonical profiles cover the Human, Dwarf/Gnome and Night Elf corridors through -# the importer's one scope catalogue. The legacy no-profile path retains its historical map-0 -# rectangle. Both paths import creature templates/spawns, quests, loot, vendors, gameobjects, -# terrain, navigation and the global client/dump catalogues that the destination needs. -# -# WHY THE BOX MATTERS (a real regression this box guards against): the 6 Human class trainers are in -# GOLDSHIRE at x=-9461.85; a box starting at x=-9460 excludes them by ~1.85 units, so they never -# import and no class can learn past its starter kit. Keep X0 well WEST of Goldshire. -# -# ONE BOX, NOT TWO PASSES: the ETL is clear+reload PER FAMILY (creature/quest/loot/vendor/gameobject -# tables are wiped and reloaded whole on every run), so a second `--dump --box` pass covering Westfall -# would WIPE the Elwynn rows the first pass just loaded, not union with them — there is no additive -# two-pass path. Widening the single box is therefore the only correct way to cover both zones in one -# importer run. -# -# After the ETL: re-run the curated spell-import. The ETL's own npc_trainer class trees are the -# PRIMARY offerings (the full Spell.dbc import makes every offered id resolvable, so the safe policy -# is to offer everything and let the level gate red-out the high ranks), and the curated pass only -# overlays the "specials" the dump never sells. Then re-arm the creature tick and ASSERT the 1-20 -# blockers landed — loudly, so a box or ETL regression fails here instead of surfacing in play. -# -# [V] on a value below means: measured or guessed against ONE operator's dump/client, not verified -# against yours. Confirm it against your own data before relying on it; correct a wrong id rather -# than loosening the check around it. Expect to retune the [V] count floors (they live in -# importer/scripts/import-manifest.sh) once you have real numbers from your own first import. -# -# Run from the checkout root. -# Usage: importer/scripts/import-world.sh (the canonical map-0 box) -# BOX="X0,X1,Y0,Y1" importer/scripts/import-world.sh (override) -# DB=lyracore-world-1 MAP=1 BOX="…" CENTER="X,Y,Z" importer/scripts/import-world.sh (a second CONTINENT) -# -# BEFORE YOUR FIRST IMPORT: derive YOUR real box from YOUR dump instead of trusting the ballpark -# below — -# cargo build -q --bin lyracore-importer && ./target/debug/lyracore-importer --dump "$DUMP" --map 0 --print-extents -# — and compare its suggested --box against BOX below; widen or replace as needed. -# -# LEGACY SPATIAL FLAGS, ONE CONTINENT PER DATABASE — -# Without WORLD_PROFILE, `MAP` picks which continent this run imports. The importer already has -# `--map`, `--box`, `--db` and `--include-map`, so a second continent is this script threading `MAP` -# through them plus a map fence and map-aware assertions. `MAP=0` is the historical default. -# -# DB=lyracore-world-1 MAP=1 BOX="-1400,600,-5400,-3600" CENTER="-618.52,-4251.67,38.72" \ -# bash importer/scripts/import-world.sh -# -# WHY NEITHER DATABASE EVER CARRIES BOTH CONTINENTS' SPATIAL DATA — three layers, none of them -# "the boxes happen not to overlap": -# 1. The importer's MAP FENCE (`creature_row_kept`/`row_in_slice`): a spawn/GO row on a map this run -# does not own can never enter the slice — including via `--include-creatures`, which used to -# match on entry alone and so could drag a foreign map's row in. -# 2. The ETL is clear+reload PER FAMILY and the clears are WHOLESALE (module `import_terrain_chunks` -# / `import_nav_chunks` / `import_creature_spawns` delete EVERY row before loading batch 0). So a -# map-1 run against a map-0 database does not merge the two continents — it REPLACES one with the -# other. Structurally, one database can only ever hold the LAST run's maps. -# 3. …which is a footgun, not a safety feature: pointing a map-1 run at the live world-0 database -# would silently swap Elwynn for Kalimdor. So the PREFLIGHT below reads the map ids already -# present in the target database's spatial tables and REFUSES when they aren't this run's maps -# (`ALLOW_MAP_SWITCH=1` to overwrite deliberately), and the assertion block re-checks the same -# invariant after the run — no foreign-map spatial row may exist when the ETL finishes. -# The non-spatial families (Spell.dbc, the DBC character-creation/faction/area/graveyard tables, the -# world-wide `areatrigger_teleport` portal table, quest/item/loot templates) are deliberately on BOTH -# shards: a quest template is not spatial data, and every shard needs the full spell/item catalogue. +# Each import clears whole content families; combine all slices in one run. set -uo pipefail cd "$(dirname "$0")/../.." || exit 1 -# WORLD_PROFILE selects a canonical multi-slice scope. It is the advanced operator path; the ordinary -# `lyracore import [world]` interface chooses it without exposing a new public flag. Keep spatial -# values in the importer profile catalogue, never in this script. WORLD_PROFILE="${WORLD_PROFILE:-}" -# Which CONTINENT this run imports. 0 = the canonical Elwynn/Westfall + Deadmines corridor. -# Exported so the manifest's MAP!=0 floor overrides see it (sourced below, and `[V]`-loose on purpose). export MAP="${MAP:-0}" -# Is this a SLICE — a run whose box is not the canonical map-0 corridor? Two ways to be one: another -# continent (MAP!=0), or a caller-chosen map-0 box, which is what a REGION shard is. -# Both mean the same two things: the corridor's named-fixture assertions cannot apply, and the -# count floors tuned for the corridor are wrong. One flag so those two facts can never disagree. -# Computed from the environment before the manifest is sourced, because it gates the floor overrides. +# SLICE must be set before sourcing the manifest because it selects the count floors. export SLICE=0 { [ "$MAP" != 0 ] || [ -n "${BOX:-}" ]; } && export SLICE=1 # shellcheck source=importer/scripts/import-manifest.sh -source importer/scripts/import-manifest.sh # FLOOR_* assertion minimums — see that file's header +source importer/scripts/import-manifest.sh # shellcheck source=importer/scripts/classic-db.lock source importer/scripts/classic-db.lock @@ -108,7 +27,6 @@ if [ -n "$WORLD_PROFILE" ]; then *) echo "WORLD_PROFILE=$WORLD_PROFILE is unknown; valid profiles: $WORLD_PROFILES" >&2; exit 2 ;; esac export MAP SLICE=1 - # Re-source after MAP/SLICE selection so a Kalimdor profile receives the map-slice floors. source importer/scripts/import-manifest.sh PROFILE_FLAGS=(--world-profile "$WORLD_PROFILE") echo "[world] canonical profile $WORLD_PROFILE (planned maps: $PROFILE_MAPS) → ${DB:-lyracore}" @@ -117,61 +35,17 @@ fi DUMP="${DUMP:-.import/classic-db-full.sql}" DBC="${DBC:-../wowclient/Data}" EVENTAI_PROFILE="${EVENTAI_PROFILE:-cmangos-classic-z2815}" -# [V] X0,X1,Y0,Y1 — full-Elwynn + full-Westfall + ALL of Redridge (Lakeshire, the natural Human L15-25 -# zone) + north-Duskwood spillover. Redridge sits entirely within the X band (-11400..-8000) and was -# excluded ONLY by the old Y-floor -1600; dropping it to -3100 admits Lakeshire/Camp Everstill/Stonewatch -# so the 15-18 solo-quest stretch (~59 Redridge quest relations) becomes completable. -# Canonical profiles do not use this rectangle. `alliance-eastern` owns separate Human, Dun Morogh, -# and Loch Modan slices from the importer's profile catalogue. -# NOTE: this widens the box ~28% → +~1286 spawns and a ~30% longer terrain+nav ETL, both measured on a -# real import; the RUNTIME cost is ~0, because creature ticking is scoped to cells that have players in -# them. CONFIRM against `--print-extents` on your own dump before importing; tighten or widen as your -# real spawn extents dictate. -# The canonical map-0 rectangle is ONLY a default for map 0 — see the MAP!=0 branch below, which -# refuses to guess a second continent's box. [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && BOX="${BOX:--11400,-8000,-3100,2000}" -# Out-of-box quest givers force-imported by ENTRY (--include-creatures): 10 NPCs whose -# spawns sit OUTSIDE the box (Westfall/Stormwind/Dun Morogh) but whose sibling giver is in-band, so -# importing them recovers 20 fully-completable broken 1-20 quests (measured with the module's -# debug_audit_quest_chains reducer on a real import). Ranked -# by completable-quest yield: 344 Magistrate Solomon (+6 couriers), 11406 High Priest Rohan (+3 priest -# ability quests), 266 Wiley the Black (+2 Defias), 415 Verner Osgood (+2), 1343 Mountaineer Stormpike -# (+2), 6966 Lucius (+1), 5165 Hulfdan (+1), 6166 Yorus Barleybrew (+1 warrior), 6569 Gnoarn (+1), -# 5149 Brandur Ironhammer (+1 paladin Tome of Divinity entry). -# …and they are all EASTERN KINGDOMS (map 0) entries, so the default is map-0-only: force-including -# them on another continent's run would ask the importer for foreign-map spawns (the map fence in -# `creature_row_kept` now refuses those outright, but shipping the flag would still be nonsense). [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && INCLUDE_CREATURES="${INCLUDE_CREATURES-344,11406,266,415,1343,6966,5165,6166,6569,5149}" INCLUDE_CREATURES="${INCLUDE_CREATURES-}" -# Overridable so the SAME ETL can load a second shard: the instances database spawns Deadmines itself -# from map-36 `game_creature_spawn` rows, so it needs its own copy of them — -# `DB=lyracore-instances bash importer/scripts/import-world.sh`. Defaults to the world/realm database. -# That second pass is no longer an advanced-path extra: since LyraCore#108 the `lyracore dev up` -# fixture routes map 36 to `lyracore-instances`, and `lyracore import` runs this script once per -# database it populates — the default shard, then the pool — with DB changed and NOTHING else. DB="${DB:-lyracore}" -# Which SpacetimeDB node every `spacetime sql`/`spacetime call` below targets. Bare `spacetime sql` -# (no --server) inherits the CLI's AMBIENT default server, which on a fresh machine is maincloud — -# not the local node `lyracore dev up` actually starts, on loopback:3000 (true even under `--lan`, -# where SpacetimeDB deliberately keeps the node on loopback). That mismatch is invisible to a -# maintainer whose `~/.config/spacetime/cli.toml` has had `local` as its default for years, and fatal -# to a fresh user: every query below would hit the wrong server, fail to connect, and — before this -# was fixed — silently read as "this table has zero rows" for ~50 tables at once (#440). Override for -# the by-hand advanced path (a remote node, a non-default port). +# Bare spacetime commands can inherit a different node from the CLI configuration. SPACETIME_SERVER="${SPACETIME_SERVER:-http://127.0.0.1:3000}" -# The ONE `spacetime sql`/`spacetime call` chokepoint every non-guard-helper call in this file routes -# through, so none of them can silently drift back to the ambient default (#440). Exit status is -# spacetime's own — callers (n()/q_list() below) decide how to read a failure. The one exception is -# the marker-delimited GUARD HELPERS block further down: import-manifest-smoke.sh extracts it -# VERBATIM and evals it in isolation, so those three calls inline the same --server flag directly -# rather than depending on q(), which wouldn't exist in that extracted context. q() { spacetime sql --server "$SPACETIME_SERVER" "$DB" "$1"; } call_q() { spacetime call --server "$SPACETIME_SERVER" "$DB" "$@"; } -# CHECKED IMPORT HELPERS BEGIN — extracted by import-manifest-smoke.sh. -# This script intentionally does not use `set -e`, so an importer in a display pipeline must have -# its status captured before grep/tail can obscure it. A failed apply can have written an early SQL -# batch; the caller exits, and the next clear+reload rerun repairs that partial family. -run_checked_step() { # label output-regex tail-lines command [args...] +# CHECKED IMPORT HELPERS BEGIN +# Capture the importer status before grep/tail can hide a failed apply. +run_checked_step() { local label="$1" pattern="$2" lines="$3" output status shift 3 output="$("$@" 2>&1)"; status=$? @@ -184,7 +58,7 @@ run_checked_step() { # label output-regex tail-lines command [args...] } # CHECKED IMPORT HELPERS END -# TAXI RESTORE HELPERS BEGIN — extracted by import-manifest-smoke.sh. +# TAXI RESTORE HELPERS BEGIN restore_taxi_fixture_or_fail() { local output status output="$(call_q restore_taxi_fixture 2>&1)"; status=$? @@ -196,49 +70,19 @@ restore_taxi_fixture_or_fail() { fi } # TAXI RESTORE HELPERS END -# Whole extra maps folded into the SAME clear+reload run (--include-map). Map 0's canonical run carries -# the Deadmines interior (36); another continent gets none by default — its own instanced maps belong -# to the instances database, not to a continent shard. -# 36 STAYS in the map-0 default even though the sharded fixture now routes map 36 off this database -# (LyraCore#108). Two reasons, either one sufficient: `dev up --single` is one database that has to -# host its own dungeons, and this script has no idea which topology it is loading; and the pool's own -# pass IS a map-0 run — it is this same default flag set with DB changed, so dropping 36 here would -# empty the Deadmines everywhere at once. On the sharded world shard the map-36 rows are simply never -# routed to. [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && INCLUDE_MAPS="${INCLUDE_MAPS-36}" INCLUDE_MAPS="${INCLUDE_MAPS-}" -# --center: the point the terrain interpolate self-check and the nav walkability self-check sample. It -# MUST lie inside $BOX and its Z must be the real ground height there (the terrain check hard-fails past -# 2 yd). Empty = the importer's own Northshire default, which is why map 0's command line is unchanged. +# CENTER must be inside the scope, with Z within 2 yards of the real ground height. CENTER="${CENTER:-}" -# A zone id whose `game_graveyard_zone` links must resolve (see the check far below). 12 = Elwynn. [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && GRAVEYARD_ZONE="${GRAVEYARD_ZONE:-12}" GRAVEYARD_ZONE="${GRAVEYARD_ZONE:-}" -# ---- MAP != 0: a second continent must be DERIVED, never guessed --------------------------------- -# There is no canonical rectangle for a continent nobody here has imported, and a wrong box is the -# expensive kind of wrong (the Goldshire-trainer regression in this file's header cost a full working -# session). So rather than shipping a remembered Durotar number, this refuses to run without BOX + -# CENTER and prints the exact derivation. `--print-extents` takes a candidate `--box`, so the loop is: -# 1. ./target/debug/lyracore-importer --dump "$DUMP" --map 1 --print-extents -# → the whole continent's spawn extents AND the dump's own playercreateinfo start positions for -# map 1 (Valley of Trials / Camp Narache / Shadowglen — the starting-zone anchors). -# 2. Pick an anchor, draw a rough rectangle around it, and re-run with `--box "X0,X1,Y0,Y1"`: -# → the REAL spawn extents + count inside your candidate, plus a `suggested --center` pinned to an -# actual spawn in the slice (a real cmangos ground point — exactly what the self-checks need). -# 3. Iterate until the count/extents cover the zone you want; feed the printed values in as BOX/CENTER. if [ -z "$WORLD_PROFILE" ] && [ "$MAP" != 0 ]; then DERIVE="cargo build -q --bin lyracore-importer && ./target/debug/lyracore-importer --db \"$DB\" --dump $DUMP --map $MAP --print-extents" : "${BOX:?MAP=$MAP needs an explicit BOX=X0,X1,Y0,Y1 — derive it, do not guess: $DERIVE (then re-run it with a candidate --box to narrow; see this script header)}" : "${CENTER:?MAP=$MAP needs an explicit CENTER=X,Y,Z inside BOX — the terrain/nav self-checks sample it; use the suggested --center that $DERIVE prints for your box}" fi -# A CALLER-SUPPLIED map-0 box needs CENTER for exactly the same reason a second continent does. -# The importer's default --center is Northshire (-8949.95,-132.49), -# which sits inside the canonical corridor and OUTSIDE any narrower slice of it — e.g. a region shard -# holding only the Goldshire side, whose box ends at x=-9433. The terrain pass then dies with -# "slice center's cell not among collected rows" partway through, after the dump families have already -# been cleared and reloaded. Failing here instead costs nothing and says what to do. if [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && [ "$SLICE" = 1 ] && [ -z "${CENTER:-}" ]; then echo "A caller-supplied map-0 BOX needs an explicit CENTER=X,Y,Z inside it." >&2 echo "The default --center is Northshire (-8949.95,-132.49,83.53), which is outside any box that" >&2 @@ -250,9 +94,6 @@ if [ -z "$WORLD_PROFILE" ] && [ "$MAP" = 0 ] && [ "$SLICE" = 1 ] && [ -z "${CENT exit 2 fi -# Legacy flag arrays. For MAP=0 with the defaults these expand to the historical single-box scope -# (--map 0 --box … --include-map 36 --include-creatures … , no --center). Canonical callers pass one -# WORLD_PROFILE instead; its slices, whole maps, and forced dependencies come from the importer. INC_MAP_FLAGS=(); for m in ${INCLUDE_MAPS//,/ }; do INC_MAP_FLAGS+=(--include-map "$m"); done INC_CREATURE_FLAGS=(); [ -n "$INCLUDE_CREATURES" ] && INC_CREATURE_FLAGS=(--include-creatures "$INCLUDE_CREATURES") CENTER_FLAGS=(); [ -n "$CENTER" ] && CENTER_FLAGS=(--center "$CENTER") @@ -261,33 +102,17 @@ if [ -n "$WORLD_PROFILE" ]; then else SCOPE_FLAGS=(--map "$MAP" --box "$BOX" ${INC_MAP_FLAGS[@]+"${INC_MAP_FLAGS[@]}"} ${INC_CREATURE_FLAGS[@]+"${INC_CREATURE_FLAGS[@]}"} ${CENTER_FLAGS[@]+"${CENTER_FLAGS[@]}"}) fi -# Every map whose spatial rows this run is ALLOWED to leave in $DB. Sorted LEXICALLY, not numerically: -# `comm` below merges as text, and a numeric sort ({36,209}) is not a lexical one ({209,36}) — mixing the -# two makes comm silently report a wrong difference. Both sides of the comparison use `sort -u`. +# comm requires both map lists in lexical order. if [ -n "$WORLD_PROFILE" ]; then EXPECTED_MAPS="$(printf '%s\n' $PROFILE_MAPS | sort -u)" else EXPECTED_MAPS="$(printf '%s\n' "$MAP" ${INCLUDE_MAPS//,/ } | sort -u)" fi -# The map ids whose SPATIAL rows $DB currently holds — the four map-keyed content tables (empty on a -# freshly published shard). Non-spatial tables (spells/items/quests/DBC/areatrigger_teleport) are -# deliberately NOT consulted: every shard carries those. -# -# A FAILED query is NOT the same as "no rows", and conflating them makes this whole guard vacuous: a -# renamed table/column, a shard published from a module that predates one of these four tables, or an -# unreachable node all print nothing a number-grep can match, so the preflight below AND the post-run -# re-assert would BOTH read "no foreign maps" and pass forever — the wrong-column-name-reads-as-0-rows -# trap this repo has been bitten by (docs/danger-zones.md). So each query's EXIT STATUS is checked and an -# unreadable table rides the output as a `!
` line, which the two callers split back out below. -# One probe, two consumers, three lines of splitting — cheaper than a second query pass. -# GUARD HELPERS BEGIN — extracted verbatim (marker-delimited) by importer/scripts/import-manifest-smoke.sh's -# offline pin; keep every guard function between these two markers. +# GUARD HELPERS BEGIN db_spatial_probe() { for t in game_creature_spawn game_gameobject game_terrain_chunk game_nav_chunk; do - # --server inlined with its own default (not $SPACETIME_SERVER / q()) — this function is - # extracted VERBATIM by import-manifest-smoke.sh's marker-delimited eval, which never sources - # the rest of this file, so it must stay self-contained under `set -u` in that context too. + # Offline tests extract these functions without q() or SPACETIME_SERVER. out="$(spacetime sql "$DB" "SELECT map_id FROM $t" --server "${SPACETIME_SERVER:-http://127.0.0.1:3000}" 2>&1)" || { echo "!$t"; continue; } printf '%s\n' "$out" done | grep -oE '^ *[0-9]+ *$|^!.+' | tr -d ' ' | sort -u @@ -296,42 +121,21 @@ probe_unreadable() { printf '%s\n' "$1" | grep '^!' | tr -d '!' | tr '\n' ' '; } probe_maps() { printf '%s\n' "$1" | grep -E '^[0-9]+$'; } foreign_spatial_maps() { comm -23 <(probe_maps "$1") <(printf '%s\n' "$EXPECTED_MAPS") | tr '\n' ' '; } -# Two of the four families above are ones `init` NEVER seeds: only a real `importer --apply` -# run writes to `game_terrain_chunk`/`game_nav_chunk` (module/src/terrain.rs, module/src/nav.rs doc -# comments; module/src/gameobject.rs's `imported_terrain_maps` reads the identical signal for its own -# pool-arming fence). `init` DOES seed 4 map-0 creature rows and up to 5 map-0 gameobject rows into -# EVERY freshly published database (the demo chicken/wolf/trainer/flight-master + -# chest/goober/gather-node fixtures), -# so `game_creature_spawn`/`game_gameobject` alone can't tell a never-imported shard from a genuinely -# contaminated one — but `game_terrain_chunk`/`game_nav_chunk` can: empty means nothing has ever been -# really imported here, so ANY foreign map `db_spatial_probe` reports can only be `init`'s own fixtures. +# init seeds neither terrain nor navigation rows. db_imported_probe() { for t in game_terrain_chunk game_nav_chunk; do - # Self-contained inline --server default — see db_spatial_probe's comment above. out="$(spacetime sql "$DB" "SELECT map_id FROM $t" --server "${SPACETIME_SERVER:-http://127.0.0.1:3000}" 2>&1)" || { echo "!$t"; continue; } printf '%s\n' "$out" done | grep -oE '^ *[0-9]+ *$|^!.+' | tr -d ' ' | sort -u } -# Second, independent fail-safe signal: `init` seeds AT MOST 4 `game_creature_spawn` -# rows (chicken/wolf/trainer/flight-master) and AT MOST 5 `game_gameobject` rows (chest/goober/2 standalone gather -# nodes/1 armed tier-pool point) into EVERY freshly published database — module/src/seed.rs. Terrain/nav -# alone is not sufficient: `./target/debug/lyracore-importer --family creatures` (or `gameobjects`) run DIRECTLY — -# a supported way to reload one family into a dev node, which bypasses this script's terrain/nav ETL -# entirely — loads real spatial rows while leaving game_terrain_chunk/game_nav_chunk -# permanently empty. A row count over the fixture ceiling in EITHER table means real content landed here -# by SOME path, terrain/nav probe notwithstanding, so `db_never_imported` below ANDs this in too. Prints a -# bare integer, or nothing (→ caller fails safe) if the count can't be read or parsed. +# init seeds at most four creature spawns and five GameObjects. INIT_MAX_CREATURE_FIXTURES=4 INIT_MAX_GAMEOBJECT_FIXTURES=5 -db_row_count() { # table - # Self-contained inline --server default — see db_spatial_probe's comment above. +db_row_count() { out="$(spacetime sql "$DB" "SELECT COUNT(*) AS n FROM $1" --server "${SPACETIME_SERVER:-http://127.0.0.1:3000}" 2>&1)" || return 1 printf '%s\n' "$out" | grep -oE '^ *[0-9]+ *$' | tr -d ' ' | tail -1 } -# True iff $DB has NEVER received a real import. An unreadable terrain/nav table, or an unreadable/ -# unparseable row count, returns FALSE (not fresh) — the same fail-safe direction as the WARNING above: -# when this can't be determined, fall through to the strict foreign-map check rather than silently -# waving the run through. +# Unreadable tables or counts must fail the fresh-shard exemption. db_never_imported() { imp_probe="$(db_imported_probe)" [ -z "$(probe_unreadable "$imp_probe")" ] || return 1 @@ -343,18 +147,10 @@ db_never_imported() { } # GUARD HELPERS END -# ---- PREFLIGHT: one continent per database ------------------------------------------------------ -# The family clears are WHOLESALE, so this run does not ADD to whatever $DB already holds — it REPLACES -# it. Pointed at the wrong database that silently swaps a live continent for another one, which is -# exactly the failure a shard split invites. Refuse; ALLOW_MAP_SWITCH=1 is the deliberate override -# (re-importing the same continent is not a switch and never trips this). PRE_PROBE="$(db_spatial_probe)" PRE_UNREADABLE="$(probe_unreadable "$PRE_PROBE")" if [ -n "${PRE_UNREADABLE// /}" ]; then - # Loud, but NOT an abort: a guard that refuses a legitimate import is worse than one that does not - # fire, and `spacetime sql`'s exit status is not something this script can pin. The hard gate is the - # post-run re-assert at the bottom, which fails the run outright on the same condition (and cannot be - # satisfied by an empty answer — it demands to SEE this run's own map). + # The post-import check treats unreadable spatial tables as a failure. echo "[world] WARNING — could not read map_id from '$DB': ${PRE_UNREADABLE% }" echo " The one-continent preflight is therefore INCONCLUSIVE, not clean. If this" echo " database holds another continent, this run REPLACES it. Node up? database published?" @@ -380,11 +176,6 @@ if [ -n "${PRE_FOREIGN// /}" ]; then fi cargo build -q --bin lyracore-importer || exit 1 -# --include-map 36: the Deadmines interior rides the SAME single clear+reload run as -# the Elwynn+Westfall box — a WHOLE extra map keyed by id, no geometry (an instance map has no --box -# worth drawing). Its creatures/GOs/waypoints/loot/quest relations all flow through the same families. -# Map 36 has ADT tiles, but its stacked floors do not fit the single-height terrain rows. The -# instance profile therefore keeps ground_z absent and retains authored spawn Z values. if [ -n "$WORLD_PROFILE" ]; then echo "[world] ETL --world-profile $WORLD_PROFILE → $DB" else @@ -395,18 +186,7 @@ run_checked_step "world content for $WORLD_PROFILE into $DB" \ ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --dump "$DUMP" --dbc "$DBC" "${SCOPE_FLAGS[@]}" \ --eventai-profile "$EVENTAI_PROFILE" --source-sha "$CLASSIC_DB_SHA" --apply || exit 1 -# Terrain heightmap ETL: --terrain honors --box (terrain.rs::slice_cell_range), -# so this covers the SAME widened Elwynn+Westfall rectangle as the creature ETL above — continuous -# ground-z across the zone border. --center stays the Northshire default (inside the box), which -# feeds the importer's own interpolate self-check (hard-fails on an axis-mapping regression, not a -# silent hole); it does not narrow the box. Idempotent clear+reload, like the other passes. -# -# MAP 1 (Kalimdor) TERRAIN + NAV WORK — verified, not assumed: `terrain::map_dir` maps 1 → "Kalimdor" -# (the same two-arm continent match that maps 0 → "Azeroth"), so both passes read -# `World\Maps\Kalimdor\Kalimdor__.adt` out of the operator's own terrain.MPQ through the same -# code path, with the same filename-axis arbitration and the same self-checks. Map 36 remains outside -# this terrain path because one height per cell cannot represent its stacked floors. The one extra -# operator obligation on another continent is $CENTER (above) — the self-checks sample it. +# Map 36 has stacked floors that a single terrain height cannot represent. if { [ "$WORLD_PROFILE" = instances ] || [ "$WORLD_PROFILE" = full-instances ]; }; then echo "[world] terrain heightmap ETL SKIPPED — map 36 needs a floor-preserving representation" else @@ -417,10 +197,6 @@ else ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --terrain "$DBC" "${SCOPE_FLAGS[@]}" --apply || exit 1 fi -# Nav grid ETL: WMO/M2 collision + slope rasterized into -# game_nav_chunk (walkability + LoS obstruction). Same box; --center feeds its own walkable -# self-check, and the rotation-convention calibration hard-fails on transform drift. M2 parse -# warnings (a handful of decorative props) are expected; the grep keeps summary lines only. if { [ "$WORLD_PROFILE" = instances ] || [ "$WORLD_PROFILE" = full-instances ]; }; then echo "[world] nav grid ETL SKIPPED — instance profiles use WMO maps" else @@ -431,8 +207,6 @@ else ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --nav "$DBC" "${SCOPE_FLAGS[@]}" --apply || exit 1 fi -# The standalone DBC pass loads character creation, faction and taxi catalogues. -# Taxi mount entries resolve through the full dump, independent of the imported creature slice. case "$WORLD_PROFILE" in full-*) echo "[world] client catalogues loaded with the world content" ;; *) @@ -441,10 +215,6 @@ case "$WORLD_PROFILE" in ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" --dbc "$DBC" --taxi-mounts "$DUMP" --apply || exit 1 ;; esac -# Real talent trees (TalentTab.dbc + Talent.dbc → game_talent_tab/game_talent). -# WITHOUT this the tables hold only the 8 demo talents, whose ids don't match what the 5875 client -# sends on a talent click → talent selection silently fails (the client renders the real trees from its -# OWN Talent.dbc). This clear+reloads 27 tabs + 432 talents so the client's real talent ids resolve. echo "[world] real talent trees (TalentTab.dbc + Talent.dbc → game_talent)" run_checked_step "talents into $DB" "loaded [0-9]+ tabs|error|abort" 2 \ ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ @@ -453,62 +223,30 @@ case "$WORLD_PROFILE" in full-*) echo "[world] spell catalogue loaded before world AI definitions" ;; *) echo "[world] full Spell.dbc import (every non-zero row)" - # Full de-curated import: EVERY non-zero Spell.dbc row (header+effects), no --only allowlist. The - # long tail (raid/other-class/PvP spells with no Rust kind mapping yet) lands as E_SCRIPTED no-ops — - # that's the intended end state, NOT a gap; mapping the long tail of spell effects to typed kinds is - # separate, later work. The - # wholesale clear in spell_delete_statements (importer/src/spell.rs) is guarded at - # SYNTHETIC_SPELL_ID_FLOOR (50000) so the module's synthetic/test fixture spells (Combat Insight - # 50000, Test PW:Shield 50072, Minor Healing 50110, Test Regeneration 50137 — none re-created by any - # import script) survive this reload. The curated class import below still runs for its `--trainer` - # offering bindings. The full catalogue already contains every imported caster spell. + # Spell IDs at or above 50000 are reserved for synthetic fixtures. run_checked_step "full spell catalogue into $DB" "imported [0-9]|WARNING|error|abort" 3 \ ./target/debug/lyracore-importer --db "$DB" --server "$SPACETIME_SERVER" \ --dbc "$DBC" --spells --apply || exit 1 ;; esac -# NOTE the explicit `DB=` prefix, and do not drop it: `DB` is a plain shell variable here (not -# exported) and the child script defaults to `lyracore`, so without the prefix a -# `DB= import-world.sh` run writes its curated spells into the DEFAULT database instead of the -# target one — silently, on a shard that otherwise imported correctly. +# An unexported DB shell variable does not reach the child without env. echo "[world] curated spell-import (functional 1-20 trainer offerings + castable ranks) → $DB" run_checked_step "curated class spells into $DB" "imported [0-9]|error" 1 \ env DB="$DB" SPACETIME_SERVER="$SPACETIME_SERVER" \ importer/scripts/import-class-spells.sh "$DBC" || exit 1 echo "[world] repair (re-arm creature tick + re-seed fixtures/schedules)" -# #378: debug_rearm_creature_tick was collapsed into debug_repair_after_publish, along with the -# other 16 debug_seed_*/debug_ensure_*/debug_rearm_* reducers — this now also re-seeds the fixture -# families the ETL truncates/leaves untouched (this precedent is why debug_seed_scenario_fixtures -# used to bolt extra seeders onto itself, per #378's history). Preconditions above (this database is -# already published + claimed) satisfy debug_repair_after_publish's `require_operator` gate. call_q debug_repair_after_publish >/dev/null 2>&1 -# The creature import above wholesale-replaces init's map-0 roster. Restore the reserved taxi route -# and its source flight master only when this database owns map 0; another continent must never gain -# a foreign-map fixture during its import. +# Restore map-0 fixtures only when this shard owns map 0. if [ "$MAP" = 0 ]; then echo "[world] restore taxi fixture" restore_taxi_fixture_or_fail || exit 1 fi -# ARM the synthesized gather pools → max_active live game_gameobject rows per pool (init does NOT re-run -# on an auto-migrate publish, and the importer writes pool members via SQL but no live rows). Idempotent. -# This call is UNCONDITIONAL (every MAP) on purpose — `arm_pool` itself map-fences each -# candidate against `game_terrain_chunk`/`game_nav_chunk` (module/src/gameobject.rs's -# `imported_terrain_maps`), so a map-1 run can no longer resurrect `init`'s map-0 Northshire tier pool; -# it simply arms 0 rows for a pool none of whose members' map this database actually hosts. +# arm_pool fences candidates against the imported terrain and navigation maps. echo "[world] arm gather pools" call_q arm_all_pools >/dev/null 2>&1 -# ---- ASSERT the 1-20 blockers landed (a box/ETL regression must fail here, not in play) ---- fail=0 -# ---- verification-stage SQL failure handling (#440) ---------------------------------------------- -# A query that fails to reach $DB (wrong/unreachable server, database not found, node down) is NOT -# the same thing as a query that reached it and legitimately found zero rows — conflating the two is -# exactly what turned one dead connection into ~50 fake "this table is empty" FAILs. n()/q_list() -# below always run inside the subshell a `$(...)` command substitution creates (every call site is -# `... "$(n ...)"`), so an `exit` INSIDE them would only kill that subshell, never this script. A -# sentinel FILE is what actually crosses that boundary: n()/q_list() record a failure into it, and -# chk() — which always runs as a normal top-level command, never inside `$(...)` — checks it first -# and aborts for real, before printing anything about, or falling through to, the next assertion. +# This file carries SQL failures out of command substitutions to the parent shell. SQL_ABORT_FLAG="$(mktemp "${TMPDIR:-/tmp}/lyracore-import-sql-abort.XXXXXX")" rm -f "$SQL_ABORT_FLAG" trap 'rm -f "$SQL_ABORT_FLAG"' EXIT @@ -524,28 +262,11 @@ sql_check_abort() { echo " default loopback address." >&2 exit 1 } -chk() { # label expected-min actual +chk() { sql_check_abort if [ "${3:-0}" -lt "$2" ]; then echo " FAIL $1: got ${3:-0}, want >= $2"; fail=1; else echo " ok $1: $3"; fi } -# MAP-FENCED assertions. Most floors below are box-dependent COUNTS and stay live for any -# continent (the manifest re-points them for MAP!=0); these two wrappers fence the checks that name a -# SPECIFIC Elwynn/Westfall or Deadmines fixture — a Goldshire trainer entry, Farley, Mr. Smite's hammer. -# Those cannot pass on another continent and must not be reported as failures there: a map-1 run drowning -# in ~40 FAILs about absent Elwynn content is noise, and noise is how a real regression gets missed. -# chk0 — only on the map-0 corridor run -# chk36 — only when the Deadmines interior is actually in this run's --include-map set -# The wrappers gate the COMPARISON, not the query — a fenced-out check still runs its -# `spacetime sql` (arguments evaluate before the call). A handful of extra SELECTs returning zero rows -# is cheaper than restructuring 200 lines of interleaved assertions into per-map blocks. -# Named Elwynn/Westfall fixtures (Goldshire trainers, Farley, Sentinel Hill, the Elwynn casters): -# only meaningful on the canonical corridor. A narrower map-0 box is a slice and must not report them -# as failures — that wall of FAILs is exactly what this wrapper was invented to prevent for map 1. -# `${SLICE:-0}` deliberately, not `$SLICE`: import-manifest-smoke.sh pins this line's semantics by -# grepping the definition out of this file and running it under `set -u` with only MAP/INCLUDE_MAPS -# set, so a bare $SLICE aborts that whole check. Unset means the canonical corridor, same default the -# manifest uses. -# PROFILE ASSERTION HELPERS BEGIN — extracted by import-manifest-smoke.sh. +# PROFILE ASSERTION HELPERS BEGIN chk0() { case "$WORLD_PROFILE" in alliance-eastern|alliance-single|full-eastern|full-world) chk "$@" ;; @@ -562,29 +283,20 @@ chk36() { } chk_open_world() { [ "$WORLD_PROFILE" != instances ] && [ "$WORLD_PROFILE" != full-instances ] && chk "$@"; return 0; } # PROFILE ASSERTION HELPERS END -n() { # [count-pattern] — the verification stage's row-COUNT chokepoint (#440): routes - # through $SPACETIME_SERVER via q(), and on a FAILED query records it via sql_note_failure - # rather than silently reading a connection fault as a count of 0. Only a query that actually - # succeeded gets its output counted. +n() { local out status out="$(q "$1" 2>&1)"; status=$? if [ "$status" -ne 0 ]; then sql_note_failure "$out"; printf '0'; return 0; fi printf '%s\n' "$out" | grep -cE "${2:-^ *[0-9]}" } -q_list() { # — like n() but returns the MATCHING NUMERIC LINES themselves (not a count); same - # failure handling. Backs the handful of checks that filter/aggregate raw rows in the - # shell (quest-level bands, sort -u dedup) instead of just counting them. +q_list() { local out status out="$(q "$1" 2>&1)"; status=$? if [ "$status" -ne 0 ]; then sql_note_failure "$out"; return 0; fi printf '%s\n' "$out" | grep -oE '^ *[0-9]+ *$' | tr -d ' ' } -# SQL TABLE PULL BEGIN — extracted by import-manifest-smoke.sh. -q_table() { # — like q_list but for a MULTI-column SELECT: one space-separated line per - # ALL-NUMERIC result row (the header, the rule line, and any row holding a non-numeric - # cell are dropped), same failure handling. It exists so a check can pull a whole column - # set in ONE `spacetime sql` and do its grouping in awk, instead of spawning a - # subprocess per row. See verify_eventai_catalogue. +# SQL TABLE PULL BEGIN +q_table() { local out status out="$(q "$1" 2>&1)"; status=$? if [ "$status" -ne 0 ]; then sql_note_failure "$out"; return 0; fi @@ -603,7 +315,7 @@ q_table() { # — like q_list but for a MULTI-column SELECT: one space-s }' } # SQL TABLE PULL END -quest_giver_levels_in_band() { # creature-entry low high +quest_giver_levels_in_band() { local entry="$1" low="$2" high="$3" quest level matched=0 for quest in $(q_list "SELECT quest_entry FROM game_creature_quest WHERE creature_entry = $entry"); do for level in $(q_list "SELECT quest_level FROM game_quest_template WHERE entry = $quest"); do @@ -629,9 +341,7 @@ verify_caster_spell_catalogue() { fail=1 fi } -# EVENTAI VERIFY BEGIN, extracted by import-manifest-smoke.sh. -# The importer's Compatibility Manifest owns value and dependency approval before apply. This -# post-import check only verifies the durable definition boundary that the Module reads. +# EVENTAI VERIFY BEGIN verify_eventai_catalogue() { local definitions count malformed duplicate definitions="$(q_table "SELECT id, creature_entry, creature_guid, definition_revision FROM game_creature_ai_definition")" @@ -666,13 +376,13 @@ verify_alliance_creation_data() { chk "Gnome start-item families (no Human fallback)" "$FLOOR_GNOME_START_ITEMS_CLASSES" "$(q_list "SELECT race_class FROM game_start_item WHERE race_class=1793 OR race_class=1796 OR race_class=1800 OR race_class=1801" | sort -u | wc -l)" chk "Night Elf start-item families (no Human fallback)" "$FLOOR_NIGHT_ELF_START_ITEMS_CLASSES" "$(q_list "SELECT race_class FROM game_start_item WHERE race_class=1025 OR race_class=1027 OR race_class=1028 OR race_class=1029 OR race_class=1035" | sort -u | wc -l)" } -verify_named_corridor() { # label map quest-giver entry floor +verify_named_corridor() { local label="$1" map="$2" giver="$3" floor="$4" chk "$label correct-map quest-giver spawn ($giver, map $map)" "$floor" "$(n "SELECT guid FROM game_creature_spawn WHERE entry=$giver AND map_id=$map")" chk "$label quest-giver relation ($giver)" "$floor" "$(n "SELECT quest_entry FROM game_creature_quest WHERE creature_entry=$giver")" chk "$label quest level band (1-20)" "$FLOOR_CORRIDOR_QUEST_LEVEL_BAND" "$(quest_giver_levels_in_band "$giver" 1 20)" } -verify_graveyard_zone() { # label zone-id +verify_graveyard_zone() { local label="$1" zone="$2" id resolved=0 for id in $(q_list "SELECT safe_loc_id FROM game_graveyard_zone WHERE zone_id = $zone"); do [ "$(n "SELECT id FROM game_graveyard WHERE id = $id")" -gt 0 ] && resolved=$((resolved + 1)) @@ -706,9 +416,7 @@ verify_profile_corridors() { ;; esac } -# TAXI FIXTURE VERIFY BEGIN — extracted by import-manifest-smoke.sh. These literals mirror the -# hand-authored constants in crates/lyracore-shared; keeping them explicit makes this an independent -# post-import check rather than asking the restore reducer whether its own transaction succeeded. +# TAXI FIXTURE VERIFY BEGIN verify_taxi_fixture_anchors() { chk "reserved taxi source storage/wire node" 1 "$(n "SELECT id FROM game_taxi_node WHERE id=5090100 AND client_node_id=255 AND map_id=0")" chk "reserved taxi destination storage/wire node" 1 "$(n "SELECT id FROM game_taxi_node WHERE id=5090101 AND client_node_id=256 AND map_id=0")" @@ -736,55 +444,20 @@ chk0 "Goldshire anchor trainers spawned {328,377,906,913,917,927} (NOT total cov chk "class trainer offerings (learn_skill_line=0)" "$FLOOR_TRAINER_OFFERINGS_CLASS" "$(n "SELECT spell_id FROM game_trainer_spell WHERE learn_skill_line = 0")" chk "profession learn offerings (learn_skill_line>0)" "$FLOOR_TRAINER_OFFERINGS_PROFESSION" "$(n "SELECT spell_id FROM game_trainer_spell WHERE learn_skill_line > 0")" chk "gather-node GO spawns" "$FLOOR_GATHER_NODE_GO" "$(n "SELECT guid FROM game_gameobject" '[0-9]')" -# [V] tune after the first real Westfall import: Elwynn-only imported ~2500 chunks; the widened box -# must land well past that. A terrain::run failure (axis self-check bail, no-cells-in-slice) must -# fail HERE, not surface as missing ground-z in play (the review's silent-terrain-skip gap). chk_open_world "terrain chunks (ground-z heightmap)" "$FLOOR_TERRAIN_CHUNKS" "$(n "SELECT key FROM game_terrain_chunk" '[0-9]')" chk_open_world "nav chunks (walkability/LoS grid)" "$FLOOR_NAV_CHUNKS" "$(n "SELECT key FROM game_nav_chunk" '[0-9]')" -# Gather nodes spawn FLAT (in-place respawn) — vanilla Elwynn is not spatially pooled. Gather pools are -# armed by the arm_all_pools call above; they're not part of the ETL, so no pool-count assertion here. -# (The old pool-2 tier-variety demonstrator rode a hand-written profession seed file, retired along -# with the rest of the synthetic profession data; professions import from the real npc_trainer + DBC.) chk0 "innkeeper Farley(295) spawned" "$FLOOR_INNKEEPER_FARLEY" "$(n "SELECT guid FROM game_world_entity WHERE entry=295 AND owner_guid=0" '[0-9]{6,}')" -# The continent's OWN spatial rows: spawn rows carrying THIS run's primary map id. Every -# other count check would pass on a shard whose spawns all landed under the wrong map — this is the one -# that says "the Kalimdor content is on the Kalimdor shard, as map 1". chk "spawn rows on this run's map ($MAP)" "$FLOOR_CONTINENT_SPAWNS" "$(n "SELECT guid FROM game_creature_spawn WHERE map_id = $MAP" '[0-9]')" -# [V] tune-to-dump: raised 300→450 — the widened Westfall box adds Sentinel Hill's quest hubs + the -# Deadmines chain's overworld half on top of the Elwynn 1-10 relations. 450 is an unverified estimate -# — tighten or widen it against your real dump's count. chk "quest-giver relations" "$FLOOR_QUEST_GIVER_RELATIONS" "$(n "SELECT quest_entry FROM game_creature_quest")" chk "L6-10 quests (quest_level band)" "$FLOOR_QUESTS_L6_10" "$(q_list "SELECT quest_level FROM game_quest_template" | awk '$1>=6 && $1<=10' | wc -l)" -# ≥1 Westfall-band quest present (quest_level 10-20) — no entry id needed, just the level band; a -# widened box with zero quests in this band means Westfall's quest_template rows never landed. chk "L10-20 quests (Westfall quest_level band)" "$FLOOR_QUESTS_L10_20" "$(q_list "SELECT quest_level FROM game_quest_template" | awk '$1>=10 && $1<=20' | wc -l)" -# Chained quests: McBride's 783->7->15->21->54 chain should populate next_quest_id symmetrically to -# the already-working prev_quest_id side. A single-column inequality filter in SQL is itself the -# "can return 0 rows wrongly" trap (docs/danger-zones.md §2) — select the raw column and filter in the -# shell instead, like the quest_level bands above. +# SpacetimeDB single-column inequality filters can return zero rows incorrectly. chk "chained quests (next_quest_id>0) [V]" "$FLOOR_QUESTS_CHAINED" "$(q_list "SELECT next_quest_id FROM game_quest_template" | awk '$1>0' | wc -l)" -# Timed quests (limit_time>0): coverage-printed only, no floor — see FLOOR_QUESTS_CHAINED's comment in -# import-manifest.sh for why (plausibly zero in this Elwynn/Westfall slice). echo " .. timed quests (limit_time>0) [V]: $(q_list "SELECT limit_time FROM game_quest_template" | awk '$1>0' | wc -l)" -sql_check_abort # no chk() follows this echo directly — check explicitly so a failed query here still aborts loud, not with a silently-wrong "0" -# [V] tune-to-dump: raised 1500→2500 — Westfall's own creature density added to Elwynn's. 2500 is an -# unverified estimate — tighten or widen it against your real dump's count. +sql_check_abort # No chk follows this query to check the abort flag. chk "live creature entities" "$FLOOR_LIVE_CREATURES" "$(n "SELECT guid FROM game_world_entity WHERE entry>0 AND owner_guid=0" '[0-9]{6,}')" -# [V] tune-to-dump: raised 200→280 — Sentinel Hill + Westfall's other vendors added. 280 is an -# unverified estimate — tighten or widen it against your real dump's count. chk "vendors (npc_vendor)" "$FLOOR_VENDORS" "$(n "SELECT item_entry FROM game_npc_vendor")" -# --- Service coverage: an NPC that ADVERTISES a service via npc_flags must actually PROVIDE it. -# This audit exists because the row-count assertions above once certified an import "OK" while every -# spawned Northshire class trainer taught NOTHING — it joins spawned+flagged templates against the provider tables -# shell-side (spacetime sql has no JOIN) and names the dead NPCs. Floors guard non-regression on -# the COVERED counts; the DEAD lists are the honest gap ledger (fix = give them rows, not silence). echo "[world] service coverage (flagged NPCs that actually provide their service):" -# python3 is not a declared prerequisite (#440): on a minimal host `command -v` fails, and letting the -# heredoc below run anyway would print a bare "python3: command not found" mid-output with COV left -# unset — the three `chk` calls after it would then read as achieved-0 FAILs indistinguishable from a -# real coverage regression. Degrade explicitly instead: this audit is a bonus check, not a gate (the -# row-count floors above it already cover "did the family import at all"), so a missing python3 SKIPs -# it rather than failing the import. if ! command -v python3 >/dev/null 2>&1; then echo " SKIP: service-coverage audit needs python3 (not found on PATH) — install it and re-run this" echo " script to get trainer/vendor/questgiver dead-NPC coverage; this does not fail the import." @@ -830,81 +503,38 @@ chk "trainer coverage (spawned trainers that teach)" "$FLOOR_TRAINER_COVERAGE chk "vendor coverage (spawned vendors that sell)" "$FLOOR_VENDOR_COVERAGE" "$COV_VENDORS" chk "questgiver coverage (spawned givers with quests)" "$FLOOR_QUESTGIVER_COVERAGE" "$COV_QUESTGIVERS" fi -fi # open-world aggregate checks +fi -# [V] — set this to the real Sentinel Hill trainer/vendor creature_template.entry from YOUR dump if -# the default below does not match it. An entry that matches nothing never matches a live spawn, so -# this check FAILS LOUD rather than passing quietly — that's deliberate: a guessed wrong id could -# silently "pass" on an unrelated creature, which is worse than an honest, visible FAIL demanding the -# real value. -# Find it via: grep the dump's creature_template INSERT for a name near Sentinel Hill (Westfall, -# around x≈-10650,y≈1180 — see module/src/world.rs SENTINEL_HILL) with npc_flags & (0x4|0x10) set -# (UNIT_NPC_FLAG_VENDOR|TRAINER). -SENTINEL_HILL_VENDOR_ENTRY="${SENTINEL_HILL_VENDOR_ENTRY:-491}" # Quartermaster Lewis — verified on a real import: spawned, with 15 vendor rows +SENTINEL_HILL_VENDOR_ENTRY="${SENTINEL_HILL_VENDOR_ENTRY:-491}" chk0 "Sentinel Hill vendor spawned (Quartermaster Lewis 491)" "$FLOOR_SENTINEL_HILL_VENDOR" \ "$(n "SELECT guid FROM game_world_entity WHERE owner_guid=0 AND entry=${SENTINEL_HILL_VENDOR_ENTRY}" '[0-9]{6,}')" -# All 6 Human classes have a creation loadout (race_class = (race<<8)|class: Warrior 257, Paladin 258, -# Rogue 260, Priest 261, Mage 264, Warlock 265). Asserts the standalone --dbc pass above ran — a --dump-only -# run leaves game_start_item empty and non-Warrior classes silently inherit the Warrior kit. Count DISTINCT -# present race_class values (the WHERE caps it at 6) — want all 6. +# race_class encodes (race << 8) | class. chk "Human start-items (all 6 classes)" "$FLOOR_START_ITEMS_CLASSES" \ "$(q_list "SELECT race_class FROM game_start_item WHERE race_class=257 OR race_class=258 OR race_class=260 OR race_class=261 OR race_class=264 OR race_class=265" | sort -u | wc -l)" -# [V] tune-to-dump: raised 40→60 — Westfall's Defias casters (Pillager/Conjurer, see the EXTEND-HERE -# block above) add to Elwynn's caster count. 60 is an unverified estimate — tighten or widen it -# against your real dump's count. chk "caster-mob cast rows" "$FLOOR_CASTER_CAST_ROWS" "$(n "SELECT creature_entry FROM game_creature_cast")" chk "total game_spell rows (full Spell.dbc import)" "$FLOOR_SPELL_TOTAL" "$(n "SELECT spell_id FROM game_spell")" -# Rank-chain, auto-learn dependent and proc-overlay tables, populated by the same --dump pass above -# (family "spellmeta" — no --family flag needed here, a plain --dump run activates every family). A 0 -# here means the spell_chain/spell_learn_spell/spell_proc_event column indices ([V] -# importer/src/main.rs `sc`/`sls`/`spe`) drifted against your dump, or those tables are absent from it. chk "spell chain rows (game_spell_chain) [V]" "$FLOOR_SPELL_CHAIN" "$(n "SELECT spell_id FROM game_spell_chain")" chk "spell auto-learn dependents (game_spell_learn) [V]" "$FLOOR_SPELL_LEARN" "$(n "SELECT id FROM game_spell_learn")" chk "proc overlay rows (game_spell_proc_event) [V]" "$FLOOR_SPELL_PROC_EVENT" "$(n "SELECT spell_id FROM game_spell_proc_event")" -# Map fencing, drawn precisely: a `game_spell` PRESENCE check is map-INDEPENDENT (the full -# Spell.dbc import + the curated `--only` passes run on every shard), so 133/13322/3110 stay live on any -# continent — free coverage. What IS map-0 content is the CREATURE-derived side: a `game_creature_cast` -# or `game_creature_spell` row exists only because an Elwynn/Westfall creature entered THIS slice, so -# those are `chk0`-fenced (the Imp 416 pet template included — it rides the map-0 corridor import). chk "476 Geomancer cast spell present" "$FLOOR_GEOMANCER_CAST_SPELL" "$(n "SELECT spell_id FROM game_spell WHERE spell_id=133")" chk "474 Rogue Wizard cast spell present" "$FLOOR_ROGUE_WIZARD_CAST_SPELL" "$(n "SELECT spell_id FROM game_spell WHERE spell_id=13322")" chk0 "Imp(416) Firebolt cast row" "$FLOOR_IMP_FIREBOLT_CAST_ROW" "$(n "SELECT spell_id FROM game_creature_cast WHERE creature_entry=416")" chk "Imp Firebolt(3110) in game_spell" "$FLOOR_IMP_FIREBOLT_SPELL" "$(n "SELECT spell_id FROM game_spell WHERE spell_id=3110")" -# ≥1 Defias caster cast row — same [V] entries as the EXTEND-HERE block above (Pillager 589? / -# Conjurer 449?): this only proves the ETL saw a creature_template_spells row for one of them (the -# main --dump pass populates game_creature_cast independent of the caster --only spell list above), -# NOT that the referenced spell header is imported — confirm both ids against your dump. chk0 "Defias caster cast row (Pillager 589?/Conjurer 449? [V])" "$FLOOR_DEFIAS_CASTER_CAST_ROW" \ "$(n "SELECT creature_entry FROM game_creature_cast WHERE creature_entry=589 OR creature_entry=449")" -# Rotation table populated (rank 20): multi-spell in-box casters now have entries in game_creature_spell. -# 474/476 each carry 2 rows (spell1 ALWAYS + spell2 SELF_MISSING_AURA); 10 entries × ≥2 rows = ≥20. chk0 "rotation rows (game_creature_spell)" "$FLOOR_ROTATION_ROWS" "$(n "SELECT id FROM game_creature_spell")" chk0 "476 Geomancer rotation nuke row" "$FLOOR_GEOMANCER_ROTATION_NUKE" "$(n "SELECT id FROM game_creature_spell WHERE creature_entry=476 AND condition=0")" chk0 "474/476 Frost Armor rotation row" "$FLOOR_FROST_ARMOR_ROTATION" "$(n "SELECT id FROM game_creature_spell WHERE spell_id=12544")" verify_caster_spell_catalogue verify_eventai_catalogue -# --- AreaTable/AreaTrigger/WorldSafeLocs import counts ------------------------------------------ -# [V] tune-to-client: these three land from the standalone --dbc pass above (dbc.rs::run), NOT the -# --dump pass, so they're populated regardless of --box. The thresholds are deliberately conservative -# ballparks — vanilla 1.12's real AreaTable/AreaTrigger/WorldSafeLocs DBCs run to the thousands and -# hundreds — so tighten them against your own client's real counts. chk "areas imported (game_area) [V]" "$FLOOR_AREAS" "$(n "SELECT id FROM game_area")" chk "area triggers imported (game_area_trigger) [V]" "$FLOOR_AREA_TRIGGERS" "$(n "SELECT id FROM game_area_trigger")" chk "graveyards imported (game_graveyard) [V]" "$FLOOR_GRAVEYARDS" "$(n "SELECT id FROM game_graveyard")" -# The importer restores a reserved 509xxxx fixture after every whole-family replacement. Count only -# low client ids here so a missing Taxi*.dbc load cannot pass by observing those fixture rows alone. +# IDs at or above 5090000 belong to the reserved taxi fixture. chk "taxi nodes imported from TaxiNodes.dbc" "$FLOOR_TAXI_NODES" "$(n "SELECT id FROM game_taxi_node WHERE id < 5090000")" chk "directed taxi paths imported from TaxiPath.dbc" "$FLOOR_TAXI_PATHS" "$(n "SELECT id FROM game_taxi_path WHERE id < 5090000")" chk "taxi path points imported from TaxiPathNode.dbc" "$FLOOR_TAXI_PATH_NODES" "$(n "SELECT id FROM game_taxi_path_node WHERE id < 5090000")" -# Zone graveyard_zone → game_graveyard "join" check: `spacetime sql` has no JOIN, so this is done -# shell-side (fetch each table separately, intersect in bash) — at least one link for $GRAVEYARD_ZONE -# must resolve to a real game_graveyard row, proving `graveyard::resolve_graveyard`'s zone-linked path -# actually has something to resolve in the zone players are levelling in, not just an orphaned -# link. The zone id is a PARAMETER: 12 = Elwynn on the map-0 corridor; on another continent -# set it to a zone in YOUR box — e.g. Durotar 14, Mulgore 215, Teldrassil 141 ([V] AreaTable ids, read -# yours from the imported game_area rather than trusting these). Unset on a non-map-0 run = skipped, not -# failed: an unset zone would search zone 0 and report a bogus FAIL. if [ -n "$GRAVEYARD_ZONE" ]; then echo "[world] zone-$GRAVEYARD_ZONE graveyard_zone→game_graveyard resolve check (shell-side, no SQL JOIN):" zone_ids="$(q_list "SELECT safe_loc_id FROM game_graveyard_zone WHERE zone_id = $GRAVEYARD_ZONE")" @@ -918,85 +548,31 @@ else echo " .. graveyard_zone resolve check SKIPPED — set GRAVEYARD_ZONE= to enable it" fi -# --- areatrigger_teleport (dungeon entrance/exit portals) import counts -------------------------- -# Unlike the DBC-sourced trio above, this lands from the --dump pass's "globals" family (NOT --box -# scoped — a world-wide portal table, see build_areatrigger_teleport_sql's doc comment), so it's -# populated by the SAME --dump --dbc invocation at the top of this script. `[V]` floor + ids: neither -# the column layout nor the Deadmines trigger ids has been confirmed against a second dump — tighten, -# widen or correct them against your own. chk "areatrigger_teleport portals imported (game_areatrigger_teleport) [V]" "$FLOOR_AREATRIGGER_TELEPORTS" \ "$(n "SELECT trigger_id FROM game_areatrigger_teleport")" -# Both directions of the Deadmines portal must import — the Moonbrook ENTRANCE (map 0 -> map 36) and -# the interior EXIT (map 36 -> map 0) are two separate areatrigger_teleport rows; a floor of 1 would -# pass vacuously on only one direction importing (walk in but can never walk back out). Both trigger -# ids are `[V]` — CONFIRM them against your own dump. chk "Deadmines portal round-trip: entrance(78)+exit(119) both present" "$FLOOR_DEADMINES_PORTAL_ROUNDTRIP" \ "$(n "SELECT trigger_id FROM game_areatrigger_teleport WHERE trigger_id=78 OR trigger_id=119")" -# --- the Deadmines (map 36) content slice — from --include-map 36 above -------------------------- -# All floors/ids here are [V] — see each key's comment in import-manifest.sh for what to confirm. -# Spawn/GO counts read the IMPORT tables (game_creature_spawn/game_gameobject), not live entities — -# they're populated synchronously by the reducer batches, independent of the creature-tick timing. +# Imported spawn rows are independent of the creature tick timing. chk36 "Deadmines creature spawns (map 36) [V]" "$FLOOR_DEADMINES_CREATURE_SPAWNS" \ "$(n "SELECT guid FROM game_creature_spawn WHERE map_id = 36" '[0-9]')" chk36 "Deadmines gameobjects (doors/cannon/chests, map 36) [V]" "$FLOOR_DEADMINES_GAMEOBJECTS" \ "$(n "SELECT guid FROM game_gameobject WHERE map_id = 36" '[0-9]')" -# Placed bosses spawned, DISTINCT entries — floor 6 (Rhahk'Zor 644 / Shredder 642 / Gilnid 1763 / -# Smite 646 / Greenskin 647 / VanCleef 639); Sneed 643 stays in the query but NOT the floor: he is -# script-summoned on the Shredder's death in cmangos-era dumps (so a correct import has no placed row -# for him), and a dump that does place him reads 7 and still passes. A wrong [V] entry id FAILS -# LOUD here; correct the id against your dump (the SENTINEL_HILL_VENDOR precedent), don't loosen. chk36 "Deadmines bosses spawned (>=6 distinct placed entries) [V]" "$FLOOR_DEADMINES_BOSSES" \ "$(q_list "SELECT entry FROM game_creature_spawn WHERE map_id = 36 AND (entry=644 OR entry=643 OR entry=642 OR entry=1763 OR entry=646 OR entry=647 OR entry=639)" | sort -u | wc -l)" -# BOTH named drops in the loot pools: Cruel Barb ~5191 (VanCleef) + Smite's Mighty -# Hammer ~5196 (Smite). DISTINCT item ids so two rows of one item can't pass vacuously. chk36 "Deadmines named drops (Cruel Barb ~5191 + Smite's Mighty Hammer ~5196) [V]" "$FLOOR_DEADMINES_NAMED_LOOT" \ "$(q_list "SELECT item_entry FROM game_creature_loot WHERE item_entry=5191 OR item_entry=5196" | sort -u | wc -l)" -# The Defias Brotherhood chain (the next_quest_id machinery, ending on VanCleef's head) is covered by -# the FLOOR_QUESTS_CHAINED check above. Its in-instance kill/collect credit is NOT asserted here — -# that needs a character actually inside the instance, so it is a live check, not an import one. -# Quest RELATIONS for map-36 creatures import via the same entries set as the box slice, so no -# separate floor is needed here either. -# --- loot-family completeness (pickpocket/skinning/gameobject-chest/fishing) --------------------- -# [V] every floor below is an UNVERIFIED presence floor — tighten them against your own dump once you -# have real counts. A floor of 1 just proves the family imported AT ALL: a parse or scope regression -# that silently drops a whole family must fail HERE, not surface as an empty loot window in play. chk "pickpocket loot rows (game_pickpocket_loot) [V]" "$FLOOR_PICKPOCKET_LOOT" "$(n "SELECT id FROM game_pickpocket_loot")" chk "skinning loot rows (game_skinning_loot) [V]" "$FLOOR_SKINNING_LOOT" "$(n "SELECT id FROM game_skinning_loot")" -# The two curated TREASURE_CHESTS (Battered Chest lootId 2277, Solid Chest lootId 9932) both carry -# real rows in every known cmangos classic-db dump — a 0 here means the CHEST data1 scoping (or the -# gameobject_loot_template parse) regressed, not that the dump lacks rows. chk "gameobject (chest) loot rows (game_gameobject_loot) [V]" "$FLOOR_GAMEOBJECT_CHEST_LOOT" "$(n "SELECT id FROM game_gameobject_loot")" chk "fishing loot rows (game_fishing_loot) [V]" "$FLOOR_FISHING_LOOT" "$(n "SELECT id FROM game_fishing_loot")" -# Creature-template loot ids actually resolved (PickpocketLootId/SkinLootId nonzero) — proves the [V] -# ct::PICKPOCKET_LOOT_ID/ct::SKIN_LOOT_ID column positions actually land on real data, not on a -# neighboring always-zero column (which would otherwise pass the row-count checks above vacuously via -# an unrelated in-slice creature that DOES have a table). chk "creatures with a pickpocket table (creature_template.pickpocket_loot_id>0) [V]" "$FLOOR_CREATURES_WITH_PICKPOCKET" \ "$(n "SELECT entry FROM game_creature_template WHERE pickpocket_loot_id > 0")" chk "creatures with a skin table (creature_template.skin_loot_id>0) [V]" "$FLOOR_CREATURES_WITH_SKIN" \ "$(n "SELECT entry FROM game_creature_template WHERE skin_loot_id > 0")" -# --- ONE CONTINENT PER DATABASE, re-checked after the run ---------------------------------------- -# The preflight refused to START against a database holding another continent; this is the same -# invariant asserted on the RESULT — every map id in the four map-keyed spatial tables must be one this -# run imports. It catches what the preflight cannot: a family whose clear+reload left foreign rows -# behind (a clear that stopped scoping wholesale, a hand-run partial import, a `--family` refresh that -# dropped `--include-map`) — i.e. "neither continent's database carries the other's spatial data" -# stated as a check rather than as a convention. -# -# …and it carries its OWN POSITIVE CONTROL, because "no foreign map ids" is exactly what an unreadable -# probe returns: this run's PRIMARY map must be visible in the same probe's output. After a successful -# import $DB necessarily holds map-$MAP spawn and terrain rows, so an empty answer means the probe read -# nothing at all and its verdict is worthless — not that the invariant holds. Without this, a renamed -# table/column silently downgrades BOTH the preflight and this re-assert to permanent `ok`s. -# -# This re-assert uses the SAME `db_never_imported` fresh-shard exemption as the preflight -# above (so the two can never disagree about what counts as "content"). In practice it's a no-op here: -# a genuinely successful import always leaves game_terrain_chunk/game_nav_chunk non-empty for $MAP, so -# `db_never_imported` is false by the time this runs and the positive control below still catches a -# run whose terrain/nav load silently failed. +# Seeing this run's primary map prevents an empty query from proving ownership. POST_PROBE="$(db_spatial_probe)" POST_UNREADABLE="$(probe_unreadable "$POST_PROBE")" POST_FOREIGN="$(foreign_spatial_maps "$POST_PROBE")" diff --git a/importer/scripts/pull-classic-db-checksum-test.sh b/importer/scripts/pull-classic-db-checksum-test.sh index 6d9b1a6e5..39a6d6b22 100755 --- a/importer/scripts/pull-classic-db-checksum-test.sh +++ b/importer/scripts/pull-classic-db-checksum-test.sh @@ -1,16 +1,4 @@ #!/usr/bin/env bash -# pull-classic-db-checksum-test.sh — OFFLINE fixture test for pull-classic-db.sh's `verify_sha256`. -# -# Run it from the checkout root, any time, with nothing set up: -# bash importer/scripts/pull-classic-db-checksum-test.sh # prints "[test] OK" when it passes -# -# No network, no git clone, no database: it sources the pull script (whose `main` only runs when the -# file is EXECUTED, not sourced — see that file's tail) and exercises `verify_sha256` directly against -# a known temp file, asserting the right/wrong-checksum exit codes. Safe to run in CI. -# -# WHY IT EXISTS: `verify_sha256` is the guard that stops a silently-changed upstream dump from being -# imported, and its only real-world exercise is a multi-gigabyte network pull nobody runs on a whim. -# An untested guard is an assumed guard, so both arms get pinned here instead. set -uo pipefail cd "$(dirname "$0")/../.." || exit 1 @@ -18,7 +6,7 @@ cd "$(dirname "$0")/../.." || exit 1 source importer/scripts/pull-classic-db.sh fail=0 -chk() { # label expected-exit actual-exit +chk() { if [ "$3" != "$2" ]; then echo " FAIL $1: exit $3, want $2"; fail=1; else echo " ok $1: exit $3"; fi } diff --git a/importer/scripts/pull-classic-db.sh b/importer/scripts/pull-classic-db.sh index 5e2940212..3dce724dd 100755 --- a/importer/scripts/pull-classic-db.sh +++ b/importer/scripts/pull-classic-db.sh @@ -1,54 +1,4 @@ #!/usr/bin/env bash -# pull-classic-db.sh — STEP ONE of a world import: fetch the cmangos `classic-db` world dump that -# importer/scripts/import-world.sh then parses. -# -# WHEN YOU RUN IT: once, before your first import, and again whenever you bump the pinned upstream -# commit. NEEDS: `git`, `gzip`, `sha256sum`, and a few GB of disk under .import/. No database, no -# node, no built importer — this step is pure download. -# -# `lyracore import` drives this for you as part of the normal import flow; running this script by -# hand is the advanced path, for when you want to control the ref, float master, or pull once and -# import many times. -# -# OPT-IN BY DESIGN. This is the mechanism docs/data-ingestion.md's "Decisions taken" section calls -# for: "an explicit opt-in pull-script, not a git submodule" — nothing auto-fetches Blizzard-derived -# content for a casual cloner, and the association stays out of the default repo graph until an -# operator explicitly runs this. -# -# LICENSING FIREWALL (docs/data-ingestion.md — read that file first if this is unfamiliar): classic-db -# is GPLv3 and its CONTENT is asserted to remain Blizzard's, offered only as non-commercial "fair-use -# demo content". This repo ships ZERO of it: -# - this script NEVER executes any cmangos SQL/code — it only clones the repo and decompresses -# the SQL it ships into one dump for the importer to PARSE (importer/src/main.rs's own doc: -# "never executes it" — same rule, same reason: running GPL code is a materially different act -# than reading its data as text). -# - everything this script writes lands under .import/, which is gitignored (see .gitignore: -# "Operator-pulled third-party data (cmangos classic-db) + generated bundles — NEVER committed"). -# - the ONLY things this repository ships are: this script, importer/scripts/classic-db.lock (our -# own metadata — a commit SHA + checksum, not their data), and the importer that reads the result. -# -# PIN-BY-LOCKFILE, NOT FLOATING (reconciles docs/data-ingestion.md's earlier "track latest master, not -# pinned by default" decision — that decision was about strategy, not this script's default): classic- -# db has no release tags, so bare "master" churns silently. This script defaults to the SHA recorded in -# importer/scripts/classic-db.lock for a REPRODUCIBLE out-of-the-box pull, with sha256 verification that ABORTS -# on mismatch (never silently import a changed shape at the same pinned commit). Override with -# CLASSIC_DB_REF to track master (or any ref) instead — pass --skip-verify too, since the checksum only -# means something for the locked SHA. -# -# Usage (run from the checkout root): -# importer/scripts/pull-classic-db.sh # clone/fetch, checkout the locked SHA, verify -# CLASSIC_DB_REF=master importer/scripts/pull-classic-db.sh --skip-verify # float master, skip the pinned checksum -# -# Output: .import/classic-db-full.sql, importer/scripts/import-world.sh's default $DUMP path. -# classic-db ships its world database as ONE gzipped mysqldump under Full_DB/ (today: -# Full_DB/ClassicDB_1_12_1_z.sql.gz), so "assemble" means: decompress every file the glob -# matches, in glob order, into one plain-text file the importer can parse. The script adds no bytes, -# so the profile digest covers the exact decompressed source. If upstream splits or moves the dump, -# the profile must change before import. -# -# Bump procedure for importer/scripts/classic-db.lock (one line): run -# CLASSIC_DB_REF=master importer/scripts/pull-classic-db.sh --skip-verify -# then copy its printed "resolved commit" + "sha256" into that lockfile and commit it. set -uo pipefail cd "$(dirname "$0")/../.." || exit 1 @@ -60,14 +10,9 @@ CLASSIC_DB_REPO="${CLASSIC_DB_REPO:-https://github.com/cmangos/classic-db.git}" CLASSIC_DB_REF="${CLASSIC_DB_REF:-$CLASSIC_DB_SHA}" CLONE_DIR=".import/classic-db" OUT_FILE=".import/classic-db-full.sql" -# Where the world dump lives inside the cloned repo — see the header note above. Confirmed against -# cmangos/classic-db at the locked commit: one gzipped mysqldump, Full_DB/ClassicDB_1_12_1_z*.sql.gz. +# The pinned classic-db commit stores its compressed dump under Full_DB/. ASSEMBLE_GLOB="Full_DB/*.sql*" -# --- sha256 verify, factored out so an offline test can exercise it without a network clone -------- -# Compares `file`'s sha256 against `expected`; prints OK/FAIL and returns 0/1. NEVER used to decide -# whether to import — that decision belongs to the caller (main, below), which aborts loudly on a -# mismatch rather than silently proceeding with unverified data. verify_sha256() { local file="$1" expected="$2" actual if [ ! -f "$file" ]; then @@ -85,7 +30,6 @@ verify_sha256() { return 0 } -# --- clone/fetch + checkout --------------------------------------------------------------------- clone_or_fetch() { if [ -d "$CLONE_DIR/.git" ]; then git -C "$CLONE_DIR" fetch --all --tags @@ -96,13 +40,11 @@ clone_or_fetch() { git -C "$CLONE_DIR" checkout "$CLASSIC_DB_REF" } -# --- assemble the single dump file the importer reads -------------------------------------------- -# NEVER executes any cmangos SQL. It only concatenates files the importer will parse. +# Preserve the decompressed bytes exactly for source-profile checksum verification. assemble() { : > "$OUT_FILE" shopt -s nullglob - # shellcheck disable=SC2206 # intentional glob expansion (ASSEMBLE_GLOB is a pattern, not a - # literal path) — nullglob is set around it so a no-match yields an empty array, not the pattern. + # shellcheck disable=SC2206 # ASSEMBLE_GLOB requires glob expansion. local sql_files=("$CLONE_DIR"/$ASSEMBLE_GLOB) shopt -u nullglob if [ "${#sql_files[@]}" -eq 0 ]; then @@ -152,8 +94,6 @@ main() { echo "[pull-classic-db] resolved SHA for provenance: $resolved_sha (pass to \`importer --source-sha $resolved_sha\`)" } -# Only run `main` when EXECUTED, not when sourced — lets an offline test `source` this file (e.g. to -# call `verify_sha256` directly against a fixture) without triggering a network clone. if [ "${BASH_SOURCE[0]}" = "${0}" ]; then main "$@" fi diff --git a/importer/src/collision.rs b/importer/src/collision.rs index 2779afd8f..7220c33cd 100644 --- a/importer/src/collision.rs +++ b/importer/src/collision.rs @@ -1,11 +1,3 @@ -//! `--dump-collision `, SPIKE: prove we can read WMO/M2 -//! collision geometry out of the operator's own client MPQs before building the nav rasterizer. -//! Same licensing firewall as `--dbc`/`--terrain`: in-memory read, nothing written. -//! -//! Reads the ONE root ADT tile containing `--center`, lists its WMO (MODF) and doodad (MDDF) -//! placements, then opens every referenced WMO root+group file and M2 model and reports -//! triangle counts. Dry-run ONLY — no tables, no schema, no `--apply`. - use anyhow::{bail, Context, Result}; use lyracore_shared::terrain::cell_index; use std::collections::BTreeMap; @@ -13,8 +5,6 @@ use std::io::Cursor; use std::path::Path; use wow_mpq::PatchChain; -/// Model/WMO/terrain archives in client load order. Wider than the terrain chain: geometry -/// lives in model.MPQ (M2) and wmo.MPQ; base.MPQ catches strays; patches override everything. pub(crate) fn open_geometry_chain(data_dir: &Path) -> Result { let mut chain = PatchChain::new(); let mut found = 0u32; @@ -44,8 +34,7 @@ pub(crate) fn open_geometry_chain(data_dir: &Path) -> Result { Ok(chain) } -/// MMDX/MWMO name blob order → byte offset of each name (names are nul-terminated back to -/// back; MMID/MWID store OFFSETS, placements index the offset list). +// Names are NUL-separated; MMID and MWID store byte offsets, while placements index that list. fn offset_to_index(names: &[String]) -> BTreeMap { let mut map = BTreeMap::new(); let mut off = 0u32; @@ -111,7 +100,7 @@ fn without_truncated_vertex_colors(bytes: &[u8]) -> Result>> { .and_then(|start| start.checked_add(size)) .context("WMO nested chunk size overflow")?; if nested_end > bytes.len() { - // Only a terminal rendering chunk may be omitted. Geometry stays byte-for-byte. + // Only a terminal rendering chunk may be omitted; preserve all geometry bytes. if &header[..4] != b"VCOM" || !size.is_multiple_of(4) || nested_end != end { return Ok(None); } @@ -135,12 +124,11 @@ fn without_truncated_vertex_colors(bytes: &[u8]) -> Result>> { Ok(None) } -/// A WMO's collision-relevant totals across all its group files. struct WmoStats { groups: u32, tris: usize, - collision_only_tris: usize, // MOPY material_id == 0xFF (invisible collision geometry) - detail_tris: usize, // MOPY flag 0x04 (vmap extractors treat these specially) + collision_only_tris: usize, // MOPY material_id 0xFF marks invisible collision geometry. + detail_tris: usize, // MOPY flag 0x04 marks detail triangles. } fn read_wmo(chain: &mut PatchChain, name: &str) -> Result { @@ -179,8 +167,7 @@ fn read_wmo(chain: &mut PatchChain, name: &str) -> Result { Ok(stats) } -/// M2 bounding (collision) mesh triangle count. Vanilla MMDX names end `.mdx`/`.mdl` but the -/// archives store `.m2` — swap the extension. +// Vanilla names use .mdx or .mdl, but the archives store .m2. fn read_m2_bounding_tris(chain: &mut PatchChain, name: &str) -> Result { Ok(crate::nav::m2_tris(chain, name)?.len() as u32) } @@ -190,14 +177,12 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { let mut chain = open_geometry_chain(data_dir)?; let map_name = crate::terrain::map_dir(args.map as u32)?; - // The one tile containing --center (16 cells per tile; same math as terrain.rs). let (cx, cy) = (args.center.0 as f32, args.center.1 as f32); let (Some(cell_x), Some(cell_y)) = (cell_index(cx), cell_index(cy)) else { bail!("--center ({cx},{cy}) outside the map square"); }; let (tx, ty) = (cell_x as i32 / 16, cell_y as i32 / 16); - // Same content-arbitrated candidate trick as terrain.rs (filename axis order is folkloric). let candidates = [ format!("World\\Maps\\{map_name}\\{map_name}_{ty}_{tx}.adt"), format!("World\\Maps\\{map_name}\\{map_name}_{tx}_{ty}.adt"), @@ -227,7 +212,6 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { root.doodad_placements.len() ); - // --- WMOs (MODF) --- let wmo_offsets = offset_to_index(&root.wmos); let mut wmo_total_tris = 0usize; let mut seen: BTreeMap = BTreeMap::new(); @@ -252,10 +236,9 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { wmo_total_tris += s.tris; } - // --- M2 doodads (MDDF) --- let m2_offsets = offset_to_index(&root.models); let mut m2_total_tris = 0u32; - let mut m2_seen: BTreeMap> = BTreeMap::new(); // None = parse failed + let mut m2_seen: BTreeMap> = BTreeMap::new(); let mut m2_zero = 0u32; for p in &root.doodad_placements { let name = @@ -268,7 +251,6 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { Some(t) => m2_total_tris += t, } } - // Doodads are numerous (trees); print unique models, not every placement. for (name, tris) in &m2_seen { match tris { Some(t) => println!(" M2 {name} bounding_tris={t}"), diff --git a/importer/src/creature_population.rs b/importer/src/creature_population.rs index 0196c8c72..7e1a6e993 100644 --- a/importer/src/creature_population.rs +++ b/importer/src/creature_population.rs @@ -1,5 +1,3 @@ -//! Preserve source positions and the choices that govern their first population. - use std::collections::{BTreeMap, BTreeSet, HashSet}; use anyhow::{bail, Context, Result}; @@ -122,8 +120,6 @@ fn chance_bp(row: &[String], column: usize, table: &str) -> Result { Ok((chance * 100.0).round() as u32) } -/// Equal-sized, equal-chance single-selection children have the same initial distribution -/// as one flat pool. Other nesting needs a hierarchical selector and fails before import. fn flatten_pool( id: u64, pools: &BTreeMap, diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index b5bebab88..230277ba5 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -1,25 +1,3 @@ -//! Stream 1 (client DBC) — extract `DBFilesClient/*.dbc` from the operator's OWN client MPQ archives -//! (`wow-mpq`) and parse them (gtker `wow_dbc`), emitting only derived `game_*` values. -//! -//! LICENSING FIREWALL (the firewall is this code's behavior, not just `.gitignore`): Blizzard client -//! data is copyrighted. The invariant is **never REDISTRIBUTE Blizzard data** — we hold extracted bytes -//! IN MEMORY ONLY and NEVER write a `.dbc` (or any Blizzard file) to the repo tree. The READ path here -//! only reads. The client-patch packager (`pack_client.rs`) DOES use `wow-mpq`'s builder, but only to -//! emit a patch MPQ into the operator's OWN client install (a local build artifact, never committed and -//! never shipped by us), built from the operator's own base bytes + our additions. The operator's MPQs -//! live outside the repo (their client install). Native-only, like the whole importer. -//! -//! One derived file IS written into the repo tree, and only when the operator asks for it by name: -//! the Base Snapshot (`--spell-snapshot `, `spell_snapshot.rs`). It holds the mapped `game_*` -//! values — our own taxonomy, the same rows the import would load — and never a client record, a -//! client field name or a client byte. It exists because a Datascript author needs base data to -//! clone from without a shard, and its author-facing target `datascripts/generated/` is git-ignored. -//! The rule is unchanged in substance: derived values may be written where the operator points them; -//! client bytes still never touch disk. -//! -//! `wow_dbc::T::read` hard-asserts the build-5875 `record_size`/`field_count` for each table, so a -//! wrong-version / heavily-patched client surfaces as a clear parse error here (the version guard). - use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Cursor; @@ -52,13 +30,9 @@ use wow_world_base::vanilla::LockType; use crate::{push_insert, sql_text, Args}; -/// Open the operator's DBC patch chain in client load order — base `dbc.MPQ` < `patch.MPQ` < -/// `patch-2.MPQ` (higher priority wins, so a table re-shipped in a patch overrides the base). Only -/// archives that actually exist are added (a fresh install may lack `patch-2.MPQ`). READ-ONLY. pub(crate) fn open_chain(data_dir: &Path) -> Result { let mut chain = PatchChain::new(); let mut added = 0u32; - // (filename, priority) — higher priority overrides lower. for (name, prio) in [("dbc.MPQ", 0i32), ("patch.MPQ", 100), ("patch-2.MPQ", 200)] { let path = data_dir.join(name); if path.exists() { @@ -77,17 +51,6 @@ pub(crate) fn open_chain(data_dir: &Path) -> Result { Ok(chain) } -/// The archives that hold the stock FrameXML and GlueXML sources, in client load order (later -/// entries override earlier ones). Only archives that exist are listed, so the result doubles as -/// the "where we looked" list in a not-found message. -/// -/// On a 1.12.1 client `interface.MPQ` carries the `Interface\` tree and the locale archive under -/// `Data//` carries the localised `.lua`, `.xml` and `.toc` sources; the numbered patches -/// re-ship both, and a locale patch outranks the matching generic one. -/// -/// `patch-3.MPQ` is deliberately absent, in both its generic and its locale spelling. It is the -/// client packer's OWN previous output, so reading it as a baseline would compose a UI Transform -/// onto an already-transformed file and drift further on every run. pub(crate) fn ui_baseline_archives(data_dir: &Path) -> Vec { let mut candidates: Vec = vec![data_dir.join("interface.MPQ")]; if let Some(locale) = client_locale(data_dir) { @@ -105,9 +68,6 @@ pub(crate) fn ui_baseline_archives(data_dir: &Path) -> Vec { candidates } -/// The client's locale, read off the one `Data//locale-.MPQ` that exists. A client -/// carries exactly one; if an operator kept several, the alphabetically first wins so two runs on -/// the same install never compose against different baselines. fn client_locale(data_dir: &Path) -> Option { let mut found: Vec = fs::read_dir(data_dir) .ok()? @@ -124,8 +84,6 @@ fn client_locale(data_dir: &Path) -> Option { found.into_iter().next() } -/// Open [`ui_baseline_archives`] as a read-only chain, for composing UI Transforms against the -/// operator's own client. READ-ONLY, and never a write target. pub(crate) fn open_ui_baseline_chain(data_dir: &Path) -> Result { let archives = ui_baseline_archives(data_dir); if archives.is_empty() { @@ -143,10 +101,6 @@ pub(crate) fn open_ui_baseline_chain(data_dir: &Path) -> Result { Ok(chain) } -/// Read + parse one DBC table by its `DbcTable::FILENAME`, resolved through the patch chain. The -/// archive stores it at `DBFilesClient\` (backslash path; MPQ lookup is by name-hash, so the -/// hash-encrypted filename table is a non-issue — we request the known path). Bytes are decompressed -/// transparently and held in memory only — never written to disk. pub(crate) fn read_table(chain: &mut PatchChain) -> Result { let in_archive = format!("DBFilesClient\\{}", T::FILENAME); let bytes = chain @@ -160,8 +114,6 @@ pub(crate) fn read_table(chain: &mut PatchChain) -> Result { }) } -/// Build the DBC catalogues from the operator's client and taxi mount dump. -/// Dry runs print the plan; `--apply` clears and reloads the derived tables. pub fn run(data_dir: &str, args: &Args) -> Result<()> { let mount_dump = crate::read_dump(args.taxi_mounts.as_deref().context( "standalone --dbc requires --taxi-mounts to resolve taxi mount displays", @@ -169,7 +121,6 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { run_from_dump(data_dir, args, &mount_dump) } -/// A complete world import shares its source dump with the client catalogues. pub(crate) fn run_from_dump(data_dir: &str, args: &Args, mount_dump: &str) -> Result<()> { let statements = catalogue_statements(data_dir, mount_dump)?; if args.apply { @@ -192,7 +143,6 @@ pub(crate) fn catalogue_statements(data_dir: &str, mount_dump: &str) -> Result Result println!( @@ -219,8 +168,6 @@ pub(crate) fn catalogue_statements(data_dir: &str, mount_dump: &str) -> Result Result Result Result Result Result Result<(Vec, usize)> { if table.rows().is_empty() { bail!("AuctionHouse.dbc contains no rows"); @@ -419,11 +339,6 @@ fn auction_house_sql(table: &DbcAuctionHouse) -> Result<(Vec, usize)> { Ok((stmts, count)) } -/// Clear+reload SQL for `game_mail_template` from `MailTemplate.dbc`. In 1.12 the DBC holds only the -/// letter body (`wow_dbc`'s `vanilla_tables::mail_template::MailTemplateRow { id, body }`); cmangos -/// sends the template id with an empty subject and lets the client render the stored text. Validate -/// before returning the first DELETE, like `auction_house_sql`. Output is ordered by id rather than -/// DBC record order to keep dry runs and imports reproducible. fn mail_template_sql(table: &DbcMailTemplate) -> Result<(Vec, usize)> { if table.rows().is_empty() { bail!("MailTemplate.dbc contains no rows"); @@ -450,11 +365,6 @@ fn mail_template_sql(table: &DbcMailTemplate) -> Result<(Vec, usize)> { Ok((stmts, count)) } -/// Clear+reload SQL for `game_faction` from `Faction.dbc` (Elwynn faction system): each parent faction's -/// reputation index + the player's starting standing. `reputation_index` 0xFFFFFFFF (no rep bar) → -1. -/// `base_standing` = the primary race-group's base reputation (group 0), reinterpreted as signed (a Hated -/// faction stores a wrapped negative like 4294925296 == -42000); correct for our Human test character. -/// Per-race resolution via `reputation_race_mask` is a follow-up. No Timestamp → plain SQL. fn faction_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { let factions: DbcFaction = read_table(chain)?; let rows: Vec = factions @@ -478,8 +388,6 @@ fn faction_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { Ok((stmts, n)) } -/// Clear+reload SQL for `game_char_base_info` from `CharBaseInfo.dbc` (importer P1): the legal -/// (race, class) combos. No PK in the DBC — iterate. No Timestamp → plain SQL. PK packs `(race<<8)|class`. fn char_base_info_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { let cbi: DbcCharBaseInfo = read_table(chain)?; let rows: Vec = cbi @@ -505,12 +413,6 @@ fn char_base_info_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { Ok((stmts, n)) } -/// The `CharStartOutfit.dbc` (race_class, item) set — shared by `start_item_sql` (the outfit's own -/// clear+reload) AND `main.rs`'s `build_createinfo_item_sql`: the cmangos dump's -/// `playercreateinfo_item` EXTRAS are deduped against this SAME set, so an item CharStartOutfit -/// already grants for a (race, class) is never double-listed. `race == 0 || class == 0` rows are -/// skipped (not a real playable combo) and `item == 0` slots (empty) are skipped, matching -/// `start_item_sql`'s own filtering exactly. pub(crate) fn outfit_item_set( chain: &mut PatchChain, ) -> Result> { @@ -560,43 +462,11 @@ fn start_item_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { Ok((stmts, n)) } -/// `SkillLineAbility.dbc`'s `acquire_method` value meaning "automatically learned once the -/// race/class/skill conditions are met" (as opposed to 2 = learned from a trainer, 0 = not learnable -/// this way). The DBC carries no strings for this enum, so this is sourced from community DBC -/// documentation (wowdev.wiki), not something verified against our own client dump — it's informational -/// only HERE (it only drives the "N autolearn" coverage-print count); a future AUTOLEARN consumer -/// Automatic spell learning at level thresholds is what needs this value truly -/// load-bearing-correct, and can re-derive/correct it against a real client at that point. const AUTOLEARN_ACQUIRE_METHOD: i32 = 1; -/// The profession `SkillLine.dbc` ids whose Apprentice→Artisan tier caps `profession_tier_values` -/// resolves — mirrors `module/src/skill.rs::skill_line`'s profession consts. Duplicated here, not -/// shared, because the wasm `module` crate is never a dependency of this native importer (same -/// reasoning as `spell.rs`'s taxonomy duplication) — keep the two lists in lockstep. NOTE: this is 12 -/// lines, not 13 — `module/src/skill.rs`'s own doc comment says "completing the 13" but only ever -/// defines 12 profession consts (a pre-existing, harmless comment miscount noticed while wiring this -/// item; out of scope to fix here). -const PROFESSION_SKILL_LINES: &[u32] = &[ - 186, // Mining - 182, // Herbalism - 393, // Skinning - 185, // Cooking - 129, // First Aid - 171, // Alchemy - 164, // Blacksmithing - 197, // Tailoring - 165, // Leatherworking - 202, // Engineering - 333, // Enchanting - 356, // Fishing -]; - -/// Clear+reload SQL for `game_skill_line` from `SkillLine.dbc` — one row per skill line (weapon lines, -/// Defense, the 12 professions, and a long tail of NPC-only/deprecated lines the client never shows a -/// player; ~135 total in vanilla). `category` is `SkillLineCategory.dbc`'s raw id (not separately -/// imported, kept as the plain foreign key — same "store the raw key" convention as -/// `race_info_sql`/`faction_sql`). Name via `.en_gb` + `sql_text` (apostrophe-escaped), matching -/// `talent.rs`'s `LocalizedString` convention. No Timestamp → plain SQL. +const PROFESSION_SKILL_LINES: &[u32] = + &[186, 182, 393, 185, 129, 171, 164, 197, 165, 202, 333, 356]; + fn skill_line_sql(table: &DbcSkillLine) -> (Vec, usize) { let rows: Vec = table .rows() @@ -621,25 +491,16 @@ fn skill_line_sql(table: &DbcSkillLine) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_skill_ability` from `SkillLineAbility.dbc` — a `(spell, skill_line)` -/// pairing plus its race/class gate, `acquire_method` (0=trained/learned-from-item, 1/2=autolearn -/// at a skill threshold), and recipe skill-up band `green`(TrivialLow)..`gray`(TrivialHigh) (~9-10k -/// rows in vanilla). ⚠ 282: wow_dbc 0.3's `SkillLineAbilityRow` MIS-NAMES every field from position -/// 8 (it omits the real `MinSkillLineRank` column), so this reads by CONTENT, not by wow_dbc's field -/// names — real `AcquireMethod` lives in `trivial_skill_line_rank_high`, real `TrivialHigh` in -/// `trivial_skill_line_rank_low`, real `TrivialLow` in `character_points[0]`, real `MinSkillLineRank` -/// in `superseded_by`. Ids are assigned sequentially (1-based) — the DBC's own `id` isn't reused -/// (mirrors `start_item_sql`). No Timestamp → plain SQL. fn skill_ability_sql(table: &DbcSkillLineAbility) -> (Vec, usize) { let rows: Vec = table .rows() .iter() .enumerate() .map(|(i, r)| { - let min_skill = r.superseded_by.id; // real MinSkillLineRank - let acquire_method = r.trivial_skill_line_rank_high; // real AcquireMethod (0/1/2) - let gray = r.trivial_skill_line_rank_low; // real TrivialSkillLineRankHigh - let green = r.character_points[0]; // real TrivialSkillLineRankLow + let min_skill = r.superseded_by.id; // Real MinSkillLineRank. + let acquire_method = r.trivial_skill_line_rank_high; // Real AcquireMethod. + let gray = r.trivial_skill_line_rank_low; // Real TrivialSkillLineRankHigh. + let green = r.character_points[0]; // Real TrivialSkillLineRankLow. format!( "({},{},{},{},{},{},{},{},{})", i as u64 + 1, @@ -665,7 +526,7 @@ fn skill_ability_sql(table: &DbcSkillLineAbility) -> (Vec, usize) { (stmts, n) } -/// The pinned reader calls the vanilla SupercededBySpell column `acquire_method`. +// The pinned reader calls vanilla SupercededBySpell acquire_method. pub(crate) fn read_spell_rank_links(data_dir: &str) -> Result> { let mut chain = open_chain(Path::new(data_dir))?; let abilities: DbcSkillLineAbility = read_table(&mut chain)?; @@ -682,10 +543,6 @@ fn spell_rank_links(abilities: &DbcSkillLineAbility) -> Result> Ok(links) } -/// Clear+reload SQL for `game_skill_availability` from `SkillRaceClassInfo.dbc` — which (race, class) -/// combos a skill line is available to, its availability flags, and the minimum character level to -/// pick it up (~700-800 rows in vanilla). Ids assigned sequentially, same convention as -/// `skill_ability_sql`. No Timestamp → plain SQL. fn skill_availability_sql(table: &DbcSkillRaceClassInfo) -> (Vec, usize) { let rows: Vec = table .rows() @@ -714,9 +571,6 @@ fn skill_availability_sql(table: &DbcSkillRaceClassInfo) -> (Vec, usize) (stmts, n) } -/// Clear+reload SQL for `game_race_info` from `ChrRaces.dbc` (importer P1): per-race male/female -/// display models + faction. No Timestamp → plain SQL. Skips race 0 (the "none" row). Returns -/// (statements, row_count). Keys (`.id`) expose the raw u32. fn race_info_sql(races: &ChrRaces) -> (Vec, usize) { let rows: Vec = races .rows() @@ -740,9 +594,6 @@ fn race_info_sql(races: &ChrRaces) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_faction_template` from `FactionTemplate.dbc`. No Timestamp columns, so -/// plain `spacetime sql` (the Timestamp-reducer rule doesn't apply). Chunked under a conservative row -/// count per INSERT. Returns (statements, row_count). Keys expose their raw id via `.id`. fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { let ft: DbcFactionTemplate = read_table(chain)?; let rows: Vec = ft @@ -768,7 +619,6 @@ fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> }) .collect(); let n = rows.len(); - // `id >= 0` matches every u32 → clears the whole table before reload (clear+reload is idempotent). let mut stmts = vec!["DELETE FROM game_faction_template WHERE id >= 0".to_string()]; for chunk in rows.chunks(500) { stmts.push(format!( @@ -779,12 +629,6 @@ fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> Ok((stmts, n)) } -/// Clear+reload SQL for `game_area` from `AreaTable.dbc`: every zone AND subzone row -/// (id, map, parent, area_bit, flags, exploration level, faction group, name). `flags` is the raw -/// `AreaFlags` bitmask reinterpreted as `u32` (`as_int()` widened) — undecoded here, a consumer -/// decodes what it needs (rest-state city detection is left to the consumer). Name via `.en_gb` + `sql_text` -/// (apostrophe-escaping), matching `skill_line_sql`'s convention. Loads ALL rows (small table — map -/// filtering is a verify-time concern, not an import-time one). No Timestamp → plain SQL. fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { let rows: Vec = table .rows() @@ -814,12 +658,6 @@ fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc`: the geometric -/// half of inn triggers, dungeon entrances, and quest explore objectives — a trigger -/// volume is either a sphere (`radius`) or a box (`box_length`/`box_width`/`box_height`/`box_yaw`); -/// the DBC carries both fields for every row regardless of which shape a given trigger actually -/// uses. Loads ALL rows (small table — map filtering is a verify-time concern). No Timestamp → -/// plain SQL. fn area_trigger_sql(table: &DbcAreaTrigger) -> (Vec, usize) { let rows: Vec = table .rows() @@ -851,19 +689,13 @@ fn area_trigger_sql(table: &DbcAreaTrigger) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_graveyard` from `WorldSafeLocs.dbc`: a graveyard's -/// fixed position, replacing the hand-coded `world::graveyard::{NORTHSHIRE, GOLDSHIRE, ...}` consts -/// as the primary data source (those consts — and `seed.rs`'s row-seeded twins — remain the -/// no-import fallback). No orientation column — the DBC carries none. Name via `.en_gb` + -/// `sql_text`. Loads ALL rows (small table). No Timestamp → plain SQL. fn graveyard_sql(table: &DbcWorldSafeLocs) -> (Vec, usize) { let rows: Vec = table .rows() .iter() .map(|r| { format!( - // o = 0.0: WorldSafeLocs.dbc carries no orientation — the seed rows are the only - // source of a real facing (the Northshire 2.72271 case). + // WorldSafeLocs.dbc has no orientation. "({},{},{},{},{},0,{})", r.id.id, r.map.id, @@ -899,7 +731,6 @@ struct TaxiCatalogueSql { warnings: Vec, } -/// Resolve from the full dump, since taxi mounts need not have a spawn in the imported area. fn taxi_mount_displays(dump: &str, display_ids: &HashSet) -> Result> { let rows = crate::parse_complete_table(dump, "creature_template")?; if rows.is_empty() { @@ -920,8 +751,6 @@ fn taxi_mount_displays(dump: &str, display_ids: &HashSet) -> Result) -> Result (Vec, usize) { let rows: Vec = table .rows() @@ -1289,16 +1104,8 @@ fn creature_family_sql(table: &DbcCreatureFamily) -> (Vec, usize) { (stmts, n) } -/// `LockType.dbc` id -> module SkillLine id, for a Lock.dbc index whose `ty == LocktypeReference` -/// (there `property` is a LockType.dbc id, NOT a SkillLine — see `lock_sql`). REPO-VERIFIED: 1 -/// Lockpicking, 2 Herbalism, 3 Mining (cmangos's `SkillByLockType[]` is the live-check reference for -/// any OTHER LockType id — [V] not extended here; an id outside this list resolves to `None` and -/// `lock_sql` surfaces it as an unmapped count in the coverage print rather than guessing). -const LOCKTYPE_TO_SKILL_LINE: &[(u32, u32)] = &[ - (1, 633), // Lockpicking - (2, 182), // Herbalism - (3, 186), // Mining -]; +// LockType.dbc ids require conversion to SkillLine ids. +const LOCKTYPE_TO_SKILL_LINE: &[(u32, u32)] = &[(1, 633), (2, 182), (3, 186)]; fn locktype_skill_line(locktype_id: u32) -> Option { LOCKTYPE_TO_SKILL_LINE @@ -1307,19 +1114,7 @@ fn locktype_skill_line(locktype_id: u32) -> Option { .map(|&(_, skill)| skill) } -/// Clear+reload SQL for `game_lock` from `Lock.dbc`, the DATA half of open-lock; the Module's -/// lock gate enforces it. Each Lock.dbc row packs FOUR parallel `[;8]` arrays: `ty`/`property`/ -/// `required_skill`/`action` — up to 8 ALTERNATIVE ways to open the SAME lock (e.g. "the right key OR -/// enough Lockpicking"). Only the non-`LockType::None` indices are real; a lock with 1 requirement -/// emits exactly 1 `game_lock` row (see the module's `GameLock` doc, `module/src/gameobject.rs`). -/// `ty == ItemRequired` → `kind` 1, `property` IS the required key item entry, verbatim. `ty == -/// LocktypeReference` → `kind` 2, `property` is a LockType.dbc id (NOT a SkillLine) resolved via -/// `locktype_skill_line` at IMPORT time, so the module never needs LockType.dbc itself — an -/// unmapped id resolves to SkillLine 0 (unknown) and is counted in the returned unmapped-id set so -/// `run()`'s coverage print can surface it loudly instead of silently miscoding a lock. `required_skill` -/// is the DBC's own signed value, floored at 0 (never negative in a real table). No Timestamp → plain -/// SQL, same clear+reload shape as `creature_family_sql`. Returns (statements, row count, unmapped -/// LockType ids encountered). +// The eight Lock.dbc indices are alternative ways to open one lock. fn lock_sql(table: &DbcLock) -> (Vec, usize, std::collections::BTreeSet) { let mut rows: Vec = Vec::new(); let mut row_id: u64 = 1; @@ -1360,10 +1155,6 @@ fn lock_sql(table: &DbcLock) -> (Vec, usize, std::collections::BTreeSet< (stmts, n, unmapped_locktype_ids) } -/// `display_id -> creature_model_scale` from CreatureDisplayInfo.dbc. The cmangos creature ETL uses -/// this to resolve `creature_template.Scale == 0` (vanilla's "use the DBC display scale" sentinel) at -/// import time — otherwise those creatures load with `scale_x = 0` and the client renders them at -/// scale 0. In-memory only (no `.dbc` written). The map is built once; lookups are O(1). pub fn load_display_scales(data_dir: &str) -> Result> { let mut chain = open_chain(Path::new(data_dir))?; let cdi: CreatureDisplayInfo = read_table(&mut chain)?; @@ -1374,12 +1165,6 @@ pub fn load_display_scales(data_dir: &str) -> Result> { Ok(scales) } -/// `skill_line -> [Apprentice, Journeyman, Expert, Artisan]` tier caps for the 12 profession lines -/// (`PROFESSION_SKILL_LINES`), resolved via `SkillRaceClassInfo.dbc` (the first row matching that -/// `skill_line`'s `skill_tier` foreign key) → `SkillTiers.dbc`'s `value[0..4]`. This is the DATA half -/// of `importer/src/main.rs`'s `PROFESSION_LEARN` byte-parity guard: the hardcoded 75/150/225/300 -/// literals it always used must equal what the real client's tables actually carry — this function is -/// what lets `main.rs` check that. In-memory only, like the rest of `dbc.rs` (no `.dbc` written). pub fn profession_tier_values(data_dir: &str) -> Result> { let mut chain = open_chain(Path::new(data_dir))?; let availability: DbcSkillRaceClassInfo = read_table(&mut chain)?; @@ -1387,10 +1172,6 @@ pub fn profession_tier_values(data_dir: &str) -> Result> Ok(resolve_profession_tier_values(&availability, &tiers)) } -/// Chain-free core of `profession_tier_values` — fixture-tested directly (constructed -/// `SkillRaceClassInfoRow`/`SkillTiersRow`s, no MPQ needed). A profession line absent from -/// `availability` (shouldn't happen with a real client) is silently skipped; the caller's parity guard -/// (`main.rs`'s `assert_profession_tier_parity`) catches the gap by failing to find it in the map. fn resolve_profession_tier_values( availability: &DbcSkillRaceClassInfo, tiers: &DbcSkillTiers, @@ -1414,10 +1195,6 @@ fn resolve_profession_tier_values( out } -// =========================================================================================== -// Tests -// =========================================================================================== - #[cfg(test)] mod tests { use super::*; @@ -1454,8 +1231,6 @@ mod tests { use wow_dbc::LocalizedString; use wow_world_base::vanilla::AreaFlags; - /// A scratch `Data/` tree of empty placeholder archives. [`ui_baseline_archives`] only tests - /// for existence, so no archive here needs real MPQ bytes. fn placeholder_data_dir(tag: &str, names: &[&str]) -> PathBuf { let dir = std::env::temp_dir().join(format!("dbc-ui-baseline-{}-{tag}", std::process::id())); @@ -1785,10 +1560,7 @@ mod tests { rows: vec![taxi_path_row(70, 10, 20, 125)], }, DbcTaxiPathNode { - // Deliberately shuffled: output must use path/node-index order, not record order. rows: vec![ - // The DBC field is a signed int32 container. Preserve its complete bit pattern, - // including the sign bit, rather than narrowing the public catalogue value. taxi_point_row(703, 70, 2, 30.0, i32::MIN, 900), taxi_point_row(701, 70, 0, 10.0, 1, 0), taxi_point_row(702, 70, 1, 20.0, 2, 450), @@ -2229,7 +2001,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_area ")) .unwrap(); - // id,map_id,parent_area_id,area_bit,flags,exploration_level,faction_group,name assert!( insert.contains("(12,0,0,4479,64,1,1,'Elwynn Forest')"), "{insert}" @@ -2241,16 +2012,11 @@ mod tests { assert!( insert.contains("(999,0,0,1,0,0,0,'O''Ryan''s Rest')"), "{insert}" - ); // apostrophe escaping + ); } #[test] fn area_trigger_sql_shape_goldshire_inn_fixture() { - // A CONSTRUCTED fixture (not client-verified — this sandbox has no real client MPQ to read a - // real trigger id from) shaped like Goldshire's Lion's Pride Inn entrance trigger, proving - // the emitted tuple's column order — id,map_id,x,y,z,radius,box_length,box_width,box_height, - // box_yaw, is exactly what the rest-state system will read a real inn - // AreaTrigger row as. let table = DbcAreaTrigger { rows: vec![area_trigger_row( 1524, 0, -9454.5, 39.8, 64.0, 0.0, 6.0, 4.0, 6.0, 1.55, @@ -2325,8 +2091,8 @@ mod tests { fn creature_family_sql_shape_escaping_and_tameable_count() { let table = DbcCreatureFamily { rows: vec![ - creature_family_row(1, "Wolf", 1, 0, 8), // tameable pet family - creature_family_row(3, "O'Ryan's Beast", 6, -1, 8), // non-pet family + apostrophe escaping + creature_family_row(1, "Wolf", 1, 0, 8), + creature_family_row(3, "O'Ryan's Beast", 6, -1, 8), ], }; let (stmts, n) = creature_family_sql(&table); @@ -2339,10 +2105,8 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_creature_family ")) .unwrap(); - // family_id,name,pet_food_mask,pet_talent_type,category assert!(insert.contains("(1,'Wolf',1,0,8)"), "{insert}"); - assert!(insert.contains("(3,'O''Ryan''s Beast',6,-1,8)"), "{insert}"); // apostrophe escaping - // tameable = pet_talent_type != -1 (mirrors run()'s coverage-print filter). + assert!(insert.contains("(3,'O''Ryan''s Beast',6,-1,8)"), "{insert}"); let tameable = table .rows() .iter() @@ -2354,9 +2118,6 @@ mod tests { ); } - /// Build a `LockRow` with `entries` filling indices `0..entries.len()` (each `(LockType, property, - /// required_skill)`); the remaining indices up to 8 are `LockType::None` (padding, matching a real - /// Lock.dbc row that rarely uses all 8 alternatives). fn lock_row(id: u32, entries: &[(LockType, u32, i32)]) -> LockRow { let mut ty = [LockType::None; 8]; let mut property = [0u32; 8]; @@ -2379,12 +2140,7 @@ mod tests { fn lock_sql_emits_only_non_none_indices_with_item_and_skill_kinds() { let table = DbcLock { rows: vec![ - // Lock 1: index 0 = ITEM (a real key, item entry 12345); indices 1..8 unused (None, - // skipped) — a lock with exactly ONE real requirement emits exactly ONE row. lock_row(1, &[(LockType::ItemRequired, 12345, 0)]), - // Lock 2: index 0 = SKILL via a MAPPED LockType id (1 = Lockpicking -> SkillLine 633), - // required value 100 (a hard pick). index 1 = SKILL via LockType 3 (Mining -> 186), - // required value 1. lock_row( 2, &[ @@ -2392,24 +2148,20 @@ mod tests { (LockType::LocktypeReference, 3, 1), ], ), - // Lock 3: an UNMAPPED LockType id (999) — must NOT be guessed; resolves to SkillLine 0 - // and is surfaced via the returned unmapped-id set. lock_row(3, &[(LockType::LocktypeReference, 999, 50)]), ], }; let (stmts, n, unmapped) = lock_sql(&table); - // 1 (lock 1) + 2 (lock 2) + 1 (lock 3) = 4 rows total; NONE of the padding (None) indices emit. assert_eq!(n, 4); assert_eq!(stmts[0], "DELETE FROM game_lock WHERE id >= 0"); let insert = stmts .iter() .find(|s| s.starts_with("INSERT INTO game_lock ")) .unwrap(); - // id,lock_id,index,kind,property,required_skill - assert!(insert.contains("(1,1,0,1,12345,0)"), "{insert}"); // item lock, kind 1, property=item entry - assert!(insert.contains("(2,2,0,2,633,100)"), "{insert}"); // Lockpicking (LockType 1 -> SkillLine 633) - assert!(insert.contains("(3,2,1,2,186,1)"), "{insert}"); // Mining (LockType 3 -> SkillLine 186) - assert!(insert.contains("(4,3,0,2,0,50)"), "{insert}"); // unmapped LockType 999 -> property 0 + assert!(insert.contains("(1,1,0,1,12345,0)"), "{insert}"); + assert!(insert.contains("(2,2,0,2,633,100)"), "{insert}"); + assert!(insert.contains("(3,2,1,2,186,1)"), "{insert}"); + assert!(insert.contains("(4,3,0,2,0,50)"), "{insert}"); assert_eq!( unmapped, std::collections::BTreeSet::from([999u32]), @@ -2419,8 +2171,6 @@ mod tests { #[test] fn lock_sql_all_none_lock_emits_no_rows() { - // A lock with every index LockType::None (shouldn't happen in a real table, but the parser - // must not crash or emit a bogus row) contributes 0 rows and 0 unmapped ids. let table = DbcLock { rows: vec![lock_row(9, &[])], }; @@ -2431,10 +2181,10 @@ mod tests { #[test] fn locktype_skill_line_covers_the_three_repo_verified_ids_only() { - assert_eq!(locktype_skill_line(1), Some(633)); // Lockpicking - assert_eq!(locktype_skill_line(2), Some(182)); // Herbalism - assert_eq!(locktype_skill_line(3), Some(186)); // Mining - assert_eq!(locktype_skill_line(4), None); // [V] not yet mapped — must NOT silently guess + assert_eq!(locktype_skill_line(1), Some(633)); + assert_eq!(locktype_skill_line(2), Some(182)); + assert_eq!(locktype_skill_line(3), Some(186)); + assert_eq!(locktype_skill_line(4), None); assert_eq!(locktype_skill_line(0), None); } @@ -2452,10 +2202,6 @@ mod tests { } } - // Params are REAL SkillLineAbility semantics; they're placed into the wow_dbc fields the - // corrected `skill_ability_sql` reads for each (see the off-by-one remap there): real - // min_skill → superseded_by, real acquire_method → trivial_high, real gray → trivial_low, - // real green → character_points[0]. #[allow(clippy::too_many_arguments)] fn skill_ability_row( id: u32, @@ -2476,11 +2222,11 @@ mod tests { class_mask: ChrClassesKey::new(class_mask), exclude_race: SkillChrRacesKey::new(0), exclude_class: ChrClassesKey::new(0), - superseded_by: SpellKey::new(min_skill), // corrected reads real MinSkillLineRank here - acquire_method: 0, // real SupercededBySpell - trivial_skill_line_rank_high: acquire_method, // corrected reads real AcquireMethod here - trivial_skill_line_rank_low: gray, // corrected reads real TrivialHigh here - character_points: [green, 0], // corrected reads real TrivialLow here + superseded_by: SpellKey::new(min_skill), + acquire_method: 0, + trivial_skill_line_rank_high: acquire_method, + trivial_skill_line_rank_low: gray, + character_points: [green, 0], num_skills_up: 1, unknown_padding: 0, } @@ -2538,7 +2284,7 @@ mod tests { rows: vec![ skill_line_row(185, 11, "Cooking"), skill_line_row(44, 7, "Axes"), - skill_line_row(999, 0, "O'Ryan's Line"), // apostrophe escaping + skill_line_row(999, 0, "O'Ryan's Line"), ], }; let (stmts, n) = skill_line_sql(&table); @@ -2558,14 +2304,10 @@ mod tests { #[test] fn skill_ability_sql_remaps_off_by_one_dbc_fields() { - // Params are REAL semantics (min_skill, acquire_method, gray, green); the builder places - // them into the mis-named wow_dbc fields the corrected importer reads. Row 1: a - // trained recipe (min 25, acquire 0, gray 75, green 30). Row 2: an autolearn ability - // (acquire 1) with a trained-with-skill min. let table = DbcSkillLineAbility { rows: vec![ skill_ability_row(1, 185, 2550, 0xFFFF_FFFF, 0xFFFF_FFFF, 25, 0, 75, 30), - skill_ability_row(2, 333, 13262, 0xFFFF_FFFF, 0xFFFF_FFFF, 0, 1, 0, 0), // autolearn (disenchant-shaped) + skill_ability_row(2, 333, 13262, 0xFFFF_FFFF, 0xFFFF_FFFF, 0, 1, 0, 0), ], }; let (stmts, n) = skill_ability_sql(&table); @@ -2575,7 +2317,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_skill_ability ")) .unwrap(); - // id,spell_id,skill_line,race_mask,class_mask,min_skill,acquire_method,gray,green assert!( insert.contains("(1,2550,185,4294967295,4294967295,25,0,75,30)"), "{insert}" @@ -2584,7 +2325,6 @@ mod tests { insert.contains("(2,13262,333,4294967295,4294967295,0,1,0,0)"), "{insert}" ); - // autolearn count reads the CORRECTED field (real AcquireMethod in trivial_high). let autolearn = table .rows() .iter() @@ -2595,14 +2335,9 @@ mod tests { #[test] fn skill_ability_sql_pairs_riding_mount_spells_with_the_skill_line_and_tiers() { - // Verifies the EXISTING skill_ability_sql plumbing — no rewrite — correctly pairs each mount - // spell with the Riding skill line (762) and carries the 75/150 tiers + race/class masks - // through untouched. Row 1: a race-specific Apprentice mount (Human only, min_skill 75, trained - // from the trainer — acquire_method 0). Row 2: a Journeyman-tier mount available to every race - // (min_skill 150). const RIDING_SKILL_LINE: u32 = 762; const RACE_HUMAN: u32 = 0x1; - const CLASS_ALL: u32 = 0xFFFF_FFFF; // riding is not class-restricted + const CLASS_ALL: u32 = 0xFFFF_FFFF; let table = DbcSkillLineAbility { rows: vec![ skill_ability_row( @@ -2635,7 +2370,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_skill_ability ")) .unwrap(); - // id,spell_id,skill_line,race_mask,class_mask,min_skill,acquire_method,gray,green assert!( insert.contains(&format!( "(1,458,{RIDING_SKILL_LINE},1,{CLASS_ALL},75,0,0,0)" @@ -2680,7 +2414,7 @@ mod tests { let availability = DbcSkillRaceClassInfo { rows: vec![skill_availability_row( 1, - 186, /* Mining */ + 186, 0xFFFF_FFFF, 0xFFFF_FFFF, 0, @@ -2699,8 +2433,8 @@ mod tests { fn profession_tier_values_skips_past_a_non_tier_variant_row_to_the_resolving_one() { let availability = DbcSkillRaceClassInfo { rows: vec![ - skill_availability_row(1, 186 /* Mining */, 0x1, 0x1, 0, 1, 0), // variant: tier FK 0 → unresolvable - skill_availability_row(2, 186, 0xFFFF_FFFF, 0xFFFF_FFFF, 0, 1, 42), // the real tier row + skill_availability_row(1, 186, 0x1, 0x1, 0, 1, 0), + skill_availability_row(2, 186, 0xFFFF_FFFF, 0xFFFF_FFFF, 0, 1, 42), ], }; let tiers = DbcSkillTiers { @@ -2712,19 +2446,12 @@ mod tests { #[test] fn profession_tier_values_skips_an_unresolvable_line() { - // No SkillRaceClassInfo row at all for line 356 (Fishing) — resolves to nothing, not a panic. let availability = DbcSkillRaceClassInfo { rows: vec![] }; let tiers = DbcSkillTiers { rows: vec![] }; let map = resolve_profession_tier_values(&availability, &tiers); assert!(map.is_empty()); } - /// The byte-parity proof (in-sandbox, no real client available): construct a `SkillRaceClassInfo`/ - /// `SkillTiers` fixture for all 12 profession lines carrying the well-known 75/150/225/300 - /// progression, resolve it, and check it against BOTH `main.rs`'s `LEGACY_TIER_CAPS` (via the exact - /// `PROFESSION_LEARN` tier_index each entry uses) AND `main.rs`'s own - /// `assert_profession_tier_parity` guard directly — proving the plumbing a real client run would - /// exercise is wired correctly, even though we can't check it against Blizzard's actual bytes here. #[test] fn profession_tier_values_matches_the_legacy_literals_for_every_profession_learn_line() { let mut availability_rows = Vec::new(); diff --git a/importer/src/eventai.rs b/importer/src/eventai.rs index d1f017375..0f60fa199 100644 --- a/importer/src/eventai.rs +++ b/importer/src/eventai.rs @@ -1,5 +1,3 @@ -//! CMaNGOS EventAI import boundary. - use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::{bt, eventai_presentation, field, go_guid, parse_table, sql_text, world_guid}; @@ -1257,7 +1255,7 @@ struct RawRelayStep { } impl RelaySource { - #[allow(clippy::too_many_lines)] // One parse pass per dumped table. + #[allow(clippy::too_many_lines)] fn parse(dump: &str) -> Self { let mut source = Self::default(); source.broadcast_ids.extend( @@ -1904,7 +1902,6 @@ impl RelaySource { closure } - /// Static definitions needed by accepted relay steps, excluding source-loader skips. fn closure_entries(&self, roots: &BTreeSet, kind: &str) -> BTreeSet { let mut entries = BTreeSet::new(); for relay_id in self.closure(roots) { @@ -1935,10 +1932,7 @@ impl RelaySource { .filter(|step| !step.is_loader_skipped()) .map(|step| step.encode(self)) .collect::, _>>() - // Every relay in this closure passed `gate_root` (each instruction encodes) and - // `gate_participants` (each subject resolves), which together cover both - // failure arms of `encode`. A panic here means a closure reached encoding - // without passing one of those two Gates. + // gate_root and gate_participants cover both encode failure paths. .expect("relay closure passed both relay Gates before encoding"); let steps = encoded_steps.join("~"); let material = format!("{relay_id}@parallel@map-or-instance@{steps}"); @@ -2168,7 +2162,7 @@ impl RawRelayStep { Ok(()) } - #[allow(clippy::too_many_lines)] // One arm per action kind that can name a dependency. + #[allow(clippy::too_many_lines)] fn resolved_dependencies( &self, source: &RelaySource, @@ -2630,7 +2624,7 @@ impl RawRelayStep { self.encode_instruction() } - #[allow(clippy::too_many_lines)] // One arm per action kind. + #[allow(clippy::too_many_lines)] fn encode_instruction(&self) -> Result { let bool_value = |value: u32| u8::from(value != 0); let forced = |value: i32| match value { @@ -2901,9 +2895,6 @@ impl RawRelayStep { command => Err(format!("unsupported command {command}")), } } - - // Relay Gates and encoding are kept together so accepted source rows cannot diverge from - // the wire form sent to the Module. } fn parse_source_maps(dump: &str) -> HashMap> { @@ -3034,9 +3025,6 @@ impl MappingFailure { } } -/// The dump's EventAI tables, parsed once. Only rule assembly depends on the World Import Scope, and -/// the scope grows to a fixpoint while summoned templates pull in more entries, so the caller -/// re-runs [`EventAiSource::assemble`] against the grown scope rather than re-reading the dump. pub(crate) struct EventAiSource { broadcasts: BTreeMap, summon_locations: BTreeMap, @@ -3259,7 +3247,7 @@ fn source_overlap_census(dump: &str, rules: &[RawRule]) -> (u64, u64, u64) { } impl EventAiSource { - #[allow(clippy::too_many_lines)] // One assembly pass per EventAI rule family. + #[allow(clippy::too_many_lines)] pub(crate) fn assemble( &self, imported_entries: &HashSet, @@ -4268,7 +4256,7 @@ fn parse_summons(dump: &str, coverage: &mut Coverage) -> BTreeMap().ok()?, field(&row, 3).parse::().ok()?, field(&row, 4).parse::().ok()?, - // The source column says seconds, but the summon API consumes it as milliseconds. + // The source column says seconds, but the summon API consumes milliseconds. source_u32(field(&row, 5))?, )) }; @@ -4566,7 +4554,6 @@ fn repeats_on_each_event(event: u32) -> bool { ) } -/// The import-wide lookup tables one action mapping consults. #[derive(Clone, Copy)] struct ActionLookups<'a> { broadcasts: &'a BTreeMap, @@ -4747,7 +4734,6 @@ fn map_action( } } -/// The source action fell through every parameter shape this loader accepts. fn unsupported_action(kind: u32) -> Result> { Err(vec![MappingFailure::source( "action", @@ -4756,7 +4742,6 @@ fn unsupported_action(kind: u32) -> Result> { )]) } -/// Speech: the legacy multi-text action and its newer single-text/template twin. fn map_speech_action( action: [u32; 4], kind: u32, @@ -4934,7 +4919,6 @@ fn map_speech_action( } } -/// Combat: casting a spell, moving threat, and stripping an aura. fn map_combat_action( action: [u32; 4], kind: u32, @@ -5055,7 +5039,6 @@ fn map_combat_action( } } -/// Phase: the four ways a rule moves its subject's phase mask. fn map_phase_action(action: [u32; 4], kind: u32) -> Result> { match kind { ACTION_SET_PHASE if action[1] < 32 => Ok(NativeAction { @@ -5133,7 +5116,6 @@ fn map_phase_action(action: [u32; 4], kind: u32) -> Result Result> { let bool_value = |value: u32| u8::from(value != 0); match kind { @@ -5367,7 +5348,6 @@ fn map_movement_action(action: [u32; 4], kind: u32) -> Result "yell", - // Source chat type 2 is a third-person narrative line, not speech. The runtime prefers the - // broadcast row's own chat type, so this mode is the fallback when that row cannot be read. - // Encoding it as `say` made the fallback disagree with the source. + // Source chat type 2 is a text emote; this mode is the missing-broadcast-row fallback. Some(2) => "emote", Some(3) => "boss-emote", Some(4) => "whisper", @@ -6909,10 +6885,6 @@ mod tests { ); } - /// An accepted action already forces its summon template into the World Import Scope. A relay - /// leaf that names a creature template carries the same weight, so the fixpoint must pull it in - /// too. Without this the manifest reports the dependency resolved and the profile apply-ready - /// while the run emits a relay step naming a template it never writes. #[test] fn an_accepted_relay_forces_the_creature_template_its_step_names() { let sql = format!( @@ -7038,7 +7010,6 @@ mod tests { #[test] fn binding_startup_rules_keep_faction_then_attack_the_summoner() { - // classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb startup actions. let source = parse(&dump(&[ rule( 567602, @@ -8376,7 +8347,7 @@ mod tests { #[test] #[ignore = "requires LYRACORE_CLASSIC_DB_SQL pointing at the pinned decompressed SQL or gzip"] - #[allow(clippy::too_many_lines)] // One assertion per censused source family. + #[allow(clippy::too_many_lines)] fn pinned_full_dump_source_census_matches_the_profile() { use std::io::Read; diff --git a/importer/src/eventai_presentation.rs b/importer/src/eventai_presentation.rs index e05ebc459..1b10794ba 100644 --- a/importer/src/eventai_presentation.rs +++ b/importer/src/eventai_presentation.rs @@ -1,5 +1,3 @@ -//! Pinned CMaNGOS presentation-action mapping. - use std::collections::HashSet; use super::eventai::{Dependency, MappingFailure, SourceNormalization}; diff --git a/importer/src/go_model.rs b/importer/src/go_model.rs index e75eb916c..85c2a9fd6 100644 --- a/importer/src/go_model.rs +++ b/importer/src/go_model.rs @@ -1,16 +1,3 @@ -//! `--go-models ` — resolves every DOOR/BUTTON -//! `gameobject_template.display_id` to its M2's bounding mesh and emits one `game_go_model` row -//! per entry. Used TOGETHER with `--dump` (the template rows come from the cmangos dump; the -//! model bytes come from the client's own MPQs). -//! -//! Local-space, not world-space: `nav::m2_tris` already loads the bounding mesh model-local, and -//! the only transform applied here is the MDDF axis shuffle (the same one `vmap.rs::apply_full` -//! pre-applies for placed doodads) — quaternion/scale/translate are per-spawn data the MODULE -//! holds in `game_go_collider`, so baking a world transform here would break for instance copies -//! (no source-guid backref on a `GO_COPY_BAND` row). Same licensing firewall as `--vmap`/`--dbc`: -//! in-memory only, nothing written to disk. A display resolving to a `.wmo` is skipped with a -//! warning — WMO door parsing is out of scope. - use anyhow::{bail, Context, Result}; use lyracore_shared::vmap::{encode, TriClass, VmapTri}; use std::path::Path; @@ -22,10 +9,7 @@ use wow_dbc::Indexable; use crate::nav::Tri; use crate::{field, got, parse_table, GO_BUTTON, GO_DOOR}; -/// MDDF axis shuffle: placement-local (Y-up) → world-axis (Z-up) orientation, applied BEFORE any -/// rotation/scale/translate — same convention `vmap.rs::apply_full` uses for placed doodads. -/// Pre-applying it here means the module's per-spawn transform is pure quaternion+scale+ -/// translate, with no shuffle of its own to get wrong at ray-query time. +// Shuffle Y-up placement coordinates to Z-up world axes before rotation and scale. fn shuffle(v: [f32; 3]) -> [f32; 3] { [-v[2], -v[0], v[1]] } @@ -34,9 +18,6 @@ fn shuffle_tri(t: Tri) -> Tri { [shuffle(t[0]), shuffle(t[1]), shuffle(t[2])] } -/// Local bounding-sphere radius: center of the mesh's AABB, radius = farthest vertex from that -/// center. Not minimal-enclosing, but conservative (contains every vertex) — the cheap -/// segment-reject the ray merge needs before decoding and transforming the full triangle blob. fn bounding_radius(tris: &[Tri]) -> f32 { let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); for t in tris { @@ -61,14 +42,9 @@ fn bounding_radius(tris: &[Tri]) -> f32 { .fold(0.0f32, f32::max) } -/// Hard cap on one `game_go_model` row's packed byte size — door meshes are tens of triangles, so -/// hitting this means something is wrong (a WMO-scale mesh slipping through, a corrupt parse), not -/// a legitimate case to shard like `vmap.rs` does for dense terrain cells. const MAX_ROW_TRI_BYTES: usize = 20_000; -/// Encode a model's shuffled local-space mesh, erroring (NOT sharding) past `MAX_ROW_TRI_BYTES`. -/// `TriClass::M2` is a placeholder the codec requires — the ray merge decides participation per -/// GO row, not per class. +// M2 is a codec placeholder here; each GameObject row decides ray participation. fn encode_capped(entry: u64, model_name: &str, tris: &[Tri]) -> Result> { let packed: Vec = tris .iter() @@ -88,7 +64,6 @@ fn encode_capped(entry: u64, model_name: &str, tris: &[Tri]) -> Result> Ok(blob) } -/// One classified DOOR/BUTTON template row worth resolving a model for. struct DoorTemplate { entry: u64, display_id: u32, @@ -111,7 +86,6 @@ fn door_templates(dump: &str) -> Vec { .collect() } -/// Reducer batch ceiling (bytes of packed payload) — same ballpark the other importers respect. const BATCH_BYTES: usize = 28_000; pub(crate) fn run(args: &crate::Args) -> Result<()> { @@ -191,8 +165,6 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { bail!("go-models: nothing resolved to import — check the warnings above"); } - // Batch + apply (byte-budgeted, same convention as nav.rs/vmap.rs): first batch clears + - // loads, the rest append. let mut batches: Vec = Vec::new(); let mut current = String::new(); for row in &rows { @@ -228,17 +200,12 @@ mod tests { use super::*; fn cube(half: f32, z: f32) -> Vec { - // Two triangles: enough to exercise the AABB/radius math without a real M2. vec![ [[-half, -half, z], [half, -half, z], [-half, half, z]], [[half, -half, z], [half, half, z], [-half, half, z]], ] } - // ------------------------------------------------------------------------------------- - // Classification gating (only DOOR/BUTTON survive `door_templates`). - // ------------------------------------------------------------------------------------- - #[test] fn door_templates_keeps_only_door_and_button_rows() { let dump = "INSERT INTO `gameobject_template` VALUES \ @@ -259,20 +226,13 @@ mod tests { #[test] fn door_templates_drops_a_row_with_no_classification() { - // Real cmangos type 25 (FISHINGHOLE) collides with the synthetic GATHER marker and is - // always dropped by `classify_go_type` — must not appear as a DOOR/BUTTON either. let dump = "INSERT INTO `gameobject_template` VALUES (5,25,50,'Fishing Hole',0,0,0,1.0,0,0);"; assert!(door_templates(dump).is_empty()); } - // ------------------------------------------------------------------------------------- - // Transform pipeline: shuffle, bounding radius, codec round trip. - // ------------------------------------------------------------------------------------- - #[test] fn shuffle_applies_the_mddf_axis_convention() { - // [-v2, -v0, v1] assert_eq!(shuffle([1.0, 2.0, 3.0]), [-3.0, -1.0, 2.0]); assert_eq!(shuffle([0.0, 0.0, 0.0]), [0.0, 0.0, 0.0]); } @@ -281,7 +241,6 @@ mod tests { fn bounding_radius_covers_every_shuffled_vertex() { let tris: Vec = cube(2.0, 5.0).into_iter().map(shuffle_tri).collect(); let radius = bounding_radius(&tris); - // Every shuffled vertex must lie within `radius` of the reported center. let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); for t in &tris { for v in t { @@ -322,13 +281,8 @@ mod tests { } } - // ------------------------------------------------------------------------------------- - // Byte-cap error — oversized rows fail loudly, they are never sharded. - // ------------------------------------------------------------------------------------- - #[test] fn encode_capped_errors_instead_of_sharding_an_oversized_mesh() { - // Each triangle costs lyracore_shared::vmap::TRI_BYTES; pick enough to blow the cap. let per_tri = lyracore_shared::vmap::TRI_BYTES; let n = MAX_ROW_TRI_BYTES / per_tri + 10; let tris: Vec = (0..n).map(|i| cube(1.0, i as f32)[0]).collect(); diff --git a/importer/src/instance_vmap.rs b/importer/src/instance_vmap.rs index 7e17a6fd8..15c10eeac 100644 --- a/importer/src/instance_vmap.rs +++ b/importer/src/instance_vmap.rs @@ -1,9 +1,3 @@ -//! Instance-specific checks for WMO placements read from selected ADT tiles. -//! -//! The shared tile scanner and vmap packer own archive parsing, transforms, and collision output. -//! This module keeps the Instance Pool's stricter rule: a selected WMO group may not depend on an -//! active nested doodad until the importer can transform that doodad into the same generation. - use anyhow::{bail, Context, Result}; use std::collections::BTreeSet; use std::io::Cursor; @@ -128,8 +122,7 @@ fn selected_doodad_range( selected_set: u16, name: &str, ) -> Result<(u32, u32, usize)> { - // Some Classic roots overstate the MOHD definition count. The MODD chunk contains the - // definitions that can be selected; MOHD must still cover that complete parsed prefix. + // Classic MOHD can overstate the doodad count; it must still cover the parsed MODD prefix. if root.n_doodad_sets as usize != root.doodad_sets.len() || (root.n_doodad_defs as usize) < root.doodad_defs.len() { diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index 7b51854e6..8fc9d53e5 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -1,5 +1,3 @@ -//! Derive Random Property catalogues from the Operator's client and ClassicDB dump. - use crate::spell_dbc::{read_spells, Spell}; use crate::{dbc, field, parse_table, push_insert, sql_text}; use anyhow::{bail, Context, Result}; @@ -28,7 +26,6 @@ fn resistance_kind(school: u32) -> Option { } } -/// Flat equip auras only. Conditional, scaling and percentage modifiers retain kind 0. fn aura_kinds(aura: u32, misc: u32) -> Vec<(u8, u32)> { match (aura, misc) { (29, u32::MAX) => (kind::STRENGTH..=kind::SPIRIT) @@ -284,7 +281,6 @@ fn insert_effect(rows: &mut BTreeMap, enchant_id: u32, effect: Effe Ok(()) } -/// ClassicDB chance has at most two decimal places. Keep hundredths exactly; reject extra precision. fn decimal_weight(value: &str) -> Result { let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); if whole.is_empty() @@ -388,7 +384,6 @@ mod tests { bytes.extend_from_slice(&value.to_le_bytes()); } bytes.resize(713, 0); - // Build 5875 columns, including the inventory mask immediately before Effect[0]. for (column, value) in [ (0, 509_0003), (58, u32::MAX), @@ -445,7 +440,6 @@ mod tests { assert!(read_spells(&bytes).is_err()); } - // Authored zero records let tests exercise the real DBC parser without client bytes. fn synthetic_table(fields: u32) -> T { let mut bytes = b"WDBC".to_vec(); for value in [1, fields, fields * 4, 1] { diff --git a/importer/src/m2_collision.rs b/importer/src/m2_collision.rs index d535e0a0d..0ef493aa1 100644 --- a/importer/src/m2_collision.rs +++ b/importer/src/m2_collision.rs @@ -1,5 +1,3 @@ -//! M2 collision geometry, independent of rendering and animation records. - use anyhow::{bail, Context, Result}; use std::io::Cursor; diff --git a/importer/src/main.rs b/importer/src/main.rs index 84be3c571..cdf973b61 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -1,56 +1,3 @@ -//! Vanilla-data importer. Reads the operator's OWN inputs — a cmangos `classic-db` dump (never -//! executed) and/or their own legally-owned 1.12.1 client `Data/` archives — maps them to our -//! `game_*` schema, and loads via SQL clear+reload (see docs/data-ingestion.md for the strategy -//! and the licensing firewall this implements: we ship the tools, the operator supplies the data, -//! neither ships in the repo). The live `tick_creatures` respawn pass turns imported spawns into -//! world entities, so we never hand-encode the entity row. -//! -//! One binary, six modes (pick by flag; the ones that write honor --db/--server/--apply, -//! defaulting to a dry run that prints the plan and writes nothing): -//! --dump cmangos creature/etc ETL -//! [--world-profile alliance-eastern|alliance-kalimdor| -//! alliance-single|starting-eastern|starting-kalimdor|instances| -//! full-world|full-eastern|full-kalimdor|full-instances] -//! [--eventai-profile cmangos-classic-z2815] -//! [advanced: --map 0 --center X,Y,Z --radius 180] -//! [--print-extents derives --box from the dump's real spawn -//! extents; always a dry run] -//! --dbc client DBC extraction/checks (dbc.rs); add --spells for the -//! Spell.dbc importer (spell.rs) or --talents for the -//! TalentTab.dbc/Talent.dbc importer (talent.rs) -//! Standalone catalogue import needs --taxi-mounts . -//! Required with --dump when items reference Random Properties. -//! [--packages reapplies every enabled -//! Package's Delta claims after the base spell rows are back; -//! without --apply it prints the plan and writes nothing — -//! package_delta.rs] -//! --spell-snapshot with --dbc: write the Base Snapshot a Datascript reads (the -//! same derived spell rows the import would load) and touch no -//! database — spell_snapshot.rs -//! --terrain ADT heightmap stream (terrain.rs) -//! --nav WMO/M2 nav-grid rasterizer (nav.rs) -//! --vmap exact bounded collision-triangle extract + pack + import -//! (--apply loads `game_vmap_chunk` via import_vmap_chunks; a -//! dry run stops at report, vmap.rs) -//! --vmap-prepare-coverage derive path-grid coverage for an already-staged vmap -//! generation (--apply calls prepare_/finalize_vmap_nav_coverage, -//! resumable; a dry run reports the plan) — vmap.rs::run_coverage -//! --go-models DOOR/BUTTON display → M2 bounding-mesh extract + import, -//! needs --dump too (--apply loads `game_go_model` via -//! import_go_models; a dry run stops at report — go_model.rs) -//! --dump-collision WMO/M2 collision-geometry inspector — dry-run only, -//! no --apply (collision.rs) -//! --pack-client builds the client patch MPQ + installs addons into the -//! OPERATOR's OWN client (pack_client.rs); standalone, writes -//! to the client dir, not a `game_*` table -//! --pack-out writes the same package-authored client content into -//! for distribution. Reads no client, refuses baseline-derived -//! files, takes no --apply (pack_client.rs) -//! e.g. importer --dump .import/classic-db-full.sql --apply -//! importer --dbc ../wowclient/Data --taxi-mounts .import/classic-db-full.sql -//! importer --dbc ../wowclient/Data --talents --apply -//! importer --dump .import/classic-db-full.sql --map 0 --print-extents - mod collision; mod creature_population; mod dbc; @@ -83,14 +30,10 @@ use anyhow::{bail, Context, Result}; use flate2::read::GzDecoder; use world_import_scope::{WorldImportProfile, WorldImportScope}; -// Kept in step with `module::items::tables`: the importer cannot depend on the wasm Module, but -// it owns the ClassicDB `-1` sentinel conversion that makes these values durable. const ALL_PLAYABLE_CLASS_MASK: u32 = 0x5df; const ALL_PLAYABLE_RACE_MASK: u32 = 0xff; -// --- cmangos column indices (verified against ClassicDB_1_12_1_z2815 schema) ------------------- mod ct { - // creature_template pub const ENTRY: usize = 0; pub const NAME: usize = 1; pub const SUBNAME: usize = 2; @@ -107,49 +50,36 @@ mod ct { pub const RANK: usize = 33; pub const MIN_LEVEL_HEALTH: usize = 40; pub const MAX_LEVEL_HEALTH: usize = 41; - pub const MIN_MELEE_DMG: usize = 44; // float; per-creature swing damage + pub const MIN_MELEE_DMG: usize = 44; pub const MAX_MELEE_DMG: usize = 45; - pub const ARMOR: usize = 48; // → game_creature_template.armor (0 = unmitigated, module fallback) + pub const ARMOR: usize = 48; pub const MELEE_BASE_ATTACK_TIME: usize = 51; pub const MIN_LOOT_GOLD: usize = 54; pub const MAX_LOOT_GOLD: usize = 55; - pub const LOOT_ID: usize = 56; // → creature_loot_template.entry (0 = no loot table) - // Loot-family completeness: PickpocketLootId/SkinLootId sit IMMEDIATELY after LootId - // (LootId, PickpocketLootId, SkinningLootId are consecutive columns). These two carried a `[V]` - // "confirm against your own dump" caveat until the pinned dump's DDL was enumerated directly — - // both are now confirmed, along with every other const here. - pub const PICKPOCKET_LOOT_ID: usize = 57; // → pickpocketing_loot_template.entry (0 = no table) - pub const SKIN_LOOT_ID: usize = 58; // → skinning_loot_template.entry (0 = no table) - // Trainer service block: TrainerType, TrainerSpell, TrainerClass, TrainerRace, TrainerTemplateId - // run 71..75, bracketed by the verified `SKIN_LOOT_ID` and `GOSSIP_MENU_ID` anchors. Enumerated - // from the pinned dump's own CREATE TABLE — mangos-classic master has since gained - // DisplayIdProbability1-4 and five stat multipliers, so its order is 4 columns out here and 9 at - // `LootId`. TrainerSpell/TrainerRace/TrainerTemplateId are not ingested: no consumer. - pub const TRAINER_TYPE: usize = 71; // cmangos enum: CLASS 0 · MOUNTS 1 · TRADESKILLS 2 · PETS 3 - pub const TRAINER_CLASS: usize = 73; // class ID (1..11), NOT a mask; 0 = serves every class - pub const GOSSIP_MENU_ID: usize = 77; // creature_template.GossipMenuId + pub const LOOT_ID: usize = 56; + pub const PICKPOCKET_LOOT_ID: usize = 57; + pub const SKIN_LOOT_ID: usize = 58; + pub const TRAINER_TYPE: usize = 71; + pub const TRAINER_CLASS: usize = 73; // Class id, not a mask; zero serves every class. + pub const GOSSIP_MENU_ID: usize = 77; } mod gm { - // gossip_menu: entry(=creature_template.GossipMenuId), text_id, script_id, condition_id pub const ENTRY: usize = 0; pub const TEXT_ID: usize = 1; pub const CONDITION_ID: usize = 3; } mod npct { - // npc_text (full 81-col format): ID, text0_0, text0_1, lang0, prob0, em0_0..5, text1_0, ... + // Legacy npc_text layout: ID plus eight 10-column male/female/language/probability/emote slots. pub const ID: usize = 0; - pub const NCOLS_FULL: usize = 81; // 1 + 8*(2+1+1+6) = 81 (old schema with embedded strings) - // New cmangos schema packs each slot as a single BroadcastTextId. 17 cols: ID + 8 probs + 8 ids. - pub const NCOLS_SHORT: usize = 17; // ID, Prob0..7, BroadcastTextId0..7 - /// Full-schema per-slot column formula (all 8 slots not just the first): each slot - /// occupies 10 columns starting at `1 + slot*10` — male, female, lang, prob, em0..em5. + pub const NCOLS_FULL: usize = 81; + // BroadcastText npc_text layout: ID, eight probabilities, eight BroadcastText ids. + pub const NCOLS_SHORT: usize = 17; pub const fn full_slot_base(slot: usize) -> usize { 1 + slot * 10 } pub const FULL_SLOT_MALE_OFFSET: usize = 0; pub const FULL_SLOT_FEMALE_OFFSET: usize = 1; pub const FULL_SLOT_PROB_OFFSET: usize = 3; - /// Short-schema per-slot column formula: prob at `1+slot`, BroadcastTextId at `9+slot`. pub const fn short_slot_prob(slot: usize) -> usize { 1 + slot } @@ -158,31 +88,23 @@ mod npct { } } pub(crate) mod bt { - // broadcast_text: Id, Text (male), Text1 (female), ChatTypeID, LanguageID, ... pub const ID: usize = 0; - pub const TEXT: usize = 1; // male greeting text - pub const TEXT1: usize = 2; // female greeting text + pub const TEXT: usize = 1; + pub const TEXT1: usize = 2; pub const CHAT_TYPE: usize = 3; pub const LANGUAGE: usize = 4; } mod gmo { - // gossip_menu_option — DUMP-VERIFIED 2026-07-10 (the [V] guess had OPTION_TYPE at 4, which is - // option_broadcast_text in this dump — every imported option's `action` was a broadcast_text id - // like 5299, so the gateway's TRAINER/VENDOR action match never fired and selecting "train me" - // just closed the window; live find). Real 15-col layout, read off the dump's CREATE TABLE: - // menu_id, id, option_icon, option_text, option_broadcast_text, option_id, - // npc_option_npcflag, action_menu_id, action_poi_id, action_script_id, - // box_coded, box_money, box_text, box_broadcast_text, condition_id pub const MENU_ID: usize = 0; - pub const ID: usize = 1; // dense ordinal within the menu (cmangos row order — importer re-indexes) + pub const ID: usize = 1; pub const OPTION_ICON: usize = 2; - pub const OPTION_TEXT: usize = 3; // literal text, OR (new-schema dumps) a broadcast_text id — both tried - pub const OPTION_TYPE: usize = 5; // option_id/action — see `lyracore_shared::constants::gossip_option` + pub const OPTION_TEXT: usize = 3; + pub const OPTION_TYPE: usize = 5; pub const ACTION_MENU_ID: usize = 7; - /// THIS dump's vintage: 15 cols, trailing generic-`conditions` FK id. + // The 15-column layout ends with a conditions foreign key. pub const NCOLS_15_CONDITION_FK: usize = 15; pub const CONDITION_ID_15: usize = 14; - /// Legacy vintage fallbacks (other dumps): trailing FK at 10 (11 cols) / inline cond at 11..12 (14 cols). + // Legacy layouts use a foreign key at column 10 or inline conditions at columns 11..13. pub const NCOLS_WITH_CONDITION_FK: usize = 11; pub const CONDITION_ID: usize = 10; pub const NCOLS_WITH_INLINE_COND: usize = 14; @@ -190,62 +112,40 @@ mod gmo { pub const INLINE_COND_VALUE1: usize = 12; } mod cond { - // conditions (mangos/cmangos generic condition table): condition_entry, type, value1, value2. - // [V] confirm — mangos `ConditionType` (subset this importer maps to the minimal 217 condition - // set): 8 = CONDITION_QUESTREWARDED, 9 = CONDITION_QUESTTAKEN. Anything else fails open - // (`lyracore_shared::constants::gossip_condition::NONE`) rather than silently hiding an option. pub const ENTRY: usize = 0; pub const TYPE: usize = 1; pub const VALUE1: usize = 2; pub const MANGOS_QUESTREWARDED: u64 = 8; pub const MANGOS_QUESTTAKEN: u64 = 9; - /// `CONDITION_ACTIVE_GAME_EVENT` [V]. No game-event calendar exists here, so this gate must fail - /// CLOSED — fail-open pitches Children's Week in July. pub const MANGOS_ACTIVE_GAME_EVENT: u64 = 12; } mod cts { - // creature_template_spells — ClassicDB_1_12_1_z2815: entry, setId, spell1..spell10 - // (PK (entry,setId)). The modern cmangos dump moved the caster-mob rotation OFF creature_template's - // old spell1-4 columns into this table; spell1 is the primary nuke for every Elwynn caster - // (476 Kobold Geomancer → 133 Fireball; 474 Defias Rogue Wizard → 13322 Frostbolt). pub const ENTRY: usize = 0; pub const SET_ID: usize = 1; pub const SPELL1: usize = 2; } mod cr { - // creature (spawn) — ClassicDB_1_12_1_z2815: guid,id,map,spawnMask,position_x,position_y, - // position_z,orientation,spawntimesecsmin,spawntimesecsmax,spawndist,MovementType... pub const GUID: usize = 0; - pub const ID: usize = 1; // == creature_template.Entry + pub const ID: usize = 1; pub const MAP: usize = 2; pub const X: usize = 4; pub const Y: usize = 5; pub const Z: usize = 6; pub const O: usize = 7; pub const SPAWN_TIME_SECS_MIN: usize = 8; - pub const MOVEMENT_TYPE: usize = 11; // 0 idle / 1 random-wander / 2 waypoint + pub const MOVEMENT_TYPE: usize = 11; } mod cm { - // creature_movement - pub const ID: usize = 0; // == creature.guid + pub const ID: usize = 0; pub const X: usize = 2; pub const Y: usize = 3; pub const Z: usize = 4; } mod cmt { - // cmangos `creature_movement_template` — the ENTRY-keyed twin of `creature_movement`'s - // guid-keyed paths: Deadmines patrols whose MovementType=2 spawns carry no - // per-guid rows. [V] BOTH vintages below are cmangos-1.12-from-knowledge, NOT verified against - // an in-repo dump (none in this sandbox), and no neighbor-anchor is possible (first import from - // this table). Vintage is sniffed by COLUMN COUNT, the `npct`/`gmo` precedent: - // >= 9 cols: Entry, PathId, Point, PositionX, PositionY, PositionZ, Orientation, WaitTime, ScriptId - // < 9 cols: Entry, Point, PositionX, PositionY, PositionZ, Orientation, WaitTime, ScriptId - // Only PathId 0 (the default path) imports — the module's waypoint cursor models one route per - // creature. Confirm via `SHOW CREATE TABLE creature_movement_template` on your real dump. + // Template paths are keyed by entry; direct creature_movement paths are keyed by spawn guid. pub const ENTRY: usize = 0; pub const NCOLS_WITH_PATH_ID: usize = 9; - pub const PATH_ID: usize = 1; // only in the >= 9-col vintage - /// (point, x, y, z) column indices for a row of `ncols` — the vintage sniff. + pub const PATH_ID: usize = 1; pub const fn pos_indices(ncols: usize) -> (usize, usize, usize, usize) { if ncols >= NCOLS_WITH_PATH_ID { (2, 3, 4, 5) @@ -255,14 +155,12 @@ mod cmt { } } mod cls { - // player_classlevelstats: (class, level, basehp, basemana), PK (class, level) pub const CLASS: usize = 0; pub const LEVEL: usize = 1; pub const BASEHP: usize = 2; pub const BASEMANA: usize = 3; } mod lvl { - // player_levelstats: (race, class, level, str, agi, sta, inte, spi), PK (race, class, level) pub const RACE: usize = 0; pub const CLASS: usize = 1; pub const LEVEL: usize = 2; @@ -273,7 +171,6 @@ mod lvl { pub const SPI: usize = 7; } mod pc { - // playercreateinfo: (race, class, map, zone, x, y, z, orientation), PK (race, class) pub const RACE: usize = 0; pub const CLASS: usize = 1; pub const MAP: usize = 2; @@ -284,53 +181,30 @@ mod pc { pub const O: usize = 7; } mod pcs { - // playercreateinfo_spell — cmangos/mangos-classic 1.12 schema: race, class, Spell(, Note). [V] — - // anchored against the well-known cmangos community schema (race TINYINT, class TINYINT, Spell - // MEDIUMINT[, Note TEXT]), NOT verified against an in-repo dump (this sandbox has none). CONFIRM - // these column indices against your own `classic-db` dump's `playercreateinfo_spell` before - // trusting the import — a wrong column here silently mis-teaches spells to the wrong race/class. - pub const RACE: usize = 0; // [V] - pub const CLASS: usize = 1; // [V] - pub const SPELL: usize = 2; // [V] + pub const RACE: usize = 0; + pub const CLASS: usize = 1; + pub const SPELL: usize = 2; } mod pca { - // playercreateinfo_action — cmangos/mangos-classic 1.12 schema: race, class, button, action, type. - // [V] — same community-schema provenance/caveat as `pcs` above; CONFIRM against your own dump. - pub const RACE: usize = 0; // [V] - pub const CLASS: usize = 1; // [V] - pub const BUTTON: usize = 2; // [V] - pub const ACTION: usize = 3; // [V] - pub const TYPE: usize = 4; // [V] + pub const RACE: usize = 0; + pub const CLASS: usize = 1; + pub const BUTTON: usize = 2; + pub const ACTION: usize = 3; + pub const TYPE: usize = 4; } mod pci { - // playercreateinfo_item — cmangos/mangos-classic 1.12 schema: race, class, itemid, amount. [V] — - // same community-schema provenance/caveat as `pcs` above; CONFIRM against your own dump. `amount` - // is parsed but currently unused (game_start_item carries no stack-count column — every starter - // item grants as a single stack, matching the existing CharStartOutfit-sourced rows). - pub const RACE: usize = 0; // [V] - pub const CLASS: usize = 1; // [V] - pub const ITEM: usize = 2; // [V] + pub const RACE: usize = 0; + pub const CLASS: usize = 1; + pub const ITEM: usize = 2; #[allow(dead_code)] - pub const AMOUNT: usize = 3; // [V] — unused (see above) + pub const AMOUNT: usize = 3; } mod gz { - // game_graveyard_zone: id(=world_safe_locs id / game_graveyard.id), ghost_zone, faction - // (0 = both factions; else the cmangos team-faction id — see module/src/world.rs - // graveyard::team_for_race). PK (id, ghost_zone) in the cmangos schema (a graveyard can serve - // more than one zone) — we assign our own sequential row_id (build_graveyard_zone_sql), like - // start_item_sql's explicit-but-sequential id convention. pub const ID: usize = 0; pub const GHOST_ZONE: usize = 1; pub const FACTION: usize = 2; } mod at { - // cmangos `areatrigger_teleport`, VERIFIED against the real dump 2026-07-10. The - // original [V] guess was off by FOUR columns and every imported portal row was garbage (the - // Deadmines "no entrance" gap). Real row shape, read off the dump's own INSERT: - // (id, name, required_level, req_item, req_quest?, req_?, target_map, x, y, z, o, message, ?) - // e.g. (78,'Deadmines - Entering',10,0,0,0,36,-14.5732,-385.475,62.4561,1.5708,'You must be at - // least level 10 to enter.',0). required_level(2) is not yet consumed (portal level gates are a - // future item); the three condition columns are skipped. pub const ID: usize = 0; pub const NAME: usize = 1; pub const TARGET_MAP: usize = 6; @@ -340,54 +214,33 @@ mod at { pub const TARGET_O: usize = 10; } mod it { - // item_template — ClassicDB_1_12_1_z2815. Only the columns game_item_template uses. pub const ENTRY: usize = 0; pub const CLASS: usize = 1; pub const SUBCLASS: usize = 2; pub const NAME: usize = 3; pub const DISPLAY_ID: usize = 4; pub const QUALITY: usize = 5; - // Flags: the raw item_template.Flags bitmask (unique/conjured/etc, ItemFlag on - // the wire). Sits right after Quality(5), before BuyCount(7)/BuyPrice(8) — anchored by the - // already-verified BUY_PRICE=8 two columns later (Quality(5), Flags(6), BuyCount(7), BuyPrice(8) - // is the standard cmangos 1.12 item_template column run). pub const FLAGS: usize = 6; - // BuyCount: the per-purchase stack a vendor hands over (water/food ×5, ammo ×200) — - // anchored between the verified Flags(6) and BUY_PRICE(8). pub const BUY_COUNT: usize = 7; pub const BUY_PRICE: usize = 8; pub const SELL_PRICE: usize = 9; pub const INVENTORY_TYPE: usize = 10; - // ClassicDB_1_12_1_z2815 follows the canonical cmangos run: InventoryType(10), - // AllowableClass(11), AllowableRace(12), ItemLevel(13). `-1` is the dump's unrestricted - // sentinel; restrictive masks are unsigned bits and must stay byte-for-byte intact. + // ClassicDB uses -1 for an unrestricted allowable-class or allowable-race mask. pub const ALLOWED_CLASS: usize = 11; pub const ALLOWED_RACE: usize = 12; - // RequiredSkill/RequiredSkillRank: weapon-skill / mail-plate proficiency gate. - // Anchored tightly between the already-verified REQUIRED_LEVEL(14) and STACKABLE(23): 14 - // RequiredLevel, 15 RequiredSkill, 16 RequiredSkillRank, 17 RequiredSpell, 18 RequiredHonorRank, - // 19 RequiredCityRank, 20 RequiredReputationFaction, 21 RequiredReputationRank, 22 maxcount, - // 23 Stackable(confirmed) — the run lands exactly on the existing STACKABLE=23 anchor. pub const REQUIRED_SKILL: usize = 15; pub const REQUIRED_SKILL_RANK: usize = 16; - // RequiredReputationFaction/Rank: the item's reputation gate. Same anchored run as above. pub const REQUIRED_REPUTATION_FACTION: usize = 20; pub const REQUIRED_REPUTATION_RANK: usize = 21; - // maxcount: unique-item stack cap. Same anchored run, one column before the - // already-verified STACKABLE=23. pub const MAX_COUNT: usize = 22; pub const ITEM_LEVEL: usize = 13; pub const REQUIRED_LEVEL: usize = 14; pub const STACKABLE: usize = 23; - pub const CONTAINER_SLOTS: usize = 24; // how many slots this bag provides (0 for non-bags) - pub const STAT_TYPE1: usize = 25; // 10 pairs: type{i}=25+2(i-1), value{i}=26+2(i-1) + pub const CONTAINER_SLOTS: usize = 24; + pub const STAT_TYPE1: usize = 25; pub const DMG_MIN1: usize = 45; pub const DMG_MAX1: usize = 46; pub const ARMOR: usize = 60; - // The 6 resistance schools, cmangos lays them out immediately after Armor(60) - // and immediately before the already-verified DELAY=67: 60 Armor, 61 HolyRes, 62 FireRes, - // 63 NatureRes, 64 FrostRes, 65 ShadowRes, 66 ArcaneRes, 67 Delay(confirmed) — the run lands - // exactly on the existing DELAY anchor. pub const HOLY_RES: usize = 61; pub const FIRE_RES: usize = 62; pub const NATURE_RES: usize = 63; @@ -395,16 +248,10 @@ mod it { pub const SHADOW_RES: usize = 65; pub const ARCANE_RES: usize = 66; pub const DELAY: usize = 67; - // On-use/on-equip spell slots (7 cols each: spellid, spelltrigger, charges, ppmRate, cooldown, - // category, categorycooldown). We import slots 1-2's id+trigger for the client green "Use:" text. pub const SPELLID_1: usize = 70; pub const SPELLTRIGGER_1: usize = 71; pub const SPELLID_2: usize = 77; pub const SPELLTRIGGER_2: usize = 78; - // Spell slots 3-5: each slot is 7 columns wide, same shape as slots 1-2 above. - // Slot 3 starts right after slot 2's 7 columns (77..=83), slot 4 after slot 3's (84..=90), slot 5 - // after slot 4's (91..=97) — and slot 5's own 7 columns (98..=104) land exactly on the - // already-verified BONDING=105 anchor one column later, confirming all three slots. pub const SPELLID_3: usize = 84; pub const SPELLTRIGGER_3: usize = 85; pub const SPELLID_4: usize = 91; @@ -412,116 +259,78 @@ mod it { pub const SPELLID_5: usize = 98; pub const SPELLTRIGGER_5: usize = 99; pub const BONDING: usize = 105; - // description(106), PageText(107), LanguageID(108), PageMaterial(109), startquest(110), - // lockid(111), Material(112) sit between BONDING(105, confirmed) and the already-verified - // SHEATH=113 below, PAGE_TEXT and START_QUEST land on that same anchored run. pub const PAGE_TEXT: usize = 107; pub const START_QUEST: usize = 110; - pub const SHEATH: usize = 113; // sheath posture, sent verbatim to the client (0=no stow, 1H sword=3, - // shield=4 — opaque client indices, see items/tables.rs::sheath) + pub const SHEATH: usize = 113; pub const RANDOM_PROPERTY: usize = 114; - pub const BLOCK: usize = 115; // shield block value (CREATE TABLE: …RandomProperty(114), block(115), itemset(116)) + pub const BLOCK: usize = 115; pub const MAX_DURABILITY: usize = 117; - // BagFamily: bag-type restriction bitmask. Anchored right after the - // already-verified MAX_DURABILITY=117: 117 MaxDurability(confirmed), 118 area, 119 Map, - // 120 BagFamily. pub const BAG_FAMILY: usize = 120; - // FoodType follows ScriptName(121) and DisenchantID(122) in the current ClassicDB schema. - // Values 1..=8 map to CreatureFamily.pet_food_mask bits; 0 is not pet food. pub const FOOD_TYPE: usize = 123; } mod clt { - // creature_loot_template: entry(=creature_template.LootId), item, ChanceOrQuestChance, groupid, - // mincountOrRef, maxcount, condition_id, comments. pub const ENTRY: usize = 0; pub const ITEM: usize = 1; - pub const CHANCE: usize = 2; // >0 = drop %, <0 = quest item (skip — no quest system yet) - pub const GROUPID: usize = 3; // 0 = independent; >0 = pick-one group within this loot table - pub const MINCOUNT_OR_REF: usize = 4; // <0 = reference to a shared pool (expanded as a group) + pub const CHANCE: usize = 2; // Negative ChanceOrQuestChance marks a quest-only drop. + pub const GROUPID: usize = 3; // 0 = independent; >0 = pick one. + pub const MINCOUNT_OR_REF: usize = 4; // Negative mincountOrRef refers to a shared loot pool. pub const MAXCOUNT: usize = 5; } mod nv { - // npc_vendor: entry(=creature_template.entry), item, maxcount, incrtime, slot, condition_id, comments. pub const ENTRY: usize = 0; pub const ITEM: usize = 1; pub const MAXCOUNT: usize = 2; pub const SLOT: usize = 4; } mod qt { - // quest_template — only the columns game_quest_template + objectives/rewards need (verified against - // the ClassicDB CREATE TABLE). ReqCreatureOrGOId/Count + RewItemId/Count are 4 contiguous slots each. pub const ENTRY: usize = 0; - pub const ZONE_OR_SORT: usize = 2; // quest-log category: >0 AreaTable zone, <0 QuestSort id + pub const ZONE_OR_SORT: usize = 2; pub const MIN_LEVEL: usize = 3; pub const QUEST_LEVEL: usize = 5; - // Type (cm:ObjectMgr.cpp:4239, cm:QuestDef.cpp:31): 62 is QUEST_TYPE_RAID, everything else an - // ordinary quest. Anchored between the already-trusted QUEST_LEVEL and REQUIRED_CLASSES. pub const TYPE: usize = 6; - pub const REQUIRED_CLASSES: usize = 7; // bitmask, 0 = all classes - pub const REQUIRED_RACES: usize = 8; // bitmask, 0 = all races - // [V] LimitTime (seconds from accept, 0 = untimed) — unverified column index (no cmangos dump in - // this sandbox to confirm against). Anchored by bracketing against the two already-trusted - // constants either side: cmangos's real `quest_template` layout runs - // exactly 2 slots before the already-verified SPECIAL_FLAGS=21 (QuestFlags at 20 between them). - // Coverage-printed at import time (timed-quest count) rather than hard floor-gated — plausible - // this Elwynn/Westfall slice has ZERO genuinely timed quests (escort quests, the usual timed - // pairing, are deliberately out of scope), so a floor here would risk a false - // failure on a legitimate import; the coverage print is the fail-LOUD net instead. + pub const REQUIRED_CLASSES: usize = 7; + pub const REQUIRED_RACES: usize = 8; + // LimitTime is seconds after acceptance; zero means untimed. pub const LIMIT_TIME: usize = 19; - pub const SPECIAL_FLAGS: usize = 21; // bit0 = QUEST_SPECIAL_FLAGS_REPEATABLE - pub const PREV_QUEST_ID: usize = 22; // prerequisite quest (must be turned in first); 0 = none - // ClassicDB names columns 23 through 26 NextQuestId, ExclusiveGroup, BreadcrumbForQuestId, - // and NextQuestInChain. The forward chain is column 26. Quest 783 carries 0 at column 25 and 7 - // at column 26, so using the breadcrumb column silently loses its link to quest 7. + pub const SPECIAL_FLAGS: usize = 21; + pub const PREV_QUEST_ID: usize = 22; pub const NEXT_QUEST_IN_CHAIN: usize = 26; - pub const SRC_ITEM_ID: usize = 27; // SrcItemId — item HANDED to the player on accept (provided item) - pub const SRC_ITEM_COUNT: usize = 28; // SrcItemCount (0 in the dump means 1) + pub const SRC_ITEM_ID: usize = 27; + pub const SRC_ITEM_COUNT: usize = 28; // SrcItemCount zero means one. pub const TITLE: usize = 30; - pub const DETAILS: usize = 31; // quest description / story text - pub const OBJECTIVES: usize = 32; // objective summary line(s) - pub const OFFER_REWARD_TEXT: usize = 33; // text on the turn-in reward screen - pub const REQUEST_ITEMS_TEXT: usize = 34; // text on the "not finished yet" screen - pub const REQ_ITEM_ID1: usize = 40; // ..=43; collect-objective item entries - pub const REQ_ITEM_COUNT1: usize = 44; // ..=47 - pub const REQ_CREATURE_OR_GO_ID1: usize = 56; // ..=59; >0 = creature entry, <0 = gameobject (skip) - pub const REQ_CREATURE_OR_GO_COUNT1: usize = 60; // ..=63 - pub const REQ_SPELL_CAST1: usize = 64; // ..=67; 0 = ordinary kill/use objective - pub const REW_CHOICE_ITEM_ID1: usize = 68; // ..=73 RewChoiceItemId1-6 (pick-1-of-N choice rewards) - pub const REW_CHOICE_ITEM_COUNT1: usize = 74; // ..=79 RewChoiceItemCount1-6 - pub const REW_ITEM_ID1: usize = 80; // ..=83 (guaranteed reward items) - pub const REW_ITEM_COUNT1: usize = 84; // ..=87 - pub const REW_REP_FACTION1: usize = 88; // ..=92 RewRepFaction1..5 (we model 2 slots; +1 = faction2) - pub const REW_REP_VALUE1: usize = 93; // ..=97 RewRepValue1..5 (parallel to the faction cols; +1 = value2) - pub const REW_OR_REQ_MONEY: usize = 98; // >0 = reward copper, <0 = required copper (clamp ≥ 0) - pub const REW_SPELL: usize = 100; // reward display, also the cast when RewSpellCast is zero - pub const REW_SPELL_CAST: usize = 101; // overrides the reward cast - pub const REW_MONEY_MAX_LEVEL: usize = 99; // cmangos RewMoneyMaxLevel (col 99, right after 98); /0.6 = authentic quest XP - - // Reward-mail (T3): the letter a quest giver sends at turn-in. Column positions verified - // against the pinned dump's own `CREATE TABLE quest_template` — RewMailTemplateId sits at 102, - // RewMailDelaySecs at 103, immediately after RewSpellCast(101). 74 of 4,245 quest_template rows - // in the pinned dump carry a non-zero template. + pub const DETAILS: usize = 31; + pub const OBJECTIVES: usize = 32; + pub const OFFER_REWARD_TEXT: usize = 33; + pub const REQUEST_ITEMS_TEXT: usize = 34; + pub const REQ_ITEM_ID1: usize = 40; + pub const REQ_ITEM_COUNT1: usize = 44; + pub const REQ_CREATURE_OR_GO_ID1: usize = 56; + pub const REQ_CREATURE_OR_GO_COUNT1: usize = 60; + pub const REQ_SPELL_CAST1: usize = 64; + pub const REW_CHOICE_ITEM_ID1: usize = 68; + pub const REW_CHOICE_ITEM_COUNT1: usize = 74; + pub const REW_ITEM_ID1: usize = 80; + pub const REW_ITEM_COUNT1: usize = 84; + pub const REW_REP_FACTION1: usize = 88; + pub const REW_REP_VALUE1: usize = 93; + pub const REW_OR_REQ_MONEY: usize = 98; + pub const REW_SPELL: usize = 100; + pub const REW_SPELL_CAST: usize = 101; + pub const REW_MONEY_MAX_LEVEL: usize = 99; + pub const REW_MAIL_TEMPLATE_ID: usize = 102; pub const REW_MAIL_DELAY_SECS: usize = 103; } mod dse { - // dbscripts_on_quest_end — verified against the pinned dump's own `CREATE TABLE`: id, delay, - // priority, command, datalong, datalong2, datalong3, buddy_entry, search_radius, data_flags, - // dataint, .... SCRIPT_COMMAND_SEND_MAIL (cm:ScriptMgr.cpp:2850-2866) is the only command this - // importer reads from this table. - pub const ID: usize = 0; // quest_template.entry the script fires on turn-in + pub const ID: usize = 0; pub const COMMAND: usize = 3; - pub const DATALONG: usize = 4; // mail template id - pub const DATALONG2: usize = 5; // alternative sender creature entry (0 = the turn-in giver) - pub const DATALONG3: usize = 6; // money, in copper - pub const DATAINT: usize = 10; // delay, in seconds - pub const SEND_MAIL: u32 = 38; // SCRIPT_COMMAND_SEND_MAIL + pub const DATALONG: usize = 4; + pub const DATALONG2: usize = 5; + pub const DATALONG3: usize = 6; // Copper. + pub const DATAINT: usize = 10; // Seconds. + pub const SEND_MAIL: u32 = 38; } mod mlt { - // mail_loot_template — verified against the pinned dump's own `CREATE TABLE`: entry (the mail - // template id), item, ChanceOrQuestChance, groupid, mincountOrRef, maxcount, condition_id, - // comments. Every real row is chance 100, group 0, count 1, no condition (a mail carries at - // most one item, cm:Mail.h:49). pub const ENTRY: usize = 0; pub const ITEM: usize = 1; pub const CHANCE: usize = 2; @@ -531,57 +340,34 @@ mod mlt { pub const CONDITION_ID: usize = 6; } mod qr { - // creature_questrelation (START) AND creature_involvedrelation (END) share this 2-col layout — - // as do their GAMEOBJECT twins gameobject_questrelation (START) / gameobject_involvedrelation - // (END): id = gameobject_template.entry, quest = quest_template.entry. - pub const CREATURE: usize = 0; // id = creature_template.entry (or gameobject_template.entry for the GO tables) + pub const CREATURE: usize = 0; pub const QUEST: usize = 1; } const HIGHGUID_UNIT: u64 = 0xF130; -/// Our deterministic creature guid from the cmangos entry + spawn guid (HIGHGUID_UNIT-encoded, like -/// the seed). Stable across re-imports → clear+reload is idempotent. (entry<<24)|(low 24 of db guid). fn world_guid(entry: u64, db_guid: u64) -> u64 { (HIGHGUID_UNIT << 48) | ((entry & 0xFF_FFFF) << 24) | (db_guid & 0xFF_FFFF) } const HIGHGUID_GAMEOBJECT: u64 = 0xF110; -/// GO guid from the cmangos gameobject.guid (HIGHGUID_GAMEOBJECT-encoded, matching the gameobject seed's -/// 0xF110<<48 scheme). Stable across re-imports → the import_gameobjects clear+reload is idempotent. fn go_guid(db_guid: u64) -> u64 { (HIGHGUID_GAMEOBJECT << 48) | (db_guid & 0xFFFF_FFFF_FFFF) } -/// Uniform GATHER-node respawn window (seconds). The dump carries spawntimesecsmin=max=300 for these -/// entries (one Copper outlier at 45/90) — use the real vanilla 300s for all, NOT the module's 180s -/// fallback. (Per-node respawn variance from the spawn row is DEFERRED — single shared value for 8 nodes.) const GATHER_RESPAWN_SECS: u32 = 300; -/// The known Elwynn gather-node gameobjects, keyed by GO entry → -/// (gather_skill_line, granted item entry, required skill level, gray ceiling). -/// HARDCODED allowlist — cmangos models mining veins + herbs as locked type-3 -/// CHESTs (a blind "all type-3" pulls in 30+ treasure chests). The granted item is the chance-100, -/// group-0 row of gameobject_loot_template[lootId]; the required level is Lock.dbc-derived (well-known -/// vanilla values — the importer reads no loot table / Lock.dbc, not worth building for 8 known nodes). -/// MINING = 186, HERBALISM = 182 (matches module skill::skill_line). -/// The `gray` ceiling is the vanilla skillup gray threshold for the node: at/above this skill a gather -/// never yields +1 (skillup_chance_bp returns 0). Values sourced from the vanilla 1.12 skill color bands -/// (orange/yellow/green/gray). The linear collapse (orange→gray) is our simplified single-band model. const GATHER_NODES: &[(u64, u32, u32, u32, u32)] = &[ - // entry, skill_line, item, orange(=required), gray (1731, 186, 2770, 1, 100), - (1732, 186, 2771, 65, 140), // Copper Vein / Tin Vein + (1732, 186, 2771, 65, 140), (1617, 182, 765, 1, 75), - (1618, 182, 2447, 1, 75), // Silverleaf / Peacebloom + (1618, 182, 2447, 1, 75), (1619, 182, 2449, 15, 90), - (1620, 182, 785, 50, 125), // Earthroot / Mageroyal + (1620, 182, 785, 50, 125), (1621, 182, 2450, 70, 145), - (1622, 182, 2453, 100, 175), // Briarthorn / Bruiseweed + (1622, 182, 2453, 100, 175), ]; -/// Lookup a gather node by GO entry → (skill_line, granted item entry, required level, gray ceiling), -/// or None if the entry is not a known gather node. fn gather_node(entry: u64) -> Option<(u32, u32, u32, u32)> { GATHER_NODES .iter() @@ -591,28 +377,20 @@ fn gather_node(entry: u64) -> Option<(u32, u32, u32, u32)> { pub(crate) const GO_DOOR: u8 = 0; pub(crate) const GO_BUTTON: u8 = 1; -const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // shared const — no drift +const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; const GO_CHEST: u8 = 3; const GO_GOOBER: u8 = 10; const GO_MEETINGSTONE: u8 = lyracore_shared::constants::go_type::MEETINGSTONE; -/// Synthetic — must equal module `go_type::GATHER`. Vanilla's REAL type 25 is -/// GAMEOBJECT_TYPE_FISHINGHOLE; see the TYPE-25 COLLISION GUARD in `classify_go_type`. +// Synthetic GATHER collides with vanilla FISHINGHOLE type 25; classify raw types first. const GO_GATHER: u8 = 25; -/// The real cmangos type that collides with [`GO_GATHER`] — see `classify_go_type`. const CMANGOS_FISHINGHOLE_TYPE: u32 = 25; -/// Classify one `gameobject_template` row's cmangos TYPE into this repo's stored `type_id` — `None` -/// means DROP the row from import entirely (template AND spawn). Pure + fixture-tested. -/// -/// Every other in-scope raw type passes through IDENTITY (this repo's type ids match cmangos' for -/// every value except the 3→25 gather remap and the 25 drop) — `main.rs`'s per-type dispatch decides -/// LIVE vs INERT from the resulting value, not from this function. pub(crate) fn classify_go_type(entry: u64, raw_type: u32) -> Option { match raw_type { 3 if gather_node(entry).is_some() => Some(GO_GATHER), CMANGOS_FISHINGHOLE_TYPE => None, t if t <= u8::MAX as u32 => Some(t as u8), - _ => None, // an out-of-range raw type (corrupt dump row) — drop rather than truncate + _ => None, } } @@ -658,13 +436,10 @@ fn go_template_row( other => ( format!("({entry},{other},{disp},{n},0,0,0,0,0,0,{size})"), None, - ), // QUESTGIVER + every INERT type + ), } } -/// DOOR/BUTTON's initial SPAWN state from the dump's own `data0` (cmangos `startOpen` [V]): nonzero → -/// spawns OPEN (state 1), else CLOSED (state 0). Every other type -/// always spawns state 0 (CHEST closed/unlooted, GOOBER/GATHER/inert unused). Pure + fixture-tested. fn go_initial_state(stored_type: u8, data0: u32) -> u8 { if (stored_type == GO_DOOR || stored_type == GO_BUTTON) && data0 != 0 { 1 @@ -674,80 +449,50 @@ fn go_initial_state(stored_type: u8, data0: u32) -> u8 { } mod nt { - // npc_trainer: entry(=creature_template.entry), spell, spellcost, reqskill, reqskillvalue, reqlevel, ... pub const ENTRY: usize = 0; pub const SPELL: usize = 1; pub const COST: usize = 2; - pub const REQ_SKILL: usize = 3; // SkillLine the offering teaches/requires (0 = a class spell) + pub const REQ_SKILL: usize = 3; pub const REQ_LEVEL: usize = 5; } -// A small reviewable CONST data table (the doc above says so): each tuple is one profession's learn ladder. #[allow(clippy::type_complexity)] -/// Profession learn-spell tiers SYNTHESIZED into game_trainer_spell. cmangos delivers Apprentice→Artisan -/// learning via the gossip "Train me" menu (core C++), NOT via npc_trainer — those rows (reqskill != 0) -/// are RECIPES that REQUIRE the skill, not the skill-learn itself. So for every in-box trainer whose -/// npc_trainer rows carry a reqskill matching one of our 12 skill_lines, we emit one synthesized learn-row -/// per tier carrying the REAL Spell.dbc learn id as spell_id (so SMSG_LEARNED_SPELL resolves client-side, -/// unlike the old 50080-88 markers) + learn_skill_line/learn_skill_cap set (the module routes the buy to -/// crate::skill::learn_profession instead of a spell). Tuple: (skill_line, &[(spell_id, tier_index, -/// required_level)]) — `tier_index` (0=Apprentice,1=Journeyman,2=Expert,3=Artisan) replaced a literal cap. -/// The real cap now comes from `SkillTiers.dbc` (see `dbc::profession_tier_values`), -/// checked against `LEGACY_TIER_CAPS` by `assert_profession_tier_parity` before it's ever emitted. This -/// table itself no longer carries the number, just WHICH tier. The gather/secondary lines (Mining/ -/// Herbalism/Skinning/Cooking/First Aid) stop at Expert(2) HERE — their Artisan(3) tier is deferred (not -/// load-bearing for 1-10); the crafting trios (Alchemy/BS/Tailoring/LW) go to Artisan(3). Tier ids verified -/// vs the dump's spell_template (effect-118 base points = tier). The final three professions — -/// Engineering(202)/Enchanting(333)/Fishing(356) — complete the 12: Eng/Ench go to Artisan(3), Fishing -/// stops at Expert(2) like the other secondary lines. KEEP THIS A SMALL REVIEWABLE CONST. +// CMaNGOS teaches profession tiers through gossip, so npc_trainer omits these rows. const PROFESSION_LEARN: &[(u32, &[(u32, u8, u8)])] = &[ - (186, &[(2575, 0, 1), (2576, 1, 10), (3564, 2, 20)]), // Mining (2576=Jour, 3564=Expert) - (182, &[(2366, 0, 1), (2368, 1, 10), (3570, 2, 20)]), // Herbalism - (393, &[(8613, 0, 1), (8617, 1, 10), (8618, 2, 20)]), // Skinning (8618=Expert, not 10768=Artisan) - (185, &[(2550, 0, 1), (3102, 1, 10), (3413, 2, 20)]), // Cooking - (129, &[(3273, 0, 1), (3274, 1, 10), (7924, 2, 20)]), // First Aid + (186, &[(2575, 0, 1), (2576, 1, 10), (3564, 2, 20)]), + (182, &[(2366, 0, 1), (2368, 1, 10), (3570, 2, 20)]), + (393, &[(8613, 0, 1), (8617, 1, 10), (8618, 2, 20)]), + (185, &[(2550, 0, 1), (3102, 1, 10), (3413, 2, 20)]), + (129, &[(3273, 0, 1), (3274, 1, 10), (7924, 2, 20)]), ( 171, &[(2259, 0, 1), (3101, 1, 10), (3464, 2, 20), (11611, 3, 35)], - ), // Alchemy + ), ( 164, &[(2018, 0, 1), (3100, 1, 10), (3538, 2, 20), (9785, 3, 35)], - ), // Blacksmithing (3538=Expert; 9785=Artisan, NOT 9787 Weaponsmith) + ), ( 197, &[(3908, 0, 1), (3909, 1, 10), (3910, 2, 20), (12180, 3, 35)], - ), // Tailoring + ), ( 165, &[(2108, 0, 1), (3104, 1, 10), (3811, 2, 20), (10662, 3, 35)], - ), // Leatherworking - // --- The final three (completing the 12). Tier ids VERIFIED vs the dump's spell_template (Effect2=118 - // SKILL, MiscVal2=skill_line, BasePoints2=tier → cap=(BP2+1)*75). Fishing's ids were corrected: the - // original spec was shifted two tiers (7733 is a LearnSpell wrapper, NOT a skill tier); the real - // Effect2=118/MiscVal=356 tiers are 7620/7731/7732/18248 → use 7620/7731/7732 (stop at Expert 2). + ), ( 202, &[(4036, 0, 1), (4037, 1, 10), (4038, 2, 20), (12656, 3, 35)], - ), // Engineering → Artisan + ), ( 333, &[(7411, 0, 1), (7412, 1, 10), (7413, 2, 20), (13920, 3, 35)], - ), // Enchanting → Artisan - (356, &[(7620, 0, 1), (7731, 1, 10), (7732, 2, 20)]), // Fishing (stops at Expert, like other secondaries) + ), + (356, &[(7620, 0, 1), (7731, 1, 10), (7732, 2, 20)]), ]; -/// The legacy Apprentice/Journeyman/Expert/Artisan tier caps `PROFESSION_LEARN` hardcoded before -/// wired in the real client's `SkillTiers.dbc` data, every profession in vanilla rides -/// this exact 75/150/225/300 progression, indexed by `tier_index`. `assert_profession_tier_parity` -/// checks the DBC-derived caps against this constant; the synthesis loop (below) falls back to it -/// directly when `--dbc` wasn't given (no client data available to check against, so no parity guard -/// is possible — documented fallback). const LEGACY_TIER_CAPS: [u32; 4] = [75, 150, 225, 300]; -/// The tier list a trainer should TEACH, given a SkillLine seen on its npc_trainer rows — `Some(tiers)` -/// iff `line` is one of our 12 mapped professions, else `None` (a class spell or a deferred line). The ONE -/// check on the spell->(skill_line,tier_index) map. fn profession_tiers(line: u32) -> Option<&'static [(u32, u8, u8)]> { PROFESSION_LEARN .iter() @@ -755,8 +500,6 @@ fn profession_tiers(line: u32) -> Option<&'static [(u32, u8, u8)]> { .map(|(_, t)| *t) } -/// Verify client skill-tier values match the importer's 75/150/225/300 progression. -/// Refuse mismatches before writing trainer offers. pub(crate) fn assert_profession_tier_parity( tier_values: &std::collections::HashMap, ) -> Result<()> { @@ -777,59 +520,46 @@ pub(crate) fn assert_profession_tier_parity( Ok(()) } mod sc { - // spell_chain (cmangos/mangos-classic 1.12 schema): spell_id, prev_spell, first_spell, rank, - // req_spell. [V] — anchored against the well-known cmangos community schema column ORDER, NOT - // verified against an in-repo dump (this sandbox has no cmangos dump — same caveat as `pcs` above). - // CONFIRM these column indices against your own `classic-db` dump's `spell_chain` table before - // trusting the import — a wrong column here silently mis-gates rank purchases. - pub const SPELL_ID: usize = 0; // [V] - pub const PREV_SPELL: usize = 1; // [V] - pub const FIRST_SPELL: usize = 2; // [V] - pub const RANK: usize = 3; // [V] - pub const REQ_SPELL: usize = 4; // [V] + pub const SPELL_ID: usize = 0; + pub const PREV_SPELL: usize = 1; + pub const FIRST_SPELL: usize = 2; + pub const RANK: usize = 3; + pub const REQ_SPELL: usize = 4; } mod sls { - pub const ENTRY: usize = 0; // [V] parent_spell - pub const SPELL_ID: usize = 1; // [V] learn_spell - pub const ACTIVE: usize = 2; // [V] parsed-but-unused + pub const ENTRY: usize = 0; + pub const SPELL_ID: usize = 1; + pub const ACTIVE: usize = 2; } mod spe { - // Legacy rows carry one family mask. Current rows carry one mask per effect before procFlags. - pub const ENTRY: usize = 0; // [V] - pub const SCHOOL_MASK: usize = 1; // [V] - pub const FAMILY_NAME: usize = 2; // [V] - pub const FAMILY_MASK: usize = 3; // [V] - pub const PROC_FLAGS: usize = 4; // [V] override; 0 = use the Spell.dbc header's mask - pub const PROC_EX: usize = 5; // [V] - pub const PPM_RATE: usize = 6; // [V] - pub const CUSTOM_CHANCE: usize = 7; // [V] a float percent in the dump; our column is a u8 percent - /// The internal cooldown, in SECONDS ([V] — cmangos holds it as `std::chrono::seconds`, and the - /// dump's own values are small integers: a 45-second trinket reads 45, not 45000). The module - /// column is milliseconds, so `build_spell_proc_event_sql` scales it. + // Legacy proc rows have one family mask; current rows have one per effect. + pub const ENTRY: usize = 0; + pub const SCHOOL_MASK: usize = 1; + pub const FAMILY_NAME: usize = 2; + pub const FAMILY_MASK: usize = 3; + pub const PROC_FLAGS: usize = 4; + pub const PROC_EX: usize = 5; + pub const PPM_RATE: usize = 6; + pub const CUSTOM_CHANCE: usize = 7; + // The dump cooldown uses seconds; durable rows use milliseconds. pub const COOLDOWN: usize = 8; - /// How many columns a row must have to be mapped at all. pub const COLUMNS: usize = 9; } pub(crate) mod got { - // gameobject_template: entry, type, displayId, name, faction, flags, ExtraFlags, size, data0..23, ... pub const ENTRY: usize = 0; pub const TYPE: usize = 1; pub const DISPLAY_ID: usize = 2; pub const NAME: usize = 3; - // The prop's render scale (OBJECT_FIELD_SCALE_X); gathering nodes are authentically sub-1.0. - // Carried verbatim — the 0/absent → 1.0 fallback lives in the gateway codec, not here. pub const SIZE: usize = 7; pub const DATA0: usize = 8; pub const DATA1: usize = 9; - /// MEETINGSTONE: the dungeon's AreaTable id. pub const DATA2: usize = 10; pub const DATA3: usize = 11; pub const DATA5: usize = 13; } mod go { - // gameobject (spawn): guid, id(=gameobject_template.entry), map, spawnMask, x, y, z, orientation, rot0..3, ... pub const GUID: usize = 0; pub const ID: usize = 1; pub const MAP: usize = 2; @@ -837,25 +567,17 @@ mod go { pub const Y: usize = 5; pub const Z: usize = 6; pub const O: usize = 7; - // The cmangos spawn quaternion, the client's real prop orientation, `orientation` - // (O, above) alone only drives movement-facing math. Positionally right after O per the dump - // schema comment; carried through byte-verbatim (no derive/snap here — that fallback lives in - // the gateway codec for the all-zero case, not the importer). + // The spawn quaternion determines render orientation; orientation alone is movement facing. pub const ROT0: usize = 8; pub const ROT1: usize = 9; pub const ROT2: usize = 10; pub const ROT3: usize = 11; } mod ai { - // areatrigger_involvedrelation: id(=areatrigger id), quest. pub const TRIGGER: usize = 0; pub const QUEST: usize = 1; } -// --- mysqldump INSERT parser ------------------------------------------------------------------ -// Extracts every value tuple from `INSERT INTO `
` VALUES (..),(..);` statements. Handles -// single-quoted strings with `\` escapes (names contain commas/apostrophes). Returns each field as -// an unescaped String (quotes stripped). We PARSE, never execute (the firewall: no GPL code runs). pub(crate) fn parse_table(dump: &str, table: &str) -> Vec> { parse_table_inserts(dump, table).0 } @@ -876,7 +598,6 @@ fn parse_table_inserts(dump: &str, table: &str) -> (Vec>, bool) { while let Some(rel) = dump[search..].find(&needle) { let mut i = search + rel + needle.len(); let bytes = dump.as_bytes(); - // Parse tuples until the terminating `;` at top level. let mut field = String::new(); let mut tuple: Vec = Vec::new(); let mut in_tuple = false; @@ -885,7 +606,6 @@ fn parse_table_inserts(dump: &str, table: &str) -> (Vec>, bool) { let c = bytes[i] as char; if in_quote { if c == '\\' && i + 1 < bytes.len() { - // escaped char, take the next literally (\' \\ \n...) let n = bytes[i + 1] as char; field.push(match n { 'n' => '\n', @@ -933,9 +653,7 @@ pub(crate) fn field(row: &[String], idx: usize) -> &str { row.get(idx).map(|s| s.trim()).unwrap_or("") } -/// ClassicDB uses signed `-1` for an unrestricted allowable-class/race column. It is normalized -/// only at this import boundary; every other mask is parsed as raw unsigned bits so unknown bits -/// survive into the Module and its item-query response. +// Normalize ClassicDB -1 masks only at this import boundary. fn normalize_item_mask(source: &str, unrestricted: u32) -> u32 { if source == "-1" { unrestricted @@ -943,14 +661,7 @@ fn normalize_item_mask(source: &str, unrestricted: u32) -> u32 { source.parse().unwrap_or(0) } } -/// Drink discriminator → `game_item_template.restores_power` (food vs drink). In classicdb z2815 BOTH -/// food and drink are class 0 (Consumable) with subclass 0 (generic) OR 5 (Food&Drink) — they do NOT -/// split on subclass — so we gate class 0 + subclass {0,5} (excluding potions=1/elixirs=2/flasks=3/ -/// scrolls=4/bandages=7 so "Elixir of Water Walking" can't false-positive) and discriminate by NAME. -/// Match WHOLE WORDS, not substrings, so "S`tea`k" / "Snapvine `Water`melon" (food) don't match; drop -/// the collision-prone "beer"/"brew" ("`Beer` Basted Boar Ribs"). wine/ale stay (Dalaran Red Wine is a -/// real mana drink). Deliberate simplification: name-match; the canonical signal is the on-use -/// spell (E_ENERGIZE) — wire that only if a beverage is ever misclassified. +// ClassicDB z2815 uses subclass zero for both food and drink. fn is_drink_consumable(class: u8, subclass: u8, name: &str) -> bool { if class != 0 || (subclass != 0 && subclass != 5) { return false; @@ -962,13 +673,10 @@ fn is_drink_consumable(class: u8, subclass: u8, name: &str) -> bool { .split(|c: char| !c.is_ascii_alphanumeric()) .any(|w| DRINK_WORDS.contains(&w)) } -/// SQL string literal with single-quotes doubled. fn sql_str(s: &str) -> String { format!("'{}'", s.replace('\'', "''")) } -/// SQL text from a cmangos field, mapping the literal `NULL` token to an empty string. Our parser -/// strips quotes, so a quoted `'NULL'` and an unquoted `NULL` are indistinguishable — but no creature -/// is named "NULL", so collapsing it to empty is safe and keeps the column NOT-NULL (no `'NULL'` text). +// The tuple parser cannot distinguish quoted NULL text from an unquoted NULL token. pub(crate) fn sql_text(s: &str) -> String { if s == "NULL" { "''".into() @@ -979,65 +687,37 @@ pub(crate) fn sql_text(s: &str) -> String { pub(crate) struct Args { taxi_mounts: Option, - dump: Option, // cmangos creature ETL input (.sql[.gz]) - dbc: Option, // client Data/ dir for the DBC stream - pub(crate) terrain: Option, // client Data/ dir for the ADT heightmap stream (see terrain.rs) - pub(crate) dump_collision: Option, // client Data/ dir: spike, WMO/M2 collision dry-run (see collision.rs) - pub(crate) nav: Option, // client Data/ dir: nav-grid rasterizer (see nav.rs) - pub(crate) vmap: Option, // Client geometry input for the per-cell collision catalogue. - pub(crate) vmap_status: bool, // print active-generation provenance/status without opening client data - pub(crate) vmap_prepare_coverage: Option, // generation id: derive path-grid coverage from an already-staged generation (see vmap.rs::run_coverage) - pub(crate) go_models: Option, // client Data/ dir: DOOR/BUTTON display -> M2 bounding-mesh extract+import (see go_model.rs); needs --dump too - pack_client: Option, // client Data/ dir for the --pack-client packager (see pack_client.rs) - pack_out: Option, // output dir for the --pack-out packager: package-authored client content only (see pack_client.rs) + dump: Option, + dbc: Option, + pub(crate) terrain: Option, + pub(crate) dump_collision: Option, + pub(crate) nav: Option, + pub(crate) vmap: Option, + pub(crate) vmap_status: bool, + pub(crate) vmap_prepare_coverage: Option, + pub(crate) go_models: Option, + pack_client: Option, + pack_out: Option, print_extents: bool, - spells: bool, // with --dbc: import Spell.dbc → game_spell/game_spell_effect (see spell.rs) - talents: bool, // with --dbc: import TalentTab.dbc + Talent.dbc → game_talent_tab/game_talent (see talent.rs) - pub(crate) only: Vec, // with --spells: import ONLY these spell ids ADDITIVELY (surgical - // delete+reload, no wholesale wipe) — preserves the seed + synthetic test fixtures. - pub(crate) trainers: Vec<(u32, Vec)>, // with --spells: repeatable `--trainer =` - // → emit a game_trainer_spell row per id keyed to that trainer CREATURE-TEMPLATE - // entry. `required_level`/`cost` are DBC-DERIVED (the spell's own spell_level + - // our formula) — NO cmangos npc_trainer value is read. The ids ARE the per-class - // offering list (a subset of --only); the importer auto-adds them to the allowlist. + spells: bool, + talents: bool, + pub(crate) only: Vec, + pub(crate) trainers: Vec<(u32, Vec)>, pub(crate) world_profile: Option, map: i64, include_maps: Vec, - // rows import WHOLE-MAP (no --box/--radius/--exclude geometry — an instance map like Deadmines 36 - // is small and self-contained; its scope IS the map id). The primary --map keeps its box scoping. - // Same single-run clear+reload as the box slice — this is the "no additive two-pass path" answer - // for instance maps (see importer/scripts/import-world.sh's ONE-BOX note). center: (f64, f64, f64), radius: f64, - // --include-creatures : FORCE-import these creature entries (template + spawns + quest - // START/END relations + vendor/loot/gossip) regardless of the --box/--exclude geometry — the - // mechanism to recover out-of-box quest givers (e.g. a Westfall/Stormwind courier NPC whose - // sibling giver is in-band). Implemented in the spawn loop: a row whose entry is force-included - // bypasses the geometry gate, so its spawn survives → it flows into `entries` (derived from - // `spawns`) → every creature-scoped consumer picks it up. See importer/scripts/import-world.sh. include_creatures: Vec, - bbox: Option<(f64, f64, f64, f64)>, // --box X0,X1,Y0,Y1: a coordinate rectangle that REPLACES the - // radius sphere for the spawn slice (a radius from a zone corner bleeds into neighbours; a box is exact). - exclude: Option<(f64, f64, f64, f64)>, // --exclude X0,X1,Y0,Y1: a SUBTRACTED rectangle (in-box but - // in-exclude → dropped). Carves an inner neighbour out of the box (e.g. the Stormwind front inside Elwynn). + bbox: Option<(f64, f64, f64, f64)>, + exclude: Option<(f64, f64, f64, f64)>, db: String, server: String, pub(crate) apply: bool, pub(crate) family: Option, - // clear+reload instead of the full ETL. `None` = full run. pub(crate) source_sha: String, - // classic-db commit this dump came from. It is threaded into every `stamp_import_meta` call this - // run makes. Empty string ("") when not given (no external provenance to record). pub(crate) eventai_profile: String, - // --packages : the enabled Package Inventory root (normally `packages/`). After a family's - // base rows are back, every enabled Package's generated Delta artifacts under - // `//data/.generated/*.json` are reapplied in one reducer call. `None` = the stage - // does not run AND the base import says so loudly: an absent flag is "the operator did not say", - // never "no Package claims this family". pub(crate) packages: Option, - // --spell-snapshot : with --dbc, derive the spell rows and write them to as the - // Base Snapshot a Datascript reads. A terminal mode — it never loads, never stamps provenance - // and never opens a database, so it is safe to run against a live realm's operator machine. pub(crate) spell_snapshot: Option, } @@ -1045,7 +725,7 @@ fn parse_args() -> Result { parse_args_from(std::env::args().skip(1)) } -#[allow(clippy::too_many_lines)] // One arm per CLI flag. +#[allow(clippy::too_many_lines)] fn parse_args_from(args: I) -> Result where I: IntoIterator, @@ -1074,7 +754,7 @@ where map: 0, include_maps: Vec::new(), include_creatures: Vec::new(), - center: (-8949.95, -132.493, 83.5312), // human/Northshire start + center: (-8949.95, -132.493, 83.5312), radius: 180.0, bbox: None, exclude: None, @@ -1129,15 +809,10 @@ where ) } "--pack-out" => a.pack_out = Some(it.next().context("--pack-out needs an output dir")?), - // Derive the operator's OWN spawn bounding box for --map from their dump instead of trusting a - // remembered rectangle: the Westfall widening needed a way to compute --box from - // real data, not from memory. Prints min/max X/Y/Z + spawn count and returns — writes nothing. "--print-extents" => a.print_extents = true, "--spells" => a.spells = true, "--talents" => a.talents = true, "--only" => { - // Comma-separated spell ids → the additive allowlist. Fail loudly on a malformed id so - // a typo can't silently import the wrong (or empty) set. let v = it .next() .context("--only needs comma-separated spell ids, e.g. 7386,1160")?; @@ -1151,8 +826,6 @@ where } } "--include-creatures" => { - // Comma-separated creature entries → force-import regardless of --box/--exclude. Fail - // loudly on a malformed entry (a typo would silently drop a quest giver). let v = it.next().context( "--include-creatures needs comma-separated creature entries, e.g. 344,266,415", )?; @@ -1166,10 +839,6 @@ where } } "--trainer" => { - // Repeatable `--trainer =`: bind a per-class offering list to a trainer - // CREATURE-TEMPLATE entry. The ids are emitted as game_trainer_spell rows with a - // DBC-derived required_level + a level-keyed cost (NO cmangos value). Fail loudly on a - // malformed spec so a typo can't silently skip a class's whole trainer tree. let v = it.next().context("--trainer needs =")?; let (entry_s, ids_s) = v .split_once('=') @@ -1201,8 +870,6 @@ where } "--include-map" => { legacy_spatial_args.push("--include-map"); - // Repeatable: a WHOLE additional map in the slice (Deadmines, map 36). - // Fail loudly on a malformed id so a typo can't silently skip the dungeon's content. let m: i64 = it .next() .context("--include-map ")? @@ -1218,8 +885,6 @@ where } "--box" => { legacy_spatial_args.push("--box"); - // A coordinate rectangle X0,X1,Y0,Y1 (REPLACES --radius for the spawn slice). Fail - // loudly on a malformed box so a typo can't silently import the wrong region. let v = it.next().context("--box X0,X1,Y0,Y1")?; let p: Vec = v .split(',') @@ -1241,7 +906,6 @@ where } "--exclude" => { legacy_spatial_args.push("--exclude"); - // A rectangle SUBTRACTED from --box (in-box AND in-exclude → dropped). Same parse as --box. let v = it.next().context("--exclude X0,X1,Y0,Y1")?; let p: Vec = v .split(',') @@ -1265,9 +929,6 @@ where "--server" => a.server = it.next().context("--server")?, "--apply" => a.apply = true, "--family" => { - // Reload ONE --dump family's clear+reload instead of the full ETL. - // Validated against the fixed FAMILIES set below (right here, not deferred) so a typo - // bails loudly before any dump parsing work happens. let v = it.next().context("--family ")?; if !FAMILIES.contains(&v.as_str()) { bail!( @@ -1298,8 +959,6 @@ where "--center" => { legacy_spatial_args.push("--center"); let v = it.next().context("--center X,Y,Z")?; - // Fail loudly on a malformed center — silently defaulting a bad value to (0,0,0) or - // keeping the default would import the WRONG slice without the operator noticing. let p: Vec = v .split(',') .map(|s| s.trim().parse::()) @@ -1345,8 +1004,6 @@ where { bail!("--taxi-mounts is only valid with standalone --dbc catalogue import"); } - // Need at least one input. `--dump` alone = cmangos creatures; `--dbc` alone = standalone DBC - // mode (proof/checks); BOTH = cmangos creatures with DBC-resolved scale (display-derived). if a.dump.is_none() && a.dbc.is_none() && a.pack_client.is_none() @@ -1361,33 +1018,21 @@ where { bail!("need an input: --dump , --dbc , --terrain , --pack-client , or --pack-out "); } - // `--pack-out` writes a distributable directory, never a client, so there is no client state - // for `--apply` to guard. if a.pack_out.is_some() && a.apply { bail!("--pack-out writes its output directory directly and takes no --apply (--apply guards the operator's own client, which --pack-out never touches)"); } - // The two packers write different trees under different rules: one into the operator's own - // client, one into a distributable directory. Dispatch can only run one, so asking for both - // is a mistake worth naming instead of silently dropping one. if a.pack_out.is_some() && a.pack_client.is_some() { bail!("--pack-client and --pack-out are separate outputs and cannot run together: --pack-client writes into the operator's own client, --pack-out writes a distributable directory. Run one, then the other"); } - // `--go-models` resolves gameobject_template rows (the cmangos dump) against client-owned M2 - // geometry — meaningless without a template source. if a.go_models.is_some() && a.dump.is_none() { bail!("--go-models needs --dump too (gameobject_template is the template source)"); } - // `--print-extents` reads `creature` rows out of the cmangos dump — meaningless without `--dump`. if a.print_extents && a.dump.is_none() { bail!("--print-extents is only valid with --dump (it reads the cmangos creature table)"); } - // `--only` is meaningless without the spell importer (it's the additive-allowlist for Spell.dbc). if !a.only.is_empty() && !a.spells { bail!("--only is only valid with --spells (the additive Spell.dbc allowlist)"); } - // The Base Snapshot is derived from Spell.dbc, so it needs the client dir and nothing else. It - // is a terminal mode rather than an extra output of `--spells`: an author asking for a snapshot - // is not asking to load anything, and `--apply` beside it would read as "and import too". if a.spell_snapshot.is_some() { if a.dbc.is_none() || a.dump.is_some() { bail!("--spell-snapshot needs --dbc and nothing else"); @@ -1402,13 +1047,9 @@ where if !a.include_creatures.is_empty() && a.dump.is_none() { bail!("--include-creatures is only valid with --dump (it force-imports cmangos creature rows)"); } - // `--family` covers `--dump` families ONLY, terrain/dbc/spells are separate - // invocations with no family concept of their own. if a.family.is_some() && a.dump.is_none() { bail!("--family is only valid with --dump (it names one of the cmangos-dump ETL families)"); } - // `--include-map` extends the `--dump` content slice only. Terrain and collision remain separate - // archive-derived modes, and the DBC/spell streams are map-agnostic. if !a.include_maps.is_empty() { if a.dump.is_none() { bail!("--include-map is only valid with --dump (it widens the cmangos content slice)"); @@ -1421,13 +1062,7 @@ where ); } } - // Resolve and validate the complete scope before the caller opens a dump or client archive. - // `build_dump_plan` resolves the same pure value once and then uses its predicate end to end. a.world_import_scope()?; - // `--trainer` rides the spell importer too (it reads each offering's DBC spell_level). Auto-extend - // the allowlist with the trainer ids so every offered wrapper's game_spell header is imported in the - // SAME pass (so the required_level is DBC-derived and the buy path resolves a real rank). When ONLY - // --trainer was given (no --only), this seeds the allowlist — the surgical/additive path stays on. if !a.trainers.is_empty() { if !a.spells { bail!( @@ -1458,24 +1093,17 @@ impl Args { self.include_maps.clone(), ), }?; - // The legacy flag remains an explicit extension. Canonical dependencies live in the - // profile catalogue so callers cannot silently omit or copy them. scope.extend_forced_creatures(&self.include_creatures); Ok(scope) } } -/// One `spacetime call` for a reducer whose only argument is a `String`. -/// -/// The CLI parses each argument as Sats JSON. Encoding here keeps packed newlines and other control -/// characters inside that one String instead of letting the CLI parse them as JSON syntax. +// Each spacetime CLI argument is Sats JSON; encode the whole payload once. pub(crate) fn call_reducer(args: &Args, reducer: &str, payload: &str) -> Result<()> { let payload = serde_json::to_string(payload).context("encode reducer String argument")?; call_reducer_args(args, reducer, &[&payload]) } -/// The `spacetime` CLI. `lyracore import` passes a temporary owner config through -/// `LYRACORE_SPACETIME_CONFIG_PATH` when its operator credential is server-issued. pub(crate) fn spacetime() -> Command { let mut command = Command::new("spacetime"); if let Some(path) = std::env::var_os("LYRACORE_SPACETIME_CONFIG_PATH") { @@ -1484,8 +1112,6 @@ pub(crate) fn spacetime() -> Command { command } -/// Reducer call with separate positional arguments (generation lifecycle reducers carry both -/// immutable manifest metadata and a packed batch). pub(crate) fn call_reducer_args(args: &Args, reducer: &str, values: &[&str]) -> Result<()> { let mut command = spacetime(); command.args(["call", "-s", &args.server, &args.db, reducer]); @@ -1499,11 +1125,6 @@ pub(crate) fn call_reducer_args(args: &Args, reducer: &str, values: &[&str]) -> Ok(()) } -/// Stamp `family`'s import provenance via the `stamp_import_meta` reducer, called -/// once per family that ACTUALLY ran, after the apply succeeds. Unlike `call_reducer`'s single -/// packed-string payload (the spawn/gameobject batch shape), `stamp_import_meta` takes 4 separate -/// positional args, so each rides its own CLI argument — the same multi-arg `spacetime call` -/// convention `debug_setup_gather_pool`'s doc comment demonstrates (module/src/debug.rs). pub(crate) fn stamp_family( args: &Args, family: &str, @@ -1535,12 +1156,6 @@ pub(crate) fn stamp_family( Ok(()) } -/// sha256 hex digest of `bytes` (`--source-sha`/provenance). This crate's `Cargo.lock` -/// carries no `sha2` dependency, so — mirroring this importer's existing convention of shelling out to -/// `spacetime` for reducer calls rather than adding a client SDK dependency — this shells out to the -/// system `sha256sum` instead of adding one. Feeds the dump bytes on stdin (`sha256sum -` reads -/// stdin), so no temp file is needed. Returns the lowercase hex digest (`sha256sum`'s first -/// whitespace-delimited field). pub(crate) fn sha256_hex(bytes: &[u8]) -> Result { use std::io::Write; let mut child = Command::new("sha256sum") @@ -1568,8 +1183,6 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> Result { pub(crate) fn read_dump(path: &str) -> Result { let bytes = std::fs::read(path).with_context(|| format!("read {path}"))?; - // mysqldumps are latin1/utf8-ish and may carry stray non-UTF8 bytes; decode lossily on BOTH paths - // (read_to_string would hard-fail the.gz path on the first bad byte, the plain path never did). let raw = if path.ends_with(".gz") { let mut v = Vec::new(); GzDecoder::new(&bytes[..]) @@ -1582,10 +1195,6 @@ pub(crate) fn read_dump(path: &str) -> Result { Ok(String::from_utf8_lossy(&raw).into_owned()) } -/// Clear+reload SQL for the two cmangos stat-curve tables (importer P3): `game_class_level_stats` -/// (base HP/mana per class+level) + `game_level_stats` (attributes per race+class+level). Both are -/// no-Timestamp → plain SQL. Keys are packed to match module/src/stats.rs: `(class<<8)|level` and -/// `(race<<16)|(class<<8)|level`. Global (not zone-filtered). Chunked under 500 rows/INSERT. fn build_stats_sql(dump: &str) -> Vec { let mut stmts: Vec = vec![ "DELETE FROM game_class_level_stats WHERE class_level >= 0".into(), @@ -1641,10 +1250,6 @@ fn build_stats_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_start_position` from cmangos `playercreateinfo` (importer P1) — the -/// per-(race,class) start coords/map/zone for character creation. No Timestamp → plain SQL. PK packs -/// `(race<<8)|class` (matching the module). `display_id` is now vestigial (player display comes from -/// `game_race_info` at login) — emit 0. fn build_start_position_sql(dump: &str) -> Vec { let rows: Vec = parse_table(dump, "playercreateinfo") .iter() @@ -1674,13 +1279,6 @@ fn build_start_position_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_graveyard_zone` from cmangos `game_graveyard_zone`: the -/// zone (+ optional faction restriction) each imported graveyard (`game_graveyard`, loaded separately -/// from `WorldSafeLocs.dbc` by `dbc.rs::graveyard_sql`) serves. `module/src/world.rs`'s -/// `graveyard::resolve_graveyard` reads this via the `by_zone` index to prefer a zone-linked -/// graveyard over a merely-closer unlinked one. GLOBAL, not box-scoped (a small table, like -/// `build_start_position_sql` above) — every graveyard/zone link imports regardless of the creature -/// ETL's map/box filter. No Timestamp → plain SQL. fn build_graveyard_zone_sql(dump: &str) -> Vec { let rows: Vec = parse_table(dump, "game_graveyard_zone") .iter() @@ -1706,19 +1304,6 @@ fn build_graveyard_zone_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport`: -/// dungeon entrance/exit portals — trigger id → target map + destination position. `id` here is the -/// SAME `AreaTrigger.dbc` trigger id `game_area_trigger` (`dbc.rs::area_trigger_sql`) carries as -/// its primary key: that importer reads the trigger's GEOMETRY (position/shape, from the client DBC), this -/// imports its TARGET (from the cmangos world dump) — two independent sources joined only by sharing -/// the same id space, exactly like `game_graveyard`/`game_graveyard_zone`'s split. GLOBAL, not -/// box-scoped, on purpose: an areatrigger's own geometry can sit anywhere (Moonbrook's entrance is in -/// Westfall, its exit is inside map 36), and a portal a party can walk into from outside today's -/// creature-ETL box must still resolve — box-scoping this table would silently drop a portal whose -/// TRIGGER lands outside the box even though the trigger volume itself (`game_area_trigger`) is -/// never box-filtered either. Same "small global table, own clear+reload" shape as -/// `build_graveyard_zone_sql` above. `[V]` column layout — see the `at` module's doc comment; no dump -/// in this sandbox to confirm against, and no neighbor-anchor is possible for this brand-new table. fn build_areatrigger_teleport_sql(dump: &str) -> (Vec, usize) { let rows: Vec = parse_table(dump, "areatrigger_teleport") .iter() @@ -1747,10 +1332,6 @@ fn build_areatrigger_teleport_sql(dump: &str) -> (Vec, usize) { (stmts, n) } -/// Count the distinct `(race, class)` combos among rows already formatted as `"(id,race,class...)"` -/// SQL tuples — shared coverage-print helper for the three `playercreateinfo_*` builders below (each -/// row string always starts `(id,race,class,`, so a light textual split is enough; no need to re-parse -/// the source dump rows a second time). fn distinct_race_class_combos(rows: &[(u32, u32)]) -> usize { let mut combos: Vec<(u32, u32)> = rows.to_vec(); combos.sort_unstable(); @@ -1758,18 +1339,6 @@ fn distinct_race_class_combos(rows: &[(u32, u32)]) -> usize { combos.len() } -/// Clear+reload SQL for `game_createinfo_spell` from cmangos `playercreateinfo_spell`. -/// **FLIPS THE SOURCE FROM SEED TO IMPORT**: `module/src/seed.rs::seed_createinfo_spells` only inserts -/// the hand-authored `CREATEINFO_KIT` rows if the table is EMPTY (the `game_start_position` / Talent.dbc -/// precedent — an importer clear+reload REPLACES whatever is there, seed or a prior import; it -/// does not merge or coexist with it). The `id >= 0` DELETE is a tautology on the unsigned PK (mirrors -/// the sibling loaders) — it's a wholesale wipe, not a conditional one. A character created BEFORE this -/// import keeps the `game_player_spell` rows ITS creation already copied (durable, per-character); only -/// characters created AFTER the import see the real dump-sourced kit. `race`/`class` are NOT -/// re-validated as wildcards here — a dump row's own 0 (if any) passes through verbatim and -/// `createinfo_row_matches` (module/src/spell/spellbook.rs) already treats 0 as a wildcard on read, -/// so this importer needs no wildcard-specific handling. ALL races import (not just Human) — cheap, and -/// race unlock becomes a pure gate flip later. No Timestamp → plain SQL. fn build_createinfo_spell_sql(dump: &str) -> Vec { let mut race_class_pairs: Vec<(u32, u32)> = Vec::new(); let rows: Vec = parse_table(dump, "playercreateinfo_spell") @@ -1798,13 +1367,6 @@ fn build_createinfo_spell_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_createinfo_action` from cmangos `playercreateinfo_action` -/// — the vanilla default action-bar layout per (race, class), read by `module/src/action_bar.rs`'s -/// `grant_createinfo_actions` at character creation (copied into `game_player_action`) and, when a -/// character has none (pre-import), by the gateway's login synth fallback -/// (`gateway/src/codec/entity.rs::login_sequence_messages`). This table has NO hand-authored seed -/// counterpart (unlike `game_createinfo_spell`) — an empty table is the fallback signal itself, not a -/// transitional pre-import state to worry about orphaning. No Timestamp → plain SQL. fn build_createinfo_action_sql(dump: &str) -> Vec { let mut race_class_pairs: Vec<(u32, u32)> = Vec::new(); let rows: Vec = parse_table(dump, "playercreateinfo_action") @@ -1838,24 +1400,9 @@ fn build_createinfo_action_sql(dump: &str) -> Vec { stmts } -/// `game_start_item.id` floor for `playercreateinfo_item` EXTRAS, a disjoint range -/// from `dbc.rs::start_item_sql`'s own densely-assigned-from-1 CharStartOutfit ids, so the two writers' -/// clear+reload DELETEs never touch each other's rows regardless of which pass runs (the canonical -/// `importer/scripts/import-world.sh` runs the `--dump` pass THEN the standalone `--dbc` pass — without a -/// disjoint range the later `--dbc` pass's `DELETE FROM game_start_item WHERE id >= 0` would wipe -/// these extras every single run). Comfortably above any realistic CharStartOutfit row count (a few -/// hundred rows across every race/class/gender combo). +// Starter extras need an id band disjoint from the DBC outfit clear-and-reload range. const CREATEINFO_ITEM_ID_FLOOR: u64 = 1_000_000; -/// Clear+reload SQL for the `playercreateinfo_item` EXTRAS into `game_start_item`, the -/// dump's per-(race,class) starter items BEYOND `CharStartOutfit.dbc`'s outfit (today's sole source — -/// see `dbc.rs::start_item_sql`). Deduped against the outfit's own (race_class, item) pairs when -/// `--dbc` is also given (via `dbc::outfit_item_set`) so the SAME item for the SAME (race, class) is -/// never listed twice — `grant_starter_item` (module/src/items/ops.rs) has no dedup of its own, it -/// trusts `game_start_item` to already be clean. Without `--dbc` (no client data available), dedup is -/// skipped — there is nothing to dedup against in that case anyway (a `--dump`-only run has never -/// loaded any outfit rows). Ids are assigned densely from `CREATEINFO_ITEM_ID_FLOOR` (see its doc) — -/// this function's own DELETE only ever touches that range. No Timestamp → plain SQL. fn build_createinfo_item_sql(dump: &str, dbc_dir: Option<&str>) -> Result> { let outfit_seen: std::collections::HashSet<(u32, i32)> = match dbc_dir { Some(dir) => { @@ -1883,10 +1430,10 @@ fn build_createinfo_item_sql(dump: &str, dbc_dir: Option<&str>) -> Result) -> Result 0` in the DELETE (not `>= 0`, unlike the unsigned-PK tables elsewhere) matches the -/// module's `u32` PK exactly and is still a wholesale wipe (every real spell_id is > 0). fn build_spell_chain_sql(dump: &str, links: &[(u32, u32)]) -> Result> { let parsed: Vec<(u32, String)> = parse_table(dump, "spell_chain") .iter() @@ -1949,7 +1489,6 @@ fn build_spell_chain_sql(dump: &str, links: &[(u32, u32)]) -> Result Ok(stmts) } -/// Derive only families not owned by explicit dump rows, including deliberate partial overrides. fn client_spell_rank_rows( links: &[(u32, u32)], explicit: &std::collections::BTreeSet, @@ -2022,14 +1561,6 @@ fn client_spell_rank_rows( Ok(rows) } -/// -/// Also synthesizes the two armor-proficiency wrapper→passive rows (verified against ClassicDB 1.12): -/// Plate Mail wrapper `PLATE_TRAINER_SPELL_ID` (7109) → passive `PLATE_PASSIVE_SPELL_ID` (750), and -/// Mail wrapper `MAIL_TRAINER_SPELL_ID` (8738) → passive `MAIL_PASSIVE_SPELL_ID` (8737). cmangos -/// `spell_learn_spell` carries no rows for these parents — the module's -/// `learn_spell_with_dependents` (`module/src/spell/spellbook.rs`) needs them to grant the passive the -/// equip Gate reads when a class trainer sells the wrapper. Skipped if the dump ever grows a real row -/// for either parent, so a future upstream fix can't collide with the synthesized one. fn build_spell_learn_sql(dump: &str) -> Vec { use lyracore_shared::constants::armor_proficiency; @@ -2040,7 +1571,7 @@ fn build_spell_learn_sql(dump: &str) -> Vec { .filter_map(|(i, r)| { let parent_spell: u32 = field(r, sls::ENTRY).parse().ok()?; let learn_spell: u32 = field(r, sls::SPELL_ID).parse().ok()?; - let _active: u8 = field(r, sls::ACTIVE).parse().unwrap_or(1); // parsed, unused (see doc) + let _active: u8 = field(r, sls::ACTIVE).parse().unwrap_or(1); parents_seen.insert(parent_spell); Some(format!("({},{parent_spell},{learn_spell})", i as u64 + 1)) }) @@ -2057,7 +1588,7 @@ fn build_spell_learn_sql(dump: &str) -> Vec { ), ] { if parents_seen.contains(&parent) { - continue; // dump already carries a real spell_learn_spell row for this wrapper + continue; } rows.push(format!("({next_id},{parent},{learn})")); next_id += 1; @@ -2073,21 +1604,6 @@ fn build_spell_learn_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_spell_proc_event` from cmangos `spell_proc_event` — the PROC OVERLAY. -/// Spell.dbc carries a Proc's event mask, flat chance and charge count and nothing else; the -/// procs-per-minute rate, the internal cooldown, the `procEx` hit rule and the school/family filter -/// exist only here. `module/src/spell/proc.rs::freeze_profile` reads it once, at aura apply, by -/// `spell_id` (the PK). No Timestamp → plain SQL. -/// -/// Two column conversions, because the dump's units are not the module's: -/// • `Cooldown` is SECONDS in cmangos and milliseconds on our row (see `spe::COOLDOWN`). -/// • `CustomChance` is a float percent in the dump and a whole percent on our row. Vanilla's own -/// values are whole numbers, so this rounds; a rate is what expresses a fractional chance, and -/// that rides `ppm_rate` unchanged. -/// -/// The DELETE stops at `SYNTHETIC_PROC_EVENT_FLOOR` rather than wiping wholesale like its `spell_chain` -/// sibling: the seeded Proc fixtures own overlay rows in the synthetic range, and a world import must -/// not silently disarm them (the same rule `importer/src/spell.rs` applies to `game_spell` itself). fn proc_family_mask( row: &[String], spell_id: u32, @@ -2143,7 +1659,7 @@ fn build_spell_proc_event_sql( let shift = if r.len() == 11 { 2 } else { 0 }; let Some(spell_id) = field(r, spe::ENTRY).parse::().ok() else { return Ok(None); }; if spell_id == 0 || spell_id >= SYNTHETIC_PROC_EVENT_FLOOR { - return Ok(None); // outside the DELETE's reach — an INSERT here would PK-collide next reload + return Ok(None); } let Some(school_mask) = field(r, spe::SCHOOL_MASK).parse::().ok() else { return Ok(None); }; let Some(family_name) = field(r, spe::FAMILY_NAME).parse::().ok() else { return Ok(None); }; @@ -2154,7 +1670,7 @@ fn build_spell_proc_event_sql( let Some(custom_chance) = field(r, spe::CUSTOM_CHANCE + shift).parse::().ok() else { return Ok(None); }; let Some(cooldown_s) = field(r, spe::COOLDOWN + shift).parse::().ok() else { return Ok(None); }; if !ppm_rate.is_finite() || ppm_rate < 0.0 || !custom_chance.is_finite() { - return Ok(None); // a corrupt rate would freeze onto every aura of this spell + return Ok(None); } let custom_chance = custom_chance.round().clamp(0.0, 100.0) as u8; let icd_ms = cooldown_s.saturating_mul(1000); @@ -2176,14 +1692,8 @@ fn build_spell_proc_event_sql( Ok(stmts) } -/// The reserved synthetic-spell floor, above which the seeded fixtures live and no real vanilla spell -/// does. Hand-synced with `importer/src/spell.rs`'s `SYNTHETIC_SPELL_ID_FLOOR` and the module's own -/// fixture ids; the two must stay equal. const SYNTHETIC_PROC_EVENT_FLOOR: u32 = 50_000; -/// Map the literal `NULL` token (and pure whitespace) to an empty string for an INTERNAL string -/// comparison — `sql_text` does the SQL-literal-quoted version of the same collapse for values that -/// go straight into an INSERT. fn null_to_empty(s: &str) -> &str { let t = s.trim(); if t == "NULL" { @@ -2193,17 +1703,8 @@ fn null_to_empty(s: &str) -> &str { } } -/// One npc_text slot: `(male, female, probability)`. Empty strings + probability 0.0 = unused slot. type NpcTextSlot = (String, String, f32); -/// Parse ALL 8 weighted npc_text slots from a raw dump row, not just the first -/// non-empty one. The client does its own weighted pick over the full 8 (see -/// `gateway::codec::build_npc_text_update`). Handles both schema vintages `npc_text_first_nonempty` -/// already distinguished: -/// • Full 81-col rows: male/female text + probability embedded directly per slot. -/// • Short 17-col rows: only a probability + a `broadcast_text` id per slot (the text lives in the -/// `broadcast` map, resolved by the caller). -/// An unrecognized row shape yields 8 empty/zero slots (same "nothing found" outcome as before). fn parse_npc_text_slots( row: &[String], broadcast: &HashMap, @@ -2233,9 +1734,6 @@ fn parse_npc_text_slots( }) } -/// The first non-empty (male-then-female) text across all 8 slots — the back-compat single string -/// (`game_npc_text.text`), derived from the SAME -/// parsed slots the multi-slot table uses, so the two can never disagree. fn slots_first_nonempty(slots: &[NpcTextSlot; 8]) -> String { for (male, female, _) in slots { if !male.is_empty() { @@ -2248,11 +1746,6 @@ fn slots_first_nonempty(slots: &[NpcTextSlot; 8]) -> String { String::new() } -/// Resolve a `gossip_menu_option` row's mangos condition (inline columns, a `conditions`-table FK, or -/// no condition support at all in this dump vintage) into our minimal `(cond_type, value1, value2)` -/// set (`lyracore_shared::constants::gossip_condition`). An unmapped/unsupported mangos condition type -/// FAILS OPEN (`NONE` = always shown) rather than silently hiding the option, and is logged so the -/// operator can extend the mapping for their dump. fn resolve_gossip_option_condition( row: &[String], conditions: &HashMap, @@ -2262,14 +1755,11 @@ fn resolve_gossip_option_condition( match mangos_type { cond::MANGOS_QUESTTAKEN => (gossip_condition::QUEST_TAKEN, value1 as u32, 0), cond::MANGOS_QUESTREWARDED => (gossip_condition::QUEST_REWARDED, value1 as u32, 0), - // Fail-CLOSED, against this map's fail-open default: a seasonal option shown year-round - // is a wrong pitch, where a hidden one is only absent. cond::MANGOS_ACTIVE_GAME_EVENT => (gossip_condition::NEVER, 0, 0), _ => (gossip_condition::NONE, 0, 0), } }; - // 15-col vintage (THIS dump): trailing generic-conditions FK at 14 — checked FIRST (its col 11 - // is box_money, which the legacy inline branch would misread as a condition type). + // Check the 15-column layout first; its column 11 is box_money, not an inline condition. if row.len() >= gmo::NCOLS_15_CONDITION_FK { let cond_id: u64 = field(row, gmo::CONDITION_ID_15).parse().unwrap_or(0); if cond_id == 0 { @@ -2321,12 +1811,6 @@ fn resolve_gossip_option_condition( (gossip_condition::NONE, 0, 0) } -/// Resolve a `gossip_menu_option` row's clickable label: mirrors the dual-schema handling -/// `parse_npc_text_slots` already does for `npc_text` — a new-schema dump's `option_text` column -/// carries a `broadcast_text` id (a bare integer) instead of literal text. Tries the broadcast-id -/// interpretation FIRST, but only trusts it when the id actually resolves (a real hand-written option -/// label is never a bare number that happens to collide with a live `broadcast_text` id, so this -/// ordering is safe for both vintages); falls through to the literal column otherwise. fn resolve_gossip_option_text( row: &[String], broadcast: &HashMap, @@ -2345,14 +1829,6 @@ fn resolve_gossip_option_text( null_to_empty(raw).to_string() } -/// Reclassify a gossip option's `action` by its resolved TEXT, for the handful of cmangos rows whose -/// real behavior is gated in C++ code at `GossipHello`, not by the dump's `OptionType` column — so -/// the column the importer copies through unchanged (see `gossip_option`'s doc) doesn't carry it. -/// Confirmed empirically: every "I wish to unlearn my talents." row across the live dump -/// (38/38) imports with `OptionType=1` (`gossip_option::GOSSIP`), indistinguishable from a plain -/// gossip line, so `filtered_gossip_options`' level-10 gate has nothing to key on without this pass. -/// Falls through to the raw `action` for every other row (the importer-reclassify-by-name pattern — -/// same shape as the wolf-faction and spell-kind reclassification passes). fn reclassify_gossip_option_action(text: &str, raw_action: u32) -> u32 { if text == "I wish to unlearn my talents." { lyracore_shared::constants::gossip_option::UNLEARNTALENTS @@ -2361,11 +1837,6 @@ fn reclassify_gossip_option_action(text: &str, raw_action: u32) -> u32 { } } -/// Build `game_gossip_menu` + `game_npc_text` + `game_npc_text_slot` + `game_gossip_option` SQL rows -/// from the cmangos dump for the given in-box creature template entries. Two `npc_text` data paths: -/// • Full 81-col npc_text rows → text extracted directly. -/// • Short 17-col rows (broadcast_text schema) → BroadcastTextId0..7 → broadcast_text.Text. -/// Returns `GossipEtl` (INSERT value-tuples per table). struct GossipEtl { menu_rows: Vec, npc_text_rows: Vec, @@ -2378,7 +1849,6 @@ struct GossipEtl { fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet) -> GossipEtl { use std::collections::{HashMap, HashSet}; - // 1. Build broadcast_text id → (male, female) text map (new-schema npc_text AND gossip options). let mut broadcast: HashMap = HashMap::new(); for row in parse_table(dump, "broadcast_text") { let id: u64 = field(&row, bt::ID).parse().unwrap_or(0); @@ -2392,7 +1862,6 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet = HashMap::new(); let mut npc_text_slots: HashMap = HashMap::new(); for row in parse_table(dump, "npc_text") { @@ -2408,10 +1877,6 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet = HashMap::new(); let mut menu_text_is_conditional: HashMap = HashMap::new(); for row in parse_table(dump, "gossip_menu") { @@ -2421,7 +1886,6 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet().unwrap_or(0) != 0; - // Among rows of the same kind the first seen wins, so the choice is deterministic. let held_conditional = menu_text_is_conditional.get(&entry).copied(); if held_conditional.is_none() || (held_conditional == Some(true) && !conditional) { menu_to_text.insert(entry, text_id); @@ -2429,12 +1893,6 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet = Vec::new(); let mut needed_text_ids: HashSet = HashSet::new(); let mut entry_to_menu: HashMap = HashMap::new(); @@ -2449,18 +1907,14 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet = Vec::new(); let mut npc_text_slot_rows: Vec = Vec::new(); let mut slot_row_id: u64 = 1; @@ -2485,7 +1938,7 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet = HashMap::new(); for row in parse_table(dump, "conditions") { let id: u64 = field(&row, cond::ENTRY).parse().unwrap_or(0); @@ -2527,9 +1978,7 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet> = opts.iter().collect(); - // Dense, deterministic option_index — the cmangos `id` order (matches in-window order). sorted_opts.sort_by_key(|r| field(r, gmo::ID).parse::().unwrap_or(0)); - // Counted rather than enumerated so a dropped row leaves no gap in the index. let mut option_index = 0u32; for row in sorted_opts.iter() { let icon: u32 = field(row, gmo::OPTION_ICON).parse().unwrap_or(0); @@ -2538,7 +1987,6 @@ fn build_gossip_sql(dump: &str, creature_entries: &std::collections::HashSet, texts: Vec, @@ -2611,34 +2057,16 @@ struct QuestEtl { reward_spells: Vec, reward_choices: Vec, relations: Vec, - // GAMEOBJECT giver relations (GO 68 "Wanted Poster" starts q176 Wanted: Hogger, - // GO 55/56 "Lost Guards" corpses drive q37/q45/q71): the `game_gameobject_quest` twin of `relations`. go_relations: Vec, - // Reward mail (T3): one row per in-scope quest that sends a letter at turn-in, from either - // `quest_template.RewMailTemplateId` or a `dbscripts_on_quest_end` `SCRIPT_COMMAND_SEND_MAIL` - // row. `reward_mail_template_ids` is the union of named templates, which scopes the - // `mail_loot_template` import to only the templates this run's reward mail actually names. reward_mail: Vec, reward_mail_template_ids: std::collections::HashSet, reward_item_entries: std::collections::HashSet, - // Collect-objective ReqItemId entries — unioned into the item-template load so every collect-quest - // item is guaranteed a template even if item_template ever returns to a referenced subset. req_item_entries: std::collections::HashSet, - // Coverage counters: how many imported quests carry a non-zero next_quest_id - // (forward-chained) / limit_time (timed). Reported by the caller's eprintln! coverage line and - // backing `FLOOR_QUESTS_CHAINED` in importer/scripts/import-manifest.sh. chained_count: usize, timed_count: usize, - // How many imported quests carry QUEST_TYPE_RAID (62). Reported, not floor-gated: today's - // world slices may hold none. raid_quest_count: usize, } -/// Build the RAW (unflattened) `reference_loot_template` map: pool entry → its own rows -/// `(item, chance, mincountOrRef, maxcount)`. Kept separate from `parse_loot_family` so it is parsed -/// ONCE and shared by every family — creature/pickpocket/gameobject/skinning/fishing all reference the -/// SAME cmangos pool table. `mincountOrRef` is kept RAW (not pre-filtered to ≥0) — a pool row can itself -/// be a reference to ANOTHER pool (`mincountOrRef < 0`), which `resolve_ref_pool` recurses into. fn parse_ref_pools(dump: &str) -> std::collections::HashMap> { let mut refs: std::collections::HashMap> = std::collections::HashMap::new(); @@ -2655,21 +2083,13 @@ fn parse_ref_pools(dump: &str) -> std::collections::HashMap>, visited: &mut std::collections::HashSet, ) -> Vec<(u64, f64, u32)> { if !visited.insert(pool_id) { - return Vec::new(); // TRUE cycle: this pool is on the CURRENT recursion path — stop + return Vec::new(); } let Some(rows) = refs.get(&pool_id) else { visited.remove(&pool_id); @@ -2678,10 +2098,7 @@ fn resolve_ref_pool( let mut out = Vec::new(); for &(item, chance, mincount_or_ref, maxcount) in rows { if mincount_or_ref < 0 { - // Nested reference. cmangos semantics: the ref row's own chance GATES entry into the - // pool, which then rolls one item internally — so the flattened children's chances are - // NORMALIZED so their group total equals the gate (a 2-deep chain multiplies through). - // gate 0 = "always" in cmangos reference rows → splice children at their own chances. + // Reference chance zero means always; otherwise it gates the normalized child weights. let children = resolve_ref_pool((-mincount_or_ref) as u64, refs, visited); let gate = chance.abs(); if gate > 0.0 { @@ -2689,7 +2106,7 @@ fn resolve_ref_pool( if child_total > 0.0 { let scale = gate / child_total; out.extend(children.into_iter().map(|(i, c, n)| (i, c * scale, n))); - } // all-zero-chance pool under a nonzero gate: nothing to weight — drop (loud in coverage) + } } else { out.extend(children); } @@ -2702,22 +2119,6 @@ fn resolve_ref_pool( out } -/// Parse ANY cmangos `*_loot_template` table into entry-keyed drops `(item, chance_bp, count, group_id, -/// quest_only)` — the five families (`creature_loot_template`, `pickpocketing_loot_template`, -/// `fishing_loot_template` [zone-keyed — its `entry` column is an AreaTable zone id, not a -/// creature/GO/skin id], `gameobject_loot_template`, `skinning_loot_template`) all share this IDENTICAL -/// column layout (see `clt`). Lifted out of the old creature-only `parse_creature_drops` so the quest -/// objective-emission gate (`creature_drop_item_set`) and every family's loot-row builder consume the -/// SAME parse — they can never diverge on what an in-slice entry actually drops. Pure function of -/// `dump`/`table`/`refs`. `reference_loot_template` pools are expanded RECURSIVELY at import -/// (`resolve_ref_pool`, cycle-guarded), a FRESH `visited` set per referencing row, so the -/// SAME pool resolves independently for every entry that references it (only a resolution's OWN -/// recursion path is cycle-guarded, not the whole import). `quest_only` is cmangos's -/// `ChanceOrQuestChance < 0` marker, KEPT (not folded away with `.abs()`) so -/// the module can gate the roll on quest possession at roll time (see the module doc). A -/// raw-chance-0 row is an empty placeholder → skipped. -// ETL row shapes: each tuple is one parsed cmangos `*_loot_template` row, kept positional so the -// parse and the emit stay side by side. #[allow(clippy::type_complexity)] fn parse_loot_family( dump: &str, @@ -2725,12 +2126,7 @@ fn parse_loot_family( refs: &std::collections::HashMap>, ) -> std::collections::HashMap> { use std::collections::HashMap; - // Chances are basis points (×100 from the dump's percent) so sub-1% odds survive. Direct rows keep - // cmangos's groupid (0 = independent; >0 = a pick-one group). A reference row (mincountOrRef<0) - // expands its (now recursively-flattened) shared pool into a pick-one GROUP gated by the row's - // chance: the group total weight = gate_bp, so it drops one pool item gate% of the time. Within the - // pool, explicit chances take gate_bp × chance/100; equal-chance (0) items split the leftover - // uniformly — the vanilla group algorithm, representable via chance_bp + group_id. + // Loot chances use basis points, preserving odds below one percent. let bp = |pct: f64| (pct * 100.0).round() as i64; let mut drops_by_entry: HashMap> = HashMap::new(); for row in parse_table(dump, table) { @@ -2740,9 +2136,6 @@ fn parse_loot_family( let groupid: u32 = field(&row, clt::GROUPID).parse().unwrap_or(0); let ref_or_min: i64 = field(&row, clt::MINCOUNT_OR_REF).parse().unwrap_or(0); let maxcount: u32 = field(&row, clt::MAXCOUNT).parse().unwrap_or(1); - // A NEGATIVE `ChanceOrQuestChance` is cmangos's QUEST-drop marker: the item drops at |chance|%, - // but ONLY for a looter who currently needs it (see the module doc), kept as `quest_only`, - // not folded away. A 0 row is an empty placeholder → skip. let quest_only = raw_chance < 0.0; let chance = raw_chance.abs(); if chance == 0.0 { @@ -2750,9 +2143,7 @@ fn parse_loot_family( } let drops = drops_by_entry.entry(entry).or_default(); if ref_or_min < 0 { - // Reference row → expand the (recursively-flattened) shared pool as a pick-one group gated - // by `chance`. A FRESH visited-set per row: the same pool_id resolves independently for - // every referencing row (only guards THIS resolution's own recursion path against a cycle). + // Each reference row needs its own recursion path to resolve shared pools. let pool_id = (-ref_or_min) as u64; let mut visited = std::collections::HashSet::new(); let items = resolve_ref_pool(pool_id, refs, &mut visited); @@ -2791,9 +2182,6 @@ fn parse_loot_family( drops_by_entry } -/// `creature_loot_template` drops, keyed by LootId. Thin wrapper over `parse_loot_family` kept for its -/// call sites' clarity (`build_items_and_loot`, `creature_drop_item_set`). -// ETL row shape: one resolved creature drop per tuple. #[allow(clippy::type_complexity)] fn parse_creature_drops( dump: &str, @@ -2802,12 +2190,6 @@ fn parse_creature_drops( parse_loot_family(dump, "creature_loot_template", &refs) } -/// The set of item ids that drop from creatures WE ACTUALLY SPAWN (`creature_loot_ids` = in-slice -/// entry→LootId). Drives the COLLECT-objective obtainability gate in `build_quests`: a quest may only -/// emit a COLLECT_ITEM objective for a ReqItem that is in this set (drops in-slice) OR is the quest's -/// SrcItem (granted on accept). Anything else would be an unobtainable objective → soft-lock, so it's -/// skipped (the quest auto-completes instead). Uses the same `parse_creature_drops` the loot builder -/// uses, so the two never disagree on what drops. fn creature_drop_item_set( dump: &str, creature_loot_ids: &std::collections::HashMap, @@ -2824,11 +2206,6 @@ fn creature_drop_item_set( set } -/// Items lootable from IN-SLICE gameobjects (chests): `gameobject_template` type 3 rows among -/// `used_go` carry their `gameobject_loot_template` lootId in DATA1; union those pools' items. -/// Extends the obtainability gate — the grape/crate/wood collect quests want items that only -/// GO loot drops, and skipping their objectives made the quests auto-complete for a free reward. -/// Same `parse_loot_family` the GO-loot builder uses, so the two never disagree. fn gameobject_loot_item_set( dump: &str, used_go: &std::collections::HashSet, @@ -2853,10 +2230,6 @@ fn gameobject_loot_item_set( set } -/// Items sold by IN-SLICE vendors (direct `npc_vendor` rows + the `npc_vendor_template` -/// indirection via creature_template col 76) — the third leg of the obtainability gate -/// (q3861's kodo feed is vendor-only). A trimmed twin of the canonical vendor ETL in -/// `build_items_and_loot` (which also carries slots/maxcount); only the ITEM SET matters here. fn vendor_stock_item_set( dump: &str, local_creatures: &std::collections::HashSet, @@ -2893,10 +2266,7 @@ fn vendor_stock_item_set( set } -/// Build quest definitions for the supplied content scope. `local_givers` restricts giver -/// relations without removing definitions a transferred Character still needs. Required and reward -/// item entries feed the item import; generated row IDs follow deterministic source orders. -#[allow(clippy::too_many_lines)] // Column-by-column mapping of one imported dump family. +#[allow(clippy::too_many_lines)] fn build_quests( dump: &str, catalogue_creatures: &std::collections::HashSet, @@ -2910,7 +2280,6 @@ fn build_quests( ) -> Result { use std::collections::HashSet; - // Creature and gameobject giver tables share the same entry/quest column layout. let relations_for = |table: &str, entries: &HashSet| -> Vec<(u64, u64)> { parse_table(dump, table) .into_iter() @@ -2933,15 +2302,12 @@ fn build_quests( .map(|&(_, q)| q) .collect(); - // 1) quest_template rows for the candidate quests (a relation can dangle past the template set; - // only quests with a real template are kept — they drive which relations/objectives we emit). let mut quest_rows: Vec = Vec::new(); let mut quest_text_rows: Vec = Vec::new(); let mut objective_rows: Vec = Vec::new(); let mut cast_objective_rows: Vec = Vec::new(); let mut reward_item_rows: Vec = Vec::new(); let mut reward_spell_rows = Vec::new(); - // Single LearnSpell wrappers have no other reward behavior to preserve. let reward_learn_spells: HashMap = parse_table(dump, "spell_template") .into_iter() .filter_map(|row| { @@ -2958,9 +2324,6 @@ fn build_quests( let mut reward_item_entries: HashSet = HashSet::new(); let mut req_item_entries: HashSet = HashSet::new(); let mut valid_quests: HashSet = HashSet::new(); - // Reward mail (T3): rows for `game_quest_reward_mail`, and the quest entries that already claimed - // one — a quest naming both a `quest_template` template AND a dbscripts send-mail row is a data - // shape the importer refuses (see the dbscripts pass below). let mut reward_mail_rows: Vec = Vec::new(); let mut reward_mail_template_ids: HashSet = HashSet::new(); let mut reward_mail_quests: HashSet = HashSet::new(); @@ -2971,8 +2334,6 @@ fn build_quests( let mut cast_obj_id: u64 = 1; let mut rew_id: u64 = 1; let mut rew_choice_id: u64 = 1; - // Per-quest count of emitted objectives → the next obj_index for area-trigger objectives (added after - // the kill/collect loops) so they line up with CharacterQuest.counts. let mut quest_obj_count: std::collections::HashMap = std::collections::HashMap::new(); for row in parse_table(dump, "quest_template") { let entry: u64 = field(&row, qt::ENTRY).parse().unwrap_or(0); @@ -2980,8 +2341,6 @@ fn build_quests( continue; } valid_quests.insert(entry); - // Reward mail (T3): RewMailTemplateId names the letter; the giver sends it (no alternative - // sender or money — those columns belong to the dbscripts source, handled after this loop). let mail_template: u32 = field(&row, qt::REW_MAIL_TEMPLATE_ID).parse().unwrap_or(0); if mail_template != 0 { let mail_delay: u32 = field(&row, qt::REW_MAIL_DELAY_SECS).parse().unwrap_or(0); @@ -3002,37 +2361,19 @@ fn build_quests( let min_level: u32 = field(&row, qt::MIN_LEVEL).parse().unwrap_or(0); let quest_level: i64 = field(&row, qt::QUEST_LEVEL).parse().unwrap_or(0); let money: i64 = field(&row, qt::REW_OR_REQ_MONEY).parse().unwrap_or(0); - // cmangos RewMoneyMaxLevel — vanilla's Quest::XPValue divides this by 0.6 for the authentic - // quest XP (lyracore_shared::quest::quest_xp). ~18% of quests have 0 here (no-XP quests). Clamp ≥ 0. let reward_money_max_level: i64 = field(&row, qt::REW_MONEY_MAX_LEVEL).parse().unwrap_or(0); - // Availability gates: prerequisite quest (cmangos PrevQuestId can be negative for exclusive-group - // semantics we don't model yet — clamp <0 to 0 = "no prereq" so it doesn't wrongly block) + - // race/class bitmasks. reward_xp stays 0 (computed). let prev_quest: i64 = field(&row, qt::PREV_QUEST_ID).parse().unwrap_or(0); - // Repeatable (SpecialFlags bit0, QUEST_SPECIAL_FLAGS_REPEATABLE): lets the quest be re-accepted - // right after turn-in (module::quest::apply_turn_in_quest deletes rather than rewards-and-keeps - // the log row when set). let special_flags: u32 = field(&row, qt::SPECIAL_FLAGS).parse().unwrap_or(0); let repeatable = (special_flags & 1) != 0; let req_races: u32 = field(&row, qt::REQUIRED_RACES).parse().unwrap_or(0); let req_classes: u32 = field(&row, qt::REQUIRED_CLASSES).parse().unwrap_or(0); let zone_or_sort: i64 = field(&row, qt::ZONE_OR_SORT).parse().unwrap_or(0); - // Reputation rewards: 2 of the 5 cmangos RewRepFaction/Value slots (most Elwynn quests use - // ≤1). Faction id stays as-is (0 = none); the value keeps its sign (vanilla values are positive, - // but a rep LOSS is representable). Deliberate simplification: 2 slots — bump to 5 if a - // quest needs >2 factions. let rf1: u32 = field(&row, qt::REW_REP_FACTION1).parse().unwrap_or(0); let rv1: i64 = field(&row, qt::REW_REP_VALUE1).parse().unwrap_or(0); let rf2: u32 = field(&row, qt::REW_REP_FACTION1 + 1).parse().unwrap_or(0); let rv2: i64 = field(&row, qt::REW_REP_VALUE1 + 1).parse().unwrap_or(0); - // Provided "source" item (cmangos SrcItemId/SrcItemCount): the item handed to the player on - // accept. For a delivery quest SrcItemId == ReqItemId, so granting it on accept satisfies the - // COLLECT objective. A dump count of 0 means 1 (cmangos treats 0 as 1; the module floors at 1). let src_item: u64 = field(&row, qt::SRC_ITEM_ID).parse().unwrap_or(0); let src_item_count: u32 = field(&row, qt::SRC_ITEM_COUNT).parse().unwrap_or(0); - // Forward chains + timed quests: NextQuestInChain (0 = no successor; clamp - // negative to 0 like prev_quest above) and LimitTime seconds (0 = untimed; cmangos never emits - // a negative LimitTime, but clamp defensively the same way every other numeric column here does). let next_quest_raw: i64 = field(&row, qt::NEXT_QUEST_IN_CHAIN).parse().unwrap_or(0); let limit_time_raw: i64 = field(&row, qt::LIMIT_TIME).parse().unwrap_or(0); if next_quest_raw > 0 { @@ -3041,9 +2382,6 @@ fn build_quests( if limit_time_raw > 0 { timed_count += 1; } - // Raid quest-credit rule: QUEST_TYPE_RAID = 62 (cm:QuestDef.h:121). Every other value is - // an ordinary quest; the module gates kill credit, quest-item need and quest-object need - // on it in a Raid. let quest_type: u32 = field(&row, qt::TYPE).parse().unwrap_or(0); if quest_type == 62 { raid_quest_count += 1; @@ -3058,8 +2396,6 @@ fn build_quests( limit_time = limit_time_raw.max(0), rmm = reward_money_max_level.max(0), )); - // Body text (side table) — Details/Objectives/OfferReward/RequestItems, verbatim ($B/$N tokens - // and all), so the dialogs render real text instead of empty sections. quest_text_rows.push(format!( "({entry},{details},{objectives},{offer},{request})", details = sql_text(field(&row, qt::DETAILS)), @@ -3067,9 +2403,6 @@ fn build_quests( offer = sql_text(field(&row, qt::OFFER_REWARD_TEXT)), request = sql_text(field(&row, qt::REQUEST_ITEMS_TEXT)), )); - // Objectives: up to 4 ReqCreatureOrGO slots; a POSITIVE id is a creature kill (negative = a - // gameobject, skipped). obj_index is the dense 0-based position among emitted objectives, so it - // lines up with CharacterQuest.counts in the module. let mut obj_index: u8 = 0; for i in 0..4 { let id: i64 = field(&row, qt::REQ_CREATURE_OR_GO_ID1 + i) @@ -3083,9 +2416,7 @@ fn build_quests( continue; } if id > 0 { - objective_rows.push(format!( - "({obj_id},{entry},{obj_index},0,{id},{count})", // kind 0 = KILL_CREATURE - )); + objective_rows.push(format!("({obj_id},{entry},{obj_index},0,{id},{count})",)); if spell_id != 0 { cast_objective_rows .push(format!("({cast_obj_id},{entry},{obj_index},{spell_id})")); @@ -3094,12 +2425,10 @@ fn build_quests( obj_id += 1; obj_index += 1; } else if id < 0 { - // A NEGATIVE id is a gameobject use-objective. Emit it ONLY if that GO is an imported - // GOOBER (type 10) — else the quest references an un-spawned object and would be stuck. let go_entry = (-id) as u64; if goobers.contains(&go_entry) { objective_rows.push(format!( - "({obj_id},{entry},{obj_index},2,{go_entry},{count})", // kind 2 = USE_GAMEOBJECT + "({obj_id},{entry},{obj_index},2,{go_entry},{count})", )); if spell_id != 0 { cast_objective_rows @@ -3111,53 +2440,25 @@ fn build_quests( } } } - // Collect objectives (parity): up to 4 ReqItemId slots → "hold N of item X". kind 1 = - // COLLECT_ITEM, target_entry = the item entry; the module checks a LIVE inventory count and - // consumes them on turn-in. Continues the same dense obj_index space as the kill loop above, so - // a quest with BOTH (kill + collect) sizes CharacterQuest.counts correctly. - // - // ENABLED (EMIT_COLLECT_OBJECTIVES = true): the loot ETL now drops the quest items. The loot path - // takes `chance.abs()` (build_items_and_loot:794), so cmangos negative-chance QUEST drops (e.g. 752 - // Red Burlap Bandana, 182 Garrick's Head) drop for everyone — the prerequisite this flag waited on. - // The ReqItemId entries are ALSO force-included into the item-template load (req_item_entries below - // → extra_item_entries), so every collect item has a template even if item_template ever returns to - // a subset. With items obtainable, emitting these COLLECT objectives makes the collect quests - // genuinely completable instead of auto-completing (the old bug). The MODULE mechanic checks a LIVE - // inventory count + consumes on turn-in. 042: GO-chest loot + vendor stock now count as - // obtainable too (the gate below unions all three sources), so the grapes/wood/feed quests - // emit real objectives instead of auto-completing. const EMIT_COLLECT_OBJECTIVES: bool = true; - // The provided item itself needs a template (grant_item Errs "no such item" otherwise) — force - // it into the item-template load even if it isn't also a ReqItem. if src_item > 0 { req_item_entries.insert(src_item); } - // Always collect the ReqItemId entries for the item-template force-include, independent of whether - // the objective is emitted — guarantees a template for every collect item. for i in 0..4 { let item: i64 = field(&row, qt::REQ_ITEM_ID1 + i).parse().unwrap_or(0); let count: u32 = field(&row, qt::REQ_ITEM_COUNT1 + i).parse().unwrap_or(0); if item > 0 { req_item_entries.insert(item as u64); } - // ZERO-STUCK-QUESTS: emit a COLLECT objective ONLY for an OBTAINABLE item — one that drops - // from an in-slice creature, drops from an in-slice CHEST, is sold by an in-slice VENDOR - // (the drops-only gate skipped GO/vendor ReqItems → empty objective set → free - // turn-in on accept), or is the quest's SrcItem (handed over on accept). A genuinely - // unobtainable ReqItem (mining/out-of-slice) still gets NO objective → the quest stays - // completable on its obtainable subset instead of soft-locking. let obtainable = item > 0 && (obtainable_items.contains(&(item as u64)) || item as u64 == src_item); if EMIT_COLLECT_OBJECTIVES && obtainable && count > 0 { - objective_rows.push(format!( - "({obj_id},{entry},{obj_index},1,{item},{count})", // kind 1 = COLLECT_ITEM - )); + objective_rows.push(format!("({obj_id},{entry},{obj_index},1,{item},{count})",)); obj_id += 1; obj_index += 1; } } quest_obj_count.insert(entry, obj_index); - // Guaranteed reward items: up to 4 RewItemId slots. for i in 0..4 { let item: u64 = field(&row, qt::REW_ITEM_ID1 + i).parse().unwrap_or(0); let count: u32 = field(&row, qt::REW_ITEM_COUNT1 + i).parse().unwrap_or(0); @@ -3170,13 +2471,6 @@ fn build_quests( rew_id += 1; } } - // Choice reward items (pick-1-of-N): up to 6 RewChoiceItemId slots. choice_index = a DENSE rank - // (0-based over the PRESENT slots — `choice_rank`, NOT the raw dump slot `i`), so an empty leading - // slot is skipped: this matches the ARRAY POSITION the client shows and replies with as `reward`, and - // the packet order the gateway sends (it sorts rows by choice_index). All 553 real choice-quests are - // gap-free (rank == i), but a custom quest with a leading gap would otherwise mismatch the wire pick. - // The chosen item entries union into the SAME reward_item_entries set so they flow through - // extra_item_entries into the item-template load — no new union site. let mut choice_rank = 0u8; for i in 0..6 { let item: u64 = field(&row, qt::REW_CHOICE_ITEM_ID1 + i) @@ -3197,10 +2491,6 @@ fn build_quests( } } - // 1.6) Reward mail from dbscripts_on_quest_end SCRIPT_COMMAND_SEND_MAIL (38) rows, for in-scope - // quests (the pinned dump carries exactly three: 8728, 5237, 5238). A quest that ALSO - // claimed a reward-mail row from quest_template above is refused — the two sources disagree - // about which letter the quest sends, and picking one silently would hide that. for row in parse_table(dump, "dbscripts_on_quest_end") { let command: u32 = field(&row, dse::COMMAND).parse().unwrap_or(0); if command != dse::SEND_MAIL { @@ -3226,10 +2516,6 @@ fn build_quests( )); } - // 1.5) Explore-area objectives (kind 3) from areatrigger_involvedrelation: a quest completed by - // ENTERING a zone (Jasperlode/Fargodeep mines). For each imported quest, append an - // EXPLORE_AREATRIGGER objective with target_entry = the trigger id; the client's CMSG_AREATRIGGER - // drives enter_areatrigger → on_areatrigger_entered increments it. for row in parse_table(dump, "areatrigger_involvedrelation") { let trigger: u64 = field(&row, ai::TRIGGER).parse().unwrap_or(0); let quest: u64 = field(&row, ai::QUEST).parse().unwrap_or(0); @@ -3237,14 +2523,11 @@ fn build_quests( continue; } let idx = quest_obj_count.get(&quest).copied().unwrap_or(0); - objective_rows.push(format!("({obj_id},{quest},{idx},3,{trigger},1)")); // kind 3 = EXPLORE_AREATRIGGER + objective_rows.push(format!("({obj_id},{quest},{idx},3,{trigger},1)")); obj_id += 1; quest_obj_count.insert(quest, idx + 1); } - // 2) creature_quest / gameobject_quest relation rows for the local relations whose quest survived - // the template join. Sorted + counter-assigned ids so clear+reload is idempotent (each table's - // ids are independent, so the two emitters below use SEPARATE counters). role: 0 = START, 1 = END. let mut creature_quest_rows: Vec = Vec::new(); let mut cq_id: u64 = 1; let mut emit_relations = |pairs: &[(u64, u64)], role: u8| { @@ -3266,8 +2549,6 @@ fn build_quests( emit_relations(&starts, 0); emit_relations(&ends, 1); - // Same shape, `game_gameobject_quest` table. A SEPARATE counter (`gq_id`), this - // table's ids are independent of `game_creature_quest`'s. let mut go_quest_rows: Vec = Vec::new(); let mut gq_id: u64 = 1; let mut emit_go_relations = |pairs: &[(u64, u64)], role: u8| { @@ -3309,12 +2590,7 @@ fn build_quests( }) } -/// First pass: DIRECT drops only — positive `ChanceOrQuestChance`, real item, `mincountOrRef >= 0` -/// (a negative ref points at another loot group, which we don't expand yet). Quest drops (negative -/// chance) are skipped until a quest system exists. Loot ids are counter-assigned over a sorted -/// creature order so clear+reload stays idempotent. `extra_item_entries` (e.g. quest reward items) are -/// force-included in the item-template load so every referenced item gets a template. -#[allow(clippy::too_many_lines)] // Column-by-column mapping of one imported dump family. +#[allow(clippy::too_many_lines)] fn build_items_and_loot( dump: &str, creature_loot_ids: &std::collections::HashMap, @@ -3324,22 +2600,15 @@ fn build_items_and_loot( ) -> Result<(Vec, Vec, Vec)> { use std::collections::HashSet; - // 0+1) Parse the LootId-keyed creature drops (reference-pool expansion + direct rows). Shared with - // creature_drop_item_set so the quest objective-emission gate and the loot rows never diverge. let drops_by_lootid = parse_creature_drops(dump); - // 2) game_creature_loot rows (one per creature×drop) + the referenced item set. Force-include the - // starter loadout entries so the login grant (resilient: skips a missing template) still arms a - // real weapon after a wipe+reload. let mut loot_rows: Vec = Vec::new(); let mut item_ids: HashSet = [25, 50, 51, 52].into_iter().collect(); - // Quest reward items (and any other forced entries) need templates too — the turn-in grant joins - // game_item_template, so a missing reward template would fail the turn-in. item_ids.extend(extra_item_entries.iter().copied()); item_ids.extend(mail_loot_item_entries.iter().copied()); let mut loot_id: u64 = 1; let mut creatures: Vec<(&u64, &u64)> = creature_loot_ids.iter().collect(); - creatures.sort(); // deterministic loot-id assignment across re-imports + creatures.sort(); for (creature_entry, lootid) in creatures { let Some(drops) = drops_by_lootid.get(lootid) else { continue; @@ -3353,11 +2622,9 @@ fn build_items_and_loot( } } - // 2.5) game_npc_vendor rows for the imported VENDOR creatures, adding their stock to the item set so - // every sold item gets a template (the gateway joins it for SMSG_LIST_INVENTORY + the buy path). let mut vendor_rows: Vec = Vec::new(); let mut vendor_id: u64 = 1; - let mut nv_seen: HashSet<(u64, u64)> = HashSet::new(); // dedupe (vendor, item) — cmangos lists variants + let mut nv_seen: HashSet<(u64, u64)> = HashSet::new(); for row in parse_table(dump, "npc_vendor") { let entry: u64 = field(&row, nv::ENTRY).parse().unwrap_or(0); if !vendor_entries.contains(&entry) { @@ -3374,12 +2641,6 @@ fn build_items_and_loot( vendor_id += 1; } - // 2.6) npc_vendor_template indirection: 11 spawned vendors (Corina Steele, - // Brog Hamfist, …) have ZERO direct npc_vendor rows — cmangos serves their goods via - // creature_template.VendorTemplateId → npc_vendor_template. Flatten that into the same - // per-creature game_npc_vendor rows (our schema deliberately has no template indirection). - // npc_vendor_template dump shape (CREATE TABLE, verified): entry, item, maxcount, incrtime, - // slot, condition_id, comments. let mut tmpl_stock: HashMap> = HashMap::new(); for row in parse_table(dump, "npc_vendor_template") { let t: u64 = field(&row, 0).parse().unwrap_or(0); @@ -3393,8 +2654,6 @@ fn build_items_and_loot( .push((item, slot, max_count)); } } - // creature_template.VendorTemplateId — column 76 of 87, read off the dump's own CREATE TABLE - // (same dump-verified confidence as the `at::` fix). const CT_VENDOR_TEMPLATE_ID: usize = 76; for row in parse_table(dump, "creature_template") { let entry: u64 = field(&row, ct::ENTRY).parse().unwrap_or(0); @@ -3407,7 +2666,7 @@ fn build_items_and_loot( }; for &(item, slot, max_count) in stock { if !nv_seen.insert((entry, item)) { - continue; // direct rows win; template stock tops up + continue; } item_ids.insert(item); vendor_rows.push(format!("({vendor_id},{entry},{item},{slot},{max_count})")); @@ -3416,18 +2675,18 @@ fn build_items_and_loot( } const USE_SPELL_OVERRIDE: &[(u64, u32)] = &[ - (118, 50110), // Minor Healing Potion -> Minor Healing (instant E_HEAL) - (1251, 50111), // Linen Bandage -> Linen Bandage (channeled A_PERIODIC_HEAL HoT) - (4360, 50096), // Rough Copper Bomb -> Rough Copper Bomb (E_DAMAGE AoE) - (2455, 50113), // Minor Mana Potion -> Minor Mana Potion (E_ENERGIZE, mana-class gated) - (159, 50114), // Refreshing Spring Water -> Refreshing Water (A_PERIODIC_ENERGIZE, mana-class gated) - (5350, 50114), // Conjured Water -> same drink spell - (4540, 50115), // Tough Hunk of Bread -> Eating (A_PERIODIC_HEAL food HoT) - (117, 50115), // Tough Jerky -> same food spell - (2681, 50115), // Roasted Boar Meat (level-1 food) -> same food spell, no Well-Fed buff - (2680, 50116), // Spiced Wolf Meat -> Well Fed (+Sta/+Spi buff) - (858, 50117), // Lesser Healing Potion -> Lesser Healing (E_HEAL, rank-2) - (2581, 50118), // Heavy Linen Bandage -> Heavy Linen Bandage (channeled HoT, rank-2) + (118, 50110), + (1251, 50111), + (4360, 50096), + (2455, 50113), + (159, 50114), + (5350, 50114), + (4540, 50115), + (117, 50115), + (2681, 50115), + (2680, 50116), + (858, 50117), + (2581, 50118), ]; let use_spell_override = |entry: u64| -> Option { USE_SPELL_OVERRIDE @@ -3437,9 +2696,6 @@ fn build_items_and_loot( let mut item_rows: Vec = Vec::new(); let mut emitted: HashSet = HashSet::new(); - // Coverage counters: how many items carry a nonzero resistance (any of the 6 - // schools) and how many carry a nonzero startquest link — printed after the loop so a re-import - // makes the new columns' reach visible without a manual SQL scan. let mut resist_nonzero_count: u64 = 0; let mut startquest_nonzero_count: u64 = 0; for row in parse_table(dump, "item_template") { @@ -3448,9 +2704,6 @@ fn build_items_and_loot( continue; } emitted.insert(entry); - // Scan the 10 (stat_type, stat_value) pairs → our 5 primaries. cmangos 1.12 item mod types: - // 3=AGI 4=STR 5=INT 6=SPI 7=STA (0=mana / 1=health have no game column → dropped). Crit/hit - // come from item spell effects in 1.12, not base stats, so stat_crit/stat_hit stay 0 on import. let (mut s_str, mut s_agi, mut s_sta, mut s_int, mut s_spi) = (0i32, 0, 0, 0, 0); for i in 0..10 { let st: u8 = field(&row, it::STAT_TYPE1 + 2 * i).parse().unwrap_or(0); @@ -3472,12 +2725,9 @@ fn build_items_and_loot( normalize_item_mask(field(&row, it::ALLOWED_CLASS), ALL_PLAYABLE_CLASS_MASK); let allowed_race = normalize_item_mask(field(&row, it::ALLOWED_RACE), ALL_PLAYABLE_RACE_MASK); - // Drink = Food&Drink consumable whose name reads as a beverage (see is_drink_consumable). The - // restores_power flag makes apply_item_use refill mana instead of HP. Generic predicate, no item-id list. let class_v: u8 = field(&row, it::CLASS).parse().unwrap_or(0); let subclass_v: u8 = field(&row, it::SUBCLASS).parse().unwrap_or(0); let is_drink = is_drink_consumable(class_v, subclass_v, field(&row, it::NAME)); - // The 6 resistance schools, data plumbing only, no consumer reads these yet. let holy: i32 = field(&row, it::HOLY_RES).parse().unwrap_or(0); let fire: i32 = field(&row, it::FIRE_RES).parse().unwrap_or(0); let nature: i32 = field(&row, it::NATURE_RES).parse().unwrap_or(0); @@ -3487,15 +2737,12 @@ fn build_items_and_loot( if holy != 0 || fire != 0 || nature != 0 || frost != 0 || shadow != 0 || arcane != 0 { resist_nonzero_count += 1; } - // Spell slots 3-5, completes the proc engine's item half; data plumbing - // only, same id+trigger shape as slots 1-2 above. let sp3: u32 = field(&row, it::SPELLID_3).parse().unwrap_or(0); let spt3: u8 = field(&row, it::SPELLTRIGGER_3).parse().unwrap_or(0); let sp4: u32 = field(&row, it::SPELLID_4).parse().unwrap_or(0); let spt4: u8 = field(&row, it::SPELLTRIGGER_4).parse().unwrap_or(0); let sp5: u32 = field(&row, it::SPELLID_5).parse().unwrap_or(0); let spt5: u8 = field(&row, it::SPELLTRIGGER_5).parse().unwrap_or(0); - // Skill/reputation gates: mail/plate proficiency and the reputation requirement. let req_skill: u32 = field(&row, it::REQUIRED_SKILL).parse().unwrap_or(0); let req_skill_rank: u32 = field(&row, it::REQUIRED_SKILL_RANK).parse().unwrap_or(0); let req_rep_faction: u32 = field(&row, it::REQUIRED_REPUTATION_FACTION) @@ -3504,7 +2751,6 @@ fn build_items_and_loot( let req_rep_rank: u32 = field(&row, it::REQUIRED_REPUTATION_RANK) .parse() .unwrap_or(0); - // Maxcount/flags/page_text/startquest/bag_family, data plumbing only. let max_count: u32 = field(&row, it::MAX_COUNT).parse().unwrap_or(0); let item_flags: u32 = field(&row, it::FLAGS).parse().unwrap_or(0); let page_text: u32 = field(&row, it::PAGE_TEXT).parse().unwrap_or(0); @@ -3513,13 +2759,8 @@ fn build_items_and_loot( startquest_nonzero_count += 1; } let bag_family: u32 = field(&row, it::BAG_FAMILY).parse().unwrap_or(0); - // BuyCount: floor at 1 — a 0 in the dump (or a parse miss) must not sell zero items. let buy_count: u32 = field(&row, it::BUY_COUNT).parse().unwrap_or(1).max(1); let food_type: u8 = field(&row, it::FOOD_TYPE).parse().unwrap_or(0); - // ON-USE SPELL OVERRIDE: a curated entry's spellid_1/spelltrigger_1 is OUR synthetic - // on-use cast, forced trigger 0 — see `USE_SPELL_OVERRIDE`'s doc above. Every other item keeps - // its raw dump value untouched (baseline-safe: the vast majority of the vanilla item catalog is - // unaffected). let (sp1, spt1): (u32, u8) = match use_spell_override(entry) { Some(spell_id) => (spell_id, 0), None => ( @@ -3527,20 +2768,12 @@ fn build_items_and_loot( field(&row, it::SPELLTRIGGER_1).parse().unwrap_or(0), ), }; - // game_item_template column order — see module/src/items/tables.rs. stat_crit/stat_hit - // are not in 1.12 item base stats, so 0,0 here. block_value is the shield's flat block (0 for - // non-shields) — makes imported shields actually block (combat::effective_block_value reads it). - // restores_power (drink) is a Bool → bare true/false SQL literal (the is_negative Bool-SQL note). item_rows.push(format!( "({entry},{class},{subclass},{name},{disp},{qual},{inv},{ilvl},{rlvl},{dur},{buy},{sell},{stack},{dmin},{dmax},{delay},{s_str},{s_agi},{s_sta},{s_int},{s_spi},0,0,{armor},{block},{drink},{sp1},{spt1},{sp2},{spt2},{cslots},{sheath},{bonding},{holy},{fire},{nature},{frost},{shadow},{arcane},{sp3},{spt3},{sp4},{spt4},{sp5},{spt5},{req_skill},{req_skill_rank},{req_rep_faction},{req_rep_rank},{max_count},{item_flags},{page_text},{start_quest},{bag_family},{buy_count},{food_type},{allowed_class},{allowed_race},{random_property})", sheath = field(&row, it::SHEATH).parse::().unwrap_or(0), sp2 = field(&row, it::SPELLID_2).parse::().unwrap_or(0), spt2 = field(&row, it::SPELLTRIGGER_2).parse::().unwrap_or(0), - // ContainerSlots — how many bag slots this item provides (0 for non-bags); col 24 in - // the cmangos 1.12 item_template schema, immediately after Stackable(23). cslots = field(&row, it::CONTAINER_SLOTS).parse::().unwrap_or(0), - // Bonding: 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem, mapped straight - // through to the client's "Binds when picked up/equipped" tooltip line. bonding = field(&row, it::BONDING).parse::().unwrap_or(0), class = field(&row, it::CLASS), subclass = field(&row, it::SUBCLASS), @@ -3571,9 +2804,6 @@ fn build_items_and_loot( startquest_nonzero_count ); - // Mail loot (T3) is held to a stricter rule than the general dangling guard below: a letter that - // names an item with no template would grant nothing, silently — fail loud instead, naming the - // item. let mut missing_mail_items: Vec = mail_loot_item_entries .iter() .copied() @@ -3584,8 +2814,6 @@ fn build_items_and_loot( bail!("item_template: mail_loot_template item {item} has no item_template row"); } - // Dangling-reference guard: any loot/vendor/quest-reward item with no item_template row would break - // that drop/turn-in. With the full set imported this should be empty; warn loudly if not. let mut dangling: Vec = item_ids .iter() .copied() @@ -3603,11 +2831,6 @@ fn build_items_and_loot( Ok((item_rows, loot_rows, vendor_rows)) } -/// `game_mail_loot` rows for every template `reward_mail_template_ids` names, from cmangos -/// `mail_loot_template`. Every real row is a 100% single item with group 0, count 1 and no condition -/// (a mail carries at most one item) — anything outside that shape is refused, naming the table and -/// the offending mail template, rather than silently dropped or truncated to the first match. A -/// named template with no row here sends a text- or money-only letter. fn build_mail_loot( dump: &str, reward_mail_template_ids: &std::collections::HashSet, @@ -3656,20 +2879,10 @@ fn build_mail_loot( item_entries.insert(item); rows.push((entry, format!("({entry},{item},1)"))); } - rows.sort_unstable_by_key(|(entry, _)| *entry); // deterministic import order + rows.sort_unstable_by_key(|(entry, _)| *entry); Ok((rows.into_iter().map(|(_, sql)| sql).collect(), item_entries)) } -/// Append chunked `INSERT INTO {table} ({cols}) VALUES (...)` statements for `rows` — bounded BOTH by -/// row count (≤500) AND by byte size (≤~32KB/statement). The byte bound matters for text-heavy tables -/// like `game_quest_text`: at a full-Elwynn slice its 170 rows of quest prose are one 110KB statement -/// that `spacetime sql` rejects (400) — row-count chunking alone never splits it. No-op for empty `rows`. -/// Data-row count across a family's generated statement list: DELETEs contribute 0; each -/// `push_insert`-shaped INSERT contributes its VALUES tuple count (`),(` separators + 1). -/// Feeds the provenance stamps for families whose builders return statements -/// rather than row vectors — statement `.len()` would under-report by the batch factor. -/// Only sound for numeric-payload tables (a text value containing `),(` would over-count); -/// the "globals" builders (stats/positions/graveyards/createinfo) are all numeric. fn insert_row_count(stmts: &[String]) -> u64 { stmts .iter() @@ -3679,7 +2892,7 @@ fn insert_row_count(stmts: &[String]) -> u64 { } pub(crate) fn push_insert(stmts: &mut Vec, table: &str, cols: &str, rows: &[String]) { - const MAX_BYTES: usize = 32 * 1024; // well under the observed spacetime-sql statement-size limit + const MAX_BYTES: usize = 32 * 1024; let mut batch: Vec<&str> = Vec::new(); let mut bytes = 0usize; let flush = |batch: &mut Vec<&str>, stmts: &mut Vec| { @@ -3702,8 +2915,6 @@ pub(crate) fn push_insert(stmts: &mut Vec, table: &str, cols: &str, rows flush(&mut batch, stmts); } -/// Keep a creature inside the scope, or a forced entry anywhere on a planned map. Forced entries -/// bypass geometry only; the map fence always applies. Pool slots cannot match a forced entry. fn creature_row_kept_in_scope( scope: &WorldImportScope, map: i64, @@ -3746,12 +2957,6 @@ pub(crate) fn row_in_slice(args: &Args, map: i64, x: f64, y: f64, z: f64) -> boo .contains(map, x, y, z) } -/// What `--print-extents` derived from the dump. `anchor` is the in-slice spawn CLOSEST to the -/// rectangle's centre — a REAL cmangos ground point, which is exactly what `--center` needs (the -/// terrain importer's interpolate self-check compares the ADT height at `--center` against the Z the -/// operator supplied, and the nav importer requires `--center` to rasterize walkable). -/// deriving a second continent's box also means deriving a second continent's centre, and the map-0 -/// default (Northshire) is off-slice there. struct Extents { count: u64, min: (f64, f64, f64), @@ -3759,12 +2964,6 @@ struct Extents { anchor: (f64, f64, f64), } -/// Pure spawn-bbox computation for `--print-extents`: scan every `creature` row on `map` — optionally -/// pre-filtered to a CANDIDATE `--box` rectangle. A whole continent's extents are the -/// continent, so deriving a starting-zone box is an iterate-and-narrow loop: pass a rough rectangle, -/// read back the real spawn extents and count inside it. Returns `None` if no row matched -/// (nothing to report). Split out from `print_extents` so the corner math is unit-tested against a -/// synthetic dump without stdout. fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> Option { let mut count = 0u64; let mut min = (f64::INFINITY, f64::INFINITY, f64::INFINITY); @@ -3789,8 +2988,6 @@ fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> if count == 0 { return None; } - // The spawn nearest the derived rectangle's centre — a real ground point INSIDE the slice, so it - // is always a valid `--center` (2D distance: the centre of a Z range is meaningless terrain-wise). let (mid_x, mid_y) = ((min.0 + max.0) / 2.0, (min.1 + max.1) / 2.0); let anchor = *kept .iter() @@ -3807,10 +3004,6 @@ fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> }) } -/// The cmangos `playercreateinfo` start positions ON `map`, as `(race, class, zone, x, y, z)` — the -/// canonical starting-zone ANCHORS of a continent: "where does a Kalimdor box go?" is -/// answered by the dump's own Valley of Trials / Camp Narache / Shadowglen rows, not from memory. -/// Pure so it is unit-testable without stdout; printed by `print_extents` below. fn start_positions_on_map(dump: &str, map: i64) -> Vec<(u32, u32, u32, f64, f64, f64)> { parse_table(dump, "playercreateinfo") .iter() @@ -3866,9 +3059,7 @@ fn print_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> Re Ok(()) } -/// Run a sequence of `spacetime sql` statements as the owner. Each is a separate invocation (Simple -/// Query Protocol — no multi-statement transaction), so on failure the sequence stops mid-way; the -/// clear+reload is idempotent, so re-running recovers. Shared by the cmangos and DBC streams. +// Each SQL statement commits separately; a failed sequence requires an idempotent rerun. pub(crate) fn run_sql_statements(args: &Args, stmts: &[String], label: &str) -> Result<()> { for (n, s) in stmts.iter().enumerate() { eprintln!( @@ -3892,20 +3083,6 @@ pub(crate) fn run_sql_statements(args: &Args, stmts: &[String], label: &str) -> Ok(()) } -/// The fixed set of `--family` values this importer recognizes, one per independent -/// clear+reload block the `--dump` ETL below already builds (the inventory `import-world.sh`'s header -/// comment describes: "the ETL is clear+reload PER FAMILY"). `--family` covers THESE families only — -/// terrain (`--terrain`), client DBCs (`--dbc`), and Spell.dbc (`--dbc --spells`) are separate -/// invocations with no family concept of their own. "globals" covers start -/// positions, the class/level stat curve, graveyard-zone links, createinfo spells/actions/items and -/// Area Trigger dungeon portals. They are not box-scoped, but each is its own -/// clear+reload, so `--family quests` reloads QUESTS ALONE (not -/// quests-plus-globals). "spellmeta" (reduced scope) is the same "global, not box-scoped, -/// own clear+reload" shape as "globals" — kept as its OWN family rather than folded into "globals" -/// because nothing else reads its output (compute-gateable too, unlike "globals"' entangled siblings — -/// see `build_dump_plan`'s gate). -/// Every family here also has a Package Delta stage of its own, so `main` runs the stage over this -/// same list rather than a second one that could drift from it. pub(crate) const FAMILIES: &[&str] = &[ "creatures", "items", @@ -3920,14 +3097,8 @@ pub(crate) const FAMILIES: &[&str] = &[ "spellmeta", ]; -/// The batch size for the two Timestamp-bearing spawn payloads (`import_creature_spawns`/ -/// `import_gameobjects` reducer args) — shared by `build_dump_plan` (which chunks the rows) and -/// `main`'s dry-run print (which reports it). const SPAWN_BATCH: usize = 250; -/// Whether `name`'s DELETE+INSERT block should be pushed/applied this run: true when `--family` was -/// not given (full run — every family active) or when it names -/// exactly this family. pub(crate) fn family_active(args: &Args, name: &str) -> bool { match &args.family { None => true, @@ -3935,13 +3106,7 @@ pub(crate) fn family_active(args: &Args, name: &str) -> bool { } } -/// One family's Package Delta stage: reapplies every enabled Package's claims on `family`'s tables -/// once its base rows are back. This is the `--dump` block's counterpart to `spell::run_spells`'s stage. -/// Runs whenever `family` is part of this invocation (`--family ` naming it, or a full run), -/// on the dry-run path as well as `--apply`: a check has to print the same plan an apply would -/// send, and `package_delta::reapply` itself decides which of those two this call is. fn run_package_stage(args: &Args, family: &str) -> Result<()> { - // Canonical quests also reload the EventAI quest completion rules. let quest_event_reload = family == "creature-ai" && args.world_profile.is_some() && family_active(args, "quests"); if !family_active(args, family) && !quest_event_reload { @@ -3954,11 +3119,6 @@ fn run_package_stage(args: &Args, family: &str) -> Result<()> { Ok(()) } -/// Everything the `--dump` ETL computes for ONE run: the plain-SQL clear+reload statements (already -/// family-gated — see `family_active`), the two Timestamp-bearing spawn payloads (packed, batched), -/// and the provenance `stamps` (family, row_count) for every family that actually had its block -/// pushed. Built by `build_dump_plan`, applied by `main` (kept separate so tests can inspect a plan -/// without shelling out to `spacetime call`). pub(crate) struct DumpPlan { pub(crate) stmts: Vec, pub(crate) spawn_batches: Vec, @@ -3975,7 +3135,7 @@ pub(crate) struct DumpPlan { } impl DumpPlan { - /// GameObject dependencies precede AI; creatures can raise spawn events only after AI is ready. + // Load GameObject dependencies before AI, then creatures whose spawn events can invoke AI. fn reducer_batches<'a>(&'a self, args: &Args) -> Result> { let mut batches = Vec::new(); if family_active(args, "gameobjects") { @@ -4023,13 +3183,6 @@ impl DumpPlan { } } -/// Build the full `--dump` ETL plan. The `--family` split lives entirely in here, via -/// `family_active` gates around each family's DELETE+INSERT block. It parses the cmangos dump, filters -/// the content slice, maps every family's rows, and returns the ready-to-apply plan. Pure w.r.t. the -/// outside world (no `spacetime call`/SQL execution) — `main` does that against the returned -/// `DumpPlan`. `--family` gates ONLY which blocks are pushed into `stmts`/counted into `stamps`; every -/// family's prerequisite parsing/scoping still runs (the quests/items builders are entangled — see -/// the module doc — so their compute can't be conditional even when only one is requested). fn build_dump_plan( dump: &str, args: &Args, @@ -4080,9 +3233,6 @@ fn build_dump_plan( } = gameobject_template_rows(&go_spawns, &eventai.forced_gameobject_entries)?; let trainer_rows = build_trainer_spell_rows(dump, &entries, profession_tier_values); - // A transferred Character needs the same quest text and objectives on every destination. - // Canonical profiles share the supported quest catalogue; giver relations stay local. - // Legacy slices keep their explicitly selected quest scope. let catalogue = if args.world_profile.is_some() { Some(build_quest_catalogue(dump, args, display_scales)?) } else { @@ -4127,9 +3277,6 @@ fn build_dump_plan( quests.cast_objectives.len(), quests.reward_items.len(), quests.reward_choices.len(), quests.relations.len(), quests.go_relations.len(), quests.chained_count, quests.timed_count, quests.raid_quest_count, ); - // Mail loot (T3): the single item each named reward-mail template attaches, scoped to the - // templates `quests.reward_mail` actually names — a template with no row here sends text/money - // only. let (mail_loot_rows, mail_loot_item_entries) = build_mail_loot(dump, &quests.reward_mail_template_ids)?; eprintln!( @@ -4139,10 +3286,6 @@ fn build_dump_plan( mail_loot_rows.len(), ); - // P4: real item templates + creature loot for the imported creatures (clear+reload, like above). - // game_item_template is wiped+reloaded with the loot-referenced set ∪ the starter loadout ∪ quest - // reward items ∪ quest COLLECT (ReqItem) items (so the login grant keeps a real weapon, turn-ins can - // deliver, AND every collect-quest item has a template); game_creature_loot is fully importer-owned. let extra_item_entries: std::collections::HashSet = quests .reward_item_entries .iter() @@ -4171,10 +3314,6 @@ fn build_dump_plan( &creature_skin_ids, &chest_loot_ids_used, ); - // Gossip menus + NPC text + menu options: map each in-box gossip NPC's - // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting - // strings (all 8 weighted slots, not just the first), and import its clickable menu options - // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. let gossip = build_gossip_sql(dump, &entries); let quest_event_requirements = catalogue @@ -4204,10 +3343,6 @@ fn build_dump_plan( eventai: &eventai, quest_event_requirements: &quest_event_requirements, }; - // 5) static content (no Timestamp columns) → plain SQL clear+reload, family by family, in the - // order the tables must land. The spawns + live-entity reset are NOT here — they go through - // the `import_creature_spawns` reducer (step 6), which clears every CREATURE entity + spawn - // and loads the new spawns with a valid `ctx.timestamp`. let mut stmts: Vec = Vec::new(); let property_row_count = if family_active(args, "items") { let (property_sql, row_count) = item_property::dump_sql(dump, args.dbc.as_deref())?; @@ -4224,10 +3359,7 @@ fn build_dump_plan( push_creature_behaviour_statements(args, content, &mut stmts); let (globals_row_count, spellmeta_row_count) = push_global_statements(args, dump, &mut stmts)?; - // The spawn payload can hold ~2k rows for a full zone — more than one `spacetime call` string arg - // takes — so split it into batches. Batch 0 goes to `import_creature_spawns` (clears the live - // roster + threat ONCE, then loads); every later batch goes to `import_creature_spawns_append` - // (load only), so the clear happens exactly once and every batch survives. + // Only the first spawn batch clears live state; later batches must append. let spawn_rows: Vec<&str> = packed_spawns.split(';').filter(|r| !r.is_empty()).collect(); let batches: Vec = spawn_rows .chunks(SPAWN_BATCH) @@ -4269,8 +3401,6 @@ fn build_dump_plan( }) } -/// ETL row shape: one `game_creature_spawn` row per tuple, in the packed-payload column order — -/// db_guid, entry, x, y, z, orientation, movement_type, respawn_secs, map. type CreatureSpawnRow = (u64, u64, f64, f64, f64, f64, u8, u32, i64); #[derive(Clone, Copy, PartialEq, Eq)] @@ -4279,15 +3409,12 @@ enum ScopePurpose { QuestCatalogue, } -/// The creature template entries this run imports, with the EventAI plan that grew them. struct ScopeEntries { entries: std::collections::HashSet, eventai: eventai::EventAiPlan, - /// The rendered Compatibility Manifest — `None` when the creature-ai family is not part of the run. eventai_manifest: Option, } -/// The `game_creature_template` rows plus the per-entry loot ids and exclusions every later family reads. struct CreatureTemplates { templates: Vec, creature_loot_ids: std::collections::HashMap, @@ -4296,13 +3423,11 @@ struct CreatureTemplates { class_trainer_count: usize, } -/// The packed `import_creature_spawns` payload and the db_guid → world guid map the waypoints need. struct SpawnPayload { guid_of: std::collections::HashMap, packed_spawns: String, } -/// One imported `gameobject_template`, reduced to the columns the per-type row builder needs. struct GoMeta { stored_type: u8, disp: u32, @@ -4311,12 +3436,10 @@ struct GoMeta { data1: u32, trap_spell_id: u32, trap_cooldown_secs: u32, - /// 0 for every type but MEETINGSTONE. meeting_stone_area_id: u32, size: f32, } -/// The gameobject spawns the World Import Scope keeps, with the template metadata they resolved against. struct GameobjectSpawns { go_meta: std::collections::HashMap, dropped_type25: Vec, @@ -4325,7 +3448,6 @@ struct GameobjectSpawns { used_go: std::collections::HashSet, } -/// The four loot families that hang off an imported creature or chest. struct LootFamilyRows { pickpocket_rows: Vec, skinning_rows: Vec, @@ -4371,8 +3493,6 @@ fn build_quest_catalogue( }) } -/// Every mapped row set the static-content SQL blocks and the provenance stamps read. Borrowed, so -/// the push helpers below stay a view over `build_dump_plan`'s own values. #[derive(Clone, Copy)] struct MappedContent<'a> { templates: &'a [String], @@ -4397,9 +3517,6 @@ struct MappedContent<'a> { quest_event_requirements: &'a [String], } -/// The creature template entries this run imports, and the EventAI plan built against them. Accepted -/// EventAI summons force their summoned templates into the entry set, to a fixpoint, so a summoned-only -/// creature still gets a template. fn resolve_scope_entries( dump: &str, args: &Args, @@ -4429,14 +3546,8 @@ fn resolve_scope_entries( { entries.extend(&all_templates); } - // Spell summons and the wisp body have no placed spawn. const SPELL_TEMPLATE_ENTRIES: &[u64] = &[416, 1860, 1863, 5676, 5677, 12861]; entries.extend(SPELL_TEMPLATE_ENTRIES.iter().copied()); - // Forced-creature template safety net: the spawn-loop gate above already keeps a force-listed - // entry's placed spawns, whether the profile owns it or the legacy flag extends the scope. - // But a giver with NO `creature` row (a pure-summon/instanced NPC) produces zero spawns — extend - // `entries` so its template + quest relations still import, and WARN LOUDLY that it will not - // physically exist in-world (so the operator doesn't ship a phantom giver). let scope_name = scope.name(); for &e in &scope.forced_creature_entries { if !entries.contains(&e) { @@ -4459,7 +3570,6 @@ fn resolve_scope_entries( Some(entry) }) .collect(); - // Canonical quest reimports must retain summoned givers discovered by a full import. let (eventai, eventai_manifest) = if args.world_profile.is_some() || family_active(args, "creature-ai") || family_active(args, "gameobjects") @@ -4526,25 +3636,18 @@ fn resolve_scope_entries( }) } -/// Creature definitions and the loot ids used by the selected catalogue. fn build_creature_templates( dump: &str, entries: &std::collections::HashSet, display_scales: &Option>, ) -> CreatureTemplates { - // 2) templates for those entries. let mut templates: Vec = Vec::new(); - // entry → LootId for the imported creatures; drives the item + loot ETL below (0 = no loot table). let mut creature_loot_ids: std::collections::HashMap = std::collections::HashMap::new(); - // Entry → PickpocketLootId / SkinLootId, same "0 = no table" convention as LootId. let mut creature_pickpocket_ids: std::collections::HashMap = std::collections::HashMap::new(); let mut creature_skin_ids: std::collections::HashMap = std::collections::HashMap::new(); - // How many imported templates carry a nonzero `trainer_class`. Reported below so an operator can - // confirm the columns populated: a wrong index reads a real neighbouring cell, which is the one - // failure mode here that produces plausible data instead of an error. let mut class_trainer_count: usize = 0; for row in parse_table(dump, "creature_template") { let entry: u64 = field(&row, ct::ENTRY).parse().unwrap_or(0); @@ -4555,9 +3658,6 @@ fn build_creature_templates( if loot_id > 0 { creature_loot_ids.insert(entry, loot_id); } - // Loot-family completeness: PickpocketLootId collapses onto creature_entry - // directly (like LootId, above); SkinLootId is recorded per-entry too but stays keyed on the - // loot-id ITSELF in game_skinning_loot (several creatures share one level-banded table). let pickpocket_loot_id: u64 = field(&row, ct::PICKPOCKET_LOOT_ID).parse().unwrap_or(0); if pickpocket_loot_id > 0 { creature_pickpocket_ids.insert(entry, pickpocket_loot_id); @@ -4566,13 +3666,9 @@ fn build_creature_templates( if skin_loot_id > 0 { creature_skin_ids.insert(entry, skin_loot_id); } - // Resolve scale: cmangos Scale==0 means "use the client CreatureDisplayInfo scale". Resolve it - // from the DBC map when --dbc was given, else floor to 1.0 — never emit a literal 0 (the 5875 - // client renders OBJECT_FIELD_SCALE_X=0 invisibly; see codec set_object_scale_x). + // Scale zero means use CreatureDisplayInfo scale. let display_id: u32 = field(&row, ct::MODEL_ID1).parse().unwrap_or(0); let cm_scale: f32 = field(&row, ct::SCALE).parse().unwrap_or(0.0); - // Accept only a POSITIVE scale (a 0 — or a corrupt negative — falls through to the DBC, then - // to the 1.0 floor); a negative OBJECT_FIELD_SCALE_X would render as broken as a 0. let scale_val: f32 = if cm_scale > 0.0 { cm_scale } else { @@ -4582,8 +3678,6 @@ fn build_creature_templates( .filter(|s| *s > 0.0) .unwrap_or(1.0) }; - // MaxLevel / MaxLevelHealth feed the spawn-time level roll; a dump missing the cell → "0" - // (the module reads 0 as "no range" and falls back to the min). End-appended, like the column. let num_or_zero = |idx: usize| { let v = field(&row, idx); if v.is_empty() { @@ -4593,9 +3687,6 @@ fn build_creature_templates( } }; let faction_template: u32 = field(&row, ct::FACTION).parse().unwrap_or(0); - // Per-creature melee damage: cmangos MinMeleeDmg/MaxMeleeDmg are floats; round to int - // (vanilla creature swings are whole-number ranges in practice). 0 stays 0 → the module's - // swing_range_ctx falls back to the flat CREATURE_MELEE range for that creature. let dmg_min: u32 = field(&row, ct::MIN_MELEE_DMG) .parse::() .unwrap_or(0.0) @@ -4604,14 +3695,6 @@ fn build_creature_templates( .parse::() .unwrap_or(0.0) .round() as u32; - // game_creature_template column order (see module/src/creatures/spawn.rs). The trailing - // `aggro_range` (proximity aggro) has no cmangos source column, so emit 0 (passive — - // retaliate-only, the column default); aggression tuning is a separate content pass. - // `pickpocket_loot_id`/`skin_loot_id` are END-appended after `armor`. - // Trainer service columns, END-appended after `skin_loot_id`. Both parse to `u8` and fall to - // 0 on an absent or unparseable cell; for `trainer_class` that 0 is the fail-open "serves - // everyone" value. Not `num_or_zero`: it only rewrites an EMPTY cell and would splice a - // non-numeric one into the INSERT, aborting the statement. let trainer_type_val: u8 = field(&row, ct::TRAINER_TYPE).parse().unwrap_or(0); let trainer_class_val: u8 = field(&row, ct::TRAINER_CLASS).parse().unwrap_or(0); if trainer_class_val != 0 { @@ -4656,42 +3739,19 @@ fn build_creature_templates( } } -/// The legacy single-spell cast rows and the multi-spell rotation rows for the scope's casters. fn build_creature_cast_rows( dump: &str, entries: &std::collections::HashSet, spell_ids: Option<&std::collections::HashSet>, ) -> (Vec, Vec) { - // 2b) caster-mob cast rows (game_creature_cast, LEGACY) + rotation rows (game_creature_spell, rank 20). - // - // LEGACY (game_creature_cast): one spell per entry (spell1), used by pass_cast as a fallback ONLY when - // a creature has NO game_creature_spell rows. Kept for single-spell casters (no spell2+) so they are - // byte-identical to the pre-rotation behaviour. - // - // ROTATION (game_creature_spell): emitted for entries that carry spell2+ in creature_template_spells. - // These creatures use the rotation path exclusively (pass_cast skips game_creature_cast when any - // game_creature_spell rows exist for the entry). Rotation layout per entry: - // • spell1 → condition ALWAYS (0), priority 10 — the primary nuke, always fires at the melee target. - // • spell2..10 → condition SELF_MISSING_AURA (3), priority 20 — assumed to be self-buffs (e.g. Frost - // Armor 12544 on entries 474/476); they fire FIRST (higher priority) when the buff is absent, then - // fall silent once applied. If a spell2+ is actually an alternative nuke (same targets as spell1), - // the self-cast will fail at resolve_cast_at target-type validation and the engine gracefully falls - // through to spell1 — net behaviour is unchanged. Heals and debuffs need a reclassify pass (deferred - // content tuning, not a code change). - // With a client catalogue, derived cast rows retain only resolvable spell references. - // - // game_creature_spell condition discriminants (mirror module/src/creatures/spawn.rs cast_condition): - const COND_ALWAYS: u8 = 0; // unconditional — cast at melee target (nuke) - const COND_SELF_MISSING_AURA: u8 = 3; // cast at self when self lacks the spell's aura (buff) + const COND_ALWAYS: u8 = 0; + const COND_SELF_MISSING_AURA: u8 = 3; let mut creature_casts: Vec = Vec::new(); - // Explicit id counter for game_creature_spell (auto_inc field; we assign sequentially from 1 rather - // than relying on DB auto-assign, matching how game_trainer_spell works in this importer). let mut rotation_id: u64 = 1; let mut creature_rotation_rows: Vec = Vec::new(); for row in parse_table(dump, "creature_template_spells") { let entry: u64 = field(&row, cts::ENTRY).parse().unwrap_or(0); - // setId 0 = the creature's own (non-set) spell list; skip the (rare) shared-set rows. if field(&row, cts::SET_ID).parse::().unwrap_or(0) != 0 { continue; } @@ -4700,14 +3760,12 @@ fn build_creature_cast_rows( } let spell1: u64 = field(&row, cts::SPELL1).parse().unwrap_or(0); if spell1 == 0 { - continue; // a row with no primary spell never casts — skip. + continue; } - // An unknown primary has no valid fallback; do not promote a secondary buff into its place. if spell_ids.is_some_and(|ids| !ids.contains(&spell1)) { eprintln!("casts: WARNING creature {entry} has unknown primary spell {spell1}; derived caster rows omitted"); continue; } - // Collect non-zero spell2..spell10 (slots SPELL1+1 through SPELL1+9). let secondary: Vec = (1..=9) .filter_map(|i| { let s: u64 = field(&row, cts::SPELL1 + i).parse().unwrap_or(0); @@ -4720,19 +3778,13 @@ fn build_creature_cast_rows( .collect(); if secondary.is_empty() { - // Single-spell caster: emit the legacy game_creature_cast row only. creature_casts.push(format!("({entry},{spell1})")); } else { - // Multi-spell caster: emit a FULL rotation into game_creature_spell (spell1 ALWAYS nuke + - // spell2+ SELF_MISSING_AURA buffs). The legacy game_creature_cast row is ALSO emitted so - // that a future re-import without rotation rows still has a working fallback. creature_casts.push(format!("({entry},{spell1})")); - // spell1: priority 10, ALWAYS (nuke at melee target). creature_rotation_rows.push(format!( "({rotation_id},{entry},{spell1},10,{COND_ALWAYS},0)" )); rotation_id += 1; - // spell2+: priority 20 (fires before the nuke when the buff is absent), SELF_MISSING_AURA. for s in &secondary { creature_rotation_rows.push(format!( "({rotation_id},{entry},{s},20,{COND_SELF_MISSING_AURA},0)" @@ -4755,25 +3807,13 @@ fn build_creature_cast_rows( (creature_casts, creature_rotation_rows) } -/// The packed spawn payload the `import_creature_spawns` reducer loads. fn build_packed_spawn_payload(spawns: &[CreatureSpawnRow]) -> SpawnPayload { - // 3) spawn payload for the `import_creature_spawns` reducer — NOT SQL. `game_creature_spawn` has - // Timestamp columns (respawn_at/despawn_at) and SpacetimeDB 2.5 SQL has no Timestamp literal, - // so spawns load through a reducer (which stamps `ctx.timestamp`). Rows `;`-separated, fields - // `,`: guid,entry,map,x,y,z,orientation,movement_type,respawn_secs. (u64 guids exceed JSON's safe-integer - // range, so this is one delimited string — never a per-field JSON number that would lose precision.) + // Timestamp columns require reducer imports because SpacetimeDB SQL cannot write them. let guid_of: std::collections::HashMap = spawns.iter().map(|s| (s.0, world_guid(s.1, s.0))).collect(); let packed_spawns: String = spawns .iter() .map(|(db_guid, entry, x, y, z, o, mt, respawn_secs, map)| { - // `map` is the ROW's own map: an --include-map spawn (Deadmines 36) - // must land on ITS map, not the primary --map's. The dump Z rides through UNALTERED on - // every path: the importer NEVER snaps spawn Z to terrain (there is no ground_z here at - // all — runtime creature legs snap with a keep-current-Z fallback, module terrain.rs), - // Map 36 terrain remains absent because one height row cannot represent stacked floors, - // so a spawn keeps its floor-correct cmangos Z. Pinned by - // `include_map_spawns_import_whole_map_with_their_own_map_and_verbatim_z`. format!( "{g},{entry},{map},{x},{y},{z},{o},{mt},{respawn_secs}", g = world_guid(*entry, *db_guid), @@ -4788,17 +3828,13 @@ fn build_packed_spawn_payload(spawns: &[CreatureSpawnRow]) -> SpawnPayload { } } -/// The `game_creature_waypoint` rows for the imported spawns: guid-keyed routes first, then the -/// entry-keyed template routes expanded onto every waypoint-typed spawn without one. fn build_waypoint_rows( dump: &str, spawns: &[CreatureSpawnRow], guid_of: &std::collections::HashMap, ) -> Vec { - // 4) waypoints for the imported spawns (id assigned by a counter — clear+reload is idempotent). let mut waypoints: Vec = Vec::new(); let mut wp_id: u64 = 1; - // Spawns that carry DIRECT (guid-keyed) rows — the template expansion below never overrides these. let mut direct_wp_guids: std::collections::HashSet = std::collections::HashSet::new(); for row in parse_table(dump, "creature_movement") { let db_guid: u64 = field(&row, cm::ID).parse().unwrap_or(0); @@ -4815,14 +3851,7 @@ fn build_waypoint_rows( direct_wp_guids.insert(db_guid); wp_id += 1; } - // 4b) creature_movement_template: ENTRY-keyed paths, expanded onto every - // in-slice MovementType=2 spawn of that entry that has NO direct rows (direct always wins). - // Waypoint Zs — like spawn Zs — ride through VERBATIM (no terrain snap exists in the importer; - // cmangos waypoints are floor-correct, which Map 36 needs while ground_z remains absent). - // Fail-open per row (a garbled row is skipped, the path just shortens) — waypoints are optional - // gating data (a pathless mob idles at its spawn), per the fail-open/fail-loud split (§5). { - // entry → [(point, x, y, z)] for PathId 0 (see `cmt`'s vintage sniff). let mut tmpl_paths: std::collections::HashMap> = std::collections::HashMap::new(); for row in parse_table(dump, "creature_movement_template") { @@ -4833,7 +3862,7 @@ fn build_waypoint_rows( if row.len() >= cmt::NCOLS_WITH_PATH_ID && field(&row, cmt::PATH_ID).parse::().unwrap_or(0) != 0 { - continue; // non-default path — the module models one route per creature + continue; } let (pt_i, x_i, y_i, z_i) = cmt::pos_indices(row.len()); let point: u32 = field(&row, pt_i).parse().unwrap_or(0); @@ -4844,7 +3873,6 @@ fn build_waypoint_rows( field(&row, z_i).to_string(), )); } - // Route order = the dump's Point column, not dump row order (defensive sort — cheap). for path in tmpl_paths.values_mut() { path.sort_by_key(|p| p.0); } @@ -4852,14 +3880,14 @@ fn build_waypoint_rows( if !tmpl_paths.is_empty() { for (db_guid, entry, _x, _y, _z, _o, mt, _rs, _map) in spawns { if *mt != 2 || direct_wp_guids.contains(db_guid) { - continue; // not waypoint-typed, or already routed by direct rows + continue; } let Some(path) = tmpl_paths.get(entry) else { continue; }; let Some(&g) = guid_of.get(db_guid) else { continue; - }; // excluded entries have no guid + }; for (_pt, x, y, z) in path { waypoints.push(format!("({wp_id},{g},{x},{y},{z})")); wp_id += 1; @@ -4876,15 +3904,7 @@ fn build_waypoint_rows( waypoints } -/// The gameobject spawns the World Import Scope keeps, packed for the `import_gameobjects` reducer, -/// with the classified template metadata they resolved against. fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> GameobjectSpawns { - // Gameobjects (widened import): EVERY in-scope cmangos `gameobject_template` type - // now imports (template + spawn) — previously only an allowlisted subset (GOOBER/curated GATHER/ - // curated CHEST/QUESTGIVER) did. `classify_go_type` decides the stored `type_id` (or drops the row - // — the TYPE-25 COLLISION GUARD); `go_template_row` then builds the per-type SQL row. The spawns - // load via the import_gameobjects reducer (game_gameobject has a Timestamp); the packed spawn row - // carries an `initial_state` field so a DOOR/BUTTON can spawn already-open. let go_tmpls = parse_table(dump, "gameobject_template"); let mut dropped_type25: Vec = Vec::new(); let go_meta: std::collections::HashMap = go_tmpls @@ -4918,8 +3938,6 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject } else { 0 }; - // 0 on an unparseable/absent column — the gateway reads that as "no size stored" and - // sends 1.0, never an invisible prop. let size: f32 = field(row, got::SIZE).parse().unwrap_or(0.0); Some(( entry, @@ -4937,17 +3955,11 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject )) }) .collect(); - // GOOBER entries (ALL, not just spawned) — kept for `build_quests`' USE_GAMEOBJECT objective gate - // below (a quest referencing a non-GOOBER or unimported GO entry - // is dropped rather than left stuck). let goober_entries: std::collections::HashSet = go_meta .iter() .filter(|(_, m)| m.stored_type == GO_GOOBER) .map(|(&e, _)| e) .collect(); - // Spawn-membership test: widens this to EVERY classified template (previously a - // union of 4 allowlists) — the whole point of the widened import. `go_meta.get(&id)` below IS the - // membership test (a dropped/unclassified entry has no `go_meta` row). let mut go_packed_rows: Vec = Vec::new(); let mut used_go: std::collections::HashSet = std::collections::HashSet::new(); let mut go_slice_counts = vec![0usize; scope.bounded_slices.len()]; @@ -4962,21 +3974,17 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject let x: f64 = field(&row, go::X).parse().unwrap_or(0.0); let y: f64 = field(&row, go::Y).parse().unwrap_or(0.0); let z: f64 = field(&row, go::Z).parse().unwrap_or(0.0); - // Creature and GameObject rows consume the same scope predicate. if !scope.contains(map, x, y, z) { continue; } let db_guid: u64 = field(&row, go::GUID).parse().unwrap_or(0); let o: f64 = field(&row, go::O).parse().unwrap_or(0.0); - // The spawn quaternion — carried verbatim; 0,0,0,0 (a row with no rotation columns, - // or a genuinely-identity spawn) is a valid packed value, not a parse failure, so the - // fallback is `0.0` exactly like every other numeric field this loop parses. + // A zero quaternion is valid packed identity, not a signal to derive rotation from facing. let rot0: f64 = field(&row, go::ROT0).parse().unwrap_or(0.0); let rot1: f64 = field(&row, go::ROT1).parse().unwrap_or(0.0); let rot2: f64 = field(&row, go::ROT2).parse().unwrap_or(0.0); let rot3: f64 = field(&row, go::ROT3).parse().unwrap_or(0.0); let initial_state = go_initial_state(meta.stored_type, meta.data0); - // `m = map` (the row's own map, never args.map) — the packed-spawn precedent above. go_packed_rows.push(format!( "{g},{id},{m},{x},{y},{z},{o},{initial_state},{rot0},{rot1},{rot2},{rot3}", g = go_guid(db_guid), @@ -5012,11 +4020,9 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject } } -/// GameObject definitions for local spawns and accepted relay dependencies, plus chest loot IDs. struct GameobjectTemplateRows { templates: Vec, traps: Vec, - /// The only copy of a spawned stone's level range and dungeon area. meeting_stones: Vec, chest_loot_ids: Vec, } @@ -5032,7 +4038,6 @@ fn gameobject_template_rows( used_go, .. } = spawns; - // SQL INSERT must name every column; migration defaults do not apply to imported rows. let mut go_template_rows: Vec = Vec::new(); let mut go_trap_rows: Vec = Vec::new(); let mut go_meeting_stone_rows: Vec = Vec::new(); @@ -5077,8 +4082,6 @@ fn gameobject_template_rows( chest_loot_ids_used.push(loot_id); } } - // Per-type coverage: loud so an operator sees exactly what a box pulled in, which - // types are LIVE (dispatched by `apply_use_gameobject`) vs INERT (template+spawn only, `use` no-ops). const GO_LIVE_TYPE_NAMES: &[(u8, &str)] = &[ (GO_DOOR, "DOOR"), (GO_BUTTON, "BUTTON"), @@ -5093,8 +4096,6 @@ fn gameobject_template_rows( ); for (&type_id, &count) in &go_type_histogram { if type_id == GO_QUESTGIVER { - // Served by the quest-giver flow (game_gameobject_quest), not by `apply_use_gameobject` — - // neither LIVE (use-dispatched) nor INERT. eprintln!(" type {type_id:>2} QUESTGIVER {count} (quest flow, not use-dispatched)"); continue; } @@ -5125,25 +4126,13 @@ fn gameobject_template_rows( }) } -/// The `game_trainer_spell` rows for the scope's trainers: every cmangos offering, plus the -/// synthesized profession learn-rows. fn build_trainer_spell_rows( dump: &str, entries: &std::collections::HashSet, profession_tier_values: &Option>, ) -> Vec { - // Trainer spell lists: npc_trainer rows for the SPAWNED trainer creatures → game_trainer_spell - // (cmangos npc_trainer is all DIRECT entries — no template indirection). Lights up the trainer system. - // Two kinds of row are emitted: - // 1) CLASS-SPELL offerings — every npc_trainer row, verbatim, with learn_skill_line=0 (the unchanged - // spell path; cmangos profession RECIPE rows (reqskill != 0) also ride here — their spells aren't - // in game_spell so they're inert, exactly as before, but harmless). - // 2) SYNTHESIZED profession LEARN rows (PROFESSION_LEARN) — for each in-box trainer seen teaching one - // of our 12 lines, the Apprentice→Artisan learn tiers with the REAL Spell.dbc id + learn_skill_line. let mut trainer_rows: Vec = Vec::new(); let mut trainer_id: u64 = 1; - // Per in-box trainer: the SkillLines it teaches (from reqskill != 0 rows) ∪ the spell ids it already - // offers (so a synthesized learn-row never collides with a class/recipe row for the SAME trainer). let mut trainer_lines: std::collections::BTreeMap> = std::collections::BTreeMap::new(); let mut trainer_spells: std::collections::HashMap> = @@ -5159,35 +4148,23 @@ fn build_trainer_spell_rows( } let cost: u64 = field(&row, nt::COST).parse().unwrap_or(0); let reqlevel: u32 = field(&row, nt::REQ_LEVEL).parse().unwrap_or(0); - // Record the profession lines this trainer teaches (reqskill matching one of our 12) + every spell - // id it offers, for the synthesis pass below (de-dup keyed on entry). let reqskill: u32 = field(&row, nt::REQ_SKILL).parse().unwrap_or(0); if profession_tiers(reqskill).is_some() { trainer_lines.entry(entry).or_default().insert(reqskill); } trainer_spells.entry(entry).or_default().insert(spell); - // CLASS-SPELL / recipe row: learn_skill_line=0, learn_skill_cap=75 — the unchanged spell path (the - // cap is ignored on a line-0 row but must be NAMED on the INSERT; the 75 matches the module's - // #[default(75u32)] so an explicit-INSERT row equals a migrated one). trainer_rows.push(format!( "({trainer_id},{entry},{spell},{cost},{reqlevel},0,75)" )); trainer_id += 1; } - // Synthesize the profession LEARN rows: for each in-box trainer teaching one of our 12 lines, emit - // the Apprentice→Artisan tiers (real Spell.dbc id → SMSG_LEARNED_SPELL resolves). cost 0 (the - // gossip-learn is free in vanilla). Skip a tier whose spell id already collides with a class/recipe - // row for THIS trainer (so a trainer can't double-insert the same spell_id). Every column is NAMED. - // The cap comes from the REAL client's SkillTiers.dbc data when - // --dbc was given (already parity-checked equal to LEGACY_TIER_CAPS above); without --dbc there's no - // client data to resolve, so it falls back to LEGACY_TIER_CAPS directly (documented fallback). let mut synthesized = 0usize; for (entry, lines) in &trainer_lines { - let offered = trainer_spells.get(entry); // the trainer's existing spell ids (de-dup guard) + let offered = trainer_spells.get(entry); for line in lines { for &(spell, tier_index, req) in profession_tiers(*line).unwrap() { if offered.is_some_and(|s| s.contains(&(spell as u64))) { - continue; // a class/recipe row already carries this spell_id for this trainer + continue; } let cap = profession_tier_values .as_ref() @@ -5210,27 +4187,19 @@ fn build_trainer_spell_rows( trainer_rows } -/// The pickpocket / skinning / gameobject(chest) / fishing loot rows, all off one parse of the -/// shared reference-pool table. fn build_loot_family_rows( dump: &str, creature_pickpocket_ids: &std::collections::HashMap, creature_skin_ids: &std::collections::HashMap, chest_loot_ids_used: &[u32], ) -> LootFamilyRows { - // Loot-family completeness: pickpocket / skinning / gameobject(chest) / fishing. - // `refs` (the reference_loot_template pool map) is parsed ONCE here and shared by all four — - // `parse_creature_drops` above already parsed its own copy for the creature family; a second parse - // for these four is the same low-cost tradeoff this importer already accepts (content-slice sized). let refs = parse_ref_pools(dump); - // PICKPOCKET: keyed directly on CREATURE ENTRY (PickpocketLootId collapsed like LootId), scoped to - // in-slice creatures only (`creature_pickpocket_ids` is already restricted to `entries`). let drops_by_pickpocketid = parse_loot_family(dump, "pickpocketing_loot_template", &refs); let mut pickpocket_rows: Vec = Vec::new(); let mut pickpocket_row_id: u64 = 1; let mut pp_creatures: Vec<(&u64, &u64)> = creature_pickpocket_ids.iter().collect(); - pp_creatures.sort(); // deterministic id assignment across re-imports + pp_creatures.sort(); for (creature_entry, pp_loot_id) in pp_creatures { let Some(drops) = drops_by_pickpocketid.get(pp_loot_id) else { continue; @@ -5241,9 +4210,6 @@ fn build_loot_family_rows( } } - // SKINNING: keyed on skin_loot_id DIRECTLY (NOT collapsed onto creature_entry — several creatures of - // the same level band share ONE table). Scope: the distinct skin_loot_ids actually used by in-slice - // creatures. let drops_by_skinid = parse_loot_family(dump, "skinning_loot_template", &refs); let mut skin_ids_used: Vec = creature_skin_ids.values().copied().collect(); skin_ids_used.sort_unstable(); @@ -5255,8 +4221,6 @@ fn build_loot_family_rows( continue; }; for &(item, chance_bp, count, group_id, _quest_only) in drops { - // game_skinning_loot carries no quest_only column (module doc: no cmangos skinning row is - // ever quest-gated) — discarded here, not emitted. skinning_rows.push(format!( "({skinning_row_id},{skin_id},{item},{chance_bp},{count},{group_id})" )); @@ -5264,9 +4228,6 @@ fn build_loot_family_rows( } } - // GAMEOBJECT (CHEST) loot: keyed on loot_id = the REAL `gameobject_template.Data1` (see `chest_meta` - // above). Scoped to the lootIds actually used by CHESTs spawned in this box (`chest_loot_ids_used`) - // — GATHER nodes never touch this table (they stay on the hardcoded GATHER_NODES grant). let drops_by_goloot = parse_loot_family(dump, "gameobject_loot_template", &refs); let mut go_loot_ids_used: Vec = chest_loot_ids_used.to_vec(); go_loot_ids_used.sort_unstable(); @@ -5285,11 +4246,6 @@ fn build_loot_family_rows( } } - // FISHING: ZONE-keyed (`entry` IS an AreaTable zone id — no creature/GO indirection to scope by). - // Imported WHOLE — the same "harmless superset" precedent `item_template` already rides (P4, above): - // an out-of-slice zone's rows are simply unreachable at roll time (`fishing_zone_id` never resolves a - // zone we didn't import terrain+AreaTable coverage for), not a dangling-reference risk the way an - // unscoped creature/GO loot import would be. let drops_by_zone = parse_loot_family(dump, "fishing_loot_template", &refs); let mut fishing_rows: Vec = Vec::new(); let mut fishing_row_id: u64 = 1; @@ -5297,7 +4253,6 @@ fn build_loot_family_rows( zones.sort(); for zone in zones { for &(item, chance_bp, count, group_id, _quest_only) in &drops_by_zone[zone] { - // game_fishing_loot carries no quest_only column — discarded, not emitted. fishing_rows.push(format!( "({fishing_row_id},{zone},{item},{chance_bp},{count},{group_id})" )); @@ -5329,7 +4284,6 @@ fn build_loot_family_rows( } } -/// The creature / item / loot / gossip clear+reload blocks, each behind its own family gate. fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mut Vec) { let MappedContent { templates, @@ -5407,10 +4361,7 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu } if family_active(args, "gossip") { - // RESERVED RANGES: the ETL owns only the low id bands (it emits dense low ids); a - // PACKAGE that mints its own gossip (e.g. the dynamic-events guard) uses the high bands — - // menu.entry ≥ 1_000_000 (above every real creature entry), and option.row_id / text_id / - // slot.id ≥ 50_000 — so a routine import never wipes package rows. Documented in danger-zones. + // Base gossip reloads preserve menu entries >= 1_000_000 and row/text ids >= 50_000. stmts.push("DELETE FROM game_gossip_menu_profile_option WHERE row_id > 0".into()); stmts.push("DELETE FROM game_gossip_menu_profile WHERE menu_id > 0".into()); stmts.push("DELETE FROM game_gossip_menu WHERE entry > 0 AND entry < 1000000".into()); @@ -5451,7 +4402,6 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu } } -/// The quest / gameobject-template / trainer clear+reload blocks, each behind its own family gate. fn push_quest_and_gameobject_statements( args: &Args, content: MappedContent, @@ -5466,9 +4416,6 @@ fn push_quest_and_gameobject_statements( trainer_rows, .. } = content; - // Quests: clear+reload the static quest tables (header / body text / objectives / cast objectives / - // reward items / creature giver relations / gameobject giver relations / reward mail / mail loot). - // game_character_quest (per-player progress) is born in the accept reducer, never here. if family_active(args, "quests") { stmts.push("DELETE FROM game_quest_template WHERE entry > 0".into()); stmts.push("DELETE FROM game_quest_text WHERE quest_entry > 0".into()); @@ -5544,8 +4491,6 @@ fn push_quest_and_gameobject_statements( ); } - // GOOBER gameobject templates (family "gameobjects" — no-Timestamp → plain SQL clear+reload). - // The gameobject SPAWNS load separately via the import_gameobjects reducer below (also family-gated). if family_active(args, "gameobjects") { stmts.push("DELETE FROM game_gameobject_trap WHERE entry > 0".into()); stmts.push("DELETE FROM game_meeting_stone WHERE entry > 0".into()); @@ -5564,7 +4509,6 @@ fn push_quest_and_gameobject_statements( go_meeting_stone_rows, ); } - // Trainer spell lists (family "trainers"). if family_active(args, "trainers") { stmts.push("DELETE FROM game_trainer_spell WHERE id > 0".into()); push_insert( @@ -5576,7 +4520,6 @@ fn push_quest_and_gameobject_statements( } } -/// The caster-mob rotation and EventAI clear+reload blocks, each behind its own family gate. fn push_creature_behaviour_statements( args: &Args, content: MappedContent, @@ -5589,8 +4532,6 @@ fn push_creature_behaviour_statements( quest_event_requirements, .. } = content; - // Caster-mob cast + rotation rows (family "casts"). The primary-key is creature_entry, so clear ALL - // rows then reload the in-box set (importer-owned). if family_active(args, "casts") { stmts.push("DELETE FROM game_creature_cast WHERE creature_entry > 0".into()); push_insert( @@ -5599,10 +4540,6 @@ fn push_creature_behaviour_statements( "creature_entry,spell_id", creature_casts, ); - // Rotation rows (game_creature_spell, rank 20): multi-spell casters use these instead of (or in - // addition to) game_creature_cast. Clear ALL rows first (same pattern as game_creature_cast: the - // importer owns this table, and an in-box re-import is a full reload). Then re-seed from - // creature_rotation_rows assembled above. stmts.push("DELETE FROM game_creature_spell WHERE id > 0".into()); push_insert( stmts, @@ -5642,7 +4579,6 @@ fn push_creature_behaviour_statements( &eventai.summon_rows, ); } - // Event credit is part of the shared quest completion rule, even on another World Shard. if quest_events_active { push_insert( stmts, @@ -5653,18 +4589,7 @@ fn push_creature_behaviour_statements( } } -/// The two global (not box-scoped) families. Returns their row counts for the provenance stamps. fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> Result<(u64, u64)> { - // P3: the class/level stat curve + P1: all-(race,class) start positions + 209: graveyard-zone - // links (all global, plain SQL, not box-scoped) + Character creation spells, actions, and items - // + Area Trigger dungeon portals. - // Family "globals": not box-scoped, but its own clear+reload — gated too, so `--family quests` - // reloads QUESTS ALONE (not quests-plus-globals). No other family's builder reads these outputs, so - // gating their compute too (not just the push) is safe — unlike the entangled quests/items pair. - // Area-trigger teleports ride "globals" rather than a new dedicated family: `game_areatrigger_teleport` is a small - // world-wide reference table with the exact same shape as `game_graveyard_zone` right next to it - // (global, own clear+reload, nothing else in this function reads its output) — no new family value - // earns its keep for one more small table. let mut globals_row_count = 0u64; if family_active(args, "globals") { let stats_sql = build_stats_sql(dump); @@ -5675,14 +4600,6 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R let createinfo_item_sql = build_createinfo_item_sql(dump, args.dbc.as_deref())?; let (areatrigger_teleport_sql, areatrigger_teleport_count) = build_areatrigger_teleport_sql(dump); - // Count DATA ROWS, not statements: these builders return DELETE + batched-INSERT - // statement lists (push_insert packs up to 500 rows per INSERT), so `.len()` would - // under-report by the batch factor and defeat the provenance stamp's purpose. - // `areatrigger_teleport_count` is added as its OWN pre-counted `usize`, not via - // `insert_row_count` — that helper is "only sound for numeric-payload tables" (its own doc - // comment), and `game_areatrigger_teleport.name` is free text that could (in principle) - // contain the `),(` tuple separator `insert_row_count` scans for; `build_areatrigger_teleport_sql` - // already counts its own rows correctly before formatting them into SQL. globals_row_count = insert_row_count(&stats_sql) + insert_row_count(&start_pos_sql) + insert_row_count(&graveyard_sql) @@ -5699,12 +4616,6 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R stmts.extend(areatrigger_teleport_sql); } - // Spell metadata (family "spellmeta", reduced scope): rank chain, auto-learn - // dependents, and the proc overlay. Not box-scoped (global, like "globals"), plain SQL (no - // Timestamp). Every one of them is map-independent, so they land on the same destinations the - // spell catalogue does. Nothing else in this function reads any builder's output, so — unlike the - // entangled quests/items pair — it's safe to gate the COMPUTE too, not just the push (the - // "globals" precedent). let mut spellmeta_row_count = 0u64; if family_active(args, "spellmeta") { let links = args @@ -5731,8 +4642,6 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R Ok((globals_row_count, spellmeta_row_count)) } -/// One provenance stamp per family that actually had its block pushed — mirroring exactly the -/// `family_active` gates the push helpers used. fn family_stamps( args: &Args, content: MappedContent, @@ -5759,9 +4668,6 @@ fn family_stamps( quest_event_requirements, .. } = content; - // Provenance stamps: one entry per family that ACTUALLY had its block pushed - // above — mirrors exactly the `family_active` gates this function used, so a stamp only ever - // corresponds to data this run's plan actually carries. let mut stamps: Vec<(&'static str, u64)> = Vec::new(); if family_active(args, "creatures") { stamps.push(("creatures", templates.len() as u64)); @@ -5841,54 +4747,38 @@ fn run_full_world_catalogues(dump: &str, args: &Args) -> Result<()> { fn main() -> Result<()> { let args = parse_args()?; - // `--pack-client` → build the client patch MPQ + install addons into the operator's own client. - // Standalone mode (independent of the cmangos/DBC import streams). if let Some(dir) = args.pack_client.clone() { return pack_client::run(&dir, &args); } - // `--pack-out` → write the package-authored client content into a plain directory. Reads no - // client at all. if let Some(dir) = args.pack_out.clone() { return pack_client::run_pack_out(&dir); } - // `--terrain` → the ADT heightmap stream (standalone, like --pack-client; see terrain.rs). if args.terrain.is_some() { return terrain::run(&args); } - // `--dump-collision` → the spike: WMO/M2 collision-geometry dry-run (see collision.rs). if args.dump_collision.is_some() { return collision::run(&args); } - // `--nav` → the nav-grid rasterizer (see nav.rs). if args.nav.is_some() { return nav::run(&args); } - // `--vmap` → the/ exact vmap triangle extract + pack + import (see vmap.rs). if args.vmap.is_some() || args.vmap_status { return vmap::run(&args); } - // `--vmap-prepare-coverage ` → derive path-grid coverage from an already-staged - // generation's own geometry (see vmap.rs::run_coverage). if let Some(generation_id) = args.vmap_prepare_coverage { return vmap::run_coverage(&args, generation_id); } - // `--go-models` → DOOR/BUTTON display → M2 bounding-mesh extract + import (see go_model.rs). - // Reads `--dump` itself (`parse_args` requires both), so it dispatches before the cmangos ETL - // pass below consumes `args.dump`. if args.go_models.is_some() { return go_model::run(&args); } - // `--dbc` alone → standalone DBC mode (proof/checks), OR `--dbc --spells` → the Spell.dbc importer, - // OR `--dbc --talents` → the TalentTab.dbc/Talent.dbc importer. With `--dump`, the DBC dir instead - // enriches the cmangos templates (display-derived scale) below, so fall through to the cmangos path. if args.dump.is_none() { let dir = args .dbc @@ -5912,16 +4802,10 @@ fn main() -> Result<()> { .expect("parse_args guarantees --dump here"); let dump = read_dump(&dump_path)?; - // `--print-extents` → derive the operator's OWN spawn bbox for --map and STOP (write nothing). - // This is how the operator computes the exact --box rectangle for a new zone instead of eyeballing - // it from memory (the Westfall widening needed this the ETL didn't have before). if args.print_extents { return print_extents(&dump, args.map, args.bbox); } - // Optional DBC scale resolution: cmangos creature_template.Scale==0 means "use the client's - // CreatureDisplayInfo scale". Without it those creatures load at scale_x=0 (invisible). With - // --dbc we resolve them; without, they fall back to 1.0 (see the template loop). let display_scales: Option> = match &args.dbc { Some(dir) => { let m = dbc::load_display_scales(dir)?; @@ -5934,12 +4818,6 @@ fn main() -> Result<()> { None => None, }; - // Optional profession tier-cap resolution: SkillRaceClassInfo.dbc + SkillTiers.dbc - // give the REAL Apprentice→Artisan caps per profession skill line — checked immediately against - // `LEGACY_TIER_CAPS` (`assert_profession_tier_parity`, bailing loudly on any disagreement) before the - // trainer-synthesis pass below ever runs. Without --dbc there's no client data to check against, so - // the synthesis loop falls back to the legacy literal caps directly (documented fallback, no parity - // guard possible — see LEGACY_TIER_CAPS). let profession_tier_values: Option> = match &args.dbc { Some(dir) => { let m = dbc::profession_tier_values(dir)?; @@ -6003,8 +4881,6 @@ fn main() -> Result<()> { return Ok(()); } - // The spawn-batch emptiness check only matters when creatures are actually being (re)loaded this - // run — `--family quests` (etc.) on a box with zero matched spawns is a legitimate (if unusual) run. if family_active(&args, "creatures") && plan.spawn_batches.is_empty() { bail!("no spawns matched the filter — check --box / --radius / --center"); } @@ -6015,17 +4891,12 @@ fn main() -> Result<()> { } eprintln!("import applied."); - // Provenance stamps: `plan.stamps` already carries exactly the families whose - // block was pushed (see `build_dump_plan`) — stamp each one now that the apply above succeeded. let file_hash = sha256_hex(dump.as_bytes())?; for (family, row_count) in &plan.stamps { stamp_family(&args, family, &args.source_sha, &file_hash, *row_count) .with_context(|| format!("stamp_import_meta({family})"))?; } - // The Package Delta stage, last, one family at a time: each family's base rows are back and - // stamped, so every enabled Package's claims go on top of them (mirrors - // `spell::run_spells`'s placement). for family in FAMILIES { run_package_stage(&args, family)?; } @@ -6042,8 +4913,8 @@ mod tests { let rows = parse_table(dump, "creature_template"); assert_eq!(rows.len(), 2); assert_eq!(field(&rows[0], 0), "1"); - assert_eq!(field(&rows[0], 1), "A, the Bold"); // comma inside the quoted name kept - assert_eq!(field(&rows[0], 2), "Sub's"); // \' unescaped + assert_eq!(field(&rows[0], 1), "A, the Bold"); + assert_eq!(field(&rows[0], 2), "Sub's"); assert_eq!(field(&rows[0], 3), "5"); assert_eq!(field(&rows[1], 2), "NULL"); } @@ -6051,9 +4922,9 @@ mod tests { #[test] fn world_guid_is_highguid_unit_encoded_and_unique_per_spawn() { let g = world_guid(299, 42); - assert_eq!(g >> 48, HIGHGUID_UNIT); // high bits mark a creature for the client - assert_eq!((g >> 24) & 0xFF_FFFF, 299); // entry in the middle - assert_eq!(g & 0xFF_FFFF, 42); // spawn db-guid in the low bits + assert_eq!(g >> 48, HIGHGUID_UNIT); + assert_eq!((g >> 24) & 0xFF_FFFF, 299); + assert_eq!(g & 0xFF_FFFF, 42); assert_ne!(world_guid(299, 42), world_guid(299, 43)); } @@ -6064,19 +4935,16 @@ mod tests { #[test] fn gather_nodes_map_is_well_formed() { - // Lookup resolves the Copper Vein (mining) + Peacebloom (herb) anchors and rejects a non-node. - assert_eq!(gather_node(1731), Some((186, 2770, 1, 100))); // Copper Vein → Mining, Copper Ore, req 1, gray 100 - assert_eq!(gather_node(1618), Some((182, 2447, 1, 75))); // Peacebloom → Herbalism, Peacebloom, req 1, gray 75 - assert_eq!(gather_node(1732), Some((186, 2771, 65, 140))); // Tin Vein → Mining, Tin Ore, req 65, gray 140 - assert_eq!(gather_node(106318), None); // Battered Chest (a treasure chest) is NOT a gather node - assert_eq!(gather_node(153453), None); // Solid Chest is NOT a gather node + assert_eq!(gather_node(1731), Some((186, 2770, 1, 100))); + assert_eq!(gather_node(1618), Some((182, 2447, 1, 75))); + assert_eq!(gather_node(1732), Some((186, 2771, 65, 140))); + assert_eq!(gather_node(106318), None); + assert_eq!(gather_node(153453), None); assert_eq!(gather_node(0), None); - // No duplicate GO entries (a dup would emit two templates for one entry → INSERT pk clash). let mut entries: Vec = GATHER_NODES.iter().map(|n| n.0).collect(); entries.sort_unstable(); let dups = entries.windows(2).filter(|w| w[0] == w[1]).count(); assert_eq!(dups, 0, "GATHER_NODES has a duplicate GO entry"); - // Every node uses a real gather skill (MINING 186 / HERBALISM 182) and grants a real item entry. for &(entry, skill, item, _req, _gray) in GATHER_NODES { assert!( skill == 186 || skill == 182, @@ -6086,73 +4954,50 @@ mod tests { } } - // ---- widened GO import, classifier, per-type row builder, TYPE-25 guard ---- - #[test] fn classify_go_type_remaps_gather_and_passes_everything_else_through_identity() { - // A GATHER_NODES entry (real cmangos type 3, a "locked CHEST" in cmangos' own model) remaps to - // the synthetic 25 marker — this is the ONLY entry point that ever produces stored type 25. - assert_eq!(classify_go_type(1731, 3), Some(GO_GATHER)); // Copper Vein - // A type-3 entry NOT in GATHER_NODES is a real CHEST now (the widened import used - // to require the TREASURE_CHESTS allowlist; ANY chest imports today). + assert_eq!(classify_go_type(1731, 3), Some(GO_GATHER)); assert_eq!(classify_go_type(999_999, 3), Some(GO_CHEST)); - assert_eq!(classify_go_type(106318, 3), Some(GO_CHEST)); // Battered Chest - // DOOR/BUTTON/QUESTGIVER/GOOBER pass through identity (this repo's ids match cmangos' here). + assert_eq!(classify_go_type(106318, 3), Some(GO_CHEST)); assert_eq!(classify_go_type(1, 0), Some(GO_DOOR)); assert_eq!(classify_go_type(2, 1), Some(GO_BUTTON)); assert_eq!(classify_go_type(3, 2), Some(GO_QUESTGIVER)); assert_eq!(classify_go_type(4, 10), Some(GO_GOOBER)); - // An unhandled-but-in-scope type (e.g. 8 SPELL_FOCUS, 6 TRAP, 19 MAILBOX) imports INERT — - // identity passthrough, not a drop. assert_eq!(classify_go_type(5, 8), Some(8)); assert_eq!(classify_go_type(6, 19), Some(19)); } #[test] fn classify_go_type_25_collision_guard_drops_a_real_fishinghole_but_not_a_gather_node() { - // THE load-bearing case: cmangos' REAL type 25 (GAMEOBJECT_TYPE_FISHINGHOLE) must NEVER be - // stored as module type 25 (the synthetic GATHER marker) — it is dropped from import entirely. assert_eq!( classify_go_type(70000, 25), None, "a real FISHINGHOLE (type 25) must be dropped" ); - // A GATHER_NODES entry stays classified as GATHER even though its STORED type (25) numerically - // equals the guarded raw type — because the raw type it actually carries in the dump is 3, not - // 25 (the guard only fires on the RAW type; the two paths never collide in practice). assert_eq!(classify_go_type(1731, 3), Some(GO_GATHER)); - // A non-gather, non-fishinghole entry is unaffected by the guard. assert_eq!(classify_go_type(70001, 3), Some(GO_CHEST)); } #[test] fn go_template_row_carries_lock_id_per_type_and_widens_chest_loot() { - // CHEST: dump data0 (lockId) -> template lock_id column; dump data1 (lootId) -> template data1, - // AND is returned as the "fold into chest_loot_ids_used" value (widened to EVERY chest now). let (row, loot_used) = go_template_row(106318, GO_CHEST, 259, "Battered Chest", 4242, 2277, 1.0); assert_eq!(row, "(106318,3,259,'Battered Chest',0,2277,0,0,0,4242,1)"); assert_eq!(loot_used, Some(2277)); - // A CHEST with lootId 0 (unimported drop table, or the seed/demo chest) does NOT get folded in. let (_, none_used) = go_template_row(1, GO_CHEST, 1, "No Loot Chest", 0, 0, 1.0); assert_eq!(none_used, None); - // DOOR/BUTTON: dump data1 (lockId) -> template lock_id column (data0 [startOpen] does NOT ride - // the template — it feeds the SPAWN row's initial state instead, see `go_initial_state`). let (door_row, door_loot) = go_template_row(2000, GO_DOOR, 100, "Stormwind Gate", 1, 555, 1.0); assert_eq!(door_row, "(2000,0,100,'Stormwind Gate',0,0,0,0,0,555,1)"); assert_eq!(door_loot, None); let (button_row, _) = go_template_row(2001, GO_BUTTON, 101, "Lever", 0, 777, 1.0); assert_eq!(button_row, "(2001,1,101,'Lever',0,0,0,0,0,777,1)"); - // GOOBER: dump data0 (lockId) -> template lock_id column. let (goober_row, _) = go_template_row(3000, GO_GOOBER, 200, "Suspicious Lever", 888, 999, 1.0); assert_eq!( goober_row, "(3000,10,200,'Suspicious Lever',0,0,0,0,0,888,1)" ); - // GATHER: unaffected by lock_id (always 0) — the existing gather-row shape, now with a 10th - // (lock_id) + 11th (size) column appended. A node's sub-1.0 size rides through verbatim. let (gather_row, gather_loot) = go_template_row(1731, GO_GATHER, 259, "Copper Vein", 0, 0, 0.5); assert_eq!( @@ -6160,7 +5005,6 @@ mod tests { "(1731,25,259,'Copper Vein',2770,1,186,300,100,0,0.5)" ); assert_eq!(gather_loot, None); - // A quest giver has no type payload. A spell focus preserves its id and radius. let (qg_row, _) = go_template_row(4000, GO_QUESTGIVER, 50, "Wanted Poster", 1, 2, 0.0); assert_eq!(qg_row, "(4000,2,50,'Wanted Poster',0,0,0,0,0,0,0)"); let (inert_row, _) = go_template_row(5000, 8, 60, "Spell Focus", 3, 4, 1.75); @@ -6169,21 +5013,16 @@ mod tests { #[test] fn go_initial_state_opens_only_a_started_open_door_or_button() { - // DOOR/BUTTON with a nonzero startOpen (dump data0) spawn OPEN (state 1). assert_eq!(go_initial_state(GO_DOOR, 1), 1); assert_eq!(go_initial_state(GO_BUTTON, 1), 1); - // DOOR/BUTTON with startOpen == 0 spawn CLOSED (state 0). assert_eq!(go_initial_state(GO_DOOR, 0), 0); assert_eq!(go_initial_state(GO_BUTTON, 0), 0); - // Every OTHER type always spawns state 0 regardless of data0 (data0 means something else there - // — e.g. a CHEST's data0 is its lockId, not a startOpen flag). assert_eq!(go_initial_state(GO_CHEST, 1), 0); assert_eq!(go_initial_state(GO_GOOBER, 1), 0); assert_eq!(go_initial_state(GO_GATHER, 1), 0); assert_eq!(go_initial_state(GO_QUESTGIVER, 1), 0); } - /// The Deadmines stone is level 15 to 20 for area 1581. #[test] fn gameobject_family_emits_one_meeting_stone_row_per_spawned_stone() { let dump = "INSERT INTO `gameobject_template` VALUES \ @@ -6263,10 +5102,6 @@ mod tests { #[test] fn cr_columns_map_spawn_time_and_movement_type() { - // Creature (spawn) row layout, guid,id,map,spawnMask,x,y,z,o, - // spawntimesecsmin,spawntimesecsmax,spawndist,MovementType — verify the column CONSTANTS - // actually land on the cmangos min-respawn-seconds and MovementType fields, not neighbors - // (a silent off-by-one here would misread every imported creature's respawn timer). let dump = "x INSERT INTO `creature` VALUES (1,299,0,1,10.0,20.0,30.0,1.5,180,300,0,2); y"; let rows = parse_table(dump, "creature"); assert_eq!(rows.len(), 1); @@ -6277,8 +5112,6 @@ mod tests { assert_eq!(field(&rows[0], cr::O), "1.5"); assert_eq!(field(&rows[0], cr::SPAWN_TIME_SECS_MIN), "180"); assert_eq!(field(&rows[0], cr::MOVEMENT_TYPE), "2"); - // Absent/garbage spawntimesecsmin parses as the "not imported" sentinel (0), which the module - // reads as "fall back to the flat legacy respawn timer" (see CreatureSpawn::respawn_secs). let dump_missing = "x INSERT INTO `creature` VALUES (1,299,0,1,10.0,20.0,30.0,1.5); y"; let rows_missing = parse_table(dump_missing, "creature"); let respawn_secs: u32 = field(&rows_missing[0], cr::SPAWN_TIME_SECS_MIN) @@ -6289,21 +5122,16 @@ mod tests { #[test] fn compute_extents_derives_min_max_xyz_and_count_for_the_target_map_only() { - // --print-extents derives the operator's real --box from THEIR dump. Three - // spawns on map 0 (min/max should span them) + one on map 1 (must be excluded). let dump = "x INSERT INTO `creature` VALUES \ (1,100,0,1,-11000.0,1000.0,10.0,0,300,300,0,0),\ (2,101,0,1,-9000.0,-500.0,50.0,0,300,300,0,0),\ (3,102,0,1,-10000.0,2000.0,-5.0,0,300,300,0,0),\ (4,103,1,1,0.0,0.0,0.0,0,300,300,0,0); y"; let e = compute_extents(dump, 0, None).expect("map 0 has spawns"); - assert_eq!(e.count, 3); // the map-1 row is excluded + assert_eq!(e.count, 3); assert_eq!(e.min, (-11000.0, -500.0, -5.0)); assert_eq!(e.max, (-9000.0, 2000.0, 50.0)); - // A map with no spawns at all reports None (nothing to derive a box from). assert!(compute_extents(dump, 7, None).is_none()); - // Map 1's extents are derived from the MAP-1 rows alone, a second continent's box - // comes out of the same one code path, never out of the map-0 numbers. let k = compute_extents(dump, 1, None).expect("map 1 has one spawn"); assert_eq!( (k.count, k.min, k.max), @@ -6313,10 +5141,6 @@ mod tests { #[test] fn compute_extents_narrows_to_a_candidate_box_and_anchors_center_on_a_real_spawn() { - // a whole continent's extents ARE the continent, so deriving a starting-zone box is - // iterate-and-narrow — pass a rough rectangle and read back the true in-box extents + count. - // The anchor is the in-box spawn nearest the rectangle centre: a REAL cmangos ground point, so - // it is always a valid `--center` for the terrain interpolate / nav walkability self-checks. let dump = "x INSERT INTO `creature` VALUES \ (1,100,1,1,-600.0,-4200.0,38.0,0,300,300,0,0),\ (2,101,1,1,-500.0,-4100.0,40.0,0,300,300,0,0),\ @@ -6330,17 +5154,12 @@ mod tests { ); assert_eq!(e.min, (-600.0, -4200.0, 38.0)); assert_eq!(e.max, (-500.0, -4100.0, 40.0)); - // Rectangle centre is (-550,-4150) — spawn 3 sits exactly on it, Z carried verbatim. assert_eq!(e.anchor, (-550.0, -4150.0, 39.0)); - // A candidate box with nothing in it reports None rather than an empty/infinite rectangle. assert!(compute_extents(dump, 1, Some((0.0, 100.0, 0.0, 100.0))).is_none()); } #[test] fn start_positions_are_listed_for_the_requested_map_only() { - // The printed starting-zone anchors come from the dump's OWN playercreateinfo, which - // is how a Kalimdor box gets drawn around Valley of Trials instead of around a remembered - // coordinate. Human(1) is map 0; Orc(2)/Tauren(6) are map 1. let dump = "x INSERT INTO `playercreateinfo` VALUES \ (1,1,0,12,-8949.95,-132.493,83.5312,0),\ (2,1,1,14,-618.518,-4251.67,38.718,0),\ @@ -6365,16 +5184,12 @@ mod tests { #[test] fn sql_text_collapses_null_token_to_empty() { - assert_eq!(sql_text("NULL"), "''"); // cmangos `NULL` subname → empty, not the text "NULL" + assert_eq!(sql_text("NULL"), "''"); assert_eq!(sql_text("Weaponsmith"), "'Weaponsmith'"); } #[test] fn build_graveyard_zone_sql_parses_id_ghost_zone_and_faction() { - // Cmangos game_graveyard_zone columns are (id, ghost_zone, faction), id is - // the world_safe_locs/game_graveyard id (safe_loc_id here), ghost_zone the zone it serves, - // faction 0 = both / else a team-faction id. Northshire+Goldshire+Eastvale serve zone 12 - // (Alliance-only, 469); Sentinel Hill serves zone 40 (also 469). let dump = "x INSERT INTO `game_graveyard_zone` VALUES (105,12,469),(106,12,469),(80,40,469); y"; let stmts = build_graveyard_zone_sql(dump); @@ -6386,8 +5201,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_graveyard_zone ")) .unwrap(); - // row_id,safe_loc_id,zone_id,faction — row_id assigned sequentially (1-based), not the - // cmangos id (which collides across factions/zones for the SAME graveyard in a real dump). assert!(insert.contains("(1,105,12,469)"), "{insert}"); assert!(insert.contains("(2,106,12,469)"), "{insert}"); assert!(insert.contains("(3,80,40,469)"), "{insert}"); @@ -6395,18 +5208,6 @@ mod tests { #[test] fn build_areatrigger_teleport_sql_maps_columns_by_tuple_position() { - // Cmangos areatrigger_teleport columns are (id, name, target_map, - // target_position_x, target_position_y, target_position_z, target_orientation) — see the `at` - // module's `[V]` doc comment. Two rows with DISTINCT values in every column (no accidental - // digit reuse across fields) so a column-index typo/transposition would fail this test, not - // pass it vacuously; one row's name carries an apostrophe to prove `sql_text` escaping rides - // through this builder too. - // The name's apostrophe is backslash-escaped (`\'`), the MySQL/mysqldump convention - // `parse_table` actually implements (see its own doc comment) — NOT the doubled-quote (`''`) - // ANSI-SQL convention `sql_text`'s OUTPUT side re-escapes it into for `spacetime sql`. - // The REAL dump-verified 13-col shape (at:: 2026-07-10): id, name, required_level, - // req_item, req_quest, req_?, target_map, x, y, z, o, message, ? — the old 7-col fixture - // pre-dated the offset fix and parsed 0 rows (fixture drift, found 2026-07-12). let dump = "x INSERT INTO `areatrigger_teleport` VALUES \ (1447,'Deadmines',10,0,0,0,36,-114.8,-9.5,-9.6,3.14,'',0),\ (1448,'Moonbrook\\'s Exit',0,0,0,0,0,-11235.7,1668.3,23.9,1.57,'',0); y"; @@ -6420,9 +5221,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_areatrigger_teleport ")) .unwrap(); - // trigger_id,target_map,x,y,z,o,name — NOT the dump's own (id,name,target_map,x,y,z,o) order; - // `name` moves to the END of our column list (module table field order), everything else keeps - // the dump's tuple position. A transposed x/y/z or a name landing in the wrong slot fails here. assert!( insert.contains("(1447,36,-114.8,-9.5,-9.6,3.14,'Deadmines')"), "{insert}" @@ -6435,37 +5233,25 @@ mod tests { #[test] fn drink_discriminator_keys_on_beverage_names_within_food_and_drink() { - // Drinks → restore mana. In classicdb z2815 these are class 0 / subclass 0 (NOT 5), incl. the key - // mage drink Conjured Water — the subclass-0 path is the common case. assert!(is_drink_consumable(0, 0, "Conjured Water")); - assert!(is_drink_consumable(0, 0, "Morning Glory Dew")); // "dew" + assert!(is_drink_consumable(0, 0, "Morning Glory Dew")); assert!(is_drink_consumable(0, 0, "Ice Cold Milk")); - assert!(is_drink_consumable(0, 5, "Refreshing Spring Water")); // subclass 5 also valid + assert!(is_drink_consumable(0, 5, "Refreshing Spring Water")); assert!(is_drink_consumable(0, 5, "Bottle of Dalaran Red Wine")); - // Food (same class/subclass as drink) → NOT a drink (stays HP) — discriminated purely by name. - assert!(!is_drink_consumable(0, 0, "Tender Wolf Steak")); // "tea" is a substring, not a word - assert!(!is_drink_consumable(0, 0, "Snapvine Watermelon")); // "water" substring, not a word - assert!(!is_drink_consumable(0, 5, "Beer Basted Boar Ribs")); // "beer" dropped (food collision) + assert!(!is_drink_consumable(0, 0, "Tender Wolf Steak")); + assert!(!is_drink_consumable(0, 0, "Snapvine Watermelon")); + assert!(!is_drink_consumable(0, 5, "Beer Basted Boar Ribs")); assert!(!is_drink_consumable(0, 0, "Tough Jerky")); - // Right name, excluded class/subclass → never a drink. - assert!(!is_drink_consumable(2, 7, "Watery Blade")); // a weapon named "water…" - assert!(!is_drink_consumable(0, 1, "Healing Potion")); // potion (subclass 1) excluded - assert!(!is_drink_consumable(0, 2, "Elixir of Water Walking")); // elixir (subclass 2) excluded + assert!(!is_drink_consumable(2, 7, "Watery Blade")); + assert!(!is_drink_consumable(0, 1, "Healing Potion")); + assert!(!is_drink_consumable(0, 2, "Elixir of Water Walking")); } - // ---- LOOT-FAMILY COMPLETENESS ---- - - /// `resolve_ref_pool` flattens a 2-DEEP reference chain (pool 1 → item A direct + a `-ref` to pool 2 - /// → item B) into ONE flat list, and a CYCLE (pool 3 → pool 4 → pool 3) terminates instead of - /// looping forever — the cycle guard returns an empty tail for the pool it re-enters, so pool 3's - /// own direct item still comes through. #[test] fn resolve_ref_pool_flattens_two_deep_and_terminates_a_cycle() { use std::collections::{HashMap, HashSet}; let mut refs: HashMap> = HashMap::new(); - // Pool 1: item 100 direct (30%) + a reference to pool 2 (mincountOrRef = -2). refs.insert(1, vec![(100, 30.0, 0, 1), (0, 0.0, -2, 1)]); - // Pool 2 (nested, 2 deep from pool 1): item 200 direct. refs.insert(2, vec![(200, 100.0, 0, 3)]); let mut visited = HashSet::new(); let flat = resolve_ref_pool(1, &refs, &mut visited); @@ -6481,11 +5267,9 @@ mod tests { .iter() .any(|&(item, chance, count)| item == 200 && chance == 100.0 && count == 3)); - // Cycle: pool 3 → pool 4 → pool 3. Pool 3's OWN direct item (300) still resolves; the cyclic - // re-entry into pool 3 (via pool 4) returns empty instead of recursing forever. let mut cyc: HashMap> = HashMap::new(); cyc.insert(3, vec![(300, 50.0, 0, 1), (0, 0.0, -4, 1)]); - cyc.insert(4, vec![(0, 0.0, -3, 1)]); // pool 4 references pool 3 right back + cyc.insert(4, vec![(0, 0.0, -3, 1)]); let mut visited2 = HashSet::new(); let flat2 = resolve_ref_pool(3, &cyc, &mut visited2); assert_eq!( @@ -6494,19 +5278,13 @@ mod tests { "the cycle terminates; only pool 3's own direct item survives" ); - // An unresolvable pool id (no rows at all) is an empty flatten, never a panic. let mut visited3 = HashSet::new(); assert!(resolve_ref_pool(999, &refs, &mut visited3).is_empty()); } - /// `parse_loot_family` sign handling: a POSITIVE `ChanceOrQuestChance` is an ordinary drop - /// (`quest_only == false`); a NEGATIVE one is cmangos's quest-drop marker — `quest_only == true`, - /// with `chance_bp` taking the ABSOLUTE value (so a -25.5% quest row still emits a real 2550 bp, - /// not a negative/garbage chance). #[test] fn parse_loot_family_reads_the_quest_only_sign_and_abs_value() { use std::collections::HashMap; - // entry, item, ChanceOrQuestChance, groupid, mincountOrRef, maxcount let dump = "x INSERT INTO `pickpocketing_loot_template` VALUES \ (500,1,50.0,0,0,1),\ (500,2,-25.5,0,0,1); y"; @@ -6548,21 +5326,15 @@ mod tests { assert_eq!(&fields[26..28], &["8690", "0"]); } - /// The dropped item_template columns (resistances, extra spell slots, skill/rep - /// gates, maxcount/flags/page_text/startquest/bag_family) parse from their VERIFIED cmangos - /// column indices into the right positions of the emitted `game_item_template` row, appended - /// after `bonding` in struct order. Builds a synthetic 121-column row (indices 0..=120, reaching - /// BagFamily) via the `it` module's own constants, so this test breaks if an index constant ever - /// drifts from the row layout it asserts against. #[test] fn item_template_parses_the_work_item_213_columns_into_the_right_positions() { use std::collections::{HashMap, HashSet}; let mut cols = vec!["0".to_string(); 124]; cols[it::ENTRY] = "90001".into(); - cols[it::CLASS] = "4".into(); // Armor — never a drink, regardless of subclass + cols[it::CLASS] = "4".into(); cols[it::SUBCLASS] = "1".into(); - cols[it::NAME] = "__NAME__".into(); // swapped for a quoted literal below + cols[it::NAME] = "__NAME__".into(); cols[it::DISPLAY_ID] = "1234".into(); cols[it::QUALITY] = "3".into(); cols[it::FLAGS] = "512".into(); @@ -6616,19 +5388,14 @@ mod tests { ); } - /// A template with no resistance/spell-3-5/skill/rep/maxcount/flags/page_text/startquest/ - /// bag_family data at all keeps every column at 0, the additive-migration - /// baseline (unchanged for an ordinary existing item like the starter - /// Worn Shortsword). Built the same index-constant-driven way as the row above, so a real - /// no-frills weapon row round-trips with every new column zeroed. #[test] fn item_template_with_no_new_data_keeps_the_work_item_213_columns_zero() { use std::collections::{HashMap, HashSet}; let mut cols = vec!["0".to_string(); 124]; cols[it::ENTRY] = "25".into(); - cols[it::CLASS] = "2".into(); // Weapon - cols[it::SUBCLASS] = "7".into(); // Sword (one-hand) + cols[it::CLASS] = "2".into(); + cols[it::SUBCLASS] = "7".into(); cols[it::NAME] = "__NAME__".into(); cols[it::DISPLAY_ID] = "1521".into(); cols[it::QUALITY] = "0".into(); @@ -6691,19 +5458,12 @@ mod tests { #[test] fn unrestricted_sentinel_expands_to_the_playable_masks() { - // Vanilla class ids skip 6 and 10 (Druid is 11 → bit 0x400); races 1-8 are contiguous. - // The client hides the tooltip "Classes:" line only when every playable bit is set. assert_eq!(normalize_item_mask("-1", ALL_PLAYABLE_CLASS_MASK), 0x5df); assert_eq!(normalize_item_mask("-1", ALL_PLAYABLE_RACE_MASK), 0xff); } - // --- playercreateinfo_* fixture tests ------------------------------------------ - #[test] fn build_createinfo_spell_sql_parses_race_class_spell_and_wipes_wholesale() { - // Human(1) Warrior(1) wildcard-style rows (race==0 class kit, class==0 racial) pass through - // verbatim — the importer does no wildcard interpretation itself (module reads it at grant - // time via createinfo_row_matches). A concrete Dwarf(3) Warrior(1) row also imports. let dump = "x INSERT INTO `playercreateinfo_spell` VALUES (0,1,78),(1,0,20598),(3,1,78); y"; let stmts = build_createinfo_spell_sql(dump); assert_eq!(stmts[0], "DELETE FROM game_createinfo_spell WHERE id >= 0"); @@ -6728,7 +5488,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_createinfo_action ")) .unwrap(); - // row_id,race,class,button,action,action_type assert!(insert.contains("(1,1,1,0,6603,0)"), "{insert}"); assert!(insert.contains("(2,1,1,1,78,0)"), "{insert}"); } @@ -6744,7 +5503,6 @@ mod tests { #[test] fn build_createinfo_item_sql_without_dbc_imports_every_row_undeduped() { - // No --dbc → no outfit set to dedup against, so every distinct (race,class,item) imports. let dump = "x INSERT INTO `playercreateinfo_item` VALUES (1,1,6948,1),(1,1,2092,1); y"; let stmts = build_createinfo_item_sql(dump, None).expect("no dbc dir needed"); assert_eq!( @@ -6771,9 +5529,6 @@ mod tests { #[test] fn build_createinfo_item_sql_dedupes_within_itself_and_skips_nonpositive_items() { - // The dump lists the SAME (race,class,item) twice (a malformed/duplicated dump row) — only - // one row should import. item 0 (an empty slot sentinel, if the dump ever carries one) is - // skipped, not imported as a bogus item_entry. let dump = "x INSERT INTO `playercreateinfo_item` VALUES (1,1,6948,1),(1,1,6948,1),(1,1,0,1); y"; let stmts = build_createinfo_item_sql(dump, None).expect("no dbc dir needed"); @@ -6785,12 +5540,8 @@ mod tests { assert!(!insert.contains(",0)"), "item 0 must not import: {insert}"); } - // --- (reduced scope): spell_chain / spell_learn_spell fixture parse/shape --------- - #[test] fn build_spell_chain_sql_parses_rank_chain_and_wipes_wholesale() { - // Fireball rank 2 (spell_id 143): prev=133 (rank 1), first=133, rank=2, no extra req_spell. - // Rank 1 itself (133): prev=0 (no predecessor), first=133, rank=1. let dump = "x INSERT INTO `spell_chain` VALUES (133,0,133,1,0),(143,133,133,2,0); y"; let stmts = build_spell_chain_sql(dump, &[]).unwrap(); assert_eq!(stmts[0], "DELETE FROM game_spell_chain WHERE spell_id > 0"); @@ -6798,7 +5549,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_spell_chain ")) .unwrap(); - // spell_id,prev_spell,first_spell,rank,req_spell assert!(insert.contains("(133,0,133,1,0)"), "{insert}"); assert!(insert.contains("(143,133,133,2,0)"), "{insert}"); } @@ -6869,9 +5619,6 @@ mod tests { #[test] fn build_spell_proc_event_sql_maps_the_nine_overlay_columns_by_tuple_position() { - // entry,SchoolMask,SpellFamilyName,SpellFamilyMask,procFlags,procEx,ppmRate,CustomChance,Cooldown - // Two shapes worth pinning together: a filtered talent proc (Impact 12358 — fire school, mage - // family, crit-only, no rate) and a rate-plus-cooldown weapon proc. let dump = "x INSERT INTO `spell_proc_event` VALUES \ (12358,4,3,32,0,2,0,0,0),(20007,0,0,0,20,0,1.5,15,45); y"; let stmts = build_spell_proc_event_sql(dump, None).unwrap(); @@ -6883,9 +5630,7 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_spell_proc_event ")) .expect("two rows must produce one INSERT"); - // spell_id,proc_flags,proc_ex,school_mask,family_name,family_flags,ppm_rate,custom_chance,icd_ms assert!(insert.contains("(12358,0,2,4,3,32,0,0,0)"), "{insert}"); - // The cooldown is seconds in the dump and milliseconds on the row. assert!( insert.contains("(20007,20,0,0,0,0,1.5,15,45000)"), "{insert}" @@ -6929,8 +5674,6 @@ mod tests { #[test] fn build_spell_proc_event_sql_empty_table_yields_delete_only_no_insert() { - // A dump with no `spell_proc_event` table at all: the overlay is optional per spell AND as a - // whole, so the clear still runs and every Proc falls back to its Spell.dbc values. let stmts = build_spell_proc_event_sql("x y", None).unwrap(); assert_eq!( stmts, @@ -6940,9 +5683,6 @@ mod tests { #[test] fn build_spell_proc_event_sql_drops_a_row_the_delete_cannot_reach_or_the_schema_moved() { - // A synthetic-range entry is a seeded fixture's own overlay row: the DELETE deliberately spares - // it, so importing one would PK-collide on the next reload. An unknown row width is dropped - // rather than mapping shifted columns onto the wrong fields. let dump = "x INSERT INTO `spell_proc_event` VALUES \ (50143,0,0,0,0,0,0,0,5),(133,0,0,0,0,0,0,0,0,0,0,0); y"; let stmts = build_spell_proc_event_sql(dump, None).unwrap(); @@ -6954,8 +5694,6 @@ mod tests { #[test] fn build_spell_chain_sql_empty_table_yields_delete_only_no_insert() { - // No cmangos `spell_chain` rows at all (e.g. an un-imported dump) — the DELETE still runs - // (wholesale wipe), but push_insert emits no INSERT for zero rows. let stmts = build_spell_chain_sql("x y", &[]).unwrap(); assert_eq!( stmts, @@ -6972,7 +5710,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_spell_learn ")) .unwrap(); - // id,parent_spell,learn_spell — ids assigned densely from 1, dump order preserved. assert!(insert.contains("(1,196,197)"), "{insert}"); assert!( insert.contains("(2,196,198)"), @@ -6982,8 +5719,6 @@ mod tests { #[test] fn build_spell_learn_sql_empty_table_still_synthesizes_armor_proficiency_wrappers() { - // No cmangos spell_learn_spell rows at all — the two verified armor-proficiency wrapper→passive - // pairs still synthesize (cmangos ships neither; see the builder's doc). let stmts = build_spell_learn_sql("x y"); assert_eq!(stmts[0], "DELETE FROM game_spell_learn WHERE id > 0"); let insert = stmts @@ -7003,8 +5738,6 @@ mod tests { #[test] fn build_spell_learn_sql_synthesizes_armor_proficiency_wrappers_exactly_once_alongside_real_rows( ) { - // Real spell_learn_spell rows for unrelated spells coexist with the two synthesized pairs, each - // appearing exactly once, with ids continuing densely past the real rows. let dump = "x INSERT INTO `spell_learn_spell` VALUES (196,197,1); y"; let stmts = build_spell_learn_sql(dump); let insert = stmts @@ -7020,8 +5753,6 @@ mod tests { #[test] fn build_spell_learn_sql_does_not_double_synthesize_if_dump_already_has_the_wrapper_row() { - // Defense-in-depth: if a future cmangos dump grows a real spell_learn_spell row for one of the - // wrapper parents, the synthesis must not add a second, colliding row for that parent. let dump = "x INSERT INTO `spell_learn_spell` VALUES (7109,750,1); y"; let stmts = build_spell_learn_sql(dump); let insert = stmts @@ -7033,15 +5764,11 @@ mod tests { 1, "must not double-insert the plate wrapper: {insert}" ); - // The mail pair still synthesizes — only the colliding parent is skipped. assert!(insert.contains("8738,8737"), "{insert}"); } #[test] fn family_spellmeta_filter_produces_only_spellmeta_family_stmts() { - // `--family spellmeta` (reduced scope) mirrors the `--family` gate parity - // pattern: the plan must carry ONLY the three spellmeta blocks — no other family's - // statements — even on a dump that also carries an in-box quest fixture. let dump = format!( "{} INSERT INTO `spell_chain` VALUES (133,0,133,1,0); \ INSERT INTO `spell_learn_spell` VALUES (196,197,1); \ @@ -7121,22 +5848,13 @@ mod tests { plan.stmts ); } - // Only the spellmeta family gets a provenance stamp (1 chain row + 1 real learn row + the 2 - // synthesized armor-proficiency wrapper→passive rows + 1 proc-event row). assert_eq!(plan.stamps, vec![("spellmeta", 5u64)], "{:?}", plan.stamps); } - // --- gossip completeness (menu options, multi-slot npc_text, conditions) -------- - - /// Build a `creature_template` INSERT tuple wide enough to reach `ct::GOSSIP_MENU_ID` (col 77): - /// entry at col 0, gossip_menu_id at col 77, everything else `0`. fn creature_template_row(entry: u64, gossip_menu_id: u64) -> String { creature_template_row_with_faction(entry, gossip_menu_id, 0, 0, 0, 0) } - /// `creature_template_row` plus `TrainerType` (71) and `TrainerClass` (73). Both sit below - /// `ct::GOSSIP_MENU_ID`, so the row is already wide enough. The gap between them is - /// `TrainerSpell`, which is not ingested. fn creature_template_row_with_trainer( entry: u64, gossip_menu_id: u64, @@ -7164,8 +5882,6 @@ mod tests { format!("({})", cols.join(",")) } - /// A minimal `gossip_menu_option` row in the SHORT (no-condition) 7-column vintage: - /// `menu_id, id, option_icon, option_text, option_type, npc_option_npcflag, action_menu_id`. fn gossip_option_row_short( menu_id: u64, id: u64, @@ -7173,19 +5889,11 @@ mod tests { text: &str, option_type: u64, ) -> String { - // The REAL dump-verified 15-col gossip_menu_option shape (gmo:: 2026-07-10): menu_id, id, - // option_icon, option_text, option_broadcast_text, option_id(TYPE), npc_option_npcflag, - // action_menu_id, action_poi_id, action_script_id, box_coded, box_money, box_text, - // box_broadcast_text, condition_id. The old 7-col fixture pre-dated the offset fix and - // silently parsed 0 options (fixture drift, found 2026-07-12). format!("({menu_id},{id},{icon},'{text}',0,{option_type},0,0,0,0,0,0,'',0,0)") } #[test] fn build_gossip_sql_orders_menu_options_by_dense_index_and_maps_actions() { - // An Elwynn-innkeeper-shaped NPC: 3 options (plain gossip / vendor / innkeeper) inserted in - // `id` order — assert the importer keeps that order (dense 0..2 option_index) and copies the - // cmangos action code through verbatim (see `lyracore_shared::constants::gossip_option`). let entry = 300u64; let menu_id = 900u64; let dump = format!( @@ -7199,7 +5907,6 @@ mod tests { let entries: std::collections::HashSet = [entry].into_iter().collect(); let etl = build_gossip_sql(&dump, &entries); assert_eq!(etl.option_rows.len(), 3, "{:?}", etl.option_rows); - // row_id,entry,option_index,icon,text,action,action_menu_id,cond_type,cond_value1,cond_value2 assert!( etl.option_rows[0].contains(&format!("(1,{entry},0,0,'Lets talk.',1,0,0,0,0)")), "{}", @@ -7257,22 +5964,18 @@ mod tests { #[test] fn build_gossip_sql_reindexes_dense_option_order_even_when_cmangos_ids_have_gaps() { - // cmangos option `id`s aren't guaranteed contiguous (a deleted row leaves a gap) — the importer - // must still emit a DENSE 0-based `option_index` (the gateway's render/select alignment depends - // on it), sorted by the cmangos id. let entry = 301u64; let menu_id = 901u64; let dump = format!( "x INSERT INTO `creature_template` VALUES {}; \ INSERT INTO `gossip_menu_option` VALUES {}, {}; y", creature_template_row(entry, menu_id), - gossip_option_row_short(menu_id, 5, 0, "Second.", 1), // higher cmangos id, inserted first + gossip_option_row_short(menu_id, 5, 0, "Second.", 1), gossip_option_row_short(menu_id, 2, 0, "First.", 1), ); let entries: std::collections::HashSet = [entry].into_iter().collect(); let etl = build_gossip_sql(&dump, &entries); assert_eq!(etl.option_rows.len(), 2); - // (row_id,entry,option_index,icon,text...), option_index 0 must land on the LOWER cmangos id. assert!( etl.option_rows[0].contains(&format!("{entry},0,0,'First.'")), "{}", @@ -7287,8 +5990,6 @@ mod tests { #[test] fn build_gossip_sql_drops_questgiver_placeholder_rows_and_keeps_the_index_dense() { - // An `OptionType=2` row's label is the literal string "GOSSIP_OPTION_QUESTGIVER" (13 NPCs in - // the dump, Innkeeper Farley among them), and its quests arrive via the quest section anyway. use lyracore_shared::constants::gossip_option; let entry = 320u64; let menu_id = 920u64; @@ -7319,18 +6020,14 @@ mod tests { "{:?}", etl.option_rows ); - // The surviving rows are indexed 0 and 1 — the drop must leave no gap for the menu to render. assert!(etl.option_rows[0].contains(&format!("{entry},0,0,'Chat.'"))); assert!(etl.option_rows[1].contains(&format!("{entry},1,0,'Train me.'"))); } #[test] fn build_gossip_sql_takes_the_unconditional_greeting_as_the_menu_title() { - // Several rows per entry, in no meaningful order: one unconditional greeting plus - // condition-gated variants. First-row-wins made a conditional greeting the permanent title. let entry = 321u64; let menu_id = 921u64; - // npc_text short (17-col) rows: id, prob0..7, BroadcastTextId0..7 — only slot 0 populated. let dump = format!( "x INSERT INTO `creature_template` VALUES {}; \ INSERT INTO `gossip_menu` VALUES ({menu_id},5100,0,44),({menu_id},5101,0,0); \ @@ -7351,7 +6048,6 @@ mod tests { #[test] fn build_gossip_sql_hides_a_seasonal_event_option_instead_of_failing_open() { - // mangos type 12 = CONDITION_ACTIVE_GAME_EVENT, the one condition that fails CLOSED. let entry = 322u64; let menu_id = 922u64; let option = @@ -7376,12 +6072,8 @@ mod tests { #[test] fn build_gossip_sql_folds_an_inline_quest_taken_condition() { - // The INLINE-condition dump vintage (14 cols: the 11-col FK shape + cond_type/value1/value2 - // appended). mangos type 9 = CONDITION_QUESTTAKEN → our QUEST_TAKEN (1), value1 = the quest id. let entry = 302u64; let menu_id = 902u64; - // 14 cols: menu_id,id,icon,text,type,npcflag,action_menu_id,poi,box_coded,box_money,box_text, - // cond_type(11),cond_value1(12),cond_value2(13, unused by the current minimal condition set). let option = format!("({menu_id},0,0,'Have you started my quest?',1,0,0,0,0,0,'NULL',9,777,0)"); let dump = format!( @@ -7392,7 +6084,6 @@ mod tests { let entries: std::collections::HashSet = [entry].into_iter().collect(); let etl = build_gossip_sql(&dump, &entries); assert_eq!(etl.option_rows.len(), 1); - // cond_type=1 (QUEST_TAKEN), cond_value1=777, cond_value2=0 — the trailing 3 fields. assert!( etl.option_rows[0].ends_with("1,777,0)"), "{}", @@ -7403,10 +6094,6 @@ mod tests { #[test] fn build_gossip_sql_resolves_condition_via_the_generic_conditions_table_and_fails_open_when_unmapped( ) { - // The FK-vintage dump (11 cols, `condition_id` at col 10) joins a generic `conditions` row. - // mangos type 8 = CONDITION_QUESTREWARDED → our QUEST_REWARDED (2). A SECOND option references a - // condition type this importer doesn't map (5) — it must fail OPEN (cond_type 0, always shown), - // never silently hide the option. let entry = 303u64; let menu_id = 903u64; let dump = format!( @@ -7424,25 +6111,21 @@ mod tests { etl.option_rows[0].ends_with("2,555,0)"), "{}", etl.option_rows[0] - ); // QUEST_REWARDED + ); assert!( etl.option_rows[1].ends_with("0,0,0)"), "{}", etl.option_rows[1] - ); // fail-open + ); } #[test] fn build_gossip_sql_resolves_broadcast_text_for_both_npc_text_and_option_labels() { - // The BroadcastText-era dump: npc_text is the SHORT 17-col schema (id + 8 probs + 8 - // BroadcastTextIds) and the option's `option_text` column carries a bare broadcast_text id - // instead of literal text — both must resolve through `broadcast_text`. let entry = 304u64; let menu_id = 904u64; let text_id = 5000u64; - // npc_text short row: id, prob0..7, bcast0..7 — only slot 0 populated (bcast id 8001). let npc_text_row = format!("({text_id},1.0,0,0,0,0,0,0,0,8001,0,0,0,0,0,0,0)"); - let option = gossip_option_row_short(menu_id, 0, 0, "9002", 1); // option_text = bcast id, quoted as a string + let option = gossip_option_row_short(menu_id, 0, 0, "9002", 1); let dump = format!( "x INSERT INTO `creature_template` VALUES {}; \ INSERT INTO `gossip_menu` VALUES ({menu_id},{text_id}); \ @@ -7471,20 +6154,15 @@ mod tests { #[test] fn build_gossip_sql_emits_every_populated_npc_text_slot_and_skips_empty_ones() { - // Full 81-col npc_text row with slot 0 AND slot 3 populated (a weighted-variant greeting) — - // all 8 slots must be considered, not just the first, and an all-empty/zero-prob slot must not - // emit a row at all (the gateway reads "no rows" as the legacy single-slot fallback). let entry = 305u64; let menu_id = 905u64; let text_id = 5001u64; let mut cols = vec!["NULL".to_string(); 81]; cols[0] = text_id.to_string(); - // slot 0: base 1 — male/female/lang/prob cols[1] = "'Well met.'".into(); cols[2] = "'Well met, traveler.'".into(); cols[3] = "0".into(); cols[4] = "0.6".into(); - // slot 3: base 31 cols[31] = "'Watch yourself.'".into(); cols[32] = "NULL".into(); cols[33] = "0".into(); @@ -7502,14 +6180,13 @@ mod tests { etl.npc_text_rows[0].contains("Well met."), "{:?}", etl.npc_text_rows - ); // first-nonempty + ); assert_eq!( etl.npc_text_slot_rows.len(), 2, "{:?}", etl.npc_text_slot_rows ); - // Row shape: (id,text_id,slot_index,text_male,text_female,probability). let slot0 = etl .npc_text_slot_rows .iter() @@ -7532,12 +6209,8 @@ mod tests { ); } - // --- import pipeline tooling (--family, --source-sha, provenance stamps) -------- - #[test] fn sha256_hex_matches_known_test_vectors() { - // Well-known NIST test vectors — pins `sha256_hex`'s `sha256sum` shell-out against a value - // nobody can silently get wrong. assert_eq!( sha256_hex(b"abc").unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" @@ -7550,9 +6223,6 @@ mod tests { #[test] fn families_fixed_set_accepts_every_named_family_and_rejects_unknown() { - // Mirrors `parse_args`'s `--family` validation exactly (`FAMILIES.contains(&v.as_str())`) — - // pins the fixed set promised: covers --dump families only (terrain/dbc/spells - // are separate invocations, not in this list). for name in [ "creatures", "items", @@ -7609,9 +6279,6 @@ mod tests { } } - /// An absent `--packages` is "the operator did not say", which the spell import then warns - /// about. It must never read as an empty enabled set, because that would delete every row a - /// Package invented. #[test] fn the_enabled_packages_root_is_absent_until_the_operator_names_it() { let unnamed = parse_args_from(["--dbc", "/path/need-not-exist", "--spells"]) @@ -7629,8 +6296,6 @@ mod tests { assert_eq!(named.packages.as_deref(), Some("packages")); } - /// The Base Snapshot is an author-time read. Naming it beside an import would leave the operator - /// guessing whether anything was loaded, so the two modes stay apart. #[test] fn the_base_snapshot_mode_is_standalone() { let alone = parse_args_from([ @@ -7671,8 +6336,6 @@ mod tests { assert!(format!("{error:#}").contains("--dbc"), "{error:#}"); } - /// `--pack-out` is an input in its own right: it writes a distributable directory out of the - /// repo's own client content, so it needs no dump and no client data path. #[test] fn the_client_pack_output_dir_is_an_input_on_its_own() { let args = parse_args_from(["--pack-out", "target/client-artifact"]) @@ -7681,8 +6344,6 @@ mod tests { assert_eq!(args.pack_client, None); } - /// `--apply` guards writes to the operator's own client. `--pack-out` never touches one, so - /// accepting the flag would promise a dry run that does not exist. #[test] fn the_client_pack_output_dir_refuses_apply() { let error = parse_args_from(["--pack-out", "target/client-artifact", "--apply"]) @@ -7694,8 +6355,6 @@ mod tests { ); } - /// Dispatch runs one packer. Taking both flags would run `--pack-client` and drop the - /// distributable directory the caller also asked for, so the pair is refused by name. #[test] fn the_two_client_packers_refuse_to_run_together() { let error = parse_args_from([ @@ -7712,8 +6371,6 @@ mod tests { ); } - /// Every `--dump` family has a Package Delta stage of its own, so `--packages` is valid beside - /// any of them and beside a full run. #[test] fn an_enabled_packages_root_with_a_package_delta_family_active_parses() { let mut argvs: Vec> = vec![vec![ @@ -7820,14 +6477,11 @@ mod tests { assert!(format!("{removed:#}").contains("scope is empty")); } - // ---- --include-map (the Deadmines map-36 slice) ------------------------------ - #[test] fn row_in_slice_keeps_box_geometry_for_the_primary_map_and_whole_map_for_included() { let mut args = test_args(); args.bbox = Some((-9000.0, -8900.0, -200.0, -100.0)); args.include_maps = vec![36]; - // Primary-map spawns remain bounded by the requested box. assert!( row_in_slice(&args, 0, -8949.95, -132.493, 83.5), "in-box map-0 row imports" @@ -7836,14 +6490,11 @@ mod tests { !row_in_slice(&args, 0, -10650.0, 1180.0, 34.0), "out-of-box map-0 row does not" ); - // Included map: WHOLE map, no geometry — Deadmines interior coords are nowhere near the box. assert!( row_in_slice(&args, 36, -16.4, -383.07, -33.35), "map-36 row imports anywhere" ); - // A map that is neither primary nor included never imports (Kalimdor stays out). assert!(!row_in_slice(&args, 1, -16.4, -383.07, -33.35)); - // The --exclude carve still applies to the PRIMARY map only. args.exclude = Some((-8960.0, -8940.0, -140.0, -120.0)); assert!( !row_in_slice(&args, 0, -8949.95, -132.493, 83.5), @@ -8465,9 +7116,7 @@ mod tests { fn force_include_creatures_keeps_an_out_of_box_row_by_entry() { let mut args = test_args(); args.bbox = Some((-9000.0, -8900.0, -200.0, -100.0)); - // An out-of-box Westfall giver (Magistrate Solomon 344 at ~-9219,-2222) is dropped normally… assert!(!creature_row_kept(&args, 0, -9219.0, -2222.0, 68.0, 344)); - // …but kept once its entry is force-included, WITHOUT changing in-box geometry. args.include_creatures = vec![344, 266]; assert!( creature_row_kept(&args, 0, -9219.0, -2222.0, 68.0, 344), @@ -8481,19 +7130,14 @@ mod tests { !creature_row_kept(&args, 0, -9219.0, -2222.0, 68.0, 999), "a non-listed out-of-box entry still drops" ); - // A pool slot (raw_entry 0) never matches a force-listed giver — falls through to geometry. assert!(!creature_row_kept(&args, 0, -9219.0, -2222.0, 68.0, 0)); } #[test] fn the_map_fence_drops_a_force_included_entry_that_spawns_on_a_foreign_map() { - // --include-creatures matches on ENTRY, so before the map fence a - // force-listed giver's spawn on ANOTHER map rode into the slice — i.e. a Kalimdor shard's ETL - // could import an Elwynn spawn (or vice versa) purely because an entry was force-listed. No - // flag combination may cross the map boundary; the fence is the gate, not script hygiene. let mut args = test_args(); - args.map = 1; // a Kalimdor world shard - args.bbox = Some((-700.0, -400.0, -4300.0, -4000.0)); // Valley of Trials-ish + args.map = 1; + args.bbox = Some((-700.0, -400.0, -4300.0, -4000.0)); args.include_creatures = vec![344]; assert!( !creature_row_kept(&args, 0, -9219.0, -2222.0, 68.0, 344), @@ -8503,38 +7147,25 @@ mod tests { creature_row_kept(&args, 1, -550.0, -4150.0, 39.0, 344), "the in-box map-1 row is kept" ); - // Symmetric: on the canonical map-0 run a force-listed entry's map-1 spawn is fenced out too, - // while its in-corridor map-0 spawn still bypasses the box exactly as needs. let mut m0 = test_args(); m0.bbox = Some((-11400.0, -8000.0, -3100.0, 2000.0)); m0.include_creatures = vec![344]; assert!(m0.map == 0 && creature_row_kept(&m0, 0, -9219.0, -2222.0, 68.0, 344)); assert!(!creature_row_kept(&m0, 1, -550.0, -4150.0, 39.0, 344)); - // --include-map still opens a whole extra map to the same force-include path (Deadmines). m0.include_maps = vec![36]; assert!(creature_row_kept(&m0, 36, -16.4, -383.07, -33.35, 644)); assert!(map_in_run(&m0, 0) && map_in_run(&m0, 36) && !map_in_run(&m0, 1)); } - /// The trainer columns land from the indices they are actually written at, with `TrainerSpell` - /// skipped between them. - /// - /// This is the only automated guard on those indices. A wrong one reads a real neighbouring cell - /// and produces plausible data rather than an error, so the fixture gives type and class - /// different values and poisons every neighbour. #[test] fn trainer_type_and_class_import_from_their_own_columns() { - // Paladin trainer: type 0 (CLASS) / class 2. Type stays at the enum's 0, so the class cell - // alone carries the signal — the same shape the live gate keys on. let mut paladin = vec!["0".to_string(); ct::GOSSIP_MENU_ID + 1]; paladin[ct::ENTRY] = "925".to_string(); paladin[ct::TRAINER_TYPE] = "0".to_string(); paladin[ct::TRAINER_CLASS] = "2".to_string(); - // Poison every neighbour so a slipped index reads a rejected value, not a plausible 0. Note - // 71+1 and 73-1 are the same cell: the two ingested columns are not adjacent. - paladin[70] = "66".to_string(); // MovementType — one before TrainerType - paladin[72] = "7".to_string(); // TrainerSpell — between the two we ingest - paladin[74] = "9".to_string(); // TrainerRace — one after TrainerClass + paladin[70] = "66".to_string(); + paladin[72] = "7".to_string(); + paladin[74] = "9".to_string(); let dump = format!( "x INSERT INTO `creature` VALUES \ (1,925,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0),\ @@ -8542,9 +7173,7 @@ mod tests { (3,620,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ INSERT INTO `creature_template` VALUES ({}),{},{}; y", paladin.join(","), - // Weapon master: TRADESKILLS (2) with NO class — serves everyone. creature_template_row_with_trainer(11867, 0, 2, 0), - // A plain critter: both cells 0, the overwhelming majority of the dump. creature_template_row_with_trainer(620, 0, 0, 0), ); let args = test_args(); @@ -8554,12 +7183,10 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_creature_template")) .unwrap_or_else(|| panic!("template insert present: {:?}", plan.stmts)); - // The two columns are named, and END-appended after skin_loot_id (the migration rule). assert!( insert.contains("skin_loot_id,trainer_type,trainer_class"), "trainer columns are named and end-appended: {insert}" ); - // Every emitted tuple, keyed by entry -> its last two cells. let values = insert .split_once(" VALUES ") .expect("the insert names its columns then VALUES") @@ -8592,14 +7219,8 @@ mod tests { ); } - /// The verbatim `npc_trainer` → `game_trainer_spell` path (unchanged by the armor-proficiency - /// wrapper work) already carries `required_level` and `cost` straight through for an armor-wrapper - /// offering — this pins that behavior rather than re-implementing it. The gap the wrapper work - /// closes is `game_spell_learn` (see `build_spell_learn_sql_*` tests), not this path. #[test] fn npc_trainer_armor_wrapper_row_imports_verbatim_required_level_and_cost() { - // A Paladin trainer (entry 925, in-box at the test-args default center) offering the Plate - // Mail wrapper (7109) at req level 40 for 22000 copper — real-world ClassicDB shape. let dump = format!( "x INSERT INTO `creature` VALUES \ (1,925,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ @@ -8614,7 +7235,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_trainer_spell")) .unwrap_or_else(|| panic!("trainer_spell insert present: {:?}", plan.stmts)); - // id,trainer_entry,spell_id,cost,required_level,learn_skill_line,learn_skill_cap assert!( insert.contains(",925,7109,22000,40,0,75)"), "wrapper row keeps req level 40 and verbatim cost: {insert}" @@ -8623,12 +7243,6 @@ mod tests { #[test] fn include_map_spawns_import_whole_map_with_their_own_map_and_verbatim_z() { - // One in-box map-0 creature, one map-36 creature far outside the box with a WMO-interior Z, - // one map-1 creature (NOT included — must be dropped), plus a map-36 DOOR gameobject. - // Pins the three invariants: (a) --include-map imports the whole extra map, - // (b) each packed row carries its OWN map id (not args.map), and (c) the dump Z rides - // through UNALTERED. There is no ground-z snap in the import path, and Map 36 does not stage - // single-layer terrain, so the spawn's cmangos Z remains its authored height. let dump = format!( "x INSERT INTO `creature` VALUES \ (1,100,0,1,-8949.95,-132.493,83.5312,0,300,300,0,0),\ @@ -8646,7 +7260,6 @@ mod tests { let plan = build_dump_plan(&dump, &args, &None, &None).expect("build_dump_plan"); let spawn_payload = plan.spawn_batches.join(";"); let rows: Vec<&str> = spawn_payload.split(';').collect(); - // (a)+(b)+(c) for creatures: the map-36 row is present, on map 36, Z byte-verbatim. let dm_row = rows .iter() .find(|r| r.split(',').nth(1) == Some("644")) @@ -8660,7 +7273,6 @@ mod tests { f[5], "-33.35", "the dump Z is verbatim — no snap, no rounding: {dm_row}" ); - // The primary map-0 row still imports (map field 0), and map 1 is NOT in the slice. assert!( rows.iter().any(|r| { let f: Vec<&str> = r.split(',').collect(); @@ -8672,7 +7284,6 @@ mod tests { !rows.iter().any(|r| r.split(',').nth(1) == Some("999")), "a map neither primary nor included must be dropped: {spawn_payload}" ); - // The map-36 gameobject rides the same rule, carrying map 36 in its packed row. let go_payload = plan.go_batches.join(";"); let go_row = go_payload .split(';') @@ -8712,11 +7323,6 @@ mod tests { #[test] fn creature_movement_template_expands_onto_waypoint_spawns_without_direct_rows() { - // Two waypoint-typed (MovementType=2) spawns of the same entry 644 on map 36, one direct- - // routed spawn of entry 645: the entry-keyed creature_movement_template path expands onto - // BOTH 644 spawns (each gets its own copy, keyed by its world guid), a PathId!=0 row is - // skipped (9-col vintage), and the direct creature_movement rows for 645 win (no template - // expansion for it, proven by 645 also having template rows that must NOT be emitted). let dump = format!( "x INSERT INTO `creature` VALUES \ (10,644,36,1,-16.4,-383.07,-33.35,0,300,300,0,2),\ @@ -8741,7 +7347,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_creature_waypoint")) .unwrap_or_else(|| panic!("waypoint insert present: {:?}", plan.stmts)); - // Both 644 spawns got the 2-point path — 4 template points total + 1 direct 645 point. assert_eq!( wp_insert.matches("-17,-384,-33.35").count(), 2, @@ -8752,7 +7357,6 @@ mod tests { 2, "point 2 lands on both spawns: {wp_insert}" ); - // Point order is the dump's Point column (1 before 2), not dump row order (2 listed first). let g10 = world_guid(644, 10).to_string(); let p1 = wp_insert .find(&format!("{g10},-17,-384")) @@ -8761,7 +7365,6 @@ mod tests { .find(&format!("{g10},-18,-385")) .expect("spawn 10 point 2"); assert!(p1 < p2, "route order follows the Point column: {wp_insert}"); - // PathId 1 rows are skipped; direct rows win for 645 (its template row never lands). assert!( !wp_insert.contains("-99,-99"), "PathId!=0 must be skipped: {wp_insert}" @@ -8807,9 +7410,6 @@ mod tests { assert!(error.to_string().contains("--taxi-mounts")); } - /// A default `Args` for tests that need the struct but not real CLI input (mirrors `parse_args`'s - /// defaults where it matters for `build_dump_plan`: center/radius match the Northshire default so - /// a fixture creature placed exactly at the center lands in scope). fn test_args() -> Args { Args { dump: None, @@ -9303,8 +7903,6 @@ mod tests { assert_eq!(quests.reward_spells, ["(5091041,50944)", "(5091042,50945)"]); } - /// A minimal `quest_template` INSERT tuple wide enough to reach `qt::REW_MONEY_MAX_LEVEL` (col 99): - /// entry at col 0, title at col 30, everything else `0`. Mirrors `creature_template_row`'s pattern. fn quest_template_row(entry: u64, title: &str) -> String { let mut cols = vec!["0".to_string(); qt::REW_MONEY_MAX_LEVEL + 1]; cols[qt::ENTRY] = entry.to_string(); @@ -9335,7 +7933,6 @@ mod tests { format!("({})", cols.join(",")) } - /// Source columns from the pinned ClassicDB schema, independent of the importer offsets. fn quest_template_row_with_reward_mail( entry: u64, title: &str, @@ -9350,8 +7947,6 @@ mod tests { format!("({})", cols.join(",")) } - /// One `dbscripts_on_quest_end` row, `command`/column positions verified against the pinned - /// dump's own `CREATE TABLE` (see the `dse` module). fn dbscripts_send_mail_row( quest_entry: u64, mail_template: u32, @@ -9364,11 +7959,6 @@ mod tests { ) } - /// The pinned dump's own three reward-mail quests — 8728 sends 1,000,000 copper from creature - /// 11811 after 36 hours; 5237 and 5238 send text-only letters after 24 hours. Quest 3645 (a - /// `quest_template.RewMailTemplateId` quest, template 99, delay 86,400) provides the OTHER - /// source, and its template names loot item 11423 — both real values, confirmed against the - /// pinned `cd0c426a…` dump rather than re-derived from this importer. #[test] fn quest_reward_mail_imports_from_quest_template_and_from_dbscripts() { let dump = format!( @@ -9584,8 +8174,6 @@ mod tests { format!("({})", cols.join(",")) } - /// The leading fields of ClassicDB's real quest 783 row. Keep the two adjacent zero and seven - /// values literal so this catches a column constant that drifts back to BreadcrumbForQuestId. fn classicdb_quest_783_row() -> String { let mut cols = vec!["0".to_string(); 123]; for (index, value) in [ @@ -9606,10 +8194,6 @@ mod tests { format!("({})", cols.join(",")) } - /// A tiny but REAL synthetic dump: one in-box creature (a quest giver) + its template + one - /// quest + the giver relation linking them — just enough for `build_dump_plan` to run - /// end-to-end (every family's prerequisite parsing touches this data) without a real cmangos - /// dump. fn tiny_quest_dump(creature_entry: u64, quest_entry: u64) -> String { format!( "x INSERT INTO `creature` VALUES (1,{creature_entry},0,1,-8949.95,-132.493,83.5312,0,300,300,0,0); \ @@ -9645,7 +8229,6 @@ mod tests { format!("({})", columns.join(",")) } - /// `creature_template_row` with `UnitFlags` (col 17) set; everything else `0`. fn creature_template_row_with_unit_flags(entry: u64, unit_flags: u32) -> String { let mut cols = vec!["0".to_string(); ct::GOSSIP_MENU_ID + 1]; cols[ct::ENTRY] = entry.to_string(); @@ -9873,7 +8456,6 @@ mod tests { let plan = build_dump_plan(&dump, &args, &None, &None).expect("EventAI plan"); let definitions = plan.eventai_definition_batches.join("\n"); - // Source rule 16 names spawn guid 2. Source rule 17 names entry 200. assert!( definitions .lines() @@ -10165,8 +8747,6 @@ mod tests { assert!(plan .definition_rows .iter() - // 903 is rewritten to source chat type 2 above, so it encodes as a narrative emote - // rather than speech. .any(|row| row.contains("speak:emote:self:903"))); assert_eq!(plan.event_counts(99), (1, 0, 1, 0)); assert_eq!(plan.action_counts(99), (3, 0, 3, 0)); @@ -10174,10 +8754,6 @@ mod tests { #[test] fn family_quests_filter_produces_only_quest_family_stmts() { - // `--family quests`: the plan must carry ONLY the quest tables' DELETE+INSERT - // — no creature/item/loot/gossip/gameobject/trainer/cast/globals statements — even though every - // family's prerequisite parsing still ran (the quest giver relation needed the creature entry - // to resolve, per `build_quests`' doc). let dump = tiny_quest_dump(100, 500); let mut args = test_args(); args.family = Some("quests".to_string()); @@ -10230,13 +8806,9 @@ mod tests { plan.stmts ); } - // Only the quests family gets a provenance stamp. assert_eq!(plan.stamps, vec![("quests", 1u64)], "{:?}", plan.stamps); } - /// `next_quest_id`/`limit_time` thread through the FULL import (dump row → parsed - /// column → the emitted `game_quest_template` INSERT tuple), not just `build_quests`' own struct — - /// extends the quests-family fixture with a chained+timed quest. #[test] fn chained_and_timed_quest_columns_thread_through_the_full_import() { let dump = format!( @@ -10255,13 +8827,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_quest_template") && s.contains("500")) .unwrap_or_else(|| panic!("quest 500 row present: {:?}", plan.stmts)); - // Match the triple adjacent-and-in-order rather than anchored at the row's end: `reward_money_max_level` - // (daf083c, authentic quest XP) END-appended a THIRD column after next_quest_id/limit_time, and an - // end-anchored check broke on that legitimate append even though both still threaded through - // correctly. Pin the VALUE of all three (777 is the fixture's distinctive REW_MONEY_MAX_LEVEL, see - // `quest_template_row_chained_timed`) rather than just checking a comma follows — a comma-only check - // would pass even if reward_money_max_level silently came out wrong (e.g. always the unwrap_or(0) - // default). Any FURTHER end-append is still expected to land after this triple, never between them. assert!(insert.contains(",501,3600,777"), "next_quest_id=501, limit_time=3600, reward_money_max_level=777 must thread through, in order, as END-appended columns: {insert}"); } @@ -10292,9 +8857,6 @@ mod tests { ); } - /// `qt::TYPE` (column 6) reads into `QuestTemplate.quest_type`, the last END-appended field, and - /// a type-62 (`QUEST_TYPE_RAID`) fixture counts toward the coverage line's raid-quest total. A - /// normal quest (type 0, the default) does not. #[test] fn quest_type_column_reads_into_quest_type_and_counts_raid_quests() { let normal_row = { @@ -10336,11 +8898,6 @@ mod tests { assert_eq!(quests.raid_quest_count, 1); } - /// The leading fields plus `RewMailTemplateId`/`RewMailDelaySecs` of ClassicDB's real quest 3645 - /// row (`Membership Card Renewal`, `cd0c426a…` pin). The pinned dump's own `quest_template` row - /// carries 130 columns; keeping 102/103 literal at that real width catches a column constant - /// that drifts off its real position, the same guard `classicdb_quest_783_row` gives - /// NextQuestInChain. fn classicdb_quest_3645_row() -> String { let mut cols = vec!["0".to_string(); 130]; for (index, value) in [ @@ -10400,7 +8957,6 @@ mod tests { "full run is missing a {family} statement: {s}" ); } - // Every family that ran got exactly one stamp, matching its own solo run's stamp count. assert_eq!( plan.stamps.len(), 1, @@ -10409,7 +8965,6 @@ mod tests { ); assert_eq!(plan.stamps[0].0, family); } - // The full run stamps every family exactly once, in FAMILIES order. let stamped_families: Vec<&str> = full_plan.stamps.iter().map(|(f, _)| *f).collect(); assert_eq!( stamped_families, @@ -10418,7 +8973,6 @@ mod tests { full_plan.stamps ); - // Determinism: re-running the full plan over the identical dump/args is byte-for-byte identical. let full_plan_again = build_dump_plan(&dump, &full_args, &None, &None).expect("full run again"); assert_eq!( @@ -10427,15 +8981,6 @@ mod tests { ); } - // --- bad input / missing files / partial datasets --------------------------------------- - // The importer's whole job is to eat an operator-supplied file we have never seen. Every test - // below pins what happens when that file is WRONG — missing, truncated, corrupt, or half a - // schema — because the failure mode we cannot afford is a silent one: an import that "succeeds" - // with garbage rows poisons the live world tables, and a panic mid-run leaves the clear+reload - // half-applied (the DELETE ran, the INSERT never did). - - /// A unique scratch file per test (std-only; no `tempfile` dep — mirrors `pack_client.rs`'s - /// `Scratch`). Removed on drop so a FAILING assert still cleans up after itself. struct ScratchFile(std::path::PathBuf); impl ScratchFile { fn new(tag: &str, bytes: &[u8]) -> Self { @@ -10462,11 +9007,6 @@ mod tests { #[test] fn read_dump_on_a_missing_path_errors_and_names_the_path() { - // A mistyped `--dump` is the single most common operator error, and the run that follows is - // destructive (clear+reload). The error MUST name the path the operator actually passed — - // a bare "No such file or directory" leaves them guessing which of --dump/--dbc/--terrain - // was wrong. Both the plain and the.gz branch route through the same `read` context, so - // both are pinned: read_dump reads the bytes BEFORE it looks at the extension. for path in [ "/nonexistent/lyracore/importer/path.sql", "/nonexistent/lyracore/importer/path.sql.gz", @@ -10482,9 +9022,6 @@ mod tests { #[test] fn read_dump_on_a_gz_path_holding_non_gzip_bytes_errors_at_the_gunzip_step() { - // A half-downloaded / plain-text-renamed-to-.gz dump must fail LOUDLY at decompression, not - // panic and not decode to an empty string (an empty dump would make every builder emit its - // DELETEs with no INSERTs — i.e. it would WIPE the live tables and reload nothing). let f = ScratchFile::new( "not-really-gzip.sql.gz", b"INSERT INTO `spell_chain` VALUES (1);", @@ -10499,11 +9036,6 @@ mod tests { #[test] fn read_dump_decodes_invalid_utf8_lossily_instead_of_failing() { - // DELIBERATE design decision, pinned so nobody "fixes" it into `read_to_string`: real - // cmangos dumps are latin1/utf8-ish and carry stray non-UTF8 bytes inside creature/item - // names. A strict decode would abort the WHOLE import over one bad byte in one name (and - // `read_to_string` cannot even be used on the.gz path). We decode lossily on both paths: - // the offending byte becomes U+FFFD and every valid row around it still imports. let f = ScratchFile::new("latin1.sql", b"INSERT INTO `t` VALUES (1,'Caf\xE9 Ale');"); let text = read_dump(f.path()).expect("a stray non-UTF8 byte must not fail the import"); assert!( @@ -10514,7 +9046,6 @@ mod tests { text.contains('\u{FFFD}'), "the bad byte must become the replacement char, not be dropped: {text:?}" ); - // And the lossily-decoded text is still parseable — the row is not lost. let rows = parse_table(&text, "t"); assert_eq!( rows.len(), @@ -10526,9 +9057,6 @@ mod tests { #[test] fn parse_table_returns_no_rows_for_a_table_the_dump_never_mentions() { - // A --family run asks for tables a partial dump may simply not contain (an operator can - // dump `creature` without `spell_chain`). That must be an EMPTY result, never a panic: - // every builder is expected to degrade to "DELETE, no INSERT" for a table it cannot find. let dump = "x INSERT INTO `creature_template` VALUES (1,'A',NULL,5); y"; assert!( parse_table(dump, "spell_chain").is_empty(), @@ -10540,12 +9068,6 @@ mod tests { #[test] fn parse_table_name_match_is_exact_not_a_prefix_of_a_longer_table() { - // `creature` is a PREFIX of `creature_template`, and cmangos ships both. The needle carries - // the closing backtick (``INSERT INTO `creature` VALUES ``), so the match is exact in BOTH - // directions — asking for the short name never harvests the long table's rows (which would - // import creature_template columns as spawn rows: total garbage), and asking for the long - // name never matches the short table. Pinned because the guard is a single backtick in a - // format! string, trivially lost in a refactor. let templates = "x INSERT INTO `creature_template` VALUES (1,'A',NULL,5); y"; assert!( parse_table(templates, "creature").is_empty(), @@ -10563,9 +9085,6 @@ mod tests { #[test] fn parse_table_keeps_complete_tuples_and_drops_an_unterminated_one() { - // A truncated dump (interrupted download / disk full) ends mid-statement. The good tuples - // ALREADY closed by a `)` are kept; the half-written tail is dropped, because a row is only - // committed on its closing paren. No panic, no partial row with missing columns. let truncated = "x INSERT INTO `spell_chain` VALUES (133,0,133,1,0),(143,133,133"; let rows = parse_table(truncated, "spell_chain"); assert_eq!( @@ -10574,19 +9093,12 @@ mod tests { "the closed tuple survives, the truncated tail is dropped: {rows:?}" ); assert_eq!(field(&rows[0], 0), "133"); - // A missing terminating `;` alone (both tuples closed) loses nothing. let no_semicolon = "x INSERT INTO `spell_chain` VALUES (133,0,133,1,0),(143,133,133,2,0)"; assert_eq!(parse_table(no_semicolon, "spell_chain").len(), 2); } #[test] fn parse_table_unterminated_quote_swallows_the_rest_of_the_dump() { - // HONEST PIN of the REAL behavior, which is NOT per-row skip: an unclosed `'` puts the - // parser in quote mode until the next `'` ANYWHERE downstream, so it eats the remainder of - // the file — including later, perfectly well-formed statements for the same table. Rows - // BEFORE the corruption still import. The consequence to know: a single unbalanced quote - // silently TRUNCATES an import rather than dropping one row, so a suspiciously low row - // count in the importer's own `eprintln!` totals is the symptom to look for. let dump = "x INSERT INTO `creature_template` VALUES (1,'Good'),(2,'oops); \ INSERT INTO `creature_template` VALUES (3,'Later'); y"; let rows = parse_table(dump, "creature_template"); @@ -10600,10 +9112,6 @@ mod tests { #[test] fn build_stats_sql_drops_rows_whose_key_columns_are_missing_or_non_numeric() { - // Partial-dataset policy (`filter_map(...parse().ok()?)`): a row we cannot key is DROPPED, - // the import does NOT abort. Losing one stat row costs one class/level's base HP; aborting - // costs the operator the whole run. The KEY columns only are validated, so this test also - // documents the sharp edge below. let dump = "x INSERT INTO `player_classlevelstats` VALUES (1,1,20,0),\ ('warrior',2,29,0),(1,'two',29,0); \ INSERT INTO `player_levelstats` VALUES (1,1,1,23,20,22,20,21),(1,1,'x',1,1,1,1,1); y"; @@ -10634,12 +9142,6 @@ mod tests { #[test] fn build_stats_sql_emits_empty_tokens_for_a_row_short_of_its_value_columns() { - // SHARP EDGE, pinned honestly rather than wished away: only the KEY columns (class/level) - // are `parse()`d — `base_health`/`base_mana` are interpolated verbatim. A row truncated to - // its keys therefore emits `(...,,)`, which `spacetime sql` rejects at APPLY time (loud) — - // it does not silently insert zeroes. If this ever needs hardening, the fix is to parse the - // value columns too; until then this test documents that the guard is apply-time, not - // parse-time, so nobody assumes short rows are already filtered. let dump = "x INSERT INTO `player_classlevelstats` VALUES (1,1); y"; let stmts = build_stats_sql(dump); let cls = stmts @@ -10654,8 +9156,6 @@ mod tests { #[test] fn build_stats_sql_empty_dump_yields_deletes_only_no_insert() { - // An empty/absent stats section must still WIPE (clear+reload semantics) and emit no - // INSERT — `push_insert` must not produce a syntactically-broken `VALUES` with no tuples. let stmts = build_stats_sql(""); assert_eq!( stmts, @@ -10669,11 +9169,6 @@ mod tests { #[test] fn build_graveyard_zone_sql_drops_unparseable_rows_and_leaves_a_gap_in_row_id() { - // Bad rows drop (a graveyard link we cannot key would resolve corpses to the wrong zone). - // HONEST PIN: `enumerate()` runs BEFORE `filter_map`, so row_id is the PRE-filter index — - // a dropped row leaves a HOLE in the sequence (1,3 here, not 1,2). That is harmless (row_id - // is an opaque surrogate key and the DELETE is wholesale), but it means row_id is NOT a - // count of imported rows and must never be used as one. let dump = "x INSERT INTO `game_graveyard_zone` VALUES (105,12,469),(106,'zone',469),(80,40,469); y"; let stmts = build_graveyard_zone_sql(dump); let insert = stmts @@ -10704,10 +9199,6 @@ mod tests { #[test] fn build_spell_chain_sql_drops_rows_with_a_short_or_out_of_range_column() { - // Every one of the five columns is parsed here, so ANY malformed column drops the row — - // stricter than build_stats_sql above, and deliberately so: a half-parsed rank-prereq row - // would let a player train rank 3 without rank 2. `rank` is a u8, so a >255 rank is also - // rejected rather than wrapping. let dump = "x INSERT INTO `spell_chain` VALUES (133,0,133,1,0),(143,133),\ (145,133,133,300,0),(147,133,133,2,'x'),(0,0,0,1,0); y"; let stmts = build_spell_chain_sql(dump, &[]).unwrap(); @@ -10725,10 +9216,6 @@ mod tests { #[test] fn build_createinfo_spell_sql_drops_bad_rows_and_leaves_a_gap_in_the_id() { - // A starter-kit row we cannot key would teach a spell to the wrong race/class at character - // creation — drop it. Same pre-filter `enumerate()` shape as the graveyard builder: the id - // of the surviving third row is 3, not 2. Pinned so a future "densify the ids" change is a - // deliberate one and not a surprise. let dump = "x INSERT INTO `playercreateinfo_spell` VALUES (0,1,78),(1,0),(3,1,'fireball'),(3,1,78); y"; let stmts = build_createinfo_spell_sql(dump); let insert = stmts @@ -10749,9 +9236,6 @@ mod tests { #[test] fn build_createinfo_spell_sql_empty_dump_yields_delete_only_no_insert() { - // Note the consequence this pins: with no `playercreateinfo_spell` in the dump, the import - // WIPES the table. That is the intended wholesale-reload semantics (see the builder's doc), - // but it means a --family run over a partial dump must not include this family by accident. let stmts = build_createinfo_spell_sql(""); assert_eq!( stmts, diff --git a/importer/src/nav.rs b/importer/src/nav.rs index 9be48cbd3..eef343898 100644 --- a/importer/src/nav.rs +++ b/importer/src/nav.rs @@ -1,25 +1,3 @@ -//! `--nav ` uses rasterize ADT terrain + WMO/M2 collision -//! geometry into per-cell nav blobs → `game_nav_chunk` (decision: grid tables, no -//! vmap/Detour runtime). Same licensing firewall as `--terrain`. Honors `--map` + `--box`. -//! -//! Pipeline: parse every ADT tile in the box (heights via terrain.rs's `collect_cells` — the -//! SAME code the heightmap import runs), collect WMO/M2 placements deduped by `unique_id` -//! (placements repeat on adjacent tiles), load each referenced model's collision triangles -//! once, transform to world space, bin by terrain cell, then rasterize each cell's 64×64 -//! walkability bitmask + 32×32 obstruction-height grid through `lyracore_shared::nav` (the same -//! codec the module reads with). The geometry rasterization policy itself lives in -//! `nav::derive_cell`; this file only adds the terrain-only blockers (slope, MCNK holes) and -//! merges the two through `nav::merge_cells`. -//! -//! The placement ROTATION convention is folkloric (like the ADT filename axis order), so it is -//! CALIBRATED, not trusted: MODF carries each WMO placement's world AABB; we try the candidate -//! (axis-shuffle × yaw) transforms and keep the one that reproduces those AABBs, bailing loudly -//! if none does. MDDF (M2) reuses the WMO-calibrated convention — it has no bounds to check. -// Deliberate simplification: yaw-only rotation (placements with real pitch/roll rasterize -// slightly wrong — rare fence-on-slope doodads); tri footprint = tri AABB (overmarks diagonal -// walls by <1 cell, conservative direction). Move to exact tri-box + full Euler when a live case -// demands it. - use anyhow::{bail, Context, Result}; use lyracore_shared::nav::{ derive_cell, merge_cells, sub_index, walk_set, NavCellData, OBS_BYTES, OBS_NONE, WALK_BYTES, @@ -32,30 +10,20 @@ use std::io::Cursor; use std::path::Path; use wow_mpq::PatchChain; -/// 32 tiles × 533.33 yd — the ADT placement-space origin offset. +// ADT placement origin offset in yards. const PLACE_K: f32 = 32.0 * 533.333_3; -/// Slope gate: a 4.17 yd sub-quad rising more than tan(50°)×4.17 ≈ 4.97 yd is unwalkable. +// A 50-degree slope rises about 4.97 yards over one 4.17-yard terrain quad. const MAX_QUAD_RISE: f32 = 4.97; -/// Reducer batch ceiling (bytes of packed payload) — same ballpark the other importers respect. const BATCH_BYTES: usize = 28_000; pub(crate) type Tri = [[f32; 3]; 3]; -// --------------------------------------------------------------------------------------------- -// Geometry extraction -// --------------------------------------------------------------------------------------------- - -/// One WMO collision triangle plus the group it came from, `vmap.rs` packs this -/// per-triangle metadata (source class + group id + MOGP flags); `nav.rs`'s rasterizer only -/// needs `.tri`. pub(crate) struct WmoTri { pub(crate) tri: Tri, pub(crate) group_id: u32, pub(crate) mogp_flags: u32, } -/// One placement's loaded collision mesh — WMO carries per-triangle group metadata, M2 doesn't -/// (a doodad has no groups/MOGP flags). pub(crate) enum Mesh { Wmo(Vec), M2(Vec), @@ -70,9 +38,7 @@ impl Mesh { } } -/// Collidable triangles of one WMO (all groups), model-local coords, tagged with group id + MOGP -/// flags. MOPY rule (wowdev): collidable = F_COLLISION (0x08) set, OR neither F_DETAIL (0x04) nor -/// F_NOCAMCOLLIDE (0x02). +// MOPY collision: 0x08 set, or neither 0x04 nor 0x02 set. fn wmo_tris(chain: &mut PatchChain, name: &str) -> Result> { let root_bytes = chain .read_file(name) @@ -119,8 +85,6 @@ fn wmo_tris(chain: &mut PatchChain, name: &str) -> Result> { Ok(tris) } -/// M2 collision mesh in model-local coordinates. Missing or malformed collision is an import -/// failure, since publishing an empty mesh would mark missing obstacles as clear ground. pub(crate) fn m2_tris(chain: &mut PatchChain, name: &str) -> Result> { let m2_name = name .rsplit_once('.') @@ -133,24 +97,16 @@ pub(crate) fn m2_tris(chain: &mut PatchChain, name: &str) -> Result> { .with_context(|| format!("reading collision from M2 {m2_name}")) } -// --------------------------------------------------------------------------------------------- -// Placement transform (calibrated, not trusted) -// --------------------------------------------------------------------------------------------- - -/// Placement-space position → world: (K − p.z, K − p.x, p.y). Verified empirically in the 240 -/// spike (nsabbey placement (17245, 80, 25964) → world (−8897, −178, 80), matching the live -/// abbey coords). +// Placement coordinates map to world as K - z, K - x, y. pub(crate) fn place_pos(p: [f32; 3]) -> [f32; 3] { [PLACE_K - p[2], PLACE_K - p[0], p[1]] } -/// One candidate local→world convention: optional axis shuffle then yaw about world +Z. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Convention { - /// false: local verts are already world-axis Z-up (v as-is). - /// true: local verts are placement-axis Y-up (shuffle like `place_pos`: (−z, −x, y)). + // true converts Y-up local axes to Z-up world axes as -z, -x, y. pub(crate) shuffle: bool, - /// world yaw = `sign` × rot[1] + `offset_deg`, in degrees. + // World yaw in degrees is sign * rotation[1] + offset_deg. pub(crate) sign: f32, pub(crate) offset_deg: f32, } @@ -207,9 +163,6 @@ fn mohd_calibration_points(bounds_min: [f32; 3], bounds_max: [f32; 3]) -> Result Ok(points) } -/// MOHD and MODF are the authored local and placed bounds of the same WMO. Comparing their -/// corners checks the placement transform without depending on how tightly MOVT vertices fill -/// the authored box. fn wmo_calibration_points(chain: &mut PatchChain, name: &str) -> Result> { let root_bytes = chain .read_file(name) @@ -223,9 +176,6 @@ fn wmo_calibration_points(chain: &mut PatchChain, name: &str) -> Result)]) -> Result { let (Some(bmin), Some(bmax)) = (p.bounds_min, p.bounds_max) else { continue; }; - // Bounds are placement-space: convert both corners, re-sort per axis. let (c1, c2) = (place_pos(bmin), place_pos(bmax)); let want_lo = [c1[0].min(c2[0]), c1[1].min(c2[1]), c1[2].min(c2[2])]; let want_hi = [c1[0].max(c2[0]), c1[1].max(c2[1]), c1[2].max(c2[2])]; @@ -270,8 +219,6 @@ fn calibration(samples: &[(&Placement, Vec<[f32; 3]>)]) -> Result { if errs.is_empty() { continue; } - // A low quantile keeps an isolated archive outlier from overriding the transform shared - // by the retained sample. The refusal below still rejects a sample with no fit. errs.sort_by(f32::total_cmp); let mean = errs[errs.len() / 4]; if std::env::var("NAV_DEBUG").is_ok() { @@ -322,10 +269,6 @@ fn calibration(samples: &[(&Placement, Vec<[f32; 3]>)]) -> Result { }) } -// --------------------------------------------------------------------------------------------- -// Tile collection -// --------------------------------------------------------------------------------------------- - #[derive(Clone)] pub(crate) struct Placement { pub(crate) name: String, @@ -373,10 +316,6 @@ fn resolve<'a>(names: &'a [String], indices: &[u32], name_id: u32) -> Result<&'a Ok(&names[idx]) } -// --------------------------------------------------------------------------------------------- -// Rasterizer -// --------------------------------------------------------------------------------------------- - struct NavRow { map_id: u32, cell_x: u16, @@ -406,18 +345,15 @@ impl NavRow { } } -// MCNK four-bit rows follow world X, matching the first heightmap index. +// MCNK hole rows follow world X, matching the first heightmap index. fn has_hole(holes: u32, quad_x: usize, quad_y: usize) -> bool { holes & (1 << ((quad_x / 2) * 4 + quad_y / 2)) != 0 } -/// The terrain half of a cell: slope and MCNK holes, the only blockers the height grid alone -/// knows. None when the terrain is clear everywhere in the cell. fn terrain_cell(cell: &crate::terrain::CellRow) -> Option { let mut walk = vec![0xFFu8; WALK_BYTES]; let mut dirty = false; - // Slope: per 4.17 yd sub-quad from the 9×9 outer corners (index i*17+j). for qi in 0..8 { for qj in 0..8 { let corner = |i: usize, j: usize| cell.heights[i * 17 + j]; @@ -446,10 +382,6 @@ fn terrain_cell(cell: &crate::terrain::CellRow) -> Option { }) } -/// Rasterize one terrain cell against the triangles binned to it: the terrain blockers merged -/// with the shared collision-geometry derivation (`nav::derive_cell` — the module derives its -/// coverage through the same function, so the two can never drift). Returns None when the cell -/// is fully clear (all-walkable, no obstruction) — those emit NO row by design. fn rasterize_cell(cell: &crate::terrain::CellRow, tris: &[VmapTri]) -> Option { let (cx, cy) = (cell.cell_x, cell.cell_y); let data = match ( @@ -471,12 +403,6 @@ fn rasterize_cell(cell: &crate::terrain::CellRow, tris: &[VmapTri]) -> Option, pub(crate) placements: Vec, @@ -519,8 +445,6 @@ fn placements_match(first: &Placement, second: &Placement) -> bool { && first.wmo == second.wmo } -/// Pass 1: parse every ADT tile in the cell-index box — heights (via the shared `collect_cells`) -/// plus WMO/M2 placements deduped by `unique_id` (placements repeat on adjacent tiles). pub(crate) fn scan_tiles( chain: &mut PatchChain, map_name: &str, @@ -556,7 +480,7 @@ pub(crate) fn scan_tiles( let Ok(bytes) = chain.read_file(name) else { continue; }; - // Patch archives remove terrain tiles with empty ADT entries. + // Empty ADT patch entries remove terrain tiles. if bytes.is_empty() { continue; } @@ -572,7 +496,7 @@ pub(crate) fn scan_tiles( } let Some((source_path, source_bytes, root)) = accepted else { continue; - }; // ocean/empty tiles simply don't exist + }; tiles_read += 1; sources.push(TileSource { path: source_path.clone(), @@ -639,8 +563,6 @@ pub(crate) fn placement_overlaps_cells(placement: &Placement, range: (i32, i32, let (Some(min), Some(max)) = (placement.bounds_min, placement.bounds_max) else { return true; }; - // Only known bounds can exclude a model. Preserve malformed bounds for the existing loader - // and calibration checks, and retain models whose bounds need their mesh to be determined. if !(0..3).all(|axis| min[axis].is_finite() && max[axis].is_finite() && min[axis] <= max[axis]) { return true; @@ -658,8 +580,6 @@ pub(crate) fn placement_overlaps_cells(placement: &Placement, range: (i32, i32, }) } -/// Pass 2: load each referenced model's collision mesh once (WMO keeps per-triangle group -/// metadata; M2 doesn't have groups). pub(crate) fn load_meshes( chain: &mut PatchChain, placements: &[Placement], @@ -678,8 +598,6 @@ pub(crate) fn load_meshes( Ok(meshes) } -/// Load one collision mesh for the streaming vmap importer. Keeping this small interface avoids -/// retaining every unique model of a production-sized selection at once. pub(crate) fn load_mesh(chain: &mut PatchChain, placement: &Placement) -> Result { if placement.is_wmo { Ok(Mesh::Wmo(wmo_tris(chain, &placement.name)?)) @@ -688,7 +606,6 @@ pub(crate) fn load_mesh(chain: &mut PatchChain, placement: &Placement) -> Result } } -/// Pass 3: calibrate the rotation convention against MODF bounds (WMOs only), capped sample. pub(crate) fn calibrate_from_placements( chain: &mut PatchChain, placements: &[Placement], @@ -713,10 +630,6 @@ pub(crate) fn calibrate_from_placements_with_evidence( calibration(&samples) } -// --------------------------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------------------------- - pub(crate) fn run(args: &crate::Args) -> Result<()> { let scope = args.world_import_scope()?; if scope.bounded_slices.is_empty() { @@ -783,7 +696,6 @@ fn rasterize_slice( } } - // Pass 1: parse tiles — heights + deduped placements. let scan = scan_tiles( chain, map_name, @@ -802,13 +714,11 @@ fn rasterize_slice( placements.iter().filter(|p| p.is_wmo).count() ); - // Calibration uses tile-wide authored bounds, including when the selected cells have no WMO. let conv = calibrate_from_placements(chain, &placements)?; placements.retain(|placement| { placement_overlaps_cells(placement, (cell_x_min, cell_x_max, cell_y_min, cell_y_max)) }); - // Pass 2: load each intersecting model's collision mesh once. let meshes = load_meshes(chain, &placements)?; let mesh_tris: usize = meshes.values().map(Mesh::len).sum(); println!( @@ -816,7 +726,6 @@ fn rasterize_slice( meshes.len() ); - // Pass 4: transform to world space + bin by terrain cell. let mut world_tris: Vec = Vec::new(); for p in &placements { let pos_w = place_pos(p.position); @@ -843,9 +752,7 @@ fn rasterize_slice( } let mut by_cell: HashMap> = HashMap::new(); for (i, t) in world_tris.iter().enumerate() { - // High world coord → LOW cell index; iterate the covered index rectangle. Inflate by - // `WALK_MARGIN` so a triangle hugging a cell border reaches the neighbor cell its inset - // walk footprint spills into. + // Include WALK_MARGIN so inset geometry reaches neighboring terrain cells. let (lo, hi) = aabb(t.verts.iter().copied()); let (Some(cx0), Some(cx1), Some(cy0), Some(cy1)) = ( cell_index(hi[0] + WALK_MARGIN), @@ -853,7 +760,7 @@ fn rasterize_slice( cell_index(hi[1] + WALK_MARGIN), cell_index(lo[1] - WALK_MARGIN), ) else { - continue; // off the map square (shouldn't happen inside the box) + continue; }; for cx in cx0..=cx1 { for cy in cy0..=cy1 { @@ -862,7 +769,6 @@ fn rasterize_slice( } } - // Pass 5: rasterize. let mut rows: Vec = Vec::new(); for cell in &cells { let empty = Vec::new(); @@ -881,8 +787,6 @@ fn rasterize_slice( cells.len() - rows.len() ); - // NAV_ASCII="x0,x1,y0,y1": print an ASCII walkability map of that world rectangle from the - // in-memory rows (dry-run diagnostic; '#' blocked, '.' walkable, ' ' no row/all-clear). if let Ok(spec) = std::env::var("NAV_ASCII") { let v: Vec = spec .split(',') @@ -899,7 +803,7 @@ fn rasterize_slice( let mut y = y1; while y >= y0 { let mut line = String::new(); - let mut x = x1; // world x decreases left→right? print x1 (high) at LEFT for map-like reading + let mut x = x1; while x >= x0 { let ch = match (cell_index(x), cell_index(y)) { (Some(cx), Some(cy)) => { @@ -930,8 +834,6 @@ fn rasterize_slice( } } - // Self-check: --center must be walkable (the operator points it at a road/spawn, same - // contract as the terrain interpolate check). Fails loudly on axis/transform breakage. { let (ccx, ccy) = (slice.sample.0 as f32, slice.sample.1 as f32); let (cx, cy) = ( @@ -939,7 +841,7 @@ fn rasterize_slice( cell_index(ccy).context("--center off map")?, ); let walkable = match rows.iter().find(|r| r.cell_x == cx && r.cell_y == cy) { - None => true, // no row = all clear + None => true, Some(r) => { let (nx, ny) = ( sub_index(ccx, cx, WALK_DIM).context("--center sub-index")?, @@ -1040,7 +942,6 @@ mod tests { } } - /// A flat 80.0-height cell at the same Northshire-ish coords the shared runtime tests use. fn flat_cell() -> crate::terrain::CellRow { crate::terrain::CellRow { map_id: 0, @@ -1090,8 +991,6 @@ mod tests { #[test] fn calibration_uses_authored_bounds_when_movt_does_not_fill_their_corners() { - // Map 36's retained Deadmines WMO placement, MOHD, and MODF values. MODF matches the - // transformed authored box while the actual transformed MOVT cloud is 2.36 yd tighter. let expected = Convention { shuffle: false, sign: 1.0, @@ -1197,25 +1096,17 @@ mod tests { #[test] fn walk_grid_insets_by_the_agent_radius_but_obs_does_not() { - // A thin vertical WMO wall down the x = walk-nx-32 line must - // block one extra sub-cell on EACH side (the body inset — the corner-clip fix), while - // the obs/LoS grid keeps the true footprint (sight is a line, not a body). let cell = flat_cell(); let (cx, cy) = (cell.cell_x, cell.cell_y); let wall_x = sub_center(cx, 32, WALK_DIM); let (y0, y1) = (sub_center(cy, 60, WALK_DIM), sub_center(cy, 3, WALK_DIM)); let wall = wmo_tri([[wall_x, y0, 79.0], [wall_x, y1, 79.0], [wall_x, y0, 92.0]]); let row = rasterize_cell(&cell, &[wall]).expect("a wall dirties the cell"); - // On the wall line: blocked. assert!(!walk_get(&row.walk, 32, 32)); - // One sub-cell out (0.52 yd — inside `WALK_MARGIN`): blocked ONLY by the agent-radius - // inset; the bare 0.35 yd `RASTER_MARGIN` left it walkable and the body clipped corners. assert!(!walk_get(&row.walk, 31, 32)); assert!(!walk_get(&row.walk, 33, 32)); - // Two sub-cells out (1.04 yd — outside the margin): walkable, the inset is bounded. assert!(walk_get(&row.walk, 30, 32)); assert!(walk_get(&row.walk, 34, 32)); - // Obs: only the wall's own column (ox=16) rises — no body inset on the sight grid. assert!(obs_top(&row.obs, row.base_z, 16, 16).is_some()); assert!(obs_top(&row.obs, row.base_z, 15, 16).is_none()); assert!(obs_top(&row.obs, row.base_z, 17, 16).is_none()); @@ -1224,7 +1115,6 @@ mod tests { #[test] fn an_asymmetric_terrain_hole_uses_world_coordinate_axes() { let mut cell = flat_cell(); - // Raw MCNK row one, column zero covers world-X subcells 16..32 and Y 0..16. cell.holes = 0x0010; let row = rasterize_cell(&cell, &[]).unwrap(); assert!(!walk_get(&row.walk, 24, 8)); @@ -1233,10 +1123,8 @@ mod tests { #[test] fn a_row_carries_both_the_terrain_blockers_and_the_geometry_ones() { - // Holes are the importer's own blocker; the wall comes from the shared derivation. One - // row must carry both, and a hole alone must still emit a row. let mut cell = flat_cell(); - cell.holes = 1; // quadrant (0, 0) → walk sub-cells 0..16 on both axes + cell.holes = 1; assert!( rasterize_cell(&cell, &[]).is_some(), "a hole dirties the cell" diff --git a/importer/src/pack_client.rs b/importer/src/pack_client.rs index 238e9620b..5f6ed8883 100644 --- a/importer/src/pack_client.rs +++ b/importer/src/pack_client.rs @@ -1,39 +1,3 @@ -//! `--pack-client` — the dev-time CLIENT PATCH packager. Builds a `patch-3.MPQ` (DBC overlays + our -//! own raw assets) into the operator's OWN client `Data/`, installs our addons into `Interface/AddOns/`, -//! and clears the `WDB/` cache — so a lyracore developer can push client-side changes to their -//! local client in ONE command. (Player distribution is a separate, out-of-scope problem.) -//! -//! SOURCES: the repo-global `client-patch/{mpq,addons}` tree PLUS every package's optional -//! `packages//client/{mpq,addons}` — a feature ships its client UI next to its server code -//! (the packages doctrine), and a client-only package (a `client/` with no `src/`) is legal. Both -//! channels are first-class: addons are additive and coexist; an `mpq/` file at -//! `Interface\FrameXML\...` is a whole-file OVERRIDE of the stock UI (reach for it only when -//! addon hooks can't — packages/ is private-only tooling, not part of the public -//! mirror). Cross-source collisions (same archive path or -//! same addon name from two sources) fail the pack loudly naming both sources — never -//! last-write-wins. -//! -//! FIREWALL (see `dbc.rs`): the repo-committed inputs under `client-patch/` are OURS only (our addons, -//! our raw assets like BLP icons, and overlay code) — NEVER Blizzard files (a `client-patch/.gitignore` -//! blocks `*.dbc`/`*.MPQ`). DBCs are read from the operator's base archives IN MEMORY, our rows applied, -//! and re-serialized into the patch MPQ; the emitted `patch-3.MPQ` + installed addons are local build -//! artifacts in the operator's own client, never committed and never shipped by us. -//! -//! Load order on this client: base archives < `patch.MPQ` < `patch-2.MPQ` < `patch-3.MPQ` (higher wins), -//! so our overlay shadows the base. Patch files must be UNENCRYPTED. After a DBC change the `WDB/` cache -//! must be cleared or the client serves stale name/tooltip/icon data. -//! -//! UI TRANSFORMS: a source may also ship a `ui-transforms.json` beside its `mpq/` and `addons/` -//! trees. Each entry anchors an insertion into one stock FrameXML or GlueXML file. The packer reads -//! that file's baseline out of the operator's own UI archives, composes every Package's edits for -//! it, and packs the result — so two Packages can extend one stock file without either owning it. -//! A composed file is [`Origin::BaselineDerived`]: it is the client's own bytes with our edits in -//! it, and it never leaves this machine. -//! -//! `--pack-out ` is the second output: the same collected sources written into a plain -//! directory, for distribution. It never opens a client, so it refuses every baseline-derived file -//! by its [`Origin`] and names where those bytes came from. - use std::collections::BTreeMap; use std::fs; use std::io::Cursor; @@ -47,36 +11,19 @@ use wow_mpq::{compression::flags, ArchiveBuilder, FormatVersion, PatchChain}; use crate::ui_transform::{self, Edit}; use crate::Args; -/// First free numeric patch slot on this client (base < patch < patch-2 < patch-3); higher wins. const PATCH_MPQ: &str = "patch-3.MPQ"; -/// Source-controlled inputs (ours only) — see the firewall note above. const SRC: &str = "client-patch"; -/// Dropped inside an installed addon's own folder, naming the source that put it there -/// (`client-patch` or `package `). This is the ONLY provenance this packer keeps — one file -/// per addon, gone if the addon folder goes — never a central inventory of every file it has ever -/// written. It exists solely to power [`stale_addons`]'s warning; nothing here reads it to decide -/// what to overwrite, and nothing here deletes on its account. const SOURCE_MARKER: &str = ".lyracore-source"; -/// A source's UI Transform declaration, read from the contribution root itself (beside `mpq/` and -/// `addons/`) rather than from inside either, so it is never mistaken for content to pack. const UI_TRANSFORMS: &str = "ui-transforms.json"; -/// Where a packed file's bytes came from. `source` is the human label; this is the provenance the -/// licensing firewall reads. Only package-authored bytes may leave this machine. #[derive(Debug, PartialEq, Eq)] enum Origin { - /// Bytes an author committed under `client-patch/` or `packages//client/`. PackageAuthored, - /// Bytes computed from the operator's own client: a DBC overlay or a UI Transform output. - /// `from` names the baseline input, for the refusal message. BaselineDerived { from: String }, } -/// One file destined for the patch MPQ: the internal archive path (backslash-separated) + its -/// bytes + which source contributed it (for dry-run provenance and collision messages) + the -/// provenance that decides whether it may be distributed. struct PackFile { archive_path: String, data: Vec, @@ -84,16 +31,12 @@ struct PackFile { origin: Origin, } -/// One addon directory to install: its name (the `Interface/AddOns/` target) + where it -/// lives + which source contributed it. struct AddonDir { name: String, path: std::path::PathBuf, source: String, } -/// The client-content contribution roots, in deterministic order: `client-patch/` first, then -/// every `packages//client/` alphabetically. Each yields raw MPQ files and/or addon dirs. fn client_sources( repo_src: &Path, packages_root: &Path, @@ -113,28 +56,20 @@ fn client_sources( Ok(sources) } -/// Collect every source's `mpq/` files and `addons/` dirs, failing LOUDLY on cross-source -/// collisions (MPQ paths compared case-insensitively — the archive is; addon names likewise — -/// the target filesystem may be) and on firewall violations (a committed `.dbc`/`.mpq` is never -/// ours to ship raw; DBC overlays go through the in-memory stage). -/// -/// Every file this yields is [`Origin::PackageAuthored`]; the roots hold committed bytes only. fn collect_client_content( sources: &[(String, std::path::PathBuf)], ) -> Result<(Vec, Vec)> { use std::collections::HashMap; let mut files: Vec = Vec::new(); let mut addons: Vec = Vec::new(); - let mut seen_paths: HashMap = HashMap::new(); // lower(archive path) → source - let mut seen_addons: HashMap = HashMap::new(); // lower(addon name) → source + let mut seen_paths: HashMap = HashMap::new(); + let mut seen_addons: HashMap = HashMap::new(); for (label, root) in sources { let mpq_root = root.join("mpq"); if mpq_root.is_dir() { let mut raw = Vec::new(); collect_raw(&mpq_root, &mpq_root, &mut raw)?; - // fs::read_dir order is filesystem-dependent — sort so the dry-run listing and the - // packed MPQ layout are stable across machines/runs (diff-able). raw.sort_by(|a, b| a.archive_path.cmp(&b.archive_path)); for mut f in raw { let lower = f.archive_path.to_ascii_lowercase(); @@ -180,9 +115,6 @@ fn collect_client_content( Ok((files, addons)) } -/// Parse every source's [`UI_TRANSFORMS`] declaration. Most sources have none; that is normal, and -/// a source with one still contributes its `mpq/` and `addons/` trees as usual. The source label -/// (`client-patch` or `package `) names the owner in every refusal the engine raises. fn collect_ui_transforms(sources: &[(String, PathBuf)]) -> Result> { let mut edits = Vec::new(); for (label, root) in sources { @@ -197,8 +129,6 @@ fn collect_ui_transforms(sources: &[(String, PathBuf)]) -> Result> { Ok(edits) } -/// Everything the repo contributes, collected once for either output. The roots are the same; only -/// what happens afterwards differs. fn collect( repo_src: &Path, packages_root: &Path, @@ -209,9 +139,6 @@ fn collect( Ok((files, addons, transforms)) } -/// Group edits by the file they change, matching paths case-insensitively because the archive -/// does. The key orders the groups, so the composed output does not depend on which Package the -/// walk reached first; the displayed path keeps its first author's casing. fn group_by_path(edits: &[Edit]) -> Vec<(&str, Vec<&Edit>)> { let mut grouped: BTreeMap)> = BTreeMap::new(); for edit in edits { @@ -224,7 +151,6 @@ fn group_by_path(edits: &[Edit]) -> Vec<(&str, Vec<&Edit>)> { grouped.into_values().collect() } -/// The sources behind one group, in source order and without repeats, for a refusal message. fn transforming_sources(edits: &[&Edit]) -> Vec { let mut labels: Vec = Vec::new(); for edit in edits { @@ -235,9 +161,7 @@ fn transforming_sources(edits: &[&Edit]) -> Vec { labels } -/// Put the record header in front of the composed file, or, when an XML document opens with an XML -/// declaration, straight after it. A declaration has to be the first thing in the document, so a -/// comment above it would make the client reject the file. +// An XML declaration must remain the first bytes of its document. fn with_record_header(header: &str, composed: &str) -> String { if composed.starts_with("") { @@ -252,9 +176,6 @@ fn with_record_header(header: &str, composed: &str) -> String { format!("{header}{composed}") } -/// An `mpq/` file replaces a stock file whole; a UI Transform edits the stock file in place. Both -/// at one path is a contradiction with no winner worth guessing at, so it fails the pack the same -/// way a cross-source collision does. fn refuse_override_of_a_transformed_path( files: &[PackFile], groups: &[(&str, Vec<&Edit>)], @@ -277,13 +198,6 @@ fn refuse_override_of_a_transformed_path( Ok(()) } -/// Compose every transformed path against the operator's own client and return the results as -/// packed files, ready to join the collision checks like any other path. -/// -/// The bytes that come back are the client's own baseline with the Packages' edits applied, so -/// each one is [`Origin::BaselineDerived`] and names the archive it was read out of. A -/// [`ui_transform::record_header`] line goes in front, carrying the baseline hash and the -/// transforms hash: the same client and the same declarations rebuild byte-identical output. fn compose_ui_transforms(data: &Path, groups: &[(&str, Vec<&Edit>)]) -> Result> { if groups.is_empty() { return Ok(Vec::new()); @@ -328,7 +242,6 @@ fn compose_ui_transforms(data: &Path, groups: &[(&str, Vec<&Edit>)]) -> Result` mode. Without `--apply` it's a dry run (prints what it would do). pub fn run(data_dir: &str, args: &Args) -> Result<()> { pack_client( Path::new(data_dir), @@ -347,26 +260,13 @@ fn pack_client(data: &Path, apply: bool, repo_src: &Path, packages_root: &Path) } let client_root = data.parent().context("client Data/ has no parent dir")?; - // 1) Raw assets (ours), from EVERY source: client-patch/mpq/ plus each packages//client/mpq/ - // → the MPQ at its relative path. This is the generic Tier-1 path — BLP icons, sounds, fonts, - // FrameXML overrides, loading screens, etc. Collisions and firewall violations bail here. let (mut files, addon_dirs, transforms) = collect(repo_src, packages_root)?; - // 1b) UI Transforms, declared by the same sources. The repo-only refusals (a bad declaration, - // an override contradicting a transform) land here, before the client is opened at all. let groups = group_by_path(&transforms); refuse_override_of_a_transformed_path(&files, &groups)?; - // 2) DBC overlays: read the operator's base DBC IN MEMORY, apply our additions, re-serialize into - // the MPQ (never a committed .dbc). [SPIKE] MVP payload: AreaTable round-trip UNCHANGED — a - // do-nothing identity stage TODAY. It proves wow_dbc::write + the MPQ packing produce a - // client-loadable DBC, and is the exact hook where custom rows go (e.g. a quest-log zone/sort - // header: `area.rows_mut().push(..)`). TODO: when a real DBC overlay lands, apply rows here; if - // none is planned, delete this stage — a patch MPQ with zero DBC overlays is valid and simpler. let mut chain = crate::dbc::open_chain(data)?; let area: AreaTable = crate::dbc::read_table(&mut chain)?; - // OVERLAY HOOK: apply our row additions/edits here before re-serializing, e.g. - // area.rows_mut().push(custom_areatable_header_row); let mut buf = Vec::new(); area.write(&mut buf).context("re-serialize AreaTable.dbc")?; files.push(PackFile { @@ -378,16 +278,10 @@ fn pack_client(data: &Path, apply: bool, repo_src: &Path, packages_root: &Path) }, }); - // 2b) UI Transforms: compose each edited stock file against this client's UI archives. The - // AreaTable read above already refused a wrong client version, so no second check is needed. files.extend(compose_ui_transforms(data, &groups)?); - // 3) Addons (ours), already collected per source: → /Interface/AddOns//. let addons_dst = client_root.join("Interface").join("AddOns"); - // Best-effort: an addon this or an earlier `--apply` installed, whose source (a Package, most - // often) is disabled or gone this run. Shown on a dry run too — it costs only a directory - // listing and an operator deciding whether to `rm -rf` it should not need `--apply` first. for warning in stale_addons(&addons_dst, &addon_dirs)? { eprintln!("pack-client: WARNING — {warning}"); } @@ -400,8 +294,6 @@ fn pack_client(data: &Path, apply: bool, repo_src: &Path, packages_root: &Path) files.len() ); for f in &files { - // The origin is on the listing because it decides where the file may go: a - // baseline-derived one reaches this client and no artifact. let derived = match &f.origin { Origin::PackageAuthored => String::new(), Origin::BaselineDerived { from } => format!(", derived from {from}"), @@ -437,7 +329,6 @@ fn pack_client(data: &Path, apply: bool, repo_src: &Path, packages_root: &Path) files.len() ); - // Install addons (every source's). for a in &addon_dirs { let to = addons_dst.join(&a.name); copy_dir(&a.path, &to) @@ -458,10 +349,6 @@ fn pack_client(data: &Path, apply: bool, repo_src: &Path, packages_root: &Path) Ok(()) } -/// `--pack-out ` mode: the client half of the Client Artifact, for distribution. It collects -/// the same sources as `--pack-client` and opens no client, so a baseline-derived file has no way -/// in and is refused by name. Writes `/Data/patch-3.MPQ` and `/Interface/AddOns//`, -/// and nothing else. Ownership of `` (replacement, the manifest, the zip) is the CLI's. pub fn run_pack_out(out_dir: &str) -> Result<()> { pack_out(Path::new(out_dir), Path::new(SRC), Path::new("packages")) } @@ -479,7 +366,6 @@ fn write_pack_out( ) -> Result<()> { refuse_baseline_derived(files, transforms)?; - // Every refusal is behind us, so nothing below can leave a half-written artifact. fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; if files.is_empty() { @@ -516,13 +402,7 @@ fn write_pack_out( Ok(()) } -/// The licensing firewall for a distributable artifact: baseline-derived bytes stay on the -/// operator's machine, where `--pack-client` puts them. Refuses before the first write and names -/// the file and the baseline input it came from. -/// -/// A UI Transform is refused from its declaration rather than from its output, because `--pack-out` -/// opens no client and so never composes one. Declaring an edit is enough: whatever it produces is -/// the client's own baseline with that edit in it. +// Refuse baseline-derived bytes before writing any distributable artifact. fn refuse_baseline_derived(files: &[PackFile], transforms: &[Edit]) -> Result<()> { for f in files { if let Origin::BaselineDerived { from } = &f.origin { @@ -549,11 +429,9 @@ fn refuse_baseline_derived(files: &[PackFile], transforms: &[Edit]) -> Result<() Ok(()) } -/// Recursively collect files under `dir` (rooted at `root`) as `PackFile`s with backslash archive paths. fn collect_raw(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let path = entry?.path(); - // Skip hidden files (.gitkeep, .gitignore, editor dotfiles) — never real assets. if path .file_name() .is_some_and(|n| n.to_string_lossy().starts_with('.')) @@ -593,19 +471,10 @@ fn copy_dir(from: &Path, to: &Path) -> Result<()> { Ok(()) } -/// Stamp an installed addon directory with the source that put it there — see [`SOURCE_MARKER`]. fn write_source_marker(addon_dir: &Path, source: &str) -> Result<()> { fs::write(addon_dir.join(SOURCE_MARKER), source).context("write addon source marker") } -/// Best-effort, no-ledger staleness check: which currently-installed addon directories carry a -/// [`SOURCE_MARKER`] this run's `current` sources no longer produce. -/// -/// The only file read is the marker inside each candidate addon's own folder, so this can never -/// flag an addon nobody's `--apply` ever wrote to — an addon the operator installed by hand, or -/// one from before this marker existed, is silently left alone rather than guessed at. Matches -/// addon names case-insensitively, the same as the collision checks above (the destination -/// filesystem may not distinguish case even where the marker's own text does). fn stale_addons(addons_dst: &Path, current: &[AddonDir]) -> Result> { if !addons_dst.is_dir() { return Ok(Vec::new()); @@ -639,7 +508,6 @@ fn stale_addons(addons_dst: &Path, current: &[AddonDir]) -> Result> Ok(stale) } -/// Delete the `*.wdb` cache files (stale name/tooltip/icon caches mask DBC changes). Idempotent. fn clear_wdb(client_root: &Path) -> Result<()> { let wdb = client_root.join("WDB"); if !wdb.is_dir() { @@ -660,7 +528,7 @@ fn clear_wdb(client_root: &Path) -> Result<()> { Ok(()) } -/// Build the patch MPQ (vanilla V1, zlib, UNENCRYPTED — the client rejects encrypted patch files). +// Vanilla patch MPQs must be V1, zlib-compressed and unencrypted. fn build_patch_mpq(files: &[PackFile], out: &Path) -> Result<()> { let mut b = ArchiveBuilder::new() .version(FormatVersion::V1) @@ -672,8 +540,6 @@ fn build_patch_mpq(files: &[PackFile], out: &Path) -> Result<()> { .with_context(|| format!("build {}", out.display())) } -/// Self-verify for `--pack-out`: re-open the built MPQ and confirm one packed file comes back -/// byte for byte. There is no DBC in a package-authored archive, so this is the round-trip proof. fn verify_readback(mpq: &Path, file: &PackFile) -> Result<()> { let mut chain = PatchChain::new(); chain.add_archive(mpq, 0).context("reopen built MPQ")?; @@ -690,9 +556,6 @@ fn verify_readback(mpq: &Path, file: &PackFile) -> Result<()> { Ok(()) } -/// Self-verify: re-open the built patch MPQ and confirm a packed DBC reads back + re-parses (proves the -/// wow-mpq write + wow_dbc round-trip without needing the client). The live-client load is the operator's -/// final check. fn verify_area_table(mpq: &Path) -> Result<()> { let mut chain = PatchChain::new(); chain @@ -711,7 +574,6 @@ mod tests { use super::*; use std::path::PathBuf; - /// A unique scratch tree per test (std-only; no tempfile dep). Cleaned on drop. struct Scratch(PathBuf); impl Scratch { fn new(tag: &str) -> Self { @@ -736,7 +598,6 @@ mod tests { } } - /// The sorted directory entry names under `dir`, for "and nothing else" assertions. fn entry_names(dir: &Path) -> Vec { let mut names: Vec = fs::read_dir(dir) .unwrap() @@ -752,7 +613,7 @@ mod tests { t.write("client-patch/mpq/.gitkeep", b""); t.write("packages/zeta/client/mpq/z.blp", b"z"); t.write("packages/alpha/client/addons/A/A.toc", b"a"); - t.write("packages/no-client/src/mod.rs", b""); // no client/ → not a source + t.write("packages/no-client/src/mod.rs", b""); let sources = client_sources(&t.0.join("client-patch"), &t.0.join("packages")).unwrap(); let labels: Vec<&str> = sources.iter().map(|(l, _)| l.as_str()).collect(); assert_eq!(labels, ["client-patch", "package alpha", "package zeta"]); @@ -852,15 +713,12 @@ mod tests { fn stale_addons_flags_only_marked_addons_the_current_sources_no_longer_provide() { let t = Scratch::new("stale-warn"); let dst = t.0.join("Interface/AddOns"); - // Still produced by this run: marked, and named by `current` — never flagged. let current_addon = dst.join("AutoLoot"); fs::create_dir_all(¤t_addon).unwrap(); write_source_marker(¤t_addon, "package autoloot").unwrap(); - // The Package behind it was disabled or removed: marked, but NOT named by `current`. let retired_addon = dst.join("Retired"); fs::create_dir_all(&retired_addon).unwrap(); write_source_marker(&retired_addon, "package retired").unwrap(); - // The operator's own addon: never marked by any sync, so it must never be flagged. t.write("Interface/AddOns/BigWigs/BigWigs.toc", b""); let current = vec![AddonDir { @@ -906,8 +764,6 @@ mod tests { assert!(stale_addons(&dst, &[]).unwrap().is_empty()); } - /// The Client Artifact's client half: every `mpq/` file in one archive, every addon directory - /// beside it, and nothing the operator's own client would need (no marker, no WDB). #[test] fn pack_out_writes_every_collected_file_and_addon_and_nothing_else() { let t = Scratch::new("pack-out-full"); @@ -955,8 +811,6 @@ mod tests { assert_eq!(entry_names(&out.join("Data")), [PATCH_MPQ]); } - /// A repo whose packages ship addons only has nothing to put in an archive. wow-mpq will build - /// an empty one, but an artifact holding a patch MPQ with no content of ours would be a lie. #[test] fn pack_out_without_mpq_content_writes_the_addons_and_no_archive() { let t = Scratch::new("pack-out-addons-only"); @@ -969,8 +823,6 @@ mod tests { assert!(out.join("Interface/AddOns/Alpha/Alpha.toc").exists()); } - /// The licensing firewall: a file computed out of the operator's client never reaches a - /// distributable artifact, and the refusal names the file and the baseline it came from. #[test] fn pack_out_refuses_a_baseline_derived_file_before_writing_anything() { let t = Scratch::new("pack-out-provenance"); @@ -1004,7 +856,6 @@ mod tests { ); } - /// `--pack-out` reads the repo, never a client. It runs where no client `Data/` dir exists. #[test] fn pack_out_needs_no_client_data_directory() { let t = Scratch::new("pack-out-no-client"); @@ -1020,8 +871,6 @@ mod tests { assert!(!t.0.join("Data").exists()); } - /// Both outputs collect the same sources, so a collision fails the artifact the same way it - /// fails a client sync, and before anything is written. #[test] fn pack_out_refuses_a_cross_source_collision() { let t = Scratch::new("pack-out-collide"); @@ -1046,10 +895,6 @@ mod tests { ); } - // --------------------------------------------------------------------------------------- - // UI Transforms - // --------------------------------------------------------------------------------------- - const LOOT_FRAME_LUA: &str = "function LootFrame_OnLoad()\n\tthis:RegisterEvent(\"LOOT_OPENED\");\nend\n"; const FRAMEXML_TOC: &str = "## Interface: 11200\nLootFrame.xml\nMainMenuBar.xml\n"; @@ -1065,8 +910,6 @@ mod tests { b.build(out).unwrap(); } - /// A scratch client `Data/` dir: an empty `AreaTable.dbc` in `dbc.MPQ` (the version check reads - /// it) and the given stock UI files in `interface.MPQ`. Never a real client. fn scratch_client(t: &Scratch, ui: &[(&str, &[u8])]) -> PathBuf { let data = t.0.join("client/Data"); let mut dbc = Vec::new(); @@ -1079,15 +922,12 @@ mod tests { data } - /// One file's text out of a built patch MPQ. fn packed_text(mpq: &Path, archive_path: &str) -> String { let mut chain = PatchChain::new(); chain.add_archive(mpq, 0).unwrap(); String::from_utf8(chain.read_file(archive_path).unwrap()).unwrap() } - /// The whole point: a Package extends a stock FrameXML file it does not own, and the composed - /// result carries the header, the insertion and the baseline it was composed from. #[test] fn a_transform_composes_the_stock_file_into_the_patch_mpq() { let t = Scratch::new("ui-compose"); @@ -1130,8 +970,6 @@ mod tests { ); } - /// The namespaced-file pattern: the Package ships `PkgLoot.lua` under its own `mpq/` and adds - /// the TOC line that loads it with a transform. Nothing else is needed to extend the stock UI. #[test] fn a_package_owned_file_and_the_toc_line_that_loads_it_both_land() { let t = Scratch::new("ui-namespaced"); @@ -1168,8 +1006,6 @@ mod tests { assert!(toc.contains("PkgLoot.lua\nLootFrame.xml\n"), "{toc}"); } - /// Two Packages extend one stock file at different anchors. Neither owns the file, and the - /// composed output holds both edits in the baseline's own order. #[test] fn two_packages_edit_one_file_at_disjoint_anchors() { let t = Scratch::new("ui-two-packages"); @@ -1206,8 +1042,6 @@ mod tests { ); } - /// An XML declaration has to stay the first thing in the document, so the header goes under it - /// rather than above it. Every other file takes the header first. #[test] fn the_record_header_never_displaces_an_xml_declaration() { assert_eq!( @@ -1220,8 +1054,6 @@ mod tests { ); } - /// Two Packages claiming byte ranges that intersect have no correct merge, so the pack fails - /// naming both of them, and the client keeps the patch it already had. #[test] fn overlapping_transforms_refuse_and_write_nothing() { let t = Scratch::new("ui-overlap"); @@ -1261,8 +1093,6 @@ mod tests { assert!(!data.join(PATCH_MPQ).exists(), "a refusal writes nothing"); } - /// An anchor the baseline holds twice could attach in two places, so the pack refuses rather - /// than pick one. #[test] fn an_ambiguous_anchor_refuses_and_writes_nothing() { let t = Scratch::new("ui-ambiguous"); @@ -1291,8 +1121,6 @@ mod tests { assert!(!data.join(PATCH_MPQ).exists(), "a refusal writes nothing"); } - /// One source replaces a stock file whole while another patches it in place. The override would - /// swallow the transform, so the pack refuses and names both sources and the path. #[test] fn an_override_of_a_transformed_path_refuses_and_writes_nothing() { let t = Scratch::new("ui-override"); @@ -1330,8 +1158,6 @@ mod tests { assert!(!data.join(PATCH_MPQ).exists(), "a refusal writes nothing"); } - /// The licensing firewall for a transform: `--pack-out` never opens a client, so it refuses the - /// declaration itself and names every Package and path behind it. #[test] fn pack_out_refuses_a_declared_transform_without_any_client() { let t = Scratch::new("ui-pack-out"); @@ -1359,8 +1185,6 @@ mod tests { ); } - /// The DBC read is the one client-version check. A client whose `AreaTable.dbc` is not the - /// build-5875 schema fails there, before a single baseline byte is read. #[test] fn a_wrong_client_version_refuses_before_any_baseline_read() { let t = Scratch::new("ui-wrong-version"); @@ -1369,7 +1193,6 @@ mod tests { &data.join("dbc.MPQ"), &[("DBFilesClient\\AreaTable.dbc", b"not a 5875 DBC")], ); - // No interface.MPQ: reaching the baseline read would fail with a different message. t.write( "packages/loot/client/ui-transforms.json", br#"[{ "path": "Interface/FrameXML/LootFrame.lua", diff --git a/importer/src/package_delta.rs b/importer/src/package_delta.rs index 7db67766a..2c212a126 100644 --- a/importer/src/package_delta.rs +++ b/importer/src/package_delta.rs @@ -10,24 +10,16 @@ use lyracore_package_delta::{ use crate::world_import_scope::WorldImportScope; use crate::{call_reducer_args, Args}; -/// Where a Package's generated Delta artifacts live, relative to the Package folder. const GENERATED_DIR: &str = "data/.generated"; -/// The reducer that applies a whole family's enabled plan in one transaction. const APPLY_REDUCER: &str = "apply_package_deltas"; -/// One artifact, and the file it came from so a refusal can name it. #[derive(Debug)] struct Artifact { path: PathBuf, delta: PackageDelta, } -/// Read the enabled Packages' artifacts for `family`, report the plan, and apply it when the caller -/// asked to write. -/// -/// Refuses the whole stage on an unreadable root, an invalid artifact, or a Claim Conflict — the -/// module would refuse the same plan, and refusing here names the file. pub(crate) fn reapply(args: &Args, family: &str, root: &str) -> Result<()> { let scope = args.world_import_scope()?; let for_family = artifacts_for_family(read_enabled(Path::new(root))?, family)?; @@ -69,9 +61,6 @@ pub(crate) fn reapply(args: &Args, family: &str, root: &str) -> Result<()> { Ok(()) } -/// Keep only the claims owned by `family`, preserving the Package and source identities carried by -/// the original artifact. One Package Delta may span several Import Families, but each reducer call -/// applies exactly one family. fn artifacts_for_family(artifacts: Vec, family: &str) -> Result> { artifacts .into_iter() @@ -102,13 +91,7 @@ fn artifacts_for_family(artifacts: Vec, family: &str) -> Result, @@ -145,11 +128,6 @@ fn routed_to_this_shard( Ok((kept, routed_away)) } -/// The warning a base import prints when the operator did not name an enabled Package root. -/// -/// Silence would be the wrong answer twice over: the shard is now running base rows with no Package -/// claims on them, and an empty enabled set is a DIFFERENT statement that would have deleted every -/// row a Package invented. Neither may be guessed. pub(crate) fn warn_not_reapplied(family: &str) { eprintln!( "{family}: WARNING — enabled Package Deltas were NOT reapplied. The base rows are back \ @@ -158,11 +136,6 @@ pub(crate) fn warn_not_reapplied(family: &str) { ); } -/// Every enabled Package's artifacts, ordered by Package folder then by file name so the same tree -/// always produces the same plan. -/// -/// A missing root is an error, never an empty set: "no Package claims this family" is a statement -/// the operator makes by pointing at a real Package Inventory that happens to hold no artifacts. fn read_enabled(root: &Path) -> Result> { if !root.is_dir() { bail!( @@ -195,11 +168,6 @@ fn read_enabled(root: &Path) -> Result> { for path in files { let json = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - // One Package ships every artifact kind it has into this one directory, so the glob - // above finds a Script Artifact next to a Package Delta. Skipping it is not leniency: - // this stage is the CLAIM half of an apply, and the script family has no base import to - // run behind. `lyracore packages apply` applies that family separately. Anything this - // router cannot read at all still goes to the parser, which names what is wrong with it. if matches!( artifact_kind(&json), Some(ArtifactKind::Script | ArtifactKind::Other(_)) @@ -214,9 +182,6 @@ fn read_enabled(root: &Path) -> Result> { Ok(artifacts) } -/// The artifacts' canonical bytes, one per line — the payload shape `apply_package_deltas` reads. -/// Canonical rather than as-written, so the digest the module records describes what the artifact -/// SAYS rather than how someone spelled it. fn pack(deltas: &[PackageDelta]) -> String { deltas .iter() @@ -225,8 +190,6 @@ fn pack(deltas: &[PackageDelta]) -> String { .join("\n") } -/// The plan, in the form a check prints and an apply announces: every Package with its file and row -/// counts, then every claimed row, then every conflict. fn plan_report( root: &str, scope: &str, @@ -289,7 +252,6 @@ mod tests { const PACKAGE_SPELL: u32 = 6_000_001; const REAL_SPELL: u32 = 133; - /// A unique scratch tree per test (std-only; no tempfile dep — mirrors `pack_client.rs`). struct Scratch(PathBuf); impl Scratch { fn new(tag: &str) -> Self { @@ -317,11 +279,8 @@ mod tests { ) } - /// Eastern Kingdoms, inside the eastern profile's Elwynn slice. const EASTERN_MAP: u32 = 0; - /// Kalimdor, which the eastern profile does not touch. const KALIMDOR_MAP: u32 = 1; - /// Deadmines, the whole map the `instances` profile owns. const INSTANCE_MAP: u32 = 36; const REAL_CREATURE: u32 = 6; const PACKAGE_CREATURE: u32 = 15_000_001; @@ -332,7 +291,6 @@ mod tests { ) } - /// One Package placing the same creature on two maps: the shape a two-Shard realm produces. fn two_map_artifact() -> String { format!( r#"{{"version":1,"package":"example.placer","source_hash":"{HASH_A}","claims":[{},{}]}}"#, @@ -354,7 +312,6 @@ mod tests { ) } - /// One Package tuning an imported EventAI line and inventing a summon placement beside it. fn creature_ai_artifact() -> String { format!( r#"{{"version":1,"package":"example.voice","source_hash":"{HASH_A}","claims":[{{"table":"game_creature_ai_broadcast_text","key":{{"id":900}},"operation":"update","fields":{{"male_text":{{"type":"string","value":"You will burn."}}}}}},{{"table":"game_creature_ai_summon","key":{{"id":17000001}},"operation":"insert","fields":{{"x":{{"type":"f32","value":1.5}},"y":{{"type":"f32","value":2.5}},"z":{{"type":"f32","value":3.5}},"orientation":{{"type":"f32","value":0.0}},"lifetime_ms":{{"type":"u32","value":30000}}}}}}]}}"# @@ -415,7 +372,6 @@ mod tests { .is_empty()); } - /// A Package with no data half is the common case, not a problem. #[test] fn a_package_with_no_generated_artifacts_contributes_nothing() { let t = Scratch::new("rust-only"); @@ -427,9 +383,6 @@ mod tests { assert!(found.is_empty()); } - /// A Package ships every artifact kind it has into one directory. This stage is the CLAIM half - /// of an apply, so a Script Artifact next to a Package Delta is not its business — and must not - /// abort the whole spell import the way an unreadable Package Delta would. #[test] fn a_script_artifact_beside_a_package_delta_is_skipped_rather_than_refused() { let t = Scratch::new("mixed-kinds"); @@ -450,8 +403,6 @@ mod tests { assert_eq!(found[0].delta.package().as_str(), "example.bolt"); } - /// The router only skips what it can positively identify as another kind. A file no parser can - /// read is still a refusal that names it. #[test] fn a_file_no_parser_can_read_still_refuses_the_stage() { let t = Scratch::new("unreadable"); @@ -462,7 +413,6 @@ mod tests { assert!(refusal.to_string().contains("spell.json"), "{refusal}"); } - /// Only `.generated/` counts. An author's own JSON beside their Datascript is not an artifact. #[test] fn json_outside_the_generated_directory_is_not_an_artifact() { let t = Scratch::new("stray-json"); @@ -478,8 +428,6 @@ mod tests { assert_eq!(found[0].delta.package().as_str(), "example.bolt"); } - /// An empty Package Inventory and an absent one are different statements: the first says no - /// Package claims this family, the second says the operator never named one. #[test] fn an_enabled_root_holding_no_packages_is_an_empty_plan() { let t = Scratch::new("empty-root"); @@ -511,8 +459,6 @@ mod tests { ); } - /// A creature spawn on a map the scope owns stays in the plan; the same Package's spawn on a - /// map it does not own is routed to the Shard that does. #[test] fn a_spatial_claim_reaches_only_the_shard_whose_scope_owns_its_map() { let t = Scratch::new("routing"); @@ -532,7 +478,6 @@ mod tests { assert_eq!(claims[0].key().map_id(), Some(EASTERN_MAP)); } - /// A global catalogue row states no map, so every Shard loads it however its scope is drawn. #[test] fn a_global_catalogue_claim_reaches_every_shard() { let t = Scratch::new("routing-global"); @@ -552,9 +497,6 @@ mod tests { assert_eq!(kept.len(), 1, "a template is not spatial"); } - /// The EventAI catalogue names no map, so its whole family is global: an `instances` Shard and - /// an open-world Shard both load every claim. The base import writes these tables the same way, - /// with global SQL and no map predicate. #[test] fn every_creature_ai_claim_reaches_every_shard() { let t = Scratch::new("routing-creature-ai"); @@ -586,8 +528,6 @@ mod tests { } } - /// A Package whose every claim routed away drops out of the plan, the way it does when it - /// claims no table of the family at all. #[test] fn a_package_whose_claims_all_route_away_leaves_the_plan() { let t = Scratch::new("routing-empty"); @@ -595,7 +535,6 @@ mod tests { let found = read_enabled(&t.0).expect("discovery succeeds"); let creatures = artifacts_for_family(found, "creatures").expect("the creatures plan"); - // The instances profile owns Deadmines alone, so neither open-world spawn belongs to it. let instances = WorldImportScope::canonical(WorldImportProfile::Instances).expect("instances profile"); let (kept, routed_away) = @@ -605,8 +544,6 @@ mod tests { assert!(kept.is_empty()); } - /// A whole map in the scope owns every claim on it, with no bounded geometry to pass — the base - /// import's own rule for a forced creature, applied to an authored spawn. #[test] fn a_whole_map_in_the_scope_owns_every_spawn_on_it() { let t = Scratch::new("routing-whole-map"); @@ -702,8 +639,6 @@ mod tests { assert!(report.contains("example.second"), "{report}"); } - /// The check has to be able to describe a Package that invents a spell, not only one that tunes - /// an existing row. #[test] fn the_report_describes_an_inserted_package_spell() { let whole_row = concat!( diff --git a/importer/src/spell.rs b/importer/src/spell.rs index da619344b..d2eef5fdc 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -1,19 +1,3 @@ -//! Stream 1 (client DBC) — Spell.dbc → `game_spell` + `game_spell_effect` importer. Reads the -//! operator's OWN `Spell.dbc` (+ the SpellCastTimes / SpellRange / SpellDuration / SpellRadius aux -//! tables) IN MEMORY via the same `open_chain`/`read_table` path as `dbc.rs`, maps each spell's -//! header + up to 3 effects onto our deduped effect/aura taxonomy in lyracore-shared, -//! and emits derived `game_*` rows as chunked clear+reload SQL. It is the stress test of the -//! taxonomy: every effect either maps to a REAL kind or falls back to `E_SCRIPTED` (a queryable -//! no-op), and the run prints a COVERAGE REPORT measuring how much real vanilla spell data the -//! taxonomy absorbs. -//! -//! LICENSING FIREWALL: like the rest of `dbc.rs`, the client bytes stay in memory — NO `.dbc` (or any -//! Blizzard file) is ever written; only derived `game_spell`/`game_spell_effect` rows are emitted. -//! -//! The AuraMod variant names + instant-effect numeric IDs are matched by hand against -//! `wow_world_base::vanilla`. The shared build 5875 reader supplies the numeric columns. Both -//! mapping tables come from the design report; unmapped → `E_SCRIPTED` for graceful coverage. - use std::collections::BTreeMap; use crate::spell_dbc::{read_spells, Spell}; @@ -55,9 +39,6 @@ use lyracore_shared::spell::taxonomy::{ T_TARGET_ALLY, T_TARGET_ANY, T_TARGET_ENEMY, }; -// cmangos/classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb: -// spell_template 7728/8674 send events 1131/1134; dbscripts_on_event summons -// 5676/5677 for 360000 ms. Their creature_ai_scripts set faction 14 and attack the summoner. fn binding_summon_entry(spell_id: u32, effect: i32, event: u32, focus: u32) -> Option { match (spell_id, effect, event, focus) { (7728, 61, 1131, 83) => Some(5676), @@ -68,8 +49,7 @@ fn binding_summon_entry(spell_id: u32, effect: i32, event: u32, focus: u32) -> O const GOUGE_RANK_IDS: [u32; 5] = [1776, 1777, 8629, 11285, 11286]; -// Vanilla form IDs differ from durable stance IDs. Both effect parameters and -// usability masks use this mapping; unsupported forms contribute no stance bit. +// Vanilla form ids differ from durable stance ids. const FORM_CAT: i32 = 1; const FORM_BEAR: i32 = 5; const FORM_DIRE_BEAR: i32 = 8; @@ -89,19 +69,16 @@ fn form_to_stance(form_id: i32) -> Option { } } -// vanilla `AttributesEx1` CHANNELED bits — IS_CHANNELLED (0x04) | CHANNELED_2 (0x40). cmangos: channeled = AttributesEx1 & 0x44. +// AttributesEx1 bits 0x04 and 0x40 both mark a channeled spell. const ATTR_EX1_CHANNELED_MASK: u32 = 0x0000_0044; const ATTR_EX1_EXCLUDE_CASTER: u32 = 0x0008_0000; -// vanilla `AttributesEx2` SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20) — marks auto-repeat ranged attacks (Auto Shot, wand Shoot). const ATTR_EX2_AUTOREPEAT: u32 = 0x0000_0020; -// vanilla SpellEffect numeric IDs that PLACE an aura → branch to the AuraMod map. const EFFECT_APPLY_AURA: i32 = 6; const EFFECT_PERSISTENT_AREA_AURA: i32 = 27; const EFFECT_APPLY_AREA_AURA_PARTY: i32 = 35; const EFFECT_APPLY_AREA_AURA_PET: i32 = 119; -/// True when this raw SpellEffect id places an aura (its taxonomy KIND comes from `effect_aura[i]`). fn is_aura_effect(effect_id: i32) -> bool { matches!( effect_id, @@ -112,19 +89,9 @@ fn is_aura_effect(effect_id: i32) -> bool { ) } -// raw vanilla SPELL_EFFECT_DISPEL_MECHANIC (108) — an INSTANT effect whose misc value names a Mechanic -// to strip; `dismount_effect_kind` below reclassifies the ONE parameter this codebase supports (the -// mount mechanic, 21) to E_DISMOUNT. Every other mechanic parameter is left unmapped by design. const EFFECT_DISPEL_MECHANIC: i32 = 108; const MECHANIC_MOUNT: u32 = 21; -/// Translate a raw `DISPEL_MECHANIC` effect (108) whose misc value names the MOUNT mechanic (21) into -/// `E_DISMOUNT` — the one data-driven reclassify the design allows: no generic mechanic-dispel module, -/// no branch on a spell id or name. ANY spell carrying this raw shape gets mount removal for free — the -/// synthetic Dazed spell (A_MOD_SPEED slow + this effect) is just the first curated consumer, not a -/// special case. Any OTHER `DISPEL_MECHANIC` parameter is left -/// unmapped (falls to `E_SCRIPTED`, counted in the existing unmapped-effect coverage) — deliberately -/// out of scope until a real gameplay need shows up in the data. Pure. [import] fn dismount_effect_kind(effect_id: i32, misc: u32) -> Option { if effect_id == EFFECT_DISPEL_MECHANIC && misc == MECHANIC_MOUNT { Some(E_DISMOUNT) @@ -133,72 +100,39 @@ fn dismount_effect_kind(effect_id: i32, misc: u32) -> Option { } } -/// The vanilla DBC "flat amount" convention: `EffectBasePoints` stores the nominal integer amount -/// MINUS one (the same off-by-one every effect's `base_points` already corrects for at the call site). -/// Named here so the mounted-speed normalization (vanilla stores 59/99 for the nominal 60%/100% speed -/// bonus) is an explicit, tested contract rather than an incidental side effect of the generic `+1`. -/// Pure. [import] +// EffectBasePoints stores the nominal integer amount minus one. fn dbc_flat_amount(raw: i32) -> i32 { raw + 1 } -/// Preserve the event mask and clamp chance and charges to the durable byte columns. fn proc_header_fields(flags: u32, chance: u32, charges: u32) -> (u32, u8, u8) { (flags, chance.min(255) as u8, charges.min(255) as u8) } -/// Translate vanilla damage and movement flags into the Module's compact mask. fn aura_interrupt_bits(raw: u32) -> u16 { u16::from(raw & 0x2 != 0) | (u16::from(raw & 0x8 != 0) << 1) } -/// Curated load-time flag correction (the Spell.sql analog for header flags): OR our OWN cast-gate bits -/// into `game_spell.attributes` BY NAME. This is the ONE place allowed to name spells; the engine then -/// gates generically on the bit, never on a spell id. Additive — returns only the bits to OR in. -/// - REQUIRES_BEHIND: Backstab — only castable from the target's rear hemisphere. -/// - REQUIRES_STEALTH: Sap — opener; caster must be stealthed (the engine also enforces out-of-combat + -/// humanoid for a REQ_STEALTH spell). -/// - STEALTH_SAFE: Sap, Pick Pocket and Distract. Casting keeps the rogue stealthed (vanilla). -/// - REQ_DAGGER: Backstab — only castable with a dagger equipped in the main hand. -/// -/// Keyed by NAME so BOTH ranks (the LearnSpell/combo wrapper AND the real spell) carry the flag; the gate -/// is harmless on a wrapper (the wrapper's triggered real spell re-runs resolve_cast_at and is gated there). fn spell_flag_attributes(name: &str) -> u32 { let mut bits = 0u32; - // REQUIRES_BEHIND — Backstab is the must; Garrote/Ambush are the other vanilla behind-only openers. if matches!(name, "Backstab" | "Garrote" | "Ambush") { bits |= SPELL_ATTR_REQ_BEHIND; } - // REQ_DAGGER — Backstab requires a dagger equipped in the main hand (vanilla melee-weapon-type gate). if name == "Backstab" { bits |= SPELL_ATTR_REQ_DAGGER; } - // REQUIRES_STEALTH — Sap + Garrote (stealth openers; the caster must be stealthed). Garrote ALSO - // breaks stealth (NOT STEALTH_SAFE) and works on ANY creature type in or out of combat, so it gets - // ONLY REQ_STEALTH — NOT the Sap-shaped out-of-combat/humanoid constraints (those ride INCAP_OPENER). if matches!(name, "Sap" | "Garrote") { bits |= SPELL_ATTR_REQ_STEALTH; } - // INCAP_OPENER — Sap's additional constraints (target OUT of combat + HUMANOID). Split out of the - // REQ_STEALTH bit so a non-Sap stealth opener (Garrote) does NOT inherit them. if name == "Sap" { bits |= SPELL_ATTR_INCAP_OPENER; } - // STEALTH_SAFE: Sap, Pick Pocket and Distract keep the rogue stealthed. Garrote is not here, so the - // cast path's break_stealth reveals the rogue. if matches!(name, "Sap" | "Pick Pocket" | "Distract") { bits |= SPELL_ATTR_STEALTH_SAFE; } - // FINISHER_DURATION — Slice and Dice: the haste aura's DURATION scales with the combo points spent. - // Both ranks (the LearnSpell/combo wrapper 5175 AND the real 5171) carry the bit harmlessly — the - // wrapper just triggers 5171, which re-runs the gated aura-apply. if name == "Slice and Dice" { bits |= SPELL_ATTR_FINISHER_DURATION; } - // REQ_OVERPOWER — Overpower: only castable in the ~5s window after the caster's swing was DODGED (the - // attack table stamps the window; the gate reads this bit). REQ_REVENGE — Revenge: only castable in the - // ~5s window after the caster DODGED/PARRIED/BLOCKED an incoming swing. Both keyed BY NAME here (the one - // place allowed to name spells); the engine gates generically on the bit, never on a spell id. if name == "Overpower" { bits |= SPELL_ATTR_REQ_OVERPOWER; } @@ -208,12 +142,6 @@ fn spell_flag_attributes(name: &str) -> u32 { bits } -/// Is this a CHANNELED spell — the cast holds the caster + ticks a per-tick effect over `duration_ms`, -/// breaking on action (Arcane Missiles)? Keyed on the DBC `AttributesEx1` CHANNELED bit (`0x44`, the cmangos -/// test), with a by-NAME fallback for the curated kit in case the bit is not cleanly readable on this client -/// build. This is the ONE place allowed to name a spell; the engine then keys on the `SPELL_ATTR_CHANNELED` -/// bit / the `A_PERIODIC_TRIGGER` kind, never a spell id. Drives BOTH the header flag AND the effect -/// reclassify (the periodic-trigger effect → `A_PERIODIC_TRIGGER`) so the two stay in lockstep. fn is_channeled(attributes_ex1: u32, name: &str) -> bool { attributes_ex1 & ATTR_EX1_CHANNELED_MASK != 0 || matches!(name, "Arcane Missiles" | "Evocation") } @@ -222,17 +150,10 @@ fn excludes_eventai_caster(attributes_ex1: u32) -> bool { attributes_ex1 & ATTR_EX1_EXCLUDE_CASTER != 0 } -/// Is this an AUTO-REPEAT ranged attack (Auto Shot 75 / wand Shoot 5019) — the client fires it on the -/// ranged-weapon timer until stopped, not as a one-shot cast? Keyed on the DBC `AttributesEx2` AUTOREPEAT -/// bit (`0x20`), with a by-NAME fallback for the two player abilities in case the bit is not cleanly -/// readable on this client build. The GATEWAY (never a spell-id list) then routes CMSG_CAST_SPELL on the -/// resulting `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit. fn is_ranged_auto_repeat(attributes_ex2: u32, name: &str) -> bool { attributes_ex2 & ATTR_EX2_AUTOREPEAT != 0 || matches!(name, "Auto Shot" | "Shoot") } -/// An effect row with every column at its inert value, for the curated additions to fill in. Naming -/// only the columns that carry meaning is what makes each synthetic row readable. fn blank_effect(spell_id: u32, effect_index: u8) -> SpellEffectRow { SpellEffectRow { spell_id, @@ -255,102 +176,65 @@ fn blank_effect(spell_id: u32, effect_index: u8) -> SpellEffectRow { } } -/// Preserve the curated seal marker and its Judgement amount. The third client effect -/// is a Dummy whose magnitude names another spell, not the Module's flat damage amount. +// The client Dummy magnitude names a spell; the curated seal marker needs flat damage. fn synthetic_seal_effect(spell_id: u32, name: &str, used_index: u8) -> Option { if !(spell_id == 21082 && name == "Seal of the Crusader") { return None; } Some(SpellEffectRow { kind: A_SEAL, - base_points: 20, // flat Judgement-of-the-Crusader holy burst; self aura, no p0 + base_points: 20, ..blank_effect(spell_id, used_index) }) } -/// The shield's linked Weakened Soul debuff is a gameplay rule absent from its DBC row. +// Power Word: Shield omits its linked Weakened Soul debuff from the DBC row. fn power_word_shield_p1_override(spell_id: u32, name: &str, kind: u8, p1: i32) -> i32 { if spell_id == 17 && name == "Power Word: Shield" && kind == A_ABSORB { - return 6788; // Weakened Soul + return 6788; } p1 } -/// `p1` for an `E_POWER_BURN` effect (Mana Burn): the DBC `EffectMultipleValue` (a fraction, 0.5 -/// for vanilla Mana Burn) carried onto the effect row as basis-points (0.5 -> 50), since `p1` is a -/// plain `i32`. The module's `mana_burn_damage` treats `<=0` as 100 (1:1) — so a spell whose -/// `EffectMultipleValue` genuinely reads 0 in the DBC (unauthored/placeholder) still deals full -/// drained-mana damage rather than silently zeroing out. Pure (unit-tested). +// Store EffectMultipleValue in hundredths: 0.5 becomes 50. fn power_burn_ratio_bp(multiple: f32) -> i32 { (multiple * 100.0).round() as i32 } -/// Map a vanilla INSTANT SpellEffect numeric id → our KIND (effect-map design unit). Unmapped → the -/// graceful `E_SCRIPTED` no-op. IDs verified against `wow_world_base::vanilla::SpellEffect` (the -/// `from_int` table): SchoolDamage=2, Heal=10, HealMaxHealth=67, Energize=30, Dispel=38, -/// TriggerSpell=64, Threat=63/ThreatAll=91, weapon-damage family 17/58/121/31, Resurrect=18, -/// AddComboPoints=80, ResurrectNew=113, AttackMe=114 (cross-checked against the -/// vendored `wow_world_base-0.3.0` vanilla/tbc/wrath `SpellEffect::from_int` tables; all three eras -/// agree on these four numeric ids). -/// CONFIRMED 2026-07-14 against the real client Spell.dbc (curated-kit dry-run + targeted --only): -/// Taunt 355 → E_TAUNT, Resurrection 2006 / Redemption 7328 → E_RESURRECT, Cheap Shot 1833 (no -/// name-rescue) → E_ADD_COMBO via this raw-80 arm — so all four raw ids are load-bearing, and the -/// full curated-kit histogram shows ZERO unmapped effects for the trivial set (114/80/18/113 absent -/// from cov.unmapped_effect; only the out-of-scope LearnSpell-36 / ScriptEffect-77 / Dummy-3 tail -/// remains E_SCRIPTED). fn instant_effect_to_kind(effect_id: i32) -> u8 { match effect_id { - 2 | 17 | 58 | 121 | 31 => E_DAMAGE, // SchoolDamage + WeaponDamage(No)School / Normalized / Percent - 10 => E_HEAL, // Heal (flat amount = base_points) - 67 => E_HEAL_MAX_HEALTH, // HealMaxHealth (Lay on Hands) — fill to max, base_points ~0 so it can NOT be E_HEAL (which would heal 0) - 30 => E_ENERGIZE, // Energize - 38 => E_DISPEL, // Dispel - 64 => E_TRIGGER, // TriggerSpell - 63 | 91 => E_TAUNT, // Threat / ThreatAll + 2 | 17 | 58 | 121 | 31 => E_DAMAGE, + 10 => E_HEAL, + 67 => E_HEAL_MAX_HEALTH, + 30 => E_ENERGIZE, + 38 => E_DISPEL, + 64 => E_TRIGGER, + 63 | 91 => E_TAUNT, 114 => E_TAUNT, - 24 => E_CREATE_ITEM, // CreateItem (conjure / quest item) — p0 = item entry - 68 => E_INTERRUPT, // InterruptCast (Kick), cancel the target's in-progress cast - 55 => E_TAME_CREATURE, // TameCreature — explicit wild target, no spell-id branch - 101 => E_FEED_PET, // FeedPet — explicit item target is routed by the gateway/manual cast seam - 56 => E_SUMMON_PET, // Summon (Summon Imp et al.) — p0 = the summoned creature entry (misc_value) + 24 => E_CREATE_ITEM, + 68 => E_INTERRUPT, + 55 => E_TAME_CREATURE, + 101 => E_FEED_PET, + 56 => E_SUMMON_PET, 62 => E_POWER_BURN, - 83 => E_DUEL, // Duel, p0 carries the duel-flag gameobject entry - 69 => E_DISTRACT, // Distract: the effect amount is the Distraction's length in seconds + 83 => E_DUEL, // Duel p0 carries the duel-flag GameObject entry. + 69 => E_DISTRACT, // Distract effect amounts use seconds. 33 | 59 => E_OPEN_LOCK, 99 => E_DISENCHANT, 80 => E_ADD_COMBO, - 94 => E_SELF_RESURRECT, // SelfResurrect (Soulstone Resurrection, Reincarnation): read by the Module's self-resurrection, never cast + 94 => E_SELF_RESURRECT, 18 | 113 => E_RESURRECT, - _ => E_SCRIPTED, // remaining vanilla effects: queryable no-op + _ => E_SCRIPTED, } } -/// Curated load-time correction — our `Spell.sql` analog. A few vanilla spells tag a generic effect as -/// SCRIPT_EFFECT(77) for client-display reasons, so the raw mapping above falls to E_SCRIPTED; mangos -/// reclassifies the same set via its `Spell.sql` fixes. Keyed by spell NAME — this is the ONE place -/// allowed to name spells, deliberately isolated from the engine — and applied ONLY to effects that fell -/// to E_SCRIPTED, so it never disturbs a correctly-mapped effect. Returns the corrected kind (or input). fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { - // Reclassify the inert no-op residue — SCRIPT_EFFECT instants (E_SCRIPTED) AND inert MARKER auras - // (A_FLAG) that are really a generic mechanic. Only the residue, so a correctly-mapped effect is - // never disturbed. - // Rogue FINISHER: a finisher's DAMAGE effect scales with + spends combo points (read at runtime; the - // base/die are the PER-COMBO-POINT damage). Fires on the real E_DAMAGE effect, before the residue guard. if kind == E_DAMAGE && name == "Eviscerate" { return E_FINISHER_DAMAGE; } - // On-next-swing QUEUE (Tier 2b): Heroic Strike / Cleave encode a normal weapon-damage effect (→ E_DAMAGE) - // in the DBC, but vanilla QUEUES them onto the next melee swing rather than hitting instantly. Reclassify - // that E_DAMAGE to E_NEXT_SWING (the cast charges rage + stamps next_swing_spell; the next swing adds the - // base_points as bonus damage). Fires on the real weapon-damage effect, before the residue guard — the - // Eviscerate→E_FINISHER_DAMAGE precedent. base_points (the flat bonus) carries through untouched. if kind == E_DAMAGE && matches!(name, "Heroic Strike" | "Cleave") { return E_NEXT_SWING; } - // Feint: vanilla encodes it as the native Threat effect (→ E_TAUNT) with a NEGATIVE - // base (−150) — i.e. a one-time threat DROP, not a taunt-yank. Reclassify the E_TAUNT effect to our - // E_REDUCE_THREAT (the handler reduces the caster's CURRENT threat by |base_points|). Fires on the - // real E_TAUNT effect, before the residue guard. DISTINCT from Fade (a COMBAT_THREAT percent). if kind == E_TAUNT && name == "Feint" { return E_REDUCE_THREAT; } @@ -358,49 +242,31 @@ fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { return kind; } match name { - "Holy Light" | "Flash of Light" => E_HEAL, // script effect IS a heal (base/die carry the amount); - // Flash of Light (paladin fast heal, L20) is the exact Holy-Light case — a SCRIPT_EFFECT dummy - // carrying the heal in base_points — and fell to E_SCRIPTED (a hollow no-op "heal") without this. - "Life Tap" => E_CONVERT_RESOURCE, // health -> mana, 1:1 - "Charge" => E_CHARGE, // the rush effect (vanilla effect 96) -> teleport-to-target - "Blink" => E_BLINK, // Mage Blink: the dead SCRIPT teleport effect (raw 29) -> teleport the caster ~20yd FORWARD along its facing. Name-rescued like Charge (raw 29 is a generic teleport with per-spell destination rules; Blink's is "forward"). eff2 (root/snare A_IMMUNITY) already maps natively. - "Seal of Righteousness" => A_SEAL, // the inert A_FLAG marker IS a proc-on-swing holy seal - "Stealth" => A_STEALTH, // the inert A_FLAG marker IS the stealth presence (creatures skip it; broken on action) - "Retaliation" => A_RETALIATE, // the inert A_FLAG marker IS the free-counter-swing self-buff (any melee attacker gets swung back at) + "Holy Light" | "Flash of Light" => E_HEAL, + "Life Tap" => E_CONVERT_RESOURCE, + "Charge" => E_CHARGE, + "Blink" => E_BLINK, + "Seal of Righteousness" => A_SEAL, + "Stealth" => A_STEALTH, + "Retaliation" => A_RETALIATE, "Battle Stance" | "Defensive Stance" | "Berserker Stance" | "Bear Form" | "Dire Bear Form" | "Cat Form" => E_SET_STANCE, - "Judgement" => E_JUDGEMENT, // unleash the active seal - // Rogue GENERATORS: the inert combo-point Dummy effect (-> E_SCRIPTED) builds a combo point. - // Garrote's eff2 is the same combo Dummy (its eff1 is the A_PERIODIC_DAMAGE bleed, unchanged). - // `instant_effect_to_kind` now maps the raw AddComboPoints effect id (80) to - // E_ADD_COMBO natively too, but the in-kit generators carry the generic Dummy effect, not raw - // 80, so this name rescue is still the ONLY path that reaches them — kept, not redundant here. + "Judgement" => E_JUDGEMENT, "Sinister Strike" | "Backstab" | "Gouge" | "Garrote" => E_ADD_COMBO, "Resurrection" | "Redemption" | "Rebirth" | "Ancestral Spirit" => E_RESURRECT, - "Pick Pocket" => E_PICKPOCKET, // the script effect grants creature copper without engaging - // (Feint is handled above — its native Threat effect maps to E_TAUNT, not the E_SCRIPTED residue.) - // Create Healthstone (all ranks): vanilla encodes these as SCRIPT_EFFECT (raw effect 77) with the - // created item hardcoded in the mangos script (effect_item_type is 0 in the DBC), so the native - // CreateItem path can't reach them — they fall to E_SCRIPTED. Reclassify by name to E_CREATE_ITEM - // (count=1 rides base_points); the item entry is injected by the kind+name p0 fixup at the call site - // (mirrors the E_SET_STANCE p0-remap), since there is no effect_item_type to read. Conjure Water/Food - // need NO arm here — they use raw effect 24 and already map natively to E_CREATE_ITEM. + "Pick Pocket" => E_PICKPOCKET, "Create Healthstone" | "Create Healthstone (Minor)" | "Create Healthstone (Lesser)" | "Create Healthstone (Greater)" | "Create Healthstone (Major)" => E_CREATE_ITEM, - // Every Soulstone rank's buff is a Dummy aura; its rank picks the option spell below. "Soulstone Resurrection" => A_SELF_RESURRECT, _ => kind, } } -/// The Self-Resurrection Option spell each Soulstone Resurrection aura rank grants. The ranks share -/// one name, so the aura spell id decides. Source: ClassicDB 1.12.1 `spell_template` at the pinned -/// commit, cross-checked against cmangos-classic `Player::GetResurrectionSpellId`. fn soulstone_option_spell(aura_spell_id: u32) -> Option { match aura_spell_id { 20707 => Some(3026), @@ -412,13 +278,7 @@ fn soulstone_option_spell(aura_spell_id: u32) -> Option { } } -/// Translate the vanilla Spell.dbc `Stances` (ShapeshiftMask) bitmask → our 0-based stance usability bits. -/// Vanilla stores bit `1 << (formId-1)` for each allowed form; every form `form_to_stance` maps (the -/// Warrior trio AND, since, the Druid combat forms Bear/Cat/Dire Bear) folds onto our bit -/// `1 << stance` — so a druid ability's Bear|DireBear requirement (vanilla 0x90) survives import as our -/// 0x28 instead of being dropped. Bits for UNMAPPED forms (Aquatic/Travel/Tree/Ghoul/…) are still -/// dropped, and a 0 mask stays 0 ("usable in any stance"). The result is a u8 (our six stance bits fit -/// bits 0..5). Pure. [import] +// Vanilla Stances stores 1 << (form_id - 1). fn translate_stance_mask(vanilla_mask: u32) -> u8 { let mut out: u8 = 0; for bit in 0u32..32 { @@ -426,20 +286,12 @@ fn translate_stance_mask(vanilla_mask: u32) -> u8 { continue; } if let Some(stance) = form_to_stance(bit as i32 + 1) { - out |= 1u8 << stance; // stance ids are 0..5 — always within the u8 + out |= 1u8 << stance; } } out } -/// Remap a resolved E_SET_STANCE effect's p0 from the vanilla form id (carried by the reclassified -/// ModShapeshift→A_FLAG marker) to our 0-based stance id via `form_to_stance` (Battle 17→0 / -/// Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5), so the cast handler writes -/// the right WorldEntity.stance. Non-stance kinds pass p0 through unchanged. An E_SET_STANCE effect -/// whose form id is unmapped passes p0 through too — unreachable by construction (the name rescue in -/// `correct_script_effect_kind` is the ONLY E_SET_STANCE producer and only names mapped forms), and a -/// raw form id ≥ 8 is inert in the module anyway (`stance_allows` disallows stances ≥ 8 under any -/// non-zero mask; the stance folds ignore it). Pure. [import] fn stance_p0(kind: u8, p0: i32) -> i32 { if kind == E_SET_STANCE { form_to_stance(p0).unwrap_or(p0) @@ -448,13 +300,6 @@ fn stance_p0(kind: u8, p0: i32) -> i32 { } } -/// Resolve an `A_MOUNTED` effect's `p0` to a creature DISPLAY id. `resolve_aura_params` already default -/// the reading to "misc value IS the display id" — the shape of every real classic mount spell (vanilla -/// stores a `CreatureDisplayInfo` id directly in `EffectMiscValue`). The rarer shape, where the DBC -/// names a mount CREATURE TEMPLATE instead, is resolved here through `creature_displays` (entry → -/// display id, sourced from the imported creature-template presentation data); an entry absent from the -/// map falls through to the direct-display reading unchanged. Non-A_MOUNTED kinds pass p0 through. Pure. -/// [import] fn mount_display_p0(kind: u8, p0: i32, creature_displays: &BTreeMap) -> i32 { if kind != A_MOUNTED { return p0; @@ -465,10 +310,7 @@ fn mount_display_p0(kind: u8, p0: i32, creature_displays: &BTreeMap) - .unwrap_or(p0) } -/// An `A_PROC_DAMAGE` effect's `p0` is the PROC SPELL'S OWN school mask (the header's `school_mask`) -/// — aura 43 carries no per-effect misc value to resolve it from, unlike every other school-typed -/// aura (`A_MOD_RESISTANCE`, `A_ABSORB`). Same kind-keyed p0 override shape as `mount_display_p0` / -/// `stance_p0`; a non-A_PROC_DAMAGE kind's (p0, p0_kind) passes through untouched. Pure. [import] +// Aura 43 takes its school from the proc spell header, not effect misc. fn proc_damage_school_p0(kind: u8, p0: i32, p0_kind: u8, school_mask: u8) -> (i32, u8) { if kind == A_PROC_DAMAGE { (school_mask as i32, P_SCHOOL_MASK) @@ -477,46 +319,30 @@ fn proc_damage_school_p0(kind: u8, p0: i32, p0_kind: u8, school_mask: u8) -> (i3 } } -/// Map a wow_dbc `AuraMod` variant → our KIND (aura-map design unit). Unmapped → `E_SCRIPTED`. The -/// per-effect p0/p0_kind are resolved separately by `resolve_aura_params` (which re-reads the variant -/// for the combat-field / mechanic / speed-kind it implies). fn aura_mod_to_kind(aura: AuraMod) -> u8 { use AuraMod::*; match aura { - // periodic PeriodicDamage | PeriodicLeech | PeriodicDamagePercent => A_PERIODIC_DAMAGE, PeriodicHeal => A_PERIODIC_HEAL, PeriodicEnergize | PeriodicManaLeech | PowerBurnMana => A_PERIODIC_ENERGIZE, - // A channel's per-tick trigger (PeriodicTriggerSpell) fires at cast time -> E_TRIGGER (the - // channel reclassify below turns it into A_PERIODIC_TRIGGER when the header is channeled). - // A Proc's trigger (aura 42 ProcTriggerSpell) and its damage twin (aura 43 ProcTriggerDamage) - // map natively onto the two proc kinds — no name rescue. PeriodicTriggerSpell => E_TRIGGER, ProcTriggerSpell => A_PROC_TRIGGER, ProcTriggerDamage => A_PROC_DAMAGE, - // stat — FLAT (ModStat) vs PERCENT (ModPercentStat / ModTotalStatPercentage). The percent ones fold - // as a multiplier in recompute_vitals (The Human Spirit = +5% Spirit), so they need a distinct kind. - // Spell modifiers: the talent-passive class (Improved Fireball's cast-time cut, - // fire-damage-% talents). Op rides EffectMiscValue -> p0 (P_SPELLMOD_OP); the affected-spell - // family mask rides EffectItemType -> p1. The engine folds them at the cast-time/damage seams. AddFlatModifier => A_SPELLMOD_FLAT, AddPctModifier => A_SPELLMOD_PCT, ModStat => A_MOD_STAT, ModPercentStat | ModTotalStatPercentage => A_MOD_STAT_PCT, - // resistance / armor ModResistance | ModBaseResistance | ModResistancePct | ModBaseResistancePct | ModResistanceOfStatPercent => A_MOD_RESISTANCE, - // absorb SchoolAbsorb | DamageShield | ManaShield => A_ABSORB, - // combat fields (AP / crit / hit / dmg-done / heal-power) ModAttackPower | ModRangedAttackPower | ModMeleeAttackPowerVersus @@ -531,12 +357,8 @@ fn aura_mod_to_kind(aura: AuraMod) -> u8 { A_MOD_COMBAT } ModHealing | ModHealingDone => A_MOD_COMBAT, - // defender avoidance + threat: Evasion (+dodge) and Fade (-threat) reuse the EXISTING - // COMBAT_DODGE / COMBAT_THREAT folds (effective_dodge_bp / threat::add_threat) — pull model, - // no engine code. The field is resolved in resolve_aura_params below. ModDodgePercent | ModTotalThreat | ModThreat => A_MOD_COMBAT, - // speed (move / swing / cast) ModIncreaseSpeed | ModDecreaseSpeed | ModSpeedAlways | ModSpeedNotStack => A_MOD_SPEED, ModIncreaseMountedSpeed | ModMountedSpeedAlways | ModMountedSpeedNotStack => A_MOD_SPEED, ModIncreaseSwimSpeed => A_MOD_SPEED, @@ -546,49 +368,27 @@ fn aura_mod_to_kind(aura: AuraMod) -> u8 { | ModRangedAmmoHaste | ModCastingSpeedNotStack => A_MOD_SPEED, - // incoming-damage modifier (defensive cooldowns like Shield Wall, or vulnerability debuffs) — - // PERCENT only; the FLAT `ModDamageTaken` stays E_SCRIPTED until a flat handler exists. ModDamagePercentTaken => A_MOD_DAMAGE_TAKEN, - // health/power pool ModIncreaseHealth | ModIncreaseEnergy | ObsModHealth | ObsModMana => A_MOD_HEALTH_POWER, - // crowd control ModStun => A_CONTROL, ModRoot => A_CONTROL, ModFear => A_CONTROL, ModConfuse | ModCharm | ModPossess | ModPacify | ModPacifySilence | AoeCharm => A_CONTROL, - // immunity SchoolImmunity | DamageImmunity | EffectImmunity | StateImmunity | MechanicImmunity | MechanicImmunityMask => A_IMMUNITY, - // in-combat health regen percent: X% of normal health regen continues during combat. - // `base_points` carries the percent (10 for Troll Regeneration racial). Reclassified from - // the inert A_FLAG so the regen gate can read the magnitude without a spell-id or race check. ModRegenDuringCombat | ModHealthRegenInCombat => A_COMBAT_HEALTH_REGEN_PCT, - // Demon Skin/Armor's health-per-5: aura 84 SPELL_AURA_MOD_REGEN - // is a COMBAT-INDEPENDENT periodic heal tick (it heals a living target on a fixed period - // whether or not it is in combat) — the same primitive already - // wired for Renew/bandages/food. Reclassified from the inert A_FLAG marker onto A_PERIODIC_HEAL; - // `period_ms` is force-set to 5000 below — the observed vanilla cadence, which overrides - // whatever EffectAmplitude the DBC carries. ModRegen => A_PERIODIC_HEAL, - // Warrior Disarm (67): strips the enemy's main-hand weapon — the swing-range seam drops a disarmed - // PLAYER to unarmed / scales a CREATURE's swing. A generic aura (natural expiry), no p0. ModDisarm => A_DISARM, - // LAND MOUNT (78): the state of record for a player's mounted state — reclassified OUT of the - // inert A_FLAG marker list below so `p0` carries a real resolved display id - // instead of the raw misc value sitting dormant. See `resolve_aura_params`/`mount_display_p0`. Mounted => A_MOUNTED, Transform => A_TRANSFORM, - // Priest Mind Soothe (91): reduces a creature's aggro/detection radius by `amount` yards while active. - // The aggro pass subtracts the summed amount from the creature's aggro radius. A generic aura, no p0. ModDetectRange => A_MOD_DETECT_RANGE, - // passive marker flags (no tick, no dispatch) Dummy | BindSight | ModTaunt @@ -632,18 +432,10 @@ fn aura_mod_to_kind(aura: AuraMod) -> u8 { | AllowChampionSpells | UseNormalMovementSpeed => A_FLAG, - // everything else (no wired handler yet) → graceful no-op _ => E_SCRIPTED, } } -/// Whether a per-effect `trigger_spell` names a castable RANK worth recording in `wrapper_to_rank` -/// (a genuine LearnSpell wrapper), as opposed to a payload that merely rides the same DBC column: a -/// channel's per-tick missile (A_PERIODIC_TRIGGER), either Proc kind's own effect data -/// (A_PROC_TRIGGER / A_PROC_DAMAGE), an inert marker (A_FLAG), or a plain instant trigger (E_TRIGGER, -/// e.g. Bloodrage's rage trickle). Mirrors `resolve_learn_target`'s own exclusion -/// (module/src/trainer.rs) — the two lists MUST stay in lockstep, so a rename or a new proc kind on -/// either side fails this test loud. Pure. [import] fn is_wrapper_rank_trigger(kind: u8) -> bool { !matches!( kind, @@ -651,13 +443,9 @@ fn is_wrapper_rank_trigger(kind: u8) -> bool { ) } -/// Resolve the typed (p0, p0_kind) for an AURA effect from its KIND + AuraMod variant + the raw -/// `effect_misc_value`. The combat-field / mechanic / speed-kind / stat-id semantics come from the -/// VARIANT (not the raw misc value); school masks / power types / immunity ids come from misc value. fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { use AuraMod::*; match kind { - // Flat and percentage modifiers both select the stat from misc. u32::MAX selects all. A_MOD_STAT | A_MOD_STAT_PCT => { let p0 = if misc == u32::MAX { i32::from(STAT_ALL) @@ -666,12 +454,12 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { }; (p0, P_STAT_ID) } - A_SPELLMOD_FLAT | A_SPELLMOD_PCT => (misc as i32, P_SPELLMOD_OP), // the SpellModOp (0=damage, 10=cast time, ...) + // SpellModOp, including 0 for damage and 10 for cast time. + A_SPELLMOD_FLAT | A_SPELLMOD_PCT => (misc as i32, P_SPELLMOD_OP), A_MOD_RESISTANCE => ((misc & 0x7F) as i32, P_SCHOOL_MASK), - // damage-taken: capture the school mask (the runtime is all-school in v1, but keep the data). A_MOD_DAMAGE_TAKEN => ((misc & 0x7F) as i32, P_SCHOOL_MASK), A_ABSORB => match aura { - ManaShield => (0, P_NONE), // absorbs from the power pool, no school + ManaShield => (0, P_NONE), _ => ((misc & 0x7F) as i32, P_SCHOOL_MASK), }, A_MOD_COMBAT => { @@ -686,19 +474,13 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { | ModCritPercentVersus => i32::from(COMBAT_CRIT), ModHitChance | ModSpellHitChance => i32::from(COMBAT_HIT), ModHealing | ModHealingDone => i32::from(COMBAT_SPELL_POWER), - ModDodgePercent => i32::from(COMBAT_DODGE), // Evasion — defender +dodge (scaled to bp below) - // Fade (ModTotalThreat, signed % on ALL threat) + Righteous Fury (ModThreat, +60% → - // 1.6× threat, the paladin tank aura) both fold onto the COMBAT_THREAT field the - // threat::add_threat multiplier already reads — zero engine code. + ModDodgePercent => i32::from(COMBAT_DODGE), ModTotalThreat | ModThreat => i32::from(COMBAT_THREAT), - // remaining A_MOD_COMBAT auras are the attack-power family _ => i32::from(COMBAT_ATTACK_POWER), }; (field, P_COMBAT_FIELD) } A_MOD_SPEED => { - // Run kinds preserve the aura's stacking rule. Mounted amounts use dbc_flat_amount - // like other effects, so DBC 59/99 becomes 60/100. let kind_v = match aura { ModAttackspeed | ModMeleeHaste | ModRangedHaste | ModRangedAmmoHaste => 1, ModCastingSpeedNotStack => 2, @@ -712,11 +494,6 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { }; (kind_v, P_SPEED_KIND) } - // LAND MOUNT (vanilla AuraMod Mounted, 78): p0 defaults to the raw misc value AS the display - // id — the shape of every real classic mount spell (EffectMiscValue names a CreatureDisplayInfo - // id directly). `mount_display_p0` (below) additionally resolves the rarer case where the DBC - // names a mount CREATURE TEMPLATE instead, through the imported creature-template presentation - // data, so the frozen p0 is a display id in every case (the runtime never resolves anything). A_MOUNTED => (misc as i32, P_DISPLAY_ID), A_TRANSFORM => (misc as i32, P_ENTRY), A_PERIODIC_ENERGIZE => ((misc & 0xFF) as i32, P_POWER_TYPE), @@ -725,32 +502,24 @@ fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { ModStun => M_STUN, ModRoot => M_ROOT, ModFear => M_FEAR, - _ => M_POLY, // confuse / charm / possess / pacify(+silence) / aoe-charm + _ => M_POLY, }; (mech, P_MECHANIC) } A_IMMUNITY => match aura { SchoolImmunity | DamageImmunity => ((misc & 0x7F) as i32, P_SCHOOL_MASK), MechanicImmunity | MechanicImmunityMask => (misc as i32, P_MECHANIC), - _ => (misc as i32, P_RAW), // effect/state immunity: opaque id + _ => (misc as i32, P_RAW), }, A_FLAG => ((misc & 0xFF) as i32, P_FLAG), - E_TRIGGER => (0, P_RAW), // periodic/proc trigger: spell id lives in trigger_spell - A_PROC_TRIGGER => (0, P_RAW), // same convention as E_TRIGGER: the trigger spell lives in trigger_spell - // A_PROC_DAMAGE's real p0 is the header's own school_mask, not an effect misc value — the call - // site overrides this placeholder once school_mask is in scope (mirrors the mount_display_p0 / - // stance_p0 kind-keyed p0 fix-ups). + E_TRIGGER => (0, P_RAW), // Trigger spell ids live in trigger_spell, outside p0. + A_PROC_TRIGGER => (0, P_RAW), A_PROC_DAMAGE => (0, P_NONE), E_SCRIPTED => (0, P_RAW), _ => (0, P_NONE), } } -/// Polarity tripwire: an aura-placing effect that REDUCES a stat / resistance / combat field (negative -/// `base_points`) is a DEBUFF, so the spell must resolve onto an ENEMY, not an ally. The AuraMod variant -/// alone is ambiguous — the SAME `ModResistance`/`ModAttackPower`/`ModStat` places both buffs and debuffs; -/// only the sign of the magnitude distinguishes them. Reuses `aura_mod_to_kind` so it tracks the taxonomy -/// rather than duplicating the variant list. (Sunder Armor / Demoralizing Shout depend on this.) fn is_reducing_modifier_aura(effect_id: i32, aura: AuraMod, base_points: i32) -> bool { is_aura_effect(effect_id) && base_points < 0 @@ -760,22 +529,18 @@ fn is_reducing_modifier_aura(effect_id: i32, aura: AuraMod, base_points: i32) -> ) } -/// Resolve the typed (p0, p0_kind) for an INSTANT effect from its KIND + raw `effect_misc_value`. fn resolve_instant_params(kind: u8, misc: u32, item_type: i32) -> (i32, u8) { match kind { E_ENERGIZE => ((misc & 0xFF) as i32, P_POWER_TYPE), - E_DISPEL => ((misc & 0x7F) as i32, P_SCHOOL_MASK), // dispel category (misnomer kept by design) + E_DISPEL => ((misc & 0x7F) as i32, P_SCHOOL_MASK), E_CREATE_ITEM => (item_type, P_ITEM_ENTRY), - // Summon names a creature template in EffectMiscValue. E_SUMMON_PET => (misc as i32, P_ENTRY), E_DUEL => (misc as i32, P_GAMEOBJECT_ENTRY), E_SELF_RESURRECT => (misc as i32, P_FLAT_MANA), - _ => (0, P_NONE), // damage/heal/trigger/taunt school is on the header; no p0 + _ => (0, P_NONE), } } -/// Map unit selections from the pinned vanilla target definitions to Module targets. -/// Location targets use their companion selection in `resolve_effect_target`. fn resolve_target(target: i32, is_negative: bool) -> u8 { match target { 0 => T_SCRIPTED, @@ -802,8 +567,6 @@ fn resolve_target(target: i32, is_negative: bool) -> u8 { } } -// Pinned SpellTargetInfoTable entries whose first target supplies a location. If the companion -// target names a unit selection, that selection decides the Module's unit routing. const LOCATION_TARGET_CODES: [i32; 19] = [ 9, 10, 17, 18, 22, 32, 39, 41, 42, 43, 44, 46, 47, 48, 49, 50, 53, 55, 63, ]; @@ -831,14 +594,11 @@ fn polarity_target(is_negative: bool) -> u8 { } } -/// Resolve an explicit target shape from the Spell header when the effect has no implicit target. -/// Only flags accepted by the source effect type participate. fn compatible_header_target(effect_id: i32, kind: u8, flags: u32, is_negative: bool) -> Option { if kind == E_SCRIPTED { return None; } let accepted = match effect_id { - // Source effects whose required target type is Unit or UnitDest. 1 | 2 | 5 | 6 | 8 | 9 | 10 | 11 | 16 | 17 | 19 | 24 | 29 | 30 | 31 | 34 | 36 | 38 | 40 | 43 | 45 | 46 | 47 | 55 | 57 | 58 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 71 | 75 | 79 | 80 | 82 | 83 | 85 | 92 | 95 | 96 | 98 | 100 | 102 | 103 | 108 | 111 | 114 | 115 | 117 @@ -849,7 +609,6 @@ fn compatible_header_target(effect_id: i32, kind: u8, flags: u32, is_negative: b | TARGET_FLAG_UNIT_ALLY | TARGET_FLAG_UNIT_DEAD) } - // Resurrection effects accept corpse flags rather than unit flags. 18 | 113 | 116 => flags & (TARGET_FLAG_CORPSE_ENEMY | TARGET_FLAG_CORPSE_ALLY), _ => 0, }; @@ -864,49 +623,33 @@ fn compatible_header_target(effect_id: i32, kind: u8, flags: u32, is_negative: b } } -/// Apply the pinned source's effect default after a `(TARGET_NONE, TARGET_NONE)` pair. Unsupported -/// effects retain the runtime fallback instead of acquiring a unit target from a foreign effect. fn supported_effect_default(effect_id: i32, kind: u8, is_negative: bool) -> u8 { if kind == E_SCRIPTED { return T_SCRIPTED; } let default = match effect_id { - 2 => 6, // enemy - 6 | 34 | 46 | 47 | 61 | 79 => 1, // caster - 11 | 18 | 24 | 36 | 44 | 85 | 116 | 118 | 123 => 25, // selected unit - 28 | 50 => 18, // caster destination - 57 => 5, // caster pet - // Source area auras use the caster when neither implicit target names a single unit. + 2 => 6, + 6 | 34 | 46 | 47 | 61 | 79 => 1, + 11 | 18 | 24 | 36 | 44 | 85 | 116 | 118 | 123 => 25, + 28 | 50 => 18, + 57 => 5, 35 | 119 => return T_SELF, _ => 0, }; resolve_target(default, is_negative) } -/// Coverage accounting accumulated across the whole import — drives the COVERAGE REPORT. #[derive(Default)] pub(crate) struct Coverage { spells: usize, effects: usize, - real: usize, // effects mapped to a real (non-E_SCRIPTED) kind - scripted: usize, // effects that fell back to E_SCRIPTED - by_kind: BTreeMap, // histogram of emitted kinds - unmapped_aura: BTreeMap, // top unmapped AuraMod variants - unmapped_effect: BTreeMap, // top unmapped instant SpellEffect ids + real: usize, + scripted: usize, + by_kind: BTreeMap, + unmapped_aura: BTreeMap, + unmapped_effect: BTreeMap, } -/// Derive the `game_spell` + `game_spell_effect` rows from the client DBC chain, plus human-readable -/// diagnostics for the dry-run. Returns (rows, coverage, diagnostics). IN-MEMORY only. -/// -/// The rows are typed rather than pre-spelled SQL because two consumers need them: the reload -/// statements ([`assemble_spell_sql`]) and the Base Snapshot a Datascript reads -/// (`spell_snapshot::write_snapshot`). Deriving both from one row is what keeps them agreeing. -/// -/// `only`: the additive allowlist. When EMPTY, the full wholesale path runs (clear ALL rows + reload -/// every spell). When NON-EMPTY, ONLY those spell ids are emitted and the DELETEs are SURGICAL (per-id), -/// so the import is additive + idempotent — it never touches the curated seed or the synthetic test -/// fixtures. The diagnostics then list EVERY allowlisted spell's full mapping (header + each effect, -/// including E_SCRIPTED no-ops) so the operator can see exactly how each ability resolved. fn build_spell_rows( data_dir: &Path, only: &[u32], @@ -922,29 +665,17 @@ fn derive_spell_rows( ) -> Result<(SpellRows, Coverage, Vec)> { let allow: std::collections::HashSet = only.iter().copied().collect(); let mut acc = SpellAccumulator::default(); - // Mount creature-template display resolution (entry → display id): the `--dbc --spells` CLI path - // is DBC-only (no cmangos creature_template dump is loaded alongside it), so this is empty today — - // every real classic mount spell resolves correctly regardless (see `mount_display_p0`), since its - // EffectMiscValue already names a display id directly. Threading a real creature-entry source into - // this map later is then a one-line change here, not a rewrite of the resolution logic itself. let creature_displays: BTreeMap = BTreeMap::new(); for s in &dbc.spells { let spell_id = s.id; if spell_id == 0 { - continue; // the 0 placeholder + continue; } - // Additive allowlist: when --only was given, emit ONLY those ids (every other spell is skipped - // so the coverage report + emitted rows describe exactly the requested set). if !allow.is_empty() && !allow.contains(&spell_id) { continue; } - // --- reagents (game_spell_reagent) --- - // Spell.dbc carries up to 8 (Reagent, ReagentCount) pairs. Every real recipe's true mats - // live here (and any reagent-consuming buff); the craft gate (module cast.rs) resolves by - // this data instead of a hardcoded id list. Deterministic id (spell_id<<3)|slot → - // idempotent clear+reload. Skip empty slots (reagent 0 or count 0). for (slot, (&item, &count)) in s.reagent.iter().zip(s.reagent_count.iter()).enumerate() { if item <= 0 || count == 0 { continue; @@ -1001,7 +732,6 @@ pub(crate) fn read_catalogue(data_dir: &str) -> Result { Ok(SpellCatalogue { ids, proc_effects }) } -/// The client tables the spell rows derive from, read once per import. struct SpellDbc { spells: Vec, cast_times: SpellCastTimes, @@ -1010,7 +740,6 @@ struct SpellDbc { radii: SpellRadius, } -/// Open the operator's MPQ chain and read every table the spell import needs. fn open_spell_tables(data_dir: &Path) -> Result { let mut chain = open_chain(data_dir)?; eprintln!("spells: opened MPQ chain from {}", data_dir.display()); @@ -1038,7 +767,6 @@ fn open_spell_tables(data_dir: &Path) -> Result { }) } -/// The row sets and diagnostics one DBC sweep accumulates. #[derive(Default)] struct SpellAccumulator { cov: Coverage, @@ -1047,13 +775,10 @@ struct SpellAccumulator { reagent_rows: Vec, eventai_metadata_rows: Vec, samples: Vec, - /// spell_id → its DBC-derived spell_level, for the trainer offerings' required_level. spell_levels: BTreeMap, - /// wrapper spell_id → the trigger RANK it teaches; the rank carries the real level and cost. wrapper_to_rank: BTreeMap, } -/// The header values the effect mapping below reads back off the spell it belongs to. struct SpellHeader { name: String, school_mask: u8, @@ -1061,7 +786,6 @@ struct SpellHeader { channeled: bool, } -/// One effect's resolved kind and magnitudes, after every curated reclassify and p0 fix-up. struct ResolvedEffect { kind: u8, p0: i32, @@ -1072,8 +796,6 @@ struct ResolvedEffect { period_ms: u32, } -/// Derive one spell's `game_spell` header row and push it, returning the values the effect mapping -/// reads back. fn push_spell_header_row( s: &Spell, dbc: &SpellDbc, @@ -1097,21 +819,14 @@ fn push_spell_header_row( .get(SpellCastTimesKey::new(s.casting_time_index)) .map(|r| r.base.max(0) as u32) .unwrap_or(0); - // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown - // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real - // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so - // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. + // Either spell or category recovery can carry the real cooldown. let cooldown_ms = s.recovery_time.max(s.category_recovery_time); let range_yd = dbc .ranges .get(SpellRangeKey::new(s.range)) .map(|r| r.range_max.max(0.0) as u32) .unwrap_or(0); - // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). - // Collapse to u32::MAX as a sentinel; cast.rs converts that to Timestamp(i64::MAX) so the - // reaper's `expires_at <= now` filter never matches (i64::MAX ≈ 9.2e18 µs >> 2026 epoch). - // Any non-negative duration value is taken as-is (milliseconds). A missing DurationIndex - // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). + // DBC duration -1 means infinite; u32::MAX preserves that sentinel. let duration_ms = dbc .durations .get(SpellDurationKey::new(s.duration)) @@ -1123,9 +838,7 @@ fn push_spell_header_row( } }) .unwrap_or(0); - // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, - // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1< bool { - // A self-only penalty belongs to its buff, such as Stealth's movement penalty. let mut is_negative = false; for i in 0..3 { let eff = s.effect[i]; @@ -1238,7 +942,6 @@ fn spell_is_negative(s: &Spell) -> bool { continue; } let aura = AuraMod::from_int(s.effect_aura[i]).unwrap_or_default(); - // damage / DoT / CC / a debuff-shaped aura on a non-self target ⇒ negative if matches!(eff, 2 | 17 | 58 | 121 | 31) { is_negative = true; } @@ -1256,11 +959,6 @@ fn spell_is_negative(s: &Spell) -> bool { ) { is_negative = true; } - // A modifier aura that REDUCES a stat / resistance / combat field (negative base points) is a - // DEBUFF — e.g. Sunder Armor (−armor via ModResistance), Demoralizing Shout (−AP via - // ModAttackPower). The variant alone can't tell buff from debuff (the SAME AuraMod does both; - // the sign of the magnitude decides), so the earlier variant-only list misses these and they'd - // wrongly resolve onto an ALLY (see `is_reducing_modifier_aura`). if is_reducing_modifier_aura(eff, aura, s.effect_base_points[i]) { is_negative = true; } @@ -1268,7 +966,6 @@ fn spell_is_negative(s: &Spell) -> bool { is_negative } -/// Derive effect rows and the curated seal marker from one normalized spell. fn push_spell_effect_rows( s: &Spell, header: &SpellHeader, @@ -1286,18 +983,12 @@ fn push_spell_effect_rows( } = acc; let spell_id = s.id; let name = header.name.as_str(); - // --- effects (game_spell_effect) --- - // The curated seal marker takes the first unused slot within the packed 2-bit index. let mut used_slots = [false; 4]; - // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four - // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND - // is packed into the deterministic row id `(spell_id << 2) | index`. Iterating one of those - // arrays instead (what `clippy::needless_range_loop` asks for) would hide that. #[allow(clippy::needless_range_loop)] for i in 0..3 { let effect_id = s.effect[i]; if effect_id == 0 { - continue; // sparse effect slots are common + continue; } used_slots[i] = true; let effect_index = i as u8; @@ -1321,7 +1012,6 @@ fn push_spell_effect_rows( None => (kind, p0, p0_kind), }; let target = resolve_effect_target(s, i, kind, header); - // Evocation's regen-percent aura needs the Module's periodic max-mana effect. let (kind, period_ms, base_points, target, p0, p0_kind) = if name == "Evocation" && effect_index == 0 { ( @@ -1345,12 +1035,6 @@ fn push_spell_effect_rows( }; let chain_targets = s.effect_chain_target[i].clamp(0, 255) as u8; let trigger_spell = s.effect_trigger_spell[i]; - // Record wrapper -> rank: a LearnSpell wrapper's first nonzero trigger is the castable rank it - // teaches (resolves the wrapper's trainer-offering level/cost to the rank, below). - // `is_wrapper_rank_trigger` excludes every OTHER kind that rides the same `trigger_spell` - // column for a non-rank payload (a channel's missile, a Proc's trigger, a plain instant - // trigger) — see its own doc comment. This list must stay in lockstep with - // `resolve_learn_target` (module/src/trainer.rs). if trigger_spell != 0 && is_wrapper_rank_trigger(kind) { wrapper_to_rank.entry(spell_id).or_insert(trigger_spell); } @@ -1360,16 +1044,11 @@ fn push_spell_effect_rows( } else if kind == E_POWER_BURN { power_burn_ratio_bp(s.effect_multiple_values[i]) } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { - // 264: the affected-spell FAMILY MASK (DBC EffectItemType) — matched at fold time - // against the cast header's family_flags. s.effect_item_type[i] } else { power_word_shield_p1_override(spell_id, name, kind, 0i32) }; let script_id = 0u32; - // Data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle - // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not - // a spell id. Any energize can opt in by adding the name. let enters_combat = name == "Bloodrage"; effect_rows.push(SpellEffectRow { @@ -1401,14 +1080,11 @@ fn push_spell_effect_rows( } if !allow.is_empty() { - // Allowlist mode: log EVERY effect (incl. E_SCRIPTED no-ops) so an unmapped ability is - // visible, not silently dropped — the operator decides whether it's usable as-is. samples.push(format!( " eff{effect_index}: {kn} (0x{kind:02X}) base={base_points} die={die_sides} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind} trigger={trigger_spell}", kn = kind_name(kind), )); } else if kind != E_SCRIPTED && samples.len() < 8 { - // Full-import mode: keep a few human-readable samples for the dry-run (first 8 REAL effects). samples.push(format!( " spell {spell_id} '{}' eff{effect_index}: kind=0x{kind:02X} base={base_points} period_ms={period_ms} target={target} p0={p0} p0_kind={p0_kind}", name.chars().take(28).collect::(), @@ -1416,7 +1092,6 @@ fn push_spell_effect_rows( } } - // The curated seal marker uses the first slot after the client's populated effects. let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; if let Some(synth) = synthetic_seal_effect(spell_id, name, synth_slot) { cov.effects += 1; @@ -1429,8 +1104,6 @@ fn push_spell_effect_rows( } } -/// Resolve one DBC effect slot to our kind + magnitudes: the aura/instant mapping, then every -/// curated reclassify and p0 fix-up that rides on it. fn resolve_effect_kind( s: &Spell, i: usize, @@ -1452,7 +1125,6 @@ fn resolve_effect_kind( } (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) } else { - // TeleportUnits to the caster home bind uses the Character's bound home. let k = if effect_id == 5 && (s.implicit_target_a[i] == 9 || s.implicit_target_b[i] == 9) { E_RECALL_HOME } else { @@ -1466,49 +1138,26 @@ fn resolve_effect_kind( resolve_instant_params(k, s.effect_misc_value[i], s.effect_item_type[i]), ) }; - // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. let kind = if name == "Stealth" && aura != AuraMod::ModStealth { kind } else { correct_script_effect_kind(name, kind) }; - // Every build 5875 Gouge rank uses the incapacitate family, although its client aura is ModStun. let p0 = if GOUGE_RANK_IDS.contains(&s.id) && name == "Gouge" && kind == A_CONTROL { M_POLY } else { p0 }; - // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose - // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not - // the resolved kind), so it fires regardless of what instant_effect_to_kind mapped 108 to. let kind = dismount_effect_kind(effect_id, s.effect_misc_value[i]).unwrap_or(kind); - // STANCE p0 remap: a reclassified E_SET_STANCE effect arrives with p0 = the vanilla form id - // (from the ModShapeshift misc value). Remap it to our 0-based stance id via `form_to_stance` - // (Battle 17→0 / Defensive 18→1 / Berserker 19→2 / Bear 5→3 / Cat 1→4 / Dire Bear 8→5) so the - // cast handler writes the right WorldEntity.stance. Mirrors the COMBAT_DODGE ×100 base-point - // rescale below — a kind-keyed p0 fix-up after the reclassify, never a spell id. Non-stance - // effects keep their resolved p0. let p0 = stance_p0(kind, p0); - // MOUNT DISPLAY resolution: an A_MOUNTED effect's p0 already defaults to the raw misc value - // (resolve_aura_params) — resolve the rarer creature-template indirection here, mirroring - // the stance/CREATE_ITEM kind-keyed p0 fix-ups above. let p0 = mount_display_p0(kind, p0, creature_displays); - // PROC_DAMAGE school: an A_PROC_DAMAGE effect deals its frozen amount as damage of the - // proc spell's own school — mirrors the mount/stance p0 fix-ups above. let (p0, p0_kind) = proc_damage_school_p0(kind, p0, p0_kind, school_mask); - // CREATE_ITEM p0 injection (Healthstone): the by-name reclassify above made these E_CREATE_ITEM, - // but their DBC effect_item_type is 0 (the mangos script hardcodes the item), so the resolved p0 - // is 0 (and p0_kind is P_NONE, since the kind was E_SCRIPTED when resolve_instant_params ran). - // Inject the per-rank Healthstone item template entry + P_ITEM_ENTRY here — the same kind-keyed, - // name-keyed p0 fix-up as the E_SET_STANCE remap above, never disturbing a real effect_item_type - // p0 (the arm only fires for these names, whose native p0 is already 0). count=1 rides base_points. - // The module's E_CREATE_ITEM handler reads only p0; the p0_kind set is for data parity with - // natively-mapped Conjure Water/Food (which carry P_ITEM_ENTRY). + // Healthstone script effects omit effect_item_type; supply their item entries here. let (p0, p0_kind) = if kind == E_CREATE_ITEM { let item = match name { "Create Healthstone (Minor)" => Some(5512), @@ -1516,7 +1165,7 @@ fn resolve_effect_kind( "Create Healthstone" => Some(5509), "Create Healthstone (Greater)" => Some(5510), "Create Healthstone (Major)" => Some(9421), - _ => None, // a native CreateItem (Conjure Water/Food) keeps its effect_item_type (p0, p0_kind) + _ => None, }; match item { Some(entry) => (entry, P_ITEM_ENTRY), @@ -1532,10 +1181,8 @@ fn resolve_effect_kind( (p0, p0_kind) }; - let base_points = dbc_flat_amount(s.effect_base_points[i]); // DBC +1 convention (mounted-speed 59/99 → 60/100 falls out of this for free) - // Avoidance-chance combat fields are basis-points in our engine but PERCENT in the DBC, so - // scale ×100 (Evasion's +50% dodge → +5000 bp). COMBAT_THREAT is a signed percent in both - // (the threat fold divides by 100), so it is NOT scaled. + let base_points = dbc_flat_amount(s.effect_base_points[i]); + // DBC dodge modifiers use percent; durable combat values use basis points. let base_points = if kind == A_MOD_COMBAT && p0 == i32::from(COMBAT_DODGE) { base_points * 100 } else { @@ -1544,32 +1191,17 @@ fn resolve_effect_kind( let die_sides = s.effect_die_sides[i]; let per_level = s.effect_real_points_per_level[i]; let period_ms = s.effect_amplitude[i]; - // Vanilla ModRegen ticks every five seconds even when its amplitude is zero. + // Vanilla ModRegen ticks every five seconds even when amplitude is zero. let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { 5000 } else { period_ms }; - // CHANNEL reclassify: on a channeled spell, the periodic-trigger effect (a PeriodicTriggerSpell / - // raw TriggerSpell mapped to E_TRIGGER, with a tick period) IS the channel tick — reclassify it to - // A_PERIODIC_TRIGGER so `tick_auras` fires its `trigger_spell` (the missile) each period at the - // channel target. Gated on the channeled header bit + a real period, so a non-channel periodic - // trigger (a proc) is untouched. Keyed on the kind + period, never a spell id; the channel target - // + the trigger spell id are frozen onto the aura at cast (aura_apply). [import] let kind = if channeled && kind == E_TRIGGER && period_ms > 0 { A_PERIODIC_TRIGGER } else { kind }; - // GROUND-AoE: a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit - // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — - // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA - // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own - // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike - // is the first CLICKED-GROUND one: the ground-target dest plumbing anchors the area at - // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. - // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is - // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). let kind = if kind == A_PERIODIC_DAMAGE && matches!(name, "Consecration" | "Flamestrike") { E_PERSISTENT_AREA } else { @@ -1586,7 +1218,6 @@ fn resolve_effect_kind( } } -/// Resolve the DBC target pair, then apply the existing caster and ground-handler conventions. fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> u8 { let name = header.name.as_str(); let is_negative = header.is_negative; @@ -1600,7 +1231,6 @@ fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> let selection = unit_selection_from_pair(target_a, target_b); resolve_target(selection, is_negative) }; - // These handlers operate on the caster or a ground location, regardless of unit selection. let target = match kind { E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT | E_TAME_CREATURE => T_TARGET_ENEMY, @@ -1610,26 +1240,16 @@ fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> E_DUEL => T_TARGET_ANY, _ => target, }; - // Slice and Dice's selected-unit marker permits spending combo points on the enemy. let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { T_TARGET_ENEMY } else { target }; - // Flamestrike's impact uses the clicked ground location supplied with the cast. let target = if name == "Flamestrike" && kind == E_DAMAGE { T_AREA_ENEMY } else { target }; - // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker - // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an - // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same - // trap fixed for Slice and Dice / Thunder Clap). Force the marker to T_SELF: it's a caster-side - // flag, so self-targeting it both lands it correctly AND removes it from the faction gate's - // hits_ally/hits_enemy scan (a self-cast effect imposes no faction constraint). Generic over the - // channeled bit + the A_FLAG kind, never a spell id; the channel's A_PERIODIC_TRIGGER tick effect - // is already T_SELF, so only the stray marker is corrected. if channeled && kind == A_FLAG { T_SELF } else { @@ -1637,55 +1257,30 @@ fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> } } -/// The `game_trainer_spell` rows for each `--trainer =` binding. fn trainer_offering_rows( trainers: &[(u32, Vec)], spell_levels: &BTreeMap, wrapper_to_rank: &BTreeMap, ) -> Vec { - // --- trainer offerings (game_trainer_spell) — FIREWALL-CLEAN --- - // For each `--trainer =` binding, emit one row per offered spell. required_level comes - // straight from the DBC `spell_level` captured above; cost from our own level-keyed formula - // (trainer_cost) — NO cmangos npc_trainer value is touched. A wrapper whose header we just imported - // (so its trigger rank is also imported) resolves to a real castable rank at buy time. The `#[auto_inc]` - // PK lets us emit 0 for the id (SpacetimeDB assigns it); the logical key is (trainer_entry, spell_id). let mut trainer_rows: Vec = Vec::new(); let mut trainer_missing: Vec = Vec::new(); for (entry, ids) in trainers { - // The IDS_* lists carry BOTH a LearnSpell wrapper AND its rank; offer each spell ONCE. Drop any - // offered id that is the trigger-rank of another offered wrapper (it is taught via that wrapper) — - // so the trainer list shows one row per learnable spell, not the wrapper + rank duplicate. let offered_triggers: std::collections::HashSet = ids .iter() .filter_map(|id| wrapper_to_rank.get(id).copied()) .collect(); for &spell_id in ids { if offered_triggers.contains(&spell_id) { - continue; // taught via its wrapper (also offered) — skip the bare rank + continue; } - // required_level + cost come from the RANK the player actually learns — a LearnSpell wrapper's - // own spell_level is 0 (the level lives on its trigger rank), so without this the trainer - // level-gate would pass at level 1 for a level-N spell. Resolve wrapper -> rank first; the - // OFFERING id stays the wrapper (the buy path resolves it to the rank), only level/cost use the rank. + // LearnSpell wrappers have level zero; use the taught rank for price and level. let rank = wrapper_to_rank.get(&spell_id).copied().unwrap_or(spell_id); match spell_levels.get(&rank) { Some(&lvl) => { let cost = trainer_cost(lvl); - // id=0 → the table's #[auto_inc] PK assigns the real id on insert. END-append - // learn_skill_line=0, learn_skill_cap=75: every - // class-spell offering is a normal spell row (the unchanged spell path); profession - // offerings are parent-SQL-seeded. The cap is unused on a line-0 row but must be NAMED - // on the INSERT; 75 matches the module's #[default(75u32)]. - // 259: EXPLICIT reserved ids (not the auto_inc 0 sentinel). Two reasons: the - // --dump ETL writes trainer rows with explicit ids WITHOUT advancing the - // sequence (an id-0 insert could collide — the errno-12 class, danger-zones §2), - // and fixed ids let the reload below delete ONLY this pass's own rows instead of - // wiping the dump-imported class trees wholesale. let id = CURATED_TRAINER_ID_BASE + trainer_rows.len() as u64; trainer_rows.push(format!("({id},{entry},{spell_id},{cost},{lvl},0,75)")); } - // An offered id with no imported header would emit required_level 0 / charge-but-uncastable, - // so SKIP it and surface it — the operator either adds it to the import or drops the offering. None => trainer_missing.push(spell_id), } } @@ -1703,11 +1298,6 @@ fn trainer_offering_rows( trainer_rows } -/// Fishing: the E_FISH marker effect rows for the three tier ids (skill 356 — 7620/7731/7732, -/// PROFESSION_LEARN's fishing entry). The gateway routes CMSG_CAST_SPELL to the `fish` reducer by -/// this KIND (the enchant-route pattern — never a spell-id list). These are OUR OWN taxonomy rows -/// (not client data, firewall-clean), written by key AFTER the bulk insert so they replace whatever -/// the DBC mapped at the same effect slot. fn fishing_marker_rows() -> Vec { [7620u32, 7731, 7732] .into_iter() @@ -1719,13 +1309,6 @@ fn fishing_marker_rows() -> Vec { .collect() } -/// The (clear+)reload statements for one derived pass (both tables have NO Timestamp column → plain -/// SQL). Pure: the same rows always assemble the same statements, so this is testable without a -/// client. -/// -/// `curated_trainers_requested` is the operator's `--trainer` intent, not the row count: an -/// offering list that resolved to nothing still has to clear the reserved span, or a shrunken list -/// leaves stale rows behind. fn assemble_spell_sql( rows: &SpellRows, only: &[u32], @@ -1744,8 +1327,6 @@ fn assemble_spell_sql( "id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat", &rows.effects.iter().map(SpellEffectRow::sql_values).collect::>(), ); - // Reagents: same clear-guard as the spell/effect tables (spell_delete_statements only - // wipes < SYNTHETIC_SPELL_ID_FLOOR, so hand/test-fixture reagents survive a curated reload). push_insert( &mut stmts, "game_spell_reagent", @@ -1759,7 +1340,6 @@ fn assemble_spell_sql( &rows.eventai_metadata, ); - // Deterministic-id delete+insert = idempotent on every curated re-run. for row in &rows.fishing { stmts.push(format!( "DELETE FROM game_spell_effect WHERE id = {}", @@ -1771,14 +1351,6 @@ fn assemble_spell_sql( )); } - // Trainer offerings: the --dump ETL's npc_trainer import is now the PRIMARY - // source of class offerings (full per-class trees, the dump's real costs/reqlevels — an - // operator-local.import read like every other --dump field); this curated pass is the - // OVERRIDE layer for the "specials" cmangos delivers outside npc_trainer (Consecration, - // Flamestrike, Kick, stances, …). It therefore reloads ONLY ITS OWN reserved-id rows - // (delete-by-id over the reserved span) and never touches the dump rows or the profession - // learn-rows. The old wholesale `DELETE WHERE learn_skill_line = 0` wiped the dump's ~4200 - // class rows and replaced them with the curated handful — exactly what 259 removes. if curated_trainers_requested { for n in 0..CURATED_TRAINER_ID_SPAN { stmts.push(format!( @@ -1797,7 +1369,6 @@ fn assemble_spell_sql( stmts } -/// The (clear+)reload SQL plus the dry-run diagnostics: the shape every existing caller expects. pub(crate) fn build_spell_sql( data_dir: &Path, only: &[u32], @@ -1808,25 +1379,16 @@ pub(crate) fn build_spell_sql( Ok((sql, cov, samples)) } -/// Reserved `game_trainer_spell` id range for the CURATED override rows: far above the --dump -/// ETL's explicit ids (~4300) and disjoint from every other reserved range. The reload deletes this -/// whole span by id, so a shrunken override list leaves no stale rows behind. const CURATED_TRAINER_ID_BASE: u64 = 5_200_000; const CURATED_TRAINER_ID_SPAN: u64 = 500; const SYNTHETIC_SPELL_ID_FLOOR: u32 = 50_000; -/// The DELETE half of the (clear+)reload, split out so the surgical-vs-wholesale choice is testable. -/// EMPTY `only` → the WHOLESALE clear (full import, replaces every row). NON-EMPTY → a SURGICAL per-id -/// DELETE so the import is ADDITIVE + idempotent and never touches the curated seed / test fixtures. -/// Per-id `WHERE x = N` (sorted, deterministic) avoids relying on SQL `IN(..)` support and keeps each -/// statement planner-friendly; the allowlist is always a small curated kit. fn spell_delete_statements(only: &[u32]) -> Vec { if only.is_empty() { return vec![ format!("DELETE FROM game_spell WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), format!("DELETE FROM game_spell_effect WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), - // Reagents share the fixture-floor guard so hand/test reagents survive a reload. format!("DELETE FROM game_spell_reagent WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), format!("DELETE FROM game_creature_ai_spell_metadata WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), ]; @@ -1850,17 +1412,10 @@ fn spell_delete_statements(only: &[u32]) -> Vec { dels } -/// Trainer-offering COST in copper, derived PURELY from the spell's DBC `required_level` — NO cmangos -/// npc_trainer value is read (the firewall-clean replacement for the cmangos cost column). 50 copper per -/// required level keeps the early Elwynn 1–10 abilities near-free (a level-1 ability costs 50c = half a -/// silver; a level-10 rank costs 5 silver), monotone-rising and the right rough shape for vanilla. One -/// line to tune. `required_level` 0 (the DBC "trainable at the trainer's floor" ranks) clamps to 1 so -/// nothing is free. fn trainer_cost(required_level: u8) -> u32 { (required_level.max(1) as u32) * 50 } -/// Human-readable name of a KIND tag (for the coverage histogram). fn kind_name(kind: u8) -> &'static str { match kind { E_DAMAGE => "E_DAMAGE", @@ -1916,8 +1471,6 @@ fn kind_name(kind: u8) -> &'static str { } } -/// Print the COVERAGE REPORT — the key deliverable: how many effect rows mapped to a REAL kind vs the -/// `E_SCRIPTED` no-op, the % real coverage, the kind histogram, and the top unmapped aura/effect types. fn print_coverage(cov: &Coverage) { let pct = |n: usize, d: usize| { if d == 0 { @@ -1983,13 +1536,6 @@ fn print_coverage(cov: &Coverage) { println!("======================================\n"); } -/// `--dbc --spell-snapshot ` mode: write the Base Snapshot a Datascript reads, and -/// touch no database. -/// -/// The snapshot is the SAME derived rows the import would load, so what an author sees is what a -/// shard gets. It holds no client bytes — only `game_*` values the taxonomy mapping produced — and -/// it goes exactly where the operator points it. The author-facing target, -/// `datascripts/generated/base-snapshot.json`, is git-ignored. pub fn run_spell_snapshot(data_dir: &str, path: &str) -> Result<()> { let (rows, cov, _) = build_spell_rows(Path::new(data_dir), &[], &[])?; let json = write_snapshot(&rows); @@ -2011,13 +1557,9 @@ pub fn run_spell_snapshot(data_dir: &str, path: &str) -> Result<()> { Ok(()) } -/// `--dbc --spells` mode: import Spell.dbc → game_spell/game_spell_effect. Dry-run by -/// default (parse + map + print coverage + sample rows, write NOTHING); `--apply` runs the SQL. pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { let (stmts, cov, samples) = build_spell_sql(Path::new(data_dir), &args.only, &args.trainers)?; - // The additive allowlist must resolve every id it was asked for — a typo'd / non-vanilla id would - // silently import nothing. Fail loudly so the operator fixes the kit rather than shipping a gap. if !args.only.is_empty() && cov.spells != args.only.len() { eprintln!( "spells: WARNING — --only requested {} ids but matched {} in Spell.dbc (some id not found).", @@ -2048,18 +1590,9 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { " [additive allowlist]" }, ); - // Provenance stamp convention, mirrored from the --dump loop's per-family - // stamp_family calls in main.rs::run_dump. file_hash is "" here: unlike the --dump path - // (which hashes the whole SQL dump's bytes in one shot), the DBC chain is read row-by-row - // through `read_table`'s parsed-struct API (dbc.rs) with no single raw byte buffer to hash - // cheaply — threading a chain-wide hash would mean re-reading every source MPQ a second time - // just for provenance. Left "" rather than adding that cost; row_count (cov.spells) still - // gives a verifiable count against this run. stamp_family(args, "spell", &args.source_sha, "", cov.spells as u64) .context("stamp_import_meta(spell)")?; } else if !args.only.is_empty() { - // Allowlist dry-run: the diagnostics ARE the deliverable — print every requested spell's full - // resolved mapping (header + per-effect lines) so the kit can be vetted before --apply. println!( "-- DRY RUN (additive allowlist, {} ids): {} surgical SQL statements, write NOTHING.\n", args.only.len(), @@ -2074,8 +1607,6 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { "-- DRY RUN: {} SQL statements (clear+reload game_spell + game_spell_effect), write NOTHING.", stmts.len() ); - // Show the two DELETEs + a truncated head of the first INSERT of each table (the full rows - // would flood the terminal — the coverage report is the real signal). for s in stmts.iter().take(2) { println!("{s};"); } @@ -2100,21 +1631,15 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { eprintln!("spells: dry-run — re-run with --apply to load."); } - // The Package Delta stage, last: the base rows are back and stamped, so every enabled Package's - // claims go on top of them. It runs on the dry-run path too — that IS the read-only check, and - // it must print the same plan an --apply would send. match &args.packages { Some(root) => package_delta::reapply(args, "spell", root)?, None => package_delta::warn_not_reapplied("spell"), } - // Trainer-offering summary (per --trainer entry): how many game_trainer_spell rows this run emits for - // each trainer. Printed on BOTH apply + dry-run so the operator confirms every class got offerings. if !args.trainers.is_empty() { println!("\n=== TRAINER OFFERINGS (game_trainer_spell) ==="); let mut total = 0usize; for (entry, _ids) in &args.trainers { - // Count the row tuples `(0,{entry},…)` across the emitted INSERT statements for this entry. let needle = format!("(0,{entry},"); let n: usize = stmts .iter() @@ -2146,7 +1671,6 @@ mod tests { } bytes.resize(712, 0); bytes.extend_from_slice(b"\0Fixture\0"); - // Authored build 5875 columns, independent of the dependency's named fields. for (column, value) in [ (0, 509_0003), (1, 4), @@ -2497,7 +2021,6 @@ mod tests { shard.assert_sql("DELETE FROM game_melee_schedule"); shard.assert_call("debug_spawn_player_entity", &["1"]); shard.assert_call("debug_set_level", &["1", "10"]); - // The friendly trainer and hostile wolf are this disposable Module's seeded fixtures. let ally = ((0xf130u64 << 48) | (51001u64 << 24) | 1).to_string(); let wolf = ((0xf130u64 << 48) | (51000u64 << 24) | 1).to_string(); for guid in ["1", &ally, &wolf] { @@ -2787,7 +2310,6 @@ mod tests { #[test] fn evasion_and_fade_map_to_combat_fields() { use AuraMod::*; - // Pull-model reuse: dodge/threat ride the EXISTING A_MOD_COMBAT(COMBAT_DODGE / COMBAT_THREAT) folds. assert_eq!(aura_mod_to_kind(ModDodgePercent), A_MOD_COMBAT); assert_eq!(aura_mod_to_kind(ModTotalThreat), A_MOD_COMBAT); assert_eq!( @@ -2802,27 +2324,21 @@ mod tests { #[test] fn curated_correction_reclassifies_script_effects_by_name() { - // The Spell.sql-analog correction only touches effects that fell to E_SCRIPTED, keyed by name. assert_eq!(correct_script_effect_kind("Holy Light", E_SCRIPTED), E_HEAL); assert_eq!( correct_script_effect_kind("Life Tap", E_SCRIPTED), E_CONVERT_RESOURCE ); assert_eq!(correct_script_effect_kind("Charge", E_SCRIPTED), E_CHARGE); - // Feint: vanilla encodes it as the native Threat effect (→ E_TAUNT) with a - // negative base; reclassify that E_TAUNT to E_REDUCE_THREAT (a one-time current-threat drop). assert_eq!( correct_script_effect_kind("Feint", E_TAUNT), E_REDUCE_THREAT ); - // A non-Feint E_TAUNT (a real taunt) is NOT disturbed. assert_eq!(correct_script_effect_kind("Growl", E_TAUNT), E_TAUNT); - // Garrote's eff2 combo Dummy reclassifies to the combo GENERATOR (its eff1 bleed is unchanged). assert_eq!( correct_script_effect_kind("Garrote", E_SCRIPTED), E_ADD_COMBO ); - // The other rogue combo GENERATORS reclassify the same way (Sinister Strike/Backstab/Gouge). assert_eq!( correct_script_effect_kind("Sinister Strike", E_SCRIPTED), E_ADD_COMBO @@ -2832,20 +2348,16 @@ mod tests { E_ADD_COMBO ); assert_eq!(correct_script_effect_kind("Gouge", E_SCRIPTED), E_ADD_COMBO); - // Pick Pocket's script effect grants creature copper without engaging combat. assert_eq!( correct_script_effect_kind("Pick Pocket", E_SCRIPTED), E_PICKPOCKET ); - // A correctly-mapped effect is never disturbed (even for a corrected name). assert_eq!(correct_script_effect_kind("Holy Light", E_HEAL), E_HEAL); assert_eq!(correct_script_effect_kind("Frostbolt", E_DAMAGE), E_DAMAGE); - // An unknown script effect stays the graceful no-op. assert_eq!( correct_script_effect_kind("Some Other Spell", E_SCRIPTED), E_SCRIPTED ); - // The three Warrior stance switches reclassify the inert ModShapeshift→A_FLAG marker to E_SET_STANCE. assert_eq!( correct_script_effect_kind("Battle Stance", A_FLAG), E_SET_STANCE @@ -2858,8 +2370,6 @@ mod tests { correct_script_effect_kind("Berserker Stance", A_FLAG), E_SET_STANCE ); - // Create Healthstone (all ranks): SCRIPT_EFFECT (E_SCRIPTED) reclassifies to E_CREATE_ITEM by name - // (the item entry is injected at the call site from effect_item_type-less data). assert_eq!( correct_script_effect_kind("Create Healthstone (Minor)", E_SCRIPTED), E_CREATE_ITEM @@ -2876,36 +2386,31 @@ mod tests { #[test] fn eviscerate_damage_reclassifies_to_finisher_damage() { - // Eviscerate's real E_DAMAGE effect scales with combo points — reclassify it to the FINISHER - // kind so the handler reads combo points at cast time instead of a flat weapon-damage effect. assert_eq!( correct_script_effect_kind("Eviscerate", E_DAMAGE), E_FINISHER_DAMAGE ); - // A different spell's E_DAMAGE effect is never disturbed (the arm is keyed on the exact name). assert_eq!(correct_script_effect_kind("Frostbolt", E_DAMAGE), E_DAMAGE); } #[test] fn rez_name_rescue_covers_every_class_resurrect_and_guards_others() { - // Every class's combat-rez shares the raw Resurrect effect, which falls to the graceful-success - // E_SCRIPTED no-op without this by-name rescue — reviving nobody while reporting cast success. assert_eq!( correct_script_effect_kind("Resurrection", E_SCRIPTED), E_RESURRECT - ); // Priest + ); assert_eq!( correct_script_effect_kind("Redemption", E_SCRIPTED), E_RESURRECT - ); // Paladin + ); assert_eq!( correct_script_effect_kind("Rebirth", E_SCRIPTED), E_RESURRECT - ); // Druid + ); assert_eq!( correct_script_effect_kind("Ancestral Spirit", E_SCRIPTED), E_RESURRECT - ); // Shaman + ); assert_eq!( correct_script_effect_kind("Reincarnation", E_SCRIPTED), @@ -2913,9 +2418,6 @@ mod tests { ); } - /// ClassicDB 1.12.1 `spell_template`: each Soulstone Resurrection aura rank (Dummy aura, friendly - /// target) and the self-resurrect spell it grants (raw effect 94, raw base = -(health + 1), misc = - /// mana). const SOULSTONE_RANKS: [(u32, u32, i32, u32); 5] = [ (20707, 3026, -401, 700), (20762, 20758, -751, 1200), @@ -3013,27 +2515,18 @@ mod tests { #[test] fn stance_p0_remaps_form_id_to_zero_based_stance() { - // The effect-loop remap turns the vanilla form id (carried in p0 by the A_FLAG marker) into our - // 0-based stance id via form_to_stance. The handler reads this p0. - assert_eq!(stance_p0(E_SET_STANCE, 17), 0); // Battle Stance → stance 0 - assert_eq!(stance_p0(E_SET_STANCE, 18), 1); // Defensive Stance → stance 1 - assert_eq!(stance_p0(E_SET_STANCE, 19), 2); // Berserker Stance → stance 2 - // Druid combat forms: Bear Form 5487 carries ModShapeshift(5), Cat Form 768 - // carries form 1, Dire Bear Form 9634 carries form 8. - assert_eq!(stance_p0(E_SET_STANCE, 5), 3); // Bear Form → stance 3 - assert_eq!(stance_p0(E_SET_STANCE, 1), 4); // Cat Form → stance 4 - assert_eq!(stance_p0(E_SET_STANCE, 8), 5); // Dire Bear Form → stance 5 - // A non-stance kind's p0 passes through untouched (the remap only fires for E_SET_STANCE). + assert_eq!(stance_p0(E_SET_STANCE, 17), 0); + assert_eq!(stance_p0(E_SET_STANCE, 18), 1); + assert_eq!(stance_p0(E_SET_STANCE, 19), 2); + assert_eq!(stance_p0(E_SET_STANCE, 5), 3); + assert_eq!(stance_p0(E_SET_STANCE, 1), 4); + assert_eq!(stance_p0(E_SET_STANCE, 8), 5); assert_eq!(stance_p0(E_DAMAGE, 17), 17); - // An unmapped form under E_SET_STANCE passes through (unreachable by construction — the name - // rescue only names mapped forms; a raw form id like Aquatic 4 is inert module-side). assert_eq!(stance_p0(E_SET_STANCE, 4), 4); } #[test] fn druid_form_switches_name_rescue_to_set_stance() { - // The Druid combat-form switches carry the same inert ModShapeshift→A_FLAG marker as the Warrior - // stances and reclassify to E_SET_STANCE by name. assert_eq!( correct_script_effect_kind("Bear Form", A_FLAG), E_SET_STANCE @@ -3043,7 +2536,6 @@ mod tests { E_SET_STANCE ); assert_eq!(correct_script_effect_kind("Cat Form", A_FLAG), E_SET_STANCE); - // Non-combat forms are NOT rescued — their marker stays the inert A_FLAG. assert_eq!(correct_script_effect_kind("Aquatic Form", A_FLAG), A_FLAG); assert_eq!(correct_script_effect_kind("Travel Form", A_FLAG), A_FLAG); assert_eq!(correct_script_effect_kind("Moonkin Form", A_FLAG), A_FLAG); @@ -3051,26 +2543,15 @@ mod tests { #[test] fn translate_stance_mask_folds_vanilla_form_bits_to_zero_based() { - // 0 mask (the stance switches + every unrestricted spell) → 0 (usable in any stance). assert_eq!(translate_stance_mask(0), 0); - // Charge/Thunder Clap/Overpower: vanilla bit16 (Battle) → our bit0 (0x01). assert_eq!(translate_stance_mask(0x10000), 0x01); - // Rend: vanilla bits16,17 (Battle/Defensive) → our bits0,1 (0x03). assert_eq!(translate_stance_mask(0x30000), 0x03); - // Hamstring: vanilla bits16,18 (Battle/Berserker) → our bits0,2 (0x05). assert_eq!(translate_stance_mask(0x50000), 0x05); - // Berserker-only would be vanilla bit18 → our bit2 (0x04). assert_eq!(translate_stance_mask(0x40000), 0x04); - // Druid combat forms survive translation (previously dropped): - // Maul/Growl-shaped Bear|DireBear masks: vanilla bits4,7 (0x90) → our bits3,5 (0x28). assert_eq!(translate_stance_mask(0x90), 0x28); - // Bear-only (vanilla bit4) → our bit3 (0x08); Cat-only (vanilla bit0) → our bit4 (0x10). assert_eq!(translate_stance_mask(0x10), 0x08); assert_eq!(translate_stance_mask(0x1), 0x10); - // A warrior spell's mask is UNCHANGED by the widening (bit16-18 fold exactly as before), and a - // mixed warrior+druid mask folds both sides. assert_eq!(translate_stance_mask(0x10090), 0x01 | 0x28); - // Unmapped form bits (Travel form 3 → bit2, Ghoul form 7 → bit6, form 16 → bit15) still drop. assert_eq!(translate_stance_mask(0x4), 0); assert_eq!(translate_stance_mask(0x40), 0); assert_eq!(translate_stance_mask(0x8000), 0); @@ -3078,7 +2559,6 @@ mod tests { #[test] fn rogue_cast_gate_flags_set_by_name() { - // REQUIRES_BEHIND on Backstab (the must) + the other vanilla behind-only openers. assert_eq!( spell_flag_attributes("Backstab") & SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_BEHIND @@ -3091,14 +2571,12 @@ mod tests { spell_flag_attributes("Ambush") & SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_BEHIND ); - // REQ_DAGGER: Backstab ONLY — not the other behind-only openers (Garrote/Ambush are not dagger-gated). assert_eq!( spell_flag_attributes("Backstab") & SPELL_ATTR_REQ_DAGGER, SPELL_ATTR_REQ_DAGGER ); assert_eq!(spell_flag_attributes("Garrote") & SPELL_ATTR_REQ_DAGGER, 0); assert_eq!(spell_flag_attributes("Ambush") & SPELL_ATTR_REQ_DAGGER, 0); - // Sap: REQ_STEALTH + STEALTH_SAFE + INCAP_OPENER (the out-of-combat/humanoid constraints); NOT REQ_BEHIND. assert_eq!( spell_flag_attributes("Sap") & SPELL_ATTR_REQ_STEALTH, SPELL_ATTR_REQ_STEALTH @@ -3112,8 +2590,6 @@ mod tests { SPELL_ATTR_INCAP_OPENER ); assert_eq!(spell_flag_attributes("Sap") & SPELL_ATTR_REQ_BEHIND, 0); - // Garrote: REQ_BEHIND + REQ_STEALTH, but NOT STEALTH_SAFE (it breaks stealth) and - // NOT INCAP_OPENER (works on any type, in or out of combat — must not inherit Sap's constraints). assert_eq!( spell_flag_attributes("Garrote") & SPELL_ATTR_REQ_STEALTH, SPELL_ATTR_REQ_STEALTH @@ -3130,51 +2606,39 @@ mod tests { spell_flag_attributes("Garrote"), SPELL_ATTR_REQ_BEHIND | SPELL_ATTR_REQ_STEALTH ); - // Pick Pocket: STEALTH_SAFE only (fixes the stealth-break), no REQ flags. assert_eq!( spell_flag_attributes("Pick Pocket"), SPELL_ATTR_STEALTH_SAFE ); - // Slice and Dice: the combo-FINISHER duration-scaling bit — and ONLY that bit. assert_eq!( spell_flag_attributes("Slice and Dice"), SPELL_ATTR_FINISHER_DURATION ); - // An unrelated spell gets no flags (baseline-safe). assert_eq!(spell_flag_attributes("Fireball"), 0); } #[test] fn ranged_auto_repeat_flagged_by_dbc_bit_or_name() { - // DBC AUTOREPEAT bit set (any name) → flagged. assert!(is_ranged_auto_repeat(ATTR_EX2_AUTOREPEAT, "Whatever")); - // By-NAME fallback for the two player abilities when the bit isn't set. assert!(is_ranged_auto_repeat(0, "Auto Shot")); assert!(is_ranged_auto_repeat(0, "Shoot")); - // A normal cast with neither → not flagged (baseline-safe). assert!(!is_ranged_auto_repeat(0, "Fireball")); - assert!(!is_ranged_auto_repeat(0x1, "Frostbolt")); // an unrelated ex2 bit doesn't trip it + assert!(!is_ranged_auto_repeat(0x1, "Frostbolt")); } #[test] fn on_next_swing_reclassifies_heroic_strike_and_cleave() { - // Tier 2b on-next-swing QUEUE: Heroic Strike / Cleave encode a weapon-damage effect (→ E_DAMAGE) in - // the DBC, but vanilla QUEUES them onto the next swing — reclassify that E_DAMAGE to E_NEXT_SWING. assert_eq!( correct_script_effect_kind("Heroic Strike", E_DAMAGE), E_NEXT_SWING ); assert_eq!(correct_script_effect_kind("Cleave", E_DAMAGE), E_NEXT_SWING); - // Only the weapon-damage E_DAMAGE effect is reclassified — a non-damage effect on the same spell - // (e.g. an aura) is untouched, and an unrelated damage spell stays E_DAMAGE (baseline-safe). assert_eq!(correct_script_effect_kind("Heroic Strike", A_FLAG), A_FLAG); assert_eq!(correct_script_effect_kind("Frostbolt", E_DAMAGE), E_DAMAGE); } #[test] fn react_window_flags_set_by_name() { - // Tier 2b react windows: Overpower carries REQ_OVERPOWER, Revenge carries REQ_REVENGE — the cast gate - // refuses each unless the matching window (armed by a dodge / an avoided incoming swing) is open. assert_eq!( spell_flag_attributes("Overpower") & SPELL_ATTR_REQ_OVERPOWER, SPELL_ATTR_REQ_OVERPOWER @@ -3191,30 +2655,21 @@ mod tests { spell_flag_attributes("Revenge") & SPELL_ATTR_REQ_OVERPOWER, 0 ); - // Each is the ONLY bit on its spell (no accidental flag bleed). assert_eq!(spell_flag_attributes("Overpower"), SPELL_ATTR_REQ_OVERPOWER); assert_eq!(spell_flag_attributes("Revenge"), SPELL_ATTR_REQ_REVENGE); } #[test] fn kick_interrupt_effect_maps_to_e_interrupt() { - // Kick's eff2 is the raw vanilla InterruptCast effect (68) — reclassified to E_INTERRUPT by id - // (cleaner than a name match; auto-covers any future interrupt). Its eff1 (SchoolDamage=2) is the - // strike, unchanged. A name-keyed correction is unnecessary — the residue guard never sees it. assert_eq!(instant_effect_to_kind(68), E_INTERRUPT); assert_eq!(instant_effect_to_kind(2), E_DAMAGE); - // A residue-keyed correction does NOT touch E_INTERRUPT (it's already a real kind, not E_SCRIPTED). assert_eq!(correct_script_effect_kind("Kick", E_INTERRUPT), E_INTERRUPT); } #[test] fn summon_effect_maps_to_e_summon_pet_with_creature_entry_in_p0() { - // The raw vanilla Summon effect (56) → E_SUMMON_PET (Summon Imp et al.). assert_eq!(instant_effect_to_kind(56), E_SUMMON_PET); - // The summoned creature entry rides in effect_misc_value → p0, tagged P_ENTRY (a creature template - // entry). Imp = 416 (item_type is unused for this kind). Mirrors E_CREATE_ITEM's item-entry routing. assert_eq!(resolve_instant_params(E_SUMMON_PET, 416, 0), (416, P_ENTRY)); - // A residue-keyed correction never disturbs it (it's already a real kind, not E_SCRIPTED). assert_eq!( correct_script_effect_kind("Summon Imp", E_SUMMON_PET), E_SUMMON_PET @@ -3234,7 +2689,6 @@ mod tests { fn distract_maps_natively_to_an_enemy_area_effect_that_keeps_stealth() { assert_eq!(instant_effect_to_kind(69), E_DISTRACT); assert_eq!(kind_name(E_DISTRACT), "E_DISTRACT"); - // Spell 1725 carries the implicit target pair (16, 0): enemies around the ground point. assert_eq!( resolve_target(unit_selection_from_pair(16, 0), true), T_AREA_ENEMY @@ -3276,11 +2730,8 @@ mod tests { #[test] fn heal_max_health_effect_split_out_of_e_heal() { - // Lay on Hands is HealMaxHealth (effect 67) — it must map to E_HEAL_MAX_HEALTH (fill-to-max), NOT - // E_HEAL (which heals base_points ~= 0 → heals nothing). Plain Heal (10) stays E_HEAL. assert_eq!(instant_effect_to_kind(67), E_HEAL_MAX_HEALTH); assert_eq!(instant_effect_to_kind(10), E_HEAL); - // It's already a real kind, so the residue-keyed correction leaves it alone. assert_eq!( correct_script_effect_kind("Lay on Hands", E_HEAL_MAX_HEALTH), E_HEAL_MAX_HEALTH @@ -3289,16 +2740,9 @@ mod tests { #[test] fn proc_auras_map_natively_no_name_involved() { - // Aura 42 (ProcTriggerSpell) and aura 43 (ProcTriggerDamage) map onto the two proc kinds for - // ANY spell — no name rescue. `name` is deliberately absent from these two calls. assert_eq!(aura_mod_to_kind(AuraMod::ProcTriggerSpell), A_PROC_TRIGGER); assert_eq!(aura_mod_to_kind(AuraMod::ProcTriggerDamage), A_PROC_DAMAGE); - // A channel's per-tick trigger (PeriodicTriggerSpell) is unaffected — it still lands on - // E_TRIGGER (the channel reclassify further downstream turns it into A_PERIODIC_TRIGGER). assert_eq!(aura_mod_to_kind(AuraMod::PeriodicTriggerSpell), E_TRIGGER); - // Both proc kinds are already real (non-E_SCRIPTED, non-A_FLAG) kinds, so the curated - // name correction passes them through untouched for any name, including one carrying no - // rescue entry at all. assert_eq!( correct_script_effect_kind("Some Other Spell", A_PROC_TRIGGER), A_PROC_TRIGGER @@ -3311,14 +2755,10 @@ mod tests { #[test] fn proc_damage_school_p0_reads_the_header_school_not_a_misc_value() { - // Aura 43 carries no per-effect misc value for its school — p0 is the PROC SPELL'S OWN - // school_mask (frost = bit 4 = 0x10), regardless of whatever misc value resolve_aura_params - // produced upstream. assert_eq!( proc_damage_school_p0(A_PROC_DAMAGE, 0, P_NONE, 0x10), (0x10, P_SCHOOL_MASK) ); - // A non-A_PROC_DAMAGE kind's (p0, p0_kind) passes through untouched. assert_eq!( proc_damage_school_p0(A_MOD_RESISTANCE, 5, P_SCHOOL_MASK, 0x10), (5, P_SCHOOL_MASK) @@ -3329,60 +2769,48 @@ mod tests { fn proc_header_fields_preserve_masks_and_bound_byte_columns() { assert_eq!(proc_header_fields(40, 100, 0), (40, 100, 0)); assert_eq!(proc_header_fields(139944, 100, 3), (139944, 100, 3)); - // A percent/charge count is clamped into its u8 column rather than silently truncating. assert_eq!(proc_header_fields(1, 999, 999), (1, 255, 255)); assert_eq!(proc_header_fields(1, 0, 0), (1, 0, 0)); } #[test] fn wrapper_rank_trigger_excludes_every_non_rank_payload() { - // Mirrors module/src/trainer.rs `resolve_learn_target`'s exclusion list — the two MUST stay - // in lockstep, so this test pins the importer's half by kind, not by name. assert!(!is_wrapper_rank_trigger(A_PERIODIC_TRIGGER)); assert!(!is_wrapper_rank_trigger(A_FLAG)); assert!(!is_wrapper_rank_trigger(A_PROC_TRIGGER)); assert!(!is_wrapper_rank_trigger(A_PROC_DAMAGE)); assert!(!is_wrapper_rank_trigger(E_TRIGGER)); - // A genuine LearnSpell wrapper's effect maps to E_SCRIPTED — its trigger_spell IS a rank. assert!(is_wrapper_rank_trigger(E_SCRIPTED)); assert!(is_wrapper_rank_trigger(E_DAMAGE)); } #[test] fn synthetic_seal_added_to_seal_of_the_crusader_only() { - // Seal of the Crusader (21082) gains a THIRD, SELF-targeted A_SEAL effect at the given slot — - // The client fills three slots, so the curated marker uses the fourth. let row = synthetic_seal_effect(21082, "Seal of the Crusader", 3) .expect("Seal of the Crusader gains a seal effect"); assert_eq!(row.row_id(), (21082u64 << 2) | 3); assert_eq!(row.kind, A_SEAL); assert_eq!(row.base_points, 20); - assert_eq!(row.target, T_SELF); // self aura, like Seal of Righteousness - assert!(synthetic_seal_effect(21082, "Seal of Command", 2).is_none()); // wrong name guard - assert!(synthetic_seal_effect(21084, "Seal of the Crusader", 2).is_none()); // wrong id guard (a hypothetical other rank) - // Seal of Righteousness reclassifies its OWN inert marker by name — it needs no synthetic add. + assert_eq!(row.target, T_SELF); + assert!(synthetic_seal_effect(21082, "Seal of Command", 2).is_none()); + assert!(synthetic_seal_effect(21084, "Seal of the Crusader", 2).is_none()); assert!(synthetic_seal_effect(21084, "Seal of Righteousness", 2).is_none()); } #[test] fn power_word_shield_p1_links_weakened_soul() { - // The real live Power Word: Shield (17) A_ABSORB effect gets its p1 overridden to link Weakened - // Soul (6788), the generic linked-debuff mechanic. assert_eq!( power_word_shield_p1_override(17, "Power Word: Shield", A_ABSORB, 0), 6788 ); - // Wrong id guard (a hypothetical other rank/spell reusing the name). assert_eq!( power_word_shield_p1_override(18, "Power Word: Shield", A_ABSORB, 0), 0 ); - // Wrong name guard. assert_eq!( power_word_shield_p1_override(17, "Power Word: Fortitude", A_ABSORB, 0), 0 ); - // Wrong kind guard — only the real A_ABSORB effect is overridden. assert_eq!( power_word_shield_p1_override(17, "Power Word: Shield", A_FLAG, 0), 0 @@ -3391,56 +2819,49 @@ mod tests { #[test] fn instant_effects_map_to_real_kinds() { - assert_eq!(instant_effect_to_kind(2), E_DAMAGE); // SchoolDamage - assert_eq!(instant_effect_to_kind(58), E_DAMAGE); // WeaponDamage - assert_eq!(instant_effect_to_kind(121), E_DAMAGE); // NormalizedWeaponDmg - assert_eq!(instant_effect_to_kind(10), E_HEAL); // Heal (flat) - assert_eq!(instant_effect_to_kind(67), E_HEAL_MAX_HEALTH); // HealMaxHealth (Lay on Hands — fill to max, not E_HEAL) - assert_eq!(instant_effect_to_kind(30), E_ENERGIZE); // Energize - assert_eq!(instant_effect_to_kind(38), E_DISPEL); // Dispel - assert_eq!(instant_effect_to_kind(64), E_TRIGGER); // TriggerSpell - assert_eq!(instant_effect_to_kind(63), E_TAUNT); // Threat - assert_eq!(instant_effect_to_kind(91), E_TAUNT); // ThreatAll - assert_eq!(instant_effect_to_kind(24), E_CREATE_ITEM); // CreateItem (conjure / quest item) - assert_eq!(instant_effect_to_kind(62), E_POWER_BURN); // PowerBurn (Mana Burn) + assert_eq!(instant_effect_to_kind(2), E_DAMAGE); + assert_eq!(instant_effect_to_kind(58), E_DAMAGE); + assert_eq!(instant_effect_to_kind(121), E_DAMAGE); + assert_eq!(instant_effect_to_kind(10), E_HEAL); + assert_eq!(instant_effect_to_kind(67), E_HEAL_MAX_HEALTH); + assert_eq!(instant_effect_to_kind(30), E_ENERGIZE); + assert_eq!(instant_effect_to_kind(38), E_DISPEL); + assert_eq!(instant_effect_to_kind(64), E_TRIGGER); + assert_eq!(instant_effect_to_kind(63), E_TAUNT); + assert_eq!(instant_effect_to_kind(91), E_TAUNT); + assert_eq!(instant_effect_to_kind(24), E_CREATE_ITEM); + assert_eq!(instant_effect_to_kind(62), E_POWER_BURN); assert_eq!(instant_effect_to_kind(33), E_OPEN_LOCK); - assert_eq!(instant_effect_to_kind(59), E_OPEN_LOCK); // OpenLockItem (the item-lock variant) - // LearnSpell / Summon are still out of taxonomy → graceful no-op. + assert_eq!(instant_effect_to_kind(59), E_OPEN_LOCK); assert_eq!(instant_effect_to_kind(36), E_SCRIPTED); } #[test] fn instant_effect_raw_id_arms_land_work_item_101() { - // Exact-arm pins for the four raw ids lands (verified against - // `wow_world_base-0.3.0`'s vanilla/tbc/wrath `SpellEffect::from_int`, all three eras agree): - // AttackMe=114, AddComboPoints=80, Resurrect=18, ResurrectNew=113. - assert_eq!(instant_effect_to_kind(114), E_TAUNT); // AttackMe - assert_eq!(instant_effect_to_kind(80), E_ADD_COMBO); // AddComboPoints - assert_eq!(instant_effect_to_kind(18), E_RESURRECT); // Resurrect - assert_eq!(instant_effect_to_kind(113), E_RESURRECT); // ResurrectNew - // Negative pins: ids adjacent to / resembling the above stay the graceful E_SCRIPTED no-op — - // proves the match arms above are exact, not off-by-one or a wildcard. - assert_eq!(instant_effect_to_kind(3), E_SCRIPTED); // Dummy — the Rogue combo generators' ACTUAL - // raw effect in-kit (rescued by name in correct_script_effect_kind, not by this raw-id arm) - assert_eq!(instant_effect_to_kind(115), E_SCRIPTED); // one past AttackMe(114) — still unmapped - assert_eq!(instant_effect_to_kind(19), E_SCRIPTED); // one past Resurrect(18) — still unmapped - assert_eq!(instant_effect_to_kind(112), E_SCRIPTED); // one before ResurrectNew(113) — still unmapped + assert_eq!(instant_effect_to_kind(114), E_TAUNT); + assert_eq!(instant_effect_to_kind(80), E_ADD_COMBO); + assert_eq!(instant_effect_to_kind(18), E_RESURRECT); + assert_eq!(instant_effect_to_kind(113), E_RESURRECT); + assert_eq!(instant_effect_to_kind(3), E_SCRIPTED); + assert_eq!(instant_effect_to_kind(115), E_SCRIPTED); + assert_eq!(instant_effect_to_kind(19), E_SCRIPTED); + assert_eq!(instant_effect_to_kind(112), E_SCRIPTED); } #[test] fn power_burn_ratio_bp_converts_the_dbc_fraction_to_basis_points() { - assert_eq!(power_burn_ratio_bp(0.5), 50); // vanilla Mana Burn: EffectMultipleValue 0.5 + assert_eq!(power_burn_ratio_bp(0.5), 50); assert_eq!(power_burn_ratio_bp(1.0), 100); - assert_eq!(power_burn_ratio_bp(0.0), 0); // the module treats <=0 as 100 (1:1) — the raw DBC value round-trips as-is + assert_eq!(power_burn_ratio_bp(0.0), 0); } #[test] fn apply_aura_effect_id_routes_to_aura_branch() { - assert!(is_aura_effect(6)); // ApplyAura - assert!(is_aura_effect(27)); // PersistentAreaAura - assert!(is_aura_effect(35)); // ApplyAreaAuraParty - assert!(is_aura_effect(119)); // ApplyAreaAuraPet - assert!(!is_aura_effect(2)); // SchoolDamage is instant + assert!(is_aura_effect(6)); + assert!(is_aura_effect(27)); + assert!(is_aura_effect(35)); + assert!(is_aura_effect(119)); + assert!(!is_aura_effect(2)); } #[test] @@ -3448,9 +2869,6 @@ mod tests { use AuraMod::*; assert_eq!(aura_mod_to_kind(PeriodicDamage), A_PERIODIC_DAMAGE); assert_eq!(aura_mod_to_kind(PeriodicHeal), A_PERIODIC_HEAL); - // Demon Skin/Armor's health-per-5: ModRegen is a combat-independent periodic - // heal, reclassified onto the SAME A_PERIODIC_HEAL kind as PeriodicHeal (Renew/bandages/food) — - // NOT left as the inert A_FLAG marker. assert_eq!(aura_mod_to_kind(ModRegen), A_PERIODIC_HEAL); assert_eq!(aura_mod_to_kind(PeriodicEnergize), A_PERIODIC_ENERGIZE); assert_eq!(aura_mod_to_kind(ModStat), A_MOD_STAT); @@ -3462,17 +2880,10 @@ mod tests { assert_eq!(aura_mod_to_kind(ModFear), A_CONTROL); assert_eq!(aura_mod_to_kind(SchoolImmunity), A_IMMUNITY); assert_eq!(aura_mod_to_kind(Dummy), A_FLAG); - // incoming-damage % modifier (Shield Wall / vulnerability) — wired; the FLAT variant stays no-op. assert_eq!(aura_mod_to_kind(ModDamagePercentTaken), A_MOD_DAMAGE_TAKEN); - assert_eq!(aura_mod_to_kind(ModDamageTaken), E_SCRIPTED); // flat damage-taken not handled yet - // Spell modifiers: AddFlatModifier/AddPctModifier were the "unmapped → no-op" - // example here until the passive-modifier engine landed (2000292) and gave them real kinds — - // this assertion is the regression guard for that reclassification, not a stale count. + assert_eq!(aura_mod_to_kind(ModDamageTaken), E_SCRIPTED); assert_eq!(aura_mod_to_kind(AddFlatModifier), A_SPELLMOD_FLAT); assert_eq!(aura_mod_to_kind(AddPctModifier), A_SPELLMOD_PCT); - // still-genuinely-unmapped → graceful no-op (ModRating has no handler anywhere in - // aura_mod_to_kind; if this starts failing, either it grew a real kind — update this pin to a - // different unmapped variant — or the catch-all arm broke). assert_eq!(aura_mod_to_kind(ModRating), E_SCRIPTED); } @@ -3529,7 +2940,6 @@ mod tests { #[test] fn speed_kind_resolves_run_swim_swing_cast_and_mount() { use AuraMod::*; - // move (default/generic — e.g. ModIncreaseSpeed/ModDecreaseSpeed). assert_eq!( resolve_aura_params(A_MOD_SPEED, ModIncreaseSpeed, 0), (0, P_SPEED_KIND) @@ -3542,17 +2952,14 @@ mod tests { resolve_aura_params(A_MOD_SPEED, ModSpeedNotStack, 0), (i32::from(SPEED_MOVE_NOT_STACK), P_SPEED_KIND) ); - // swing (melee/ranged attack speed). assert_eq!( resolve_aura_params(A_MOD_SPEED, ModMeleeHaste, 0), (1, P_SPEED_KIND) ); - // cast (casting speed). assert_eq!( resolve_aura_params(A_MOD_SPEED, ModCastingSpeedNotStack, 0), (2, P_SPEED_KIND) ); - // mounted. assert_eq!( resolve_aura_params(A_MOD_SPEED, ModIncreaseMountedSpeed, 0), (3, P_SPEED_KIND) @@ -3611,9 +3018,7 @@ mod tests { #[test] fn absorb_params_split_mana_shield_from_school_absorb() { use AuraMod::*; - // ManaShield absorbs from the power pool — no school, P_NONE. assert_eq!(resolve_aura_params(A_ABSORB, ManaShield, 0x7F), (0, P_NONE)); - // A real SchoolAbsorb (Power Word: Shield) carries the 7-bit school mask from misc. assert_eq!( resolve_aura_params(A_ABSORB, SchoolAbsorb, 0x7F), (0x7F, P_SCHOOL_MASK) @@ -3623,7 +3028,6 @@ mod tests { #[test] fn immunity_params_resolve_all_three_arms() { use AuraMod::*; - // school/damage immunity: 7-bit school mask. assert_eq!( resolve_aura_params(A_IMMUNITY, SchoolImmunity, 0x7F), (0x7F, P_SCHOOL_MASK) @@ -3632,7 +3036,6 @@ mod tests { resolve_aura_params(A_IMMUNITY, DamageImmunity, 0x7F), (0x7F, P_SCHOOL_MASK) ); - // mechanic immunity: the raw mechanic id/mask, untouched. assert_eq!( resolve_aura_params(A_IMMUNITY, MechanicImmunity, 5), (5, P_MECHANIC) @@ -3641,46 +3044,36 @@ mod tests { resolve_aura_params(A_IMMUNITY, MechanicImmunityMask, 5), (5, P_MECHANIC) ); - // anything else (effect/state immunity): an opaque raw id. assert_eq!(resolve_aura_params(A_IMMUNITY, ModStun, 9), (9, P_RAW)); } #[test] fn is_channeled_reads_the_attributes_ex1_bit_and_the_arcane_missiles_fallback() { - // AttributesEx1 CHANNELED bit 0x44 set → channeled, for any name. assert!(is_channeled(0x44, "Some Spell")); - // The bit alone (0x04) or (0x40) individually still trips the mask (bitwise AND != 0). assert!(is_channeled(0x04, "Some Spell")); assert!(is_channeled(0x40, "Some Spell")); - // No bit set, no name match → not channeled. assert!(!is_channeled(0, "Some Spell")); - // Arcane Missiles is channeled by NAME even if the DBC carries no CHANNELED bit at all (the - // cmangos-classic fallback for the one channel the vanilla DBC under-flags). assert!(is_channeled(0, "Arcane Missiles")); } #[test] fn stat_all_sentinel_and_school_mask() { use AuraMod::*; - // misc -1 (u32::MAX) on a ModStat ⇒ all-stats sentinel 0xFF. assert_eq!( resolve_aura_params(A_MOD_STAT, ModStat, u32::MAX), (i32::from(STAT_ALL), P_STAT_ID) ); - assert_eq!(resolve_aura_params(A_MOD_STAT, ModStat, 3), (3, P_STAT_ID)); // INT - // ModStat is FLAT; ModTotalStatPercentage / ModPercentStat are PERCENT (distinct kind). BOTH resolve - // the stat from misc — a SPECIFIC stat (4=Spirit, The Human Spirit), not a blanket all-stats. + assert_eq!(resolve_aura_params(A_MOD_STAT, ModStat, 3), (3, P_STAT_ID)); assert_eq!(aura_mod_to_kind(ModStat), A_MOD_STAT); assert_eq!(aura_mod_to_kind(ModTotalStatPercentage), A_MOD_STAT_PCT); assert_eq!( resolve_aura_params(A_MOD_STAT_PCT, ModTotalStatPercentage, 4), (4, P_STAT_ID) - ); // Spirit + ); assert_eq!( resolve_aura_params(A_MOD_STAT_PCT, ModTotalStatPercentage, u32::MAX), (i32::from(STAT_ALL), P_STAT_ID) ); - // resistance keeps the 7-bit school mask. assert_eq!( resolve_aura_params(A_MOD_RESISTANCE, ModResistance, 0x7F), (0x7F, P_SCHOOL_MASK) @@ -3689,7 +3082,6 @@ mod tests { #[test] fn periodic_energize_decodes_power_type_from_misc() { - // p0 = the power type id, read straight from the low byte of misc (rage=1, mana=0). assert_eq!( resolve_aura_params(A_PERIODIC_ENERGIZE, AuraMod::PeriodicEnergize, 1), (1, P_POWER_TYPE) @@ -3702,15 +3094,14 @@ mod tests { #[test] fn instant_param_decodes_power_and_dispel() { - assert_eq!(resolve_instant_params(E_ENERGIZE, 1, 0), (1, P_POWER_TYPE)); // rage - assert_eq!(resolve_instant_params(E_DISPEL, 1, 0), (1, P_SCHOOL_MASK)); // magic category - assert_eq!(resolve_instant_params(E_DAMAGE, 4, 0), (0, P_NONE)); // school on header + assert_eq!(resolve_instant_params(E_ENERGIZE, 1, 0), (1, P_POWER_TYPE)); + assert_eq!(resolve_instant_params(E_DISPEL, 1, 0), (1, P_SCHOOL_MASK)); + assert_eq!(resolve_instant_params(E_DAMAGE, 4, 0), (0, P_NONE)); assert_eq!(instant_effect_to_kind(83), E_DUEL); assert_eq!( resolve_instant_params(E_DUEL, 21680, 0), (21680, P_GAMEOBJECT_ENTRY) ); - // CreateItem: p0 = the item entry (from effect_item_type), tagged P_ITEM_ENTRY; misc ignored. assert_eq!( resolve_instant_params(E_CREATE_ITEM, 99, 2070), (2070, P_ITEM_ENTRY) @@ -3719,36 +3110,26 @@ mod tests { #[test] fn target_definitions_distinguish_enemy_ally_area_and_unsupported_selections() { - // Target 6 is explicitly hostile, independent of magnitude or aura polarity. assert_eq!(resolve_target(6, true), T_TARGET_ENEMY); assert_eq!(resolve_target(6, false), T_TARGET_ENEMY); assert_eq!(resolve_target(1, false), T_SELF); assert_eq!(resolve_target(0, false), T_SCRIPTED); - // Target 20 selects the party within caster range. assert_eq!(resolve_target(20, false), T_AREA_ALLY); - // area-of-effect code 8: polarity still decides enemy-area vs ally-area. assert_eq!(resolve_target(8, true), T_AREA_ENEMY); assert_eq!(resolve_target(8, false), T_AREA_ALLY); - // Unrecognized target codes retain the scripted fallback. assert_eq!(resolve_target(-5, true), T_SCRIPTED); - // an unrecognized positive code defers to runtime resolution. assert_eq!(resolve_target(999, false), T_SCRIPTED); } #[test] fn reducing_modifier_aura_is_a_debuff() { use AuraMod::*; - // Sunder Armor shape: ApplyAura(6) + ModResistance + negative base → debuff (→ enemy target). assert!(is_reducing_modifier_aura(6, ModResistance, -90)); - // Demoralizing Shout shape: ApplyAura(6) + ModAttackPower + negative base → debuff. assert!(is_reducing_modifier_aura(6, ModAttackPower, -35)); assert!(is_reducing_modifier_aura(6, ModStat, -10)); - // A POSITIVE modifier (a buff — e.g. Battle Shout +AP, Inner Fire +armor) is NOT flagged. assert!(!is_reducing_modifier_aura(6, ModAttackPower, 15)); assert!(!is_reducing_modifier_aura(6, ModResistance, 2000)); - // A negative magnitude on a NON-modifier aura is not this rule's business (handled elsewhere). assert!(!is_reducing_modifier_aura(6, PeriodicDamage, -5)); - // An INSTANT effect (not aura-placing) is never a "reducing modifier aura". assert!(!is_reducing_modifier_aura(2, ModResistance, -90)); } @@ -3761,24 +3142,15 @@ mod tests { #[test] fn trainer_cost_is_level_keyed_and_never_free() { - // DBC-derived: 50 copper per required level, monotone, clamped so a level-0 rank still costs 50c. - assert_eq!(trainer_cost(0), 50); // the DBC "floor" ranks clamp to level 1 → 50c (not free) + assert_eq!(trainer_cost(0), 50); assert_eq!(trainer_cost(1), 50); assert_eq!(trainer_cost(4), 200); assert_eq!(trainer_cost(10), 500); - assert!(trainer_cost(10) > trainer_cost(4)); // monotone-rising - // No cmangos value is consulted — the cost is purely a function of the level. + assert!(trainer_cost(10) > trainer_cost(4)); } #[test] fn delete_statements_are_wholesale_or_surgical() { - // Full import (no allowlist): the wholesale clear of both tables, GUARDED at the synthetic - // fixture floor (module/src/seed.rs 50000/50110, module/src/seed/fixtures.rs 50072/50137) so a - // full-DBC reload never deletes rows no import script re-creates. Weakened Soul (6788) sits - // BELOW the floor by design — the full import re-creates it from its own real DBC entry. - // `game_spell_effect` MUST be filtered on its own `spell_id` column (not the packed - // `(spell_id<<2)|effect_index` `id`) — asserting the exact column name here catches a - // regression back to the packed-id form. let full = spell_delete_statements(&[]); assert_eq!(full.len(), 4); assert_eq!(full[0], "DELETE FROM game_spell WHERE spell_id < 50000"); @@ -3827,17 +3199,12 @@ mod tests { surgical[7], "DELETE FROM game_creature_ai_spell_metadata WHERE spell_id = 7386" ); - // The surgical path NEVER emits an unbounded clear (that would clobber the seed/fixtures). assert!(surgical.iter().all(|s| !s.contains(">= 0"))); } #[test] fn mounted_aura_maps_to_a_mounted_with_display_id_p0() { - // The vanilla Mounted aura (78) reclassifies out of the inert A_FLAG marker list onto A_MOUNTED - // — no longer a dormant no-op. assert_eq!(aura_mod_to_kind(AuraMod::Mounted), A_MOUNTED); - // p0 defaults to the raw misc value AS the display id (the shape of every real classic mount: - // EffectMiscValue names a CreatureDisplayInfo id directly), tagged P_DISPLAY_ID. assert_eq!( resolve_aura_params(A_MOUNTED, AuraMod::Mounted, 723), (723, P_DISPLAY_ID) @@ -3847,37 +3214,23 @@ mod tests { #[test] fn mount_display_p0_resolves_creature_template_indirection_and_falls_through() { let mut displays: BTreeMap = BTreeMap::new(); - displays.insert(1234, 5678); // a rare mount that names a creature TEMPLATE, not a display - // Acceptance criterion: "A mount spell whose DBC names a creature template resolves to the - // template's display." + displays.insert(1234, 5678); assert_eq!(mount_display_p0(A_MOUNTED, 1234, &displays), 5678); - // The common case: the misc value isn't in the map (it's already a real display id) — pass - // through unchanged. assert_eq!(mount_display_p0(A_MOUNTED, 723, &displays), 723); - // Non-A_MOUNTED kinds are never touched. assert_eq!(mount_display_p0(E_DAMAGE, 1234, &displays), 1234); } #[test] fn dispel_mechanic_reclassifies_the_mount_parameter_only() { - // The ONE data-driven reclassify the design allows: raw DISPEL_MECHANIC (108) whose misc value - // names the mount mechanic (21) becomes E_DISMOUNT — Dazed's shape (A_MOD_SPEED slow + - // this effect), but not keyed on Dazed's spell id or name. assert_eq!(dismount_effect_kind(108, 21), Some(E_DISMOUNT)); - // Acceptance criterion: any OTHER mechanic parameter does NOT reclassify — it stays unmapped - // (falls to E_SCRIPTED via instant_effect_to_kind, counted in cov.unmapped_effect). - assert_eq!(dismount_effect_kind(108, 1), None); // e.g. mechanic 1 (a different CC) + assert_eq!(dismount_effect_kind(108, 1), None); assert_eq!(dismount_effect_kind(108, 0), None); - // A different raw effect id carrying misc==21 is not a dispel-mechanic effect at all — untouched. assert_eq!(dismount_effect_kind(2, 21), None); - // 108 isn't in instant_effect_to_kind's raw-id table — it falls to the graceful no-op until the - // mechanic-21 reclassify above fires. assert_eq!(instant_effect_to_kind(108), E_SCRIPTED); } #[test] fn mounted_speed_normalizes_the_vanilla_stored_flat_amount() { - // Mounted speed stays A_MOD_SPEED / SPEED_MOUNTED, never a second concept. use AuraMod::*; for mounted in [ ModIncreaseMountedSpeed, @@ -3889,7 +3242,6 @@ mod tests { (i32::from(SPEED_MOUNTED), P_SPEED_KIND) ); } - // Vanilla stores the flat amount MINUS one; 59/99 normalize to the nominal 60%/100%. assert_eq!(dbc_flat_amount(59), 60); assert_eq!(dbc_flat_amount(99), 100); } @@ -3935,8 +3287,6 @@ mod binding_tests { #[test] fn binding_source_events_import_one_hostile_summon_and_keep_the_focus() { - // Facts from classic-db cd0c426a3b2ff56dd518bf009025299468e60fdb, - // spell_template and dbscripts_on_event. This fixture contains no client archive bytes. for (spell_id, event, entry) in [(7728, 1131, 5676), (8674, 1134, 5677)] { let row = source_spell(spell_id, event); let dbc = SpellDbc { diff --git a/importer/src/spell_dbc.rs b/importer/src/spell_dbc.rs index 6862abbf3..bdd7810c9 100644 --- a/importer/src/spell_dbc.rs +++ b/importer/src/spell_dbc.rs @@ -1,9 +1,4 @@ -//! Build 5875 Spell columns shared by spell import and item enchantment import. -//! -//! Layout: cmangos/mangos-classic 8ec338a1704e7dcb1c0213eb7ed58f9231ade40f, -//! src/game/Server/DBCStructure.h, SpellEntry columns 0..172. -//! wow_dbc 0.3 omits InterruptFlags at column 21. Its equipment, reagent and effect -//! fields stay shifted until an extra unknown_flag resynchronizes the name at column 120. +// wow_dbc 0.3 omits build-5875 InterruptFlags at column 21 and realigns at column 120. use anyhow::{bail, Context, Result}; use std::collections::BTreeSet; @@ -36,7 +31,6 @@ pub(crate) struct Spell { pub(crate) stack_amount: u32, pub(crate) reagent: [i32; 8], pub(crate) reagent_count: [u32; 8], - /// Class, subclass mask and inventory mask in source order. pub(crate) equipment: [u32; 3], pub(crate) effect: [i32; 3], pub(crate) effect_die_sides: [i32; 3], @@ -61,7 +55,6 @@ pub(crate) struct Spell { pub(crate) family_flags: u64, } -/// Read only the supported fixed-width layout. Client bytes remain in memory. pub(crate) fn read_spells(bytes: &[u8]) -> Result> { if bytes.len() < 20 || &bytes[..4] != b"WDBC" { bail!("Spell.dbc has no WDBC header"); @@ -78,7 +71,6 @@ pub(crate) fn read_spells(bytes: &[u8]) -> Result> { if records_end.and_then(|n| n.checked_add(strings)) != Some(bytes.len()) { bail!("Spell.dbc record and string lengths do not match its header"); } - // The length check proves both the record boundary and every fixed column below fit. let records_end = records_end.unwrap(); let strings = &bytes[records_end..]; let mut ids = BTreeSet::new(); diff --git a/importer/src/spell_snapshot.rs b/importer/src/spell_snapshot.rs index e86b34a88..0d1d6563a 100644 --- a/importer/src/spell_snapshot.rs +++ b/importer/src/spell_snapshot.rs @@ -1,39 +1,11 @@ -//! The **Base Snapshot**: the `game_spell` / `game_spell_effect` rows the DBC import derives, -//! written as canonical JSON so a Datascript can read base data without a shard and without client -//! files. -//! -//! # Why the rows are typed here -//! -//! The import emits SQL. A Datascript needs the same values as data. Rather than parse the SQL back, -//! both come from one typed row: [`SpellHeaderRow`] and [`SpellEffectRow`] carry the derived values, -//! [`SpellHeaderRow::sql_values`] spells them as an SQL tuple, and [`write_snapshot`] spells them as -//! JSON. One row, two spellings, no drift. -//! -//! # Licensing -//! -//! A snapshot holds DERIVED `game_*` values, never client bytes, and it is written only where the -//! operator asks. The author-facing target (`datascripts/generated/`) is git-ignored. See the -//! firewall note at the top of `dbc.rs`. -//! -//! # Canonical form -//! -//! The same rules as a Package Delta artifact (`lyracore-package-delta`'s `canonical` module): no -//! whitespace, no trailing newline, a fixed member order, integers in plain decimal, an unsigned -//! 64-bit value as a decimal string, a float as the shortest decimal that reads back as the same -//! `f32`. Every scalar is spelled by `FieldValue`'s `Display`, which IS that writer, so a snapshot -//! and a delta agree on every value by construction. Rows are primary-key sorted, so the same client -//! data always produces the same bytes. - use std::collections::BTreeMap; use lyracore_package_delta::{packed_spell_effect_id, FieldValue}; use crate::sql_text; -/// The snapshot format this build writes. const SNAPSHOT_VERSION: u32 = 1; -/// One `game_spell` row, before it becomes either SQL or JSON. pub(crate) struct SpellHeaderRow { pub(crate) spell_id: u32, pub(crate) name: String, @@ -63,8 +35,6 @@ pub(crate) struct SpellHeaderRow { } impl SpellHeaderRow { - /// The row's columns in the table's declaration order, which is also the order the INSERT names - /// them in. fn columns(&self) -> Vec<(&'static str, FieldValue)> { vec![ ("spell_id", FieldValue::U32(self.spell_id)), @@ -95,14 +65,11 @@ impl SpellHeaderRow { ] } - /// The `VALUES` tuple for the reload INSERT. pub(crate) fn sql_values(&self) -> String { sql_tuple(&self.columns()) } } -/// One `game_spell_effect` row. The packed durable key is derived from the spell and the effect -/// index, never carried, so the two can never disagree. pub(crate) struct SpellEffectRow { pub(crate) spell_id: u32, pub(crate) effect_index: u8, @@ -124,13 +91,10 @@ pub(crate) struct SpellEffectRow { } impl SpellEffectRow { - /// `(spell_id << 2) | effect_index`, the deterministic primary key the Module and the importer - /// have always used. pub(crate) fn row_id(&self) -> u64 { packed_spell_effect_id(self.spell_id, self.effect_index) } - /// The row's columns in declaration order, without the derived `id`. fn columns(&self) -> Vec<(&'static str, FieldValue)> { vec![ ("spell_id", FieldValue::U32(self.spell_id)), @@ -153,7 +117,6 @@ impl SpellEffectRow { ] } - /// The `VALUES` tuple for the reload INSERT, whose first column is the derived key. pub(crate) fn sql_values(&self) -> String { let mut columns = self.columns(); columns.insert(0, ("id", FieldValue::U64(self.row_id()))); @@ -161,11 +124,6 @@ impl SpellEffectRow { } } -/// The SQL `VALUES` tuple for one row. -/// -/// Two spellings differ from the canonical JSON one: a string is SQL-quoted rather than -/// JSON-quoted, and an unsigned 64-bit value is a bare number rather than a decimal string. Every -/// other type has the same literal in both, which is why `Display` carries them. fn sql_tuple(columns: &[(&'static str, FieldValue)]) -> String { let mut out = String::from("("); for (index, (_, value)) in columns.iter().enumerate() { @@ -182,30 +140,18 @@ fn sql_tuple(columns: &[(&'static str, FieldValue)]) -> String { out } -/// Everything one DBC spell pass derived, in the order the reload statements name it. -/// -/// The typed halves feed both the SQL and the snapshot. The remaining families are SQL-only: they -/// are not Package Delta tables, so no Datascript can read or claim them. #[derive(Default)] pub(crate) struct SpellRows { - /// `game_spell` headers, in DBC order. pub(crate) headers: Vec, - /// `game_spell_effect` rows the DBC mapping produced, including the curated synthetic additions. pub(crate) effects: Vec, - /// The curated fishing marker rows, which the reload writes by key AFTER the bulk insert and so - /// replace any DBC-derived row at the same key. + // Fishing markers replace DBC effects at the same key after the bulk insert. pub(crate) fishing: Vec, - /// `game_spell_reagent` value tuples. pub(crate) reagents: Vec, - /// `game_creature_ai_spell_metadata` value tuples. pub(crate) eventai_metadata: Vec, - /// `game_trainer_spell` value tuples for the curated override rows. pub(crate) trainers: Vec, } impl SpellRows { - /// The effect rows a shard ends up holding, primary-key sorted, with a fishing marker replacing - /// whatever the DBC put at its key — exactly what the reload statements leave behind. fn resolved_effects(&self) -> BTreeMap { let mut resolved = BTreeMap::new(); for row in self.effects.iter().chain(&self.fishing) { @@ -214,13 +160,11 @@ impl SpellRows { resolved } - /// The headers a shard ends up holding, primary-key sorted. fn resolved_headers(&self) -> BTreeMap { self.headers.iter().map(|row| (row.spell_id, row)).collect() } } -/// The Base Snapshot for these rows, in canonical form. pub(crate) fn write_snapshot(rows: &SpellRows) -> String { let mut out = format!("{{\"version\":{SNAPSHOT_VERSION},\"game_spell\":["); for (index, header) in rows.resolved_headers().values().enumerate() { @@ -240,8 +184,6 @@ pub(crate) fn write_snapshot(rows: &SpellRows) -> String { out } -/// One row as a JSON object. `FieldValue`'s `Display` is the Package Delta canonical writer, so -/// every scalar here is spelled exactly as a claim on the same column would spell it. fn write_row(out: &mut String, columns: &[(&'static str, FieldValue)]) { out.push('{'); for (index, (name, value)) in columns.iter().enumerate() { @@ -314,9 +256,6 @@ mod tests { } } - /// The snapshot column list IS the durable schema the Package Delta catalogue names. A column - /// added to one and not the other would let a Datascript claim a field its base data never - /// carries, or read a field no claim can set. #[test] fn the_snapshot_names_every_claimable_column_of_both_tables() { let header_names: Vec<&str> = header(133, "Fireball") @@ -344,7 +283,6 @@ mod tests { assert_eq!(effect(133, 2).row_id(), (133 << 2) | 2); } - /// SQL and JSON disagree about exactly two spellings, and about nothing else. #[test] fn sql_quotes_text_and_writes_a_bare_unsigned_64_bit_value() { let sql = header(133, "Fireball").sql_values(); @@ -374,8 +312,6 @@ mod tests { assert!(json.contains(r#""is_negative":true"#), "{json}"); } - /// A float always carries a decimal point, so a float column stays distinct from an integer one - /// in both spellings. #[test] fn a_float_column_always_carries_a_decimal_point() { let rows = SpellRows { @@ -433,9 +369,6 @@ mod tests { assert_eq!(write_snapshot(&rows), write_snapshot(&rows)); } - /// The reload writes the fishing marker by key after the bulk insert, so a shard holds the - /// marker and not the DBC row it landed on. The snapshot has to describe the shard, not the - /// intermediate. #[test] fn a_curated_fishing_marker_replaces_the_row_it_lands_on() { let mut marker = effect(7620, 0); diff --git a/importer/src/talent.rs b/importer/src/talent.rs index 7b86d3bf6..b65845719 100644 --- a/importer/src/talent.rs +++ b/importer/src/talent.rs @@ -1,5 +1,3 @@ -//! Client talent data extraction and import planning. - use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -11,9 +9,6 @@ use wow_dbc::DbcTable; use crate::dbc::{open_chain, read_table}; use crate::{push_insert, run_sql_statements, sql_text, Args}; -/// Clear+reload SQL for `game_talent_tab` from `TalentTab.dbc` — one row per class talent-tree tab (27 in -/// vanilla: 9 classes × 3 tabs). `class_mask`/`race_mask` are the DBC's raw bitmasks; `order_index` is the -/// tab's 0-based position within its class. No Timestamp → plain SQL. Returns (statements, row_count). fn map_talent_tabs(tabs: &DbcTalentTab) -> (Vec, usize) { let rows: Vec = tabs .rows() @@ -40,15 +35,6 @@ fn map_talent_tabs(tabs: &DbcTalentTab) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_talent` from `Talent.dbc` — every class tree's real talents (tier/column/ -/// prereqs/rank spells), replacing the `module/src/talent.rs` demo Warrior seed by id collision (the demo -/// ids 1-8 sit far below the real Talent.dbc id range, so a clear+reload never touches them — see the -/// module doc's KEEP decision). `order_map` resolves each talent's DBC `tab` id to that tab's -/// `order_index` (0/1/2), which becomes `tree_id` (mirrors the demo's Arms/Fury/Protection convention); -/// an unresolved tab (shouldn't happen with both DBCs from the same client) falls back to 0. -/// `max_rank` is derived by counting the nonzero entries in `spell_rank` (rank 1 lives in `spell_id`, -/// ranks 2-5 in `rank_spell_2..5`); `required_points_in_tree` derives from `tier * POINTS_PER_TIER` (5). -/// No Timestamp → plain SQL. Returns (statements, talent_count). fn map_talents(talents: &DbcTalent, order_map: &HashMap) -> (Vec, usize) { let rows: Vec = talents .rows() @@ -57,9 +43,7 @@ fn map_talents(talents: &DbcTalent, order_map: &HashMap) -> (Vec) -> (Vec) -> (Vec --talents` mode: import `TalentTab.dbc` + `Talent.dbc` → `game_talent_tab` + -/// `game_talent`. Dry-run by default (parse + map + print coverage, write NOTHING); `--apply` runs the -/// SQL. Mirrors `spell::run_spells`'s shape. pub fn run_talents(data_dir: &str, args: &Args) -> Result<()> { let dir = Path::new(data_dir); let mut chain = open_chain(dir)?; @@ -189,10 +167,6 @@ pub fn run_talents(data_dir: &str, args: &Args) -> Result<()> { Ok(()) } -// =========================================================================================== -// Tests -// =========================================================================================== - #[cfg(test)] mod tests { use super::*; @@ -255,7 +229,7 @@ mod tests { rows: vec![ tab_row(161, "Arms", 1, 0, 0), tab_row(162, "Fury", 1, 0, 1), - tab_row(163, "O'Ryan's Tab", 1, 0, 2), // apostrophe escaping + tab_row(163, "O'Ryan's Tab", 1, 0, 2), ], }; let (stmts, n) = map_talent_tabs(&tabs); @@ -272,17 +246,16 @@ mod tests { #[test] fn talents_map_two_rank_with_prereq_and_tab_order() { - // tab 161 is the FIRST tab of its class (order_index 0) → tree_id 0. let order_map: HashMap = [(161u32, 0u8)].into_iter().collect(); let talents = DbcTalent { rows: vec![talent_row( 100, 161, - 1, // tier - 0, // column_index - [5001, 5002, 0, 0, 0, 0, 0, 0, 0], // rank 1 + rank 2 (2-rank talent) - [99, 0, 0], // prereq talent 99 - [2, 0, 0], // must be learned to rank 2 + 1, + 0, + [5001, 5002, 0, 0, 0, 0, 0, 0, 0], + [99, 0, 0], + [2, 0, 0], 0, )], }; @@ -293,9 +266,6 @@ mod tests { .iter() .find(|s| s.starts_with("INSERT INTO game_talent ")) .unwrap(); - // talent_id=100,name,tree_id=0,tier=1,column=0,max_rank=2,spell_id=5001,required_talent_id=99, - // required_points_in_tree=5(tier*5),grant_spell_id=0,tab_id=161,rank_spell_2=5002, - // rank_spell_3=0,rank_spell_4=0,rank_spell_5=0,required_talent_rank=2,required_spell_id=0 assert!( insert.contains("(100,'Talent 100',0,1,0,2,5001,99,5,0,161,5002,0,0,0,2,0)"), "{insert}" @@ -304,7 +274,7 @@ mod tests { #[test] fn talents_map_missing_tab_falls_back_to_tree_zero() { - let order_map: HashMap = HashMap::new(); // no TalentTab row for tab 999 + let order_map: HashMap = HashMap::new(); let talents = DbcTalent { rows: vec![talent_row( 1, diff --git a/importer/src/terrain.rs b/importer/src/terrain.rs index 6cf5e7db5..09fd3f8f4 100644 --- a/importer/src/terrain.rs +++ b/importer/src/terrain.rs @@ -1,16 +1,3 @@ -//! `--terrain `, ADT heightmap slice → `game_terrain_chunk`. -//! -//! Reads the operator's OWN client terrain MPQs (same licensing firewall as `--dbc`: extracted -//! data never enters the repo), parses the ADT tiles covering the content slice with `wow-adt`, -//! and loads one row per MCNK cell through the `import_terrain_chunks` reducer(s) — `Vec` -//! has no spacetime-SQL literal, so terrain takes the packed-string reducer path like spawns. -//! Heights are rebased to ABSOLUTE world Z here (MCVT is relative to the chunk header Z), so -//! the module's `ground_z` is pure arithmetic. Dry-run prints the plan + a self-check; `--apply` -//! calls the reducers. Honors `--map/--center/--radius`, or `--box X0,X1,Y0,Y1` in place of -//! `--radius` for an exact rectangle (a wide zone like Westfall+Elwynn together -//! needs a rectangle, not a center-radius circle's bounding square). `--center` still drives the -//! interpolate self-check below, so put it INSIDE the box. - use anyhow::{bail, Context, Result}; use lyracore_shared::terrain::{cell_index as shared_cell_index, cell_key, interpolate}; use std::collections::BTreeMap; @@ -18,19 +5,11 @@ use std::io::Cursor; use std::path::Path; use wow_mpq::PatchChain; -/// Vanilla map id → client map directory name. Both CONTINENTS are supported: map 0 is the Elwynn -/// corridor, and map 1 is the Kalimdor continent, `--terrain`/`--nav --map 1` -/// read `World\Maps\Kalimdor\*.adt` through this same code path, self-checks included. Instance -/// terrain remains refused because this height row represents only one floor per cell. -// Deliberate simplification: two-arm match, not Map.dbc — add arms (or read the DBC) when a third -// CONTINENT matters. pub(crate) fn map_dir(map_id: u32) -> Result<&'static str> { match map_id { 0 => Ok("Azeroth"), 1 => Ok("Kalimdor"), - // Deadmines has ADT tiles, but `game_terrain_chunk` cannot represent its stacked floors. - // Keep terrain absent until archive and route evidence support a multi-floor representation. - // Runtime movement therefore keeps its imported or current Z through the existing fallback. + // A scalar heightmap cannot represent the stacked floors in Deadmines. 36 => bail!( "map 36 (Deadmines) has stacked instance floors that game_terrain_chunk cannot \ represent; terrain import remains unavailable" @@ -39,13 +18,8 @@ pub(crate) fn map_dir(map_id: u32) -> Result<&'static str> { } } -/// Largest gap, in yards, between a slice sample's Z and the heightmap interpolated at its X/Y -/// before the slice is refused. A wrong sample (a WMO floor, a wrong map) is tens of yards off; a -/// right one is within a fraction of a yard, so this is a coarse gate, not a precision claim. const SELF_CHECK_TOLERANCE: f32 = 2.0; -/// Open the terrain patch chain in client load order: `terrain.MPQ` < `patch.MPQ` < `patch-2.MPQ` -/// (ADTs live in terrain.MPQ in the 1.12 client; patches may override tiles). fn open_terrain_chain(data_dir: &Path) -> Result { let mut chain = PatchChain::new(); let mut found_base = false; @@ -113,11 +87,10 @@ pub(crate) struct CellRow { pub(crate) has_liquid: bool, pub(crate) holes: u32, pub(crate) area_id: u32, - pub(crate) heights: Vec, // 145, absolute + pub(crate) heights: Vec, } impl CellRow { - /// `map,cx,cy,liquid,has_liquid,holes,area,h0:h1:...:h144` fn packed(&self) -> String { let hs: Vec = self.heights.iter().map(|h| format!("{h}")).collect(); format!( @@ -134,18 +107,10 @@ impl CellRow { } } -/// World coord → global cell index (0..1023) as i32 for range math. Wraps the shared -/// `lyracore_shared::terrain::cell_index`; out-of-square is -1 (the caller range-checks hard). fn cell_index(coord: f32) -> i32 { shared_cell_index(coord).map(|c| c as i32).unwrap_or(-1) } -/// Cell-index range `(x_min, x_max, y_min, y_max)` covering the content slice: a `--box` -/// rectangle when given (a wide zone like Westfall needs an exact rectangle, not a -/// center±radius bounding square that either misses corners or overshoots into neighbours), else -/// the original center±radius square. Cell indices count DOWN as world coordinates grow (see -/// `cell_index`), so a box's HIGH world coordinate (x1/y1) maps to the LOW cell index and vice -/// versa. Pure so the corner math is unit-tested without a client Data/ dir. pub(crate) fn slice_cell_range( bbox: Option<(f64, f64, f64, f64)>, center: (f64, f64, f64), @@ -212,9 +177,6 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { Ok(()) } -/// Every cell row the scope's Bounded Map Slices cover, deduplicated where slices overlap, plus -/// the tile count read. Each slice's terrain self-check runs here, so a dry run and `--apply` -/// prove the same thing: no slice reaches a reducer with a sample the heightmap contradicts. fn collect_scope( chain: &mut PatchChain, scope: &crate::world_import_scope::WorldImportScope, @@ -261,7 +223,7 @@ fn collect_slice( let tiles = map_tiles(chain, map_name)?; for tx in tx_min..=tx_max { for ty in ty_min..=ty_max { - // WDT filename axes are the reverse of the world-coordinate cell axes. + // WDT filename axes reverse the world-coordinate cell axes. if let Some(tiles) = &tiles { let present = tiles .get((tx * 64 + ty) as usize) @@ -280,7 +242,7 @@ fn collect_slice( let Ok(bytes) = chain.read_file(name) else { continue; }; - // Patch archives remove terrain tiles with empty ADT entries. + // Empty ADT patch entries remove terrain tiles. if bytes.is_empty() { continue; } @@ -349,9 +311,6 @@ fn dedup_rows(slice_rows: Vec>) -> Vec { rows.into_values().collect() } -/// Content check for the filename arbitration: does this parsed tile's terrain actually live in -/// grid tile (tx, ty)? True when every height-bearing MCNK's position-derived cell falls in the -/// tile's 16-cell range on both axes (sampling all chunks; a tile is 256 of them). pub(crate) fn tile_matches(mcnks: &[wow_adt::McnkChunk], tx: i32, ty: i32) -> bool { let mut saw_any = false; for mcnk in mcnks { @@ -370,9 +329,6 @@ pub(crate) fn tile_matches(mcnks: &[wow_adt::McnkChunk], tx: i32, ty: i32) -> bo saw_any } -/// Pull every MCNK inside the cell bbox out of one parsed ADT. Cell indices come from the -/// chunk's own header POSITION (world coords of its high-corner), not from tile/ix/iy -/// bookkeeping — immune to filename-order and row/column folklore. pub(crate) fn collect_cells( mcnks: &[wow_adt::McnkChunk], map_id: u32, @@ -381,7 +337,7 @@ pub(crate) fn collect_cells( ) -> Result<()> { for mcnk in mcnks { let Some(mcvt) = &mcnk.heights else { - continue; // no MCVT (WMO-only chunk) — nothing to stand on, skip + continue; }; if mcvt.heights.len() != 145 { continue; @@ -391,14 +347,13 @@ pub(crate) fn collect_cells( mcnk.header.position[1], mcnk.header.position[2], ); - let (cx, cy) = (cell_index(px - 0.01), cell_index(py - 0.01)); // corner sits ON the boundary; nudge inside + // Nudge boundary corners inside their cell. + let (cx, cy) = (cell_index(px - 0.01), cell_index(py - 0.01)); if cx < x_min || cx > x_max || cy < y_min || cy > y_max { continue; } let heights: Vec = mcvt.heights.iter().map(|h| h + pz).collect(); - // MCLQ max_height is the vanilla water surface for the near-flat classic liquids. - // Deliberate simplification: per-cell scalar, not the per-vertex surface — store the grid - // when swimming physics needs per-vertex accuracy. + // MCLQ max_height supplies the scalar liquid surface for near-flat classic liquids. let (has_liquid, liquid_level) = match &mcnk.liquid { Some(l) => (true, l.max_height), None => (false, 0.0), @@ -494,8 +449,6 @@ mod tests { assert!(format!("{error:#}").contains("parsing World"), "{error:#}"); } - /// The operator's own 1.12.1 client `Data/` dir. Client archives never enter the repo, so the - /// tests that read it are ignored by default. fn client_data_dir() -> Option { let dir = std::env::var_os("LYRACORE_CLIENT_DATA")?; Some(std::path::PathBuf::from(dir)) @@ -504,9 +457,6 @@ mod tests { #[test] #[ignore = "requires the Operator's LYRACORE_CLIENT_DATA"] fn alliance_eastern_terrain_dry_run_passes_every_slice_self_check() { - // Verification against the owned client: the same `collect_scope` a `--terrain - // --world-profile alliance-eastern` dry run takes, so a green result proves the profile's - // Human, Dun Morogh and Loch Modan samples stand on the heightmap the Module will read. let data_dir = client_data_dir() .expect("LYRACORE_CLIENT_DATA must name the owned client Data directory"); let scope = WorldImportScope::canonical(WorldImportProfile::AllianceEastern) @@ -556,14 +506,11 @@ mod tests { #[test] fn slice_cell_range_from_box_uses_corners_not_center_radius() { - // The widened Westfall box. High world X/Y (x1/y1) map to the LOW cell index - // (cell index counts DOWN as world coords grow) — the min/max pairing is corner-derived, not - // center±radius, and radius is ignored entirely once a box is given. let (x0, x1, y0, y1) = (-11400.0f64, -8000.0, -1600.0, 2000.0); let (cx_min, cx_max, cy_min, cy_max) = slice_cell_range(Some((x0, x1, y0, y1)), (-8949.95, -132.493, 83.5312), 180.0); - assert_eq!(cx_min, cell_index(x1 as f32)); // high world X → low cell index - assert_eq!(cx_max, cell_index(x0 as f32)); // low world X → high cell index + assert_eq!(cx_min, cell_index(x1 as f32)); + assert_eq!(cx_max, cell_index(x0 as f32)); assert_eq!(cy_min, cell_index(y1 as f32)); assert_eq!(cy_max, cell_index(y0 as f32)); assert!(cx_min < cx_max); @@ -579,13 +526,11 @@ mod tests { err36.contains("stacked instance floors"), "the map-36 refusal names the floor representation limit: {err36}" ); - // Any other unknown map still fails loud. assert!(super::map_dir(429).is_err()); } #[test] fn slice_cell_range_without_box_falls_back_to_center_radius_square() { - // No --box: reproduces the original center±radius bounding square exactly. let center = (-8949.95f64, -132.493, 83.5312); let radius = 180.0f64; let got = slice_cell_range(None, center, radius); diff --git a/importer/src/ui_transform.rs b/importer/src/ui_transform.rs index 5fcadadfa..6220f3431 100644 --- a/importer/src/ui_transform.rs +++ b/importer/src/ui_transform.rs @@ -1,24 +1,5 @@ -//! Parses a Package's `ui-transforms.json` and composes its edits against a baseline FrameXML or -//! GlueXML file. Pure string logic: no file system, no MPQ, no client. `pack_client.rs` reads the -//! baseline bytes from the operator's own client, groups edits by normalised path, and calls -//! [`compose`]; this module owns none of that I/O. -//! -//! Invariants `compose` enforces for one path: -//! - single anchor: each edit's anchor text occurs exactly once in the baseline; -//! - disjoint spans: no two edits may claim overlapping byte ranges of the baseline; -//! - order independence: spans are computed against the original baseline before any edit is -//! applied, then applied in ascending span order, so the composed output does not depend on -//! the order `edits` arrives in (e.g. package enable order). - use anyhow::{anyhow, bail, Context, Result}; -/// One edit a `ui-transforms.json` asked for. `source` is the caller's own label for the -/// contribution root (`package loot`, or `client-patch`) and every refusal message opens with it, -/// so this module never adds a word of its own in front. -/// -/// `path` is normalised to backslashes (`Interface\FrameXML\...`) and is compared -/// case-insensitively by callers that group edits across Packages; this module never lowercases -/// it, so messages keep the author's own casing. #[derive(Debug)] pub(crate) struct Edit { pub(crate) source: String, @@ -27,8 +8,6 @@ pub(crate) struct Edit { pub(crate) insert: String, } -/// Where an edit attaches to the baseline. The wrapped string is the anchor text: a byte range -/// [`compose`] must find exactly once in the baseline. #[derive(Debug)] pub(crate) enum Anchor { Before(String), @@ -43,7 +22,6 @@ impl Anchor { } } - /// A stable tag for canonical rendering ([`transforms_hash`]); never shown to a Package author. fn tag(&self) -> u8 { match self { Anchor::Before(_) => 0, @@ -53,14 +31,6 @@ impl Anchor { } } -/// Parses one contribution root's `ui-transforms.json`. `source` is the caller's label for that -/// root and opens every refusal message; the JSON itself names no owner. -/// -/// Refuses: a document that isn't a JSON array; an entry that isn't a JSON object; an entry -/// missing `path` or `insert`; an entry with zero or several of `before`/`after`/`replace`; an -/// anchor that isn't a string or is empty; a `path` outside `Interface\FrameXML\` and -/// `Interface\GlueXML\` (either slash direction, case-insensitive); a `path` whose extension -/// isn't `.lua`, `.xml`, or `.toc`. pub(crate) fn parse(source: &str, json: &str) -> Result> { let value: serde_json::Value = serde_json::from_str(json) .with_context(|| format!("{source}: ui-transforms.json is not valid JSON"))?; @@ -130,15 +100,7 @@ fn validate_path(source: &str, path: &str) -> Result<()> { Ok(()) } -/// Composes the edits for one baseline file. `edits` must already share one normalised `path` -/// (the caller groups by it); `path` is used only to name it in refusal messages. -/// -/// Each anchor must occur exactly once in `baseline`: zero occurrences refuses as "anchor not -/// found", more than one as "ambiguous anchor". Two edits whose anchor byte ranges intersect -/// refuse as an overlap naming both sources; identical anchors always intersect. Spans are -/// resolved against `baseline` before any edit is applied, then applied in ascending order, so -/// the result does not depend on `edits`' input order. `Before` inserts before the anchor, -/// `After` after it, `Replace` substitutes it. +// Resolve every span against the original baseline before applying edits. pub(crate) fn compose(path: &str, baseline: &str, edits: &[&Edit]) -> Result { struct Located<'a> { edit: &'a Edit, @@ -206,11 +168,6 @@ pub(crate) fn compose(path: &str, baseline: &str, edits: &[&Edit]) -> Result String { let body = format!( "Generated by LyraCore from the operator's client. baseline={baseline_hash} transforms={transforms_hash}" @@ -227,13 +184,9 @@ pub(crate) fn record_header(path: &str, baseline_hash: &str, transforms_hash: &s } } -/// Domain-separated so a future change to the canonical rendering below cannot collide with -/// today's hashes. +// Domain separation prevents collisions with a future canonical rendering. const TRANSFORMS_HASH_DOMAIN: &[u8] = b"lyracore-ui-transform-v1"; -/// Blake3 over a canonical rendering of `edits`, sorted independently of the slice's own order, -/// so two runs that assemble the same edits in a different order (e.g. package enable order) -/// hash identically on any machine. pub(crate) fn transforms_hash(edits: &[&Edit]) -> String { const FIELD_SEP: char = '\u{1}'; diff --git a/importer/src/vmap.rs b/importer/src/vmap.rs index 3a73d69f1..0deedbfb9 100644 --- a/importer/src/vmap.rs +++ b/importer/src/vmap.rs @@ -1,8 +1,3 @@ -//! Client geometry extraction for `--vmap` and `--vmap-prepare-coverage`. -//! Tile scans reuse nav mesh loading and placement calibration. Collision triangles use full -//! rotation and the shared per-cell codec. `--apply` sends bounded batches to the import reducers. -//! Client bytes stay in memory. - use anyhow::{bail, Context, Result}; use lyracore_shared::terrain::cell_key; use lyracore_shared::vmap::{encode, TriClass, VmapTri, HEADER_BYTES, TRI_BYTES}; @@ -15,20 +10,7 @@ use wow_mpq::PatchChain; use crate::nav::{Mesh, Placement, Tri}; -/// Full local→world rotation, unlike `nav::apply`'s yaw-only shortcut (the rasterizer's documented -/// simplification — thin walls with real pitch/roll rasterize slightly wrong but the walkability -/// grid tolerates it; an exact triangle store must not). -/// -/// Axis order confirmed against the reference VMAP extractor convention (TrinityCore -/// `model.cpp::ExtractSet`): `Matrix3::fromEulerAnglesZYX(Rotation.y, Rotation.x, Rotation.z)`, -/// i.e. world rotation = Rz(yaw) · Ry(pitch) · Rx(roll) applied to the local vertex, where -/// yaw = `rotation[1]`, pitch = `rotation[0]`, roll = `rotation[2]` — matching `nav.rs`'s existing -/// yaw-only convention (which already uses `rotation[1]` as the calibrated Z-axis term). Only the -/// yaw term is empirically calibrated against MODF bounds (`nav::calibrate`); pitch/roll compose in -/// the same local frame before that calibrated yaw+translate. Reasonable synthesis of a documented -/// convention, not verified against a live tilted placement — flag for a measured follow-up if one -/// turns up wrong (same "deliberate simplification, revisit when a live case demands it" posture -/// `nav.rs` uses elsewhere). +// Apply local roll, local pitch, then calibrated world yaw. pub(crate) fn apply_full( conv: crate::nav::Convention, rotation: [f32; 3], @@ -43,13 +25,10 @@ pub(crate) fn apply_full( }; let mut p = [l[0] * scale, l[1] * scale, l[2] * scale]; - // Roll about local X (rotation[2]). let (rs, rc) = rotation[2].to_radians().sin_cos(); p = [p[0], rc * p[1] - rs * p[2], rs * p[1] + rc * p[2]]; - // Pitch about local Y (rotation[0]). let (ps, pc) = rotation[0].to_radians().sin_cos(); p = [pc * p[0] + ps * p[2], p[1], -ps * p[0] + pc * p[2]]; - // Calibrated yaw about world Z (rotation[1], same sign/offset nav.rs's rasterizer uses). let theta = (conv.sign * rotation[1] + conv.offset_deg).to_radians(); let (ys, yc) = theta.sin_cos(); [ @@ -128,8 +107,6 @@ fn for_each_placement_tri( } } -/// A world-space triangle's cell-index rectangle (high coord → LOW cell index, same convention as -/// `terrain::cell_index`/`nav.rs`'s binning). None when the triangle falls off the map square. fn tri_cell_range(t: &VmapTri) -> Option<(u16, u16, u16, u16)> { let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); for v in &t.verts { @@ -149,14 +126,12 @@ fn tri_cell_range(t: &VmapTri) -> Option<(u16, u16, u16, u16)> { Some((cx0, cx1, cy0, cy1)) } -/// Bin triangles by the terrain cell key; a triangle spanning a cell boundary lands in EVERY cell -/// its AABB touches (conservative — matches the nav rasterizer's binning direction). #[cfg(test)] pub(crate) fn bin_by_cell(map_id: u32, tris: &[VmapTri]) -> HashMap> { let mut by_cell: HashMap> = HashMap::new(); for t in tris { let Some((cx0, cx1, cy0, cy1)) = tri_cell_range(t) else { - continue; // off the map square (shouldn't happen inside the box) + continue; }; for cx in cx0..=cx1 { for cy in cy0..=cy1 { @@ -172,8 +147,6 @@ pub(crate) fn bin_by_cell(map_id: u32, tris: &[VmapTri]) -> HashMap Result { .any(|row| row[0].as_u64() == Some(map_id as u64))) } -/// SpacetimeDB JSON SQL returns a result set as `schema.elements` plus array rows, not JSON -/// objects. Match columns by schema name so a query cannot mistake row-count text for ownership. fn sql_column_rows<'a>( value: &'a serde_json::Value, columns: &[&str], @@ -408,8 +379,6 @@ fn active_generation_status(args: &crate::Args, map_id: u32) -> Result<()> { Ok(()) } -/// A vmap generation follows already-owned spatial data. Fail before opening client archives when -/// this database cannot authoritatively serve the requested map. fn preflight_map_ownership(args: &crate::Args, map_id: u32) -> Result<()> { let query = |table: &str| -> Result { sql_query(args, &format!("SELECT map_id FROM {table}")) @@ -1124,8 +1093,6 @@ fn point_in_sphere(point: [f32; 3], center: [f32; 3], radius: f32) -> bool { } const ROUTE_FLOOR_EPSILON_YD: f32 = 0.01; -/// Static support sampling interval. It finds narrow holes without claiming to reproduce a -/// Character's committed movement leg, which has caller-specific distance and height behavior. const INSTANCE_ROUTE_SAMPLE_YD: f32 = 0.5; fn instance_route_sample( @@ -1449,44 +1416,28 @@ fn apply_plan(args: &crate::Args, plan: &VmapPlan) -> Result<()> { Ok(()) } -/// Byte budget per `spacetime call` payload, grouping several shard rows into one call (matches -/// `nav.rs`'s `BATCH_BYTES`). const BATCH_BYTES: usize = 28_000; -/// Keep each hex-encoded row below Linux's ~128 KB argv-string limit. +// Hex-encoded rows must remain below the Linux argv-string limit of about 128 KB. const MAX_ROW_TRI_BYTES: usize = 20_000; -/// Bounds the 45-byte spool records retained by one Instance Vmap Slice to 22.5 MB. +// The 45-byte spool records occupy at most 22.5 MB per Instance Vmap Slice. const MAX_INSTANCE_SELECTED_TRI_REFS: usize = 500_000; -/// Split one cell's triangle list into `lyracore_shared::vmap::encode`d shards, each at most -/// `MAX_ROW_TRI_BYTES`. Order within a cell doesn't matter (the module concatenates every shard's -/// decoded triangles back into one list — see `module/src/vmap.rs`'s `fetcher`), so this just -/// chunks by triangle count. #[cfg(test)] fn shard_cell(tris: &[VmapTri]) -> Vec> { let per_shard = ((MAX_ROW_TRI_BYTES.saturating_sub(HEADER_BYTES)) / TRI_BYTES).max(1); if tris.is_empty() { - return vec![encode(tris)]; // preserve the header-only "cell exists but empty" row + return vec![encode(tris)]; } tris.chunks(per_shard).map(encode).collect() } -// ============================================================================================= -// `--vmap-prepare-coverage ` — drives `prepare_vmap_nav_coverage` / -// `finalize_vmap_nav_coverage` (module/src/vmap.rs) over a generation's OWN staged chunks. No -// client Data/ dir: the derivation runs module-side from data already staged by an earlier -// `--vmap --apply` run, so this workflow only enumerates cells and drives batches. -// ============================================================================================= - -/// The generation's map + lifecycle state, read before any coverage work so an ineligible target -/// fails closed the same way `preflight_map_ownership` does for `--vmap`. struct GenerationInfo { map_id: u32, state: u64, } -/// Lifecycle state codes mirrored from `module/src/vmap.rs` (no shared crate for a bare u8 enum). const VERIFIED: u64 = 1; const ACTIVE: u64 = 2; @@ -1506,8 +1457,6 @@ fn generation_info(args: &crate::Args, generation_id: u64) -> Result Result> { let output = sql_query( args, @@ -1526,9 +1475,6 @@ fn generation_cells(args: &crate::Args, generation_id: u64) -> Result, @@ -1563,10 +1506,6 @@ fn pending_cells( .collect() } -/// Split `cells` into JSON-array `prepare_vmap_nav_coverage` arguments, each bounded by -/// `BATCH_BYTES` like every other reducer batch in this file. Batch boundaries are an efficiency -/// detail only — the reducer is idempotent per cell, so splitting differently reproduces the same -/// final coverage set and manifest digest (deterministic coverage identity). fn coverage_batches(cells: &[(u16, u16)]) -> Vec { let mut batches = Vec::new(); let mut cur = String::from("["); @@ -1588,10 +1527,6 @@ fn coverage_batches(cells: &[(u16, u16)]) -> Vec { batches } -/// `--vmap-prepare-coverage `: derive and store nav coverage for an already-staged -/// generation, resumable and manifest-reporting. Reuses `preflight_map_ownership` fail-closed, so -/// coverage never prepares on a World Shard that does not own the map and, like `--vmap`, only writes -/// with `--apply`; without it this reports the plan and touches nothing. pub(crate) fn run_coverage(args: &crate::Args, generation_id: u64) -> Result<()> { let generation = generation_info(args, generation_id)?; preflight_map_ownership(args, generation.map_id)?; @@ -1651,8 +1586,6 @@ mod tests { const MAP: u32 = 0; - /// World-coord bounds of terrain cell (cx, cy): (x_hi, x_lo, y_hi, y_lo) — cell indices count - /// DOWN as world coords grow, same convention as `terrain::cell_index`. fn cell_bounds(cx: u16, cy: u16) -> (f32, f32, f32, f32) { let x_hi = MAP_COORD_MAX - cx as f32 * CELL_SIZE; let x_lo = x_hi - CELL_SIZE; @@ -1668,10 +1601,6 @@ mod tests { } } - /// Identity convention (no axis shuffle, yaw = rotation[1] verbatim) — isolates the - /// pitch/roll math in `apply_full` from the calibrated-convention machinery so the assertions - /// below are hand-computable straight from the doc comment's `Rz(yaw)*Ry(pitch)*Rx(roll)` - /// formula. fn identity_convention() -> crate::nav::Convention { crate::nav::Convention { shuffle: false, @@ -1682,8 +1611,6 @@ mod tests { #[test] fn roll_of_90_degrees_rotates_local_y_onto_world_z() { - // rotation = [pitch=0, yaw=0, roll=90]. Rx(90) sends the local Y axis to local/world Z - // (roll is applied about local X first, before pitch/yaw, both of which are identity here). let out = apply_full( identity_convention(), [0.0, 0.0, 90.0], @@ -1698,8 +1625,6 @@ mod tests { #[test] fn pitch_of_90_degrees_rotates_local_x_onto_negative_world_z() { - // rotation = [pitch=90, yaw=0, roll=0]. Ry(90) sends the local X axis to −Z (roll and yaw - // are identity here, so this isolates the pitch term). let out = apply_full( identity_convention(), [90.0, 0.0, 0.0], @@ -1714,13 +1639,6 @@ mod tests { #[test] fn roll_then_pitch_composition_order_is_roll_first() { - // rotation = [pitch=90, yaw=0, roll=90], v = local Y. Isolated-axis tests above can't catch - // an axis-order swap (both leave a lone X or lone Y vector on a single output axis either - // way); this vector has zero X/Z components, so the two orders diverge. Roll-then-pitch - // (the order `apply_full` applies): Rx(90) sends local Y (0,1,0) to local Z (0,0,1), then - // Ry(90) sends local Z (0,0,1) to world X (1,0,0). Pitch-then-roll would instead leave the - // vector at local Y all the way through pitch (Ry(90) doesn't touch a pure-Y vector), then - // Rx(90) would send it to (0,0,1) — a different result, so this test pins the order. let out = apply_full( identity_convention(), [90.0, 0.0, 90.0], @@ -1747,10 +1665,8 @@ mod tests { #[test] fn a_triangle_spanning_a_cell_boundary_lands_in_every_cell_it_touches() { - // Straddle the x boundary between cell 500 and 501 (a wide-enough triangle on the x axis, - // centered exactly on the boundary), fully inside one cell on the y axis. let (x_hi_500, _x_lo_500, y_hi, y_lo) = cell_bounds(500, 500); - let boundary_x = x_hi_500; // the coordinate separating cell 500 from cell 501 + let boundary_x = x_hi_500; let cy = (y_hi + y_lo) / 2.0; let t = tri_at(boundary_x, cy, 100.0, CELL_SIZE * 0.4, TriClass::M2); let by_cell = bin_by_cell(MAP, &[t]); @@ -1786,8 +1702,6 @@ mod tests { assert!(back.contains(&m2)); } - /// A cell whose triangle count fits comfortably under `MAX_ROW_TRI_BYTES` shards to exactly - /// ONE blob — the common case shouldn't multiply row count for no reason. #[test] fn a_small_cell_shards_to_exactly_one_blob() { let tris: Vec = (0..5) @@ -1799,11 +1713,10 @@ mod tests { assert_eq!(back.len(), 5); } - /// Dense cells split into byte-capped shards whose decoded union preserves every triangle. #[test] fn a_dense_cell_splits_into_byte_capped_shards_that_reassemble_exactly() { let per_shard = (MAX_ROW_TRI_BYTES - HEADER_BYTES) / TRI_BYTES; - let n = per_shard * 3 + 7; // spans multiple shards, last one partial + let n = per_shard * 3 + 7; let tris: Vec = (0..n) .map(|i| tri_at(0.0, 0.0, i as f32, 1.0, TriClass::M2)) .collect(); @@ -1825,9 +1738,6 @@ mod tests { .flat_map(|b| lyracore_shared::vmap::decode(b).expect("shard round trip")) .collect(); let mut expected = tris.clone(); - // Order isn't part of the contract (the module concatenates shards in storage order, not - // triangle-original order) — compare as multisets via a stable sort on the z coordinate, - // which is unique per triangle in this fixture. reassembled.sort_by(|a, b| a.verts[0][2].partial_cmp(&b.verts[0][2]).unwrap()); expected.sort_by(|a, b| a.verts[0][2].partial_cmp(&b.verts[0][2]).unwrap()); assert_eq!(reassembled, expected); @@ -1837,8 +1747,6 @@ mod tests { fn disk_spool_orders_cells_and_bounds_live_shard_memory() { let spool = VmapSpool::new(77).unwrap(); let per_shard = (MAX_ROW_TRI_BYTES - HEADER_BYTES) / TRI_BYTES; - // Insert deliberately reverse key order; output must be keyed order, independent of - // filesystem enumeration. Each cell is read one shard at a time, not as the full slice. for key in [9u64, 3] { for i in 0..(per_shard * 2 + 1) { spool @@ -2163,9 +2071,6 @@ mod tests { ); } - /// A fixture too large for one `BATCH_BYTES` call must split into several, and the split must - /// be lossless and order-independent — batch boundaries are an efficiency detail, not part of - /// coverage identity (the reducer keys each cell independently). #[test] fn coverage_batches_split_a_fixture_larger_than_one_batch_and_reassemble_exactly() { let cells: Vec<(u16, u16)> = (0..2000u32) diff --git a/importer/src/world_import_scope.rs b/importer/src/world_import_scope.rs index f551460dc..9ecdaba5e 100644 --- a/importer/src/world_import_scope.rs +++ b/importer/src/world_import_scope.rs @@ -136,17 +136,6 @@ impl WorldImportScope { } pub(crate) fn canonical(profile: WorldImportProfile) -> Result { - // Human bounds are the existing canonical importer rectangle. Dwarf/Gnome and Night Elf - // bounds were narrowed with `--print-extents` against the local classic-db dump. Their - // samples are `playercreateinfo` anchors or real creature rows emitted by that command. - // Keeping the catalogue here lets later Verification refine data without changing callers. - // - // A sample must stand on the ADT heightmap: the terrain self-check interpolates the - // heightmap at the sample and the nav self-check rasterizes it, so a spawn on a WMO floor - // (a cave, a porch, a dam) reads tens of yards off the surface. The Loch Modan sample is - // Mountaineer Kadrell's spawn on the Thelsamar road; the owned 1.12.1 client's heightmap - // interpolates to 327.700 there. The nearest-to-centre spawn `--print-extents` offers for - // this rectangle is a Tunnel Rat Surveyor 28 yards under the surface in Silver Stream Mine. let eastern = || -> Result> { Ok(vec![ BoundedMapSlice::rectangle( @@ -186,8 +175,6 @@ impl WorldImportScope { ]) }; - // These Eastern Kingdoms quest givers complete chains whose other endpoints are in the - // bounded slices. Their placed spawns bypass slice geometry, but never the scope's map fence. let eastern_forced_creatures = || { vec![ 344, 11_406, 266, 415, 1_343, 6_966, 5_165, 6_166, 6_569, 5_149, @@ -264,8 +251,7 @@ impl WorldImportScope { } fn full(profile: WorldImportProfile) -> Result { - // The client continent grid is 64 tiles of 533 1/3 yards, centred on zero. - // Stay inside its outer edge so inclusive cell ranges remain in 0..1024. + // Stay inside the 64-tile client grid so inclusive cell indices remain below 1024. let bounds = (-17_066.66, 17_066.66, -17_066.66, 17_066.66); let mut slices = Vec::new(); if matches!( @@ -741,9 +727,6 @@ mod tests { #[test] fn loch_modan_anchor_is_the_thelsamar_road_spawn_inside_its_own_slice() { - // Mountaineer Kadrell's spawn on the Thelsamar road: a heightmap ground point, not a WMO - // floor. The earlier Silver Stream Mine sample sat 28 yards under the surface and failed - // the terrain self-check on the live Realm. let kadrell = (-5_367.58, -2_936.21, 327.64); let eastern = WorldImportScope::canonical(WorldImportProfile::AllianceEastern) .expect("eastern profile"); diff --git a/install.sh b/install.sh index 2ab2c3605..26da3c86e 100755 --- a/install.sh +++ b/install.sh @@ -1,59 +1,19 @@ #!/bin/sh -# LyraCore installer (#302) — one line from a cold machine to a prereq-checked checkout: -# -# curl -sSfL https://raw.githubusercontent.com/LyraCoreProject/LyraCore/main/install.sh | sh -# -# Or, to read it before you run it (recommended for anything you pipe to a shell): -# -# curl -sSfLO https://raw.githubusercontent.com/LyraCoreProject/LyraCore/main/install.sh -# less install.sh && sh install.sh -# -# What it does, in the order docs/quickstart.md §1 documents: -# -# 1. refuses immediately if ./LyraCore already exists — a re-run touches nothing; -# 2. checks the prerequisites. It NEVER runs sudo: a missing system package prints the exact -# apt-get line for you to run yourself and stops; -# 3. offers to install rustup, SpacetimeDB exactly 2.7.1, and wasm-opt (Binaryen) — all user-local, -# no root. wasm-opt is optional: spacetime publish just warns and skips module optimisation -# without it, so a decline, a download failure, or an unsupported platform all fall through to -# an apt/brew hint rather than stopping anything; -# 4. clones the repository into ./LyraCore; -# 5. installs a ten-line checkout-resolving launcher at $HOME/.local/bin/lyracore, so the CLI is -# typed as bare `lyracore` from anywhere inside any checkout while EVERY checkout keeps the CLI -# revision pinned in its own .lyracore-cli-rev. `./lyracore` keeps working verbatim; -# 6. if $HOME/.local/bin is not already on PATH, prints the export line and offers to append one -# marked, idempotent line to one shell profile. -# -# Flags: --yes (-y) assume yes to every prompt; --help. -# -# Everything it writes: ./LyraCore, $HOME/.local/bin/lyracore, the rustup and SpacetimeDB installers' -# own roots, a Binaryen release tree under $HOME/.local/share/lyracore/ plus a $HOME/.local/bin/wasm-opt -# symlink to it (each only if you consent to running/downloading them), and — only if you consent — -# one line in one shell profile. It writes nothing else, and it never needs root. -# -# POSIX sh, Linux and macOS, matching the ./lyracore shim's style: this runs before anything at all -# is installed, so it may not assume bash. +# POSIX sh is required before any toolchain is installed. set -eu REPO_URL=https://github.com/LyraCoreProject/LyraCore.git REPO_URL_RAW=https://raw.githubusercontent.com/LyraCoreProject/LyraCore/main/install.sh TARGET_DIR=LyraCore SPACETIME_VERSION=2.7.1 -# Binaryen (wasm-opt) pin — bump periodically. Verify the asset still exists at -# https://github.com/WebAssembly/binaryen/releases/tag/version_$BINARYEN_VERSION before bumping; -# release asset naming has changed across Binaryen versions before (aarch64-linux is a recent -# addition, absent from older releases). BINARYEN_VERSION=131 BIN_DIR=$HOME/.local/bin LAUNCHER=$BIN_DIR/lyracore -# Both greppable and load-bearing: the profile line is only ever appended when this marker is absent, -# and a pre-existing ~/.local/bin/lyracore is only overwritten when it carries the launcher's. PROFILE_MARKER='# added by the LyraCore installer' LAUNCHER_MARKER='# LyraCore launcher (installed by install.sh)' -# The prompt reads from the terminal, not from stdin: in a `curl | sh` run stdin IS the script. assume_yes=0 -# PATH as the user's shell will have it — the checks below may prepend $BIN_DIR for their own use. +# Retain the PATH inherited by this installer for the final shell instructions. original_path=${PATH:-} os=$(uname -s) arch=$(uname -m) @@ -85,9 +45,7 @@ confirm() { printf '%s [y/N] y (--yes)\n' "$1" return 0 fi - # Not `[ -r /dev/tty ]`: the device node usually exists and is readable even when the process - # has no controlling terminal — only opening it tells you, and the failed open would otherwise - # leak a raw shell error over this prompt. + # Opening /dev/tty detects a controlling terminal; a readable device node does not. if ! (exec /dev/null; then fail "no terminal to prompt on (a piped \`curl | sh\` run) and --yes was not given. Re-run as: curl -sSfL $REPO_URL_RAW | sh -s -- --yes @@ -101,9 +59,6 @@ confirm() { esac } -# Same shape as confirm(), for a prompt that gates something optional (wasm-opt): a missing -# terminal is not fatal here — with nothing to ask on and no --yes, the safe default is to skip -# the optional install and keep going, not to abort an otherwise-good run like confirm() does. confirm_optional() { if [ "$assume_yes" -eq 1 ]; then printf '%s [y/N] y (--yes)\n' "$1" @@ -137,11 +92,7 @@ while [ $# -gt 0 ]; do shift done -# --------------------------------------------------------------------------- -# 1. Refuse an existing checkout FIRST, before installing anything. -# --------------------------------------------------------------------------- -# This is deliberately the very first thing that happens: a second run in the same directory must -# exit non-zero having changed nothing at all, not after half an hour of toolchain installs. +# Refuse an existing checkout before installing prerequisites. if [ -e "$TARGET_DIR" ]; then fail "$(pwd)/$TARGET_DIR already exists — nothing was changed. Move or remove it, or run the installer from another directory." @@ -149,13 +100,9 @@ fi say "LyraCore installer — checking prerequisites (nothing here runs sudo)." -# --------------------------------------------------------------------------- -# 2. Prerequisites we cannot install without root: report, never sudo. -# --------------------------------------------------------------------------- missing='' apt_pkgs='' want() { - # want missing="$missing $1" case " $apt_pkgs " in *" $2 "*) ;; @@ -163,13 +110,9 @@ want() { esac } -# Same shape as want(), but for prerequisites that are soft: the stack works without them, so a -# miss is reported and the install continues rather than aborting. wasm-opt is the one example -# today — `spacetime publish` runs fine without it, it just skips module optimisation. recommended='' recommended_pkgs='' want_recommended() { - # want_recommended recommended="$recommended $1" case " $recommended_pkgs " in *" $2 "*) ;; @@ -185,7 +128,7 @@ if [ "$os" = Linux ]; then want "a C compiler" build-essential fi if have pkg-config; then - # The gateway's SpacetimeDB SDK pulls in native-tls, which links system OpenSSL on Linux. + # The Gateway's native-tls dependency links system OpenSSL on Linux. if ! pkg-config --exists openssl; then want "OpenSSL development headers" libssl-dev fi @@ -215,17 +158,9 @@ if [ -n "$missing" ]; then fi note "✓ system packages" -# --------------------------------------------------------------------------- -# 3. rustup — user-local, no root. -# --------------------------------------------------------------------------- -# rustup reads the checkout's rust-toolchain.toml and fetches the pinned toolchain itself on the -# first cargo command, so there is deliberately no version check or `rustup update` here. -# Tracks whether THIS shell only sees cargo because we just sourced ~/.cargo/env for our own use — -# rustup's PATH line applies to shells started after the install, so the shell that ran this script -# needs the same reminder in step 8 below, whether cargo was already there or installed just now. +# rustup reads the checkout's rust-toolchain.toml on the first cargo command. cargo_needs_reload=0 if ! have cargo && [ -f "$HOME/.cargo/env" ]; then - # Installed already, just not exported into this non-login shell. # shellcheck disable=SC1091 . "$HOME/.cargo/env" cargo_needs_reload=1 @@ -250,12 +185,6 @@ else note "✓ Rust ($(cargo --version 2>/dev/null || echo cargo))" fi -# --------------------------------------------------------------------------- -# 4. SpacetimeDB — exactly 2.7.1. -# --------------------------------------------------------------------------- -# The installer lands `spacetime` in $HOME/.local/bin, which is exactly why the launcher below can -# piggyback on that PATH entry rather than inventing a new one. Prepend it for the rest of THIS -# script only; $original_path is what the user's own shell will see. case ":$PATH:" in *":$BIN_DIR:"*) ;; *) PATH="$BIN_DIR:$PATH" ;; @@ -272,7 +201,6 @@ if ! have spacetime; then have spacetime || fail "SpacetimeDB installed, but \`spacetime\` is not in $BIN_DIR" fi -# `spacetime --version` prints e.g. "spacetimedb tool version 2.7.1; ...". found=$(spacetime --version 2>/dev/null | sed -n 's/.*version[ ]*\([0-9][0-9.]*\).*/\1/p' | head -n 1) if [ "$found" = "$SPACETIME_VERSION" ]; then note "✓ SpacetimeDB $found" @@ -289,15 +217,7 @@ else note "✓ SpacetimeDB $SPACETIME_VERSION" fi -# --------------------------------------------------------------------------- -# 5. wasm-opt (Binaryen) — user-local, no root. -# --------------------------------------------------------------------------- -# wasm-opt is a soft prerequisite (#436): `spacetime publish` runs it to optimise the compiled -# module wasm and just warns if it's absent. Unlike rustup and SpacetimeDB, Binaryen ships no -# installer script, so this fetches a release tarball straight from GitHub. The extracted tree -# (not just bin/wasm-opt) is kept under $HOME/.local/share/lyracore/, because wasm-opt's own rpath -# points at lib/ next to it in at least some Binaryen builds — copying the binary alone can leave -# it unable to find its own library. +# Keep the Binaryen lib/ directory beside wasm-opt for its runtime library lookup. if have wasm-opt; then note "✓ wasm-opt ($(wasm-opt --version 2>/dev/null || echo wasm-opt))" else @@ -362,28 +282,16 @@ if [ -n "$recommended" ]; then fi fi -# --------------------------------------------------------------------------- -# 6. Clone. -# --------------------------------------------------------------------------- say "" say "Cloning $REPO_URL into $(pwd)/$TARGET_DIR ..." git clone "$REPO_URL" "$TARGET_DIR" || fail "git clone failed" -# --------------------------------------------------------------------------- -# 7. The checkout-resolving launcher. -# --------------------------------------------------------------------------- -# Putting a checkout's own ./lyracore on PATH would defeat the shim: one global CLI would shadow -# every checkout's pin and only one clone would work. This launcher instead resolves the checkout -# you are standing in, so N clones with N different pins coexist and it never needs updating. if [ -e "$LAUNCHER" ] && ! grep -Fq "$LAUNCHER_MARKER" "$LAUNCHER" 2>/dev/null; then fail "$LAUNCHER already exists and was not written by this installer — leaving it alone. Remove it yourself if you want the launcher, or just use ./lyracore inside the checkout." fi mkdir -p "$BIN_DIR" -# The body goes through a QUOTED heredoc so it is written verbatim — no shell expansion, no -# backslash escaping to get subtly wrong. Only the marker line is interpolated, so the string this -# script greps for and the string it writes cannot drift apart. { printf '#!/bin/sh\n' printf '%s — https://github.com/LyraCoreProject/LyraCore\n' "$LAUNCHER_MARKER" @@ -411,9 +319,6 @@ chmod +x "$LAUNCHER" say "" note "✓ launcher installed at $LAUNCHER" -# --------------------------------------------------------------------------- -# 8. PATH: append nothing if $HOME/.local/bin is already there. -# --------------------------------------------------------------------------- path_export='export PATH="$HOME/.local/bin:$PATH"' path_line="$path_export $PROFILE_MARKER" path_ready=0 @@ -451,15 +356,10 @@ case ":$original_path:" in ;; esac -# --------------------------------------------------------------------------- -# 9. What to do next. -# --------------------------------------------------------------------------- say "" say "✓ LyraCore is installed in $(pwd)/$TARGET_DIR" say "" if [ "$cargo_needs_reload" -eq 1 ]; then - # rustup's PATH line only applies to shells started after it ran, so this one — the one running - # `lyracore doctor` next — still can't see cargo even though Rust is installed. Same fix either way. say 'This shell doesn'"'"'t have cargo on PATH yet (rustup only wires up new shells) — run: . "$HOME/.cargo/env"' say "(or start a new shell, which picks this up too)" say "" diff --git a/lyracore b/lyracore index 1906d98c3..69728534b 100755 --- a/lyracore +++ b/lyracore @@ -1,15 +1,4 @@ #!/bin/sh -# Entrypoint for the LyraCore development CLI, which lives in its own repository -# (LyraCoreProject/lyracore-cli). This installs the exact revision pinned by this checkout in -# .lyracore-cli-rev and runs it, so the CLI version is a property of the checkout rather than of -# whatever happens to be on your PATH. -# -# To bump the CLI: put the new commit SHA in .lyracore-cli-rev and commit it. -# -# Deliberate simplification: the cache is a plain per-revision directory and the only concurrency -# control is the lock `cargo install` already takes on its --root. Ceiling: two simultaneous -# first-runs of the same new revision will both build it, wasting one build but not corrupting -# anything. Upgrade path: a mkdir-based lock around the install, if that ever actually bites. set -eu fail() { @@ -29,15 +18,12 @@ esac if ! command -v cargo >/dev/null 2>&1; then if [ -f "$HOME/.cargo/env" ] || [ -x "$HOME/.cargo/bin/cargo" ]; then - # rustup wires cargo onto PATH via a shell-profile line that only new shells pick up, so a - # shell that predates the install still can't see it — telling this user to install Rust - # again would be wasted work; the actual fix is loading that PATH line into THIS shell. fail "Rust is installed but not on this shell's PATH — run: . \"\$HOME/.cargo/env\" (or start a new shell)" fi fail "cargo is required — install Rust from https://rustup.rs/" fi -# Publishing must never reach the CLI with a schema whose normalized field names collide. +# Publishing requires unique table field names after snake_case normalization. case ${1-} in preflight | publish) cargo test --quiet -p lyracore-module module_tables_have_unique_published_column_names \ @@ -45,21 +31,13 @@ case ${1-} in ;; esac +# cargo install serializes cache writes through its --root lock. install_root=$root/.lyracore/cli/$revision binary=$install_root/bin/lyracore if [ ! -x "$binary" ]; then - # First contact for most people: whatever they typed, THIS runs before it. It used to print the - # 40-character revision and then ~20 lines of cargo build chatter — including a cargo `warning:` - # about PATH that is not a problem — so a newcomer's first command looked like it had failed. - # `--quiet` silences the progress log; cargo still prints errors, so a genuinely broken build is - # still diagnosable, and `fail` below still names the revision when it matters. printf 'lyracore: preparing the CLI (first run — this takes a minute)...\n' >&2 - # Anonymous HTTPS, because the CLI repository is public: cargo's own git fetcher needs no - # credentials, no SSH key and no CARGO_NET_GIT_FETCH_WITH_CLI escape hatch for a public https - # dependency. This URL was `ssh://git@github.com/...` while the repository was private — which a - # stranger who has just cloned has no key for, so the very first command after `git clone` failed - # on the fetch. Do not put it back (#302). + # Anonymous HTTPS lets cargo fetch the public CLI without credentials. cargo install \ --quiet \ --git https://github.com/LyraCoreProject/lyracore-cli.git \ diff --git a/module/Cargo.toml b/module/Cargo.toml index 0e9ea8397..1e00c1a9a 100644 --- a/module/Cargo.toml +++ b/module/Cargo.toml @@ -13,36 +13,17 @@ description = "LyraCore SpacetimeDB module: all WoW 1.12 game state and logic." crate-type = ["cdylib", "rlib"] [features] -# Layer 1 client-automation test reducers (`module/src/debug/`). They are off by default, but the -# current deploy procedure deliberately enables them for every publish via: -# spacetime publish -s local --build-options='--features=debug_reducers' lyracore -# `--build-options` is forwarded verbatim to the wasm-target `cargo build`. `danger-zones.md` §3 is -# authoritative on this accepted alpha posture. debug_reducers = [] -# Inert Package registration used only by the disposable integration-test Module. package_test_fixture = ["debug_reducers"] [dependencies] blake3 = "1" -# `unstable` opts into SpacetimeDB's unstable schema API. -# Pinned exactly (not "2.5"): the SpacetimeDB CLI, SDK and module-crate contract is a single unit -# (see gateway/Cargo.toml's spacetimedb-sdk/-lib pins; `lyracore preflight` check 0 asserts the CLI -# version). A minor bump here can silently drift the module ahead of what `spacetime` can publish. +# Pin the Module, SDK and CLI to the same SpacetimeDB version. spacetimedb = { version = "=2.7.1", features = ["unstable"] } lyracore-shared.workspace = true -# The Package Delta artifact and the claim conflict tracer (src/package_import.rs). Pure and -# deterministic — no IO, no clock — so it compiles to wasm alongside the rest of the module. It is -# the trust boundary for a Package's claims: the reducer parses the artifact here rather than -# trusting a caller that already parsed it. lyracore-package-delta.workspace = true -# The Runtime Script Host's embedded Lua interpreter (src/runtime_script.rs). Pinned exactly, same house -# rule as the spacetimedb pin above: the fuel constants are sized against THIS version's metering, -# and the stdlib subset a Runtime Script sees is this version's — a minor bump can move both under -# scripts that already shipped. +# Fuel budgets and library behavior depend on this exact interpreter version. piccolo = "=0.3.3" -# Only for the `Error` type the getrandom 0.3 custom backend must return. piccolo reaches getrandom -# through `ahash`, and `wasm32-unknown-unknown` has no built-in backend; the Module supplies one in -# `runtime_script::fixed_entropy`, selected by the `getrandom_backend` cfg in `.cargo/config.toml`. getrandom = "0.3" [build-dependencies] @@ -52,9 +33,6 @@ serde_json = "1" [dev-dependencies] serde = { version = "1", features = ["derive"] } lyracore-test-support.workspace = true -# Integration tests only. `spacetime call` parses every reducer argument as JSON, so a `String` -# parameter has to arrive as a JSON string literal rather than as bare text — this encodes them. -# Already in the lockfile through `lyracore-package-delta`, so it adds nothing to the tree. serde_json = "1" [lints] diff --git a/module/build.rs b/module/build.rs index f237b6057..c71663414 100644 --- a/module/build.rs +++ b/module/build.rs @@ -1,18 +1,12 @@ -//! Module registry generation and Package discovery. See `docs/module-build.md`. -//! -//! The `package_api_lint` test target checks Package API paths. - use std::fs; use std::path::{Path, PathBuf}; -// Each consumer reads the lexical operations it needs. #[allow(dead_code)] #[path = "build_support/source.rs"] mod source; use source::{collect_rs_files, file_gate, strip_source, DEBUG_REDUCERS_FILE_CFG}; -/// One core hook and the Runtime Script payload it supplies. #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct HookEvent { @@ -43,13 +37,12 @@ enum HookFieldType { Text, } -/// Author declarations and dispatch use one catalogue. Rust checks every payload expression. static HOOK_EVENTS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { serde_json::from_str(include_str!("../datascripts/runtime-scripts/events.json")) .expect("the checked-in Runtime Script event catalogue must have valid rows") }); -#[allow(clippy::too_many_lines)] // One scan-and-emit step per generated registry. +#[allow(clippy::too_many_lines)] fn main() { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"); @@ -63,11 +56,8 @@ fn main() { .join("packages"); println!("cargo:rerun-if-changed={}", packages_dir.display()); - // Scan roots: (crate-path prefix, dir). Core src/ derives per-file prefixes; each package - // collapses to its generated root module. let mut registries = Registries::default(); - // ---- core src/ ---- let mut files = Vec::new(); collect_rs_files(&src_dir, &mut files); files.sort(); @@ -76,8 +66,7 @@ fn main() { scan_file(file, &src_dir, false, &prefix, &mut registries); } - // ---- packages/*/src/ ---- - let mut pkg_mods: Vec<(String, PathBuf)> = Vec::new(); // (ident, abs path to src/mod.rs) + let mut pkg_mods: Vec<(String, PathBuf)> = Vec::new(); if packages_dir.is_dir() { let mut pkg_dirs: Vec = fs::read_dir(&packages_dir) .unwrap_or_else(|e| panic!("build.rs: cannot read {}: {e}", packages_dir.display())) @@ -116,13 +105,11 @@ fn main() { } } - // Package-only actor verbs need no caller in a Core checkout with no installed Rust Package. println!("cargo::rustc-check-cfg=cfg(has_packages)"); if !pkg_mods.is_empty() { println!("cargo::rustc-cfg=has_packages"); } - // Durable tests need a registered name for ownership and teardown, without installing content. if std::env::var_os("CARGO_FEATURE_PACKAGE_TEST_FIXTURE").is_some() { pkg_mods.push(( "test_fixture".to_string(), @@ -132,7 +119,6 @@ fn main() { let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by cargo"); - // ---- character_sweeps.rs (also fed by package markers) ---- registries.delete.sort(); registries.restamp.sort(); let mut out = String::new(); @@ -153,8 +139,6 @@ fn main() { out.push_str(&format!(" {path},\n")); } out.push_str("];\n"); - // The SAME enumeration as table-accessor names — the escrowed-transfer manifest (src/transfer.rs). - // Derived from the delete-sweep fn names so there is no parallel list to hand-maintain. out.push_str("pub const CHARACTER_OWNED_TABLES: &[&str] = &[\n"); for path in ®istries.delete { let fn_name = path @@ -172,15 +156,7 @@ fn main() { out.push_str(&format!(" \"{table}\",\n")); } out.push_str("];\n"); - // The CROSS-DATABASE row transport, keyed by table accessor so - // `transfer::export_rows`/`import_rows` can pair a manifest entry with its mover. Derived from - // the `sweep_transfer_` fn names by the same prefix-strip rule as the delete - // sweeps — so a transport arm can never name a table that isn't in the manifest. registries.transfer.sort(); - // ONE table, ONE arm. A second arm for a table that already has one exports its rows twice, - // and — when the second one declines — is how a drop-in could stop a CORE table from crossing. - // `the_not_transported_allowlist_matches_the_arms_that_decline` used to catch that from the - // package side; it no longer sees a package-registered decline, so the shape check moves here. let mut claimed: Vec<(&str, &String)> = Vec::new(); for path in ®istries.transfer { let table = transfer_table_name(path); @@ -194,11 +170,7 @@ fn main() { claimed.push((table, path)); } out.push_str( - // The `(name, fn)` pair trips `clippy::type_complexity` in the GENERATED file, where nobody - // can annotate it — emit the allow with it. The pair is the registry's row shape, not an - // accidental type: each entry is one table's name plus its transfer sweep. - "// The `(&str, fn(..))` pair is this registry's row shape: one table name + its sweep.\n\ - #[allow(clippy::type_complexity)]\n\ + "#[allow(clippy::type_complexity)]\n\ pub const CHARACTER_OWNED_TRANSFERS: &[(&str, fn(&spacetimedb::ReducerContext, u64, &mut crate::transfer::RowIo<'_>))] = &[\n", ); for path in ®istries.transfer { @@ -208,21 +180,12 @@ fn main() { )); } out.push_str("];\n"); - // The SAME names again, as plain strings. `CHARACTER_OWNED_TRANSFERS` above cannot be - // named from a NATIVE test binary — referencing it materializes every registered fn's POINTER, - // which drags the SpacetimeDB host imports (`datastore_insert_bsatn`, …) in and they cannot - // link outside wasm. The transfer ratchet used to work around that by string-parsing this very - // generated file at test time; it reads this array instead. - out.push_str("// The transported-table names, in `CHARACTER_OWNED_TRANSFERS` order.\n"); + // Names remain native-linkable; function pointers would pull in wasm-only SpacetimeDB imports. out.push_str("pub const CHARACTER_OWNED_TRANSFER_NAMES: &[&str] = &[\n"); for path in ®istries.transfer { out.push_str(&format!(" \"{}\",\n", transfer_table_name(path))); } out.push_str("];\n"); - // The DECLINING subset — the arms written with the `not_transported` marker kind. This is the - // mechanical half of the decision; the reasoned half is `transfer::NOT_TRANSPORTED`, and - // `the_not_transported_allowlist_matches_the_arms_that_decline` fails if they disagree in - // either direction. Sorted by TABLE name so the assertion compares two stable lists. registries.not_transported.sort(); let mut declines: Vec = registries .not_transported @@ -230,10 +193,6 @@ fn main() { .map(|p| transfer_table_name(p).to_string()) .collect(); declines.sort(); - out.push_str( - "// Tables whose transport arm deliberately carries NOTHING (the `not_transported` marker\n\ - // kind), sorted by table name. Cross-checked against `transfer::NOT_TRANSPORTED`.\n", - ); out.push_str("pub const CHARACTER_OWNED_NOT_TRANSPORTED: &[&str] = &[\n"); for table in &declines { out.push_str(&format!(" \"{table}\",\n")); @@ -241,7 +200,6 @@ fn main() { out.push_str("];\n"); write_out(&out_dir, "character_sweeps.rs", &out); - // ---- package_mods.rs ---- let mut out = String::new(); out.push_str( "// GENERATED by module/build.rs from packages/*/src/mod.rs discovery. DO NOT EDIT.\n", @@ -252,7 +210,6 @@ fn main() { } write_out(&out_dir, "package_mods.rs", &out); - // ---- package_registries.rs ---- registries.tick_passes.sort(); registries .encounter_packages @@ -272,11 +229,8 @@ fn main() { out.push_str( "// GENERATED by module/build.rs from package markers under src/ and packages/*/src/. DO NOT EDIT.\n", ); - // Same reason as `CHARACTER_OWNED_TRANSFERS` above: the allow has to be emitted, because the - // file it lands in is generated and `DO NOT EDIT`. out.push_str( - "// The `(&str, fn(..))` pair is this registry's row shape: one pass name + the pass.\n\ - #[allow(clippy::type_complexity)]\n\ + "#[allow(clippy::type_complexity)]\n\ pub const GAME_TICK_PASSES: &[(&str, fn(&spacetimedb::ReducerContext))] = &[\n", ); for path in ®istries.tick_passes { @@ -346,9 +300,6 @@ fn main() { "pub const GAME_CLIENT_COMMAND: Option = None;\n", ), } - // Every event name, as plain strings a NATIVE test binary can read without materializing the - // fn-pointer arrays above — the same reason `CHARACTER_OWNED_TRANSFER_NAMES` exists. This is - // what `script_binding.rs` asserts the Package Delta crate's mirror of the catalog against. out.push_str("pub const GAME_HOOK_EVENT_NAMES: &[&str] = &[\n"); for HookEvent { event, .. } in &*HOOK_EVENTS { out.push_str(&format!(" \"{event}\",\n")); @@ -390,19 +341,8 @@ fn main() { out.push_str("];\n"); write_out(&out_dir, "package_registries.rs", &out); - // ---- hook_dispatch.rs ---- included INSIDE src/hooks.rs, so `payload_for` and the - // `fire_*` fns keep their `crate::hooks::` paths. Generated from the same HOOK_EVENTS rows as - // the registry arrays above — the catalog cannot drift between alias, dispatch, and array. let mut out = String::new(); out.push_str("// GENERATED by module/build.rs from events.json. DO NOT EDIT.\n"); - out.push_str( - "// Included inside src/hooks.rs — see the module doc there for firing semantics.\n", - ); - out.push_str( - "/// Event-name -> payload-type aliases so the `game_hook` marker can resolve the handler\n\ - /// signature from the event ident alone. The names ARE the event names, hence the\n\ - /// non-camel-case carve-out.\n", - ); out.push_str("#[allow(non_camel_case_types)]\npub mod payload_for {\n"); for HookEvent { event, @@ -421,10 +361,7 @@ fn main() { fields, } in &*HOOK_EVENTS { - // Two dispatches per event, in this order. The Rust handlers registered by `game_hook!` - // are compiled into the build and run first; the Runtime Scripts bound to the event are - // data, reconciled onto the shard by a Package, and run after. A Package cannot displace - // engine code by shipping a script. + // Run Runtime Scripts after compiled hooks so Package data cannot displace engine code. let payload = script_payload(fields); out.push_str(&format!( "pub(crate) fn fire_{event}(ctx: &spacetimedb::ReducerContext, payload: &{payload_ty}) {{\n \ @@ -467,19 +404,15 @@ struct Registries { delete: Vec, restamp: Vec, transfer: Vec, - /// The subset of `transfer` registered through the `not_transported` marker kind — the arms - /// that deliberately carry nothing. Emitted as `CHARACTER_OWNED_NOT_TRANSPORTED`. not_transported: Vec, tick_passes: Vec, - hooks: Vec<(String, String)>, // (event, fully-qualified fn path) - encounter_packages: Vec<(String, String)>, // (binding variant, fully-qualified fn path) - client_commands: Vec<(String, String, String)>, // (parser, admitted apply, reply name) paths - package_tables: Vec<(String, String)>, // (package ident, table accessor) - package_characters: Vec<(String, String)>, // (package ident, fully-qualified fn path) + hooks: Vec<(String, String)>, + encounter_packages: Vec<(String, String)>, + client_commands: Vec<(String, String, String)>, + package_tables: Vec<(String, String)>, + package_characters: Vec<(String, String)>, } -/// The registry entry for `path`. A Package's fn is wrapped so it stops running once Package -/// Teardown has run for that Package; the wrapper then returns `otherwise`. fn gated(path: &str, params: &str, args: &str, otherwise: &str) -> String { match path .strip_prefix("crate::pkg_") @@ -492,8 +425,6 @@ fn gated(path: &str, params: &str, args: &str, otherwise: &str) -> String { } } -/// The accessor of every `#[table(..)]` and `#[spacetimedb::table(..)]` in stripped source. -/// Only the attribute's top-level `accessor`, so an index's own `accessor = ..` is never taken. fn table_accessors(content: &str) -> Vec { let mut accessors = Vec::new(); for (start, _) in content.match_indices("#[") { @@ -528,8 +459,7 @@ fn table_accessors(content: &str) -> Vec { fn accessor_item(item: &str) -> Option { let item = item.trim(); - // Teardown clears a table by name, and `name = ..` would make the name differ from the - // accessor this scan reads. + // Teardown addresses table names, so an accessor cannot use a different stored name. if item .strip_prefix("name") .is_some_and(|rest| rest.trim_start().starts_with('=')) @@ -548,9 +478,6 @@ fn accessor_item(item: &str) -> Option { .then(|| value.to_string()) } -/// The table accessor a transport arm's fully-qualified fn path names: the same -/// `sweep_transfer_` prefix-strip rule the delete sweeps use, so a mover can never -/// be paired with the wrong manifest entry. fn transfer_table_name(path: &str) -> &str { let fn_name = path .rsplit("::") @@ -572,8 +499,6 @@ fn write_out(out_dir: &str, name: &str, content: &str) { .unwrap_or_else(|e| panic!("build.rs: cannot write {}: {e}", dest.display())); } -/// `my-package` -> `my_package`, validated as a Rust identifier — anything else panics (a package -/// folder name must map cleanly onto the generated `pkg_` module). fn package_ident(name: &str) -> String { let ident: String = name .chars() @@ -591,8 +516,6 @@ fn package_ident(name: &str) -> String { ident } -/// Resolve a Core marker through its top-level Module. A nested file exposes its registered -/// operation through a named re-export; `check_facade_reexport` checks that declaration. fn core_module_path(src_root: &Path, file: &Path) -> String { let rel = file.strip_prefix(src_root).unwrap_or_else(|_| { panic!( @@ -617,11 +540,6 @@ fn strip_comments_and_strings(src: &str) -> String { strip_source(src).code } -/// Verify that a marker registered in nested submodule `file` (e.g. `src/spell/spellbook.rs`) is -/// actually re-exported by its facade (`src/spell/mod.rs` or `src/spell.rs`), so the generated -/// `crate::spell::` path resolves. Accepts `pub use ::*`, `pub(crate) use ::*`, -/// and non-glob forms naming `name` (optionally through `self::`). Panics naming the missing -/// re-export — a build.rs panic beats the opaque rustc error inside `$OUT_DIR` it prevents. fn check_facade_reexport(file: &Path, scan_root: &Path, in_package: bool, name: &str) { let rel = match file.strip_prefix(scan_root) { Ok(r) => r, @@ -632,14 +550,8 @@ fn check_facade_reexport(file: &Path, scan_root: &Path, in_package: bool, name: .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); if rel.file_stem().map(|s| s == "mod").unwrap_or(false) { - return; // the facade itself + return; } - // Which facade must re-export this file's items depends on the collapse rule: - // - core `src/foo.rs` -> crate::foo resolves DIRECTLY (no facade); `src/foo/bar.rs` needs - // foo's facade (foo/mod.rs or foo.rs) to re-export bar. - // - a package's EVERY file collapses to crate::pkg_, so even a depth-1 `src/foo.rs` - // needs the package's src/mod.rs to re-export foo. Deeper nesting than one directory does - // not exist today; if it ever does, the per-level check would need to walk the chain. let (facade, sub, collapsed) = if in_package && segs.len() == 1 { let sub = rel.file_stem().unwrap().to_string_lossy().into_owned(); ( @@ -666,12 +578,11 @@ fn check_facade_reexport(file: &Path, scan_root: &Path, in_package: bool, name: }; (facade, sub, format!("`{dir}`")) } else { - return; // core depth-1 file: crate::foo::name resolves directly + return; }; let content = fs::read_to_string(&facade) .unwrap_or_else(|e| panic!("build.rs: cannot read facade {}: {e}", facade.display())); let stripped = strip_comments_and_strings(&content); - // Normalize whitespace so multi-line use statements match. let norm: String = stripped.split_whitespace().collect::>().join(" "); for vis in ["pub use ", "pub(crate) use "] { for path_head in [format!("{vis}{sub}::"), format!("{vis}self::{sub}::")] { @@ -683,11 +594,6 @@ fn check_facade_reexport(file: &Path, scan_root: &Path, in_package: bool, name: .map(|e| stmt_start + e) .unwrap_or(norm.len()); let tail = &norm[stmt_start..stmt_end]; - // `*` re-exports everything; otherwise the statement must name the fn (as a path - // segment or inside a brace list — a substring check bounded by non-ident chars). - // A bare `*` re-exports everything. Otherwise the statement must name the fn — - // but `name as other` does NOT count (the original spelling is renamed away; - // only `other as name`, where `name` is the rename TARGET, keeps it reachable). let toks: Vec<&str> = tail .split(|c: char| !(c.is_alphanumeric() || c == '_')) .filter(|t| !t.is_empty()) @@ -712,9 +618,6 @@ fn check_facade_reexport(file: &Path, scan_root: &Path, in_package: bool, name: ); } -/// The exact two shapes a `character_owned!` invocation head may take (see the macro doc in -/// `src/lib.rs`). A `(kind, name)` match is only recorded for input matching ONE of these; any other -/// occurrence of the literal substring in the file is treated as malformed and panics below. fn try_match_character_owned(head: &str) -> Option<(&'static str, String)> { for kind in ["delete", "restamp", "transfer", "not_transported"] { let prefix = format!("({kind},"); @@ -729,14 +632,11 @@ fn try_match_character_owned(head: &str) -> Option<(&'static str, String)> { None } -/// `game_tick_pass!` head: `(fn NAME(...` — one shape only. fn try_match_tick_pass(head: &str) -> Option { let rest = head.strip_prefix('(')?; match_fn_name(rest) } -/// `game_hook!` head: `(EVENT, fn NAME(...` — EVENT must be in the known catalog (checked by the -/// caller so the panic can list the valid names). fn try_match_hook(head: &str) -> Option<(String, String)> { let rest = head.strip_prefix('(')?.trim_start(); let ev_end = rest.find(|c: char| !(c.is_alphanumeric() || c == '_'))?; @@ -749,7 +649,6 @@ fn try_match_hook(head: &str) -> Option<(String, String)> { Some((event, name)) } -/// `game_client_command!` head: `(PARSE, APPLY, REPLY)` — package files only. fn try_match_client_command(head: &str) -> Option<(String, String, String)> { let mut rest = head.strip_prefix('(')?; let mut ident_before = |separator: char| { @@ -764,7 +663,6 @@ fn try_match_client_command(head: &str) -> Option<(String, String, String)> { Some((ident_before(',')?, ident_before(',')?, ident_before(')')?)) } -/// `encounter_package!` head: `(BINDING, fn NAME(...` — package files only. fn try_match_encounter_package(head: &str) -> Option<(String, String)> { let rest = head.strip_prefix('(')?.trim_start(); let binding_end = rest.find(|c: char| !(c.is_alphanumeric() || c == '_'))?; @@ -777,8 +675,6 @@ fn try_match_encounter_package(head: &str) -> Option<(String, String)> { Some((binding, name)) } -/// Shared tail matcher: optional whitespace, `fn NAME`, then (after optional whitespace) `(` — the -/// fn's own param list. fn match_fn_name(rest: &str) -> Option { let rest = rest.trim_start(); let rest = rest.strip_prefix("fn ")?; @@ -795,11 +691,6 @@ fn match_fn_name(rest: &str) -> Option { } } -/// Scan one file for every marker kind, registering each hit under `prefix` (the file's -/// collapsed crate path). The scan runs on the comment/string-stripped text, so quoted or -/// commented-out marker syntax is inert; on real code, any occurrence of a marker's literal -/// substring that doesn't parse panics — never skip silently. Every registered marker in a nested -/// submodule also has its facade re-export verified (`check_facade_reexport`). fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: &mut Registries) { let raw = fs::read_to_string(file) .unwrap_or_else(|e| panic!("build.rs: cannot read {}: {e}", file.display())); @@ -823,17 +714,6 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: "delete" => reg.delete.push(path), "restamp" => reg.restamp.push(path), "transfer" => reg.transfer.push(path), - // A DECLINING arm is still a transport arm — it is registered in the same - // registry, so `every_manifest_table_can_cross_a_database_boundary` keeps - // seeing an arm for the table — but it is ALSO recorded separately, because - // "these rows deliberately do not cross" is a decision that must be - // cross-checkable against `transfer::NOT_TRANSPORTED`'s written reasons - // instead of being read back out of the arm's source text. - // A PACKAGE's decline is registered as a transport arm like any other, but it - // is NOT cross-checked against `transfer::NOT_TRANSPORTED`. That list is the - // core's written decision about core tables, and it cannot name a table that - // is absent from most builds — a Package is a drop-in. A Package writes its - // reason where the reader looks for it: at its own table. "not_transported" => { reg.transfer.push(path.clone()); if !in_package { @@ -977,22 +857,15 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: ); } -/// Keep registry discovery on the same whole-file feature boundary as rustc. -/// -/// The scanner deliberately supports one exact leading inner attribute instead of interpreting -/// general Rust `cfg` expressions. A parent module may repeat the gate, but the file owns the -/// registry contract so recursive discovery can decide without reconstructing the module tree. +// Discovery recognizes exact leading whole-file gates rather than general cfg expressions. fn registry_file_enabled(source: &str, debug_reducers: bool) -> bool { debug_reducers || !debug_only_file(source) } -/// Whether the file's first non-blank line is `DEBUG_REDUCERS_FILE_CFG`. fn debug_only_file(source: &str) -> bool { file_gate(source) == Some(DEBUG_REDUCERS_FILE_CFG) } -/// Find every occurrence of `marker` in `content` and hand its head (text after the marker, left- -/// trimmed) plus 1-based line number to `on_hit`. fn scan_marker(content: &str, _file: &Path, marker: &str, mut on_hit: impl FnMut(&str, usize)) { let mut search_from = 0usize; while let Some(rel_idx) = content[search_from..].find(marker) { diff --git a/module/build_support/source.rs b/module/build_support/source.rs index e21240a97..5de4affb0 100644 --- a/module/build_support/source.rs +++ b/module/build_support/source.rs @@ -1,26 +1,14 @@ -//! Lexical source operations shared by `module/build.rs` and the Package API lint test target. - use std::fs; use std::path::{Path, PathBuf}; -/// The whole-file gate of a source file compiled only with `debug_reducers`. pub const DEBUG_REDUCERS_FILE_CFG: &str = "#![cfg(feature = \"debug_reducers\")]"; -/// The whole-file gate of a source file compiled only in a test build. pub const TEST_FILE_CFG: &str = "#![cfg(test)]"; pub struct StrippedSource { - /// The source with comments and literals blanked, keeping every line break. pub code: String, - /// Every line comment, as (1-based line, comment text from `//`). pub line_comments: Vec<(usize, String)>, } -/// Blank out comments (line + nested block), string literals (plain, byte, C, raw), and char -/// literals, keeping line breaks for diagnostics. Lifetimes (`'a`) stay intact; only a quoted -/// character is blanked. This makes a commented-out marker or path -/// inert and lets doc comments show real syntax. -/// -/// Line comments are returned beside the code, so text inside a string can never pose as one. pub fn strip_source(src: &str) -> StrippedSource { let b: Vec = src.chars().collect(); let mut out: Vec = Vec::with_capacity(b.len()); @@ -33,7 +21,6 @@ pub fn strip_source(src: &str) -> StrippedSource { line += b[counted_until..i].iter().filter(|c| **c == '\n').count(); counted_until = i; let c = b[i]; - // Line comment (also covers /// and //!): blank to end of line. if c == '/' && b.get(i + 1) == Some(&'/') { let comment_start = i; while i < b.len() && b[i] != '\n' { @@ -43,7 +30,6 @@ pub fn strip_source(src: &str) -> StrippedSource { line_comments.push((line, b[comment_start..i].iter().collect())); continue; } - // Block comment, nested per Rust. if c == '/' && b.get(i + 1) == Some(&'*') { let mut depth = 0usize; while i < b.len() { @@ -67,8 +53,6 @@ pub fn strip_source(src: &str) -> StrippedSource { } continue; } - // Raw strings, only when the r/b/c starts a token (previous - // char is not part of an identifier), so `for` / `attr` never false-trigger. let prev_is_ident = i > 0 && (b[i - 1].is_alphanumeric() || b[i - 1] == '_'); if !prev_is_ident && (c == 'r' || (matches!(c, 'b' | 'c') && b.get(i + 1) == Some(&'r'))) { let r_at = if c == 'r' { i } else { i + 1 }; @@ -78,7 +62,6 @@ pub fn strip_source(src: &str) -> StrippedSource { } if b.get(j) == Some(&'"') { let hashes = j - (r_at + 1); - // Emit the opener as blanks, then scan for `"` + hashes. for c in &b[i..=j] { out.push(blank(*c)); } @@ -102,15 +85,13 @@ pub fn strip_source(src: &str) -> StrippedSource { } continue; } - // not a raw string: fall through to emit `c` normally below } - // Plain / byte / C string literal with escapes. if c == '"' || (!prev_is_ident && matches!(c, 'b' | 'c') && b.get(i + 1) == Some(&'"')) { if c != '"' { out.push(' '); i += 1; } - out.push(' '); // the opening quote + out.push(' '); i += 1; while i < b.len() { if b[i] == '\\' { @@ -131,7 +112,6 @@ pub fn strip_source(src: &str) -> StrippedSource { } continue; } - // Char literal vs lifetime: a char literal is `'` + (escape | one char) + `'`. if c == '\'' { let is_char_lit = match b.get(i + 1) { Some('\\') => true, @@ -142,9 +122,7 @@ pub fn strip_source(src: &str) -> StrippedSource { out.push(' '); i += 1; if b.get(i) == Some(&'\\') { - // Consume the backslash AND its escaped char first: for '\'' the escaped char - // IS a quote, and a terminator scan alone would stop on it one char early. The - // tail loop then covers multi-char escapes ('\u{...}'). + // Consume escaped quotes before looking for the character literal's terminator. out.push(' '); i += 1; if i < b.len() { @@ -165,7 +143,6 @@ pub fn strip_source(src: &str) -> StrippedSource { } continue; } - // lifetime: keep the quote so positions stay aligned } out.push(c); i += 1; @@ -176,15 +153,10 @@ pub fn strip_source(src: &str) -> StrippedSource { } } -/// The file's first non-blank line, trimmed: the whole-file gate when it is one. -/// -/// Both consumers read one exact leading inner attribute instead of interpreting general `cfg` -/// expressions, so they decide a file's gate without reconstructing the module tree. pub fn file_gate(source: &str) -> Option<&str> { source.lines().map(str::trim).find(|line| !line.is_empty()) } -/// Every `.rs` file beneath `dir`, in no particular order. pub fn collect_rs_files(dir: &Path, out: &mut Vec) { let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read dir {}: {e}", dir.display())); diff --git a/module/src/account_ownership.rs b/module/src/account_ownership.rs index ff844d256..712fabae3 100644 --- a/module/src/account_ownership.rs +++ b/module/src/account_ownership.rs @@ -1,5 +1,3 @@ -//! Account ownership shared by independent Gateways. Closed generations remain as fences. - use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table}; use crate::{game_account, game_character, game_gateway_session, game_world_entity, Account}; @@ -16,8 +14,6 @@ pub struct WorldSessionToken { pub request_nonce: u128, } -/// A request retains its World Session's token even while queued for delivery. -/// An absent token is reserved for Operator-driven Characters without a World Session. #[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub struct SessionActor { pub guid: u64, @@ -47,9 +43,7 @@ pub struct AccountFence { pub closed: bool, } -/// Retained Realm Account ownership for one globally unique Character guid. Every World Shard -/// keeps this row across Transfer, logout and Character deletion so a shadow Account never becomes -/// an ownership authority by itself. +// Retain ownership across Transfer, logout and deletion; shadow Accounts confer no authority. #[table( accessor = game_account_character_owner, index(accessor = by_account, btree(columns = [account_id])) @@ -140,7 +134,7 @@ fn deadline(ctx: &ReducerContext) -> Result { .ok_or_else(|| "Account claim deadline overflow".into()) } -/// Retry the same request nonce to recover a lost reply. A closed or expired request never reopens. +// Retrying a nonce can recover its reply but cannot reopen a closed or expired generation. #[reducer] pub fn claim_account( ctx: &ReducerContext, @@ -183,8 +177,7 @@ pub fn claim_account( .ok_or("Account generation exhausted")?, None => 1, }; - // The prior generation is dead: released, or expired before the reaper closed it. What it - // admitted ends before the new generation exists. A no-op after release. + // End the old generation's admitted state before installing its successor. if let Some(row) = &prior { claim_ended(ctx, row.character_guid); } @@ -239,16 +232,12 @@ pub fn release_account_claim(ctx: &ReducerContext, token: WorldSessionToken) -> Ok(()) } -/// End what an Account Claim admitted for its Character: every Chat Channel Membership and a solo -/// Seeker. Each place that releases, replaces or reaps a claim calls this, so the next claim-scoped -/// state hooks here once. fn claim_ended(ctx: &ReducerContext, character_guid: u64) { crate::channel::leave_all(ctx, character_guid); crate::meeting_stone::claim_ended(ctx, character_guid); } -/// The Gateway must obtain this generation from Realm-core and install it on every configured -/// World Shard before admission. Older deliveries cannot lower a Shard's retained generation. +// Install the Realm-core generation on every World Shard before admitting the Character. #[reducer] pub fn fence_account( ctx: &ReducerContext, @@ -274,8 +263,7 @@ pub fn fence_account( if token.generation < row.generation { return Err(STALE.into()); } - // This is the only ownership history an upgraded Shard can recover without trusting its - // local Account id. Preserve it before a new generation replaces the fence. + // Preserve prior ownership before replacing the fence; local Account ids are not authority. remember_character_owner(ctx, row.account_id, &row.account_name, row.character_guid)?; } if !character_belongs_to_account(ctx, token.account_id, &account_name, character_guid) { @@ -478,8 +466,7 @@ fn remove_exact_character(ctx: &ReducerContext, guid: u64) { ctx.db.game_gateway_session().entity_guid().delete(guid); } -/// Shared Gate for gameplay, Realm-core actions and Transfer steps. It checks the token in the -/// transaction that acts on the Character; a prior Gateway read grants no authority. +// Check the token in the transaction that acts; a prior Gateway read grants no authority. pub(crate) fn require_actor(ctx: &ReducerContext, actor: SessionActor) -> Result { if let Some(token) = actor.ownership { if let Some(row) = ctx @@ -530,8 +517,6 @@ pub(crate) fn require_actor(ctx: &ReducerContext, actor: SessionActor) -> Result } } -/// Completion requests must name the Character stored in the referenced durable operation. -/// Operator recovery without a token still checks that Character's current ownership. pub(crate) fn require_actor_for( ctx: &ReducerContext, actor: SessionActor, @@ -550,11 +535,6 @@ pub(crate) fn require_actor_for( .map(|_| ()) } -/// Close open Account Claims whose deadline has passed, at most [`REAP_LIMIT`] per call. Called -/// from the Gateway lease schedule, so a crashed Gateway's Characters leave their Chat Channels -/// and the Meeting Stone Queue. -/// [`claim_account`] and [`require_actor`] already treat an expired claim as dead, so closing one -/// changes no admission outcome. pub(crate) fn reap_account_claims(ctx: &ReducerContext) { let cutoff = now(ctx); let expired: Vec = ctx @@ -573,8 +553,6 @@ pub(crate) fn reap_account_claims(ctx: &ReducerContext) { } } -/// Called from the existing Gateway lease schedule; one surviving Gateway cannot renew another -/// World Session's Account claim or keep its Character alive. pub(crate) fn reap_account_fences(ctx: &ReducerContext) { let cutoff = now(ctx); let expired: Vec<_> = ctx diff --git a/module/src/action_bar.rs b/module/src/action_bar.rs index 54f0e7582..031ca5e65 100644 --- a/module/src/action_bar.rs +++ b/module/src/action_bar.rs @@ -1,32 +1,7 @@ -//! Creation-time action-bar rows — `game_player_action` is the per-character bar the gateway's login -//! sequence reads FIRST (before falling back to synthesizing a bar from the spellbook, see -//! `gateway/src/codec/entity.rs::login_sequence_messages`). Mirrors the `game_createinfo_spell` / -//! `game_player_spell` precedent one level over: `game_createinfo_action` is the -//! per-(race, class) dump-sourced default layout (`playercreateinfo_action`), copied into -//! `game_player_action` at character creation exactly like `grant_createinfo_spells` copies the spell -//! kit. UNLIKE the spell kit there is no hand-authored fallback seeded into `game_createinfo_action` — -//! vanilla's real default bars vary too much per class/level to hand-author cheaply, and the gateway -//! already has a working synth (Attack + known spells) for the pre-import state. So: no import yet → -//! `game_createinfo_action` is empty → creation grants NOTHING → the gateway's existing synth covers -//! login, byte-identical to today. Import lands → creation copies the real rows → the gateway prefers -//! them. [entity] - use spacetimedb::{table, Identity, ReducerContext, Table}; -use crate::spell::spellbook::createinfo_row_matches; // shared (race,class) wildcard-match helper +use crate::spell::spellbook::createinfo_row_matches; -/// One createinfo action-bar button: the vanilla default layout for a fresh character of a matching -/// (race, class). `race == 0` / `class == 0` wildcards are supported via the SAME -/// `createinfo_row_matches` helper `game_createinfo_spell` uses, though the dump is expected to carry -/// concrete (race, class) rows for every real combo (no wildcard convention documented for -/// `playercreateinfo_action` the way there is for `playercreateinfo_spell`) — supporting it costs -/// nothing and keeps the two createinfo tables' matching semantics identical. -/// `action` | `action_type` mirror the client's action-button encoding (`action_type` 0 = spell, other -/// values = item/macro/etc — see `gateway/src/codec/entity.rs`'s packing). Hand-authored data has no -/// client DBC of its own (createinfo exists in no DBC); this table is entirely importer-populated -/// (`playercreateinfo_action`), never seeded, unlike `game_createinfo_spell`. `row_id` -/// is an importer-assigned dense id (the `game_start_item`/`game_graveyard_zone` convention), not a -/// logical key — the table is a flat bag of rows scanned in full, like `game_createinfo_spell`. [static] #[table(accessor = game_createinfo_action, public)] pub struct CreateinfoAction { #[primary_key] @@ -39,11 +14,6 @@ pub struct CreateinfoAction { pub action_type: u8, } -/// A character's live action-bar button — copied from `game_createinfo_action` at creation (see -/// `grant_createinfo_actions`). Per-character, owner-scoped RLS (mirrors `game_player_spell`/ -/// `game_player_skill`). Logical key `(character_guid, button)` via an `#[auto_inc]` PK + a -/// `by_character` btree — module-written only (the importer never touches this table; it populates -/// `game_createinfo_action` instead, which creation reads FROM). [entity] #[table(accessor = game_player_action, public, index(accessor = by_character, btree(columns = [character_guid])))] pub struct PlayerAction { #[primary_key] @@ -56,17 +26,12 @@ pub struct PlayerAction { pub action_type: u8, } -// Character-owned sweeps: action-bar rows are deleted on character delete, re-owned (identity -// re-stamp) on a relog under a changed gateway identity — same pattern as `game_player_spell`. crate::character_owned!(delete, fn sweep_delete_game_player_action(ctx, character_guid) { let actions = ctx.db.game_player_action(); for r in actions.by_character().filter(&character_guid).collect::>() { actions.id().delete(r.id); } }); -// CROSS-DATABASE transport. `id` is a surrogate `#[auto_inc]` PK — the LOGICAL key is -// (character, button) — so it is re-minted at the destination rather than carried; carrying it would -// insert explicit ids without advancing the destination's sequence (danger-zones §2). crate::character_owned!(transfer, fn sweep_transfer_game_player_action(ctx, character_guid, io) { table = game_player_action, by = by_character, @@ -82,14 +47,6 @@ crate::character_owned!(restamp, fn sweep_restamp_game_player_action(ctx, charac } }); -/// Pure filter+project step behind `grant_createinfo_actions`, pulled out so it's unit-testable -/// without a live `ReducerContext` (this crate's tests never construct one — see the module test -/// suite's convention of testing only ctx-free helpers). Returns `(button, action, action_type)` for -/// every `game_createinfo_action` row matching (`race`, `class`) via the shared wildcard predicate, -/// AT MOST ONE per button (first match wins — a wildcard row overlapping a concrete row, or a -/// malformed dump with duplicate buttons, must not produce two `game_player_action` rows the gateway -/// would resolve by iteration order). Empty input (pre-import) or no match → empty output — the -/// "create nothing" fallback policy is structurally guaranteed here, not just documented. pub(crate) fn matching_actions<'a>( rows: impl Iterator, race: u8, @@ -102,13 +59,6 @@ pub(crate) fn matching_actions<'a>( .collect() } -/// Copy the createinfo action bar for (`race`, `class`) into `guid`'s `game_player_action` rows — the -/// character-creation grant, called right after `grant_createinfo_spells` (same transaction as -/// `create_character`'s insert). A full scan with wildcard matching, like `grant_createinfo_spells` -/// (the table stays tiny — one row per button per class, not per character). If NO row matches (the -/// common case pre-import: `game_createinfo_action` is empty), this inserts NOTHING — deliberately: the -/// gateway's login synth (`login_sequence_messages`) already builds a working bar from the spellbook, -/// and an empty `game_player_action` is exactly the signal it reads as "no imported bar, fall back." pub(crate) fn grant_createinfo_actions( ctx: &ReducerContext, guid: u64, @@ -119,8 +69,6 @@ pub(crate) fn grant_createinfo_actions( let actions = ctx.db.game_player_action(); let rows: Vec = ctx.db.game_createinfo_action().iter().collect(); for (button, action, action_type) in matching_actions(rows.iter(), race, class) { - // Idempotent like `learn_spell`: a re-grant (there is none today — creation runs once per - // guid) must never stack a second row on an occupied button. if actions .by_character() .filter(&guid) @@ -129,7 +77,7 @@ pub(crate) fn grant_createinfo_actions( continue; } actions.insert(PlayerAction { - id: 0, // auto_inc + id: 0, character_guid: guid, owner_identity: owner, button, @@ -139,9 +87,6 @@ pub(crate) fn grant_createinfo_actions( } } -/// The action-bar write core, actor-explicit: everything [`set_action_button`] does after -/// resolving WHOSE bar this is. Takes the row — the insert arm stamps the owner's RLS identity -/// off it. pub(crate) fn apply_set_action_button( ctx: &ReducerContext, character: crate::WorldEntity, @@ -170,9 +115,6 @@ pub(crate) fn apply_set_action_button( actions.insert(PlayerAction { id: 0, character_guid: character.guid, - // The BAR OWNER's own binding, not `ctx.sender()` — identical on the sender path - // (the entity was resolved BY that identity) and correct on the gateway path, - // where `ctx.sender()` is the shared connection's operator identity. owner_identity: character.owner_identity, button, action, @@ -205,46 +147,37 @@ mod tests { } } - /// Confirm the reused `createinfo_row_matches` helper behaves identically for actions as for - /// spells (it's the SAME function, so this is a byte-parity sanity check, not new logic). #[test] fn createinfo_row_matches_is_reused_verbatim_for_actions() { - assert!(createinfo_row_matches(1, 1, 1, 1)); // Human Warrior row on a Human Warrior - assert!(!createinfo_row_matches(1, 1, 1, 4)); // class mismatch - assert!(createinfo_row_matches(0, 1, 3, 1)); // race-wildcard class row matches any race + assert!(createinfo_row_matches(1, 1, 1, 1)); + assert!(!createinfo_row_matches(1, 1, 1, 4)); + assert!(createinfo_row_matches(0, 1, 3, 1)); } - /// The empty-table case (no import yet) — the load-bearing "create nothing" fallback policy. - /// `matching_actions` on an empty slice returns empty, so `grant_createinfo_actions`'s insert loop - /// runs zero times and the character gets no `game_player_action` rows at all. #[test] fn matching_actions_on_an_empty_table_creates_nothing() { let rows: Vec = Vec::new(); assert!(matching_actions(rows.iter(), 1, 1).is_empty()); } - /// Once rows ARE imported, only the ones matching (race, class) are copied — a Dwarf Warrior's - /// row for a Human Priest's creation is never granted. #[test] fn matching_actions_copies_only_the_matching_race_and_class() { let rows = [ - row(1, 1, 1, 0, 6603, 0), // Human Warrior, Attack on button 0 - row(2, 1, 1, 1, 78, 0), // Human Warrior, Heroic Strike on button 1 - row(3, 3, 1, 0, 6603, 0), // Dwarf Warrior — different race, must NOT match - row(4, 1, 5, 0, 6603, 0), // Human Priest — different class, must NOT match + row(1, 1, 1, 0, 6603, 0), + row(2, 1, 1, 1, 78, 0), + row(3, 3, 1, 0, 6603, 0), + row(4, 1, 5, 0, 6603, 0), ]; let granted = matching_actions(rows.iter(), 1, 1); assert_eq!(granted, vec![(0, 6603, 0), (1, 78, 0)]); } - /// Two matching rows for the SAME button (wildcard overlapping concrete, or a malformed dump) - /// yield exactly one grant — first match wins, never two rows racing on gateway iteration order. #[test] fn matching_actions_grants_at_most_one_row_per_button() { let rows = [ - row(1, 1, 1, 0, 6603, 0), // Human Warrior concrete: Attack on button 0 - row(2, 0, 1, 0, 78, 0), // race-wildcard Warrior row also claiming button 0 - row(3, 1, 1, 1, 78, 0), // distinct button — unaffected + row(1, 1, 1, 0, 6603, 0), + row(2, 0, 1, 0, 78, 0), + row(3, 1, 1, 1, 78, 0), ]; assert_eq!( matching_actions(rows.iter(), 1, 1), @@ -252,13 +185,11 @@ mod tests { ); } - /// Wildcard rows (`race == 0` or `class == 0`) apply broadly, exactly like the spell kit — - /// e.g. a class-wildcard row (Warrior's Attack, race 0) matches every playable race. #[test] fn matching_actions_respects_wildcards_like_the_spell_kit() { - let rows = [row(1, 0, 1, 0, 6603, 0)]; // race-wildcard: every Warrior gets Attack - assert_eq!(matching_actions(rows.iter(), 1, 1), vec![(0, 6603, 0)]); // Human Warrior - assert_eq!(matching_actions(rows.iter(), 3, 1), vec![(0, 6603, 0)]); // Dwarf Warrior - assert!(matching_actions(rows.iter(), 1, 4).is_empty()); // Human Mage — class still gates + let rows = [row(1, 0, 1, 0, 6603, 0)]; + assert_eq!(matching_actions(rows.iter(), 1, 1), vec![(0, 6603, 0)]); + assert_eq!(matching_actions(rows.iter(), 3, 1), vec![(0, 6603, 0)]); + assert!(matching_actions(rows.iter(), 1, 4).is_empty()); } } diff --git a/module/src/actor.rs b/module/src/actor.rs index 8849ad52b..df8011b62 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -1,4 +1,4 @@ -//! Actor verbs. See `docs/actor-verbs.md` for the contract and rationale. +//! Before adding Actor verbs or changing their Gates, read docs/actor-verbs.md. use crate::import_meta::game_import_meta; use spacetimedb::ReducerContext; @@ -32,7 +32,6 @@ macro_rules! debug_only { }; } -// Each Package selects the Actor operations it needs from these named exports. macro_rules! package_only { ($(pub(crate) use $path:path as $verb:ident;)+) => { $( @@ -48,16 +47,12 @@ package_only! { pub(crate) use crate::spell::request_cast as request_cast; } package_only! { pub(crate) use crate::spell::cast_readiness as cast_readiness; } debug_only! { pub(crate) use crate::combat::apply_start_ranged_attack as ranged_attack; } -/// Disarm the actor's outgoing auto-attack (melee or ranged). Shape adapter ONLY: the core returns -/// `()` (it never fails); this lifts it into the uniform `Result` verb shape. -#[cfg_attr(not(has_packages), allow(dead_code))] // package-only consumer — see `package_only!` +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn stop_attack(ctx: &ReducerContext, actor_guid: u64) -> Result<(), String> { crate::combat::stop_attack_for(ctx, actor_guid); Ok(()) } -/// Compatibility adapter for callers that only need acceptance. Use `request_cast` to retain -/// the scheduled identity and observe completion through `on_cast_finished`. #[allow( dead_code, reason = "Package API v1 retains the result-only cast_at adapter" @@ -73,8 +68,6 @@ pub(crate) fn cast_at( .map_err(Into::into) } -// ---- quests ---- - package_only! { pub(crate) use crate::quest::apply_accept_quest as accept_quest; pub(crate) use crate::quest::request_accept_quest as request_accept_quest; @@ -98,8 +91,6 @@ package_only! { pub(crate) use crate::bridge::party_command_fixture_drive as fixture_command_drive; debug_only! { pub(crate) use crate::quest::grant_quest_unchecked as stage_quest; } -// ---- loot / inventory / vendor ---- - pub(crate) use crate::loot::open_creature_corpse as open_creature_loot; package_only! { pub(crate) use crate::items::apply_item_use as use_item; @@ -108,7 +99,6 @@ package_only! { pub(crate) use crate::items::request_profile_item as reconcile_profile_item; pub(crate) use crate::items::apply_equip_profile_upgrade as equip_profile_upgrade; pub(crate) use crate::items::request_take_loot as request_take_loot; - // A Package bot also loots money through this verb. pub(crate) use crate::loot::apply_loot_money as loot_money; } pub(crate) use crate::creatures::apply_item_target_spell as cast_item_target; @@ -125,8 +115,6 @@ package_only! { pub(crate) use crate::seed::reconcile_curated_starter_role_levels as reconcile_starter_role_spell_levels; } -// ---- reads ---- - package_only! { pub(crate) use crate::helpers::live_entity as live_entity; pub(crate) use crate::helpers::character_by_guid as character_by_guid; @@ -143,8 +131,6 @@ package_only! { pub(crate) use crate::gameobject::go_type as go_type; } -// ---- loot entitlement ---- - package_only! { pub(crate) use crate::loot::corpse_access as corpse_access; pub(crate) use crate::loot::corpse_eligible_for_access as corpse_eligible_for_access; @@ -154,8 +140,6 @@ package_only! { pub(crate) use crate::loot::tag::LiveLootTagEligibility as LiveLootTagEligibility; } -// ---- spells ---- - package_only! { pub(crate) use crate::spell::pending_cast as pending_cast; pub(crate) use crate::spell::cancel_cast_attempt as cancel_cast_attempt; @@ -185,8 +169,6 @@ package_only! { pub(crate) use crate::spell::T_TARGET_ENEMY as T_TARGET_ENEMY; } -// ---- party ---- - package_only! { pub(crate) use crate::group::party_facts as party_facts; pub(crate) use crate::group::group_of as group_of; @@ -202,8 +184,6 @@ package_only! { pub(crate) use crate::group::PartyUnitFacts as PartyUnitFacts; } -// ---- NPC services / world ---- - package_only! { pub(crate) use crate::gameobject::apply_use_gameobject as use_gameobject; pub(crate) use crate::gameobject::request_use_gameobject as request_use_gameobject; @@ -216,8 +196,6 @@ package_only! { pub(crate) use crate::spell::do_self_resurrect as self_resurrect; } -// ---- social ---- - package_only! { pub(crate) use crate::chat::emit_system_message as system_message; pub(crate) use crate::chat::apply_send_chat as send_chat; @@ -234,7 +212,6 @@ package_only! { pub(crate) use crate::quest::enter_sessionless_areatrigger as enter_sessionless_areatrigger; } -/// A Refusal classified at the operation's Gate. Detail preserves existing client messages. #[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct ActionRefusal { pub kind: ActionRefusalKind, @@ -284,10 +261,6 @@ impl std::fmt::Display for ActionRefusal { } } -/// Return the existing body or restore it from the available Character row. A released ghost -/// stays a ghost, and a restored body loses its old movement leg. Without an existing body, an -/// absent or Transfer-fenced Character returns `None`. The caller owns controller consent and -/// pending Transfer Intent Gates. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn restore_sessionless_body( ctx: &ReducerContext, @@ -314,8 +287,6 @@ pub(crate) fn restore_sessionless_body( Some(entity) } -/// Stop the current leg where the client renders it and persist the position. If the body is -/// absent, remove its stale leg. The caller owns authorization and movement ownership checks. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn stop_movement(ctx: &ReducerContext, actor_guid: u64) { use crate::tables::{game_creature_spline, game_world_entity}; diff --git a/module/src/actor/movement.rs b/module/src/actor/movement.rs index 42ef27713..fcee105ed 100644 --- a/module/src/actor/movement.rs +++ b/module/src/actor/movement.rs @@ -3,9 +3,6 @@ reason = "Packages select the named API operations they need" )] -//! Movement reads and legs for an Actor: navigation routes and coverage, terrain height and zone, -//! and the creature-leg operations a Package mover drives. - pub(crate) use crate::creatures::tick::{emit_creature_leg, emit_creature_path}; pub(crate) use crate::nav::{ coverage_generation, has_los, inputs, route_path, route_path_with_budget, route_segment_clear, diff --git a/module/src/auction.rs b/module/src/auction.rs index 6c3949cd8..4a52c88c7 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -1,6 +1,3 @@ -//! Durable imported auction houses, value-preserving bid and Cancellation transport, and atomic -//! settlement. - use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; use lyracore_shared::auction::bid_outcome::{ @@ -17,7 +14,6 @@ use crate::import_meta::game_import_meta; use crate::mail::game_mail; use crate::{game_faction_template, game_item_instance, game_item_template, game_world_entity}; -/// Client-authored auction-house policy imported from `AuctionHouse.dbc`. #[table(accessor = game_auction_house, public)] pub struct AuctionHouseDefinition { #[primary_key] @@ -28,8 +24,6 @@ pub struct AuctionHouseDefinition { pub name: String, } -/// One active listing in its imported house market. The complete item-instance snapshot is the -/// item while it is listed; no inventory row exists until ordinary mail returns or delivers it. #[table( accessor = game_auction, public, @@ -61,15 +55,11 @@ pub struct Auction { pub consignment_rate: u32, #[default(0)] pub random_property_id: u32, - /// The listed Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`. 0 for every other item. #[default(0u32)] pub item_text_id: u32, } -/// Source-shard value reserved by a sharded listing operation. An active operation receipt makes -/// the row recovery evidence rather than spendable value; it is deleted only after that evidence -/// is durably copied back to the source. A refused listing instead keeps this Hold until -/// Realm-core durably commits its refund Mail and zero-id receipt. +// Retain the Hold until matching source evidence or a durable Realm-core refund exists. #[table( accessor = game_auction_hold, index(accessor = by_seller, btree(columns = [seller_guid])) @@ -95,19 +85,10 @@ pub struct AuctionHold { pub consignment_rate: u32, #[default(0)] pub random_property_id: u32, - /// The listed Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`. 0 for every other item. #[default(0u32)] pub item_text_id: u32, } -// A listing Hold keeps the item and the deposit on the seller's Home Shard, and its refund mails -// them back from that Shard. It travels with its Character like the bid Hold, so the next -// MSG_AUCTION_HELLO on the new Home Shard finishes the listing. This sweep deletes only a Hold that -// travelled: a Transfer's cascade keeps every Hold its payload does not carry -// (`transfer::ListingHolds::Keep`). That is the destination's own Hold on an import, and the -// source's Hold under a blob from the build before Holds travelled. A kept Hold finishes on the -// next MSG_AUCTION_HELLO its seller sends from that Shard. Character deletion is refused while a -// Hold exists. crate::character_owned!(delete, fn sweep_delete_game_auction_hold(ctx, character_guid) { let operations: Vec = ctx .db @@ -126,9 +107,7 @@ crate::character_owned!(transfer, fn sweep_transfer_game_auction_hold(ctx, chara keep_key, }); -/// Durable idempotency receipt. The full listing payload makes identical replay distinguishable -/// from conflicting reuse even after the source Hold has been deleted. Realm-core alone uses -/// `auction_id == 0` as a refused-listing refund receipt; that sentinel never reaches the source. +// auction_id == 0 certifies a refused listing refund only on Realm-core. #[table( accessor = game_auction_operation_receipt, index(accessor = by_actor, btree(columns = [actor_guid])) @@ -155,16 +134,10 @@ pub struct AuctionOperationReceipt { pub consignment_rate: u32, #[default(0)] pub random_property_id: u32, - /// The listed Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`. 0 for every other item. #[default(0u32)] pub item_text_id: u32, } -/// Source-shard copper Hold for one caller-identified bid or Cancellation. `outcome == 0` is -/// pending; every nonzero outcome is terminal. `accepted_price` records realm-core's normalized -/// charge, while `deferred_refund` retains any remainder that could not fit back in the purse. On a -/// Cancellation (`operation == hold_operation::CANCEL`) `bidder_guid` is the seller and `offer` is -/// the Auction Cut the seller agreed to pay. #[table( accessor = game_auction_bid_hold, index(accessor = by_bidder, btree(columns = [bidder_guid])) @@ -189,10 +162,6 @@ pub struct AuctionBidHold { pub operation: u8, } -// A Hold is copper on the Character's Home Shard, so it travels with the Character. A Hold left on -// a Shard the Character has left could never be refunded there, because the refund credits the -// purse on the Shard that holds the Hold. Deletion is refused while a Hold is unfinished, so the -// delete sweep only removes finished rows. crate::character_owned!(delete, fn sweep_delete_game_auction_bid_hold(ctx, character_guid) { let operations: Vec = ctx .db @@ -211,9 +180,6 @@ crate::character_owned!(transfer, fn sweep_transfer_game_auction_bid_hold(ctx, c keep_key, }); -/// Realm-core's terminal serialized decision for one bid or Cancellation payload. Auction changes, -/// buyout and Cancellation mail, displaced mail, and any later source-refund mail are exact-once -/// updates recorded on this row. #[table(accessor = game_auction_bid_decision)] pub struct AuctionBidDecision { #[primary_key] @@ -231,10 +197,7 @@ pub struct AuctionBidDecision { #[default(0)] pub accepted_price: u32, pub house: u32, - /// The auctioned item's catalogue entry and Random Property, captured while the Auction row - /// still exists. A deferred refund can fire after that row is gone (the auction settled or - /// expired between the decision and the refund), and this is the only place left to read the - /// item the refund's Outbid subject names. + // Preserve the item for refund subjects after the Auction row settles or expires. #[default(0u32)] pub item_entry: u32, #[default(0u32)] @@ -243,7 +206,6 @@ pub struct AuctionBidDecision { pub operation: u8, } -/// One one-shot scheduler row for each active Auction. #[table( accessor = game_auction_expiry, scheduled(expire_auction) @@ -257,11 +219,6 @@ pub struct AuctionExpiry { pub auction_id: u32, } -/// Private Relay event: a live outbid/won/sold/expired/new-bid/removed notice for one online seller -/// or bidder, -/// inserted in the same transaction as the Auction Mail it accompanies. `kind` is one of -/// `lyracore_shared::auction::auction_notice`. Reaped by the shared event GC, same as -/// `game_whisper_event`. #[table( accessor = game_auction_notice, index(accessor = by_recipient, btree(columns = [recipient_guid])) @@ -282,17 +239,6 @@ pub struct AuctionNotice { pub created_at: Timestamp, } -// Auction durability belongs to the listing protocol, not character transport. Active Auction or -// Hold value blocks character deletion, and every row except the two Holds stays on the database -// that owns its protocol phase rather than entering the character movement manifest. - -// A build 5875 client offers only three listing durations, 2, 8 and 24 hours -// (`AuctionsRadioButton_OnClick`, fx:Blizzard_AuctionUI.lua:991-1006; the default selection at -// line 798 picks the 8-hour middle button). cmangos-classic accepts exactly the matching etime, -// 1, 4 or 12 times its `MIN_AUCTION_TIME` of 2 hours (cm:AuctionHouseHandler.cpp:250-262, -// cm:AuctionHouseMgr.h:30), and the deposit scales by that same multiplier: -// `SellPrice * count * (etime / MIN_AUCTION_TIME) * depositPercent / 100` -// (cm:AuctionHouseMgr.cpp:65-77, `GetAuctionDeposit`). fn duration_multiplier(duration_minutes: u32) -> Option { match duration_minutes { 120 => Some(1), @@ -482,7 +428,7 @@ enum OperationMatch { Fresh, } -/// What a bid Hold row pays for. Stored as `hold_operation` codes by position, so only append. +// Stored variant ordinals are protocol codes; append new operations. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum HoldOperation { Bid, @@ -506,8 +452,6 @@ impl HoldOperation { } } -/// One Hold's identity. On a Cancel, `bidder_guid` is the seller and `offer` is the Auction Cut -/// the Gateway read, which Realm-core must still find current. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct HoldRequest { operation: HoldOperation, @@ -560,8 +504,6 @@ enum HoldDecision { BidIncrement, BidOwn, Database, - /// The seller withdrew the listing and pays `cut`. A displaced bidder gets `displaced_bid` - /// back by mail. Cancelled { cut: u32, displaced_bidder_guid: u64, @@ -570,8 +512,6 @@ enum HoldDecision { } impl HoldDecision { - /// Whether Realm-core can reach this decision for `operation`. A decision for the other - /// operation is a forged or crossed payload. fn belongs_to(self, operation: HoldOperation) -> bool { match self { Self::Accepted(_) | Self::HigherBid { .. } | Self::BidIncrement | Self::BidOwn => { @@ -583,10 +523,6 @@ impl HoldDecision { } } -/// The vanilla `MailAuctionAnswers` action code embedded in an Auction Mail's subject -/// (`cm:Mail.h:100-109`). `Cancelled` (5) returns a cancelled listing's item to its seller; a -/// refused listing's return uses it too, as the closest client string for a return with no vanilla -/// twin. `CancelledToBidder` (4) refunds the displaced bid of a cancelled listing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AuctionMailAction { Outbid = 0, @@ -597,11 +533,6 @@ enum AuctionMailAction { Cancelled = 5, } -/// An Auction Mail's own vanilla shape: `MAIL_AUCTION` from the house, `checked = COPIED` -/// (`cm:Mail.cpp:81-84`), a machine subject the client turns into `AUCTION_*_MAIL_SUBJECT` -/// (`fx:GlobalStrings.lua:83,87,88,89,99`), and — for Won/Successful — an invoice body the client -/// turns into the auction details (`fx:MailFrame.lua:300-361`). `item_entry`/`random_property_id` -/// name the AUCTIONED item, which the subject always carries even when nothing is attached. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct AuctionMail { recipient_guid: u64, @@ -611,8 +542,6 @@ struct AuctionMail { random_property_id: u32, money: u32, attached_item: crate::items::ItemSnapshot, - /// The invoice's other party: the seller on a Won mail, the winning bidder on a Successful - /// mail. Unused (0) on every other action. counterparty_guid: u64, bid: u32, buyout: u32, @@ -707,10 +636,6 @@ fn buyout_settlement_mail( ) } -/// Mail that returns a refused listing's item and deposit to the seller. No vanilla auction -/// refuses a listing after acceptance, so this renders as the closest client string, a Cancelled -/// mail carrying the item and the deposit, and hides Return the same way every other Auction Mail -/// does. fn listing_release_mail(listing: &PreparedListing) -> AuctionMail { AuctionMail { recipient_guid: listing.request.seller_guid, @@ -728,9 +653,6 @@ fn listing_release_mail(listing: &PreparedListing) -> AuctionMail { } } -/// A Cancellation's mail (`cm:AuctionHouseHandler.cpp:167-189,440-456`): the item goes back to the -/// seller in a Cancelled mail, and a displaced bidder gets the bid back in a Cancelled-to-bidder -/// mail. No mail carries the deposit, because the house keeps it. fn cancellation_mail( auction: BidAuction, displaced_bidder_guid: u64, @@ -769,9 +691,7 @@ fn cancellation_mail( .collect() } -/// `"{item_entry}:{random_property_id}:{action}"` (`cm:AuctionHouseMgr.cpp:134,181,229`, -/// `cm:AuctionHouseHandler.cpp:155,180,451`). The client builds the visible subject from this and -/// the item's name (`fx:GlobalStrings.lua:83,87,88,89,99`). +// The client parses subjects as item_entry:random_property_id:action. fn auction_mail_subject( item_entry: u32, random_property_id: u32, @@ -780,10 +700,7 @@ fn auction_mail_subject( format!("{item_entry}:{random_property_id}:{}", action as u8) } -/// The invoice the client renders from a Won or Successful mail's body -/// (`fx:MailFrame.lua:300-361`). The guid is lowercase hex, right-aligned in a 16-character, -/// space-filled field — cmangos prints the low guid, which a 1.12 character guid equals whole. -/// Every other action carries no body. +// Invoice guids use lowercase hex in a 16-character, space-filled field. fn auction_mail_body(mail: &AuctionMail) -> String { match mail.action { AuctionMailAction::Won => format!( @@ -819,7 +736,6 @@ fn insert_auction_mail(ctx: &ReducerContext, mail: AuctionMail) { ); } -/// One `game_auction_notice` row before its id and timestamp are stamped. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct AuctionNoticeDraft { recipient_guid: u64, @@ -849,8 +765,6 @@ fn insert_auction_notice(ctx: &ReducerContext, draft: AuctionNoticeDraft) { }); } -/// Won (to the buyer) and Sold (to the seller): the two notices every settled sale sends, whether -/// it settled by buyout or by expiry with a bid (`cm:AuctionHouseMgr.cpp:146-147,195-199`). #[allow(clippy::too_many_arguments)] fn settlement_notices( house: u32, @@ -888,11 +802,6 @@ fn settlement_notices( ] } -/// Outbid, to the displaced bidder, sent before the bid changes (`cm:AuctionHouseHandler.cpp:93-107,157-158`). -/// No displaced bidder (a fresh listing's first bid), or the displaced bidder raising their own -/// bid, sends nothing: cmangos's `UpdateBid` charges a self-raise only the delta and never calls -/// `SendAuctionBidderNotification` for it (`cm:AuctionHouseMgr.cpp:780-793`), because the bidder -/// was never actually outbid. #[allow(clippy::too_many_arguments)] fn outbid_notice( house: u32, @@ -917,8 +826,6 @@ fn outbid_notice( ) } -/// New bid, to the owner: the client refreshes its list and prints nothing -/// (`cm:AuctionHouseMgr.cpp:802-806`). fn new_bid_notice( house: u32, auction_id: u32, @@ -940,7 +847,6 @@ fn new_bid_notice( } } -/// Expired, to the owner: an unsold listing (`cm:AuctionHouseMgr.cpp:231-232`). fn expired_notice( house: u32, auction_id: u32, @@ -955,16 +861,12 @@ fn expired_notice( item_entry: item.entry, random_property_id: item.random_property_id, bid: 0, - // `bid_increment` returns 0 for a zero bid — nothing to raise — but the client always - // shows a nonzero minimum raise, even on an unsold listing. Floor it the same way cmangos - // does (`cm:AuctionHouseMgr.cpp:739-745`: `if (!outbid) outbid = 1`). + // The client requires a nonzero minimum raise even on an unsold listing. out_bid: 1, bidder_guid: 0, } } -/// Removed, to the bidder a Cancellation displaces (`cm:AuctionHouseHandler.cpp:131-139,182-183`). -/// The client prints `ERR_AUCTION_REMOVED_S` with the item's name. fn removed_notice( house: u32, auction_id: u32, @@ -1075,7 +977,7 @@ fn bid_decision_from_fields(fields: BidDecisionFields, legacy_offer: u32) -> Opt }), BID_INCREMENT => Some(HoldDecision::BidIncrement), BID_OWN => Some(HoldDecision::BidOwn), - // No legacy fallback: a Cancellation's cut of 0 is a real charge of nothing. + // A Cancellation cut of zero is a real charge, not a missing legacy value. BID_CANCELLED => Some(HoldDecision::Cancelled { cut: fields.accepted_price, displaced_bidder_guid: fields.result_bidder_guid, @@ -1132,8 +1034,6 @@ trait BidSource { fn confirm_refund(&mut self, request: HoldRequest) -> Result<(), AuctionRefusal>; } -/// A request no Hold can carry: a zero operation, Character, listing or house, or a bid of 0. A -/// Cancellation's cut is 0 for a listing nobody bid on. fn request_is_malformed(request: &HoldRequest) -> bool { request.operation_id == 0 || request.bidder_guid == 0 @@ -1142,7 +1042,6 @@ fn request_is_malformed(request: &HoldRequest) -> bool { || (request.operation == HoldOperation::Bid && request.offer == 0) } -/// The listing `request` names, while it is still active in the request's Auction Market. fn active_listing( auction: Option, request: &HoldRequest, @@ -1156,9 +1055,6 @@ fn active_listing( }) } -/// Move a Hold's value out of the purse. A bid holds its full offer. A Cancellation holds the Auction -/// Cut, which is 0 for a listing nobody bid on, and still takes the Hold so the interaction Gate -/// runs on the Home Shard. fn fence_bid(source: &mut S, request: HoldRequest) -> Result<(), AuctionRefusal> { if request_is_malformed(&request) { return Err(AuctionRefusal::Database); @@ -1208,7 +1104,6 @@ fn refundable_bid_value(decision: HoldDecision, offer: u32) -> Option { offer.checked_sub(price) } HoldDecision::Accepted(_) => None, - // A Cancellation spends exactly the cut it held; a different cut was never agreed. HoldDecision::Cancelled { cut, .. } if cut == offer => Some(0), HoldDecision::Cancelled { .. } => None, _ => Some(offer), @@ -1278,10 +1173,6 @@ fn decide_bid(auction: Option, request: HoldRequest, now_micros: i64 }) } -/// Realm-core's Cancellation Gate (`cm:AuctionHouseHandler.cpp:403-468`). Only the seller cancels, -/// only an active listing of the auctioneer's market, and only at the cut the seller's Hold paid -/// for: a bid that landed after the Gateway read the listing changes the cut and refuses the -/// Cancellation, so the Hold refunds and the seller can try again. fn decide_cancel( auction: Option, request: HoldRequest, @@ -1295,8 +1186,6 @@ fn decide_cancel( else { return HoldDecision::ItemNotFound; }; - // A bid without a bidder, or a bidder without a bid, has no one to refund; expiry keeps the - // same state for repair. if (auction.highest_bidder_guid == 0) != (auction.highest_bid == 0) { return HoldDecision::Database; } @@ -1599,8 +1488,7 @@ fn refund_listing( } } -/// Give a Hold's value back after realm-core refused its listing. A Hold with a matching receipt -/// backs a live Auction and is never released; a missing Hold is a replay. +// A listing receipt blocks refunding the Hold even after the Auction settles. fn release_listing( sink: &mut S, operation_id: u64, @@ -1684,7 +1572,6 @@ fn expire_active(sink: &mut S, auction_id: u32) -> Result<(), Str Ok(()) } -/// Reducer edge: only the tag crosses to the gateway; the detail stays in module logs. fn refused(refusal: AuctionRefusal, detail: &str) -> String { let tag = refusal.as_tag(); spacetimedb::log::info!("auction refused {tag}: {detail}"); @@ -2266,9 +2153,6 @@ impl BidMarket for CtxBidMarket<'_> { )?; } let fields = bid_decision_fields(decision); - // The Auction row this decision is about may be deleted later (settled or expired) before - // a purse-overflow refund on it fires. Keep the item the refund's subject needs here, - // while the row is still guaranteed present. let (item_entry, random_property_id) = auction.map_or((0, 0), |a| (a.item.entry, a.item.random_property_id)); self.ctx @@ -2296,8 +2180,6 @@ impl BidMarket for CtxBidMarket<'_> { } impl CtxBidMarket<'_> { - /// Remove the listing the decision read, return its item to the seller, and refund the - /// displaced bidder, all in the decision's transaction. fn commit_cancellation( &mut self, expected: BidAuction, @@ -2365,12 +2247,6 @@ impl BidRefundSink for CtxBidMarket<'_> { if decided_request(&row) != Some(request) || row.deferred_refund != 0 { return Err(AuctionRefusal::Database); } - // The Auction this Hold was about may already be settled and gone (this refund is the - // purse-overflow remainder of a bid that did not win, or of a refused Cancellation's cut). - // No vanilla auction sends this mail at all; it renders as the closest client string, - // Outbid, carrying the refund. The item comes from the decision row itself, not a fresh - // Auction lookup, because the row this refund is about can outlive the Auction it was - // decided against. insert_auction_mail( self.ctx, AuctionMail { @@ -2416,8 +2292,6 @@ fn hold_request( } } -/// The request a Realm-core decision row records. An operation code this Module does not know -/// reads as no request, so every comparison against it fails closed. fn decided_request(row: &AuctionBidDecision) -> Option { Some(hold_request( HoldOperation::from_code(row.operation)?, @@ -2429,9 +2303,6 @@ fn decided_request(row: &AuctionBidDecision) -> Option { )) } -/// The operation a stored Hold or decision row pays for. The later phases take their operation -/// from the row, never from the caller, so one reducer finishes bids and Cancellations alike. A -/// missing row reads as a bid; the phase then finds no matching Hold and refuses. fn stored_hold_operation(ctx: &ReducerContext, operation_id: u64) -> HoldOperation { ctx.db .game_auction_bid_hold() @@ -2562,7 +2433,6 @@ impl ExpirySink for CtxExpiry<'_> { } } -/// Single-database listing: item, deposit, Auction, receipt, and expiry are one transaction. #[reducer] #[allow(clippy::too_many_arguments)] pub fn gw_auction_list_local( @@ -2621,7 +2491,6 @@ pub fn gw_auction_list_local( .map_err(|refusal| refused(refusal, "listing rejected")) } -/// Sharded listing: atomically move the source value into a caller-identified Hold. #[reducer] #[allow(clippy::too_many_arguments)] pub fn gw_auction_hold_listing( @@ -2687,7 +2556,6 @@ pub fn gw_auction_hold_listing( .map_err(|refusal| refused(refusal, "listing Hold rejected")) } -/// Sharded listing: create the realm Auction and idempotency receipt from a held payload. #[reducer] #[allow(clippy::too_many_arguments)] pub fn realm_auction_commit_listing( @@ -2771,7 +2639,6 @@ fn require_listing_actor( Ok(()) } -/// Sharded listing: copy the matching realm receipt onto the source shard. #[reducer] pub fn realm_auction_confirm_listing( ctx: &ReducerContext, @@ -2799,7 +2666,7 @@ pub fn realm_auction_confirm_listing( .map_err(|refusal| refused(refusal, "listing receipt conflict")) } -/// Sharded listing: delete the Hold only after the source has matching receipt evidence. +// Delete the Hold only after its matching Realm-core receipt is durable on the source. #[reducer] pub fn realm_auction_settle_listing( ctx: &ReducerContext, @@ -2812,10 +2679,8 @@ pub fn realm_auction_settle_listing( .map_err(|refusal| refused(refusal, "listing Hold is not confirmed")) } -/// Sharded listing abort: Realm-core commits the seller's exact return Mail and durable -/// refund receipt together. Replays with the same payload do not create a second Mail. #[reducer] -#[allow(clippy::too_many_arguments)] // The persisted refund receipt's value columns. +#[allow(clippy::too_many_arguments)] pub fn realm_auction_refund_listing( ctx: &ReducerContext, operation_id: u64, @@ -2867,9 +2732,7 @@ pub fn realm_auction_refund_listing( .map_err(|refusal| refused(refusal, "listing refund conflict")) } -/// Sharded listing abort: after realm-core refuses, mail the held item and deposit back -/// to the seller and delete the Hold. The gateway calls this only after Realm-core has committed -/// the matching refund receipt and Mail. Refused once the Hold has a receipt. +// Release only after Realm-core commits the matching refund receipt and Mail. #[reducer] pub fn gw_auction_release_listing_hold( ctx: &ReducerContext, @@ -2882,8 +2745,6 @@ pub fn gw_auction_release_listing_hold( .map_err(|refusal| refused(refusal, "listing Hold is confirmed")) } -/// The Home Shard half every Hold starts with: the interaction Gate on a fresh operation, then the -/// fence. A replay skips the Gate, because the Hold already proves it passed. fn gate_and_fence( ctx: &ReducerContext, request: HoldRequest, @@ -2908,8 +2769,6 @@ fn gate_and_fence( .map_err(|refusal| refused(refusal, "Hold rejected")) } -/// Single-database Hold: the fence, the realm decision, its Auction and mail effects, the terminal -/// source outcome and any purse-overflow refund mail commit in one transaction. fn drive_local_hold( ctx: &ReducerContext, request: HoldRequest, @@ -2935,8 +2794,6 @@ fn drive_local_hold( Ok(()) } -/// Single-database bid: full-offer Hold, realm decision, Auction update or buyout settlement, -/// ordinary mail, and terminal source outcome commit atomically. #[reducer] pub fn gw_auction_bid_local( ctx: &ReducerContext, @@ -2960,8 +2817,6 @@ pub fn gw_auction_bid_local( drive_local_hold(ctx, request, auctioneer_guid) } -/// Single-database Cancellation: the cut's Hold, the realm decision, the listing's removal, its -/// Cancelled and refund mail, and the terminal source outcome commit atomically. #[reducer] pub fn gw_auction_cancel_local( ctx: &ReducerContext, @@ -2985,7 +2840,6 @@ pub fn gw_auction_cancel_local( drive_local_hold(ctx, request, auctioneer_guid) } -/// Sharded bid: move the complete offer into a source-shard Hold before realm-core decides. #[reducer] pub fn gw_auction_hold_bid( ctx: &ReducerContext, @@ -3009,8 +2863,6 @@ pub fn gw_auction_hold_bid( gate_and_fence(ctx, request, auctioneer_guid) } -/// Sharded Cancellation: move the Auction Cut into a source-shard Hold before realm-core -/// decides. A seller who cannot pay it is refused with nothing held. #[reducer] pub fn gw_auction_hold_cancel( ctx: &ReducerContext, @@ -3034,7 +2886,6 @@ pub fn gw_auction_hold_cancel( gate_and_fence(ctx, request, auctioneer_guid) } -/// Sharded bid: serialize against the realm Auction and persist one terminal decision. #[reducer] pub fn realm_auction_decide_bid( ctx: &ReducerContext, @@ -3061,9 +2912,6 @@ pub fn realm_auction_decide_bid( .map_err(|refusal| refused(refusal, "bid decision conflict")) } -/// Sharded Cancellation: serialize against the realm Auction and persist one terminal -/// decision. A Cancelled decision removes the listing and writes its mail and notice in the same -/// transaction; a replay changes nothing. #[reducer] pub fn realm_auction_decide_cancel( ctx: &ReducerContext, @@ -3090,8 +2938,6 @@ pub fn realm_auction_decide_cancel( .map_err(|refusal| refused(refusal, "Cancellation decision conflict")) } -/// Sharded for a bid or a Cancellation: consume the accepted price or the cut, or restore -/// refused value, exactly once. The operation comes from the stored Hold. #[reducer] #[allow(clippy::too_many_arguments)] pub fn gw_auction_finish_bid( @@ -3138,8 +2984,6 @@ pub fn gw_auction_finish_bid( .map_err(|refusal| refused(refusal, "Hold outcome conflict")) } -/// Sharded for a bid or a Cancellation: place an unrepresentable purse refund in -/// realm-core mail exactly once. The operation comes from the stored decision. #[reducer] pub fn realm_auction_refund_bid( ctx: &ReducerContext, @@ -3167,8 +3011,6 @@ pub fn realm_auction_refund_bid( .map_err(|refusal| refused(refusal, "Hold refund conflict")) } -/// Sharded for a bid or a Cancellation: record on the source that realm-core durably -/// accepted the refund mail. The operation comes from the stored Hold. #[reducer] pub fn gw_auction_confirm_bid_refund( ctx: &ReducerContext, @@ -3196,7 +3038,6 @@ pub fn gw_auction_confirm_bid_refund( .map_err(|refusal| refused(refusal, "Hold refund confirmation conflict")) } -/// Scheduler-only one-shot expiry. Replays see no active Auction and therefore create no mail. #[reducer] pub fn expire_auction(ctx: &ReducerContext, schedule: AuctionExpiry) -> Result<(), String> { if ctx.sender() != ctx.database_identity() { @@ -3205,7 +3046,7 @@ pub fn expire_auction(ctx: &ReducerContext, schedule: AuctionExpiry) -> Result<( expire_active(&mut CtxExpiry { ctx }, schedule.auction_id) } -/// Character deletion must not destroy value held by or listed for that character. +// Character deletion must not destroy held or listed value. pub(crate) fn character_has_auction_value(ctx: &ReducerContext, character_guid: u64) -> bool { ctx.db .game_auction_bid_hold() @@ -3235,22 +3076,15 @@ pub(crate) fn character_has_auction_value(ctx: &ReducerContext, character_guid: .is_some() } -/// The exact English subjects LyraCore's earlier auction code sent, each a `Character` mail with -/// no vanilla twin. A real player's letter can carry the same words, so the subject alone never -/// authorizes a re-tag; [`legacy_mail_matches_shape`] is the rest of the check. const LEGACY_AUCTION_SUBJECTS: &[(&str, AuctionMailAction)] = &[ ("Auction outbid", AuctionMailAction::Outbid), ("Auction won", AuctionMailAction::Won), ("Auction sold", AuctionMailAction::Successful), ("Auction expired", AuctionMailAction::Expired), ("Auction listing refused", AuctionMailAction::Cancelled), - // The deferred bid refund shares no subject with the ordinary Outbid mail, so it needs its - // own row here; it also renders as Outbid (see `CtxBidMarket::commit_refund`). ("Auction bid refund", AuctionMailAction::Outbid), ]; -/// The pure half of the repair: which `AuctionMailAction` a legacy row's exact English subject -/// maps to, or `None` for a subject this repair does not recognize. fn legacy_auction_action(subject: &str) -> Option { LEGACY_AUCTION_SUBJECTS .iter() @@ -3258,18 +3092,6 @@ fn legacy_auction_action(subject: &str) -> Option { .map(|(_, action)| *action) } -/// Whether `mail` carries the exact field shape the earlier auction code produced for its -/// subject — the first of two checks that tell a real letter apart from a look-alike. A player's -/// own mail can share the subject text (nothing stops them from titling a letter "Auction won"), -/// but every legacy row also carries `cod == 0` and an empty body (auction mail never used -/// either), and: -/// - Outbid, Expired, Cancelled and the deferred bid refund came from guid 0 — no player-sent -/// letter ever does, since a send always stamps the sender's own actor guid. That alone rules -/// out a look-alike for these four subjects. -/// - Expired and Cancelled carry an item; Outbid and the bid refund carry none. -/// - Won carries an item and no money; Sold carries money and no item. Neither has a guid-0 tell — -/// a real player letter can be titled either with a real sender and the right shape — so -/// [`legacy_repair_authorization`] is what actually clears them, not this function. fn legacy_mail_matches_shape(mail: &crate::Mail) -> bool { if mail.cod != 0 || !mail.body.is_empty() { return false; @@ -3285,43 +3107,13 @@ fn legacy_mail_matches_shape(mail: &crate::Mail) -> bool { } } -/// A converted row's house and the item reference its vanilla subject encodes. struct LegacyRepairTarget { house: u32, item_entry: u32, random_property_id: u32, } -/// The second, and for Won/Sold the decisive, check: whether a durable listing receipt backs -/// `mail`'s claim, and if so, the house and subject-item that receipt supplies. Every other legacy -/// subject already cleared [`legacy_mail_matches_shape`]'s guid-0 tell, so a receipt only sharpens -/// its house; Won and Sold do not have that tell, because nothing else in the mail row tells a -/// real settlement apart from a same-titled player letter with an unrelated real sender: -/// - Won: some receipt's actor is the mail's claimed sender (the seller who listed this exact -/// item) and its item entry and stack count match what the mail carries. A look-alike sent by an -/// uninvolved player never has a receipt naming them as that item's seller. -/// - Sold: some receipt's actor is the mail's recipient and its `auction_id` is not the -/// refused-listing sentinel (0) — proceeds mail carries no item of its own to cross-check, so a -/// refused listing's refund receipt is not a settlement at all. A seller can have more than one -/// real receipt, so when more than one candidate remains, only the one whose listing terms could -/// have produced this exact proceeds figure ([`receipt_could_pay_out`]) is trusted; if that still -/// leaves more than one, or none, the row is left unmapped rather than guessed at. The subject -/// also borrows the winning receipt's item, since the mail's own item fields are 0 (Sold never -/// attaches the item). -/// - Expired and Cancelled: some receipt's actor is the mail's recipient (the seller the item -/// returned to) and its item entry and stack count match what the mail carries, the same -/// assurance Won uses. A `Cancelled` (refused-listing) row's own receipt IS the `auction_id == 0` -/// sentinel, so unlike Sold this does not exclude it. -/// -/// Every subject above takes its house from that receipt rather than the recipient's own race -/// (`house_for_faction_template`'s approach for a live sale, -/// `crates/lyracore-shared/src/auction.rs`): Realm-core, the database this repair runs against, -/// carries no Character rows at all, so a race lookup would always miss and fall back to the -/// neutral house. A receipt's `house` is durable state recorded when the listing was created, so -/// it needs no Character lookup and is correct on Realm-core too. Expired and Cancelled fall back -/// to the race lookup only if no receipt matches their item (a legacy row that predates receipts, -/// say); Outbid and the deferred bid refund have no receipt to draw from at all — a bidder is never -/// a receipt's actor — and always use the race lookup. +// Won/Sold subjects can be real letters; retag them only with a matching durable listing receipt. fn legacy_repair_authorization( ctx: &ReducerContext, mail: &crate::Mail, @@ -3361,7 +3153,7 @@ fn legacy_repair_authorization( .filter(|receipt| receipt_could_pay_out(receipt, mail.money)); let only = paying.next()?; if paying.next().is_some() { - return None; // still ambiguous — more than one listing could have paid this + return None; } only } @@ -3400,12 +3192,6 @@ fn legacy_repair_authorization( } } -/// Whether some winning price within `receipt`'s listed range (`start_bid..=buyout`, or -/// `start_bid..` when `buyout` is 0 — an auction the vanilla protocol lets bidding pass without a -/// cap) pays the seller exactly `money`. `seller_proceeds` is non-decreasing in price and never -/// skips a whole copper as price climbs by one (its cut grows by at most one copper per copper of -/// price), so every integer between its low and high ends is reachable — checking the two ends -/// bounds every price in between too. fn receipt_could_pay_out(receipt: &AuctionOperationReceipt, money: u32) -> bool { let Some(low) = seller_proceeds(receipt.start_bid, receipt.deposit, receipt.consignment_rate) else { @@ -3419,16 +3205,10 @@ fn receipt_could_pay_out(receipt: &AuctionOperationReceipt, money: u32) -> bool } match seller_proceeds(receipt.buyout, receipt.deposit, receipt.consignment_rate) { Some(high) => money <= high, - None => true, // the buyout's own proceeds overflow u32; some in-range price still might not + None => true, } } -/// The house a converted row shows the client on a deployment where Realm-core's Character rows -/// exist, chosen from the recipient's own team: Alliance goes to Stormwind (1), Horde to Orgrimmar -/// (6), matching `house_for_faction_template`'s own team fallback -/// (`crates/lyracore-shared/src/auction.rs`). Falls back to the neutral house (7) for a recipient -/// with no Character row — the same fallback that function uses for a template it cannot place on -/// either team, and Realm-core's only outcome, since it carries no Character rows at all. fn legacy_character_house(ctx: &ReducerContext, recipient_guid: u64) -> u32 { match crate::helpers::character_by_guid(ctx, recipient_guid) { Some(character) @@ -3442,28 +3222,8 @@ fn legacy_character_house(ctx: &ReducerContext, recipient_guid: u64) -> u32 { } } -/// Family name `repair_legacy_auction_mail` stamps in `game_import_meta` once it has run on a -/// database, so a later publish's repair pass does not re-scan mail a player wrote after the -/// re-tag — including a letter that happens to name one of [`LEGACY_AUCTION_SUBJECTS`]. const LEGACY_AUCTION_MAIL_REPAIR_FAMILY: &str = "repair_legacy_auction_mail"; -/// Re-tag auction mail written before the vanilla Auction Mail format shipped to the vanilla -/// `AuctionHouse` sender and machine subject, once per database. A legacy row still reads as -/// `Character` mail: once the Mail Timer is live, its expiry returns an already-settled "Auction -/// won" to the seller, who was already paid — cmangos deletes a Character mail's item at expiry -/// only when it is unreturned (`cm:ObjectMgr.cpp:6188-6232`), and a legacy Auction Mail was never -/// meant to be returnable at all. Re-tagging moves it out from under that rule, the same way -/// `mail::plan_return` already refuses Return on any current Auction Mail. -/// -/// Runs at most once: a marker in `game_import_meta` (`LEGACY_AUCTION_MAIL_REPAIR_FAMILY`) records -/// that this database has already been swept, so a later publish's repair pass leaves every mail -/// alone, including a real player letter that happens to match one of the legacy subjects. Within -/// that one run, [`legacy_mail_matches_shape`] and [`legacy_repair_authorization`] are the two -/// safeguards for every row already on the table: a row converts only if its sender, cod, body, -/// item and money exactly match what the old code produced, and — for "Auction won" and "Auction -/// sold", which carry no tell of their own — a durable listing receipt backs its claim. Returns -/// `(converted, unmapped)`; a nonzero `unmapped` is an auction-looking row this repair left alone, -/// worth a human look. pub(crate) fn repair_legacy_auction_mail(ctx: &ReducerContext) -> (u64, u64) { if ctx .db diff --git a/module/src/auction/fixtures.rs b/module/src/auction/fixtures.rs index dc9915647..1ec54cbb2 100644 --- a/module/src/auction/fixtures.rs +++ b/module/src/auction/fixtures.rs @@ -10,9 +10,6 @@ const BUYOUT_FIXTURE_NEW_BID_OPERATION_ID: u64 = 509_0056; const BUYOUT_FIXTURE_NEW_BID_SELLER_GUID: u64 = 509_0057; const BUYOUT_FIXTURE_NEW_BID_BIDDER_GUID: u64 = 509_0058; -/// Stage one reserved Auction row for the standalone buyout integration test, plus a second, -/// unbid, no-buyout Auction (`BUYOUT_FIXTURE_NEW_BID_*`) the same test drives through -/// `AuctionBidEffect::RemainActive` to cover the New Bid notice. #[reducer] pub fn debug_stage_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -103,8 +100,6 @@ pub fn debug_stage_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), St auction_id: BUYOUT_FIXTURE_AUCTION_ID, }); - // No buyout term, so any accepted offer is RemainActive — an ordinary raise, never a - // settlement — and no prior bidder, so the decision carries no displaced bidder either. let new_bid_expires_at = Timestamp::from_micros_since_unix_epoch(expires_micros.saturating_add(1)); ctx.db.game_auction().insert(Auction { @@ -164,8 +159,6 @@ fn auction_fixture_mail( Ok(matches.remove(0)) } -/// The exactly-one notice of `kind` for `recipient_guid`, verifying every wire field the -/// gateway relays. #[allow(clippy::too_many_arguments)] fn auction_fixture_notice( ctx: &ReducerContext, @@ -206,11 +199,6 @@ fn auction_fixture_notice( Ok(()) } -/// Verify the real realm reducer committed exact settlement rows in a prior transaction, including -/// every Auction Notice. Auction Notices are a one-shot, TTL-reaped relay (see `gc.rs`); the -/// durable test disarms the reaper schedule before staging this fixture, so nothing claims a -/// notice row while this test runs and this check is safe to call whenever the test wants it, -/// regardless of what ran before it. #[reducer] pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -326,7 +314,7 @@ pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), S item.entry, item.random_property_id, 201, - 11, // vanilla minimum raise on a 201 bid: 5% rounded up + 11, BUYOUT_FIXTURE_DISPLACED_GUID, )?; auction_fixture_notice( @@ -338,7 +326,7 @@ pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), S item.entry, item.random_property_id, 0, - 25, // vanilla minimum raise on a 500 bid: 5% rounded up + 25, BUYOUT_FIXTURE_WINNER_GUID, )?; auction_fixture_notice( @@ -350,13 +338,10 @@ pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), S item.entry, item.random_property_id, 500, - 25, // vanilla minimum raise on a 500 bid: 5% rounded up + 25, 0, )?; - // RemainActive coverage: an ordinary raise on a fresh listing settles nothing and displaces - // nobody, so it must record the new bid, mail nobody, and write exactly one New Bid notice to - // the owner. let new_bid_auction = ctx .db .game_auction() @@ -389,25 +374,17 @@ pub fn debug_verify_auction_buyout_fixture(ctx: &ReducerContext) -> Result<(), S BUYOUT_FIXTURE_NEW_BID_AUCTION_ID, 0, 60, - 3, // vanilla minimum raise on a 60 bid: 5% rounded up + 3, BUYOUT_FIXTURE_NEW_BID_BIDDER_GUID, )?; Ok(()) } -/// The listing with a bid, the unbid listing, and the listing whose cut the test offers wrong. const CANCEL_FIXTURE_AUCTION_IDS: [u32; 3] = [509_0086, 509_0088, 509_0089]; const CANCEL_FIXTURE_BIDDER_GUID: u64 = 509_0087; -/// Every operation id the Cancellation test may use, cleared before each staging. const CANCEL_FIXTURE_OPERATION_IDS: std::ops::RangeInclusive = 509_0086..=509_0095; -/// Blackwater's faction template (`cm:AuctionHouseMgr.cpp:461-518`), so the auctioneer serves the -/// neutral house 7. const CANCEL_FIXTURE_FACTION_TEMPLATE: u32 = 120; -/// Stage three listings of `seller_guid` in house 7 at a consignment rate of 15, each with its -/// expiry row: 509_0086 carries a bid of 1000 by 509_0087 (cut 150), 509_0088 has no bid (cut 0), -/// and 509_0089 carries the same bid. A nonzero `auctioneer_guid` becomes an auctioneer of house 7, -/// and the fixture imports house 7 and its faction template when the database has none. #[reducer] pub fn debug_stage_auction_cancel_fixture( ctx: &ReducerContext, @@ -578,9 +555,6 @@ const EXPIRY_FIXTURE_UNSOLD_ITEM: crate::items::ItemSnapshot = crate::items::Ite item_text_id: 0, }; -/// Stage a valid winning-bid Auction whose one-shot schedule fires shortly after this -/// transaction, plus a second, unbid Auction (`EXPIRY_FIXTURE_UNSOLD_*`) whose expiry has no -/// winner, to cover the Expired notice. #[reducer] pub fn debug_stage_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -697,8 +671,6 @@ pub fn debug_stage_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), St auction_id: EXPIRY_FIXTURE_AUCTION_ID, }); - // Unsold: no bidder ever placed an offer, so expiry returns the item and mails the owner an - // Expired notice rather than settling a sale. let unsold_expires_at = Timestamp::from_micros_since_unix_epoch(expires_micros + 1); ctx.db .game_auction_operation_receipt() @@ -756,7 +728,6 @@ pub fn debug_stage_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), St Ok(()) } -/// Re-drive the callback body after settlement to prove the missing Auction is a durable no-op. #[reducer] pub fn debug_replay_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -764,11 +735,6 @@ pub fn debug_replay_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), S expire_active(&mut CtxExpiry { ctx }, EXPIRY_FIXTURE_UNSOLD_AUCTION_ID) } -/// Verify the scheduler committed the exact bid-expiry mail, removed only active state, and fired -/// every Auction Notice. Auction Notices are a one-shot, TTL-reaped relay (see `gc.rs`); the -/// durable test disarms the reaper schedule before staging this fixture, so nothing claims a -/// notice row while this test runs and this check is safe to call whenever the test wants it, -/// regardless of what ran before it. #[reducer] pub fn debug_verify_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -856,7 +822,6 @@ pub fn debug_verify_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), S return Err("expiry seller proceeds mail changed".to_string()); } - // Unsold coverage: no bidder means expiry returns the item, not a Won/Sold pair. let returned = auction_fixture_mail( ctx, EXPIRY_FIXTURE_UNSOLD_SELLER_GUID, @@ -884,7 +849,7 @@ pub fn debug_verify_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), S EXPIRY_FIXTURE_ITEM.entry, EXPIRY_FIXTURE_ITEM.random_property_id, 0, - 11, // vanilla minimum raise on a 201 bid: 5% rounded up + 11, EXPIRY_FIXTURE_WINNER_GUID, )?; auction_fixture_notice( @@ -896,7 +861,7 @@ pub fn debug_verify_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), S EXPIRY_FIXTURE_ITEM.entry, EXPIRY_FIXTURE_ITEM.random_property_id, 201, - 11, // vanilla minimum raise on a 201 bid: 5% rounded up + 11, 0, )?; auction_fixture_notice( @@ -908,7 +873,7 @@ pub fn debug_verify_auction_expiry_fixture(ctx: &ReducerContext) -> Result<(), S EXPIRY_FIXTURE_UNSOLD_ITEM.entry, EXPIRY_FIXTURE_UNSOLD_ITEM.random_property_id, 0, - 1, // bid_increment(0) is 0, but expired_notice floors an unsold listing's raise to 1. + 1, 0, )?; Ok(()) @@ -920,31 +885,17 @@ const LEGACY_MAIL_LOOKALIKE_RECIPIENT_GUID: u64 = 509_0072; const LEGACY_MAIL_LOOKALIKE_SENDER_GUID: u64 = 509_0073; const LEGACY_MAIL_LOOKALIKE_ITEM_ENTRY: u32 = 509_0074; const LEGACY_MAIL_LOOKALIKE_COD: u32 = 250; -// A real letter titled "Auction won" can carry cod == 0 too (nothing stops a player from gifting -// an item for free); the guid-0 look-alike above already covers the COD case, so this covers the -// other one. It has no receipt naming its sender as this item's seller, so it must stay untouched -// even though it clears `legacy_mail_matches_shape`. const LEGACY_MAIL_NO_COD_LOOKALIKE_RECIPIENT_GUID: u64 = 509_0076; const LEGACY_MAIL_NO_COD_LOOKALIKE_SENDER_GUID: u64 = 509_0077; const LEGACY_MAIL_NO_COD_LOOKALIKE_ITEM_ENTRY: u32 = 509_0078; -// The receipt that authorizes the legitimate "Auction won" row above. Its house (6, Horde) is -// deliberately not the neutral fallback (7): Realm-core carries no Character rows, so a converted -// row's house only comes from here if `legacy_repair_authorization` truly reads the receipt -// instead of silently falling back. const LEGACY_MAIL_WON_RECEIPT_OPERATION_ID: u64 = 509_0075; const LEGACY_MAIL_WON_RECEIPT_AUCTION_ID: u32 = 509_0075; const LEGACY_MAIL_WON_RECEIPT_HOUSE: u32 = 6; -// A legitimate "Auction sold" row: proceeds mail with no item attached, so its vanilla subject can -// only name the sold item by borrowing the authorizing receipt's own entry and random property id. const LEGACY_MAIL_SOLD_FIXTURE_RECIPIENT_GUID: u64 = 509_0079; const LEGACY_MAIL_SOLD_RECEIPT_OPERATION_ID: u64 = 509_0080; const LEGACY_MAIL_SOLD_RECEIPT_AUCTION_ID: u32 = 509_0080; const LEGACY_MAIL_SOLD_RECEIPT_ITEM_ENTRY: u32 = 509_0081; const LEGACY_MAIL_SOLD_RECEIPT_HOUSE: u32 = 1; -// The same seller has two other receipts on file: a refused listing (the `auction_id == 0` -// sentinel — excluded outright, whatever its proceeds) and a second real listing whose price range -// could never have paid out the Sold mail's money (excluded by `receipt_could_pay_out`). Both use a -// house the repair must never select, so picking either fails the test loudly instead of quietly. const LEGACY_MAIL_REFUSED_RECEIPT_OPERATION_ID: u64 = 509_0082; const LEGACY_MAIL_REFUSED_RECEIPT_ITEM_ENTRY: u32 = 509_0083; const LEGACY_MAIL_DECOY_RECEIPT_OPERATION_ID: u64 = 509_0084; @@ -952,14 +903,6 @@ const LEGACY_MAIL_DECOY_RECEIPT_AUCTION_ID: u32 = 509_0084; const LEGACY_MAIL_DECOY_RECEIPT_ITEM_ENTRY: u32 = 509_0085; const LEGACY_MAIL_WRONG_RECEIPT_HOUSE: u32 = 7; -/// Stage `Character`-sender rows covering every outcome `repair_legacy_auction_mail` must tell -/// apart: a legitimate "Auction won" row backed by a listing receipt, a legitimate "Auction sold" -/// row backed by one of three receipts on the same seller (a refused listing, a real listing that -/// could not have paid this exact price, and the real listing that did), and two look-alikes a real -/// player could send today — one with a cash-on-delivery price (the shape check alone rules this -/// out), one without (only the missing receipt rules this out). Proves `repair_legacy_auction_mail` -/// against real rows on a real database: it must convert the two legitimate rows, picking the one -/// true receipt for Sold out of three candidates, and leave both look-alikes alone. #[reducer] pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1041,7 +984,6 @@ pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<( house: LEGACY_MAIL_SOLD_RECEIPT_HOUSE, ..receipt_template }); - // A refused listing: the `auction_id == 0` sentinel, excluded outright regardless of price. receipts.insert(AuctionOperationReceipt { operation_id: LEGACY_MAIL_REFUSED_RECEIPT_OPERATION_ID, auction_id: 0, @@ -1050,8 +992,6 @@ pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<( house: LEGACY_MAIL_WRONG_RECEIPT_HOUSE, ..receipt_template }); - // A second real listing: seller_proceeds(50, 10, 5) == 58 through seller_proceeds(200, 10, 5) - // == 200, a range that never reaches the Sold mail's money (485). receipts.insert(AuctionOperationReceipt { operation_id: LEGACY_MAIL_DECOY_RECEIPT_OPERATION_ID, auction_id: LEGACY_MAIL_DECOY_RECEIPT_AUCTION_ID, @@ -1063,9 +1003,6 @@ pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<( ..receipt_template }); - // Every row enters `game_mail` through `mail::insert_letter` (the sole writer), so every - // fixture goes through `Letter::from_character` exactly as the sending path that produced each - // shape did. crate::mail::insert_letter( ctx, crate::mail::Letter::from_character( @@ -1141,11 +1078,6 @@ pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<( Ok(()) } -/// Verify `debug_repair_after_publish` re-tagged both legitimate legacy rows to the vanilla -/// `AuctionHouse` sender, each receipt's own house, a machine subject built from the item that -/// receipt names, and COPIED — and left both look-alikes exactly as sent: still `Character` mail, -/// one still carrying its cash on delivery price, the other still from its uninvolved real sender. -/// Run twice in a row in the durable test to prove the repair is idempotent. #[reducer] pub fn debug_verify_legacy_auction_mail_repaired(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/auction/tests.rs b/module/src/auction/tests.rs index 7ee92a492..428733562 100644 --- a/module/src/auction/tests.rs +++ b/module/src/auction/tests.rs @@ -18,10 +18,6 @@ fn seller_proceeds_apply_the_imported_cut_and_return_the_deposit_checked() { assert_eq!(seller_proceeds(100, 10, 101), None); } -// The subject is "{item_entry}:{random_property_id}:{action}" (`cm:AuctionHouseMgr.cpp:134, -// 181,229`), and the action codes are `MailAuctionAnswers` (`cm:Mail.h:100-109`): every string -// below is written out by hand from that format, not produced by calling the function under -// test with different inputs. #[test] fn the_subject_is_the_item_the_random_property_and_the_mail_auction_answers_code() { assert_eq!( @@ -68,9 +64,6 @@ fn mail_for_body(action: AuctionMailAction) -> AuctionMail { } } -/// The invoice body's guid is lowercase hex, right-aligned in a 16-character, space-filled -/// field (`fx:MailFrame.lua:300-361`): the padding widths below are counted by hand, not -/// produced by the `{:>16x}` specifier under test. #[test] fn the_won_and_successful_bodies_are_the_padded_invoice_cmangos_sends() { let won = AuctionMail { @@ -134,15 +127,12 @@ fn outbid_notice_fires_only_for_a_real_displaced_bidder() { item_entry: 1234, random_property_id: 56, bid: 100, - out_bid: 5, // vanilla minimum raise on a 100 bid: 5% rounded up + out_bid: 5, bidder_guid: 9, }) ); } -/// cmangos's `UpdateBid` never calls `SendAuctionBidderNotification` when the new bidder is -/// the bidder it displaces — raising your own bid is not being outbid -/// (`cm:AuctionHouseMgr.cpp:780-793`). #[test] fn a_bidder_who_raises_their_own_bid_gets_no_outbid_notice() { let item = crate::items::ItemSnapshot { @@ -174,7 +164,7 @@ fn new_bid_and_expired_notices_address_the_owner() { item_entry: 1234, random_property_id: 56, bid: 107, - out_bid: 6, // vanilla minimum raise on a 107 bid: 5% rounded up + out_bid: 6, bidder_guid: 9, } ); @@ -188,7 +178,6 @@ fn new_bid_and_expired_notices_address_the_owner() { item_entry: 1234, random_property_id: 56, bid: 0, - // bid_increment(0) is 0, but expired_notice floors an unsold listing's raise to 1. out_bid: 1, bidder_guid: 0, } @@ -213,7 +202,7 @@ fn settlement_notices_are_won_to_the_buyer_and_sold_to_the_seller() { item_entry: 1234, random_property_id: 56, bid: 0, - out_bid: 25, // vanilla minimum raise on a 500 bid: 5% rounded up + out_bid: 25, bidder_guid: 9, } ); @@ -227,7 +216,7 @@ fn settlement_notices_are_won_to_the_buyer_and_sold_to_the_seller() { item_entry: 1234, random_property_id: 56, bid: 500, - out_bid: 25, // vanilla minimum raise on a 500 bid: 5% rounded up + out_bid: 25, bidder_guid: 0, } ); @@ -266,8 +255,6 @@ fn every_legacy_auction_subject_maps_to_its_action_and_an_unknown_one_does_not() #[test] fn listing_deposit_uses_the_imported_rate_and_supported_duration_ladder() { - // Hand-derived from cm:AuctionHouseMgr.cpp:65-77's - // `SellPrice * count * (etime / MIN_AUCTION_TIME) * depositPercent / 100`. assert_eq!(listing_deposit(100, 2, 120, 5), Some(10)); assert_eq!(listing_deposit(100, 2, 480, 5), Some(40)); assert_eq!(listing_deposit(100, 2, 1_440, 5), Some(120)); @@ -892,9 +879,6 @@ fn realm_refusal_releases_the_hold_and_mails_the_item_and_deposit_back() { assert_eq!(source.money, Some(40)); assert!(source.item.is_none()); - // Realm-core refused (for example, its item catalogue lacks the template), so - // the market never took the listing and the source Hold is the only copy of the value. - // The refund Mail and its receipt are written on Realm-core before its source Hold moves. let refund = listing_refund(&source.hold(request.operation_id).unwrap().listing); assert_eq!(refund_listing(&mut realm, refund.clone()), Ok(())); assert_eq!(realm.mails, vec![refund.mail]); @@ -919,7 +903,6 @@ fn realm_refusal_releases_the_hold_and_mails_the_item_and_deposit_back() { ); assert!(source.hold.is_none()); - // A gateway crash after Realm-core commits but before source cleanup replays one Mail. assert_eq!(refund_listing(&mut realm, refund), Ok(())); assert_eq!(realm.mails.len(), 1); assert_eq!( @@ -1206,7 +1189,7 @@ fn replayed_bid_expiry_mails_the_exact_item_and_imported_rate_proceeds_once() { item_entry: 25, random_property_id: 117, bid: 0, - out_bid: 11, // vanilla minimum raise on a 201 bid: 5% rounded up + out_bid: 11, bidder_guid: 8, }, AuctionNoticeDraft { @@ -1217,7 +1200,7 @@ fn replayed_bid_expiry_mails_the_exact_item_and_imported_rate_proceeds_once() { item_entry: 25, random_property_id: 117, bid: 201, - out_bid: 11, // vanilla minimum raise on a 201 bid: 5% rounded up + out_bid: 11, bidder_guid: 0, }, ], @@ -1344,17 +1327,15 @@ fn realm_bid_decision_enforces_the_vanilla_price_ladder_and_revision() { )); } -/// The seven imported houses pool into three markets: a bid through any Alliance house -/// reaches a listing at any Alliance house, but never a Horde or neutral one. #[test] fn realm_bid_decision_accepts_a_bid_within_the_listings_market_and_refuses_outside_it() { - let active = active_bid_auction(); // lists in house 1 (Stormwind, Alliance). + let active = active_bid_auction(); let request = HoldRequest { operation: HoldOperation::Bid, operation_id: 900, bidder_guid: 8, auction_id: 41, - house: 2, // a different Alliance house. + house: 2, offer: 100, }; @@ -2132,7 +2113,6 @@ fn collect_flow_mail(outcome: &mut CompleteFlowOutcome, mail: Vec) fn complete_flow(sharded: bool) -> CompleteFlowOutcome { let buyout_listing = listing_for_flow(sharded, 910); let mut buyout_market = bid_market_for(&buyout_listing); - // Browse/search renders the same authoritative Auction row that the later writes consume. let browse_search_rows = buyout_market .auction .into_iter() @@ -2525,8 +2505,6 @@ fn expiry_preserves_inconsistent_or_overflowing_sale_state_for_repair() { assert!(store.mail.is_empty()); } -/// Seller 7's listing 41 in house 1 at a 5% cut, with bidder 9's bid of 201. The cut is -/// 201 * 5 / 100 = 10.05, truncated to 10. fn bid_listing() -> BidAuction { BidAuction { highest_bidder_guid: 9, @@ -2874,7 +2852,6 @@ fn a_bid_or_a_settlement_after_the_read_refuses_the_cancellation_and_refunds_the .iter() .all(|mail| mail.action == AuctionMailAction::Outbid)); - // A settlement that wins the race removes the listing first. let mut settled = cancel_market(bid_listing()); let mut late_seller = FakeBidSource::new(100); let late = cancel_request(957, 10); diff --git a/module/src/auth.rs b/module/src/auth.rs index 1eab309c3..bdde1294d 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -1,20 +1,12 @@ -//! Account / auth tables (private — never delivered to clients) and the two gateway-coordination -//! reducers that provision SRP6 credentials and establish a session. [session] - use spacetimedb::{ reducer, table, Identity, ReducerContext, ScheduleAt, Table, TimeDuration, Timestamp, }; -use crate::items::game_item_instance; // monotonic-guid: don't reuse a guid that still owns items +use crate::items::game_item_instance; use crate::{ game_char_base_info, game_character, game_start_position, game_world_entity, Character, }; -// =========================================================================================== -// Account / auth tables [session] — PRIVATE -// =========================================================================================== - -/// SRP6 verifier material — never the password. Private. [session] #[table(accessor = game_account)] pub struct Account { #[primary_key] @@ -26,14 +18,10 @@ pub struct Account { pub verifier: Vec, // 32 bytes pub identity: Option, pub banned: bool, - /// Account-owned Alpha Test Tools authority. Existing rows enroll on the additive migration; - /// new rows copy [`AlphaTestToolsEnrollment::enabled`]. #[default(true)] pub alpha_test_tools: bool, } -/// Whether newly provisioned Accounts receive Alpha Test Tools. Private and authoritative on -/// Realm-core. A missing row means enabled so an additive publish behaves like a fresh database. #[table(accessor = game_alpha_test_tools_enrollment)] pub struct AlphaTestToolsEnrollment { #[primary_key] @@ -69,26 +57,6 @@ fn current_alpha_test_tools_enrollment(ctx: &ReducerContext) -> bool { ) } -/// Ensure a SHADOW account row exists for `account_id` on THIS database. -/// -/// Accounts are realm-scoped: SRP6 lives on the logon tier and writes `game_account` on the realm -/// (default) database only. An instance/continent shard therefore has no account row at all — and -/// `gw::gw_player_login` resolves the account by id, so a character that arrives via -/// `transfer::import_character_blob` could not log in. -/// -/// This row is an INDEX ENTRY, not a credential: `salt`/`verifier` are empty, so it can never -/// satisfy an SRP proof even if the logon tier were ever pointed at this database. `identity` is -/// left unbound; the gateway calls `establish_session` here at every world entry, which is the only -/// field the row exists to carry. -/// -/// Deliberate simplification: the username is synthetic (`#`, which no real account name can -/// collide with — account names are uppercased alphanumerics) purely to satisfy the `#[unique]` -/// constraint. Upgrade path: realm-core owns accounts for every shard and this function is -/// deleted. -/// Idempotent — a second call is a no-op, and it NEVER overwrites a real account row. The explicit -/// id does not advance the table's `auto_inc` sequence (danger-zones §2), which is safe only -/// because nothing ever calls `provision_account` on a non-realm shard — if that ever changes, this -/// is where the collision comes from. pub(crate) fn ensure_shadow_account(ctx: &ReducerContext, account_id: u64) { let accounts = ctx.db.game_account(); if accounts.id().find(account_id).is_some() { @@ -105,9 +73,6 @@ pub(crate) fn ensure_shadow_account(ctx: &ReducerContext, account_id: u64) { }); } -/// Gateway coordination: shared session key K. Private. [session] -/// A row lives `SESSION_TTL_MICROS` from its last logon; the world handshake refuses an expired -/// row and `reap_sessions` deletes it. #[table(accessor = game_session)] pub struct Session { #[primary_key] @@ -118,14 +83,9 @@ pub struct Session { pub expires_at: Timestamp, } -/// How long a logon's session key stays valid for the world handshake. Every logon rewrites the -/// row, so a returning Account always gets a fresh window. pub(crate) const SESSION_TTL_MICROS: i64 = 3_600_000_000; -/// How often the reaper deletes expired sessions. pub(crate) const SESSION_REAP_MICROS: i64 = 300_000_000; -/// Drives `reap_sessions`. Armed lazily by `establish_session`, so no post-publish repair step -/// is needed for it. #[table(accessor = game_session_reaper_schedule, scheduled(reap_sessions))] pub struct SessionReaperSchedule { #[primary_key] @@ -134,12 +94,10 @@ pub struct SessionReaperSchedule { pub scheduled_at: ScheduleAt, } -/// Expiry includes the deadline itself, as in the Gateway handshake. pub(crate) fn session_expired(now_micros: i64, expires_at_micros: i64) -> bool { expires_at_micros <= now_micros } -/// Which of `sessions` (account id, expiry) the reaper deletes at `now_micros`. pub(crate) fn expired_sessions( sessions: impl Iterator, now_micros: i64, @@ -150,7 +108,6 @@ pub(crate) fn expired_sessions( .collect() } -/// Delete every session whose window has closed. Scheduler-only. #[reducer] pub fn reap_sessions(ctx: &ReducerContext, _schedule: SessionReaperSchedule) -> Result<(), String> { if ctx.sender() != ctx.database_identity() { @@ -180,15 +137,11 @@ fn ensure_session_reaper_armed(ctx: &ReducerContext) { }); } -/// The single trusted OPERATOR identity — the gateway's coordinator connection + the deploy CLI, which -/// both authenticate with the owner token (verified: that identity is NOT `ctx.database_identity()`, the -/// module's own identity, so we capture it rather than derive it). `require_operator` gates the -/// privileged owner-fired reducers against this, defending them from a direct anonymous SpacetimeDB -/// connection that bypasses the gateway. Set once via `claim_operator` (TOFU). Private. [session] +// The Owner Token caller differs from ctx.database_identity(), so capture it explicitly. #[table(accessor = game_operator)] pub struct Operator { #[primary_key] - pub id: u8, // singleton: always 0 + pub id: u8, pub identity: Identity, } @@ -198,8 +151,6 @@ enum OperatorClaimAction { AlreadyOwned, } -/// Decide whether a claim inserts the singleton, succeeds as an idempotent retry, or must refuse a -/// different identity. Kept context-free so all three authorization outcomes are unit-testable. fn operator_claim_action( claimed_identity: Option, sender: Identity, @@ -211,15 +162,9 @@ fn operator_claim_action( } } -/// Trust-on-first-use claim of the operator identity: the deploy admin calls this right after the -/// first publish over the owner token, so `ctx.sender()` is the coordinator/CLI identity. A retry by -/// that same identity is an idempotent success; a different identity is refused. Persists across -/// additive republishes; only a `-c` reprovision clears it (re-claim then). NOT gated — it's the -/// bootstrap; it's safe because it can only ever be set when unclaimed or confirmed by the owner. #[reducer] pub fn claim_operator(ctx: &ReducerContext) -> Result<(), String> { let ops = ctx.db.game_operator(); - // `find(0)`, not `find(&0)` — main's clippy sweep dropped the needless borrow. let claimed_identity = ops.id().find(0).map(|operator| operator.identity); if operator_claim_action(claimed_identity, ctx.sender())? == OperatorClaimAction::Insert { ops.insert(Operator { @@ -265,13 +210,6 @@ mod operator_claim_tests { } } -// =========================================================================================== -// Session establishment (gateway coordination) -// =========================================================================================== - -/// Provision (or update) an account's SRP6 credentials. Called by the trusted gateway, which -/// computes the salt/verifier with `wow_srp`. Privileged in production (restrict to the -/// gateway identity); permissive here for the slice. #[reducer] pub fn provision_account( ctx: &ReducerContext, @@ -282,18 +220,14 @@ pub fn provision_account( crate::helpers::require_operator(ctx)?; let accounts = ctx.db.game_account(); match accounts.username().find(&username) { - // Existing account → rotate its credentials (e.g. give the seeded TEST a real password). Some(mut acc) => { acc.salt = salt; acc.verifier = verifier; accounts.id().update(acc); } - // New account → create it (e.g. TEST2 through `gateway provision TEST2 --password-stdin` - // for two-client tests). No bound identity yet (a login binds it via establish_session); - // not banned. Upsert so provisioning a new username works. None => { accounts.insert(Account { - id: 0, // auto_inc + id: 0, username, salt, verifier, @@ -309,7 +243,6 @@ pub fn provision_account( Ok(()) } -/// Set whether genuinely new Accounts receive Alpha Test Tools. Existing Accounts are unchanged. #[reducer] pub fn set_alpha_test_tools_enrollment(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -349,14 +282,12 @@ fn set_account_alpha_test_tools( Ok(()) } -/// Grant Alpha Test Tools to one Account by realm-wide, uppercased Account name. #[reducer] pub fn grant_alpha_test_tools(ctx: &ReducerContext, account_name: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; set_account_alpha_test_tools(ctx, &account_name, true) } -/// Revoke Alpha Test Tools from one Account by realm-wide, uppercased Account name. #[reducer] pub fn revoke_alpha_test_tools(ctx: &ReducerContext, account_name: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -394,8 +325,6 @@ mod alpha_test_tools_tests { } } -/// Logon writes the shared session key K and binds the per-account identity. -/// In production, restrict the caller to the gateway identity. #[reducer] pub fn establish_session( ctx: &ReducerContext, @@ -412,10 +341,6 @@ pub fn establish_session( acc.identity = Some(bound_identity); accounts.id().update(acc); - // No per-character owner_identity restamp here: it existed so per-owner RLS would admit the - // player's own connection, which no longer exists — `apply_player_login` stamps owner rows - // via `restamp_owned_data`, and the gateway predicates are the visibility layer. - let now = ctx.timestamp; let expires = now .checked_add(TimeDuration::from_micros(SESSION_TTL_MICROS)) @@ -455,32 +380,18 @@ mod session_expiry_tests { } } -// =========================================================================================== -// Realm-safe guid allocation -// =========================================================================================== - -/// Shared durable high-water mark for Character and item creation. Deletion and Transfer never -/// lower it. With a range installed, only local Character GUIDs advance the Transfer floor. -/// A scan of surviving rows cannot replace it, because issued identities may now live elsewhere. +// Deletion and Transfer never lower the issued GUID floor; surviving rows cannot recover it. #[table(accessor = game_guid_allocator)] pub struct GuidAllocator { #[primary_key] - pub id: u32, // singleton: always 0 (matches game_config's convention, config.rs:50) + pub id: u32, pub high_water: u64, } -/// Never let the high-water mark move backwards. This one line IS the whole guarantee: as long as -/// every write to `GuidAllocator.high_water` goes through it, a deleted character's guid can never -/// come back into circulation. Pure — no `ReducerContext` — so it's unit-testable directly (the -/// module crate has no ctx test harness by design; playbook §7). fn ratchet_high_water(current: u64, floor: u64) -> u64 { current.max(floor) } -/// The ORIGINAL per-database scan (the precursor — see `create_character`'s old comment), -/// generalised over guid iterators so it needs no `ReducerContext` either. Used ONLY to seed a -/// `game_guid_allocator` row that has never been touched — an auto-migrated database that already -/// has characters seeds from their real max instead of resetting to 0 and re-issuing them. fn legacy_guid_seed( char_guids: impl Iterator, owned_guids: impl Iterator, @@ -495,19 +406,10 @@ fn allocate_next_guid(seed: u64) -> u64 { seed + 1 } -/// Read the singleton row, whole. `None` means "never written" — every caller here treats that as -/// "run the legacy seed", never as "the mark is 0". Callers that need just the mark derive it with -/// `.map(|r| r.high_water)` rather than this function returning a second, redundant `Option`. fn read_high_water(ctx: &ReducerContext) -> Option { ctx.db.game_guid_allocator().id().find(0) } -/// The ONLY raw `game_character`/`game_item_instance` scan site in this file (both -/// `next_character_guid` and `bump_guid_high_water` below route through it via -/// `Option::unwrap_or_else`, so it only actually runs on a mark's first-ever touch) — deliberately -/// factored out so it stays ONE occurrence rather than two, which is what -/// `character_fence_tripwire`'s per-file raw-lookup budget (module/src/tripwires.rs's auth.rs -/// WHITELIST entry) counts. fn legacy_guid_seed_now(ctx: &ReducerContext) -> u64 { legacy_guid_seed( ctx.db.game_character().iter().map(|c| c.guid), @@ -532,7 +434,6 @@ fn write_high_water(ctx: &ReducerContext, existing: Option, mark: } } -/// Allocate and persist the next Character guid from the durable high-water mark. pub(crate) fn next_character_guid(ctx: &ReducerContext) -> u64 { let existing = read_high_water(ctx); let seed = existing @@ -544,8 +445,6 @@ pub(crate) fn next_character_guid(ctx: &ReducerContext) -> u64 { next } -/// Reserve a batch from this Shard's GUID Range. Items and Characters consume the same durable -/// high-water mark; deletion and Transfer never return issued identities to the range. pub(crate) fn reserve_guids( ctx: &ReducerContext, count: u64, @@ -589,9 +488,7 @@ fn guid_batch_end( Ok(last) } -/// Preserve a Character's issued GUID before import or deletion. Once a range is installed, -/// foreign Characters cannot advance its mark, including cleanup before a Transfer import. -/// Before installation, retain the legacy floor so a provisioning failure cannot erase it. +// Foreign guids cannot advance an installed range; retain the legacy floor before installation. pub(crate) fn bump_guid_high_water(ctx: &ReducerContext, guid: u64) { let range = ctx .db @@ -609,29 +506,24 @@ pub(crate) fn bump_guid_high_water(ctx: &ReducerContext, guid: u64) { .unwrap_or_else(|| legacy_guid_seed_now(ctx)); let mark = ratchet_high_water(seed, guid); if mark == seed && existing.is_some() { - return; // already seeded, already ahead — avoid a no-op write + return; } write_high_water(ctx, existing, mark); } -/// The permanent GUID Range assigned by Realm-core. Character and item creation both require it. -/// Reassignment would let the Shard reuse identities still held in other Shards or Escrow. +// Reassignment could reissue identities still held in other Shards or Escrow. #[table(accessor = game_guid_range)] pub struct GuidRange { #[primary_key] - pub id: u32, // singleton: always 0 (game_config's convention) + pub id: u32, pub base: u64, pub size: u64, } -/// May this Shard mint one more GUID? The next value must remain below the exclusive range end. -/// Pure, so both allocation paths use the same range boundary. pub(crate) fn may_mint(range: Option<(u64, u64)>, mark: u64) -> Result<(), String> { guid_batch_end(range, mark, 1, u64::MAX).map(|_| ()) } -/// [`may_mint`]'s ctx glue: read this database's range + mark and apply the rule. Called by -/// `create_character` BEFORE anything is written, so a refusal leaves nothing behind. pub(crate) fn require_guid_range(ctx: &ReducerContext) -> Result<(), String> { let range = ctx .db @@ -643,8 +535,6 @@ pub(crate) fn require_guid_range(ctx: &ReducerContext) -> Result<(), String> { may_mint(range, mark.unwrap_or(0)) } -/// True only inside an installed range. Transfer uses this Gate directly; legacy deletion -/// retains its old high-water floor until a range is installed. pub(crate) fn in_guid_range(range: Option<(u64, u64)>, guid: u64) -> bool { let Some((base, size)) = range else { return false; @@ -652,24 +542,14 @@ pub(crate) fn in_guid_range(range: Option<(u64, u64)>, guid: u64) -> bool { guid >= base && guid < base.saturating_add(size) } -/// Install the range realm-core assigned to this database. Idempotent — re-installing the -/// same range is how the gateway confirms one is held — and it never *moves* a range: a different -/// base is an error, not an overwrite. -/// -/// Ratchets the allocator up to `base` through the same `bump_guid_high_water` every other caller -/// uses, so installing can only ever move the mark UP. Refuses a range this database has already -/// minted past, which is the loud failure that replaces the silent one: it means the range being -/// assigned is not the range in use. #[reducer] pub fn install_guid_range(ctx: &ReducerContext, base: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - // One owner for the size (`realm_core::GUID_RANGE_SIZE`, which is also what carves the slots), - // so the gateway never carries a second copy of it that could drift from the registry's. let size = crate::realm_core::GUID_RANGE_SIZE; let ranges = ctx.db.game_guid_range(); if let Some(row) = ranges.id().find(0) { if row.base == base && row.size == size { - return Ok(()); // already held — the gateway's confirm path + return Ok(()); } return Err(format!( "install_guid_range: this database already holds [{}, {}) and a range is never \ @@ -705,11 +585,7 @@ mod guid_allocator_tests { #[test] fn a_guid_floor_only_ever_ratchets_upwards() { - // A fresh shard given a floor: the mark jumps to it, so the next guid is above it. assert_eq!(ratchet_high_water(0, 1_000_000_000), 1_000_000_000); - // A shard that has ALREADY issued guids past the floor keeps its mark — re-running the - // provisioning step, or applying a floor to the default shard by mistake, must not hand out - // a guid that is already alive. assert_eq!( ratchet_high_water(2_000_000_050, 1_000_000_000), 2_000_000_050, @@ -738,9 +614,6 @@ mod guid_allocator_tests { ); } - /// The floor above is monotonic but *optional* — a shard nobody ran it on mints - /// from zero. This is the rule that makes it non-optional, and the two refusals are the whole - /// point: no range at all, and a mark that has escaped the range it was assigned. #[test] fn a_database_without_an_assigned_range_refuses_to_mint() { use super::may_mint; @@ -751,28 +624,20 @@ mod guid_allocator_tests { "having minted before is not a licence — that IS the shard that mints into someone \ else's range" ); - // A licensed shard mints anywhere inside its range, including at the very base. assert!(may_mint(Some((1_000_000_000, 1_000_000_000)), 1_000_000_000).is_ok()); assert!(may_mint(Some((1_000_000_000, 1_000_000_000)), 1_999_999_998).is_ok()); assert!(may_mint(Some((1_000_000_000, 1_000_000_000)), 1_999_999_999).is_err()); - // The next GUID must stay below the exclusive end. assert_eq!( may_mint(Some((1_000_000_000, 1_000_000_000)), 2_000_000_000).unwrap_err(), "GUID_RANGE_EXHAUSTED" ); } - /// `in_guid_range` is the pure decision `transfer::apply_import_blob` calls to - /// decide whether an ARRIVING character's guid may ratchet this database's allocator. - /// Boundary-checked the same way as `may_mint` just above (base included, end excluded), and, - /// like it, conservative when there is no range installed at all. #[test] fn in_guid_range_only_admits_guids_inside_the_installed_range() { use super::in_guid_range; - // No installed range proves membership. Legacy deletion preserves its floor separately. assert!(!in_guid_range(None, 0)); assert!(!in_guid_range(None, 4242)); - // Inside a real range, base included, end excluded — same boundary shape as `may_mint`. let range = Some((1_000_000_000, 1_000_000_000)); assert!( in_guid_range(range, 1_000_000_000), @@ -786,7 +651,6 @@ mod guid_allocator_tests { !in_guid_range(range, 2_000_000_000), "the end itself belongs to the NEXT range" ); - // Anything below base, or from another shard's slot entirely, is foreign. assert!(!in_guid_range(range, 0)); assert!(!in_guid_range(range, 3_000_000_000)); } @@ -832,7 +696,7 @@ mod guid_allocator_tests { #[test] fn defect_1_transfer_then_create_no_longer_reissues_the_departed_guid() { - let chars_after_transfer = [10u64, 30, 49]; // guid 50 already gone when this runs + let chars_after_transfer = [10u64, 30, 49]; let seed = legacy_guid_seed(chars_after_transfer.into_iter(), std::iter::empty()); let allocator_mark_after_delete = ratchet_high_water(seed, 50); assert_eq!(allocator_mark_after_delete, 50); @@ -842,15 +706,9 @@ mod guid_allocator_tests { } } -// =========================================================================================== -// Character creation (CMSG_CHAR_CREATE) -// =========================================================================================== - -/// Most Characters one Account holds. The 5875 client cannot render a longer character list: it -/// rejects SMSG_CHAR_ENUM with "Error retrieving character list". +// The 5875 client refuses a Character list longer than this limit. pub(crate) const CHARACTERS_PER_ACCOUNT: usize = 10; -/// The look bytes a client picks at character creation. #[derive(Clone, Copy, Default)] pub(crate) struct Appearance { pub gender: u8, @@ -861,12 +719,6 @@ pub(crate) struct Appearance { pub facial_hair: u8, } -/// Create a new character for `account_id` (called by the trusted gateway on `CMSG_CHAR_CREATE`, -/// like `provision_account`/`establish_session`; restrict to the gateway identity in production). -/// Returns a distinguished `Err("NAME_IN_USE")` for a taken name (the gateway maps it to -/// `CharCreateNameInUse`); any other `Err` maps to a generic failure. The new character spawns at -/// the seeded start position for its (race, class), falling back to Human Warrior's if that combo -/// has none seeded (we have no DBC; the slice only seeds Human Warrior). #[reducer] #[allow(clippy::too_many_arguments)] pub fn create_character( @@ -894,19 +746,13 @@ pub fn create_character( insert_new_character(ctx, account_id, name, race, class, appearance).map(|_| ()) } -/// The Refusals a new Character meets before anything is written. Client and Package creation -/// both pass through here. pub(crate) fn check_new_character( ctx: &ReducerContext, name: &str, race: u8, class: u8, ) -> Result<(), String> { - // Minting is licensed by a range realm-core assigned, and an unlicensed database refuses - // rather than minting from zero into another shard's guids. FIRST — before the cheaper - // validations, so the refusal is unambiguous in the log. require_guid_range(ctx)?; - // `name` is unique; check first so we can return a clean, distinguished result. if ctx .db .game_character() @@ -916,9 +762,6 @@ pub(crate) fn check_new_character( { return Err("NAME_IN_USE".to_string()); } - // Reject an illegal (race, class) (e.g. Human Shaman) — server-side defense-in-depth from the - // imported CharBaseInfo.dbc (the client already gates the UI). Skipped when the table is empty - // (unloaded), so creation still works pre-import. [importer P1] let cbi = ctx.db.game_char_base_info(); if cbi.count() > 0 && cbi @@ -931,7 +774,6 @@ pub(crate) fn check_new_character( Ok(()) } -/// Whether `account_id` holds fewer than [`CHARACTERS_PER_ACCOUNT`] Characters on this database. pub(crate) fn account_has_room(ctx: &ReducerContext, account_id: u64) -> bool { ctx.db .game_character() @@ -941,8 +783,6 @@ pub(crate) fn account_has_room(ctx: &ReducerContext, account_id: u64) -> bool { < CHARACTERS_PER_ACCOUNT } -/// Create a Character for `account_id` and return its guid. Applies every -/// [`check_new_character`] Refusal, then the per-Account cap. pub(crate) fn insert_new_character( ctx: &ReducerContext, account_id: u64, @@ -952,8 +792,6 @@ pub(crate) fn insert_new_character( appearance: Appearance, ) -> Result { check_new_character(ctx, &name, race, class)?; - // The distinguished error the gateway maps to CHAR_CREATE_SERVER_LIMIT ("cannot create any - // more"). if !account_has_room(ctx, account_id) { return Err("SERVER_LIMIT".to_string()); } @@ -965,7 +803,6 @@ pub(crate) fn insert_new_character( .find(account_id) .ok_or_else(|| format!("no such account id: {account_id}"))?; - // Start position for this (race, class); fall back to Human(1)/Warrior(1) if unseeded. let positions = ctx.db.game_start_position(); let race_class = ((race as u16) << 8) | class as u16; let human_warrior = (1u16 << 8) | 1u16; @@ -975,19 +812,11 @@ pub(crate) fn insert_new_character( .or_else(|| positions.race_class().find(human_warrior)) .ok_or_else(|| "no start position seeded".to_string())?; - // Realm-safe GUID allocation: a durable high-water mark, not a per-transaction scan - // of THIS database alone. The old scan (still the seed a never-touched mark bootstraps from — - // see `legacy_guid_seed`) looked free the instant a cross-database transfer deleted the departed - // character's row (and its items) here, and the next creation could re-issue that guid while the - // character was still alive on the destination database. `next_character_guid` never re-derives - // the mark from a scan once it's seeded, and never lets it move backwards. let next_guid = next_character_guid(ctx); let row = Character { guid: next_guid, account_id, - // Bind to the account's identity (set at establish_session) so the per-owner RLS lets the - // player see it immediately; ZERO if somehow unbound (gateway reads via the owner conn anyway). owner_identity: account.identity.unwrap_or(Identity::ZERO), name: name.clone(), race, @@ -1009,37 +838,31 @@ pub(crate) fn insert_new_character( orientation: sp.orientation, first_login: true, online: false, - money: 0, // new characters start broke - rested_xp: 0, // rank 30: no rested XP until the first logout/login cycle + money: 0, + rested_xp: 0, last_logout_micros: 0, - // Hearthstone home = the start position — a fresh character recalls to where it began until it - // binds at an innkeeper (`bind_home`). home_map: sp.map_id, home_zone: sp.zone_id, home_x: sp.x, home_y: sp.y, home_z: sp.z, - played_total_secs: 0, // fresh character, nothing played yet - session_start_micros: 0, // no live session until player_login stamps it - health: 0, // sentinel: no persisted value yet — build_player_entity spawns at full health - power: 0, // sentinel: no persisted value yet — build_player_entity spawns at starting power + played_total_secs: 0, + session_start_micros: 0, + health: 0, + power: 0, respec_count: 0, - death_expire_micros: 0, // never died // fresh character, never respec'd - pending_instance_id: 0, // open world - gm_level: 0, // no GM access until granted via set_gm_level + death_expire_micros: 0, + pending_instance_id: 0, + gm_level: 0, pending_ghost: false, - resting: false, // 196: not in an inn until they walk into one - rested_since_micros: 0, // 196: live-accrual clock idle - pending_godmode: false, // 289: GM playtest carry — off until `.god` + a map change - pending_run_speed_mult_bp: crate::world::RUN_SPEED_BP_1X, // 289: 1× until `.speed` + a map change - bank_bag_slots: 0, // none bought yet + resting: false, + rested_since_micros: 0, + pending_godmode: false, + pending_run_speed_mult_bp: crate::world::RUN_SPEED_BP_1X, + bank_bag_slots: 0, watched_faction_index: -1, }; - // `try_insert` rather than `insert`: if a same-name character was created between the find() - // above and here (the race the early check can't fully close), the `#[unique] name` constraint - // makes this fail cleanly instead of panicking. `name` is game_character's only unique - // constraint and `guid` is max+1, so a failure here means the name is now taken → NAME_IN_USE. if chars.try_insert(row).is_err() { return if chars.name().find(&name).is_some() { Err("NAME_IN_USE".to_string()) @@ -1047,24 +870,14 @@ pub(crate) fn insert_new_character( Err("character create failed".to_string()) }; } - // Same transaction as the insert: the createinfo kit becomes the character's spellbook rows — - // the one castability source (`knows_spell`) and the gateway's SMSG_INITIAL_SPELLS both read them. let owner = account.identity.unwrap_or(Identity::ZERO); crate::spell::spellbook::grant_createinfo_spells(ctx, next_guid, owner, race, class); - // Action-bar rows: copies `game_createinfo_action` the same way the spell grant - // above copies `game_createinfo_spell` — a no-op pre-import (table empty), so the gateway's login - // synth covers it until the dump is imported. crate::action_bar::grant_createinfo_actions(ctx, next_guid, owner, race, class); crate::items::grant_starter_item(ctx, next_guid, owner)?; Ok(next_guid) } -/// Delete `character_guid` for `account_id` — the player `CMSG_CHAR_DELETE` path. Operator-gated (the -/// gateway coordinator calls it) AND ownership-checked: the character must belong to `account_id`, so -/// one account can't delete another's. Cascades via [`crate::world::cascade_delete_character`] (items, -/// learned spells/skills/talents, quest log, buyback, corpse, world entity) so no orphaned rows survive -/// for a reused guid to inherit (the Warrior-wore-Warlock-gear bug). #[reducer] pub fn delete_character( ctx: &ReducerContext, @@ -1082,17 +895,6 @@ pub fn delete_character( if c.account_id != account_id { return Err("NOT_OWNER".to_string()); } - // IN-TRANSIT FENCE. `cascade_delete_character` runs the `character_owned!` delete - // sweeps, which include `sweep_delete_game_transfer_out` — so a delete landing mid-transfer wipes - // the character AND both escrow rows in one transaction, voiding a transfer another shard is - // still driving. Cross-database that is the annihilation case: the destination's in-row and its - // arrival copy survive the source's delete, and its reaper (no out-row ⇒ `Hold`) never settles - // them. The escrow must be resolved (finished or reaped) before the character can be destroyed. - // - // Routed through the shared gate rather than a bespoke `is_in_transit` call, but the - // DISTINGUISHED error is kept: the raw `find` above is what lets NO_SUCH_CHAR / NOT_OWNER / - // CHAR_IN_TRANSIT stay three different answers, which `character_by_guid` alone would flatten to - // NO_SUCH_CHAR. The gateway maps every non-OK delete the same way, so no seam either way. if crate::helpers::character_by_guid(ctx, character_guid).is_none() { return Err("CHAR_IN_TRANSIT".to_string()); } diff --git a/module/src/away.rs b/module/src/away.rs index 5e10b3d91..2f876f742 100644 --- a/module/src/away.rs +++ b/module/src/away.rs @@ -1,34 +1,22 @@ -//! Away Status: AFK or DND on a live Character. Observers see the `PLAYER_FLAGS` bit on the live -//! entity through the entity Relay; [`CharacterAway`] holds the Auto-Reply a whisperer receives. -//! Both change together in one transaction. A real login ends both. A world-port rebuilds the -//! entity from the durable row, so [`at_world_entry`] restores the bit from [`CharacterAway`]; -//! the row travels with a Transfer. - use spacetimedb::{table, ReducerContext, Table}; use crate::game_world_entity; use lyracore_shared::chat::chat_kind; use lyracore_shared::constants::player_flags; -/// The Auto-Reply of one Character with an Away Status. Private: the owner-token Coordinator is -/// its only reader. [entity] #[table(accessor = game_character_away)] pub struct CharacterAway { #[primary_key] pub character_guid: u64, - /// `chat_kind::AFK` or `chat_kind::DND`. pub kind: u8, pub message: String, } -/// cm mangos.sql:3986 (`LANG_PLAYER_AFK_DEFAULT`), fx:GlobalStrings.lua:910-912. pub(crate) const AFK_DEFAULT_REPLY: &str = "Away from Keyboard"; -/// cm mangos.sql:3985 (`LANG_PLAYER_DND_DEFAULT`), fx:GlobalStrings.lua:910-912. pub(crate) const DND_DEFAULT_REPLY: &str = "Do not Disturb"; const AWAY_BITS: u32 = player_flags::AFK | player_flags::DND; -/// The Auto-Reply a whisperer gets when the Character stored none. pub(crate) fn default_reply(kind: u8) -> &'static str { if kind == chat_kind::DND { DND_DEFAULT_REPLY @@ -45,25 +33,12 @@ fn flag_of(kind: u8) -> u32 { } } -/// A Character's Away Status after one `/afk` or `/dnd`. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AwayChange { - /// The live entity's new `PLAYER_FLAGS`. pub(crate) player_flags: u32, - /// The Auto-Reply to store, or `None` to delete it. pub(crate) reply: Option<(u8, String)>, } -/// One `/afk` or `/dnd` against the current `PLAYER_FLAGS`, as cm:ChatHandler.cpp:641-693 applies -/// it. `kind` is `chat_kind::AFK` or `chat_kind::DND`. -/// -/// - The kind is set and the text is empty: the kind ends. -/// - The kind is set and the text is not empty: only the Auto-Reply changes. -/// - The kind is not set: it starts with the text, or with the default text, and the other kind -/// ends. -/// -/// There is no combat Gate: cmangos has none, and vmangos refuses AFK in combat -/// (vm:ChatHandler.cpp:613-614). LyraCore follows cmangos. pub(crate) fn next_away(player_flags: u32, kind: u8, message: &str) -> AwayChange { let flag = flag_of(kind); let text = crate::chat::normalized_message(message); @@ -86,7 +61,6 @@ pub(crate) fn next_away(player_flags: u32, kind: u8, message: &str) -> AwayChang } } -/// Apply one `/afk` or `/dnd` to `entity` and its Auto-Reply in this transaction. pub(crate) fn apply_set_away( ctx: &ReducerContext, mut entity: crate::WorldEntity, @@ -106,10 +80,6 @@ pub(crate) fn apply_set_away( Ok(()) } -/// The Away bits of an entity built for `entry`, from its fresh `player_flags` and the stored -/// Auto-Reply kind. A real login ends the Away Status (cm:Player.cpp:2932 runs from -/// `LoadFromDB`). A world-port keeps it: cmangos keeps `PLAYER_FLAGS` across a far teleport, and -/// the rebuilt entity here starts with no Away bits, so the stored kind restores them. pub(crate) fn flags_at_entry( player_flags: u32, stored_kind: Option, @@ -122,8 +92,6 @@ pub(crate) fn flags_at_entry( } } -/// Apply [`flags_at_entry`] to the entity login is about to insert. A real login also deletes -/// the Auto-Reply. pub(crate) fn at_world_entry( ctx: &ReducerContext, entity: &mut crate::WorldEntity, @@ -161,7 +129,6 @@ fn write_reply(ctx: &ReducerContext, character_guid: u64, reply: Option<(u8, Str crate::character_owned!(delete, fn sweep_delete_game_character_away(ctx, character_guid) { ctx.db.game_character_away().character_guid().delete(character_guid); }); -// Transports: a Transfer is a world-port, and the Away Status survives it. crate::character_owned!(transfer, fn sweep_transfer_game_character_away(ctx, character_guid, io) { table = game_character_away, primary_key = character_guid, @@ -182,7 +149,6 @@ mod tests { } } - /// cm:ChatHandler.cpp:669-682: `/afk` with no text sets the flag and the default text. #[test] fn afk_without_text_starts_with_the_default_reply() { assert_eq!( @@ -203,14 +169,12 @@ mod tests { ); } - /// cm:ChatHandler.cpp:649-656: the kind is set and the text is empty, so it ends. #[test] fn afk_again_without_text_ends_it() { assert_eq!(next_away(0x02, AFK, ""), change(0x00, None)); assert_eq!(next_away(0x04, DND, " "), change(0x00, None)); } - /// cm:ChatHandler.cpp:658-666: the kind is set and there is text, so only the text changes. #[test] fn afk_again_with_text_changes_only_the_reply() { assert_eq!( @@ -219,7 +183,6 @@ mod tests { ); } - /// cm:ChatHandler.cpp:677-690: setting one kind ends the other. #[test] fn dnd_while_afk_switches_and_the_reverse() { assert_eq!( @@ -241,8 +204,6 @@ mod tests { assert_eq!(next_away(0x12, AFK, ""), change(0x10, None)); } - /// cm:Player.cpp:2932: a real login ends the Away Status. A world-port keeps it, and the - /// ghost bit beside it stays as the rebuild set it. #[test] fn a_login_ends_the_away_status_and_a_world_port_keeps_it() { use crate::world::WorldEntry::{Login, WorldPort}; diff --git a/module/src/breath.rs b/module/src/breath.rs index 7cb298103..52a9d18b8 100644 --- a/module/src/breath.rs +++ b/module/src/breath.rs @@ -1,17 +1,8 @@ -//! Player breath: a tiny non-spatial timer for underwater players. The movement edge only arms or -//! flips rows; this scheduled reducer advances them so a motionless player still drowns. - use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{game_world_entity, world::WorldEntity}; use lyracore_shared::env::{advance_breath, drowning_damage, FULL_AIR_MICROS}; -// Vanilla's `Player::HandleDrowning` gives a player about one minute of air. Surfacing recovers -// the same bar over ten seconds; the slower recovery prevents brief surface bobs from resetting it. - -/// One active breath timer. This is deliberately non-spatial: the scheduled tick must iterate all -/// rows, and spatial iteration would silently omit players outside the caller's subscribed slice. -/// A row exists only while the player is draining or recovering a partial bar. #[table(accessor = game_breath_state)] pub struct BreathState { #[primary_key] @@ -21,14 +12,11 @@ pub struct BreathState { pub draining: bool, } -// Breath is transient live-world state, not character progression. Delete it with the character; -// never transport it so an arrival starts with a fresh bar instead of replaying stale water state. crate::character_owned!(delete, fn sweep_delete_game_breath_state(ctx, character_guid) { ctx.db.game_breath_state().entity_guid().delete(character_guid); }); crate::character_owned!(not_transported, fn sweep_transfer_game_breath_state()); -/// Drives breath independently of movement packets. [server] #[table(accessor = game_breath_schedule, scheduled(tick_breath))] pub struct BreathSchedule { #[primary_key] @@ -37,8 +25,6 @@ pub struct BreathSchedule { pub scheduled_at: ScheduleAt, } -/// Fold one movement edge into the state table. Callers have already passed the godmode/dead/ghost -/// exemption and supplied the authoritative underwater predicate. pub(crate) fn update_breath_edge(ctx: &ReducerContext, mover: &WorldEntity, submerged: bool) { let states = ctx.db.game_breath_state(); let now = ctx.timestamp; @@ -101,7 +87,6 @@ pub fn tick_breath(ctx: &ReducerContext, _schedule: BreathSchedule) { states.entity_guid().delete(entity_guid); continue; }; - // A dead player, ghost, or godmode mover must never retain a draining/refilling timer. if entity.dead || entity.godmode || !entity.is_player() @@ -124,9 +109,6 @@ pub fn tick_breath(ctx: &ReducerContext, _schedule: BreathSchedule) { if advanced.drowning_ticks == 0 { continue; } - // Preserve one one-second hit (and one relay line) per catch-up tick. `kill_player` owns - // the lethal write, so the last line reports the health actually removed rather than an - // overkill nominal value. let mut health = entity.health; let mut died = false; for _ in 0..advanced.drowning_ticks { @@ -134,7 +116,6 @@ pub fn tick_breath(ctx: &ReducerContext, _schedule: BreathSchedule) { let actual = nominal.min(health); let (_, lethal) = crate::world::resolve_environmental_damage(nominal, health); if lethal { - // Self/environmental killer: no player receives kill credit or loot. if health != entity.health { if let Some(mut pre_death) = ctx.db.game_world_entity().guid().find(entity_guid) { diff --git a/module/src/breath_relay.rs b/module/src/breath_relay.rs index 40b9ceb49..07f956ee5 100644 --- a/module/src/breath_relay.rs +++ b/module/src/breath_relay.rs @@ -1,21 +1,9 @@ -//! Client-facing breath updates: a one-shot relay row on each timer edge or drowning hit. -//! -//! The private `breath` state owns elapsed-time accounting. This table deliberately carries only -//! the facts the client needs to mirror it: start the countdown, stop it, or display a drowning -//! damage line. In particular, a draining second that does not cross either edge writes nothing. - use spacetimedb::{table, ReducerContext, Table, Timestamp}; -/// A breath timer began; the gateway sends `SMSG_START_MIRROR_TIMER`. pub const KIND_START: u8 = 0; -/// A breath timer ended after surfacing; the gateway sends `SMSG_STOP_MIRROR_TIMER`. pub const KIND_STOP: u8 = 1; -/// One server-applied drowning hit; the gateway sends `SMSG_ENVIRONMENTAL_DAMAGE_LOG`. pub const KIND_DROWNING_DAMAGE: u8 = 2; -/// A public relay row consumed by the gateway. The recipient is always `character_guid`; this is -/// public rather than RLS-scoped because coordinator subscriptions share the feed and resolve that -/// self-only audience through the owner-session lookup. #[table(accessor = game_breath_relay_event, public)] pub struct BreathRelayEvent { #[primary_key] @@ -23,17 +11,12 @@ pub struct BreathRelayEvent { pub id: u64, pub character_guid: u64, pub kind: u8, - /// Starting air remaining, in milliseconds. Used only by [`KIND_START`]. pub time_remaining_ms: u32, - /// Full breath duration, in milliseconds. Used only by [`KIND_START`]. pub duration_ms: u32, - /// Health actually removed by the resolver. Used only by [`KIND_DROWNING_DAMAGE`]. pub damage: u32, pub created_at: Timestamp, } -// Character-owned sweep: this is transient relay data, so it dies with the character. Public -// rows have no owner identity to re-stamp. crate::character_owned!(delete, fn sweep_delete_game_breath_relay_event(ctx, character_guid) { let events = ctx.db.game_breath_relay_event(); for row in events.iter().filter(|row| row.character_guid == character_guid).collect::>() { @@ -41,9 +24,6 @@ crate::character_owned!(delete, fn sweep_delete_game_breath_relay_event(ctx, cha } }); -// CROSS-DATABASE transport: this one-shot relay has a GC TTL and no durable state. Carrying it -// would replay a stale breath bar or drowning line after a transfer; the destination re-arms from -// its next movement edge instead. crate::character_owned!(not_transported, fn sweep_transfer_game_breath_relay_event()); fn insert( @@ -65,7 +45,6 @@ fn insert( }); } -/// Write the single packet-worthy edge when a player submerges. pub(crate) fn start( ctx: &ReducerContext, character_guid: u64, @@ -82,12 +61,10 @@ pub(crate) fn start( ); } -/// Write the single packet-worthy edge when a player surfaces. pub(crate) fn stop(ctx: &ReducerContext, character_guid: u64) { insert(ctx, character_guid, KIND_STOP, 0, 0, 0); } -/// Relay the exact damage that the server already applied to a drowning player. pub(crate) fn drowning_damage(ctx: &ReducerContext, character_guid: u64, damage: u32) { insert(ctx, character_guid, KIND_DROWNING_DAMAGE, 0, 0, damage); } diff --git a/module/src/bridge.rs b/module/src/bridge.rs index 388f29879..4463a9389 100644 --- a/module/src/bridge.rs +++ b/module/src/bridge.rs @@ -1,18 +1,3 @@ -//! Addon⇄server message bridge, the UI↔server RPC channel custom features ride. -//! -//! Client→server: the 1.12 client's `SendAddonMessage` is `CMSG_MESSAGECHAT` with language -//! `LANG_ADDON` (0xFFFFFFFF) and text `"\t"`. The GATEWAY intercepts those frames -//! raw (gtker's Language enum can't decode 0xFFFFFFFF), parses the `STC` envelope, and calls the -//! [`client_command`] reducer AS the player — a command handler has exactly a reducer's authority. -//! -//! Server→client: [`send`] inserts a [`AddonMessage`] row; the gateway relays it (COORDINATOR -//! connection — the delivery law: addon UI state must survive fat transactions) as an -//! addon-language whisper the client surfaces to addons as `CHAT_MSG_ADDON`. -//! -//! Core owns `ping`; one installed Package may register a parser, an admitted apply operation and a -//! reply command name with `game_client_command!`. Parsing queues only a pending intent. Gateway -//! certifies Realm-core party authority before the target World Shard applies gameplay. - use lyracore_shared::group::COMMAND_RESULT_WINDOW_MICROS; use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; @@ -94,7 +79,6 @@ pub struct AdmittedClientCommand { pub struct ClientCommandHandler { pub parse: fn(&str, &str) -> Option>, pub apply: fn(&ReducerContext, &AdmittedClientCommand) -> CommandOutcome, - /// The addon command name that carries each outcome back to the issuer. pub reply: &'static str, } @@ -109,7 +93,6 @@ pub enum CommandIntentState { index(accessor = by_pending, btree(columns = [pending])), index(accessor = by_result_reap, btree(columns = [result_reap_micros])) )] -/// An authenticated client command awaiting Gateway party-authority admission. [entity] pub struct PartyCommandIntent { #[primary_key] #[auto_inc] @@ -131,7 +114,6 @@ pub struct PartyCommandIntent { } #[table(accessor = game_party_command_dispatch_lane)] -/// One durable fair queue lane for authenticated companion commands. [entity] pub struct PartyCommandDispatchLane { #[primary_key] pub lane: u8, @@ -140,7 +122,6 @@ pub struct PartyCommandDispatchLane { } #[table(accessor = game_party_command_issuer)] -/// The next causal Companion Order sequence minted for one authenticated issuer. [entity] pub struct PartyCommandIssuer { #[primary_key] pub character_guid: u64, @@ -165,7 +146,6 @@ crate::character_owned!(transfer, fn sweep_transfer_game_party_command_issuer(ct index(accessor = by_bot, btree(columns = [bot_guid])), index(accessor = by_retention, btree(columns = [retain_until_micros])) )] -/// The target Shard's idempotency result for one source Module and intent id. [entity] pub struct PartyCommandReceipt { #[primary_key] #[auto_inc] @@ -179,14 +159,9 @@ pub struct PartyCommandReceipt { pub retain_until_micros: i64, } -// A settled Character deletion retains receipts until their deadline, so a lost acknowledgement -// cannot turn into a second application. Cross-Shard transfer removes the source copies through -// `detach_command_receipts_for_transfer` before the character cascade; this registered sweep stays -// deliberately empty so its result cannot depend on the generated cascade order. +// Retain receipts through deletion until expiry so a lost acknowledgement cannot apply twice. crate::character_owned!(delete, fn sweep_delete_game_party_command_receipt(_ctx, _character_guid) {}); -/// Remove receipts already carried in the cross-Shard Escrow snapshot before source teardown. -/// Same-database transfer never calls the source teardown boundary and therefore retains them. pub(crate) fn detach_command_receipts_for_transfer(ctx: &ReducerContext, character_guid: u64) { let receipts = ctx.db.game_party_command_receipt(); for row in receipts @@ -209,25 +184,17 @@ pub(crate) fn party_command_receipt_key(source_identity: Identity, intent_id: u6 format!("{source_identity}:{intent_id}") } -/// One server→client addon message: relayed by the gateway as an addon-language whisper, arriving -/// in the recipient's addons as `CHAT_MSG_ADDON` with `prefix == "STC"`. TTL-reaped with the other -/// event tables (same `created_at` shape). [event] #[table(accessor = game_addon_message, public)] pub struct AddonMessage { #[primary_key] #[auto_inc] pub id: u64, pub recipient_identity: Identity, - /// Command name, e.g. `"pong"` or `"event.progress"` — the addon routes on it. pub cmd: String, - /// Command payload, a command-defined micro-format (no JSON — 255-byte wire budget). pub payload: String, pub created_at: Timestamp, } -/// Queue an addon message for `character_guid`'s live session. The recipient identity comes off -/// the durable character row (its owner binding) — a logged-out recipient's row is inserted and -/// simply reaped unread (the 1s event TTL), which is the right semantics for UI state. pub(crate) fn send(ctx: &ReducerContext, character_guid: u64, cmd: &str, payload: &str) { let Some(c) = crate::helpers::character_by_guid(ctx, character_guid) else { return; @@ -250,15 +217,11 @@ fn client_command_handler() -> Option { crate::GAME_CLIENT_COMMAND } -/// A debug Module with no Package handler uses [`FIXTURE_COMMAND`], so Core's durable tests drive -/// the command transport with no Package installed. #[cfg(feature = "debug_reducers")] fn client_command_handler() -> Option { crate::GAME_CLIENT_COMMAND.or(Some(FIXTURE_COMMAND)) } -/// Core's neutral test command: `fixture.command` with a bot guid as payload. It applies nothing, -/// so each outcome comes from the transport's own Gates. #[cfg(feature = "debug_reducers")] const FIXTURE_COMMAND: ClientCommandHandler = ClientCommandHandler { parse: parse_fixture_command, @@ -286,8 +249,6 @@ fn parse_fixture_command( }) } -/// The shared core behind [`client_command`] and its gateway twin `gw_client_command`: -/// dispatch with the sender already resolved to a guid. pub(crate) fn apply_client_command( ctx: &ReducerContext, character_guid: u64, @@ -298,10 +259,8 @@ pub(crate) fn apply_client_command( Ok(()) } -/// Command dispatch — one arm per command, handlers live next to their features. fn dispatch(ctx: &ReducerContext, character_guid: u64, cmd: &str, payload: &str) { match cmd { - // The 184 acceptance round-trip: `STC ping` → `STC pong` (payload echoed). "ping" => send(ctx, character_guid, "pong", payload), other => { let parsed = client_command_handler().and_then(|handler| { @@ -443,8 +402,7 @@ pub fn defer_party_command_intent( if lane.head_intent_id != intent.id { return Err("IntentNotDispatchHead".to_string()); } - // Expiry permits lane rotation without stealing the worker that may still be completing its - // target transaction. Its lease keeps a second worker from claiming a false Expired result. + // Expiry rotates the lane; keep its lease while the prior target transaction may finish. if !foreign_live_claim { intent.claim_token = 0; intent.claim_until_micros = 0; @@ -543,7 +501,6 @@ pub fn finish_party_command_intent( let reply_identity = intent.reply_identity; retire_party_command_intent(ctx, &intent)?; table.id().update(intent); - // An intent outlives an uninstalled Package; no addon remains to read its reply. if let Some(handler) = client_command_handler() { send_to_identity( ctx, @@ -648,8 +605,6 @@ pub fn apply_admitted_party_command( Ok(()) } -/// Acknowledge an exact Command Receipt read. `Ok` proves absence in this transaction; a known -/// outcome tag proves presence. Gateway treats any other reducer failure as unavailable state. #[reducer] pub fn confirm_party_command_receipt( ctx: &ReducerContext, @@ -669,8 +624,6 @@ pub fn confirm_party_command_receipt( } } -/// Acknowledge whether this World Shard owns the durable Character for `bot_guid`. Transfer is a -/// nonterminal routing state. Live-body admission remains the target transaction's Group Gate. #[reducer] pub fn confirm_party_command_holder(ctx: &ReducerContext, bot_guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -695,8 +648,6 @@ fn fixture_command_intent( .ok_or_else(|| "fixture command intent missing".to_string()) } -/// Drive Realm-core admission and target application on one private unsharded fixture, stopping -/// before source finalization so durable tests can model a Gateway loss at that boundary. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_apply( @@ -708,8 +659,6 @@ pub fn party_command_fixture_apply( fixture_command_apply_after_authority(ctx, intent_id, claim_token, 0) } -/// Change one target Gate after authority admission and before application in the same private -/// transaction. Mode 1 removes the live body; mode 2 suppresses Sessionless Action Consent. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_apply_after_gate_change( @@ -793,7 +742,6 @@ fn fixture_command_apply_after_authority( } } -/// Finish a private fixture command from the exact target receipt without applying it again. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_finish( @@ -813,7 +761,6 @@ pub fn party_command_fixture_finish( finish_party_command_intent(ctx, intent.id, claim_token, receipt.outcome) } -/// Apply and finish one private unsharded fixture command through the production phase operations. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_drive( @@ -836,7 +783,6 @@ pub fn party_command_fixture_drive( Ok(()) } -/// Release the oldest receipt owned by one private command fixture to exercise capacity recovery. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_release_receipt( @@ -855,7 +801,6 @@ pub fn party_command_fixture_release_receipt( Ok(()) } -/// Expire one still-pending private fixture command without changing its target receipt. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_expire(ctx: &ReducerContext, intent_id: u64) -> Result<(), String> { @@ -870,7 +815,6 @@ pub fn party_command_fixture_expire(ctx: &ReducerContext, intent_id: u64) -> Res Ok(()) } -/// Move one pending fixture intent beyond the target receipt-retention guarantee. #[cfg(feature = "debug_reducers")] #[reducer] pub fn party_command_fixture_expire_after_receipt_window( diff --git a/module/src/channel.rs b/module/src/channel.rs index 7a825fa9b..d8508682f 100644 --- a/module/src/channel.rs +++ b/module/src/channel.rs @@ -1,14 +1,3 @@ -//! Chat Channels on Realm-core: one copy of every channel per team, whichever World Shard its -//! members play on. -//! -//! Every op runs through [`realm_channel_op`]. A successful op writes its Channel Notices as -//! [`ChatChannelNoticeEvent`] rows with explicit recipients, which the Gateway delivers like a -//! Realm Chat Line. A Refusal rolls the transaction back, so the Gateway answers it from the tag. -//! Channel speech is a Realm Chat Line of kind CHANNEL; [`chat_audience`] is its audience rule. -//! -//! Channel Membership lasts as long as the Account Claim generation that admitted it. The claim -//! code calls [`leave_all`] when it releases, replaces or reaps a claim. - use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table, Timestamp}; use lyracore_shared::channel::{ @@ -20,8 +9,6 @@ use lyracore_shared::faction::team_for_race; use crate::realm_chat::{ChatAudience, RealmChatRequest, SpeakerFacts}; -/// One Chat Channel. Private: the owner-token Coordinator is its only reader. Deleted with its -/// bans when its last member leaves. [entity] #[table( accessor = game_chat_channel, index(accessor = by_name, btree(columns = [team, name_key])) @@ -30,26 +17,17 @@ pub struct ChatChannel { #[primary_key] #[auto_inc] pub channel_id: u64, - /// `lyracore_shared::faction` team id. Each team has its own channels. pub team: u32, - /// The lowercase name. With `team`, the channel's identity. pub name_key: String, - /// The creator's spelling, echoed on the wire. pub name: String, - /// The ChatChannels id. 0 for a custom channel. pub builtin_id: u32, - /// `lyracore_shared::channel::channel_flag` bits. pub flags: u8, pub password: String, - /// 0 for none. Built-in channels never have one. pub owner_guid: u64, - /// JOINED and LEFT go to the other members. pub announcements: bool, - /// Only moderators may speak. pub moderated: bool, } -/// One Channel Membership. The row id is the join order that owner succession reads. [entity] #[table( accessor = game_chat_channel_member, index(accessor = by_channel, btree(columns = [channel_id])), @@ -61,14 +39,12 @@ pub struct ChatChannelMember { pub id: u64, pub channel_id: u64, pub character_guid: u64, - /// `lyracore_shared::channel::member_flag` bits. pub member_flags: u8, - /// The Account Claim that admitted the member. 0 for an actor without a World Session. + // Membership ends with the Account Claim generation that admitted it. pub account_id: u64, pub claim_generation: u64, } -/// A Character banned from one channel. Deleted with its channel. [entity] #[table( accessor = game_chat_channel_ban, index(accessor = by_channel, btree(columns = [channel_id])) @@ -81,58 +57,34 @@ pub struct ChatChannelBan { pub character_guid: u64, } -/// One Channel Notice with its complete recipient list. Private, reaped by the shared event GC. -/// Which fields a notice fills follows cm:Channel.cpp:748-946. [event] #[table(accessor = game_chat_channel_notice_event)] pub struct ChatChannelNoticeEvent { #[primary_key] #[auto_inc] pub id: u64, - /// `lyracore_shared::channel::notice`. pub notice: u8, pub channel_name: String, - /// JOINED, LEFT, MODE_CHANGE, PLAYER_ALREADY_MEMBER, OWNER_CHANGED, the target of - /// PLAYER_KICKED, PLAYER_BANNED and PLAYER_UNBANNED, and the inviter of INVITE. pub subject_guid: u64, - /// PASSWORD_CHANGED, the announcement and moderation toggles, and the source of - /// PLAYER_KICKED, PLAYER_BANNED and PLAYER_UNBANNED. pub actor_guid: u64, - /// MODE_CHANGE only. pub old_flags: u8, pub new_flags: u8, - /// YOU_JOINED only: the wire channel flags. pub channel_flags: u32, - /// The name PLAYER_INVITED and PLAYER_INVITE_BANNED carry. pub text: String, pub recipients: Vec, pub created_at: Timestamp, } -/// One channel op as the client asked for it, with the facts only the Gateway can read. Every op -/// uses the same shape, so adding an op never changes the reducer signature. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct ChannelRequest { - /// As the client typed it. pub channel_name: String, - /// JOIN and PASSWORD. pub password: String, - /// Ops that name a player. The Gateway resolves the name realm-wide. pub target_guid: u64, - /// The target's name, for notices that carry one. pub target_name: String, - /// INVITE: the target's team check. pub target_race: u8, - /// INVITE: read from the target's own contact rows. pub target_ignores_actor: bool, - /// The actor. Its race decides the team. pub speaker: SpeakerFacts, } -/// Run one channel op for `request_actor`. -/// -/// Operator-gated because the actor is an argument: Realm-core has no live entity to derive it -/// from. A Refusal rolls the transaction back and returns its stable tag. An op byte this Module -/// does not know is an untagged error. #[reducer] pub fn realm_channel_op( ctx: &ReducerContext, @@ -190,8 +142,6 @@ pub fn realm_channel_op( }) } -/// cm:Channel.cpp:59-124. The channel is created on the first join; a refused join rolls the -/// creation back with it. fn join( ctx: &ReducerContext, team: u32, @@ -201,10 +151,6 @@ fn join( ) -> Result<(), ChannelRefusal> { let name = channel_name(&request.channel_name)?; check_password(&request.password)?; - // A Guild member stays out of GuildRecruitment and hears nothing about it. mangos tests the - // channel's wire flags 0x38, which only GuildRecruitment carries (cm:Channel.cpp:94-95). The - // test runs before the channel is found or created, so the committed Ok leaves no empty - // channel behind. mangos runs it after the ban test; a banned Guild member hears silence here. if classify(&name.display).is_some_and(|builtin| builtin.id == GUILD_RECRUITMENT_ID) && crate::guild::member(ctx, joiner).is_some() { @@ -212,7 +158,6 @@ fn join( } let channel = find(ctx, team, &name.key).unwrap_or_else(|| create(ctx, team, name)); if member_of(ctx, channel.channel_id, joiner).is_some() { - // Built-in channels answer a repeat join with nothing (cm:Channel.cpp:64-72). return if channel.builtin_id != 0 { Ok(()) } else { @@ -225,8 +170,6 @@ fn join( if !channel.password.is_empty() && channel.password != request.password { return Err(ChannelRefusal::WrongPassword); } - // A built-in channel can hold every player of a team, so its member list is read only when an - // announcement or a first owner needs it. Built-in channels need neither. let takes_ownership = channel.builtin_id == 0 && channel.owner_guid == 0; let others = if channel.announcements || takes_ownership { members_in_join_order(ctx, channel.channel_id) @@ -266,7 +209,6 @@ fn join( Ok(()) } -/// cm:Channel.cpp:126-167. fn leave( ctx: &ReducerContext, team: u32, @@ -285,7 +227,6 @@ fn leave( Ok(()) } -/// cm:Channel.cpp:288-317. fn set_password( ctx: &ReducerContext, team: u32, @@ -311,9 +252,6 @@ fn set_password( Ok(()) } -/// SET_OWNER (cm:Channel.cpp:393-444, cm:Channel.cpp:976-1017). The actor must be the owner -/// (`NotOwner`) and the target must be a member (`PlayerNotFound`). A built-in channel has no -/// owner, so its `owner_guid` is always 0 and every actor fails the owner check. fn set_owner( ctx: &ReducerContext, team: u32, @@ -332,11 +270,6 @@ fn set_owner( Ok(()) } -/// MODERATOR, UNMODERATOR, MUTE and UNMUTE (cm:Channel.cpp:319-391, cm:Channel.cpp:960-974). The -/// actor must be a moderator (`NotModerator`) and the target must be a member (`PlayerNotFound`). -/// Naming the owner refuses `NotOwner`, except that the owner's own MODERATOR bit never changes -/// (cm:Channel.cpp:359-360): a silent no-op, not a mute exemption. `change_member_flags` already -/// silently skips a flag that is already at the requested state. fn set_mode_flag( ctx: &ReducerContext, team: u32, @@ -371,11 +304,6 @@ fn set_mode_flag( Ok(()) } -/// KICK and BAN (cm:Channel.cpp:169-237). The actor must be a moderator; only the owner may remove -/// the owner. A BAN of a Character not yet banned records the ban and writes PLAYER_BANNED; -/// otherwise, and for KICK, write PLAYER_KICKED. Either notice names target and actor and goes to -/// every member, target included, before the target departs (cm:Channel.cpp:231-233). `depart` -/// sends no LEFT of its own here, because this notice already covers the departure. fn kick_or_ban( ctx: &ReducerContext, team: u32, @@ -421,11 +349,6 @@ fn kick_or_ban( Ok(()) } -/// UNBAN (cm:Channel.cpp:239-286). The actor must be a moderator. An unresolved target (guid 0, -/// the Gateway's sentinel for a name that did not resolve to an online Character) refuses -/// `PlayerNotFound`, the same as cmangos's own `GetPlayer` miss, checked before the ban table, -/// never after: no real Character ever holds guid 0. A Character not banned refuses -/// `PlayerNotBanned`. Otherwise remove the ban and write PLAYER_UNBANNED naming target and actor. fn unban( ctx: &ReducerContext, team: u32, @@ -465,13 +388,6 @@ fn unban( Ok(()) } -/// INVITE (cm:Channel.cpp:666-726). Needs no moderator right. An unresolved target (guid 0, the -/// Gateway's sentinel for a name that did not resolve to an online Character) refuses -/// `PlayerNotFound`, cmangos's own `GetPlayer` miss, checked first: race 0 would otherwise pass -/// the team check below as Alliance and falsely succeed. A target already a member refuses -/// `PlayerAlreadyMember`; a banned target refuses `PlayerInviteBanned`; another team refuses -/// `InviteWrongFaction`. Otherwise write INVITE naming the actor to the target, unless the target -/// ignores the actor, and write PLAYER_INVITED with the target's name to the actor. fn invite( ctx: &ReducerContext, team: u32, @@ -513,14 +429,11 @@ fn invite( Ok(()) } -/// What [`toggle`] flips. enum Toggle { Announcements, Moderate, } -/// ANNOUNCEMENTS and MODERATE (cm:Channel.cpp:523-591). The actor must be a moderator. Flip the -/// flag and tell every member. fn toggle( ctx: &ReducerContext, team: u32, @@ -564,8 +477,6 @@ fn toggle( Ok(()) } -/// Leave every channel without YOU_LEFT, as vanilla does at logout (cm:Player.cpp:4739-4750). -/// The other members still see LEFT where announced, and ownership passes on. pub(crate) fn leave_all(ctx: &ReducerContext, character_guid: u64) { let memberships: Vec = ctx .db @@ -586,12 +497,6 @@ pub(crate) fn leave_all(ctx: &ReducerContext, character_guid: u64) { } } -/// Remove one member: LEFT to the rest when `notify_left`, owner succession, and the empty channel -/// deleted with its password and bans (cm:ChannelMgr.cpp:85-103). Built-in channels keep no state -/// worth an empty row either. `notify_left` is `channel.announcements` for a plain leave; KICK and -/// BAN pass `false`, because their own PLAYER_KICKED or PLAYER_BANNED notice already covers the -/// departure and vanilla does not double-announce it. The remaining members are read only when a -/// notice or a new owner needs them, so leaving a built-in channel never reads its member list. fn depart( ctx: &ReducerContext, channel: ChatChannel, @@ -629,9 +534,6 @@ fn depart( } } -/// Owner succession: the earliest-joined moderator, else the earliest joiner. mangos -/// `SelectNewOwner` does the same with guid order (cm:Channel.cpp:948-958); join order is the -/// fairer tie-break. `members` is in join order and not empty. pub(crate) fn successor(members: &[ChatChannelMember]) -> u64 { members .iter() @@ -640,9 +542,6 @@ pub(crate) fn successor(members: &[ChatChannelMember]) -> u64 { .map_or(0, |member| member.character_guid) } -/// Make `new_owner` the owner of a custom channel (cm:Channel.cpp:976-1016). A previous owner who -/// is still a member keeps MODERATOR and loses OWNER. The new owner gains both. Each change is one -/// MODE_CHANGE to every member, and OWNER_CHANGED follows when `exclaim` is set. pub(crate) fn hand_ownership( ctx: &ReducerContext, mut channel: ChatChannel, @@ -668,8 +567,6 @@ pub(crate) fn hand_ownership( } } -/// Rewrite one member's flags and tell every member with MODE_CHANGE. No row, no notice when the -/// character is not a member or the flags do not change. pub(crate) fn change_member_flags( ctx: &ReducerContext, channel: &ChatChannel, @@ -705,9 +602,6 @@ pub(crate) fn change_member_flags( ); } -/// Who hears a CHANNEL line (cm:Channel.cpp:593-664): every member, speaker included. A listener -/// who ignores the speaker skips the line unless the speaker is a moderator (cm:Channel.cpp:663, -/// vm:Channel.cpp:677). Vanilla has no death check here. pub(crate) fn chat_audience( ctx: &ReducerContext, speaker_guid: u64, @@ -731,7 +625,6 @@ pub(crate) fn chat_audience( }) } -/// The channel `raw_name` names for `team`, and `character_guid`'s membership in it. pub(crate) fn membership( ctx: &ReducerContext, team: u32, @@ -745,8 +638,6 @@ pub(crate) fn membership( Ok((channel, member)) } -/// `character_guid`'s membership in one channel, read through the Character's own few -/// memberships rather than the channel's member list. fn member_of( ctx: &ReducerContext, channel_id: u64, @@ -767,8 +658,6 @@ fn find(ctx: &ReducerContext, team: u32, key: &str) -> Option { .next() } -/// A built-in channel gets its wire flags, no announcements and never an owner. A custom channel -/// gets CUSTOM and announcements (cm:Channel.cpp:26-57). fn create(ctx: &ReducerContext, team: u32, name: ChannelName) -> ChatChannel { let builtin = classify(&name.display); ctx.db.game_chat_channel().insert(ChatChannel { @@ -825,8 +714,6 @@ pub(crate) fn guids(members: &[ChatChannelMember]) -> Vec { members.iter().map(|member| member.character_guid).collect() } -/// A [`ChatChannelNoticeEvent`] without the channel, recipients, id and time. Unused fields stay -/// zero. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct Notice { pub(crate) notice: u8, @@ -839,7 +726,6 @@ pub(crate) struct Notice { } impl Notice { - /// A notice that names one member, or none when `subject_guid` is 0. pub(crate) fn about(notice: u8, subject_guid: u64) -> Self { Self { notice, @@ -849,7 +735,6 @@ impl Notice { } } -/// The only writer of `game_chat_channel_notice_event`. Nothing is written for an empty audience. pub(crate) fn notify( ctx: &ReducerContext, channel: &ChatChannel, diff --git a/module/src/character.rs b/module/src/character.rs index 75e77062a..563ce79d3 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -1,18 +1,14 @@ -//! The durable Character table. Public but RLS-restricted so a player connection only ever sees -//! its own characters (owner bound at `establish_session`). [entity] - use spacetimedb::{table, Identity}; -// A named constant avoids the table macro's unary-expression precedence in default serialization. +// The named constant avoids unary-expression precedence in the table macro's default serializer. const NO_WATCHED_FACTION: i32 = -1; -/// Durable character; exists whether or not online. [entity] #[table(accessor = game_character, public, index(accessor = by_account, btree(columns = [account_id])))] pub struct Character { #[primary_key] pub guid: u64, pub account_id: u64, - pub owner_identity: Identity, // Identity::ZERO until bound at establish_session + pub owner_identity: Identity, #[unique] pub name: String, pub race: u8, @@ -24,8 +20,8 @@ pub struct Character { pub hair_color: u8, pub facial_hair: u8, pub level: u8, - pub xp: u32, // current XP toward next level - pub next_level_xp: u32, // XP threshold to ding (0 at cap) + pub xp: u32, + pub next_level_xp: u32, pub map_id: u32, pub zone_id: u32, pub x: f32, @@ -34,26 +30,12 @@ pub struct Character { pub orientation: f32, pub first_login: bool, pub online: bool, - /// Persisted purse in copper; mirrors WorldEntity.money while online. `#[default(0)]` - /// so adding this column auto-migrates existing rows instead of forcing a `-c` data wipe. #[default(0)] pub money: u32, - /// Rested-XP pool in XP points: accrued from offline time at login, drained as it doubles - /// kill XP in `xp::award_xp`. Durable (the whole point — it builds up while logged out). `#[default(0)]` - /// → auto-migrates; 0 = no rest bonus, so existing characters are unaffected until they log out/in. #[default(0)] pub rested_xp: u32, - /// Unix-epoch micros of this character's last logout, stamped by `persist_entity` on a real - /// logout/disconnect and consumed (reset to 0) by the login rested-XP accrual. 0 = never logged out - /// (no accrual on first login). `#[default(0u64)]` — TYPED: a bare `0` is encoded as a 4-byte i32 and - /// the publish migration rejects it for a u64 column ("data too short for u64") → auto-migrates. [entity] #[default(0u64)] pub last_logout_micros: u64, - /// Hearthstone home (the bound recall point — vanilla's innkeeper bind). `use_hearthstone` teleports - /// here; `bind_home` sets it to the player's current position (an approximation of vanilla's inn - /// bind point). `create_character` seeds these to the start position so a fresh char recalls home. - /// Typed defaults + END-appended → auto-migrate (the `last_logout_micros` precedent: a bare `0` is a - /// 4-byte i32 and the migration rejects it for a u32/f32 column). [entity] #[default(0u32)] pub home_map: u32, #[default(0u32)] @@ -64,99 +46,38 @@ pub struct Character { pub home_y: f32, #[default(0.0f32)] pub home_z: f32, - /// Accrued played-time total in whole seconds, for `CMSG_PLAYED_TIME`/`/played`. Advanced by - /// `persist_entity` on every persist (real logout AND the ghost-relog cleanup in `player_login`) - /// using the elapsed time since `session_start_micros`. `#[default(0)]` → auto-migrates existing - /// rows to "0 played" rather than forcing a wipe. [entity] #[default(0)] pub played_total_secs: u32, - /// Unix-epoch micros this character's CURRENT session began, stamped by `player_login` and - /// consumed (reset to 0) by `persist_entity` once its elapsed span is folded into - /// `played_total_secs`. 0 = not currently in a live session (offline, or between persist and the - /// next login). TYPED `0u64` — a bare `0` encodes as a 4-byte i32 and the publish migration - /// rejects it for a u64 column (the `last_logout_micros` precedent). [entity] #[default(0u64)] pub session_start_micros: u64, - /// Persisted current health at last logout, mirrors `WorldEntity.health` while online. `0` is - /// the sentinel for "no persisted value yet" (a fresh character, or an existing row migrated - /// before this column existed) — `build_player_entity` treats 0 as "spawn at full health". A real - /// logout at 1..=max_health persists exactly that value. `#[default(0u32)]` → END-appended, - /// additive auto-migrate (the `home_map` precedent). [entity] #[default(0u32)] pub health: u32, - /// Persisted current power (mana/rage/energy) at last logout, mirrors - /// `WorldEntity.power`. Same `0` = "no persisted value" sentinel as `health` — 0 is also a - /// legitimate live value for rage/energy classes, so `build_player_entity` disambiguates using - /// `health == 0` rather than power alone. `persist_entity` saves health as at least 1, including - /// for a released ghost. [entity] + // Health zero, rather than power zero, identifies an uninitialized vitals snapshot. #[default(0u32)] pub power: u32, - /// Number of times this character has reset its talents at a trainer (`talent::do_reset_talents`) — - /// the escalation counter `talent::respec_cost_copper` reads to price the NEXT reset (never decays, - /// never resettable; a durable lifetime count, like `played_total_secs`). `#[default(0u32)]` — TYPED, - /// END-appended (the `home_map`/`health`/`power` precedent: a bare `0` is a 4-byte i32 and the publish - /// migration rejects it for a u32 column) → auto-migrates existing characters to "never respec'd". [entity] #[default(0u32)] pub respec_count: u32, - /// Corpse-reclaim escalation deadline (`corpse::escalated_reclaim`'s recurrence state) — DURABLE so a - /// disconnect/relog cannot reset the death ladder back to 30s (the live entity is rebuilt on every - /// login; without this column the most common interruption defeated the escalation entirely). - /// Persisted at logout, threaded back through `build_player_entity`. `#[default(0i64)]` — TYPED, - /// END-appended. [entity] #[default(0i64)] pub death_expire_micros: i64, #[default(0u64)] pub pending_instance_id: u64, - /// GM playtest authorization level: `0` = no access to any `.command`; the - /// operator-only `gm::set_gm_level` reducer is the only writer. Moderation-facing per-level - /// distinctions beyond "has access at all" are out of scope: every - /// `gm_command` today only checks `gm_level != 0`. `#[default(0)]` + END-appended so `publish` - /// auto-migrates existing characters to "no GM access" (safe default). Gateway-subscribed - /// (`game_character` is in the coordinator's subscription list) → hand-synced in - /// `character_type.rs` + widened in `gateway/tests/schema_parity.rs`. #[default(0)] pub gm_level: u8, #[default(false)] pub pending_ghost: bool, - /// Rest state: logged out in a rest area (inn/city)? Stamped by `persist_entity` from the live - /// entity's `resting` flag at logout, read by `player_login` to pick the offline rested rate (full in - /// a rest area vs 1/4 in the field) and to spawn the character already showing the zzz/blue-bar byte. - /// `#[default(false)]` + END-appended → auto-migrates. Gateway-subscribed (`game_character`) → - /// hand-synced in `character_type.rs` + `schema_parity.rs` (the `gm_level`/`pending_ghost` precedent). #[default(false)] pub resting: bool, - /// Live-accrual clock: unix-epoch micros from which un-materialized ONLINE rested time is - /// counted; 0 = not live-accruing (offline, or not in a rest area). The `rested_accrue_pass` tick - /// grows `rested_xp` from this stamp and re-stamps it once ≥1 XP banks (lossless). `#[default(0u64)]` - /// — TYPED (a bare `0` is a 4-byte i32 → the u64-column migration rejects it, `last_logout_micros` - /// precedent) + END-appended → auto-migrates. #[default(0u64)] pub rested_since_micros: u64, - /// GM playtest GODMODE carried across an entity REBUILD: a CROSS-MAP `.tele` (and every cross-database shard hop, which rides the same - /// primitive) DESPAWNS the live entity, and `build_player_entity` rebuilds it from THIS row — so - /// before this column, `.god` was silently dropped on arrival and the GM was eaten by the local - /// wildlife with no message. `persist_entity` stamps it from the live entity on every NON-logout - /// persist (cross-map hop, shard-transfer freeze, stale-entity cleanup) and FORCES it off on a - /// real logout/disconnect (`set_offline`) — see `persisted_gm_playtest` for the policy and its - /// rationale (a map change is a loading screen; a session boundary is a deliberate reset). - /// `#[default(false)]` + END-appended so `publish` auto-migrates existing rows to "not godmode" - /// (byte-identical to before this column existed). Gateway-subscribed (`game_character` is in the - /// coordinator's subscription list) → hand-synced in `character_type.rs` + widened in - /// `gateway/tests/schema_parity.rs` (the `gm_level`/`pending_ghost` precedent). #[default(false)] pub pending_godmode: bool, #[default(10000)] pub pending_run_speed_mult_bp: u32, - /// Bank bag slots bought at a banker (0..=6). Mirrors `WorldEntity.bank_bag_slots` while online - /// exactly as `money` does: the purchase writes the live entity, `persist_entity` writes it back - /// here. `#[default(0)]` + END-appended → auto-migrates existing characters to "owns none". - /// Gateway-subscribed (`game_character`) → hand-synced in `character_type.rs` + `schema_parity.rs`. #[default(0)] pub bank_bag_slots: u8, - /// The watched reputation slot, or -1 when the Character has no selection. #[default(NO_WATCHED_FACTION)] pub watched_faction_index: i32, } diff --git a/module/src/chat.rs b/module/src/chat.rs index 666723d32..00ef7dc85 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -1,53 +1,25 @@ -//! Social tier: say, yell, `/e`, text emotes (`/dance`, `/wave`, …), contacts and `/roll`. A -//! player's `CMSG_MESSAGECHAT` or `CMSG_TEXT_EMOTE` becomes a broadcast event row that the Gateway -//! turns into `SMSG_MESSAGECHAT` / `SMSG_TEXT_EMOTE` (+ `SMSG_EMOTE` animation). Say, yell and `/e` -//! rows carry no range: the Gateway scopes them to the speaker's surroundings when it relays them. -//! Character whispers, party and channel chat are Realm Chat Lines (`crate::realm_chat`), -//! committed on Realm-core with their whole audience. Creature whispers stay on the World Shard. -//! Chat Channels themselves live in -//! `crate::channel`, and Away Status in `crate::away`. [event] - use spacetimedb::{table, Identity, ReducerContext, Table, Timestamp}; use crate::game_world_entity; use lyracore_shared::social::ContactRefusal; -// `game_character_contact` is defined further down in THIS module — its `#[table]` accessor -// (`game_character_contact`) is generated there, so it's in scope for `add_friend`/etc. without a -// `use`. - -/// `game_chat_event.chat_type` discriminants for the broadcast chat types this table carries, -/// re-exported from the shared source of truth both crates read -/// ([`lyracore_shared::chat::broadcast_chat`]). Whisper and party are not `game_chat_event` rows: -/// they ride `game_realm_chat_event`, whose per-recipient shape this broadcast table cannot hold. pub const CHAT_SAY: u8 = lyracore_shared::chat::broadcast_chat::SAY; pub const CHAT_YELL: u8 = lyracore_shared::chat::broadcast_chat::YELL; -/// Creature-authored text emote (`CHAT_TYPE_TEXT_EMOTE` on the source wire). It uses the same -/// broadcast row as Say and Yell; the gateway maps the discriminant to `CHAT_MSG_MONSTER_EMOTE`. -/// EventAI is its only source — [`apply_send_chat`] refuses it from a Character. pub const CHAT_TEXT_EMOTE: u8 = lyracore_shared::chat::broadcast_chat::CREATURE_TEXT_EMOTE; -/// A Character's `/e` custom emote. [`apply_send_chat`] is its only source and always stores it in -/// [`lyracore_shared::chat::language::UNIVERSAL`], whatever language byte the client sent -/// (cm:Player.cpp:16591-16599). pub const CHAT_EMOTE: u8 = lyracore_shared::chat::broadcast_chat::EMOTE; -/// Max stored message length — vanilla caps client input around 255; we hard-cap to bound the row. const MAX_CHAT_LEN: usize = 255; -/// A spoken line to fan out as `SMSG_MESSAGECHAT`. Public broadcast (no RLS), like `game_combat_event`; -/// the gateway relays each `on_insert`. Reaped by the shared event GC (same `id` + `created_at` shape). [event] #[table(accessor = game_chat_event, public)] pub struct ChatEvent { #[primary_key] #[auto_inc] pub id: u64, pub sender_guid: u64, - pub chat_type: u8, // CHAT_SAY / CHAT_YELL / CHAT_TEXT_EMOTE / CHAT_EMOTE - pub language: u8, // vanilla Language discriminant, echoed back to clients + pub chat_type: u8, + pub language: u8, pub message: String, pub created_at: Timestamp, - /// The addressed unit for creature-authored Say/Yell. Zero for unaddressed and player chat. - /// END-appended with a typed default so existing rows migrate safely. #[default(0u64)] pub target_guid: u64, } @@ -68,8 +40,6 @@ pub(crate) fn is_supported_chat_type(chat_type: u8) -> bool { ) } -/// Trim + length-cap a chat line, returning `None` when nothing is left (so an empty/whitespace line -/// is rejected rather than broadcast). Pure — unit-tested. [pure] pub(crate) fn normalized_message(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -78,21 +48,11 @@ pub(crate) fn normalized_message(raw: &str) -> Option { Some(trimmed.chars().take(MAX_CHAT_LEN).collect()) } -/// An addon-language payload exactly as the client sent it, length-capped like any line. Addons -/// frame their own data, so a trim would change it in transit; cmangos reads it raw and drops only -/// an empty one (cm:ChatHandler.cpp:306-312). [pure] +// Addons frame their own bytes; trimming would alter the payload in transit. pub(crate) fn addon_payload(raw: &str) -> Option { (!raw.is_empty()).then(|| raw.chars().take(MAX_CHAT_LEN).collect()) } -/// The say/yell/`/e` core, actor-explicit: everything the old sender-path `send_chat` -/// did after resolving WHO spoke, plus the player/EventAI boundary — a Character may say, yell or -/// `/e`, never submit the creature-only text emote. EMOTE is admitted ONLY here, never in -/// [`apply_send_chat_to`]: that function is EventAI's own entry, and its `chat_type` comes straight -/// off an imported `game_creature_ai_broadcast_text` row that Package import never range-checks — -/// widening ITS gate to admit EMOTE would let a malformed broadcast line masquerade as a -/// Character's `/e`, and the codec has no packet for a creature EMOTE row anyway. `gw::gw_send_chat` -/// delegates here. pub(crate) fn apply_send_chat( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -104,10 +64,6 @@ pub(crate) fn apply_send_chat( write_chat_event(ctx, sender, 0, chat_type, language, message) } -/// A Character may say, yell or `/e`, and only in a language its race knows. cmangos checks the -/// language skill before it looks at the kind (cm:ChatHandler.cpp:100-111), so `/e` passes the -/// same Gate even though it goes out in Universal. The Refusal is the `chat:*` tag the Gateway -/// answers with "You don't know that language". [pure] fn admit_player_line(chat_type: u8, race: u8, language: u8) -> Result<(), String> { use lyracore_shared::chat::chat_kind; let kind = match chat_type { @@ -121,10 +77,6 @@ fn admit_player_line(chat_type: u8, race: u8, language: u8) -> Result<(), String .map_err(|refusal| refusal.as_tag().to_string()) } -/// Creature-authored speech with its resolved addressed unit retained for the monster chat packet. -/// EventAI's only entry (`crate::creatures::eventai::relay`'s `RelayInstruction::Talk`, `engine`'s -/// `eventai_deliver_line`). The gate is `is_supported_chat_type` alone — see [`apply_send_chat`]'s -/// doc for why EMOTE must never widen it. pub(crate) fn apply_send_chat_to( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -149,10 +101,6 @@ pub(crate) fn apply_send_chat_to( write_chat_event(ctx, sender, target_guid, chat_type, language, message) } -/// The row-write both entries above share once their own type gate has passed: the dead-guard -/// (matching vmangos for `/e`, vm:ChatHandler.cpp:360-361; cmangos has no such check for `/e`, but -/// the say/yell rule already followed vmangos, so one gate covers all three types either entry can -/// reach), message normalization, and the EMOTE-always-Universal rule (cm:Player.cpp:16594). fn write_chat_event( ctx: &ReducerContext, mut sender: crate::WorldEntity, @@ -161,8 +109,6 @@ fn write_chat_event( language: u8, message: String, ) -> Result<(), String> { - // Vanilla: a dead/ghost player can't be heard via Say/Yell/`/e` (proximity chat). Whisper + - // party/guild are NOT gated by death (and aren't routed here anyway). if sender.dead { return Err("dead players cannot speak".to_string()); } @@ -173,7 +119,6 @@ fn write_chat_event( language }; if lyracore_shared::chat::broadcast_chat::is_zone_wide(chat_type) { - // Terrain can arrive after the creature import, and creatures can move between zones. sender.zone_id = crate::terrain::zone_id_at(ctx, sender.map_id, sender.x, sender.y).unwrap_or(0); sender = ctx.db.game_world_entity().guid().update(sender); @@ -190,13 +135,6 @@ fn write_chat_event( Ok(()) } -// =========================================================================================== -// Retired shard-local chat channels. Chat Channels live on Realm-core (`crate::channel`). These -// two tables stay in the schema, unwritten and unsubscribed, because dropping a table is a -// destructive migration. -// =========================================================================================== - -/// Retired: nothing writes it. [entity] #[table(accessor = game_channel_member, public, index(accessor = by_channel, btree(columns = [channel])))] pub struct ChannelMember { #[primary_key] @@ -207,7 +145,6 @@ pub struct ChannelMember { pub owner_identity: Identity, } -/// Retired: nothing writes it. [event] #[table(accessor = game_channel_event, public)] pub struct ChannelEvent { #[primary_key] @@ -220,16 +157,9 @@ pub struct ChannelEvent { pub created_at: Timestamp, } -/// A social emote to fan out — the "X dances." chat line (`SMSG_TEXT_EMOTE`) plus the animation -/// (`SMSG_EMOTE`). Public broadcast (no RLS), reaped by the shared event GC (`id` + `created_at`). -/// `emote_anim` is the client-provided `Emote` animation id; an unknown id degrades to text-only. -/// `target_guid` is the CMSG-supplied target (0 = untargeted); the gateway resolves it to a name so -/// the chat line reads "X waves at Y." instead of "X waves." `#[default(0)]` — additive END-append, -/// existing rows (untargeted, pre-migration) read back as 0 = no target. [event] #[table( accessor = game_emote_event, public, - // perf catalog 2.3: AOI-box scoping instead of a global `SELECT *`. index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] pub struct EmoteEvent { @@ -237,14 +167,11 @@ pub struct EmoteEvent { #[auto_inc] pub id: u64, pub sender_guid: u64, - pub text_emote: u32, // the TextEmote id (drives the "X dances." chat line) - pub emote_anim: u32, // the Emote animation id (observers play it; unknown → no animation) + pub text_emote: u32, + pub emote_anim: u32, pub created_at: Timestamp, #[default(0u64)] - pub target_guid: u64, // CMSG_TEXT_EMOTE's target guid; 0 = untargeted - // --- AOI columns (perf catalog 2.3), END-appended + TYPED defaults (a bare `0` on a u64 - // encodes as 4 bytes and fails the publish). Stamped from the actor via `helpers::grid_of`; - // (0,0,0,0) means "no live actor", which matches no box and is correctly never delivered. + pub target_guid: u64, #[default(0u32)] pub map_id: u32, #[default(0u64)] @@ -255,13 +182,6 @@ pub struct EmoteEvent { pub grid_y: i32, } -/// Perform a text emote (`CMSG_TEXT_EMOTE`): broadcast the social line + animation to observers. -/// `target_guid` (0 = untargeted) is the client-supplied target; the gateway resolves it to a name -/// for the "waves at " line. Authorized via `ctx.sender()` like the other player ops. The -/// `text_emote` / `emote_anim` ids come from the client; invalid ones degrade gracefully gateway-side -/// (the text line is skipped / the animation is dropped) rather than erroring. -/// -/// The text-emote core, actor-explicit, same split as [`apply_send_chat`]. pub(crate) fn apply_send_emote( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -269,12 +189,9 @@ pub(crate) fn apply_send_emote( emote_anim: u32, target_guid: u64, ) -> Result<(), String> { - // Vanilla: a dead/ghost player can't perform a (proximity) social emote. if sender.dead { return Err("dead players cannot emote".to_string()); } - // perf catalog 2.3: AOI address of the actor — `sender` is already the live entity in hand, so - // this stamps directly off it (`entity_addr`) instead of paying a redundant `grid_of` PK lookup. let (map_id, instance_id, grid_x, grid_y) = crate::helpers::entity_addr(&sender); ctx.db.game_emote_event().insert(EmoteEvent { id: 0, @@ -294,13 +211,6 @@ pub(crate) fn apply_send_emote( Ok(()) } -// =========================================================================================== -// Retired whisper table. Whispers are Realm Chat Lines (`crate::realm_chat::realm_whisper`). The -// table stays in the schema, unwritten and unsubscribed, because dropping a table is a destructive -// migration. -// =========================================================================================== - -/// Retired: nothing writes it. [event] #[table(accessor = game_whisper_event, public, index(accessor = by_recipient, btree(columns = [recipient_identity])))] pub struct WhisperEvent { #[primary_key] @@ -315,8 +225,6 @@ pub struct WhisperEvent { pub recipient_guid: u64, } -/// A private System Message for one connected Character. The Gateway addresses the row by guid and -/// rechecks the recipient before enqueueing. Reaped by the shared event GC. [event] #[table(accessor = game_system_message_event, public, index(accessor = by_recipient, btree(columns = [recipient_identity])))] pub struct SystemMessageEvent { #[primary_key] @@ -328,8 +236,6 @@ pub struct SystemMessageEvent { pub created_at: Timestamp, } -/// Emit a private System Message for one online Character on this Shard. Package code calls this -/// through `actor::system_message`, so it needs no table or protocol knowledge. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn emit_system_message( ctx: &ReducerContext, @@ -369,19 +275,9 @@ fn prepare_system_message( }) } -// =========================================================================================== -// Friends / ignore list [entity] — durable contact rows -// =========================================================================================== - -/// Vanilla client caps: the social pane won't render past 50 friends / 25 ignored names. pub const MAX_FRIENDS: usize = 50; pub const MAX_IGNORED: usize = 25; -/// One contact-list row: `owner_guid`'s friend OR ignore entry for `target_guid` (same table for -/// both — `is_ignore` picks the list, mirroring how `chat_type` picks Say vs Yell). RLS-scoped to -/// the owning character (like `game_player_skill`/`game_player_reputation`) — a connection only -/// ever sees its OWN contacts; the gateway resolves an online friend's presence/level/class/zone via -/// the privileged coordinator read (same trick `online_players` uses for `/who`). [entity] #[table(accessor = game_character_contact, public, index(accessor = by_owner, btree(columns = [owner_guid])))] pub struct ContactEntry { #[primary_key] @@ -390,13 +286,9 @@ pub struct ContactEntry { pub owner_guid: u64, pub owner_identity: Identity, pub target_guid: u64, - pub is_ignore: bool, // false = friend, true = ignore + pub is_ignore: bool, } -// Character-owned sweeps: a character's OWN contact rows are deleted with it; rows -// OTHER characters hold that point AT the deleted guid (a stale friend/ignore reference) are swept -// too — `character_guid` here plays double duty as both "owner" (the delete-marker's contract) and -// "possible target elsewhere", so the delete sweep does both passes. crate::character_owned!(delete, fn sweep_delete_game_character_contact(ctx, character_guid) { let contacts = ctx.db.game_character_contact(); for r in contacts.by_owner().filter(&character_guid).collect::>() { @@ -406,10 +298,6 @@ crate::character_owned!(delete, fn sweep_delete_game_character_contact(ctx, char contacts.id().delete(r.id); } }); -// CROSS-DATABASE transport: the character's OWN friend/ignore rows travel with it (`id` -// re-minted — surrogate PK). Rows where it is the TARGET belong to OTHER characters and stay where -// their owner is, which is why only `by_owner` is exported: a friend list is per-owner state, and -// copying someone else's row would fork it. crate::character_owned!(transfer, fn sweep_transfer_game_character_contact(ctx, character_guid, io) { table = game_character_contact, by = by_owner, @@ -425,15 +313,12 @@ crate::character_owned!(restamp, fn sweep_restamp_game_character_contact(ctx, ch } }); -/// Reducer edge for the contact list: only the tag crosses to the gateway, the detail stays here. pub(crate) fn refused_contact(refusal: ContactRefusal, detail: &str) -> String { let tag = refusal.as_tag(); spacetimedb::log::info!("contact refused {tag}: {detail}"); tag.to_string() } -/// Shared add path for `add_friend`/`add_ignore`, at the reducer edge: the core's typed Refusal -/// becomes its stable tag. pub(crate) fn add_contact( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -450,12 +335,6 @@ pub(crate) fn add_contact( }) } -/// `target_guid` is resolved by the GATEWAY (name → guid, same realm-wide lookup whisper and -/// group invite use) before the reducer is called — that resolution IS the existence Gate, so this -/// never re-checks for a durable row locally (a target on another Shard has none here). It still -/// re-validates everything server-side (never trusts the caller): self, a duplicate, a full list, -/// and — friends only — the other team. `target_race` is the Gateway-conveyed Speaker Fact for the -/// target; `None` for an ignore add, which has no faction rule. fn add_contact_core( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -487,8 +366,6 @@ fn add_contact_core( contacts.insert(ContactEntry { id: 0, owner_guid: sender.guid, - // The OWNER's own binding, not `ctx.sender()` — identical on the sender path, and correct - // on the gateway path where `ctx.sender()` is the shared connection's operator identity. owner_identity: sender.owner_identity, target_guid, is_ignore, @@ -496,9 +373,6 @@ fn add_contact_core( Ok(()) } -/// Shared remove path for `del_friend`/`del_ignore`: deletes the caller's own row for `target_guid` -/// in the given list, or [`ContactRefusal::NotOnList`] if it isn't there (idempotent double-remove -/// from the client). pub(crate) fn remove_contact( ctx: &ReducerContext, sender: crate::WorldEntity, @@ -520,21 +394,9 @@ pub(crate) fn remove_contact( Ok(()) } -// `CMSG_ADD_FRIEND` / `CMSG_DEL_FRIEND` / `CMSG_ADD_IGNORE` / `CMSG_DEL_IGNORE` all land on -// `add_contact`/`remove_contact` above via `gw::gw_add_friend`/`gw_del_friend`/`gw_add_ignore`/ -// `gw_del_ignore` (the sender-path wrappers are gone). - -// =========================================================================================== -// Random roll: the range rule and the retired roll table -// =========================================================================================== - -/// The former `/roll` result row. `/roll` is a Group Broadcast now (`group::random_roll_on`), so -/// nothing writes this table. A live table cannot be dropped, so it stays, empty, and the event -/// GC still reaps it. [event] #[table( accessor = game_roll_event, public, - // perf catalog 2.3: AOI-box scoping instead of a global `SELECT *`. index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] pub struct RollEvent { @@ -546,8 +408,6 @@ pub struct RollEvent { pub max_roll: u32, pub result: u32, pub created_at: Timestamp, - // --- AOI columns (perf catalog 2.3), END-appended + TYPED defaults (a bare `0` on a u64 - // encodes as 4 bytes and fails the publish). #[default(0u32)] pub map_id: u32, #[default(0u64)] @@ -558,11 +418,7 @@ pub struct RollEvent { pub grid_y: i32, } -/// Normalize a client `/roll` range to the sent `(lo, hi)`: swap an inverted pair (`min > max`) -/// and cap BOTH ends at 10 000, the vanilla client ceiling. `lo <= hi` always holds on the way -/// out. Pure. pub(crate) fn normalized_roll_range(min_roll: u32, max_roll: u32) -> (u32, u32) { - // Vanilla caps roll at 10 000; invert if client sends min > max. const ROLL_MAX: u32 = 10_000; let lo = min_roll.min(max_roll).min(ROLL_MAX); let hi = min_roll.max(max_roll).min(ROLL_MAX); @@ -578,13 +434,10 @@ mod tests { assert!(is_supported_chat_type(CHAT_SAY)); assert!(is_supported_chat_type(CHAT_YELL)); assert!(is_supported_chat_type(CHAT_TEXT_EMOTE)); - // EMOTE is player-only (`apply_send_chat` accepts it directly); `is_supported_chat_type` - // serves EventAI, which never emits it. assert!(!is_supported_chat_type(CHAT_EMOTE)); - assert!(!is_supported_chat_type(255)); // party/guild/whisper/etc. rejected + assert!(!is_supported_chat_type(255)); } - /// Wire language values from gtker vanilla `language.rs`: Universal 0, Orcish 1, Common 7. #[test] fn a_player_line_needs_a_language_the_speakers_race_knows() { const HUMAN: u8 = 1; @@ -601,7 +454,6 @@ mod tests { } } - /// The creature-only text emote is EventAI's; a Character never submits it. #[test] fn a_player_cannot_submit_the_creature_text_emote() { assert_eq!( @@ -693,20 +545,15 @@ mod tests { #[test] fn roll_range_swaps_an_inverted_pair_and_caps_both_ends_at_10000() { - // The default /roll is passed through untouched. assert_eq!(normalized_roll_range(1, 100), (1, 100)); - // An inverted pair (min > max) swaps rather than erroring or underflowing the range width. assert_eq!(normalized_roll_range(100, 1), (1, 100)); - // A degenerate single-value range survives (the draw is then that value). assert_eq!(normalized_roll_range(5, 5), (5, 5)); assert_eq!(normalized_roll_range(0, 0), (0, 0)); - // The 10 000 client ceiling caps the high end alone, or both ends of a huge range. assert_eq!(normalized_roll_range(1, 2_000_000), (1, 10_000)); assert_eq!( normalized_roll_range(3_000_000, 2_000_000), (10_000, 10_000) ); - // Exactly at the ceiling is legal, not clipped below it. assert_eq!(normalized_roll_range(1, 10_000), (1, 10_000)); } } diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index de090ca0f..797d99237 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -1,13 +1,3 @@ -//! Death + the shared damage pipeline (split of the former monolithic `combat/mod.rs`, on top of -//! the shared damage pipeline). `kill_creature`/`kill_player` are the two chokepoints every lethal -//! path funnels through so a melee kill, `debug_set_health(0)`, and a lethal DoT tick all produce an -//! IDENTICAL corpse/release. `fold_incoming_damage` (the MODIFIER stage: outgoing % → incoming % → -//! absorb → godmode), `final_damage` (the EventAI lethal floor), and `apply_hit` (the APPLICATION -//! stage: rage + skill-ups, the lethal fork through the two kill chokepoints, the health write, -//! break-on-damage, threat) are the pipeline every damage resolver in `swing.rs` and -//! `spell::apply_target_damage` routes through. See the banner below. -//! `mod.rs` names the operations shared with other Module files. - use spacetimedb::{table, ReducerContext, Table, TimeDuration}; #[cfg(feature = "debug_reducers")] @@ -20,24 +10,12 @@ use crate::{ #[cfg(feature = "debug_reducers")] use crate::{game_corpse_loot, game_spell_cast_event}; -// The corpse-lifecycle timers below are used by `kill_creature`'s decay-arm step; `RESPAWN_MICROS` has -// no local reader (the flat-timer fallback lives in `creatures::tick`) but stays here beside -// `CORPSE_DECAY_MICROS` since both are read externally as `crate::combat::{RESPAWN_MICROS, -// CORPSE_DECAY_MICROS}` and describe the same corpse/respawn timeline. -pub(crate) const RESPAWN_MICROS: i64 = 15_000_000; // 15s after the corpse despawns -pub(crate) const CORPSE_DECAY_MICROS: i64 = 60_000_000; // a corpse lingers 60s before it despawns +pub(crate) const RESPAWN_MICROS: i64 = 15_000_000; +pub(crate) const CORPSE_DECAY_MICROS: i64 = 60_000_000; -// Warlock soul-shard generation: Drain Soul's real vanilla script effect (`ChannelDeathItem`) mints a -// Soul Shard when its channel's target dies mid-cast. Our Drain Soul import carries no Rust script hook -// for that effect, so the grant lives here instead, at the ONE place every creature death already -// funnels through. `DRAIN_SOUL_SPELL_ID` is the real vanilla spell id (1120); `SOUL_SHARD_ENTRY` is the -// real vanilla item id (6265, hand-seeded in `seed.rs` since the.import ETL doesn't reliably carry it). pub(crate) const DRAIN_SOUL_SPELL_ID: u32 = 1120; pub(crate) const SOUL_SHARD_ENTRY: u32 = 6265; -/// A creature's EventAI-owned lethal floor. Row presence means enabled. It survives engagement and -/// evade resets, then clears on definition replacement, death, despawn, or respawn. Enabling it -/// again replaces the row and rearms the first-prevention notification. #[table(accessor = game_creature_lethal_damage_floor)] pub struct CreatureLethalDamageFloor { #[primary_key] @@ -156,9 +134,6 @@ pub(crate) fn final_damage( ) } -/// True when a live Lethal Damage Floor protects this creature, reaping the row when its defining -/// revision has moved on. Exposed on its own for the periodic-damage fold, which folds several -/// ticks against an in-transaction health that [`final_damage`] cannot see. pub(crate) fn lethal_floor_protects(ctx: &ReducerContext, target_guid: u64) -> bool { let Some(target) = ctx.db.game_world_entity().guid().find(target_guid) else { return false; @@ -498,16 +473,8 @@ pub fn debug_verify_lethal_damage_floor_fixture( Ok(()) } -// Shared combat operations and formulas are named in the parent module. use super::*; -/// Kill a CREATURE — the SHARED creature-death path. The melee kill, `debug_set_health(0)`, AND lethal -/// spell/DoT damage all funnel through here so they produce an IDENTICAL corpse (no drift between the -/// paths). Steps, in order: resolve the Loot Tag and grant its recipients' rewards; roll tagged -/// money and item loot; set `health = 0` + `dead` (the sole on-wire corpse signal, never -/// `UNIT_DYNFLAG_DEAD`, which is feign-death); drop the engagement; stop corpse movement; and arm -/// corpse decay. The killing source remains separate from reward ownership. Returns `true` if it -/// killed a live creature and `false` for a missing guid, a player, or an already-dead unit. [entity] pub(crate) fn kill_creature(ctx: &ReducerContext, target_guid: u64, killer: Option) -> bool { kill_creature_with_attribution(ctx, target_guid, CreatureDeathAttribution::credited(killer)) } @@ -524,13 +491,9 @@ fn kill_creature_with_attribution( return false; }; if target.is_player() || target.dead { - return false; // creatures only, and never re-kill a corpse + return false; } clear_lethal_damage_floor(ctx, target_guid); - // A PET death (owner_guid != 0) is a CLEAN DESPAWN: no XP/loot/corpse/decay. A pet has NO - // game_creature_spawn row, so the decay pass can never reap a dead pet — without this it would linger as - // a stale, lootable corpse until the owner re-summons/logs out/dies. Delete it + free its engagements; - // the owner can re-summon. (A pet kill credits no XP — an enemy killing your Imp gains nothing.) if target.owner_guid != 0 { crate::creatures::begin_death_dispatch(ctx, target_guid, killer); crate::creatures::on_pet_death(ctx, target_guid); @@ -538,7 +501,6 @@ fn kill_creature_with_attribution( entities.guid().delete(target_guid); crate::creatures::clear_live_pet_kind(ctx, target_guid); crate::creatures::distraction::clear(ctx, target_guid); - // Notify-hook: a pet death is still a death — fired after the despawn is committed. crate::hooks::fire_on_death( ctx, &crate::hooks::DeathPayload { @@ -551,25 +513,12 @@ fn kill_creature_with_attribution( crate::loot::tag::clear(ctx, target_guid); return true; } - // A creature killed mid-leg (flee, patrol, chase) still has an in-flight spline row. Left alone, - // the client keeps sliding the corpse toward the old destination. Stop it where the client - // renders it at the kill, before anything reads the corpse position, so the Loot Tag range, - // the rewards and loot-click range all judge the point the client shows. - // - // The stop row is not deleted here. SpacetimeDB relays a transaction's net effect per row, so - // an update and a delete of the same row reach subscribers as a bare delete, and the Gateway - // relays nothing for a deleted spline row. Spline deletion also stays in - // `despawn_creature_entity`, the canonical despawn checklist. The next advance firing reads the - // zero-duration stop as arrived and forgets it. crate::creatures::tick::stop_where_rendered(ctx, &mut target); - // Snapshot the victim's identity for the notify-hooks fired at the end of this fn — the corpse row - // is mutated (and `target` moved into the update) before they fire. let victim_entry = target.entry; let victim_level = target.level; let victim_instance = target.instance_id; let current_target_guid = target.target_guid; - // The Loot Tag, not the killing blow, owns rewards and corpse eligibility. Resolve it while the - // creature is still live. Death dispatch defers its combat-end clear until every hook runs. + // Resolve the Loot Tag while live; death hooks still need its reward ceiling. let entitlement = crate::loot::tag::death_entitlement( ctx, target_guid, @@ -588,9 +537,7 @@ fn kill_creature_with_attribution( target.dead = true; entities.guid().update(target); crate::creatures::begin_death_dispatch(ctx, target_guid, killer); - // Snapshot the victim's threat table for the death payload BEFORE disengage wipes it — threat - // is the engine's per-player damage/heal ledger, and hooks fire only after the wipe (see - // CreatureDeathPayload.threat_snapshot). + // Snapshot threat before disengage clears the ledger used by death hooks. let threat_snapshot: Vec<(u64, i64)> = ctx .db .game_threat() @@ -599,18 +546,9 @@ fn kill_creature_with_attribution( .map(|t| (t.source_guid, t.threat)) .collect(); disengage(ctx, target_guid); - // A dead creature's taunt lock is meaningless — drop it with the engagement. threat.rs owns the - // lock table's whole lifecycle; this is its teardown verb. crate::threat::clear_taunt_lock(ctx, target_guid); - // A creature that dies mid-channel (a caster mob) stops channeling immediately — tear down its channel - // aura + any cast bar. No-op for the common non-caster kill (no channel/cast). A channel aimed AT this - // dying creature is ended separately by the tick arm (`!target_alive`), so both directions are covered. crate::spell::break_channel(ctx, target_guid); - // A corpse carries no buffs, no DoTs and no crowd control — vanilla sheds them on the death - // transition itself, for every unit and not just players. Runs AFTER the reward/loot steps above, - // which still read the victim's live aura set (Drain Soul's channel). crate::spell::remove_auras_on_death(ctx, target_guid); - // Arm the corpse-decay timer on the spawn row (the tick_creatures decay pass gates on it). let spawns = ctx.db.game_creature_spawn(); if let Some(mut spawn) = spawns.guid().find(target_guid) { spawn.despawn_at = ctx @@ -619,13 +557,6 @@ fn kill_creature_with_attribution( .unwrap_or(ctx.timestamp); spawns.guid().update(spawn); } - // VANILLA combat-drop on kill: leave combat when the mob dies UNLESS something else is still engaged. - // Vanilla drops you the moment the last hostile link is gone rather than waiting the flat - // COMBAT_DROP_MS. `disengage` above cleared this mob's rows, so if the killer now has NO melee - // engagement — neither attacking (its own outgoing row) nor being attacked (any row targeting it) — - // pull its combat deadline to NOW so the next combat-drop tick clears IN_COMBAT (~0.5s, not 6s). A - // killer still meleeing another mob, or with another mob on it, keeps its combat. (Applies to any - // killer; the player is the case that matters.) if let Some(killer_guid) = killer { if !is_engaged(ctx, killer_guid) { if let Some(mut k) = entities.guid().find(killer_guid) { @@ -634,9 +565,7 @@ fn kill_creature_with_attribution( } } } - // Notify-hooks fire last so handlers observe the fully committed death. Victim and encounter - // callbacks retain the real killing source. The reward-oriented kill hook runs once for each - // entitled recipient. + // Run hooks after death state is settled so handlers observe the complete transition. crate::hooks::fire_on_death( ctx, &crate::hooks::DeathPayload { @@ -645,9 +574,6 @@ fn kill_creature_with_attribution( victim_is_player: false, }, ); - // Encounter kernel: the entry-keyed, instance-stamped creature death, the - // pet clean-despawn branch above deliberately does NOT fire this (encounters key on wild - // creatures). Snapshots were taken before the corpse row was mutated. crate::hooks::fire_on_creature_death( ctx, &crate::hooks::CreatureDeathPayload { @@ -671,15 +597,11 @@ fn kill_creature_with_attribution( ); } crate::creatures::finish_death_dispatch(ctx, target_guid, killer); - // EventAI OnDeath quest actions resolve the same Loot Tag as death rewards. Clear it only after - // every synchronous death callback has observed it; ordinary live disengage still clears now. + // Clear the Loot Tag only after every synchronous death callback has observed it. crate::loot::tag::clear(ctx, target_guid); true } -/// Grant kill rewards to the entitled Loot Tag recipients. `reward_source` retains the controlled -/// Character behind the killing source for source-specific rewards such as Drain Soul, but it can -/// receive one only when it is also in the entitled recipient set. fn award_tag_rewards( ctx: &ReducerContext, target: &WorldEntity, @@ -687,8 +609,6 @@ fn award_tag_rewards( recipients: &[u64], reward_source: Option, ) { - // Elite/rare-elite/boss creatures pay 2× kill XP (vanilla) — the rank + creature_type live on - // the template (one lookup, reused below for the soul-shard XP/critter gate). let template = ctx.db.game_creature_template().entry().find(target.entry); let rank = template.as_ref().map(|t| t.rank).unwrap_or(0); let share_count = recipients.len() as u32; @@ -704,17 +624,6 @@ fn award_tag_rewards( crate::creatures::award_hunter_pet_kill_progression(ctx, *recipient, target.level, rank); crate::quest::on_creature_killed(ctx, *recipient, target.entry); } - // Soul shard generation: if the entitled killing source is channeling Drain Soul (1120) on this dying - // creature — an aura targeting it, cast BY the killer, naming that spell — the killing blow - // mints 1x Soul Shard into the killer's backpack. Real vanilla grants - // this off Drain Soul's own `ChannelDeathItem` script effect on a lethal tick; we hook it here - // instead since that effect imports as an inert E_SCRIPTED no-op (see combat/mod.rs module docs - // above). Vanilla's actual rule is "the kill yields experience or honor" — no honor system - // exists yet, so that reduces to: NOT a critter (creature_type 8, template-driven, never yields - // XP regardless of level) AND NOT grey (`xp::xp_for_kill` > 0, the SAME grey clamp award_xp - // used two lines up — shard eligibility can never drift from XP eligibility). Best-effort: an - // inventory-full killer still gets the kill (`grant_item`'s Err is swallowed, never rolls back - // the death). let is_critter = template .as_ref() .map(|t| t.creature_type == crate::spell::CRITTER_TYPE) @@ -740,30 +649,12 @@ fn award_tag_rewards( } } -/// Roll a dead creature's money + item loot onto its corpse, apply the GROUP loot method's -/// need/greed/round-robin/master-loot stamping, and mark it LOOTABLE if anything dropped. Also purges -/// any stale corpse-loot residue on this guid BEFORE rolling fresh drops — the fix MUST -/// run on every kill so a leftover pickpocket row can't collide with a freshly-rolled kill-drop slot. -/// Extracted out of `kill_creature`'s -/// inline body so the death sequence reads as a table of contents. fn roll_corpse_loot( ctx: &ReducerContext, target: &mut WorldEntity, target_guid: u64, entitlement: Option<&crate::loot::tag::DeathEntitlement>, ) { - // STALE-SNAPSHOT PURGE: corpse guids can be REUSED — - // `debug_spawn_at_feet` allocates max+1 per entry, and a harness SQL teardown deletes the entity - // WITHOUT running the decay reaper — so a fresh kill on a reused guid inherited a long-dead - // group's `game_corpse_loot_eligible` rows and split the solo looter's purse with departed - // members (46c purse → 13c, caught by the quest scenario's money delta). A LIVE creature also - // accrues its OWN residue on this same guid: `roll_pickpocket_loot` (E_PICKPOCKET) inserts - // `game_corpse_loot` rows at slots 0.. before it dies, and if the rogue doesn't take them all, - // this fn's `roll_creature_loot` below would otherwise re-insert kill drops starting at slot 0 - // too — duplicate `(corpse_guid, slot)` pairs break every first-match consumer's addressing. - // `purge_corpse_residue` closes both classes at the ONE spot every creature death routes - // through, BEFORE the fresh loot roll / group snapshot below. No-op for a guid with no residue - // (the common path). crate::loot::purge_corpse_residue(ctx, target_guid); let Some(entitlement) = entitlement else { target.money = 0; @@ -771,8 +662,6 @@ fn roll_corpse_loot( return; }; crate::loot::tag::record_corpse_eligibility(ctx, target_guid, &entitlement.recipients); - // Money loot from the template plus data-driven item loot. A tagged creature may roll a corpse - // even when every snapshot member fails the death-site eligibility checks. let loot = ctx .db .game_creature_template() @@ -788,14 +677,7 @@ fn roll_corpse_loot( if loot > 0 { target.money = loot; } - // A valid Loot Tag rolls quest-only rows with ordinary corpse loot. Visibility and takability - // remain per-viewer decisions downstream, so `roll_creature_loot` needs no killing source. let dropped = crate::loot::roll_creature_loot(ctx, target.entry, target_guid); - // Group loot methods: a GROUPED kill's above-threshold rows may - // spawn a need/greed roll, below-threshold/round-robin rows get a designated looter stamped, and - // above-threshold-under-MASTER rows get restricted to the master — all decided HERE at kill - // time (see loot.rs's module doc for why not lazily "at loot-open"). A no-op for an ungrouped - // kill or an FFA-method group (`apply_group_loot_rules`'s own early-outs). if let Some(group_id) = entitlement.group_id { crate::loot::apply_group_loot_rules(ctx, target_guid, group_id); } @@ -804,21 +686,6 @@ fn roll_corpse_loot( } } -/// Kill a PLAYER — the SHARED player-death path, `kill_creature`'s twin: both swing resolvers and -/// `debug_set_health(0)` funnel through here so every player death is IDENTICAL (channel teardown, -/// combat-deadline zeroing, and everything below all happen exactly once, the same way). `health = 0` -/// is the SOLE on-wire death signal — vanilla 1.12 has no death opcode; the client shows Release Spirit -/// purely from UNIT_FIELD_HEALTH == 0 on its own unit (via the crash-safe build_health_values relay). No -/// corpse/loot/XP (creatures don't loot players); the entity persists (the decay pass only touches -/// creature spawns) until `CMSG_REPOP_REQUEST` → the `repop` reducer revives it. Steps, in order: -/// zero health + set `dead` (gates regen + re-attack); pull `combat_until_ms` to NOW so the -/// gateway's logout gate doesn't block a dead player for up to COMBAT_DROP_MS (vanilla allows dead -/// players to log out immediately; `disengage` clears melee rows but does NOT zero the deadline); -/// drop the engagements; tear down any channel/cast bar; shed the auras death removes (which dismounts -/// a rider); apply the 10% equipped-durability loss; -/// and fire the `on_death` notify-hook LAST so handlers observe the fully-committed death. -/// `killer_guid = 0` = no killer (a debug/environmental death). Returns `false` (no-op) for a -/// missing guid, a creature, or an already-dead player. [entity] pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u64) -> bool { let entities = ctx.db.game_world_entity(); let Some(mut victim) = entities.guid().find(victim_guid) else { @@ -836,13 +703,8 @@ pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u crate::creatures::begin_death_dispatch(ctx, victim_guid, killer); disengage(ctx, victim_guid); crate::spell::break_channel(ctx, victim_guid); - // Dying sheds the auras vanilla does not exempt, and converges every projection they fed. A rider - // who dies is dismounted HERE, by ordinary aura removal — the corpse must not keep its mount, its - // buffs, or the crowd control that was on it. The Self-Resurrection Option is read first: the - // Soulstone aura that grants it does not survive the shed. crate::spell::record_self_resurrect_option(ctx, victim_guid); crate::spell::remove_auras_on_death(ctx, victim_guid); - // A live Trade Session dies with the victim — both windows hear `TradeCanceled`. crate::trade::cancel_trade_for(ctx, victim_guid); crate::items::apply_death_durability_loss(ctx, victim_guid); crate::hooks::fire_on_death( @@ -857,63 +719,22 @@ pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u true } -// =========================================================================================== -// The SHARED damage pipeline [entity] -// - -// -// 1. `fold_incoming_damage` — the MODIFIER stage: outgoing % → incoming % → absorb → godmode. -// Every caller that rolls a fresh number runs it; the ranged IMPACT skips it because its -// damage was already folded (and frozen) at launch, and applies only the godmode re-check. -// 2. `final_damage`: the EventAI lethal floor, after every modifier and before any damage effect. -// 3. `apply_hit`: the APPLICATION stage: rage + skill-ups, the lethal fork through the shared -// `kill_player`/`kill_creature` chokepoints, the health write, break-on-damage, and threat. -// -// Each caller writes its own wire event between the folds and application, so the stages remain -// separate operations. -// =========================================================================================== - -/// Where a hit came from. [`HitSource::is_weapon`] is the ONE axis [`apply_hit`] branches on, so -/// "what is different about a spell hit" is a single readable predicate instead of a second copy of -/// the whole pipeline; the finer split exists because the Proc engine fires a DIFFERENT combat event -/// per source (an off-hand swing and a ranged shot are not the same event as a main-hand swing). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum HitSource { - /// A main-hand melee auto-attack swing. MainHand, - /// An off-hand melee auto-attack swing — its own proc event, so an off-hand-only proc fires off - /// these alone. OffHand, - /// A ranged auto-attack impact (Auto Shot, a wand). Ranged, - /// A melee ABILITY: a weapon strike or a finisher. Its own proc event pair, distinct from the - /// auto-attack swing that shares its weapon. MeleeSpell, - /// A direct damaging SPELL hit. Spell, - /// A hit produced inside a **Triggered Cast**. Grants nothing, and raises NO proc event — which is - /// what makes "a proc can never start a proc" structural instead of an exception list. Triggered, } impl HitSource { - /// A WEAPON hit — a melee main-hand swing, an off-hand swing, or a ranged projectile impact. - /// Grants rage BOTH ways, trains weapon/defense skill, KILLS a player at 0 hp, credits a PET's - /// kill to its owner, and feeds the real attacker guid to the break-on-damage scan (so - /// Retaliation can counter-swing). - /// - /// Everything else — a spell, a melee ability, a Triggered Cast — grants no rage and no - /// skill-ups; a PLAYER is FLOORED at 1 hp instead of dying (there is no spell-death of players - /// yet); the kill credit is player-caster-only (a pet's spell doesn't credit its owner — pets - /// have no spells today); the break-on-damage scan gets the `0` attacker sentinel; and a player - /// caster's damaging hit ARMS the creature's melee retaliation. pub(crate) fn is_weapon(self) -> bool { matches!(self, Self::MainHand | Self::OffHand | Self::Ranged) } } -/// One landed hit, as the pipeline and the Proc engine both read it: where it came from, which spell -/// carried it (0 for an auto-attack swing), and whether it crit. #[derive(Clone, Copy)] pub(crate) struct Hit { pub source: HitSource, @@ -922,7 +743,6 @@ pub(crate) struct Hit { } impl Hit { - /// An auto-attack hit — no spell carried it. pub(crate) fn weapon(source: HitSource, crit: bool) -> Self { Self { source, @@ -931,7 +751,6 @@ impl Hit { } } - /// A hit carried by `spell_id`, whose school and family the Proc filter may judge. pub(crate) fn spell(source: HitSource, spell_id: u32, crit: bool) -> Self { Self { source, @@ -940,8 +759,6 @@ impl Hit { } } - /// A hit nobody took an action to land: a Retaliation counter-swing, a ground-area tick. Grants - /// nothing and raises no proc event. pub(crate) fn triggered() -> Self { Self { source: HitSource::Triggered, @@ -951,15 +768,8 @@ impl Hit { } } -/// What [`apply_hit`] did. `killed` is what a caller checks to decide whether its own post-hit tail (a -/// swing-timer stamp, an IN_COMBAT stamp, a react window) still has anything to stamp — after a kill -/// the engagement row is already gone and the defender is a corpse. A struct rather than a bare bool -/// so the pipeline can report more (resisted portions, overkill) without touching four signatures. pub(crate) struct HitOutcome { - /// The hit went through `kill_creature` / `kill_player`. False for a spell hit that floored a - /// player at 1 hp, and false for any hit the target survived. pub killed: bool, - /// The hit ended an active Duel at one health. Callers must not re-arm combat afterward. pub duel_completed: bool, } @@ -969,30 +779,11 @@ impl HitOutcome { } } -/// Pure: does a hit of `dmg` reduce a target at `health` to 0? A 0-damage hit (a miss, a fully -/// absorbed swing, a godmode target) is NEVER lethal, which is the whole reason this is one shared -/// predicate — the melee swing needs the answer up front for its event's `killing_blow` flag, and -/// [`apply_hit`] needs it again for the fork. [pure] pub(crate) fn is_lethal(health: u32, dmg: u32) -> bool { dmg > 0 && health <= dmg } -/// Stage 1 of the shared pipeline: fold the post-roll damage MODIFIERS onto a freshly-rolled hit and -/// return `(dealt, absorbed)`. -/// -/// In order (the order is vanilla's and is load-bearing): -/// 1. the ATTACKER's outgoing % — Defensive Stance's −10% (`stance_damage_done_pct`) summed with any -/// `A_MOD_COMBAT(COMBAT_DMG_DONE)` aura on it (Curse of Weakness); -/// 2. the TARGET's incoming % — `A_MOD_DAMAGE_TAKEN` (Shield Wall, a vulnerability debuff); -/// 3. absorb shields (`A_ABSORB`, e.g. Power Word: Shield) — BEFORE the lethal check, so a -/// fully-absorbed hit can't kill and drains the shield instead of health; -/// 4. the GM playtest godmode zero (the `.god` command) — keyed on the TARGET only, so a godmode unit's -/// OUTGOING damage is untouched. Applied AFTER absorb, so a godmode target's shield still drains -/// exactly as it did before (and the zeroed remainder reads as "absorbed"). -/// -/// `absorbed` is the difference — what shields (plus godmode) soaked — which the spell path surfaces -/// on its cast-GO row for the floating damage breakdown. Weapon callers ignore it. A missing attacker -/// contributes stance 0 / bonus 0 (full damage), the codebase's "missing actor stat → 0" convention. +// Absorb before lethal checks and godmode so even a zeroed hit drains shields. pub(crate) fn fold_incoming_damage( ctx: &ReducerContext, attacker_guid: u64, @@ -1022,37 +813,6 @@ pub(crate) fn fold_incoming_damage( (dealt, incoming - dealt) } -/// Stage 3 of the shared pipeline. Applies the [`FinalDamage`] produced after -/// [`fold_incoming_damage`] to `target_guid`, credited to `attacker_guid`, and returns what it did. -/// -/// In order: -/// 1. **Attacker gains** (weapon hits only) — rage for dealing damage, and a weapon skill-up when a -/// PLAYER lands on a CREATURE. Both run BEFORE the fork: you gain rage on the killing blow too. -/// 2. **The lethal fork** — a target reduced to 0 goes through the SHARED `kill_player` / -/// `kill_creature` chokepoints (corpse, loot, XP, disengage, hooks) and this returns -/// `killed: true`. The one exception is a SPELL hit on a PLAYER, which floors at 1 hp instead. -/// Death attribution names the attacking unit. Loot Tag ownership separately resolves the -/// entitled Character recipients, including a pet's owner. -/// 3. **The survivor path** — a defense skill-up for a player that took a weapon hit and lived, the -/// health write (floored at 1 by `damaged_value`, which is a no-op here for a weapon hit since a -/// survivor by definition had `health > dmg`), rage for TAKING the hit, then the single entity -/// write. `enter_combat` for a spell hit lands here (a weapon caller stamps IN_COMBAT at its own -/// tail instead — a swing that FIRED flags combat even when it MISSED, which is a fact only the -/// caller can see). -/// 4. **Break-on-damage** (drops polymorph, interrupts a timed cast) and **threat** (player → -/// creature only), plus the spell path's engage-on-damage retaliation arm. -/// 5. **The proc pass** ([`crate::spell::proc::run_proc_pass`]) — once per lethal branch, and from -/// nowhere else in the module. The attacker's "dealt" Procs run even on a killing blow; the -/// target's "taken" Procs run only on the survivor path. -/// -/// A 0-damage hit (miss, fully absorbed, godmode) is a complete no-op: no health write, no kill, no -/// break-on-damage, no threat — matching what the melee swing and `apply_target_damage` have always -/// done, and what the ranged impact now does too (before it ran the survivor path with a 0 -/// damage value on a godmode target — the same drift class fixed twice). -/// -/// The one 0-damage hit that is NOT inert is a hit a Lethal Damage Floor reduced to nothing: it -/// still refreshes the pursuit leash and fires the death-prevented hook once, because the creature -/// is pinned at one health in a live fight rather than untouched. pub(crate) fn apply_hit( ctx: &ReducerContext, attacker_guid: u64, @@ -1066,16 +826,13 @@ pub(crate) fn apply_hit( }; let entities = ctx.db.game_world_entity(); let Some(mut target) = entities.guid().find(target_guid) else { - return miss; // the target left the world between the roll and here + return miss; }; if target.dead || (damage.amount == 0 && !damage.lethal_prevented) { return miss; } let dmg = damage.amount; let weapon = hit.source.is_weapon(); - // Read the attacker ONCE, fresh: a caller's in-hand snapshot may predate its own writes this tick - // (the queued-strike rage deduction, an earlier engagement's swing), and the spell path may have - // no attacker row at all (an environmental / departed caster → not a player, no threat). let attacker = entities.guid().find(attacker_guid); if attacker .as_ref() @@ -1087,9 +844,7 @@ pub(crate) fn apply_hit( let reward_source = crate::loot::tag::controlling_character(ctx, attacker_guid); let target_is_player = target.is_player(); if damage.lethal_prevented && dmg == 0 { - // Pinned at one health and still under attack. No damage lands, so nothing damage-derived - // (threat, break-on-damage, procs) applies — but the fight is real, so the pursuit deadline - // must keep moving or the leash pass evades the creature out of the script holding it here. + // A lethal floor still refreshes pursuit so the scripted fight cannot evade. refresh_leash(ctx, attacker_guid, target_guid); commit_death_prevention(ctx, target_guid, attacker_guid); return miss; @@ -1098,27 +853,20 @@ pub(crate) fn apply_hit( crate::loot::tag::record_first_threat(ctx, target_guid, attacker_guid); } - // 1. Attacker-side gains, before the fork (rage lands on the killing blow too). if weapon { if let Some(a) = &attacker { if is_rage_user(a) { - // Fresh mutable copy (read-your-writes): `a` is still needed read-only below. if let Some(mut fresh) = entities.guid().find(attacker_guid) { fresh.power = (fresh.power + rage_from_damage(dmg, true)).min(fresh.max_power); entities.guid().update(fresh); } } - // Weapon skill-up: a PLAYER landing a damaging hit on a CREATURE trains the equipped - // weapon's skill line one step toward cap. Touches only `game_player_skill` rows, so it is - // independent of the rage write above. A no-op at cap (the normal case). if attacker_is_player && !target_is_player { crate::skill::gain_weapon_skill(ctx, a); } } } - // 2. The lethal fork. An active opponent Duel floors its loser at one health and completes - // before any player-death work can run, regardless of whether the damage was weapon or spell. if target_is_player && is_lethal(target.health, dmg) && crate::duel::active_opponents(ctx, attacker_guid, target_guid) @@ -1134,15 +882,12 @@ pub(crate) fn apply_hit( target_guid, ); } - // The duel finisher dealt real damage to a target that survived it, so both sides' Procs fire, - // exactly as they would on any other surviving hit. crate::spell::proc::run_proc_pass(ctx, attacker_guid, target_guid, &hit, true); return HitOutcome { killed: false, duel_completed: true, }; } - // A spell hit on a PLAYER is the one non-fatal ordinary 0-hp case (floored at 1 below). let killed = is_lethal(target.health, dmg) && (weapon || !target_is_player); if killed { if target_is_player { @@ -1152,27 +897,19 @@ pub(crate) fn apply_hit( ctx, target_guid, CreatureDeathAttribution { - // Death callbacks keep the actual unit that landed the blow. A missing caster - // remains unattributed rather than naming an entity that has left the world. source_guid: attacker.as_ref().map(|_| attacker_guid), reward_guid: reward_source, }, ); } - // A killing blow still fires the ATTACKER's "dealt" Procs (a Lightning Shield zap that lands - // the kill still counts); the corpse's own "taken" Procs do not. crate::spell::proc::run_proc_pass(ctx, attacker_guid, target_guid, &hit, false); return HitOutcome { killed: true, duel_completed: false, }; } - // 3. The survivor path. - // Capture the threat predicate before `target` is moved by the write below. let accrues_threat = !target_is_player && reward_source.is_some(); if weapon && target_is_player { - // Defense skill-up: a PLAYER that takes a damaging weapon hit and SURVIVES trains Defense one - // step toward cap. Read-only on `target`; a no-op at cap (the normal case). crate::skill::gain_defense_skill(ctx, &target); } target.health = crate::spell::damaged_value(target.health, dmg as i32); @@ -1183,28 +920,17 @@ pub(crate) fn apply_hit( if damage.lethal_prevented { commit_death_prevention(ctx, target_guid, attacker_guid); } - // Damage kept the fight alive: restart the pursuit deadline and re-remember where the creature - // stands. Both guids go in, so a creature hitting the player refreshes the leash just like the - // player hitting it. The lethal branch above already returned — a dead pair has no engagement left. refresh_leash(ctx, attacker_guid, target_guid); if !weapon { - // The surviving spell target is in combat too. Weapon callers stamp both sides at their own - // tail (a swing that fired flags combat even on a miss), so this would be redundant for them. enter_combat(ctx, target_guid); } - // 4. Break-on-damage drops the target's break-on-damage CC (polymorph etc.) and interrupts its - // in-progress timed cast (direct damage → `periodic = false`). The attacker guid rides along for a - // WEAPON hit so Retaliation can counter-swing the assailant; everything else passes the `0` - // sentinel (a counter-swing answers a real incoming swing only). crate::spell::break_auras_on_damage( ctx, target_guid, if weapon { attacker_guid } else { 0 }, false, ); - // Threat accrues on the creature's table so the retarget pass targets the highest-threat attacker. - // Player → creature only; the lethal branch already returned (a killing blow wiped the table). if accrues_threat { crate::threat::add_threat( ctx, @@ -1213,14 +939,9 @@ pub(crate) fn apply_hit( crate::threat::threat_from_damage(dmg), ); if !weapon { - // ENGAGE-ON-DAMAGE: a player's spell hit pulls the creature into melee (the ranged twin of - // `aggro_pass`; the melee paths already have their engagement row by construction). - // Idempotent — shared with the cast-level engage in `resolve_cast_at`. crate::spell::arm_spell_retaliation(ctx, attacker_guid, target_guid); } } - // 5. The proc pass. Both sides fire here: the attacker's "dealt" Procs and — because the target - // lived — its "taken" Procs too (Frost Armor chills whoever hit its wearer). crate::spell::proc::run_proc_pass(ctx, attacker_guid, target_guid, &hit, true); HitOutcome { killed: false, @@ -1232,22 +953,17 @@ pub(crate) fn apply_hit( mod lethality_tests { use super::{is_lethal, lethal_floor_amount, CreatureDeathAttribution, HitOutcome}; - // The one shared lethality predicate: the melee swing needs it up front for its wire event's - // `killing_blow` flag and `apply_hit` needs it again for the fork, so they must not be two - // hand-written expressions. #[test] fn a_zero_damage_hit_is_never_lethal() { - // A miss, a fully-absorbed swing, and a godmode target all arrive here as 0 damage. None of - // them may kill — not even a target already sitting at 0 hp. assert!(!is_lethal(1, 0)); assert!(!is_lethal(0, 0)); } #[test] fn a_hit_at_or_above_remaining_health_is_lethal() { - assert!(is_lethal(10, 10)); // exactly lethal - assert!(is_lethal(10, 11)); // overkill - assert!(!is_lethal(10, 9)); // one short — the target survives at 1 hp + assert!(is_lethal(10, 10)); + assert!(is_lethal(10, 11)); + assert!(!is_lethal(10, 9)); } #[test] diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 34c011ab3..83c01285f 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -1,36 +1,13 @@ -//! The engagement model (split of the former monolithic `combat/mod.rs`, on top of the shared -//! damage pipeline): `enter_combat`/`disengage`/`clear_target`/`break_own_attacks` + the `is_engaged`/ -//! `melee_combatant_guids` queries over `game_melee_attack` (the single source of truth for who fights -//! whom — see the banner below for the both-directions rule those two helpers encode), the engagement -//! tables (`MeleeAttack`/`CombatEvent`/`MeleeSchedule`/`RangedImpactSchedule`), and the -//! `start_attack`/`start_ranged_attack`/`stop_attack` reducers that arm/disarm them. The tick-driven -//! swing resolution (`tick_melee` and everything it calls) lives in `swing.rs`. `mod.rs` names the operations shared with other Module files. - use spacetimedb::{table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{game_faction_template, game_world_entity, WorldEntity}; -// Shared combat operations and formulas are named in the parent module. use super::*; use crate::actor::{ActionRefusal, ActionRefusalKind}; use crate::creatures::tick::game_creature_spline; -// --- Engagement queries over `game_melee_attack` (the single source of truth for who fights whom). -// `attacker_guid` is the PK; an engagement "touches" a unit when it is on EITHER side. These three -// helpers are the ONE place the both-directions rule is encoded, so leash evade, killing blow, -// logout teardown, the patrol-hold gate, and regen all share it instead of re-deriving it inline. - -/// How long (ms) a unit stays flagged IN COMBAT after its last hostile action — vanilla's ~5-6s -/// combat-drop. `enter_combat` stamps `now + this`; the tick's combat-drop pass clears the flag past it. pub(crate) const COMBAT_DROP_MS: u64 = 6000; -/// Put `guid` into combat: stamp `combat_until_ms = now + COMBAT_DROP_MS` and set `UNIT_FLAG_IN_COMBAT` -/// on its `unit_flags`, so OBSERVERS see the combat state — including a pure caster, whom the -/// auto-attack / `game_melee_attack` stance (`SMSG_ATTACKSTART`) can't cover. Idempotent; skips -/// dead/missing. Re-stamps only when newly entering OR the deadline is within half the window, so a -/// sustained fight doesn't write + fire a relay callback every 500ms tick. The flag is CLEARED by the -/// tick's combat-drop pass (`creatures::tick`), not here — so it lingers ~COMBAT_DROP_MS after the last -/// action, like vanilla (NOT instantly on `disengage`). [entity] pub(crate) fn enter_combat(ctx: &ReducerContext, guid: u64) { let entities = ctx.db.game_world_entity(); let Some(mut e) = entities.guid().find(guid) else { @@ -42,7 +19,7 @@ pub(crate) fn enter_combat(ctx: &ReducerContext, guid: u64) { let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; let already = e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0; if already && e.combat_until_ms > now_ms + COMBAT_DROP_MS / 2 { - return; // already comfortably in combat — skip the redundant write + relay callback + return; } e.combat_until_ms = now_ms + COMBAT_DROP_MS; e.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; @@ -50,7 +27,6 @@ pub(crate) fn enter_combat(ctx: &ReducerContext, guid: u64) { crate::creatures::distraction::clear(ctx, guid); } -/// Arm a creature's first outgoing melee engagement and dispatch its aggro edge once. pub(crate) fn arm_creature_engagement( ctx: &ReducerContext, creature_guid: u64, @@ -90,19 +66,11 @@ pub(crate) fn arm_creature_engagement( true } -/// Refresh the pursuit leash on the CREATURE side of a damage exchange: deadline to -/// `now + PURSUIT_WINDOW_MS`, remembered position to wherever that creature stands. Fed both guids, so -/// the creature's own damage refreshes it like the player's. -/// -/// Called from the damage chokepoint (`apply_hit`), never from `enter_combat`: the creature tick -/// re-stamps combat flags through that helper every 500ms, which would defer the deadline forever. -/// [entity] pub(crate) fn refresh_leash(ctx: &ReducerContext, guid_a: u64, guid_b: u64) { let melee = ctx.db.game_melee_attack(); let entities = ctx.db.game_world_entity(); let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; for guid in [guid_a, guid_b] { - // Only a CREATURE's own outgoing row is ever read by the leash pass; a player's is not. let (Some(mut row), Some(e)) = (melee.attacker_guid().find(guid), entities.guid().find(guid)) else { @@ -118,22 +86,13 @@ pub(crate) fn refresh_leash(ctx: &ReducerContext, guid_a: u64, guid_b: u64) { } } -/// Free every melee engagement touching `guid` — its own outgoing attack AND any attack on it. -/// The canonical "leave combat" teardown. Collect-then-delete (never mutate while iterating). pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { let melee = ctx.db.game_melee_attack(); - // Capture the CREATURES attacking `guid` (their target_guid points at `guid`) BEFORE deleting their - // rows, so we can also drop their stale selection below. Without this a creature whose melee row is - // gone keeps facing/tracking the dead/ghost player (the row stops the swing, but target_guid lingers - // and the client renders the mob still selecting the corpse). Generic over any attacker of any unit. - // Perf catalog 1.15: `by_target` turns these three full scans into own-row probes. let attackers_of_guid: Vec = melee .by_target() .filter(&guid) .map(|a| a.attacker_guid) .collect(); - // `touching` = the outgoing row (keyed by PK) plus every incoming one. Deletes are by PK, so the - // visit order is irrelevant; the SET is identical to the old scan's. for a in melee .attacker_guid() .find(guid) @@ -143,9 +102,6 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { { melee.attacker_guid().delete(a); } - // Threat is part of being in combat: leaving it clears `guid` from the threat tables — its OWN table - // (a creature that evaded/fled/died forgets everyone) AND its entries on every other creature's table - // (a player who died/logged out is forgotten by every mob). One symmetric clear, the canonical place. use crate::game_threat; let threat_members = ctx .db @@ -156,15 +112,9 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { .collect::>(); crate::threat::clear_for_unit(ctx, guid); clear_target(ctx, guid); - // Drop the stale target on every creature that was attacking `guid`. `clear_target` skips players and - // is a no-op when target_guid == 0, so this only touches creatures that actually pointed at the dying - // unit — baseline-safe (a creature attacking someone else is untouched) and covers a whole pack. for a in &attackers_of_guid { clear_target(ctx, *a); } - // 249: leaving combat is IMMEDIATE for anyone this disengage left with no engagement at all — - // vanilla drops the player's combat the moment the mob evades, not ~COMBAT_DROP_MS later (the - // decay window still covers DoT/spell-only combat via the cycle's combat exit, unchanged). let mut freed = attackers_of_guid; freed.extend(threat_members); freed.push(guid); @@ -173,14 +123,10 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { if is_engaged(ctx, g) { continue; } - // A dead creature's synchronous OnDeath hooks still resolve its Loot Tag. Its death - // chokepoint clears the tag after dispatch; every live combat-end path clears it here. + // Death clears the Loot Tag after synchronous hooks; live disengage clears it now. if !entities.guid().find(g).is_some_and(|entity| entity.dead) { crate::loot::tag::clear(ctx, g); } - // The fight is over for this unit however it ended — evade, the player dying, a logout, a - // map change. This is the ONLY place that sees all four, so the EventAI engagement resets - // here rather than in the cycle's combat-drop pass, which sees only a decayed flag. crate::creatures::reset_engagement(ctx, g); if let Some(mut e) = entities.guid().find(g) { if e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 { @@ -192,8 +138,6 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { } } -/// Remove only the two attack rows belonging to a Duel. Other combat remains intact; each -/// participant's combat flag clears only when no other engagement still touches them. pub(crate) fn stop_duel_combat(ctx: &ReducerContext, first_guid: u64, second_guid: u64) { let melee = ctx.db.game_melee_attack(); for attacker_guid in [first_guid, second_guid] { @@ -221,9 +165,6 @@ pub(crate) fn stop_duel_combat(ctx: &ReducerContext, first_guid: u64, second_gui } } -/// Zero a CREATURE's `target_guid` when it leaves combat (evade / flee / death) so the client stops -/// showing a stale target on a disengaged mob. Skips PLAYERS: a player's `target_guid` is their -/// SELECTION (`CMSG_SET_SELECTION`), which persists out of combat. No-op when already 0 (common path). pub(crate) fn clear_target(ctx: &ReducerContext, guid: u64) { let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(guid) { @@ -234,19 +175,12 @@ pub(crate) fn clear_target(ctx: &ReducerContext, guid: u64) { } } -/// Drop only `guid`'s OWN outgoing attack (and clear its threat), LEAVING any attack *on* it intact. -/// Used when a creature FLEES at low HP: it stops swinging and forgets its foes, but whoever is hitting -/// it stays engaged so they can run it down (vanilla "the mob flees, you chase the kill"). Contrast -/// [`disengage`], which frees BOTH directions — using that here deleted the player's row too, dropping -/// them out of combat one swing short of the kill (the "combat just stops at low HP" bug). pub(crate) fn break_own_attacks(ctx: &ReducerContext, guid: u64) { - // `attacker_guid` is the PK → at most one outgoing row for `guid`. ctx.db.game_melee_attack().attacker_guid().delete(guid); crate::threat::clear_for_unit(ctx, guid); clear_target(ctx, guid); } -/// Is `guid` in any melee engagement, attacking or attacked? pub(crate) fn is_engaged(ctx: &ReducerContext, guid: u64) -> bool { let melee = ctx.db.game_melee_attack(); use crate::game_threat; @@ -268,8 +202,6 @@ fn may_help_decision(same_unit: bool, active_duel: bool, hostile: bool) -> bool same_unit || (!active_duel && !hostile) } -/// Current harmful-target authorization. An active Duel overrides friendship only for its exact -/// pair; neutral and hostile targets retain the ordinary faction behavior. pub(crate) fn may_harm(ctx: &ReducerContext, attacker: &WorldEntity, target: &WorldEntity) -> bool { may_harm_decision( attacker.guid == target.guid, @@ -278,8 +210,6 @@ pub(crate) fn may_harm(ctx: &ReducerContext, attacker: &WorldEntity, target: &Wo ) } -/// Current helpful-target authorization. Active Duel opponents are hostile to one another even -/// when their ordinary faction relation is friendly. pub(crate) fn may_help(ctx: &ReducerContext, helper: &WorldEntity, target: &WorldEntity) -> bool { may_help_decision( helper.guid == target.guid, @@ -288,8 +218,6 @@ pub(crate) fn may_help(ctx: &ReducerContext, helper: &WorldEntity, target: &Worl ) } -/// Whether an enemy-only area selector should include `target`. Unlike direct attacks, ordinary -/// neutral units are not selected automatically; an active Duel opponent is the narrow exception. pub(crate) fn is_hostile_target( ctx: &ReducerContext, attacker: &WorldEntity, @@ -300,18 +228,6 @@ pub(crate) fn is_hostile_target( || crate::faction::is_hostile(ctx, attacker.faction_template, target.faction_template)) } -/// Every guid currently in combat, for bulk in-combat gates (e.g. regen). -/// -/// Includes both sides of every live melee engagement AND any entity whose -/// `UNIT_FLAG_IN_COMBAT` bit is set (covers pure casters, warriors with Bloodrage, -/// and anything else `enter_combat()` flagged without a melee row). The two sets -/// overlap in practice; callers use `.contains()` so duplicates are harmless. -/// The MELEE half of the combatant set — both sides of every live `game_melee_attack` row. The other -/// half is every entity carrying `UNIT_FLAG_IN_COMBAT` (pure casters, Bloodrage warriors — anything -/// `enter_combat()` flagged without a melee row); perf catalog 1.6 moved that half to the CALLER, -/// because the only caller (the cycle's regeneration candidate read) gets that half from the flag -/// bits the tick's active-cell sweep already harvested, instead of paying a second full table scan -/// for it. A future caller must harvest the flag half itself the same way. pub(crate) fn melee_combatant_guids(ctx: &ReducerContext) -> Vec { ctx.db .game_melee_attack() @@ -320,12 +236,6 @@ pub(crate) fn melee_combatant_guids(ctx: &ReducerContext) -> Vec { .collect() } -// =========================================================================================== -// Combat tables [entity]/[event] -// =========================================================================================== - -/// An active melee auto-attack: `attacker_guid` swings `target_guid`. Keyed by attacker (one -/// target at a time). [entity] #[table( accessor = game_melee_attack, public, @@ -336,52 +246,24 @@ pub struct MeleeAttack { #[primary_key] pub attacker_guid: u64, pub target_guid: u64, - pub last_swing_ms: u32, // 0 = never swung (the next tick swings immediately) - /// 0 = melee auto-attack (the original behavior); 75 (Auto Shot) / 5019 (wand Shoot) = a RANGED - /// auto-attack — the swing tick then uses the equipped ranged weapon (slot 17), its delay as the - /// swing interval, a longer range, and a reduced attack table (no parry/block). One engagement per - /// attacker, so a unit is either meleeing OR shooting. END-appended + `#[default(0)]` → adding it - /// auto-migrates existing rows (no `-c` wipe). [entity] + pub last_swing_ms: u32, #[default(0)] pub ranged_spell_id: u32, - /// (dual wield): the OFF-HAND swing's own clock, independent of `last_swing_ms`, an - /// off-hand weapon has its own `delay_ms`, so it swings on a different cadence than the main hand. - /// 0 = never swung (the next eligible tick swings immediately), same sentinel as `last_swing_ms`. - /// Only consulted for a MELEE engagement (`ranged_spell_id == 0`) with a live off-hand weapon - /// (`equipped_offhand_weapon_damage`) — a ranged engagement or a bare/shielded off-hand never - /// advances this field. END-appended + `#[default(0)]` → adding it auto-migrates existing rows (no - /// `-c` wipe). [entity] #[default(0)] pub last_offhand_swing_ms: u32, - /// The low-HP ROUT clock for this engagement: 0 = no rout has started; otherwise the wall-clock ms - /// at which the rout window closes. A value in the past means the rout is over AND spent, which is - /// what makes a rout once-per-engagement — the row dies on disengage, so the next fight rearms it - /// with no cleanup path. Read through `ai::rout_window_open` / `ai::may_start_rout`. - /// END-appended + `#[default(0)]` → adding it auto-migrates existing rows (no `-c` wipe). [entity] #[default(0)] pub rout_ends_ms: u32, - /// The wall-clock ms past which the leash pass may evade this creature, re-stamped by every damage - /// exchange in either direction (`refresh_leash`). 0 = never refreshed, which never evades — the - /// leash pass seeds it instead, so a pull whose first shot has not landed cannot be evaded out from - /// under. Read through `ai::should_evade`. - /// END-appended + `#[default(0)]` → adding it auto-migrates existing rows (no `-c` wipe). [entity] #[default(0)] pub pursuit_ends_ms: u32, - /// Where the CREATURE stood at that refresh — the point the TARGET's distance is measured from. - /// Meaningless while `pursuit_ends_ms` is 0. 2D, like the wander/return-home math (z varies on - /// slopes). END-appended + `#[default(0.0)]` → auto-migrates existing rows. [entity] #[default(0.0)] pub leash_x: f32, #[default(0.0)] pub leash_y: f32, } -/// A logged melee swing to relay as `SMSG_ATTACKERSTATEUPDATE`. Broadcast (public, no RLS), like -/// `game_creature_move_event` — the gateway fans it to in-world clients. [event] #[table( accessor = game_combat_event, public, - // perf catalog 2.3: AOI-box scoping instead of a global `SELECT *`. index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] pub struct CombatEvent { @@ -391,39 +273,19 @@ pub struct CombatEvent { pub attacker_guid: u64, pub target_guid: u64, pub damage: u32, - pub hit_info: u8, // 0 normal, 1 crit, 2 miss, 3 dodge, 4 parry, 5 glancing, 6 crushing, 7 block - pub killing_blow: bool, // the swing that killed the target → gateway also sends SMSG_ATTACKSTOP + pub hit_info: u8, + pub killing_blow: bool, pub created_at: Timestamp, - /// The flat shield `block_value` this swing absorbed (HIT_BLOCK only; 0 otherwise). Drives the - /// SMSG_ATTACKERSTATEUPDATE `blocked_amount` wire field so the client shows the "Block N" text. - /// END-appended + `#[default(0)]` → adding it auto-migrates existing rows (no `-c` wipe). [event] #[default(0)] pub blocked_amount: u32, - /// 0 for a melee swing; the ranged spell (75 Auto Shot / 5019 Shoot) for a ranged shot. The gateway - /// sets the SMSG_ATTACKERSTATEUPDATE `spell_id` field from this so the shot is attributed to the - /// ranged ability. END-appended + `#[default(0)]` → auto-migrates. [event] #[default(0)] pub ranged_spell_id: u32, - /// The `display_id` of the ammo (arrow/bullet) this shot consumed — Auto Shot (75) only; 0 for melee, - /// wand Shoot, and out-of-data shots. The gateway sets the SMSG_SPELL_GO AMMO flag + this display so - /// the client renders the arrow projectile. END-appended + `#[default(0)]` → auto-migrates. [event] #[default(0)] pub ammo_display_id: u32, - /// True when a queued on-next-swing spell (Heroic Strike/Cleave) FIRED on this landed swing: - /// vanilla REPLACES the white hit — the whole swing is the spell (one yellow named line, carried by - /// the SpellCastEvent the swing inserts). The gateway then SKIPS the SMSG_ATTACKERSTATEUPDATE for - /// this row (killing_blow/ATTACKSTOP still honored); `damage` keeps the true total for QA readers. - /// END-appended + `#[default(false)]` → auto-migrates. [event] #[default(false)] pub spell_swing: bool, - /// Projectile travel time (ms) for a RANGED shot. The launch row carries SMSG_SPELL_GO only; - /// `ranged_impact` applies health and emits the damage log at fire + this delay. 0 = melee. - /// END-appended + `#[default(0)]` → auto-migrates. [event] #[default(0)] pub impact_delay_ms: u32, - // --- AOI columns (perf catalog 2.3), END-appended + TYPED defaults (a bare `0` on a u64 - // encodes as 4 bytes and fails the publish). Stamped from the actor via `helpers::grid_of`; - // (0,0,0,0) means "no live actor", which matches no box and is correctly never delivered. #[default(0u32)] pub map_id: u32, #[default(0u64)] @@ -462,7 +324,6 @@ impl CombatEvent { } } -/// Drives the melee swing tick. [server] #[table(accessor = game_melee_schedule, scheduled(tick_melee))] pub struct MeleeSchedule { #[primary_key] @@ -471,10 +332,6 @@ pub struct MeleeSchedule { pub scheduled_at: ScheduleAt, } -/// One in-flight RANGED projectile: scheduled at fire + travel time; `ranged_impact` then -/// applies the frozen post-mitigation damage (health/lethal/threat/rage/skill/combat-flag) so the -/// server-side hit lands when the client's arrow does. The final damage and its client log are both -/// committed at impact, after fresh lethal-floor and health checks. Module-private. #[table(accessor = game_ranged_impact_schedule, scheduled(ranged_impact))] pub struct RangedImpactSchedule { #[primary_key] @@ -483,31 +340,13 @@ pub struct RangedImpactSchedule { pub scheduled_at: ScheduleAt, pub attacker_guid: u64, pub target_guid: u64, - /// Post-mitigation damage frozen at launch (0 = a fully-absorbed shot: nothing to apply). pub damage: u32, - /// The ranged ability named by the launch event. The impact uses it for the authoritative - /// damage log after the final damage decision. #[default(0u32)] pub ranged_spell_id: u32, #[default(false)] pub is_crit: bool, } -// =========================================================================================== -// Reducers -// =========================================================================================== - -/// The TARGET-side half of the attack-command gate, shared verbatim by `apply_start_attack` and -/// `apply_start_ranged_attack` (it was a copy-paste, and a copy of a gate is a gate that -/// eventually only half-holds): the target must EXIST, be on the same map + instance, not be a -/// CORPSE, and not be FRIENDLY. Returns the resolved target row so the caller doesn't fetch it twice. -/// -/// The error strings are LOAD-BEARING — the gateway substring-maps them onto the 1.12 client -/// responses (`ERR_ATTACK_TARGET_DEAD` → SMSG_ATTACKSWING_DEADTARGET, `ERR_ATTACK_FRIENDLY` → -/// SMSG_ATTACKSWING_CANT_ATTACK), which is how the client leaves combat stance. The CC and -/// attack-self checks stay with the callers: they run BEFORE this block, and the ranged path -/// additionally wedges its "a ranged weapon must be equipped" check between them, so the order in -/// which a doubly-invalid command reports its reason is preserved exactly. pub(crate) fn validate_attack_target( ctx: &ReducerContext, attacker: &WorldEntity, @@ -522,25 +361,17 @@ pub(crate) fn validate_attack_target( )); } if target.dead { - // Can't attack a corpse during decay. The gateway maps this exact error to - // SMSG_ATTACKSWING_DEADTARGET so the client leaves combat stance (shared constant). return Err(ActionRefusal::new( ActionRefusalKind::DeadTarget, lyracore_shared::ERR_ATTACK_TARGET_DEAD, )); } - // Faction gate: reject a swing only at a FRIENDLY target. Hostile (red) AND neutral (yellow — - // e.g. Elwynn wolves, which are huntable) stay attackable, matching vanilla; only friendly (green) - // units are protected. The gateway maps this to SMSG_ATTACKSWING_CANT_ATTACK so the client leaves - // stance. SKIPPED when faction data isn't loaded (table empty) so missing data never blocks combat. if ctx.db.game_faction_template().count() > 0 && !may_harm(ctx, attacker, &target) { return Err(lyracore_shared::ERR_ATTACK_FRIENDLY.to_string().into()); } Ok(target) } -/// Shared core: arm (or retarget) `attacker_guid`'s MELEE auto-attack on `target_guid` — the -/// explicit-guid body behind the `start_attack` reducer and `actor::attack`. pub(crate) fn apply_start_attack( ctx: &ReducerContext, attacker_guid: u64, @@ -557,9 +388,6 @@ pub enum AttackStart { AlreadyArmed, } -/// Arm melee without resetting a current swing timer. An in-range Character turns toward the -/// admitted target when Core's facing Gate would block its swing, unless its current movement leg -/// still owns position and facing. Acceptance does not imply a hit. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn request_attack( ctx: &ReducerContext, @@ -653,14 +481,6 @@ fn start_attack( "dead attackers cannot attack", )); } - // Crowd control: an ACTION-blocked attacker (stunned/polymorphed/feared) cannot ENTER combat — - // arming an engagement is itself an action. This is the player-command twin of the per-swing gate in - // `tick_melee` (without it a CC'd player could insert a `game_melee_attack` row whose swings are then - // all blocked — a hollow "in combat but can't swing" state). Mirrors the cast chokepoint - // (`resolve_cast_at`) and the creature aggro/cast action passes. Baseline-safe: `false` without a CC - // aura → an un-CC'd attack command arms exactly as before. (The CC error message deliberately does NOT - // match the gateway's desync classifier, so it just rejects the command rather than dropping the - // session.) if crate::spell::is_action_blocked(ctx, attacker.guid) { return Err(ActionRefusal::new( ActionRefusalKind::CannotAct, @@ -672,9 +492,7 @@ fn start_attack( } validate_attack_target(ctx, &attacker, target_guid)?; - // LAND-MOUNT dismount (22): an ACCEPTED attack start drops the attacker's mount BEFORE the - // engagement is armed. Every rejection above returned already, so an invalid attack packet leaves - // the mount up. No-op for an unmounted attacker, and idempotent on a re-target. + // Dismount only after the last attack Gate so a Refusal leaves the mount intact. crate::mount::dismount(ctx, attacker.guid); if retain_current && ctx @@ -699,13 +517,12 @@ fn start_attack( } } - // Arm the engagement (the tick gates each swing on range + timer). Re-arming retargets. let melee = ctx.db.game_melee_attack(); let row = MeleeAttack { attacker_guid: attacker.guid, target_guid, last_swing_ms: 0, - ranged_spell_id: 0, // melee auto-attack + ranged_spell_id: 0, last_offhand_swing_ms: 0, rout_ends_ms: 0, pursuit_ends_ms: 0, @@ -720,10 +537,6 @@ fn start_attack( Ok(AttackStart::Armed) } -/// Shared core: arm `attacker_guid`'s RANGED auto-attack on `target_guid` with `spell_id`. Same gates as -/// `start_attack` (CC / self / target exists / same map / not a corpse / not friendly) PLUS a ranged -/// weapon must be equipped (slot 17) — Auto Shot/Shoot are impossible bare-handed. Arms/retargets the one -/// engagement row with `ranged_spell_id = spell_id`; the swing tick then runs the ranged branch. pub(crate) fn apply_start_ranged_attack( ctx: &ReducerContext, attacker_guid: u64, @@ -748,13 +561,6 @@ pub(crate) fn apply_start_ranged_attack( let ranged_weapon = equipped_ranged_weapon(ctx, attacker.guid) .ok_or_else(|| "no ranged weapon equipped".to_string())?; let target = validate_attack_target(ctx, &attacker, target_guid)?; - // Activation CheckCast (vanilla): vmangos REJECTS the auto-repeat activation for any hard - // cast failure — the gateway relays the reason as SMSG_CAST_RESULT and the client drops its - // toggle, so the client's auto-repeat state never outlives a loop that could not start. Without - // these gates the row armed silently, every shot was suppressed by the same checks in the swing - // tick, and the client sat toggled-on over a dead loop. Error strings are load-bearing: the - // gateway's `cast_failure_reason_for` substring-maps them to the 1.12 red-error reasons - // ("out of range" → 0x59, "too close" → 0x76, "in front" → 0x7C, "line of sight" → 0x2A). { let (dx, dy, dz) = ( target.x - attacker.x, @@ -794,21 +600,13 @@ pub(crate) fn apply_start_ranged_attack( return Err("no ammo for ranged weapon".to_string()); } } - // LAND-MOUNT dismount (22): the ranged twin of the melee hook — after the last activation gate, - // before the engagement is armed, so a refused activation leaves the mount up. crate::mount::dismount(ctx, attacker.guid); - // Initial-shot wind-up: a ranged auto-attack must NOT fire instantly on activation — vanilla's - // Auto Shot has a ~0.5s cast before the first shot (the user: "we shoot right away, no waiting for the - // attack timer"). `last_swing_ms == 0` would make the swing tick fire THIS tick; instead seed it so the - // first shot is `RANGED_INITIAL_SHOT_MS` out. The swing gate fires when `now - last_swing >= delay`, so - // set `last_swing = now - (delay - initial)` → the first shot lands after `initial` ms, then subsequent - // shots on the full weapon `delay`. A fast weapon (delay < initial) → seed `now` → waits its full delay. - // Melee keeps `last_swing_ms: 0` (swings immediately on engage — correct for melee). + // Delay the first Auto Shot by its initial wind-up; zero is the immediate-swing sentinel. let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; let ranged_delay = ranged_weapon.2; let first_swing_seed = now_ms .saturating_sub(ranged_delay.saturating_sub(RANGED_INITIAL_SHOT_MS)) - .max(1); // .max(1): never the 0 "swing now" sentinel + .max(1); let melee = ctx.db.game_melee_attack(); let row = MeleeAttack { attacker_guid: attacker.guid, @@ -829,9 +627,6 @@ pub(crate) fn apply_start_ranged_attack( Ok(()) } -/// Shared core: disarm `attacker_guid`'s outgoing auto-attack row (melee or ranged) — the -/// explicit-guid body behind the `stop_attack` reducer and `actor::stop_attack`. -/// Unconditional; a no-op when nothing is armed. pub(crate) fn stop_attack_for(ctx: &ReducerContext, attacker_guid: u64) { ctx.db .game_melee_attack() diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 4914f7eac..27d1bbc66 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -1,12 +1,3 @@ -//! Effective-value folds over aura/gear + the weapon-damage/attack-table roll family (split of the -//! former monolithic `combat/mod.rs`, on top of the shared damage pipeline). Everything here is the -//! "what does this unit's swing look like right now" layer: the `effective_*` stat folds (dodge/parry/ -//! block/crit/miss/swing-time/armor/strength/agility/move-speed), the seal-on-swing + queued-strike -//! (Heroic Strike/Cleave) on-next-swing folds, the react-window (Overpower/Revenge) arm/check, the -//! equipped-weapon/ammo lookups, and `roll_swing`/`roll_ranged_swing` — the attack-table rolls that -//! `death`'s pipeline and `swing`'s resolvers consume. No lethal/death handling and no reducers live -//! here. `mod.rs` names the operations shared with other Module files. - use spacetimedb::ReducerContext; use crate::{ @@ -14,10 +5,8 @@ use crate::{ game_world_entity, ItemInstance, WorldEntity, }; -// Shared combat operations and formulas are named in the parent module. use super::*; -/// Fold aura and equipped-item rating onto one attack-table band. fn fold_band( ctx: &ReducerContext, target_guid: u64, @@ -31,9 +20,6 @@ fn fold_band( .clamp(0, 10_000) as u32 } -/// `target`'s EFFECTIVE dodge chance (bp) vs a swing with skill difference `sd`: the agility-derived -/// `dodge_chance_bp` PLUS any `A_MOD_COMBAT(COMBAT_DODGE)` aura. `roll_swing` builds the dodge band from -/// this and `debug_compute_swing` reads it back. No dodge aura → exactly the agility band (baseline-safe). pub(crate) fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { fold_band( ctx, @@ -44,9 +30,6 @@ pub(crate) fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: ) } -/// `target`'s EFFECTIVE parry chance (bp) vs a swing with skill difference `sd`: `parry_chance_bp(sd)` PLUS -/// any `A_MOD_COMBAT(COMBAT_PARRY)` aura (e.g. the Deflection talent). No parry aura → the flat band -/// (baseline-safe). The parry twin of `effective_crit_bp`/`effective_dodge_bp`. [entity] pub(crate) fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { fold_band( ctx, @@ -57,11 +40,6 @@ pub(crate) fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: ) } -/// `target`'s EFFECTIVE block chance (bp) vs a swing with skill difference `sd`: the `block_chance_bp(sd)` -/// band PLUS any `A_MOD_COMBAT(COMBAT_BLOCK)` aura (e.g. the Shield Specialization talent) — but ONLY when -/// the defender has a shield (`block_value > 0`, passed in by the caller so the gate uses the SAME value -/// the HIT_BLOCK damage subtraction does — no recompute, no split-gate divergence). An unshielded unit -/// blocks at 0 regardless of any block aura (you can't block without a shield) — baseline-safe. [entity] pub(crate) fn effective_block_bp( ctx: &ReducerContext, target: &WorldEntity, @@ -77,35 +55,20 @@ pub(crate) fn effective_block_bp( crate::items::EquipStat::Block, ) } else { - 0 // no shield → no block band (a block aura is inert without a shield) + 0 } } -/// The total attack-power BONUS from a unit's active self-buff auras (e.g. Battle Shout): the sum of -/// `effect_basis` over every aura on `unit_guid` whose spell is a positive-basis self-buff. The -/// convention — a self-buff aura carrying a positive `effect_basis` grants that many attack-power -/// points while active — connects the aura tier to combat: a buffed attacker's effective AP rises, so -/// the swing range (computed via the `_ap` cores) is strictly higher while the buff is up. Delegates to -/// the spell engine's TYPED active-aura read (`combat_field_bonus` sums the frozen `amount` of every -/// `A_MOD_COMBAT` aura on the unit whose `eff_p0 == COMBAT_ATTACK_POWER`). Returns 0 for an un-buffed -/// unit (clamped ≥0), so a no-aura swing is byte-identical to today's range. [entity] pub(crate) fn aura_attack_power_bonus(ctx: &ReducerContext, unit_guid: u64) -> u32 { crate::spell::combat_field_bonus(ctx, unit_guid, crate::spell::COMBAT_ATTACK_POWER).max(0) as u32 } -/// Holy damage a unit's active SEAL aura adds to a LANDED melee swing (Seal of Righteousness): the summed -/// `A_SEAL` aura value, weapon-speed-weighted — vanilla SoR is per-swing, scaled by weapon speed. `(0, 0)` -/// for a unit with no seal (baseline-safe). Pull model — the swing READS the seal aura, no per-spell code. -/// Also returns the seal's SPELL id so the swing can name the yellow proc line after it. pub(crate) fn seal_holy_on_swing(ctx: &ReducerContext, attacker: &WorldEntity) -> (u32, u32) { let (amount, spell) = crate::spell::seal_amount_and_spell(ctx, attacker.guid); (seal_swing_holy(amount, attacker.base_attack_time_ms), spell) } -/// Pure: holy damage a seal of coefficient `seal` adds to one swing on an `attack_time_ms`-speed weapon — -/// vanilla SoR ≈ seal_coeff × weapon_speed / 100000 (coeff 108@r1 / 216@r10; a 2.0s weapon → ~2 / ~4). 0 -/// for no seal (coeff ≤ 0), so an unsealed swing is byte-identical. [pure] fn seal_swing_holy(seal: i32, attack_time_ms: u32) -> u32 { if seal <= 0 { return 0; @@ -113,11 +76,6 @@ fn seal_swing_holy(seal: i32, attack_time_ms: u32) -> u32 { ((seal as u64 * attack_time_ms as u64) / 100_000) as u32 } -/// Pure: the bonus damage a queued on-next-swing strike (`queued_spell_id`, e.g. Heroic Strike) adds to a -/// LANDED melee swing, given the queued spell's E_NEXT_SWING effect `base_points`. 0 when nothing is queued -/// (`queued_spell_id == 0`) or the queued bonus is non-positive (`base_points <= 0`), so an un-queued swing -/// is byte-identical. The bonus is the flat effect base (negative bases are clamped to 0 — a queued strike -/// only ever ADDS). [pure] fn queued_strike_bonus(queued_spell_id: u32, base_points: i32) -> u32 { if queued_spell_id == 0 { return 0; @@ -125,17 +83,11 @@ fn queued_strike_bonus(queued_spell_id: u32, base_points: i32) -> u32 { base_points.max(0) as u32 } -/// Bonus damage the attacker's QUEUED on-next-swing strike (Heroic Strike 78 / Cleave 845) adds to a LANDED -/// melee swing — the pull-model twin of `seal_holy_on_swing`. Reads `attacker.next_swing_spell`; if non-zero, -/// looks up that spell's E_NEXT_SWING effect `base_points` (the flat queued bonus) and returns it. 0 for an -/// un-queued attacker (the common path). Does NOT clear the queue — `clear_queued_strike` does that whenever -/// a swing FIRES (so a miss spends the queue too). Generic over the kind, never a per-spell branch. pub(crate) fn queued_strike_on_swing(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { let queued = attacker.next_swing_spell; if queued == 0 { return 0; } - // The queued bonus is the base_points of the spell's E_NEXT_SWING effect (Heroic Strike's flat +N). let base_points = ctx .db .game_spell_effect() @@ -147,10 +99,6 @@ pub(crate) fn queued_strike_on_swing(ctx: &ReducerContext, attacker: &WorldEntit queued_strike_bonus(queued, base_points) } -/// Clear `guid`'s queued on-next-swing strike (set `next_swing_spell = 0`) — called whenever a swing FIRES -/// (landed OR missed), so a missed swing spends the queue too (vanilla: the on-next-swing slot is consumed). -/// Re-fetches a fresh mutable copy (read-your-writes) like the rage block. No-op if nothing is queued or the -/// unit left the world. pub(crate) fn clear_queued_strike(ctx: &ReducerContext, guid: u64) { let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(guid) { @@ -161,31 +109,18 @@ pub(crate) fn clear_queued_strike(ctx: &ReducerContext, guid: u64) { } } -/// Reactive-ability window (ms): how long an Overpower / Revenge proc stays usable after it is armed. -/// Vanilla ~5s. The attack table stamps `*_until_ms = now + this`; the cast gate refuses the react spell -/// once `now >= *_until_ms`. pub(crate) const REACT_WINDOW_MS: u64 = 5000; -/// Which react window a dodge/avoid arms. #[derive(Clone, Copy)] pub(crate) enum ReactKind { - /// The ATTACKER got DODGED → arm its Overpower. Overpower, - /// The DEFENDER dodged/parried/blocked an incoming swing → arm its Revenge. Revenge, } -/// Pure: is a react window armed at `now_ms` given its stored deadline `until_ms`? A window is OPEN while -/// `until_ms > now_ms` (a 0 deadline — never armed, or already lapsed — is closed). The cast gate's check. -/// [pure] pub(crate) fn react_window_active(until_ms: u64, now_ms: u64) -> bool { until_ms > now_ms } -/// Arm a react window on `guid`: stamp `now_ms + REACT_WINDOW_MS` onto the matching `*_until_ms` field. Used -/// by the attack table — a DODGE of this unit's swing arms Overpower; a dodge/parry/block by this unit arms -/// Revenge. Re-fetches a fresh mutable copy (read-your-writes), since the row's own damage `update` happens -/// later in the swing loop. No-op if the unit left the world. pub(crate) fn arm_react(ctx: &ReducerContext, guid: u64, kind: ReactKind) { let entities = ctx.db.game_world_entity(); let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; @@ -199,12 +134,6 @@ pub(crate) fn arm_react(ctx: &ReducerContext, guid: u64, kind: ReactKind) { } } -/// A unit's EFFECTIVE Strength: the stored base attribute plus every active `A_MOD_STAT(STR)` aura -/// (e.g. Mark of the Wild) AND the STR summed across its EQUIPPED gear (item stat bonuses). Folded in -/// before AP/swing derivation so a +STR buff OR a +STR chest is mechanically real (it raises the swing). -/// Aura + gear are added ALONGSIDE the base. Clamped ≥0 so a debuff exceeding base never underflows the -/// `u32`. A unit with no aura AND no stat-bearing gear (a creature, an unequipped player, the starter -/// loadout) gets +0 → the base value, so its readout is byte-identical to before (baseline-safe). [entity] pub(crate) fn effective_strength(ctx: &ReducerContext, e: &WorldEntity) -> u32 { effective_stat( e.strength as i32, @@ -213,10 +142,6 @@ pub(crate) fn effective_strength(ctx: &ReducerContext, e: &WorldEntity) -> u32 { ) } -/// A unit's EFFECTIVE Agility: base plus every active `A_MOD_STAT(AGI)` aura plus the AGI summed across -/// its EQUIPPED gear. The defender's effective agility drives dodge (the avoidance twin of how effective -/// Strength drives the swing), so an agility buff OR a +AGI item actually raises dodge chance. Aura + -/// gear added alongside the base; clamped ≥0; no aura and no gear → the base value (baseline-safe). [entity] pub(crate) fn effective_agility(ctx: &ReducerContext, e: &WorldEntity) -> u32 { effective_stat( e.agility as i32, @@ -225,12 +150,6 @@ pub(crate) fn effective_agility(ctx: &ReducerContext, e: &WorldEntity) -> u32 { ) } -/// A unit's EFFECTIVE armor: the stored base armor (the player's agility*2 line) plus every active -/// `A_MOD_RESISTANCE(armor)` aura plus the bonus armor summed across its EQUIPPED gear — the gear armor -/// is ADDED ON TOP of the agility*2 base, NOT a re-derivation (no double-count). The defender's effective -/// armor drives physical-damage mitigation, so an armor buff OR an armored piece actually softens -/// incoming hits. Clamped ≥0; no aura and no armor gear → the base value (mitigation byte-identical to -/// before — baseline-safe). [entity] pub(crate) fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { lyracore_shared::resistance::effective_resistance( e.armor, @@ -238,82 +157,46 @@ pub(crate) fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { ) } -/// A unit's EFFECTIVE shield BLOCK VALUE — the flat physical damage a blocked swing absorbs: the -/// equipped off-hand shield's base `block_value` plus a Strength term (`Str / 20`, vanilla-ish), or 0 if -/// the unit has no shield, has a non-shield in the off-hand, or the shield is broken. Drives BOTH the -/// block-band gate (`effective_block_bp`) and the flat subtraction on a `HIT_BLOCK` swing. Creatures carry -/// no equipment so this is always 0 for them; an unshielded player is likewise 0 — so the block band is -/// 0 and combat is byte-identical until a real shield is equipped (baseline-safe). [entity] pub(crate) fn effective_block_value(ctx: &ReducerContext, defender: &WorldEntity) -> u32 { let Some(inst) = crate::items::item_in_slot(ctx, defender.guid, crate::items::equip_slot::OFFHAND) else { - return 0; // nothing in the off-hand → no shield + return 0; }; let Some(tmpl) = ctx.db.game_item_template().entry().find(inst.entry) else { - return 0; // dangling instance (no template) → contribute nothing + return 0; }; if tmpl.inventory_type != crate::items::invtype::SHIELD || crate::items::item_is_broken(&tmpl, &inst) { - return 0; // a non-shield off-hand, or a broken shield, blocks nothing (mirrors broken-weapon=unarmed) + return 0; } - // Block value = the shield's flat base + Str/20 (the shielded defender's Strength term). The base is - // clamped ≥0 (an i32 column; today every shield is ≥0); Strength uses the EFFECTIVE value so a +STR - // buff/gear raises block too. let from_strength = effective_strength(ctx, defender) / 20; (tmpl.block_value.max(0) as u32).saturating_add(from_strength) } -/// The attacker's EFFECTIVE crit chance in basis points: the flat `CRIT_BP` base plus every active -/// `A_MOD_COMBAT(CRIT)` aura (a crit-rating buff like the test "Combat Insight"), clamped to -/// `[0, 10000]`. `bonus == 0` → exactly `CRIT_BP` (the no-aura crit band is byte-identical to before). -/// The ctx-aware twin of how `effective_strength` folds a stat buff into the swing — `roll_swing` builds -/// the table's crit band from this and `debug_compute_swing` reads it back. [entity] pub(crate) fn effective_crit_bp(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { - // Aura crit rating (A_MOD_COMBAT(CRIT)) PLUS the crit rating summed across equipped gear — both fold - // into the same flat `CRIT_BP` base via the pure core. No aura and no +crit gear → bonus 0 → exactly - // `CRIT_BP` (the no-gear/no-aura crit band is byte-identical to before — baseline-safe). - // Agility-derived crit, level-suppressed, ADDED to the aura+gear bonus so a higher-Agility attacker - // crits more (a zero-Agility creature contributes 0 → the flat base, baseline-safe). let bonus = crate::spell::combat_field_bonus(ctx, attacker.guid, crate::spell::COMBAT_CRIT) + crate::items::equipped_stat_bonus(ctx, attacker.guid, crate::items::EquipStat::Crit) + agility_crit_bp(effective_agility(ctx, attacker), attacker.level) as i32; crit_bp_with_bonus(bonus) } -/// The attacker's EFFECTIVE miss chance in basis points for a swing with the given `skill_diff` (the -/// caller computes it via `crate::skill::skill_diff_ctx`): the skill-derived `miss_chance_bp` base MINUS -/// every active `A_MOD_COMBAT(HIT)` aura + gear hit rating (hit rating REDUCES the chance to miss), -/// floored at 0. `bonus == 0` → exactly `miss_chance_bp(skill_diff)` (the no-aura miss band is -/// byte-identical to before). The hit-rating twin of `effective_crit_bp` — `roll_swing` builds the -/// table's miss band from this and `debug_compute_swing` reads it back. [entity] pub(crate) fn effective_miss_bp( ctx: &ReducerContext, attacker: &WorldEntity, skill_diff: u32, ) -> u32 { - // Aura hit rating (A_MOD_COMBAT(HIT)) PLUS the hit rating summed across equipped gear — both REDUCE - // the miss band (the pure core subtracts the bonus, floored at 0). No aura and no +hit gear → bonus 0 - // → exactly `miss_chance_bp(skill_diff)` (the no-gear/no-aura miss band is byte-identical — baseline-safe). let bonus = crate::spell::combat_field_bonus(ctx, attacker.guid, crate::spell::COMBAT_HIT) + crate::items::equipped_stat_bonus(ctx, attacker.guid, crate::items::EquipStat::Hit); miss_bp_with_bonus(miss_chance_bp(skill_diff), bonus) } -/// A unit's EFFECTIVE swing interval (ms): its `base_attack_time_ms` adjusted by every active -/// `A_MOD_SPEED(SPEED_SWING)` aura (signed haste/slow %, summed). The melee tick gates each swing on this, -/// so a haste buff (e.g. Quickening) makes the unit attack faster and an attack-speed SLOW (e.g. Thunder -/// Clap, a negative %) makes it slower — the swing-timer twin of the crit/hit folds. No speed aura → the -/// base time (the un-buffed swing cadence is byte-identical — baseline-safe). [entity] pub(crate) fn effective_swing_time(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { let speed = crate::spell::speed_bonus(ctx, attacker.guid, crate::spell::SPEED_SWING); swing_time_with_haste(attacker.base_attack_time_ms, speed) } -/// Authoritative movement speed in yards per second, folded from current aura effects. -/// Creature motion, movement checks and the Mount Projection share this calculation. -/// With no movement effects the result is `base`. pub(crate) fn effective_move_speed(ctx: &ReducerContext, guid: u64, base: f32) -> f32 { lyracore_shared::aura::run_speed( base, @@ -329,15 +212,6 @@ pub(crate) fn effective_move_speed(ctx: &ReducerContext, guid: u64, base: f32) - #[cfg(test)] use lyracore_shared::aura::mounted_move_pct; -/// The player's equipped WEAPON profile in inventory `slot`: `(damage_min, damage_max, delay_ms, -/// subclass)`, or `None` when the slot is EMPTY, its instance has no template, it holds a NON-weapon -/// (a shield/holdable frill in the off-hand, a relic/quiver in the ranged slot), or it holds a BROKEN -/// weapon (has durability and it hit 0 — that unit swings unarmed until it is repaired). The ONE -/// equipped-weapon reader: main-hand / off-hand / ranged all differ only in the slot they name, and -/// keeping three copies of this join is exactly how the "non-weapon" and "broken" gates drift apart. -/// The tooltip `damage_min`/`damage_max` are `f32`; rounded to whole points for the -/// integer swing math (vanilla weapon damage is whole numbers). `subclass` is the weapon subclass -/// (BOW=2, GUN=3, CROSSBOW=18, WAND=19, …) — only the ranged path reads it. [entity] fn weapon_profile_in_slot( ctx: &ReducerContext, player_guid: u64, @@ -346,10 +220,10 @@ fn weapon_profile_in_slot( let inst = crate::items::item_in_slot(ctx, player_guid, slot)?; let tmpl = ctx.db.game_item_template().entry().find(inst.entry)?; if tmpl.class != lyracore_shared::constants::starter_item::CLASS_WEAPON { - return None; // a non-weapon in the slot contributes no weapon damage + return None; } if crate::items::item_is_broken(&tmpl, &inst) { - return None; // a BROKEN weapon (has durability and it hit 0) gives no damage + return None; } let bonus = crate::items::item_stat_bonus(ctx, &inst, &tmpl, crate::items::EquipStat::WeaponDamage); @@ -365,9 +239,6 @@ fn add_weapon_damage(base: f32, bonus: i32) -> u32 { (base.round() as i64 + i64::from(bonus)).clamp(0, i64::from(u32::MAX)) as u32 } -/// The player's equipped main-hand WEAPON damage profile `(damage_min, damage_max, delay_ms)`, or -/// `None` when unarmed (see [`weapon_profile_in_slot`] for the full "no weapon here" rule) → the -/// swing falls back to the unarmed range. [entity] fn equipped_weapon_damage(ctx: &ReducerContext, player_guid: u64) -> Option<(u32, u32, u32)> { weapon_profile_in_slot( ctx, @@ -377,11 +248,6 @@ fn equipped_weapon_damage(ctx: &ReducerContext, player_guid: u64) -> Option<(u32 .map(|(dmin, dmax, delay, _)| (dmin, dmax, delay)) } -/// The player's equipped OFF-HAND WEAPON damage profile `(damage_min, damage_max, delay_ms)` — dual -/// wield. `None` when the off-hand is empty, holds a non-weapon (a shield/holdable frill, which -/// `effective_block_value` reads instead), or holds a BROKEN weapon. Unlike main-hand there is no -/// unarmed fallback — no live off-hand weapon means no second swing stream at all (the tick simply -/// skips the off-hand roll). [entity] pub(crate) fn equipped_offhand_weapon_damage( ctx: &ReducerContext, player_guid: u64, @@ -390,10 +256,6 @@ pub(crate) fn equipped_offhand_weapon_damage( .map(|(dmin, dmax, delay, _)| (dmin, dmax, delay)) } -/// The player's equipped RANGED weapon profile `(damage_min, damage_max, delay_ms, subclass)` from the -/// ranged slot (17) — a bow/gun/crossbow (Auto Shot) or a wand (Shoot) — or `None` when nothing usable -/// is equipped there. The ranged swing tick uses `delay_ms` as its swing interval and rolls damage from -/// `[damage_min, damage_max]`. [entity] pub(crate) fn equipped_ranged_weapon( ctx: &ReducerContext, player_guid: u64, @@ -401,10 +263,6 @@ pub(crate) fn equipped_ranged_weapon( weapon_profile_in_slot(ctx, player_guid, crate::items::equip_slot::RANGED) } -/// The player's loaded ammo: the lowest-slot inventory item that is Projectile ammo (item class 6 — -/// arrows subclass 2 / bullets 3) with stock left. Bow/gun/crossbow shots consume one per shot; `None` = -/// out of ammo (the engagement ends). Deliberate simplification: LOOSE match — any class-6 item -/// fires (we don't enforce the bow→arrow / gun→bullet subclass pairing); any ammo is fine for v1. pub(crate) fn find_ammo(ctx: &ReducerContext, player_guid: u64) -> Option { let templates = ctx.db.game_item_template(); lyracore_shared::item::loaded_ammo( @@ -422,25 +280,14 @@ pub(crate) fn find_ammo(ctx: &ReducerContext, player_guid: u64) -> Option 0 && ctx.random::() % 100 < DURABILITY_WEAR_CHANCE_PCT { inst.durability -= 1; ctx.db.game_item_instance().guid().update(inst); @@ -448,31 +295,14 @@ pub(crate) fn wear_weapon(ctx: &ReducerContext, player_guid: u64) { } } -/// The `[min, max]` damage range for one of `attacker`'s swings: the flat creature range (creatures -/// carry no attributes) SCALED by the creature's classification rank (elite/rare/boss hit harder), the -/// equipped-weapon range (a player wielding a main-hand weapon), or the unarmed stat range (an unarmed -/// player). The ctx-aware entry point `roll_swing` / `debug_compute_swing` use so the equipped weapon -/// (and now creature rank) actually drives live damage. The pure AP cores (`player_swing_range_ap` / -/// `weapon_swing_range_ap`) carry the unarmed/weapon math (and its unit tests); this layers the ctx -/// reads — effective Strength, aura AP, the equipped weapon, and the creature rank — on top. pub(crate) fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> (u32, u32) { - // Warrior Disarm (A_DISARM): a disarmed attacker's main-hand weapon is stripped. Read ONCE — the - // creature and player branches handle it differently (a creature has no weapon/unarmed split). `false` - // for any un-disarmed unit, so the whole function stays byte-identical without an A_DISARM aura. let disarmed = crate::spell::is_disarmed(ctx, attacker.guid); if !attacker.is_player() { - // Per-creature base swing: the template's imported cmangos MinMeleeDmg/MaxMeleeDmg, - // falling back to the flat CREATURE_MELEE_MIN/MAX when not imported (`damage_max == 0`) so the - // seed chicken + any un-updated row are byte-identical to before. Then ELITE/RARE/BOSS scaling - // multiplies by the template rank (rank 0 → ×1.0). let tmpl = ctx.db.game_creature_template().entry().find(attacker.entry); let (base_min, base_max) = match &tmpl { Some(t) if t.damage_max > 0 => (t.damage_min, t.damage_max), _ => (CREATURE_MELEE_MIN, CREATURE_MELEE_MAX), }; - // Imported creature damage is anchored to the template's minimum level. An advancing - // Hunter pet scales that range with its durable live level; summoned and wild creatures - // retain the existing template range. let (base_min, base_max) = match (&tmpl, crate::creatures::live_pet_kind(ctx, attacker.guid)) { (Some(t), crate::creatures::PetKind::Hunter { .. }) => { @@ -488,20 +318,11 @@ pub(crate) fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> ( let rank = tmpl.map(|t| t.rank).unwrap_or(0); let (min, max) = scale_swing_for_rank(base_min, base_max, rank); if disarmed { - // A creature has no weapon/unarmed split (its swing is a flat template range), so instead of - // zeroing damage we retain a documented fraction (`DISARM_SWING_RETAINED_PCT`) — a tuning - // approximation of "lost its weapon" that keeps the mechanic meaningful. See its const doc. let pct = crate::spell::DISARM_SWING_RETAINED_PCT; return (min * pct / 100, max * pct / 100); } return (min, max); } - // Effective AP = the stat-derived base (from EFFECTIVE Strength, base + any A_MOD_STAT(STR) buff - // like Mark of the Wild) + every active self-buff aura's AP (Battle Shout). Folded in BEFORE the - // per-swing bonus so a buff scales with weapon speed, like vanilla. With no auras the effective - // Strength equals base and `extra == 0` → the range is byte-identical to the delegating wrappers. - // Class-specific AP curve: Rogue/Hunter fold in effective AGILITY; every other class keeps the - // Str-class curve (byte-identical). let class = attacker.class(); let ap = melee_attack_power_for( class, @@ -509,9 +330,6 @@ pub(crate) fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> ( effective_agility(ctx, attacker), attacker.level, ) + aura_attack_power_bonus(ctx, attacker.guid); - // A DISARMED player ignores the equipped main-hand weapon and swings for its UNARMED range (the - // player has a real unarmed damage model, unlike a creature). An un-disarmed player takes the - // equipped-weapon path exactly as before (baseline-safe). if !disarmed { if let Some((dmin, dmax, delay)) = equipped_weapon_damage(ctx, attacker.guid) { return weapon_swing_range_ap(ap, dmin, dmax, delay); @@ -520,16 +338,6 @@ pub(crate) fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> ( player_swing_range_ap(ap, attacker.base_attack_time_ms) } -/// Damage of a PHYSICAL weapon-based ABILITY (Mortal Strike and other instant strikes) by -/// `caster` vs `target`: roll the caster's weapon swing range (`swing_range_ctx` — the same weapon + AP -/// the auto-attack uses), add the ability's flat `bonus`, roll a MELEE crit (`×CRIT_MULT` at the caster's -/// effective crit), then shave the target's armor (`armor_mitigation_pct`), floored at 1. Returns the -/// pre-absorb damage AND whether the crit landed; the caller folds absorb + threat + the kill via -/// `spell::apply_target_damage`, and carries the crit into the `Hit` so a crit-only Proc can read it — -/// the roll happens here, so nowhere else can tell. Reusing the swing/crit/armor math means an ability -/// scales off the weapon like a real strike, not a flat number. (Slice scope: specials always land — no -/// miss/dodge/parry roll yet; the attack table for yellow hits is a follow-up.) Uses the module RNG -/// (`ctx.random`, deterministic per reducer transaction). [entity] pub(crate) fn weapon_strike_damage( ctx: &ReducerContext, caster: &WorldEntity, @@ -545,22 +353,6 @@ pub(crate) fn weapon_strike_damage( ((pre_mit * (100 - mit_pct) / 100).max(1), crit) } -/// Warrior Retaliation (`A_RETALIATE`): when `victim_guid` takes a REAL incoming melee hit from -/// `attacker_guid` and carries an active A_RETALIATE aura, fire ONE free main-hand counter-swing back at the -/// attacker. Reuses the E_WEAPON_STRIKE path — `weapon_strike_damage` (the victim's own weapon + AP + crit + -/// armor) rolls the blow, `apply_target_damage` folds absorb/threat/the kill. Hooked from -/// `break_auras_on_damage`'s melee block (`attacker_guid != 0`), so ONLY a genuine incoming swing triggers it. -/// -/// No recursion / infinite loop: the counter-swing lands via `apply_target_damage`, which calls -/// `break_auras_on_damage` with the attacker sentinel `0` (it's an ability hit, not a tracked melee swing), so -/// the counter-swing can NEVER itself re-enter this fn — a Retaliating attacker's own aura is not provoked by -/// the counter, matching vanilla (only real incoming swings trigger). No-op for a victim without an -/// A_RETALIATE aura (the common path) or when either combatant is gone/dead. -/// -/// Known limitation: the vanilla ~30-counter-per-Retaliation cap is DEFERRED — tracking a -/// per-aura counter needs a mutable bookkeeping field on the aura row; the 15s aura window -/// already bounds the effect for the slice. -/// [entity] pub(crate) fn retaliate_on_hit(ctx: &ReducerContext, victim_guid: u64, attacker_guid: u64) { let carries_retaliate = ctx .db @@ -579,13 +371,9 @@ pub(crate) fn retaliate_on_hit(ctx: &ReducerContext, victim_guid: u64, attacker_ return; }; if victim.dead || attacker.dead { - return; // a dead combatant doesn't trade blows + return; } - // The counter-swing's crit is rolled and spent inside the damage: a Triggered hit raises no proc - // event, so there is no Proc to read the flag. let (dmg, _crit) = weapon_strike_damage(ctx, &victim, &attacker, 0); - // Marked Triggered: the counter-swing is not an action the retaliator took, so it grants nothing - // and raises no proc event — the same structural reason a Triggered Cast cannot chain a proc. crate::spell::apply_target_damage( ctx, attacker_guid, @@ -595,13 +383,6 @@ pub(crate) fn retaliate_on_hit(ctx: &ReducerContext, victim_guid: u64, attacker_ ); } -/// Roll one melee swing of `attacker` against `target`: `(damage, hit_info, blocked_amount)`. One roll -/// picks the attack-table outcome (bands scaled by the attacker/target level difference, the defender's -/// agility, and — when the defender has a shield — a block band); a second roll gives the damage variance -/// for a landed hit. Avoidance (miss/dodge/parry) deals 0; glancing lands reduced damage and can't crit; a -/// crit is 2× the base; a BLOCK lands a normal hit minus the shield's flat `block_value` (the absorbed -/// amount is the third tuple element, for the wire). Uses the module RNG (`ctx.random`, deterministic per -/// reducer transaction). pub(crate) fn roll_swing( ctx: &ReducerContext, attacker: &WorldEntity, @@ -621,32 +402,15 @@ pub(crate) fn roll_swing_with_range( target: &WorldEntity, range: (u32, u32), ) -> (u32, u8, u32) { - // The defender's flat shield block value, computed ONCE: it both gates the block band (0 → no band, - // baseline-safe) and is the flat amount subtracted on a HIT_BLOCK below. let block_value = effective_block_value(ctx, target); - // The skill difference (defender defense skill − attacker weapon skill) driving the four skill-based - // bands, computed ONCE. With no tracked skill rows this is exactly `(target.level − attacker.level)*5` - // — the level-derived formula — so an all-untracked fight's table is byte-identical (baseline-safe). let sd = crate::skill::skill_diff_ctx(ctx, attacker, target); let table = AttackTable { - // Miss/crit fold in the attacker's A_MOD_COMBAT(HIT)/(CRIT) auras: hit rating REDUCES miss, - // crit rating RAISES crit. With no such aura these equal the flat `miss_chance_bp`/`CRIT_BP`, - // so an un-buffed swing's table is byte-identical to before. miss: effective_miss_bp(ctx, attacker, sd), - // Dodge/parry/block fold the defender's A_MOD_COMBAT(DODGE/PARRY/BLOCK) auras (talents like - // Deflection / Shield Spec) on top of the agility/skill bands. No such aura → the plain band - // (baseline-safe). Block stays shielded-defender-only (0 without a shield, even with a block aura). dodge: effective_dodge_bp(ctx, target, sd), parry: effective_parry_bp(ctx, target, sd), glancing: glancing_chance_bp(attacker.level, target.level), - // Block is a shielded-DEFENDER-only band (walks after glancing, before crit). 0 for any unit - // without an equipped shield (every creature, an unshielded player), so those tables are - // byte-identical to before (baseline-safe). block: effective_block_bp(ctx, target, sd, block_value), crit: effective_crit_bp(ctx, attacker), - // Crushing is a CREATURE→PLAYER-only band: a mob 3+ levels above a player lands amplified hits. - // 0 on every other path (player→mob, PvP, equal/lower-level creature), so those tables are - // byte-identical to before (baseline-safe). crushing: if !attacker.is_player() && target.is_player() { crushing_chance_bp(attacker.level, target.level) } else { @@ -663,19 +427,11 @@ pub(crate) fn roll_swing_with_range( let pre_mit = match outcome { HIT_CRIT => base * CRIT_MULT, HIT_GLANCING => (base * GLANCING_DAMAGE_PCT / 100).max(1), - HIT_CRUSHING => base * CRUSH_DAMAGE_PCT / 100, // amplified hit (150%) — the glancing twin - _ => base, // HIT_NORMAL and HIT_BLOCK both land the plain base; block's reduction is applied below + HIT_CRUSHING => base * CRUSH_DAMAGE_PCT / 100, + _ => base, }; - // Functional armor: the target's armor shaves a percent off the landed hit, applied after - // crit/glancing, vanilla-style. A 0-armor creature target is unaffected; the player's - // agility-derived armor softens incoming creature swings. Floored at 1 (a landed hit always lands). let mit_pct = armor_mitigation_pct(effective_armor(ctx, target), attacker.level); let dmg = (pre_mit * (100 - mit_pct) / 100).max(1); - // Shield block: a HIT_BLOCK lands for (post-armor damage − flat block_value), clamped ≥0. This is a - // physical mitigation like armor, applied here (before the spell-side damage-taken%/absorb folds in - // `fold_incoming_damage`). Unlike other landed hits it is NOT re-floored at 1 — a shield strong enough to - // cover the whole swing is a vanilla "full block" (0 damage). `blocked` is the amount actually - // absorbed (never more than the pre-block damage), reported on the wire so the client shows "Block N". if outcome == HIT_BLOCK { let blocked = block_value.min(dmg); return (dmg - blocked, HIT_BLOCK, blocked); @@ -683,12 +439,6 @@ pub(crate) fn roll_swing_with_range( (dmg, outcome, 0) } -/// Roll one RANGED shot of `attacker` vs `target` from a `[dmin, dmax]` ranged weapon: -/// `(damage, hit_info)`. A reduced attack table vs melee — miss / crit / normal only (a shot can't be -/// dodged-in-v1, parried, or blocked). The miss + crit bands reuse the same `effective_miss_bp` / -/// `effective_crit_bp` as melee; a crit is `CRIT_MULT`×; armor mitigation shaves the landed hit (floored -/// at 1). Known limitation: weapon damage only — no ranged attack-power scaling and no -/// dodge/glancing band in v1. pub(crate) fn roll_ranged_swing( ctx: &ReducerContext, attacker: &WorldEntity, @@ -708,10 +458,6 @@ pub(crate) fn roll_ranged_swing( (dmg, if is_crit { HIT_CRIT } else { HIT_NORMAL }) } -/// Roll a copper loot amount in `[min, max]` via the module RNG. 0 if the range is empty -/// (`max == 0`), so a creature with no money loot drops nothing and its corpse isn't made lootable. -/// `pub(crate)` so the E_PICKPOCKET handler reuses the SAME money-range roll the kill path uses (one -/// formula, not two). pub(crate) fn roll_money(ctx: &ReducerContext, min: u32, max: u32) -> u32 { if max == 0 || max < min { return 0; @@ -737,10 +483,10 @@ mod seal_tests { #[test] fn seal_swing_holy_scales_with_coeff_and_speed() { - assert_eq!(seal_swing_holy(0, 2000), 0); // no seal → nothing (unsealed swing unchanged) - assert_eq!(seal_swing_holy(108, 2000), 2); // Seal of Righteousness r1 on a 2.0s weapon - assert_eq!(seal_swing_holy(216, 2000), 4); // r10 → double the per-swing holy - assert_eq!(seal_swing_holy(216, 3600), 7); // a slower weapon lands more per swing + assert_eq!(seal_swing_holy(0, 2000), 0); + assert_eq!(seal_swing_holy(108, 2000), 2); + assert_eq!(seal_swing_holy(216, 2000), 4); + assert_eq!(seal_swing_holy(216, 3600), 7); } } @@ -748,28 +494,20 @@ mod seal_tests { mod next_swing_tests { use super::queued_strike_bonus; - // The pure bonus-add logic of the on-next-swing queue (Heroic Strike / Cleave). The ctx-aware - // `queued_strike_on_swing` reads `next_swing_spell` + the effect base_points and delegates here; this - // tests the bonus computation + the no-queue clear path in isolation. #[test] fn no_queue_adds_nothing() { - // queued_spell_id == 0 → no queued strike → 0 bonus, regardless of any base_points value (an - // un-queued swing is byte-identical to a plain white swing). assert_eq!(queued_strike_bonus(0, 0), 0); - assert_eq!(queued_strike_bonus(0, 137), 0); // a stale base_points is ignored when nothing is queued + assert_eq!(queued_strike_bonus(0, 137), 0); } #[test] fn queued_strike_adds_its_flat_base() { - // A queued Heroic Strike (78) adds its flat E_NEXT_SWING base_points to the swing. - assert_eq!(queued_strike_bonus(78, 11), 11); // Heroic Strike r1 flat bonus - assert_eq!(queued_strike_bonus(845, 5), 5); // Cleave's queued bonus + assert_eq!(queued_strike_bonus(78, 11), 11); + assert_eq!(queued_strike_bonus(845, 5), 5); } #[test] fn non_positive_base_clamps_to_zero() { - // A queued spell whose effect base is 0 / negative only ever ADDS (never subtracts) — clamp to 0, - // so a mis-imported queued spell can't turn a swing into a heal. assert_eq!(queued_strike_bonus(78, 0), 0); assert_eq!(queued_strike_bonus(78, -50), 0); } @@ -779,23 +517,19 @@ mod next_swing_tests { mod react_window_tests { use super::react_window_active; - // The pure window-active check the Overpower / Revenge cast gate uses. A window is OPEN while its - // stored deadline is strictly AFTER now; a 0 deadline (never armed) or a lapsed one is CLOSED. #[test] fn unarmed_window_is_closed() { - // 0 = never armed → the gate refuses (Overpower can't be cast without a dodge). assert!(!react_window_active(0, 1_000)); assert!(!react_window_active(0, 0)); } #[test] fn armed_window_is_open_until_deadline() { - // Armed at now=1000 for 5s → deadline 6000. Open while now < 6000, closed at/after it. let until = 6_000u64; - assert!(react_window_active(until, 1_000)); // just armed → open - assert!(react_window_active(until, 5_999)); // 1ms before expiry → still open - assert!(!react_window_active(until, 6_000)); // exactly at the deadline → closed (strict >) - assert!(!react_window_active(until, 6_001)); // past the deadline → closed + assert!(react_window_active(until, 1_000)); + assert!(react_window_active(until, 5_999)); + assert!(!react_window_active(until, 6_000)); + assert!(!react_window_active(until, 6_001)); } } @@ -805,29 +539,20 @@ mod mounted_speed_tests { #[test] fn mounted_speed_folds_only_while_mounted() { - // Slow mount (+60%), no ordinary buff, mounted: the fold sees the full +60%. assert_eq!(mounted_move_pct(0, 60, true), 60); - // Fast mount (+100%), mounted. assert_eq!(mounted_move_pct(0, 100, true), 100); - // Same mounted-speed aura amount, but NOT mounted (A_MOUNTED absent) — contributes nothing. assert_eq!(mounted_move_pct(0, 60, false), 0); - // Mounted AND snared: both compose (a mount doesn't cancel an active slow). assert_eq!(mounted_move_pct(-20, 60, true), 40); } #[test] fn dismounting_keeps_an_ordinary_move_speed_buff_instead_of_forcing_bare_base() { - // Dismounted with an ordinary SPEED_MOVE buff (e.g. a totem, +20%) still up: base + that - // buff, never a blind reset to bare base. assert_eq!(mounted_move_pct(20, 0, false), 20); - // The same buff plus a (now inert, unmounted) leftover SPEED_MOUNTED reading: still just the - // ordinary buff — the mount's speed never leaks in once A_MOUNTED is gone. assert_eq!(mounted_move_pct(20, 60, false), 20); } #[test] fn nominal_60_and_100_percent_mount_tiers_yield_the_exact_run_speed() { - // BASE_RUN_SPEED (7.0 yd/s) at +60% (slow mount) and +100% (fast mount). assert!((move_speed_with_pct(7.0, mounted_move_pct(0, 60, true)) - 11.2).abs() < 1e-4); assert!((move_speed_with_pct(7.0, mounted_move_pct(0, 100, true)) - 14.0).abs() < 1e-4); } diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 801164c27..2639ecf0e 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -1,28 +1,3 @@ -//! Combat — melee auto-attack, **bidirectional** with **per-unit attack speed**: each unit swings on -//! its own `base_attack_time_ms`, and a creature that is attacked **retaliates** and swings back at its -//! own speed. Each swing writes a `game_combat_event` row for the gateway to relay as -//! `SMSG_ATTACKERSTATEUPDATE` (swing animation + damage text); the health drop flows through the -//! `game_world_entity` `on_update` → VALUES relay. A creature target dies at 0 HP (DESTROY + respawn); -//! a **player** target also dies at 0 HP (health=0 + `dead` → the death/release/ghost corpse-run, see -//! `world::repop`). [entity]/[event]/[server] -//! -//! Submodules (split along this file's own comment banners, on top of the shared damage -//! pipeline): -//! - `tables` — the ctx-free hit-table/damage/regen math (pure formulas, exhaustive tests; unchanged -//! by the split — it is the model file for the module). -//! - `folds` — effective-value folds over aura/gear (dodge/parry/block/crit/miss/swing-time/armor/ -//! strength/agility), seal-on-swing + queued-strike, the react-window, equipped-weapon/ammo -//! lookups, and the `roll_swing`/`roll_ranged_swing` family. -//! - `death` — `kill_creature`/`kill_player` (the two death chokepoints) + the shared damage -//! pipeline (`fold_incoming_damage`/`final_damage`/`apply_hit`) every damage resolver routes -//! through. -//! - `engage` — the engagement model: enter/disengage/queries over `game_melee_attack`, the -//! engagement tables, and the start/stop-attack reducers. -//! - `swing` — the tick passes (`tick_melee`'s leash/aggro/resolve) + the resolvers that fire a hit -//! (`fire_melee_swing`/`resolve_offhand_swing`/`fire_ranged_shot`/`ranged_impact`). -//! -//! Named exports below hold the operations and tables shared with other Module files. - pub(crate) mod death; mod engage; mod folds; diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 5a3c13c8b..4e50d3b89 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -1,10 +1,3 @@ -//! The swing tick (split of the former monolithic `combat/mod.rs`, on top of the shared damage -//! pipeline): `tick_melee`'s three passes (`leash_pass`/`aggro_pass`/`resolve_swing`), the positional -//! gate (`swing_blocked`), and the resolvers that actually roll + fire a hit (`fire_melee_swing`, -//! `resolve_offhand_swing`, `fire_ranged_shot`, and the scheduled `ranged_impact` reducer). Every -//! resolver here routes through `death`'s shared `fold_incoming_damage`/`final_damage`/`apply_hit` -//! pipeline. See `damage_pipeline_drift_tests` below, which pins that wiring. `mod.rs` names the operations shared with other Module files. - use spacetimedb::{reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::spell::SpellCastEventKind; @@ -13,56 +6,18 @@ use crate::{ SpellCastEvent, WorldEntity, }; -// Shared combat operations and formulas are named in the parent module. use super::*; -/// Swing tick (scheduled; scheduler-only). Two passes: -/// 1. **Aggro** — a creature attacked by a player retaliates: ensure a reciprocal creature→player -/// engagement exists (one target at a time). No creature AI beyond "fight whoever hit me". -/// 2. **Swings** — for each engagement in range whose swing timer elapsed, deal damage -/// (player→7, creature→2). A creature target dies at 0 HP (DESTROY + free attackers + arm -/// respawn); a **player** target also dies at 0 HP (health=0 + `dead`, then release→ghost). -/// -/// A leash pass runs first: a creature whose pursuit deadline expired with the target away from the -/// remembered refresh position evades. -/// -/// (per-instance ticks): DELIBERATELY LEFT GLOBAL, one schedule row, no `instance_id` -/// scoping. Verified same-instance-by-construction: every pass here outer-loops `game_melee_attack`, -/// and a melee row's attacker/target pair shares one instance at ARM time on every arming path — -/// `apply_start_attack`/`apply_start_ranged_attack` reject a cross-instance target explicitly, this -/// file's `aggro_pass` retaliation mirrors an existing (same-instance) row, and `tick_creatures`'s -/// aggro/assist/pet passes pair within one instance. So this tick's cost is O(active -/// engagements) — it scales with combat, NOT with instance count, and scoping it per instance would -/// divide an already-small table while adding a per-row entity fetch. (Known pre-existing edge, not -/// widened here: `teleport_player` can move one side of a live pair cross-instance AFTER -/// arming; the leash pass then evades the creature on raw coordinate distance.) The tick_ms -/// smoothing knob is `tick_creatures`'s per-instance row; melee swing timing is already 100ms -/// globally. #[reducer] pub fn tick_melee(ctx: &ReducerContext, _schedule: MeleeSchedule) { if ctx.sender() != ctx.database_identity() { return; } - // Three named passes, run in this order. Each pass re-derives its own table handles / - // now-timestamp from `ctx` (SpacetimeDB handles are live views, so a re-fetch is behavior-identical). - // There is NO cross-pass shared state: the leash `evaders`, the aggro `new_aggro`, and the swing - // `attacks` Vecs are each created and consumed within one pass. leash_pass(ctx); aggro_pass(ctx); resolve_swing(ctx); } -/// Pass 0 — leash. A creature evades once its pursuit deadline has expired AND the target has left -/// `LEASH_RADIUS_SQ` of the position remembered at the last damage exchange (`ai::should_evade`), or -/// unconditionally past the absolute backstop. Evading drops ALL its combat and heals it to full (the -/// on_update health-VALUES relay refills the bar); the return pass walks it home. -/// -/// Distance alone does not end a fight: while damage keeps flowing the engagement is refreshed, so combat -/// continues however far it travels from the creature's camp. -/// -/// A row whose clock has never been stamped (freshly armed, or auto-migrated from before the field -/// existed) is SEEDED here instead of judged — otherwise an engagement that never sees a damage exchange -/// would never time out, and a max-range Auto Shot pull would evade before its first shot landed. fn leash_pass(ctx: &ReducerContext) { let melee = ctx.db.game_melee_attack(); let entities = ctx.db.game_world_entity(); @@ -77,12 +32,9 @@ fn leash_pass(ctx: &ReducerContext) { ) else { continue; }; - // Only a creature's OWN engagement leashes (attacker is the creature, target is the player). if attacker.is_player() || evaders.contains(&attacker.guid) { continue; } - // The absolute backstop, on the raw creature→target distance: the only distance that still - // evades, so nothing stays in combat across a zone. 3D, as the old target cap was. let (dx, dy, dz) = ( target.x - attacker.x, target.y - attacker.y, @@ -96,8 +48,6 @@ fn leash_pass(ctx: &ReducerContext) { seeds.push((atk.attacker_guid, attacker.x, attacker.y)); continue; } - // The target measured against the REMEMBERED position, never the creature against its spawn. - // 2D like the wander/return-home math (z varies on slopes). let (rx, ry) = (target.x - atk.leash_x, target.y - atk.leash_y); if crate::creatures::should_evade(now_ms, atk.pursuit_ends_ms, rx * rx + ry * ry) { evaders.push(atk.attacker_guid); @@ -113,9 +63,7 @@ fn leash_pass(ctx: &ReducerContext) { } for creature in evaders { crate::creatures::eventai_on_evade(ctx, creature); - // Drop every engagement touching the creature (its attack + attacks on it). disengage(ctx, creature); - // Evade-heal to full (the on_update relay refills the bar for observers). if let Some(mut c) = entities.guid().find(creature) { if c.health != c.max_health { c.health = c.max_health; @@ -125,13 +73,11 @@ fn leash_pass(ctx: &ReducerContext) { } } -/// Pass 1 — aggro. A creature with an incoming player attack and no engagement of its own retaliates -/// against that attacker (one target at a time). Collect first, then insert (don't mutate while iterating). fn aggro_pass(ctx: &ReducerContext) { let melee = ctx.db.game_melee_attack(); let entities = ctx.db.game_world_entity(); - let mut new_aggro: Vec<(u64, u64)> = Vec::new(); // (creature, player) + let mut new_aggro: Vec<(u64, u64)> = Vec::new(); for atk in melee.iter() { let (Some(attacker), Some(target)) = ( entities.guid().find(atk.attacker_guid), @@ -139,33 +85,17 @@ fn aggro_pass(ctx: &ReducerContext) { ) else { continue; }; - // Retaliate only when the attacker is actually in MELEE RANGE (the same leeway gate the - // swing tick uses): arming melee auto-attack from 20 yd out must NOT aggro the creature — - // vanilla mobs engage on proximity-aggro range or a LANDED hit, never on a player merely - // entering attack stance at distance (user-observed bug). The armed row is harmless until - // the player closes: their first in-range swing lands the same tick this pass fires. - // A RANGED auto-repeat (Auto Shot / Shoot) retaliates at any distance — but only once a - // shot has actually FIRED (the swing tick's `enter_combat` stamped the target IN_COMBAT). - // Arming alone is NOT a hit: the first shot is seeded ~500ms out and can be - // rejected/suppressed, and retaliating at arm time let the wolf aggro-then-instantly-evade - // on a pull whose first shot never fired ("enters combat, leaves combat immediately"). + // Retaliation requires a fired attack; entering attack stance alone is not a hit. let is_ranged = atk.ranged_spell_id != 0; let dx = attacker.x - target.x; let dy = attacker.y - target.y; let dz = attacker.z - target.z; - // MELEE retaliation additionally requires a swing to have FIRED (`last_swing_ms` is 0 at - // arm/retarget and only the tail-of-loop stamp sets it, after the range/facing/LoS gates - // let a swing through). Without this a player standing IN range with their BACK to a - // neutral wolf aggro'd it just by toggling attack — the facing gate ate every swing, so - // the wolf was retaliating against an attack that never happened (user find; the melee - // twin of the ranged IN_COMBAT gate above). Costs one 100ms tick vs the old same-tick arm. if attacker.is_player() && !target.is_player() - && !target.dead // a corpse doesn't retaliate + && !target.dead && ((is_ranged && target.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0) - || (atk.last_swing_ms != 0 - && dx * dx + dy * dy + dz * dz <= MELEE_RANGE_LEEWAY_SQ)) + || (atk.last_swing_ms != 0 && dx * dx + dy * dy + dz * dz <= MELEE_RANGE_LEEWAY_SQ)) && melee.attacker_guid().find(target.guid).is_none() && !new_aggro.iter().any(|(c, _)| *c == target.guid) { @@ -177,12 +107,6 @@ fn aggro_pass(ctx: &ReducerContext) { } } -/// The OFF-HAND swing (dual wield): a second, independent attack rolled by -/// `resolve_swing` for a MELEE engagement whenever `attacker_guid` has a live off-hand weapon -/// (`equipped_offhand_weapon_damage` — a shield/holdable off-hand short-circuits at `None`, unchanged -/// swing count). Runs on its OWN clock — the off-hand weapon's own `delay_ms`, tracked in -/// `MeleeAttack::last_offhand_swing_ms` — so it fires at a different cadence than the main hand and is -/// NOT gated by the main-hand swing-timer `continue` in `resolve_swing`. fn resolve_offhand_swing( ctx: &ReducerContext, attacker_guid: u64, @@ -190,36 +114,26 @@ fn resolve_offhand_swing( attacker: &WorldEntity, now_ms: u32, ) { - // No off-hand weapon (empty, shield/holdable, or broken) → no second stream. The common case for - // every creature (no equipment) and every player without a second one-hander — a no-op that leaves - // `last_offhand_swing_ms` untouched, so this is byte-identical to before for them. let Some((dmin, dmax, delay)) = equipped_offhand_weapon_damage(ctx, attacker_guid) else { return; }; let melee = ctx.db.game_melee_attack(); let Some(atk) = melee.attacker_guid().find(attacker_guid) else { - return; // engagement row already gone (freed by something earlier this tick) + return; }; if atk.last_offhand_swing_ms != 0 && now_ms.wrapping_sub(atk.last_offhand_swing_ms) < delay { - return; // off-hand still on its own cooldown + return; } let Some(target) = ctx.db.game_world_entity().guid().find(target_guid) else { return; }; if attacker.dead || target.dead { - return; // mirrors resolve_swing's corpse guard + return; } - // Warrior Disarm (A_DISARM): mirrors the main-hand read in `swing_range_ctx`, folded - // into this range derivation too — a disarmed PLAYER drops to UNARMED on the main hand, so there is - // no dual-wield stream left to swing (an unarmed fighter has no off-hand weapon either). Without - // this the off-hand kept swinging at full value while Disarm stripped only the main hand. `false` - // for any un-disarmed attacker, so this stays a no-op without an A_DISARM aura (baseline-safe). if crate::spell::is_disarmed(ctx, attacker_guid) { return; } - // Effective AP identical to the main-hand fold (swing_range_ctx's player branch), scaled by the - // OFF-HAND weapon's own delay, then halved by the vanilla dual-wield penalty. let class = attacker.class(); let ap = melee_attack_power_for( class, @@ -234,9 +148,6 @@ fn resolve_offhand_swing( crate::spell::break_stealth(ctx, attacker_guid); - // The SHARED pipeline: outgoing % → incoming % → absorb → godmode, then the wire event, - // then the application (rage/skill/lethal fork/health/break-on-damage/threat). Identical to the - // main-hand swing by construction — this is exactly the copy whose Disarm gate drifted. let (dmg, _absorbed) = fold_incoming_damage(ctx, attacker_guid, target_guid, rolled); let damage = final_damage(ctx, target_guid, dmg); let lethal = is_lethal(target.health, damage.amount); @@ -246,8 +157,6 @@ fn resolve_offhand_swing( hit_info, killing_blow: lethal, blocked_amount: blocked, - // the off-hand stream is always melee, never a ranged auto-attack (ranged_spell_id/ - // ammo_display_id/impact_delay_ms/spell_swing stay at the baseline's 0/0/0/false). ..CombatEvent::signal_at(ctx, attacker, target_guid) }); @@ -260,8 +169,6 @@ fn resolve_offhand_swing( ) .combat_ended() { - // The kill's `disengage` already freed this engagement row, so there is no off-hand clock - // left to stamp (the stamp below would find nothing) — return like the main-hand path does. return; } @@ -271,8 +178,6 @@ fn resolve_offhand_swing( } } -/// Pass 2 — swings. For each engagement (re-read, since aggro may have added rows), run the SHARED -/// ELIGIBILITY GATE and, if it passes, fire the swing. fn resolve_swing(ctx: &ReducerContext) { let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; let melee = ctx.db.game_melee_attack(); @@ -280,8 +185,6 @@ fn resolve_swing(ctx: &ReducerContext) { let attacks: Vec = melee.iter().collect(); for atk in attacks { - // Drop the engagement if either party left the world (also covers rows freed earlier this - // tick by another engagement's killing blow). let Some(attacker) = entities.guid().find(atk.attacker_guid) else { melee.attacker_guid().delete(atk.attacker_guid); continue; @@ -291,39 +194,18 @@ fn resolve_swing(ctx: &ReducerContext) { continue; }; - // A corpse neither swings nor is a valid swing target. With a lingering corpse - // (the entity persists until the decay pass), a stale snapshot row whose attacker OR target - // was killed earlier in THIS tick survives the two existence guards above (the corpse row is - // still present). Without this guard such a row would: let a dead unit keep swinging - // (attacker.dead); re-enter the killing-blow branch on a 0-HP target (target.dead → double - // XP, duplicate death event, reset decay timer); and reach the `update` at the loop tail on a - // row already freed by this tick's killing-blow `disengage`, which PANICS on a missing PK and - // rolls back the entire tick. `delete` is idempotent if the row was already freed. if attacker.dead || target.dead { melee.attacker_guid().delete(atk.attacker_guid); continue; } if melee.attacker_guid().find(atk.attacker_guid).is_none() { - continue; // an earlier hit in this snapshot already tore the engagement down + continue; } if !crate::combat::may_harm(ctx, &attacker, &target) { melee.attacker_guid().delete(atk.attacker_guid); - continue; // friendship was restored, including an ended Duel + continue; } - // Crowd control: an ACTION-blocked attacker (stunned/polymorphed/feared) cannot swing. Gated HERE — after the existence/corpse - // guards (so a stale row is still reaped) but BEFORE the range/swing-timer checks below and the - // `last_swing_ms` write at the loop tail — so a stun does NOT even reset the swing timer (the - // attacker resumes its prior cadence the instant the stun ends, the vanilla feel). The - // engagement ROW is LEFT INTACT (we don't `delete` it): the unit is still in combat, just - // locked, so it retaliates the moment the stun lifts. `continue` only skips THIS row's swing — - // the outer `for mut atk in attacks` reads an owned snapshot Vec, so skipping a row never - // corrupts the iteration (no table cursor is held across the body). A ROOTED attacker is NOT - // gated here — root blocks movement, not attacks, so a rooted-but-in-range unit keeps swinging. - // Baseline-safe: `is_action_blocked` is `false` for any unit without a stun/poly/fear aura, so an - // un-CC'd swing reaches the identical range/timer/roll path it did before — byte-for-byte. A - // POLYMORPHED or FEARED attacker is action-blocked too, so it can't swing either (a feared unit - // routs without retaliating). if !attacker.is_player() && !crate::creatures::melee_enabled(ctx, attacker.guid) { continue; } @@ -331,20 +213,10 @@ fn resolve_swing(ctx: &ReducerContext) { continue; } - // A creature inside its ROUT window is RUNNING, not fighting — skip its swing while it routs. Like - // the CC gate above, the engagement ROW is LEFT INTACT: routing is a SHARED combat state (the flee - // pass keeps both sides in combat). Once the window closes the same creature swings again at - // whatever health it has left, and a low-HP humanoid whose rout never started (frozen, feared) - // keeps swinging throughout — both follow from the one shared predicate. if !attacker.is_player() && crate::creatures::tick::creature_is_routing(ctx, &attacker) { continue; } - // RANGED vs MELEE: a ranged auto-attack (Auto Shot 75 / wand Shoot 5019) reads the slot-17 - // weapon, fires on ITS delay over a longer range, and uses a reduced attack table; a melee - // engagement (ranged_spell_id == 0) is byte-identical to before. The ranged weapon is fetched - // ONCE (a swing needs its damage + delay); if it's gone (unequipped mid-fight) the engagement - // ends gracefully rather than firing bare-handed. let ranged = if atk.ranged_spell_id != 0 { match equipped_ranged_weapon(ctx, atk.attacker_guid) { Some(w) => Some(w), @@ -357,29 +229,13 @@ fn resolve_swing(ctx: &ReducerContext) { None }; - // (vanilla) A RANGED engagement whose shot comes DUE against a hard blocker (out of range / - // too close / no LoS / not facing) is TORN DOWN, not silently suppressed — the vanilla - // auto-repeat rule is that a failed check on a DUE ranged shot interrupts - // the loop, and the row's delete relays the server-initiated SMSG_CANCEL_AUTO_REPEAT that - // drops the client's toggle. Gated on DUE so a transient blocker BETWEEN shots (the mob - // circling behind you, an LoS flicker) never cancels — vanilla only checks when the shot is - // ready. Melee rows keep the silent per-tick `continue` (a melee swing resumes by itself). let ranged_due = ranged.is_some_and(|(_, _, delay, _)| now_ms.wrapping_sub(atk.last_swing_ms) >= delay); - // (vanilla) Realtime movement rule — vanilla applies this BEFORE any castability - // check: a PLAYER who is actually TRANSLATING (MOVE_MASK_MOVING — turning in place does - // not count) CANCELS a wand loop outright, and DEFERS an Auto Shot loop: the due shot - // re-arms RANGED_INITIAL_SHOT_MS out, over and over while moving, so the first shot after - // stopping lands ~0.5s later and never fires mid-run (user bug: "we can shoot while - // running"). Placed before the hard-fail gates so a moving out-of-range player defers - // rather than cancels (vanilla order). Creatures are exempt (server-driven movement). if let Some((_, _, delay, subclass)) = ranged { use crate::items::weapon_subclass as ws; if attacker.is_player() && attacker.movement_flags & MOVE_MASK_MOVING != 0 { if subclass == ws::WAND { - // Wand Shoot dies on movement (vmangos: Category 351 → InterruptSpell); the row - // delete relays the server-initiated SMSG_CANCEL_AUTO_REPEAT. melee.attacker_guid().delete(atk.attacker_guid); } else if ranged_due { if let Some(mut fresh) = melee.attacker_guid().find(atk.attacker_guid) { @@ -410,7 +266,7 @@ fn resolve_swing(ctx: &ReducerContext) { if ranged.is_none() { resolve_offhand_swing(ctx, atk.attacker_guid, atk.target_guid, &attacker, now_ms); if melee.attacker_guid().find(atk.attacker_guid).is_none() { - continue; // a lethal off-hand Duel hit removed both participants' attack rows + continue; } match entities.guid().find(atk.target_guid) { @@ -425,10 +281,6 @@ fn resolve_swing(ctx: &ReducerContext) { } } - // Swing timer — melee uses the ATTACKER's own `base_attack_time_ms` shortened by any melee-haste - // aura (`effective_swing_time`); ranged uses the equipped ranged weapon's `delay_ms` (bows are - // slow, wands faster). `wrapping_sub` guards the u32 millisecond clock wrapping; the 100ms tick - // gives 0.1s resolution. Known limitation: no ranged-haste fold in v1. let swing_interval = match ranged { Some((_, _, delay, _)) => delay, None => effective_swing_time(ctx, &attacker), @@ -452,12 +304,6 @@ fn resolve_swing(ctx: &ReducerContext) { } } -/// The POSITIONAL half of the swing gate: may `attacker` reach `target` this tick? `true` = blocked. -/// Checked in vanilla's order — max range, ranged MINIMUM range, line of sight, then facing — and -/// deliberately SIDE-EFFECT-FREE, because a block means different things to the two swing kinds and -/// that decision belongs to the caller: a melee row silently waits for the next tick, while a DUE -/// ranged row is torn down (vanilla's auto-repeat interrupt, whose row delete relays the -/// server-initiated SMSG_CANCEL_AUTO_REPEAT that drops the client's toggle). fn swing_blocked( ctx: &ReducerContext, attacker: &WorldEntity, @@ -466,21 +312,12 @@ fn swing_blocked( dist_sq: f32, now_ms: u32, ) -> bool { - // Range (re-checked each swing; the vanilla client walks into range itself). Ranged reaches - // farther than melee. let range_sq = if is_ranged { RANGED_RANGE_SQ } else { - // Melee LEEWAY: when BOTH the attacker and the target are moving (a chase), the swing reaches - // +8/3 yd farther — so a player chasing a FLEEING mob at run-speed parity (stuck ~5yd behind) - // can still land the hit. A standstill fight keeps the 5yd base. `last_move_ms` is the per-unit - // move clock (player: world::move; creature: the flee/chase tick). let both_moving = now_ms.wrapping_sub(attacker.last_move_ms) < MELEE_LEEWAY_WINDOW_MS && now_ms.wrapping_sub(target.last_move_ms) < MELEE_LEEWAY_WINDOW_MS; if both_moving { - // Direction-aware: only a PLAYER chasing a leg-quantized creature needs the - // padded 9 yd; a creature attacking a continuously-streamed player uses the - // exact classic 7.67 yd (see tables.rs). if attacker.is_player() { MELEE_RANGE_LEEWAY_SQ } else { @@ -493,17 +330,9 @@ fn swing_blocked( if dist_sq > range_sq { return true; } - // Auto Shot / wand Shoot have a MINIMUM range (~5 yd): a target in melee range is "too close" - // (vanilla SPELL_FAILED_TOO_CLOSE → InterruptSpell, so the player's next melee press is a clean - // single-press swap). Melee has no minimum. if is_ranged && dist_sq < MELEE_RANGE_SQ { return true; } - // 243: no swinging THROUGH geometry — a mob parked at the outside of a thin wall sits - // within the 5 yd 3D reach of a player just inside it (live find: the Rogue Wizard - // beat a wall-separated player to death by melee). One LoS ray per due swing, both - // directions symmetric (players can't hit through walls either). `has_los` is `true` - // whenever nav is off. if !crate::nav::has_los( ctx, attacker.map_id, @@ -513,10 +342,6 @@ fn swing_blocked( ) { return true; } - // Facing (PLAYERS only): vanilla refuses a swing at a target behind you — without this a fleeing - // player auto-attacked the chaser at their back. Creatures are exempt (they auto-face their - // victim; their `orientation` trails their movement direction, so gating them on it would break - // legitimate mob melee). if attacker.is_player() && !crate::spell::is_facing( attacker.x, @@ -531,14 +356,6 @@ fn swing_blocked( false } -/// Fire one MAIN-HAND MELEE swing that has already passed every eligibility gate in `resolve_swing`. -/// Rolls the full vanilla attack table, folds in the melee-only procs (the Overpower react window, the -/// seal's holy portion, a queued on-next-swing strike), writes the wire events, applies the damage -/// through the SHARED [`apply_hit`] pipeline, then arms the defender's Revenge window and stamps the -/// swing timer + IN_COMBAT on both sides. -/// -/// Returns early after a KILLING blow: the kill's `disengage` already freed the engagement row, so -/// there is no swing timer left to stamp and nothing left to flag in combat. fn fire_melee_swing( ctx: &ReducerContext, attacker: &WorldEntity, @@ -551,25 +368,12 @@ fn fire_melee_swing( let events = ctx.db.game_combat_event(); let melee = ctx.db.game_melee_attack(); - // The full vanilla attack table: miss / dodge / parry / glancing / block / crit / crushing. let (mut rolled, hit_info, blocked) = roll_swing(ctx, attacker, target); - // React windows: an avoidance outcome ARMS a reactive ability for ~5s. A DODGE of this swing arms - // the ATTACKER's Overpower; any avoid (dodge/parry/block) of it arms the DEFENDER's Revenge (armed - // AFTER the damage application below, so a partial block's `update(target)` can't clobber the - // stamp). Keyed on the HIT_* outcome, never a spell id — the cast gate (SPELL_ATTR_REQ_OVERPOWER / - // REQ_REVENGE) reads the stamped `*_until_ms`. Stamped BEFORE the lethal fork so the window always - // arms (the dodge already cost the attacker its swing). Ranged shots have no avoidance bands, so - // both windows are structurally melee-only now. if hit_info == HIT_DODGE { arm_react(ctx, attacker_guid, ReactKind::Overpower); } - // Seal proc (Seal of Righteousness): a LANDED swing adds the attacker's seal holy damage — the - // swing READS the A_SEAL aura (pull model, no per-spell code). A 0-roll (miss/dodge/parry) lands - // nothing and doesn't proc. 114: the portion (and the seal's spell id) is TRACKED so it can be - // reported as its own yellow named line — it still folds into `rolled` (one health deduction, one - // lethal check). let (mut seal_portion, mut seal_spell) = (0u32, 0u32); if rolled > 0 { let (p, s) = seal_holy_on_swing(ctx, attacker); @@ -578,23 +382,8 @@ fn fire_melee_swing( rolled += seal_portion; } - // On-next-swing QUEUE (Heroic Strike / Cleave): a LANDED swing adds the attacker's queued strike - // bonus (the pull-model twin of the seal proc) — reads `attacker.next_swing_spell` and adds its - // E_NEXT_SWING base_points. A 0-roll lands nothing. The queue is CLEARED below whenever a swing - // FIRES (even a miss), so a missed swing still spends it (vanilla). The bonus flows through absorb - // / lethal like the rest of `rolled`. Vanilla REPLACES the white hit — the whole landed swing IS - // the spell (one yellow named line; live-confirmed 2026-07-11), so `queued_fired` marks the white - // event `spell_swing` (the gateway skips its ATTACKERSTATEUPDATE) and the spell row below carries - // the full post-mitigation damage. let queued_spell = attacker.next_swing_spell; - // 114: the cost was VALIDATED at press but is CHARGED here, when the spell actually fires - // (vanilla defers the rage deduction to the swing; a MISSED swing still charges — the spell - // cast and missed). FIZZLE: rage fell below cost between press and swing (a Battle Shout in - // between) → the strike doesn't fire, power is left alone, the swing stays a plain white swing, - // and the queue is cleared below. The fizzle row carries CAST_FAIL_NO_POWER, so the caster gets - // the cast-bar teardown AND a failed cast result naming the queued spell (vanilla's - // SendInterrupted + SendCastResult pair). The teardown alone left the 1.12 client holding the - // ability as its current melee spell: the button stayed lit and refused every later press. + // Charge queued-strike rage when the swing fires, including a miss. let mut swing_is_spell = false; if queued_spell != 0 && attacker.is_player() { let cost = ctx @@ -604,7 +393,6 @@ fn fire_melee_swing( .find(queued_spell) .map(|h| h.cost) .unwrap_or(0); - // Fresh row (read-your-writes): the seal/roll path above never wrote power. if let Some(mut a) = entities.guid().find(attacker_guid) { if a.power >= cost { a.power -= cost; @@ -612,8 +400,8 @@ fn fire_melee_swing( swing_is_spell = true; } else { ctx.db.game_spell_cast_event().insert(SpellCastEvent { - is_interrupted: true, // SMSG_SPELL_FAILURE → the caster's cast bar tears down - failure_reason: crate::spell::CAST_FAIL_NO_POWER, // → "Not enough rage" + is_interrupted: true, + failure_reason: crate::spell::CAST_FAIL_NO_POWER, ..SpellCastEvent::signal_at( ctx, attacker, @@ -624,89 +412,53 @@ fn fire_melee_swing( } } } else if queued_spell != 0 { - swing_is_spell = true; // non-player queued strikes (none today) fire without a pool + swing_is_spell = true; } let queued_fired = swing_is_spell && rolled > 0; if queued_fired { rolled += queued_strike_on_swing(ctx, attacker); } - // Spend the queue on ANY melee swing that fires (landed or missed) — re-fetches the row to clear it. - // Read-your-writes safe: the bonus above already read `attacker.next_swing_spell` from the snapshot. clear_queued_strike(ctx, attacker_guid); - // Stealth breaks on action: a swing that fires this tick (hit or miss) drops the attacker's - // A_STEALTH presence — a stealthed unit that attacks is revealed. The other action chokepoint is - // the cast path (resolve_cast_at). No-op for the common un-stealthed swing. crate::spell::break_stealth(ctx, attacker_guid); - // Durability: a player's main-hand weapon wears 1 point per swing (after the roll). Creatures - // don't wear gear. if attacker.is_player() { wear_weapon(ctx, attacker_guid); } - // The SHARED modifier fold: outgoing % → incoming % → absorb → godmode. `lethal` is needed - // HERE, before the events, for the wire's `killing_blow` flag; `apply_hit` re-derives it for the - // fork from the same shared predicate. let (dmg, _absorbed) = fold_incoming_damage(ctx, attacker_guid, target_guid, rolled); let damage = final_damage(ctx, target_guid, dmg); let lethal = is_lethal(target.health, damage.amount); let (projected_swing, projected_seal) = split_damage_projection(damage.amount, seal_portion); - // One swing event per swing; `lethal` (killing_blow) also tells the relay to send - // SMSG_ATTACKSTOP so the attacker leaves combat stance. Logged before any teardown so the - // event doesn't depend on whether the target row survives this tick. - // 114: the seal's holy portion gets its own named yellow line (the proc insert below), so the - // WHITE event reports the swing minus it. A fired queued strike goes further — the WHOLE swing - // is the spell (spell_swing → the gateway skips this event's ATTACKERSTATEUPDATE; the spell row - // below carries the full damage). `dmg` (the total) still drives health/lethal/rage; the seal - // subtraction is display-only (a pre-modifier flat, so a modified/absorbed swing's split is - // approximate — totals stay exact). events.insert(CombatEvent { damage: projected_swing, hit_info, killing_blow: lethal, blocked_amount: blocked, spell_swing: swing_is_spell, - // ranged_spell_id / ammo_display_id / impact_delay_ms stay at the baseline's 0: a melee - // engagement always carries 0 (that is what makes it melee), consumes no ammo, and lands - // instantly (only a projectile carries travel time). ..CombatEvent::signal_at(ctx, attacker, target_guid) }); - // 114 FIX (a): the queued on-next-swing strike FIRES with this swing — emit its cast event NOW - // (never at queue time). is_completion=true rides the timed-completion relay shape, which is - // exactly the vanilla on-next-swing fire: CAST_RESULT(OK) to the caster (releases the client's - // pending cast = un-lights the button) + SMSG_SPELL_GO alone (no second START) + the yellow - // "Heroic Strike hits..." damage log carrying the WHOLE landed swing (weapon roll + bonus, - // post-mitigation; the seal's portion stays on its own line). A MISSED swing still emits the - // row (damage 0, no log) — the GO must fire or the client's button stays lit forever. if swing_is_spell { ctx.db.game_spell_cast_event().insert(SpellCastEvent { target_guid, is_completion: true, damage: if queued_fired { projected_swing } else { 0 }, - // school stays at the baseline's 0 (physical — HS/Cleave are weapon strikes). is_crit: hit_info == HIT_CRIT, - // The swing outcome — on a 0-damage fire the relay shapes the GO miss list from it - // (yellow "Heroic Strike missed/was dodged/was parried", not a white MISS line). swing_hit_info: hit_info, ..SpellCastEvent::signal_at(ctx, attacker, queued_spell, SpellCastEventKind::Go) }); } - // 114 FIX (b): the seal's holy portion — a log-only row (is_proc_log): the gateway sends ONLY - // SMSG_SPELLNONMELEEDAMAGELOG named after the seal spell (yellow "Seal of Righteousness hits if projected_seal > 0 { ctx.db.game_spell_cast_event().insert(SpellCastEvent { target_guid, damage: projected_seal, - school: 1, // holy (school_mask 2 → index 1, the mask→index rule in resolve_cast_at) + school: 1, is_proc_log: true, ..SpellCastEvent::signal_at(ctx, attacker, seal_spell, SpellCastEventKind::ProcLog) }); } - // The SHARED application: rage both ways, weapon/defense skill-ups, the lethal fork through - // kill_player/kill_creature, the health write, break-on-damage, and threat. let combat_ended = apply_hit( ctx, attacker_guid, @@ -731,26 +483,21 @@ fn fire_melee_swing( ); } if combat_ended { - return; // the kill's `disengage` freed the row — nothing left to stamp + return; } - // Defender's Revenge window: armed AFTER the damage write above so a partial block (dmg>0, - // non-lethal) cannot clobber the stamp with a pre-stamp `target` snapshot. `arm_react` re-fetches - // the defender fresh, so it works for both the dmg==0 (dodge/parry) and dmg>0 (partial block) - // paths. A lethal block returned earlier — a dead defender needs no window. + // Arm Revenge after damage so a stale defender snapshot cannot erase its deadline. if matches!(hit_info, HIT_DODGE | HIT_PARRY | HIT_BLOCK) { arm_react(ctx, target_guid, ReactKind::Revenge); } - // Re-fetch fresh before the tail write: `resolve_offhand_swing` (run by the caller earlier this - // tick) may have persisted its own `last_offhand_swing_ms` stamp on this same row. + // Re-read before stamping; the off-hand may have updated its clock on this row. if let Some(mut fresh) = melee.attacker_guid().find(attacker_guid) { fresh.last_swing_ms = now_ms; melee.attacker_guid().update(fresh); } - // A swing FIRED this tick (hit or miss) → both combatants are IN COMBAT. Set the flag at the tail - // so it lands AFTER every entity write above; `enter_combat` re-fetches fresh. + // Enter combat after entity writes so a stale snapshot cannot erase the flag. enter_combat(ctx, attacker_guid); enter_combat(ctx, target_guid); } @@ -760,17 +507,6 @@ fn split_damage_projection(final_damage: u32, seal_portion: u32) -> (u32, u32) { (final_damage - seal, seal) } -/// Fire one RANGED shot (Auto Shot 75 / wand Shoot 5019) that has already passed every eligibility -/// gate in `resolve_swing`. Consumes ammo, rolls the REDUCED ranged attack table (miss / crit / -/// normal — a shot can't be dodged, parried or blocked here), folds the damage through the SHARED -/// modifier chain, and launches a PROJECTILE: the wire event relays the SMSG_SPELL_GO (arrow) NOW and -/// carries `impact_delay_ms`, while the scheduled `ranged_impact` applies the frozen damage through -/// the SAME [`apply_hit`] pipeline when the arrow actually lands — so the number, the health drop and -/// the projectile arrive together (user bug: "damage lands earlier than the projectile"). Lethality is -/// therefore decided AT IMPACT, which is why this event never claims `killing_blow`. -/// -/// Out of ammo ends the engagement: the row delete relays the server-initiated -/// SMSG_CANCEL_AUTO_REPEAT that drops the client's toggle. fn fire_ranged_shot( ctx: &ReducerContext, attacker: &WorldEntity, @@ -787,10 +523,6 @@ fn fire_ranged_shot( let melee = ctx.db.game_melee_attack(); let launcher = matches!(subclass, ws::BOW | ws::GUN | ws::CROSSBOW); - // Ammo: reaching here means a shot FIRES this tick. A launcher consumes 1 arrow/bullet from the - // bag and stamps its display id onto the event (so the gateway renders the arrow projectile); - // out of ammo ends the engagement, which stops the client's auto-shoot too. A wand consumes - // nothing (display 0). Consumed on the shot regardless of hit/miss, like vanilla. let ammo_display_id: u32 = if launcher { match find_ammo(ctx, attacker_guid) { Some(mut a) => { @@ -815,12 +547,9 @@ fn fire_ranged_shot( } } } else { - 0 // wand: flat damage, no ammo + 0 }; - // Ranged AP fold: a launcher scales with the shooter's Agility-derived ranged attack power, folded - // into the weapon range by speed exactly like melee; a wand is FLAT weapon damage (no ranged AP in - // vanilla). Known limitation: no ranged-AP aura (e.g. Aspect of the Hawk) — we have none. let (rdmin, rdmax) = if attacker.is_player() { let (_, rdmin, rdmax) = ranged_sheet_values( effective_agility(ctx, attacker), @@ -833,12 +562,9 @@ fn fire_ranged_shot( }; let (rolled, hit_info) = roll_ranged_swing(ctx, attacker, target, rdmin, rdmax); - // Stealth breaks on action — a shot that fires (hit or miss) reveals the shooter, exactly like a - // melee swing. crate::spell::break_stealth(ctx, attacker_guid); - // The SHARED modifier fold. Vanilla folds absorb at impact; this engine freezes that - // chain at launch, then resolves the lethal floor and damage log together at impact. + // Freeze mitigation at launch; resolve lethal floors and the damage log together at impact. let (dmg, _absorbed) = fold_incoming_damage(ctx, attacker_guid, target_guid, rolled); let speed = if subclass == ws::WAND { @@ -848,19 +574,13 @@ fn fire_ranged_shot( }; let travel_ms = crate::spell::projectile_travel_ms(dist_sq.sqrt(), speed); ctx.db.game_combat_event().insert(CombatEvent { - // Launch carries SPELL_GO only. The impact transaction emits the one authoritative damage - // log after it resolves fresh health and lethal-floor state. damage: 0, hit_info, ranged_spell_id, ammo_display_id, impact_delay_ms: travel_ms, - // killing_blow stays at the baseline's false (decided at IMPACT — health can move - // mid-flight); blocked_amount stays 0 (a shot can't be blocked in this engine). ..CombatEvent::signal_at(ctx, attacker, target_guid) }); - // A miss / fully-absorbed shot schedules nothing (the GO's miss list, or the absence of a damage - // log, is the whole story). if dmg > 0 { let land_at = ctx .timestamp @@ -878,20 +598,12 @@ fn fire_ranged_shot( is_crit: hit_info == HIT_CRIT, }); } - // Same fresh-row stamp discipline as the melee tail (the off-hand never runs on a ranged row, but - // the re-fetch is the file's established pattern for a row written mid-tick). if let Some(mut fresh) = melee.attacker_guid().find(attacker_guid) { fresh.last_swing_ms = now_ms; melee.attacker_guid().update(fresh); } } -/// Scheduled RANGED-projectile impact (scheduler-only): the arrow/bullet/bolt lands — apply the -/// launch-frozen post-mitigation damage through the SAME shared [`apply_hit`] pipeline the melee swing -/// uses (health/lethal via the shared kill helpers, rage both ways, weapon/defense skill-ups, -/// break-on-damage, threat), then stamp IN_COMBAT on both sides. Guards re-checked at landing: either -/// side gone or the target already dead → the shot fizzles silently (the client saw the arrow; vanilla -/// eats mid-flight kills the same way). Lethality is decided HERE from fresh health, never at launch. #[reducer] pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { if ctx.sender() != ctx.database_identity() { @@ -899,7 +611,7 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { } let entities = ctx.db.game_world_entity(); let Some(attacker) = entities.guid().find(shot.attacker_guid) else { - return; // the shooter left the world mid-flight + return; }; let Some(target) = entities.guid().find(shot.target_guid) else { return; @@ -908,14 +620,11 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { return; } if !crate::combat::may_harm(ctx, &attacker, &target) { - return; // Duel or faction authorization changed while the projectile was in flight + return; } let dmg = if target.godmode { 0 } else { shot.damage }; let damage = final_damage(ctx, shot.target_guid, dmg); - // The SHARED pipeline — the same one the main-hand and off-hand swings route through, so a - // shot can never again drift from a swing (a godmode-zeroed hit is now a full no-op here too, - // instead of running the survivor path with a 0 damage value). let outcome = apply_hit( ctx, shot.attacker_guid, diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 449010f2e..2315ebfd4 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -1,156 +1,65 @@ -//! Combat — the **ctx-free** melee math: the hit-table / damage / regen formulas and their -//! constants, with no `&ReducerContext` and no DB access. Pure functions only, so the whole region is -//! unit-testable in isolation (the tests live at the bottom of this file). The ctx/db layer that wires -//! these into live combat — `roll_swing`, `swing_range_ctx`, the `effective_*` aura family, -//! `kill_creature`, the reducers — lives in the parent `mod.rs` and re-exports this module -//! by name for its callers. - use crate::WorldEntity; -pub(crate) const MELEE_RANGE_SQ: f32 = 25.0; // (5 yd)² - // Melee LEEWAY (vanilla): a MOVING attacker swinging a MOVING target gets the combat-reach term (+4/3 yd) - // PLUS the famous +8/3yd running bonus — the full formula is MAX(5, reachA+reachB+4/3) + 8/3 when both are - // running, i.e. 5 + 4/3 + 8/3 = 9yd. This matters because the creature's flee leg snaps its AUTHORITATIVE - // position the full per-tick run distance (3.5yd @ RUN*0.5s) the instant the tick fires (the client only - // animates the glide), so right after a flee tick a run-speed-parity chaser reads up to ~8.5yd away for - // the brief window until it closes again — anything short of the full 9yd would miss that swing and read - // the mob as uncatchable. The full 9yd leeway covers that gap for a chaser already pinned at the 5yd - // boundary going into the tick (vanilla's exact formula value); it reduces, but doesn't eliminate, misses - // for a chaser who's settled farther back. A standstill fight stays at 5yd. +pub(crate) const MELEE_RANGE_SQ: f32 = 25.0; pub(crate) const MELEE_RANGE_LEEWAY_SQ: f32 = - (5.0 + 4.0 / 3.0 + 8.0 / 3.0) * (5.0 + 4.0 / 3.0 + 8.0 / 3.0); // (9 yd)² = 81 - // CREATURE-attacker leeway: the exact classic both-moving formula (5 + 8/3 ≈ 7.67 yd) WITHOUT the - // +4/3 leg-quantization pad above — that pad compensates for a leg-quantized TARGET (a fleeing - // creature's server position jumps a half-second leg at once), and a creature's target is a PLAYER - // whose position streams continuously (no jump to cover). With the full 9 yd a chasing mob tagged a - // fleeing player from visibly farther than classic (live report 2026-07-10; render-lead makes it - // look even longer than the server range). - // "Moving" = the unit's `last_move_ms` is within this window of now. Covers the 500ms creature flee/chase - // tick + the player's movement-packet cadence with margin, so a genuine chase reads as both-running while a - // standstill does not (no leeway leak into stationary combat). -pub(crate) const MELEE_RANGE_LEEWAY_CREATURE_SQ: f32 = (5.0 + 8.0 / 3.0) * (5.0 + 8.0 / 3.0); // (7.67 yd)² + (5.0 + 4.0 / 3.0 + 8.0 / 3.0) * (5.0 + 4.0 / 3.0 + 8.0 / 3.0); +pub(crate) const MELEE_RANGE_LEEWAY_CREATURE_SQ: f32 = (5.0 + 8.0 / 3.0) * (5.0 + 8.0 / 3.0); pub(crate) const MELEE_LEEWAY_WINDOW_MS: u32 = 1200; -pub(crate) const RANGED_RANGE_SQ: f32 = 1225.0; // (35 yd)² — ranged auto-attack max reach; inside MELEE_RANGE_SQ is "too close" (shot hard-fails) -pub(crate) const RANGED_INITIAL_SHOT_MS: u32 = 500; // Auto Shot wind-up before the FIRST shot (vanilla's 0.5s RANGED_ATTACK timer re-arm), vs firing instantly on activation - // Projectile speeds from the real 1.12 Spell.dbc (speed column, calibrated on Fireball=24.0): - // spell 75 Auto Shot = 40 yd/s, spell 5019 wand Shoot = 20 yd/s. Drive the shot's damage-at-impact - // delay (dist/speed) so the hit lands with the client's arrow, not at the muzzle. +pub(crate) const RANGED_RANGE_SQ: f32 = 1225.0; +pub(crate) const RANGED_INITIAL_SHOT_MS: u32 = 500; +// Projectile speeds are in yd/s from Spell.dbc. pub(crate) const AUTO_SHOT_PROJECTILE_SPEED: f32 = 40.0; pub(crate) const WAND_PROJECTILE_SPEED: f32 = 20.0; -// The 1.12 MovementFlags bits that mean "actually translating" (vmangos movementFlagsMask minus the -// turn/pitch bits): forward|backward|strafe L/R|jumping|falling-far. Turning in place must NOT -// count — vanilla keeps Auto Shot firing while you spin, but DEFERS shots while you move. pub(crate) const MOVE_MASK_MOVING: u32 = 0x1 | 0x2 | 0x4 | 0x8 | 0x2000 | 0x4000; -/// The single forward bit the SERVER sets on a unit it moves itself (vanilla `MOVEFLAG_FORWARD`, -/// which its own spline launch records on the unit). A client-driven unit sends its own flags on -/// every heartbeat; one the module moves along a leg has none of its own, so its carrier says so. pub(crate) const MOVE_FLAG_FORWARD: u32 = 0x1; -/// Is this unit TRANSLATING — travelling through the world rather than turning, pitching or standing? -/// The one predicate every movement-state gate reads, so a spin, a strafe, a fall and a standstill are -/// classified the same way wherever the question is asked. pub(crate) const fn is_translating(movement_flags: u32) -> bool { movement_flags & MOVE_MASK_MOVING != 0 } -// Leashing is a TIMER, not a distance cap: damage in either direction stamps `now + PURSUIT_WINDOW_MS` -// and remembers where the creature stood, and only an expired deadline plus a target outside -// LEASH_RADIUS_SQ of that spot evades (`ai::should_evade`). Calibration knobs — vanilla's `Pursuit` and -// `LeashRadius` defaults. pub(crate) const PURSUIT_WINDOW_MS: u32 = 15_000; -pub(crate) const LEASH_RADIUS_SQ: f32 = 900.0; // (30 yd)² — around the remembered refresh position - // The absolute backstop: the only raw distance that still ends a fight, at the ~90 yd drop retail - // shows, so nothing stays in combat across a zone. Must exceed RANGED_RANGE_SQ or a max-range Auto - // Shot pull evades before its first shot lands; asserted in `ai`'s test block. -pub(crate) const COMBAT_BACKSTOP_SQ: f32 = 8100.0; // (90 yd)² -/// Default melee interval (ms) for an unarmed player — vanilla's unarmed `UNIT_FIELD_BASEATTACKTIME`. -/// Per-unit speed lives on `WorldEntity::base_attack_time_ms`; this is just the player default. +pub(crate) const LEASH_RADIUS_SQ: f32 = 900.0; +pub(crate) const COMBAT_BACKSTOP_SQ: f32 = 8100.0; pub const DEFAULT_ATTACK_TIME_MS: u32 = 2000; -// Combat-depth rolls (real hit/crit/miss + variance). Damage is a [min, max] range; the swing rolls -// 0..100 for the outcome. A player's range is derived from the stat curve (attack power, below); a -// creature uses a flat low range (creatures carry no attributes — `strength == 0`). -pub(crate) const CREATURE_MELEE_MIN: u32 = 1; // creature swing 1..=3 (avg 2) +pub(crate) const CREATURE_MELEE_MIN: u32 = 1; pub(crate) const CREATURE_MELEE_MAX: u32 = 3; -pub(crate) const CRIT_MULT: u32 = 2; // crits hit for 2x - -// The vanilla melee **attack table** is a single 0..10000 roll (basis points: 100 bp = 1%) walked in -// order miss → dodge → parry → glancing → block → crit → crushing → hit, each outcome claiming its slice -// of the line. A -// high-avoid defender therefore also crowds out crit, exactly as in vanilla. The bands scale with the -// LEVEL DIFFERENCE: since weapon skill / defense aren't tracked yet, weapon skill = attacker.level*5 -// and defense = target.level*5, so `skill_diff = (target.level - attacker.level) * 5` drives the -// vanilla hit/dodge/glancing curves (yellow/orange mobs are harder to hit, and tougher mobs glance). -const BASE_MISS_BP: u32 = 500; // 5% miss vs an equal/lower-level target -const MAX_MISS_BP: u32 = 6000; // cap miss at 60% — symmetry with the dodge/glancing caps + keeps the - // table from being swamped by miss alone at absurd level deltas -const PARRY_BP: u32 = 500; // 5% parry floor (defender vs an equal/lower-level attacker) -const PARRY_BP_PER_SKILL: u32 = 10; // +0.1%/skill point the defender's defense exceeds the attacker's - // weapon skill — mirrors the dodge/miss skill term (defense = lvl*5) -const MAX_PARRY_BP: u32 = 3000; // cap parry at 30% (mirrors the dodge cap; keeps the table under 100%) - // Block CHANCE (a shielded defender). Mirrors parry's scaling (defense-vs-weapon-skill, level-derived); - // the block AMOUNT is a flat per-shield `block_value` subtracted in `roll_swing`, NOT a percent here. -const BLOCK_BP: u32 = 500; // 5% block floor (a shielded defender vs an equal/lower-level attacker) -const BLOCK_BP_PER_SKILL: u32 = 10; // +0.1%/skill point the defender's defense exceeds the attacker's weapon skill -const MAX_BLOCK_BP: u32 = 3000; // cap block chance at 30% (mirrors the parry cap; keeps the table under 100%) -const CRIT_BP: u32 = 1000; // 10% crit (attacker) -const BASE_DODGE_BP: u32 = 500; // 5% dodge floor (creatures, zero-agility, equal level) -const DODGE_BP_PER_AGILITY: u32 = 5; // +0.05%/agi → ~+1% dodge per 20 agility (low-level vanilla-ish) -const MAX_DODGE_BP: u32 = 3000; // cap dodge at 30% so the table never overflows 100% +pub(crate) const CRIT_MULT: u32 = 2; + +const BASE_MISS_BP: u32 = 500; +const MAX_MISS_BP: u32 = 6000; +const PARRY_BP: u32 = 500; +const PARRY_BP_PER_SKILL: u32 = 10; +const MAX_PARRY_BP: u32 = 3000; +const BLOCK_BP: u32 = 500; +const BLOCK_BP_PER_SKILL: u32 = 10; +const MAX_BLOCK_BP: u32 = 3000; +const CRIT_BP: u32 = 1000; +const BASE_DODGE_BP: u32 = 500; +const DODGE_BP_PER_AGILITY: u32 = 5; +const MAX_DODGE_BP: u32 = 3000; #[cfg(test)] -const SKILL_BP_PER_LEVEL: u32 = 5; // a level of skill difference = 5 skill points; tests derive skill_diff - // from levels via this (production gets skill_diff from crate::skill) -const GLANCING_BP_PER_LEVEL: u32 = 1000; // +10% glancing per level the target is above the attacker -const MAX_GLANCING_BP: u32 = 4000; // cap glancing at 40% (vanilla's rough ceiling vs +3 targets) -pub(crate) const GLANCING_DAMAGE_PCT: u32 = 70; // a glancing blow lands 70% damage - -// Crushing blows — the CREATURE-side mirror of glancing. A creature whose level is `CRUSH_MIN_LEVEL_DIFF`+ -// above its target lands occasional CRUSHING blows for `CRUSH_DAMAGE_PCT` damage (vanilla: a mob whose -// weapon skill exceeds your defense by 15+, i.e. ~3 levels, can crush for 150%). Only NPC→player swings -// crush (players never crush, and PvP doesn't), so the band is armed only on that path; a same/lower-level -// creature has `crushing_chance_bp == 0`, so the table is unchanged (baseline-safe). The damage twin of -// glancing's reduced hit — here an AMPLIFIED hit. -const CRUSH_MIN_LEVEL_DIFF: u32 = 3; // a creature must be 3+ levels above to crush (the vanilla threshold) -const CRUSH_BP_PER_LEVEL: u32 = 1000; // +10% crushing per level above the threshold floor -const MAX_CRUSH_BP: u32 = 5000; // cap crushing at 50% (a deep level gap is brutal but never certain) -pub(crate) const CRUSH_DAMAGE_PCT: u32 = 150; // a crushing blow lands 150% damage - -// Player melee damage from the stat curve (combat-depth). Unarmed base damage + a contribution from -// attack power, the vanilla way: bonus = AP / 14 per second of weapon speed. With no weapons yet -// every player is unarmed (2.0s `base_attack_time_ms`); when items land, weapon speed/damage just -// feed the same formula. AP uses the Strength-based-class formula (`level*3 + Str*2 - 20`) — exact -// for Warrior/Paladin (the only fully set-up class); Agility-based classes (Rogue/Hunter) get a -// class-specific AP curve in a later pass. -const UNARMED_BASE_MIN: u32 = 1; // unarmed base swing before the AP bonus +const SKILL_BP_PER_LEVEL: u32 = 5; +const GLANCING_BP_PER_LEVEL: u32 = 1000; +const MAX_GLANCING_BP: u32 = 4000; +pub(crate) const GLANCING_DAMAGE_PCT: u32 = 70; + +const CRUSH_MIN_LEVEL_DIFF: u32 = 3; +const CRUSH_BP_PER_LEVEL: u32 = 1000; +const MAX_CRUSH_BP: u32 = 5000; +pub(crate) const CRUSH_DAMAGE_PCT: u32 = 150; + +const UNARMED_BASE_MIN: u32 = 1; const UNARMED_BASE_MAX: u32 = 3; -const AP_PER_DPS: u32 = 14; // 14 attack power == +1 damage per second of weapon speed +const AP_PER_DPS: u32 = 14; -// `game_combat_event.hit_info` discriminant → mapped to the `HitInfo`/VictimState SMSG fields by the -// gateway. Dodge/parry are zero-damage avoidance outcomes (VictimState in vanilla, not HitInfo flags). pub(crate) const HIT_NORMAL: u8 = 0; pub(crate) const HIT_CRIT: u8 = 1; pub(crate) const HIT_MISS: u8 = 2; pub(crate) const HIT_DODGE: u8 = 3; pub(crate) const HIT_PARRY: u8 = 4; pub(crate) const HIT_GLANCING: u8 = 5; -pub(crate) const HIT_CRUSHING: u8 = 6; // a landed amplified hit (150%); the gateway renders it as a normal hit - // (graceful `_` default) until the Crushing HitInfo wire flag is mapped -pub(crate) const HIT_BLOCK: u8 = 7; // a LANDED hit a shield reduced by a flat block_value (partial mitigation, - // NOT avoidance). Rendered as an AffectsVictim hit + VictimState BLOCK(4) - // + blocked_amount (gtker's HitInfo has no Block variant). This DISCRIMINANT - // value (7) is independent of the WALK position (after glancing, before crit). - // NB: the value 7 must match the gateway's hit_info match arm in - // gateway/src/codec/combat.rs (the `7 => (HitInfo::AffectsVictim, BLOCK)` case). - -// Section note (deliberately `//`, not `///`: it documents the block below, not the next item). -// The attack-table SKILL DIFFERENCE (defense skill − weapon skill, in skill points, clamped ≥0) is -// supplied by the CALLER — `crate::skill::skill_diff_ctx` derives it from the real tracked weapon/defense -// skill, falling back to `level*5` when untracked (so an all-untracked fight reproduces the flat -// level-derived `(target.level − attacker.level)*5` formula exactly — baseline-safe). The four -// skill-based bands below (miss/dodge/parry/block) take it as a parameter; glancing/crushing stay -// raw-level-based. - -/// Miss chance in basis points (vanilla hit table): 5% at `skill_diff` 0, rising with the skill -/// difference — `+0.1%`/point up to +10, then `+0.4%`/point beyond (the classic 5%→7%→9% curve vs -/// +1/+2/+3 skill tiers). Pure — unit-tested. +pub(crate) const HIT_CRUSHING: u8 = 6; +pub(crate) const HIT_BLOCK: u8 = 7; + pub(crate) fn miss_chance_bp(skill_diff: u32) -> u32 { let raw = if skill_diff > 10 { 700 + (skill_diff - 10) * 40 @@ -160,42 +69,22 @@ pub(crate) fn miss_chance_bp(skill_diff: u32) -> u32 { raw.min(MAX_MISS_BP) } -/// Defender dodge chance in basis points: a `BASE_DODGE_BP` floor, plus an agility term, plus the -/// skill-difference term (a higher-defense target dodges more), capped at `MAX_DODGE_BP`. Pure. A -/// same-skill creature (agility 0) dodges at the floor. pub(crate) fn dodge_chance_bp(agility: u32, skill_diff: u32) -> u32 { (BASE_DODGE_BP + agility * DODGE_BP_PER_AGILITY + skill_diff * 10).min(MAX_DODGE_BP) } -/// Defender parry chance in basis points: a `PARRY_BP` floor plus the skill-difference term — a -/// higher-defense defender parries more — capped at `MAX_PARRY_BP`. Unlike dodge, parry does NOT scale -/// with agility; it tracks defense-vs-weapon-skill only (vanilla). Pure — unit-tested. `skill_diff` 0 → -/// the flat 5% floor (baseline-safe). pub(crate) fn parry_chance_bp(skill_diff: u32) -> u32 { (PARRY_BP + skill_diff * PARRY_BP_PER_SKILL).min(MAX_PARRY_BP) } -/// Defender block CHANCE in basis points (NOT the blocked amount): a `BLOCK_BP` floor plus the -/// skill-difference term, capped at `MAX_BLOCK_BP`. Mirrors parry in scaling, but block differs in -/// OUTCOME — it lands a hit whose damage a flat shield `block_value` was subtracted from, not the -/// zero-damage avoidance of dodge/parry. Pure — unit-tested. The CALLER (`roll_swing`) gates this to a -/// defender that actually has a shield equipped; an unshielded unit's band is 0 (baseline-safe). pub(crate) fn block_chance_bp(skill_diff: u32) -> u32 { (BLOCK_BP + skill_diff * BLOCK_BP_PER_SKILL).min(MAX_BLOCK_BP) } -/// Glancing chance in basis points: 0 against an equal/lower-level target, else `+10%` per level the -/// target is above the attacker, capped at `MAX_GLANCING_BP`. Glancing blows can't crit and land -/// reduced damage — the leveling-combat "tougher mob shrugs part of it off". Pure — unit-tested. pub(crate) fn glancing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { (target_level.saturating_sub(attacker_level) * GLANCING_BP_PER_LEVEL).min(MAX_GLANCING_BP) } -/// Crushing chance in basis points: 0 unless the ATTACKER is `CRUSH_MIN_LEVEL_DIFF` (3) or more levels -/// ABOVE the target, then `+10%` per level past the threshold floor, capped at `MAX_CRUSH_BP`. The -/// creature-side mirror of glancing (which keys on the target being above the attacker) — a deep -/// level gap lets a mob shrug the player around for amplified hits. Pure — unit-tested. The CALLER gates -/// this to NPC→player swings (players never crush); an equal/lower-level attacker gets 0 here regardless. pub(crate) fn crushing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { let diff = attacker_level.saturating_sub(target_level); if diff < CRUSH_MIN_LEVEL_DIFF { @@ -204,13 +93,10 @@ pub(crate) fn crushing_chance_bp(attacker_level: u32, target_level: u32) -> u32 ((diff - CRUSH_MIN_LEVEL_DIFF + 1) * CRUSH_BP_PER_LEVEL).min(MAX_CRUSH_BP) } -/// Melee attack power from the Strength-based-class formula: `level*3 + Str*2 - 20`, clamped at 0 -/// (a tiny-stat unit has no negative AP). Exact for Warrior/Paladin; the general str-class curve. pub(crate) fn melee_attack_power(strength: u32, level: u32) -> u32 { (level * 3 + strength * 2).saturating_sub(20) } -// Vanilla class ids (the subset whose melee AP curve diverges from the Str-class default). const CLASS_HUNTER: u8 = 3; const CLASS_ROGUE: u8 = 4; @@ -221,16 +107,10 @@ pub(crate) fn melee_attack_power_for(class: u8, strength: u32, agility: u32, lev } } -/// Ranged attack power from the Agility-based (Hunter-style) curve: `level*2 + Agi - 10`, clamped at 0. -/// An approximation of the vanilla ranged-AP formula — close enough at low level; the only consumer is -/// Auto Shot (spell 75, bows/guns). Wands (Shoot, 5019) use NO ranged AP (flat weapon damage). Folded into -/// the ranged weapon's `[min,max]` by `weapon_swing_range_ap` so it scales with the weapon's speed. Pure. pub(crate) fn ranged_attack_power(agility: u32, level: u32) -> u32 { (level * 2 + agility).saturating_sub(10) } -/// Ranged paperdoll values from the same weapon facts and pure calculations used by a ranged shot. -/// Launchers scale with ranged attack power. Wands and other ranged weapons keep flat damage. pub(crate) fn ranged_sheet_values( agility: u32, level: u32, @@ -250,19 +130,11 @@ pub(crate) fn ranged_sheet_values( } } -/// The unarmed `[min, max]` swing range from an ALREADY-COMPUTED attack power: unarmed base plus the -/// `AP / 14`-per-second bonus (`ap * attack_time_ms / 14000`). The AP-explicit core so a caller can -/// fold in extra AP (e.g. a Battle Shout aura) BEFORE the bonus is derived — the multiply is on the -/// summed AP, the vanilla way (the buff scales with weapon speed too). Pure — unit-tested. pub(crate) fn player_swing_range_ap(ap: u32, attack_time_ms: u32) -> (u32, u32) { let bonus = ap * attack_time_ms / (AP_PER_DPS * 1000); (UNARMED_BASE_MIN + bonus, UNARMED_BASE_MAX + bonus) } -/// The weapon `[min, max]` swing range from an ALREADY-COMPUTED attack power: the weapon's tooltip -/// damage plus the `AP * delay_ms / 14000` bonus. The AP-explicit weapon core (mirrors -/// `player_swing_range_ap`) so an aura's extra AP folds into the summed AP before the per-swing -/// bonus is scaled by the weapon's speed. Pure — unit-tested. pub(crate) fn weapon_swing_range_ap( ap: u32, dmg_min: u32, @@ -277,89 +149,47 @@ pub(crate) fn apply_offhand_penalty(min: u32, max: u32) -> (u32, u32) { ((min / 2).max(1), (max / 2).max(1)) } -/// Fold a base attribute together with its aura and gear bonuses into the EFFECTIVE value: the three -/// terms summed and clamped ≥0 so a debuff exceeding base never underflows the `u32`. The shared core of -/// `effective_strength`/`effective_agility`/`effective_armor` (each still computes its own base/aura/gear -/// from the unit, which are semantically distinct) so the "base + aura + gear, floored at 0" shape lives -/// in ONE place. Pure — unit-tested. pub(crate) fn effective_stat(base: i32, aura: i32, gear: i32) -> u32 { (i64::from(base) + i64::from(aura) + i64::from(gear)).clamp(0, i64::from(u32::MAX)) as u32 } -/// Apply a crit-rating bonus to the flat `CRIT_BP` base: `CRIT_BP + bonus`, clamped to `[0, 10000]` -/// so the attack table's crit band never goes negative or overflows the 0..10000 line. The AP-explicit -/// twin (mirrors `player_swing_range_ap`): the pure core so `effective_crit_bp` folds in the aura sum -/// and the unit tests cover the math without a context. `bonus == 0` → exactly `CRIT_BP`. Pure. [entity] pub(crate) fn crit_bp_with_bonus(bonus: i32) -> u32 { (CRIT_BP as i32 + bonus).clamp(0, 10_000) as u32 } -/// Agility-derived melee crit chance in basis points: `agi * 100 / (level + 4)` — crit -/// rises with the attacker's Agility and is SUPPRESSED by level (the per-agi value shrinks as the -/// attacker levels, the vanilla "more Agility per 1% crit at higher level" shape). Folded into the crit -/// band ALONGSIDE the flat `CRIT_BP` base + the aura/gear bonus (so this is an ADD, not a replace — a -/// zero-Agility unit, e.g. every creature, contributes 0 here and keeps the flat base, baseline-safe). -/// Low-level approximation, NOT a byte-exact vanilla curve — `100`/`+4` are tuning knobs. Pure. pub(crate) fn agility_crit_bp(agility: u32, level: u32) -> u32 { agility * 100 / (level + 4) } -/// Apply a hit-rating bonus to a level-derived miss base: `miss - bonus`, clamped ≥0 (hit rating -/// REDUCES the chance to miss, floored at 0 rather than underflowing). The pure core so -/// `effective_miss_bp` folds in the aura sum and the unit tests cover the math without a context. -/// `bonus == 0` → exactly `miss`. Pure. [entity] pub(crate) fn miss_bp_with_bonus(miss: u32, bonus: i32) -> u32 { (miss as i32 - bonus).max(0) as u32 } -/// Swing interval (ms) after a SIGNED melee haste/slow PERCENT: `base * 100 / (100 + pct)`. Positive = -/// faster (shorter interval, a haste); negative = slower (longer, an attack-speed slow like Thunder Clap). -/// The denominator is floored at 1 so a ≥100% slow can't divide-by-zero — it just becomes a very slow -/// swing. Pure — unit-tested. `pct == 0` → exactly `base_ms` (baseline-safe). pub(crate) fn swing_time_with_haste(base_ms: u32, haste_pct: i32) -> u32 { let denom = (100 + haste_pct).max(1) as u32; base_ms.saturating_mul(100) / denom } -/// Movement speed (yd/s) after a SIGNED move-speed PERCENT: `base * (100 + pct) / 100`. Positive = -/// faster; negative = slower (a snare like Hamstring). Floored at 10% of base so a heavy snare slows but -/// never freezes a unit dead (a zero-length step breaks the movement passes' no-op-leg skip and would -/// look frozen). Pure — unit-tested. `pct == 0` → exactly `base` (baseline-safe). [pure] #[cfg(test)] use lyracore_shared::aura::move_speed_with_pct; -/// Per-1000 melee-damage multiplier for a creature's classification `rank` (the swing twin of -/// `creatures::rank_hp_multiplier_permille`). cmangos `creature_template.rank`: `0`=normal, `1`=elite, -/// `2`=rare-elite, `3`=boss, `4`=rare. An elite/rare/boss hits harder as well as having more HP, but -/// the damage bump is GENTLER than the HP bump (vanilla elites are tanky more than they are bursty): -/// normal 1.0×, elite 1.4×, rare-elite 1.8×, boss 2.5×, rare 1.5×. PERMILLE (×1000) keeps the scaling -/// exact integer math (`dmg * permille / 1000`); rank 0 (`1000`) is the identity so every current -/// rank-0 creature swing stays BYTE-IDENTICAL. Unknown rank → normal (never amplify). Pure — unit-tested. pub(crate) fn rank_damage_multiplier_permille(rank: u8) -> u32 { match rank { - 0 => 1000, // normal — identity (baseline) - 1 => 1400, // elite - 2 => 1800, // rare-elite - 3 => 2500, // boss - 4 => 1500, // rare - _ => 1000, // unknown rank → treat as normal + 0 => 1000, + 1 => 1400, + 2 => 1800, + 3 => 2500, + 4 => 1500, + _ => 1000, } } -/// Scale a creature swing `[min, max]` range by its `rank` (integer permille math, saturating). Shared -/// by `swing_range_ctx` and its unit test so the live scaling and the asserted numbers never drift. -/// Rank 0 → unchanged (`(CREATURE_MELEE_MIN, CREATURE_MELEE_MAX)` verbatim). Pure. pub(crate) fn scale_swing_for_rank(min: u32, max: u32, rank: u8) -> (u32, u32) { let permille = rank_damage_multiplier_permille(rank); let scale = |d: u32| ((d as u64 * permille as u64) / 1000) as u32; (scale(min), scale(max)) } -/// Physical damage reduction from the target's armor, as a whole-number percent (vanilla formula): -/// `armor / (armor + 400 + 85*attacker_level)`, capped at 75%. Pure — unit-tested. A 0-armor target -/// (every creature today) takes full damage; a player's agility-derived armor shaves a few percent -/// off incoming creature swings. The single mitigation source shared by `roll_swing` (applies it to -/// the rolled hit) and `swing_profile`/`debug_compute_swing` (surfaces it) — so they never drift. pub(crate) fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { if armor == 0 { return 0; @@ -368,25 +198,15 @@ pub(crate) fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { (armor * 100 / denom).min(75) } -/// -/// PROVENANCE: the linear `75 × resist / (level×5)` average-resist with a 75% cap is the widely-used -/// 1.12 community approximation (the real engine rolls partial-resist bands whose *average* this models) -/// and is empirically live-verified here (a +6 fire ward vs an L2 caster mitigates Fireball 20→11 = 45%). -/// It is NOT cross-checked byte-for-byte against retail; a band-rolled refinement is a later option. pub(crate) fn resist_mitigation_pct(resistance: u32, caster_level: u32) -> u32 { if resistance == 0 || caster_level == 0 { return 0; } let cap = caster_level * 5; - let r = resistance.min(cap); // resistance past the cap is wasted (the 75% ceiling) + let r = resistance.min(cap); (75 * r / cap).min(75) } -/// The deterministic profile of one swing, for the combat-readback debug readout: the base `[min,max]` -/// range, the armor `mitigation_pct`, and the resulting mitigated normal-hit `[final_min,final_max]`. -/// Pure. `roll_swing` rolls within the base range then applies the SAME `armor_mitigation_pct`, so a -/// `debug_compute_swing` readout reflects exactly what live combat does (no drift). A landed hit is -/// floored at 1 damage. #[cfg(any(test, feature = "debug_reducers"))] pub struct SwingProfile { pub base_min: u32, @@ -414,24 +234,16 @@ pub(crate) fn swing_profile( } } -/// The attack-table band widths in basis points, in roll order — the full composition of one swing's -/// 0..10000 outcome line. Built per swing from the attacker/target levels + the defender's agility. pub(crate) struct AttackTable { pub(crate) miss: u32, pub(crate) dodge: u32, pub(crate) parry: u32, pub(crate) glancing: u32, - pub(crate) block: u32, // a shielded defender — walks after glancing, before crit (vanilla order) + pub(crate) block: u32, pub(crate) crit: u32, pub(crate) crushing: u32, } -/// Classify one attack-table roll (0..10000) against `t`'s bands: walks the vanilla single-roll table -/// miss → dodge → parry → glancing → block → crit → crushing → hit and returns the `hit_info` -/// discriminant. Block sits AFTER glancing and BEFORE crit (the vanilla single-roll order) and is 0 -/// except for a shielded defender; crushing sits AFTER crit and is 0 except on the NPC→player path — so a -/// shieldless player→mob / PvP swing walks the identical bands it did before. Pure — the slice boundaries -/// are unit-tested. pub(crate) fn attack_outcome(roll: u32, t: &AttackTable) -> u8 { let mut floor = t.miss; if roll < floor { @@ -464,66 +276,27 @@ pub(crate) fn attack_outcome(roll: u32, t: &AttackTable) -> u8 { HIT_NORMAL } -/// Does this unit use rage (warriors)? The power type is byte 3 of `unit_bytes_0`. Only rage-users -/// generate rage in combat; mana/energy classes and creatures (power byte 0) gain none here. pub(crate) fn is_rage_user(e: &WorldEntity) -> bool { ((e.unit_bytes_0 >> 24) & 0xFF) as u8 == lyracore_shared::packing::power_type::RAGE } -/// Simplified vanilla-ish rage from one damage event, in INTERNAL units (rage is stored ×10, so -/// `max_power` 1000 = 100 rage). The attacker (dealing) gains more than the defender (taking) — -/// generous enough to afford a 10-rage shout in ~2 swings, roughly matching low-level vanilla. (The -/// real curve is a level-scaled conversion; this is the slice approximation until a stats pass.) +// Rage uses tenths; 100 displayed rage is 1000 internal units. pub(crate) fn rage_from_damage(damage: u32, dealt: bool) -> u32 { damage * if dealt { 8 } else { 3 } } -/// Flat energy gained per regen tick (rogues / energy creatures), in AND out of combat. Vanilla -/// energy ticks 20 per 2s tick; our regen tick fires once per 4s sense tick, so the correct -/// equivalent is 20 × 2 = 40. Independent of spirit/level — energy regen is flat in vanilla. const ENERGY_TICK: u32 = 40; -/// Internal rage units removed per regen tick when OUT of combat. Rage is stored ×10 internally -/// (100 displayed = 1000 internal). Vanilla drains approximately 1.25 displayed rage/sec; our -/// regen tick fires once per ~4s, so the target decrement is 1.25 × 4 × 10 = 50 internal units. -/// This is a tuning knob — the operator should eyeball the live drain speed and tweak this value. const RAGE_DECAY_PER_TICK: u32 = 50; -/// Spirit-derived MANA gained per regen tick, OUT of combat. Vanilla mana regen is a spirit-scaled -/// per-tick amount (the classic low-level approximation is ~`Spirit/4 + 5` mana per 2s tick); our -/// tick is ~4s, so we use roughly double that and fold in a small level term. The `+ level/2 + 1` -/// floor means a mana unit with spirit 0 (a stat-less mana creature) still trickles a tiny amount — a -/// small "regens a little out of combat" trickle without overshooting, and never zero so a drained pool -/// always recovers. Pure — unit-tested. fn mana_regen_per_tick(spirit: u32, level: u32) -> u32 { spirit / 2 + level / 2 + 1 } -/// Spirit-derived HEALTH gained per regen tick, OUT of combat. Vanilla out-of-combat health regen is -/// spirit-scaled (plus a level term); our tick is ~4s. We use `spirit + level` per tick. The `+1` -/// floor keeps a spirit-0 unit (every creature today — creatures carry spirit 0) recovering at -/// `level + 1` per tick, which for a low-level mob is a handful of HP — sane and close to "heals a -/// bit out of combat", while NEVER exceeding max. Pure — unit-tested. fn health_regen_per_tick(spirit: u32, level: u32) -> u32 { spirit + level + 1 } -/// Per-tick passive POWER adjustment by power type, returning the new power value (the regen pass in -/// `tick_creatures` applies it). Mana regenerates toward max at a SPIRIT+level scaled rate -/// (`mana_regen_per_tick`) when NOT in the FSR window (`mana_paused == false` — the caller derives -/// this from `now_ms < entity.mana_regen_paused_until_ms`); energy regenerates toward max ALWAYS (in -/// or out of combat, like a rogue's) at a flat `ENERGY_TICK`, INDEPENDENT of spirit; rage DECAYS -/// toward 0 out of combat at `RAGE_DECAY_PER_TICK` internal units/tick (~1.25 displayed rage/sec, -/// close to vanilla) and holds during a fight; other power types are unchanged. Pure — unit-tested. -/// `max == 0` (creatures, which carry no power bar) yields no change. Power never exceeds `max` (the -/// `.min(max)` cap) and never underflows (the `saturating_sub` on rage). -/// -/// FSR (Five-Second Rule): `mana_paused` is true for 5s after the caster last spent mana. It pauses -/// the spirit-derived term in or out of combat. Flat mana-per-five continues during the window. -/// After the window, the spirit term resumes regardless of combat state. -/// -/// BASELINE-SAFETY: with `spirit == 0` a mana unit still regens `level/2 + 1` per tick when not -/// paused; energy is unchanged by spirit; rage decays at the vanilla-approximate rate. #[cfg(test)] pub(crate) fn regen_power( power_type: u8, @@ -585,15 +358,10 @@ fn regen_power_with_flat( current.saturating_sub(RAGE_DECAY_PER_TICK) } } - _ => current, // focus/happiness: unchanged + _ => current, } } -/// Per-tick passive HEALTH regen OUT of combat, returning the new health value. Spirit+level scaled -/// (`health_regen_per_tick`); applied by the health-regen pass in `tick_creatures` to any unit below -/// max HP that is NOT engaged. Pure — unit-tested. Never exceeds `max` (the `.min(max)` cap); -/// `max == 0` yields no change. A spirit-0 unit (every creature today) still heals `level + 1` per -/// tick — a small, sane amount that keeps the prior "recovers a bit out of combat" behavior. #[cfg(test)] pub(crate) fn regen_health(current: u32, max: u32, spirit: u32, level: u32) -> u32 { regen_health_with_flat(current, max, spirit, level, 0) @@ -617,7 +385,6 @@ fn regen_health_with_flat( ) } -/// The scheduled window covered by one regeneration pass. #[derive(Clone, Copy)] pub(crate) struct RegenWindow { now_ms: u64, @@ -630,8 +397,6 @@ impl RegenWindow { } } -/// Convert a points-per-five-seconds rate without losing low rates to per-tick truncation. The -/// difference between cumulative quotients assigns each whole point to one deterministic window. fn per_five_in_window(per_five: i32, window: RegenWindow) -> i32 { let previous_ms = window.now_ms.saturating_sub(window.elapsed_ms); let accrued = |at_ms: u64| (i128::from(per_five) * i128::from(at_ms)).div_euclid(5_000); @@ -645,11 +410,6 @@ fn apply_regen(current: u32, max: u32, base_tick: u32, flat_tick: i32) -> u32 { current.saturating_add(tick).min(max) } -/// Entity-aware POWER-regen wrapper: reads `spirit`/`level`/power bar off the row so the `tick_creatures` -/// caller passes ONE entity + the in-combat flag + the current tick's `now_ms` (the spirit/level wiring -/// and FSR derivation live here, not inline in the tick). Power type is byte 3 of `unit_bytes_0`. -/// FSR gate: mana regen is paused when `now_ms < e.mana_regen_paused_until_ms` (stamped on every -/// mana-spend in the cast path). Returns the new power value (the caller writes it back only if it changed). pub(crate) fn regen_entity_power( e: &WorldEntity, in_combat: bool, @@ -672,8 +432,6 @@ pub(crate) fn regen_entity_power( ) } -/// Entity-aware HEALTH-regen wrapper: reads `spirit`/`level` off the row so the `tick_creatures` health -/// pass passes one entity. Returns the new health value for an out-of-combat unit. pub(crate) fn regen_entity_health( e: &WorldEntity, health_per_five: i32, @@ -688,13 +446,6 @@ pub(crate) fn regen_entity_health( ) } -/// Per-tick PARTIAL health regen DURING COMBAT: `combat_regen_pct%` of the normal out-of-combat -/// regen tick amount. Used when the entity carries one or more `A_COMBAT_HEALTH_REGEN_PCT` auras — -/// the caller sums their `amount × stacks` and passes the total here. `combat_regen_pct == 0` → -/// no change (caller should skip). `combat_regen_pct >= 100` → the full tick (uncapped at 100 so -/// multiple auras can stack to 100). Never exceeds `max`; `max == 0` yields no change. Pure — -/// unit-tested. `spirit == 0` (every creature today) still heals `(level+1) * pct / 100` which -/// for a L5 troll is (6) * 10 / 100 = 0 (floored) — tiny but not 0 once `level` is meaningful. #[cfg(any(test, feature = "debug_reducers"))] pub(crate) fn regen_health_in_combat( current: u32, @@ -712,8 +463,6 @@ pub(crate) fn regen_health_in_combat( apply_regen(current, max, partial, 0) } -/// Entity-aware in-combat health regeneration. Aura percentage applies only to natural recovery; -/// item health-per-five remains active independently. pub(crate) fn regen_entity_health_in_combat( e: &WorldEntity, combat_regen_pct: u32, @@ -735,34 +484,20 @@ pub(crate) fn regen_entity_health_in_combat( mod tests { use super::*; - /// Test oracle for the level-derived skill difference the four skill-based bands take as input — - /// `(target_level − attacker_level) * 5`, clamped ≥0. Lets these tests keep their readable "+N - /// levels" form while feeding the `skill_diff: u32` parameter; production derives the same number - /// from real tracked skill via `crate::skill::skill_diff_ctx` (the equivalence to this form is - /// brute-forced in `skill::tests::skill_diff_from_caps_equals_old_level_derived_form`). fn sd(attacker_level: u32, target_level: u32) -> u32 { target_level.saturating_sub(attacker_level) * SKILL_BP_PER_LEVEL } #[test] fn melee_leeway_covers_the_post_flee_tick_gap_for_a_pinned_chaser() { - // A fleeing creature's authoritative position snaps the full per-tick run distance - // (RUN * MOVE_TICK_SECS) the instant its flee leg fires — the client only animates the glide, the - // swing-range check reads the post-snap DB position immediately. Worst case: a run-speed-parity - // chaser is sitting right at the base melee boundary (5yd) the instant the mob's leg fires, so the - // gap jumps to 5 + one flee leg. The leeway must cover that gap for a chaser already pinned at the - // boundary, or the swing whiffs and the mob reads as uncatchable; a chaser who's settled farther - // back can still miss occasionally, but the leeway keeps the pinned case solid. use crate::creatures::MOVE_TICK_SECS; use lyracore_shared::constants::speeds::RUN; - let worst_case_gap = 5.0 + RUN * MOVE_TICK_SECS; // 8.5 yd + let worst_case_gap = 5.0 + RUN * MOVE_TICK_SECS; let leeway = MELEE_RANGE_LEEWAY_SQ.sqrt(); assert!( leeway >= worst_case_gap, "leeway {leeway}yd must cover the worst-case post-flee-tick gap {worst_case_gap}yd" ); - // Pin the exact vanilla-formula value (5 base + 4/3 reach + 8/3 running = 9yd) so a future edit - // can't silently shrink it back below the worst-case gap. assert!( (leeway - 9.0).abs() < 0.001, "leeway {leeway}yd expected exactly 9yd" @@ -771,40 +506,31 @@ mod tests { #[test] fn attack_power_uses_the_strength_class_formula() { - // L1 Human Warrior: Str 23 → 1*3 + 23*2 - 20 = 29. assert_eq!(melee_attack_power(23, 1), 29); - // Scales with both level and strength. assert_eq!(melee_attack_power(40, 10), 30 + 80 - 20); assert!(melee_attack_power(40, 10) > melee_attack_power(23, 1)); - // Clamps at 0 — a near-zero-stat unit never gets negative AP. assert_eq!(melee_attack_power(0, 1), 0); - assert_eq!(melee_attack_power(5, 1), 0); // 3 + 10 - 20 saturates to 0 + assert_eq!(melee_attack_power(5, 1), 0); } #[test] fn agility_crit_rises_with_agility_and_is_suppressed_by_level() { - // More Agility → strictly more crit, level held constant. assert!(agility_crit_bp(40, 5) > agility_crit_bp(20, 5)); - // Same Agility at a HIGHER level → less crit (per-agi suppression). assert!(agility_crit_bp(20, 10) < agility_crit_bp(20, 1)); - // Zero Agility (every creature) → 0, so the crit band stays the flat CRIT_BP base (baseline-safe). assert_eq!(agility_crit_bp(0, 5), 0); assert_eq!(crit_bp_with_bonus(agility_crit_bp(0, 5) as i32), CRIT_BP); } #[test] fn rogue_melee_ap_folds_in_agility_others_keep_the_str_curve() { - // Rogue (class 4): level*2 + Str + Agi - 20 → Agility raises AP. - let lo_agi = melee_attack_power_for(CLASS_ROGUE, 20, 20, 5); // 10 + 20 + 20 - 20 = 30 - let hi_agi = melee_attack_power_for(CLASS_ROGUE, 20, 40, 5); // 10 + 20 + 40 - 20 = 50 + let lo_agi = melee_attack_power_for(CLASS_ROGUE, 20, 20, 5); + let hi_agi = melee_attack_power_for(CLASS_ROGUE, 20, 40, 5); assert_eq!(lo_agi, 30); assert!( hi_agi > lo_agi, "more Agility → strictly higher Rogue AP (Str/level held constant)" ); - // Hunter (class 3) shares the Agi curve. assert_eq!(melee_attack_power_for(CLASS_HUNTER, 20, 40, 5), 50); - // Warrior (class 1) + any non-Agi class keep the Str-class curve byte-identical, Agility inert. assert_eq!( melee_attack_power_for(1, 23, 99, 1), melee_attack_power(23, 1) @@ -812,18 +538,15 @@ mod tests { assert_eq!( melee_attack_power_for(8, 23, 99, 1), melee_attack_power(23, 1) - ); // Mage: no agi term + ); } #[test] fn ranged_attack_power_scales_with_level_and_agility_and_floors_at_zero() { - // level*2 + agi - 10, clamped at 0. - assert_eq!(ranged_attack_power(20, 10), 30); // 10*2 + 20 - 10 - assert_eq!(ranged_attack_power(0, 5), 0); // 5*2 + 0 - 10 == 0, exactly at the floor - assert_eq!(ranged_attack_power(0, 1), 0); // 1*2 + 0 - 10 saturates to 0 (would be negative) - // More Agility → strictly more ranged AP (level held constant). + assert_eq!(ranged_attack_power(20, 10), 30); + assert_eq!(ranged_attack_power(0, 5), 0); + assert_eq!(ranged_attack_power(0, 1), 0); assert!(ranged_attack_power(40, 10) > ranged_attack_power(20, 10)); - // Higher level → strictly more ranged AP (agility held constant). assert!(ranged_attack_power(20, 20) > ranged_attack_power(20, 10)); } @@ -851,80 +574,55 @@ mod tests { #[test] fn effective_stat_sums_base_aura_gear_and_clamps_at_zero() { - // base + aura + gear, the no-buff/no-gear case is exactly the base (baseline-safe). assert_eq!(effective_stat(23, 0, 0), 23); assert_eq!(effective_stat(23, 5, 7), 35); - // A debuff (negative aura) that exceeds base+gear clamps to 0 — never underflows the u32. assert_eq!(effective_stat(10, -25, 3), 0); - // A negative aura partially offset by gear stays non-negative. assert_eq!(effective_stat(10, -4, 2), 8); } #[test] fn player_swing_range_scales_with_stats_and_stays_sane() { - // The unarmed range from the stat-derived AP (the AP core is fed `melee_attack_power(str, level)`, - // exactly as `swing_range_ctx` does for an unarmed player). let unarmed = |str: u32, level: u32, t: u32| player_swing_range_ap(melee_attack_power(str, level), t); - // L1 Str 23 unarmed (2.0s): AP 29 → bonus 29*2000/14000 = 4 → range (5, 7). assert_eq!(unarmed(23, 1, 2000), (5, 7)); - // The window width is constant (max - min == base width); only the floor shifts up. let (lo, hi) = unarmed(23, 1, 2000); assert_eq!(hi - lo, UNARMED_BASE_MAX - UNARMED_BASE_MIN); - // A stronger character hits strictly harder (both ends of the range rise). let (slo, shi) = unarmed(60, 20, 2000); assert!(slo > lo && shi > hi); - // A faster weapon (lower attack time) yields a smaller per-swing bonus (DPS preserved). let (flo, _) = unarmed(23, 1, 1000); assert!(flo <= lo); - // Never zero damage from the floor (a real hit always lands ≥ unarmed base). assert!(unarmed(0, 1, 2000).0 >= UNARMED_BASE_MIN); } #[test] fn ap_explicit_cores_take_stat_derived_ap_and_scale_with_ap() { - // The AP-explicit cores fed the stat-derived AP (`melee_attack_power(str, level)`) — exactly how - // `swing_range_ctx` drives an unarmed/weapon swing. L1 Str 23 → AP 29; unarmed 2.0s → (5, 7). assert_eq!( player_swing_range_ap(melee_attack_power(23, 1), 2000), (5, 7) ); - // A 1–3 / 1900ms Worn Shortsword with that same AP: bonus 29*1900/14000 = 3 → (4, 6). assert_eq!( weapon_swing_range_ap(melee_attack_power(23, 1), 1, 3, 1900), (4, 6) ); - // A Battle Shout-style AP buff (extra AP on top of the base) hits strictly harder — both - // ends of the range rise — exactly what an active self-buff aura grants the attacker. let base_ap = melee_attack_power(23, 1); let (lo, hi) = player_swing_range_ap(base_ap, 2000); - let (blo, bhi) = player_swing_range_ap(base_ap + 30, 2000); // +30 AP (Battle Shout) + let (blo, bhi) = player_swing_range_ap(base_ap + 30, 2000); assert!(blo > lo && bhi > hi); - // Same for the weapon core: more AP → strictly higher floor at the same weapon/speed. let (wlo, _) = weapon_swing_range_ap(base_ap, 8, 12, 1900); let (wblo, _) = weapon_swing_range_ap(base_ap + 30, 8, 12, 1900); assert!(wblo > wlo); - // Zero extra AP yields exactly the base range (baseline-safe). assert_eq!(player_swing_range_ap(base_ap, 2000), (lo, hi)); } #[test] fn weapon_swing_range_uses_weapon_base_plus_ap_bonus() { - // The weapon range from the stat-derived AP (the weapon AP core fed `melee_attack_power(str, level)`, - // exactly as `swing_range_ctx` does for a player wielding a main-hand weapon). let weapon = |str: u32, level: u32, dmin: u32, dmax: u32, delay: u32| { weapon_swing_range_ap(melee_attack_power(str, level), dmin, dmax, delay) }; - // L1 Str 23 (AP 29) wielding a 1–3 / 1900ms Worn Shortsword: bonus = 29*1900/14000 = 3 → - // range (4, 6). The weapon's own damage range replaces the unarmed (1,3) base. assert_eq!(weapon(23, 1, 1, 3, 1900), (4, 6)); - // A stronger weapon (8–12) hits clearly harder than the worn sword at the same stats/speed. let (wlo, whi) = weapon(23, 1, 8, 12, 1900); assert!(wlo > 4 && whi > 6); - // The window width tracks the WEAPON's spread, not the unarmed one. assert_eq!(whi - wlo, 12 - 8); - // A slower weapon yields a bigger per-swing AP bonus (DPS preserved), so its floor is higher - // than the same weapon swung faster. let (slow_lo, _) = weapon(23, 1, 8, 12, 2600); let (fast_lo, _) = weapon(23, 1, 8, 12, 1300); assert!(slow_lo > fast_lo); @@ -932,162 +630,136 @@ mod tests { #[test] fn dodge_scales_with_agility_level_and_caps() { - // Equal level (no skill diff): floor + agility term. - assert_eq!(dodge_chance_bp(0, sd(10, 10)), BASE_DODGE_BP); // creature, no agility → floor + assert_eq!(dodge_chance_bp(0, sd(10, 10)), BASE_DODGE_BP); assert_eq!( dodge_chance_bp(20, sd(10, 10)), BASE_DODGE_BP + 20 * DODGE_BP_PER_AGILITY - ); // +1% at 20 agi - assert!(dodge_chance_bp(100, sd(10, 10)) > dodge_chance_bp(20, sd(10, 10))); // monotonic in agility - // A higher-level target dodges more (skill-diff term); lower-level adds nothing. + ); + assert!(dodge_chance_bp(100, sd(10, 10)) > dodge_chance_bp(20, sd(10, 10))); assert!(dodge_chance_bp(0, sd(10, 13)) > dodge_chance_bp(0, sd(10, 10))); assert_eq!(dodge_chance_bp(0, sd(10, 5)), BASE_DODGE_BP); - assert_eq!(dodge_chance_bp(1_000_000, sd(10, 10)), MAX_DODGE_BP); // capped, never overflows the table + assert_eq!(dodge_chance_bp(1_000_000, sd(10, 10)), MAX_DODGE_BP); } #[test] fn parry_scales_with_defense_skill_not_agility_and_caps() { - // Equal/lower-level attacker → the flat 5% floor (baseline-safe). assert_eq!(parry_chance_bp(sd(10, 10)), PARRY_BP); - assert_eq!(parry_chance_bp(sd(10, 5)), PARRY_BP); // lower-level defender still floors at 5% (skill_diff=0) - // A higher-level defender's greater defense skill parries more: +1 level = 5 skill pts = +0.5%. + assert_eq!(parry_chance_bp(sd(10, 5)), PARRY_BP); assert_eq!( parry_chance_bp(sd(10, 11)), PARRY_BP + 5 * PARRY_BP_PER_SKILL - ); // +1 → 5.5% - assert!(parry_chance_bp(sd(10, 13)) > parry_chance_bp(sd(10, 11))); // monotonic in level gap - assert_eq!(parry_chance_bp(sd(1, 1_000_000)), MAX_PARRY_BP); // capped, never overflows the table + ); + assert!(parry_chance_bp(sd(10, 13)) > parry_chance_bp(sd(10, 11))); + assert_eq!(parry_chance_bp(sd(1, 1_000_000)), MAX_PARRY_BP); } #[test] fn swing_time_haste_shortens_interval_and_is_baseline_safe_at_zero() { - assert_eq!(swing_time_with_haste(2000, 0), 2000); // no haste → base time (baseline-safe) - assert_eq!(swing_time_with_haste(2000, 50), 1333); // +50% → 2000*100/150 (swings faster) - assert_eq!(swing_time_with_haste(2000, 100), 1000); // +100% → half the interval - assert!(swing_time_with_haste(2600, 30) < 2600); // any positive haste shortens the interval - // A NEGATIVE percent is an attack-speed SLOW (Thunder Clap): LENGTHENS the interval. - assert_eq!(swing_time_with_haste(2000, -10), 2222); // −10% → 2000*100/90 (swings slower) - assert!(swing_time_with_haste(2000, -50) > 2000); // any slow lengthens the interval - // A ≥100% slow can't divide-by-zero — the denominator floors at 1 (a very slow, bounded swing). + assert_eq!(swing_time_with_haste(2000, 0), 2000); + assert_eq!(swing_time_with_haste(2000, 50), 1333); + assert_eq!(swing_time_with_haste(2000, 100), 1000); + assert!(swing_time_with_haste(2600, 30) < 2600); + assert_eq!(swing_time_with_haste(2000, -10), 2222); + assert!(swing_time_with_haste(2000, -50) > 2000); assert_eq!(swing_time_with_haste(2000, -100), 200_000); - assert_eq!(swing_time_with_haste(2000, -150), 200_000); // clamped, not negative/overflow + assert_eq!(swing_time_with_haste(2000, -150), 200_000); } #[test] fn move_speed_snare_slows_and_is_baseline_safe_at_zero() { - assert_eq!(move_speed_with_pct(7.0, 0), 7.0); // no aura → base (baseline-safe) - assert!((move_speed_with_pct(7.0, -40) - 4.2).abs() < 1e-4); // −40% snare → 4.2 yd/s - assert!(move_speed_with_pct(7.0, 50) > 7.0); // a speed buff quickens - // A heavy snare floors at 10% of base (never freezes dead → no zero-length step). - assert!((move_speed_with_pct(7.0, -95) - 0.7).abs() < 1e-4); // floored at 10% - assert!((move_speed_with_pct(7.0, -200) - 0.7).abs() < 1e-4); // clamped, not 0/negative + assert_eq!(move_speed_with_pct(7.0, 0), 7.0); + assert!((move_speed_with_pct(7.0, -40) - 4.2).abs() < 1e-4); + assert!(move_speed_with_pct(7.0, 50) > 7.0); + assert!((move_speed_with_pct(7.0, -95) - 0.7).abs() < 1e-4); + assert!((move_speed_with_pct(7.0, -200) - 0.7).abs() < 1e-4); } #[test] fn rank_damage_multiplier_scales_swing_range_and_rank0_is_identity() { - // The six documented permille multipliers, in `rank` order (unknown ranks fall through to normal). - assert_eq!(rank_damage_multiplier_permille(0), 1000); // normal — identity - assert_eq!(rank_damage_multiplier_permille(1), 1400); // elite - assert_eq!(rank_damage_multiplier_permille(2), 1800); // rare-elite - assert_eq!(rank_damage_multiplier_permille(3), 2500); // boss - assert_eq!(rank_damage_multiplier_permille(4), 1500); // rare - assert_eq!(rank_damage_multiplier_permille(7), 1000); // unknown rank → normal, never amplify - - // Rank 0 is the IDENTITY for the swing range — every rank-0 creature (all current spawns) swings - // for exactly its base range. + assert_eq!(rank_damage_multiplier_permille(0), 1000); + assert_eq!(rank_damage_multiplier_permille(1), 1400); + assert_eq!(rank_damage_multiplier_permille(2), 1800); + assert_eq!(rank_damage_multiplier_permille(3), 2500); + assert_eq!(rank_damage_multiplier_permille(4), 1500); + assert_eq!(rank_damage_multiplier_permille(7), 1000); + assert_eq!( scale_swing_for_rank(CREATURE_MELEE_MIN, CREATURE_MELEE_MAX, 0), (CREATURE_MELEE_MIN, CREATURE_MELEE_MAX) ); - // Elite (×1.4): 1..3 → (1, 4) (1*1400/1000 = 1, 3*1400/1000 = 4). assert_eq!(scale_swing_for_rank(1, 3, 1), (1, 4)); - // Boss (×2.5): 10..20 → (25, 50). assert_eq!(scale_swing_for_rank(10, 20, 3), (25, 50)); } #[test] fn resist_mitigation_scales_and_caps_at_75() { - // No resistance (every unit today) → 0% → a magic hit is byte-identical (baseline-safe). assert_eq!(resist_mitigation_pct(0, 60), 0); assert_eq!(resist_mitigation_pct(0, 2), 0); - // A 0-level caster → 0% (guards the divide-by-zero). assert_eq!(resist_mitigation_pct(100, 0), 0); - // At the per-level cap (caster_level*5) → the full 75% ceiling. - assert_eq!(resist_mitigation_pct(300, 60), 75); // cap 300 → 75% - assert_eq!(resist_mitigation_pct(10, 2), 75); // cap 10 at L2 → 75% - // Resistance PAST the cap is wasted — still 75%, never more (never overflows the band). + assert_eq!(resist_mitigation_pct(300, 60), 75); + assert_eq!(resist_mitigation_pct(10, 2), 75); assert_eq!(resist_mitigation_pct(10_000, 60), 75); - // Partial: half the cap → half of 75% (37%, integer-floored). L2 cap 10, resist 5 → 37%. - assert_eq!(resist_mitigation_pct(5, 2), 37); // 75*5/10 = 37 (floored) - assert_eq!(resist_mitigation_pct(6, 2), 45); // 75*6/10 = 45 (the live-verified value) - // Monotonic up to the cap. + assert_eq!(resist_mitigation_pct(5, 2), 37); + assert_eq!(resist_mitigation_pct(6, 2), 45); assert!(resist_mitigation_pct(150, 60) > resist_mitigation_pct(75, 60)); } #[test] fn miss_chance_follows_the_level_hit_curve() { - assert_eq!(miss_chance_bp(sd(10, 10)), BASE_MISS_BP); // equal level → 5% - assert_eq!(miss_chance_bp(sd(10, 5)), BASE_MISS_BP); // lower-level target → still base - assert_eq!(miss_chance_bp(sd(10, 12)), 600); // +2 (skill diff 10) → 6% - assert_eq!(miss_chance_bp(sd(10, 13)), 900); // +3 (skill diff 15) → 7% + 5*0.4% = 9% - assert!(miss_chance_bp(sd(10, 14)) > miss_chance_bp(sd(10, 13))); // keeps rising past +3 - assert_eq!(miss_chance_bp(sd(1, 60)), MAX_MISS_BP); // capped at an absurd level delta (no overflow) + assert_eq!(miss_chance_bp(sd(10, 10)), BASE_MISS_BP); + assert_eq!(miss_chance_bp(sd(10, 5)), BASE_MISS_BP); + assert_eq!(miss_chance_bp(sd(10, 12)), 600); + assert_eq!(miss_chance_bp(sd(10, 13)), 900); + assert!(miss_chance_bp(sd(10, 14)) > miss_chance_bp(sd(10, 13))); + assert_eq!(miss_chance_bp(sd(1, 60)), MAX_MISS_BP); } #[test] fn crit_and_hit_ratings_fold_into_the_attack_table_bands() { - // No-aura path is byte-identical: zero bonus → exactly the flat base bands. - assert_eq!(crit_bp_with_bonus(0), CRIT_BP); // crit band unchanged with no A_MOD_COMBAT(CRIT) + assert_eq!(crit_bp_with_bonus(0), CRIT_BP); assert_eq!( miss_bp_with_bonus(miss_chance_bp(sd(10, 10)), 0), miss_chance_bp(sd(10, 10)) - ); // miss unchanged + ); - // A positive crit bonus ADDS to the crit band (the "Combat Insight" +1000 = +10% crit). assert_eq!(crit_bp_with_bonus(1000), CRIT_BP + 1000); - assert!(crit_bp_with_bonus(1000) > crit_bp_with_bonus(0)); // monotonic in the bonus + assert!(crit_bp_with_bonus(1000) > crit_bp_with_bonus(0)); - // A hit bonus SUBTRACTS from the miss band (hit rating reduces the chance to miss). - let base_miss = miss_chance_bp(sd(10, 12)); // 600 vs a +2 target - assert_eq!(miss_bp_with_bonus(base_miss, 500), base_miss - 500); // +500 hit → -5% miss - assert!(miss_bp_with_bonus(base_miss, 500) < base_miss); // monotonic down in the bonus + let base_miss = miss_chance_bp(sd(10, 12)); + assert_eq!(miss_bp_with_bonus(base_miss, 500), base_miss - 500); + assert!(miss_bp_with_bonus(base_miss, 500) < base_miss); - // The miss band FLOORS at 0 — a hit bonus larger than the base miss never underflows the u32. assert_eq!( miss_bp_with_bonus(BASE_MISS_BP, BASE_MISS_BP as i32 + 1000), 0 ); assert_eq!(miss_bp_with_bonus(0, 100), 0); - // The crit band CAPS at 10000 (never overflows the 0..10000 attack-table line) and floors at 0 - // for a (hypothetical) crit DEBUFF whose magnitude exceeds the base. assert_eq!(crit_bp_with_bonus(100_000), 10_000); assert_eq!(crit_bp_with_bonus(-(CRIT_BP as i32) - 500), 0); } #[test] fn glancing_only_vs_higher_level_and_caps() { - assert_eq!(glancing_chance_bp(10, 10), 0); // equal level → no glancing - assert_eq!(glancing_chance_bp(10, 5), 0); // lower-level target → none - assert_eq!(glancing_chance_bp(10, 11), GLANCING_BP_PER_LEVEL); // +1 → 10% - assert_eq!(glancing_chance_bp(10, 13), 3 * GLANCING_BP_PER_LEVEL); // +3 → 30% - assert_eq!(glancing_chance_bp(10, 60), MAX_GLANCING_BP); // capped at 40% + assert_eq!(glancing_chance_bp(10, 10), 0); + assert_eq!(glancing_chance_bp(10, 5), 0); + assert_eq!(glancing_chance_bp(10, 11), GLANCING_BP_PER_LEVEL); + assert_eq!(glancing_chance_bp(10, 13), 3 * GLANCING_BP_PER_LEVEL); + assert_eq!(glancing_chance_bp(10, 60), MAX_GLANCING_BP); } #[test] fn attack_table_walks_miss_dodge_parry_glancing_crit_hit_in_order() { - // +3 target: miss 9%, agi-20 dodge incl. skill term, parry 5%, glancing 30%, crit 10%. let t = AttackTable { miss: miss_chance_bp(sd(10, 13)), dodge: dodge_chance_bp(20, sd(10, 13)), parry: PARRY_BP, glancing: glancing_chance_bp(10, 13), - block: 0, // an unshielded defender has no block band + block: 0, crit: CRIT_BP, - crushing: 0, // a player→higher-mob swing never crushes + crushing: 0, }; let (m, d, p, g, c) = (t.miss, t.dodge, t.parry, t.glancing, t.crit); - // Boundaries are half-open [floor, next): the last bp of each band still belongs to it. assert_eq!(attack_outcome(0, &t), HIT_MISS); assert_eq!(attack_outcome(m - 1, &t), HIT_MISS); assert_eq!(attack_outcome(m, &t), HIT_DODGE); @@ -1097,9 +769,8 @@ mod tests { assert_eq!(attack_outcome(m + d + p + g - 1, &t), HIT_GLANCING); assert_eq!(attack_outcome(m + d + p + g, &t), HIT_CRIT); assert_eq!(attack_outcome(m + d + p + g + c, &t), HIT_NORMAL); - assert_eq!(attack_outcome(9_999, &t), HIT_NORMAL); // the remaining line is a normal hit + assert_eq!(attack_outcome(9_999, &t), HIT_NORMAL); - // An equal-level fight has no glancing band: parry abuts crit directly. let eq = AttackTable { miss: miss_chance_bp(sd(10, 10)), dodge: dodge_chance_bp(0, sd(10, 10)), @@ -1115,17 +786,14 @@ mod tests { #[test] fn crushing_blows_arm_only_for_higher_level_creatures_and_amplify() { - // 0 below the +3 threshold (so player→mob, PvP, and small-gap fights are byte-identical). - assert_eq!(crushing_chance_bp(10, 10), 0); // equal level - assert_eq!(crushing_chance_bp(10, 13), 0); // attacker LOWER (the glancing case) → no crush - assert_eq!(crushing_chance_bp(12, 10), 0); // +2 → still below the 3-level threshold - // At/above the threshold: +10% per level past the floor. - assert_eq!(crushing_chance_bp(13, 10), CRUSH_BP_PER_LEVEL); // +3 → 10% - assert_eq!(crushing_chance_bp(14, 10), 2 * CRUSH_BP_PER_LEVEL); // +4 → 20% - assert!(crushing_chance_bp(15, 10) > crushing_chance_bp(14, 10)); // monotonic in the gap - assert_eq!(crushing_chance_bp(60, 1), MAX_CRUSH_BP); // capped at 50%, never overflows the table - - // The crushing band sits AFTER crit in the walk, and a crushing outcome amplifies to 150%. + assert_eq!(crushing_chance_bp(10, 10), 0); + assert_eq!(crushing_chance_bp(10, 13), 0); + assert_eq!(crushing_chance_bp(12, 10), 0); + assert_eq!(crushing_chance_bp(13, 10), CRUSH_BP_PER_LEVEL); + assert_eq!(crushing_chance_bp(14, 10), 2 * CRUSH_BP_PER_LEVEL); + assert!(crushing_chance_bp(15, 10) > crushing_chance_bp(14, 10)); + assert_eq!(crushing_chance_bp(60, 1), MAX_CRUSH_BP); + let t = AttackTable { miss: 0, dodge: 0, @@ -1135,18 +803,16 @@ mod tests { crit: 1000, crushing: 2000, }; - assert_eq!(attack_outcome(999, &t), HIT_CRIT); // last bp of the crit band - assert_eq!(attack_outcome(1000, &t), HIT_CRUSHING); // first bp past crit → crushing - assert_eq!(attack_outcome(2999, &t), HIT_CRUSHING); // last bp of the crushing band - assert_eq!(attack_outcome(3000, &t), HIT_NORMAL); // remainder is a normal hit + assert_eq!(attack_outcome(999, &t), HIT_CRIT); + assert_eq!(attack_outcome(1000, &t), HIT_CRUSHING); + assert_eq!(attack_outcome(2999, &t), HIT_CRUSHING); + assert_eq!(attack_outcome(3000, &t), HIT_NORMAL); } #[test] fn block_chance_mirrors_parry_structure_and_caps() { - // Flat floor vs an equal/lower-level attacker (the shielded baseline). assert_eq!(block_chance_bp(sd(10, 10)), BLOCK_BP); - assert_eq!(block_chance_bp(sd(13, 10)), BLOCK_BP); // attacker HIGHER → still the floor (skill_diff 0) - // +1 target level = 5 skill points → +0.5% (mirrors parry exactly). + assert_eq!(block_chance_bp(sd(13, 10)), BLOCK_BP); assert_eq!( block_chance_bp(sd(10, 11)), BLOCK_BP + 5 * BLOCK_BP_PER_SKILL @@ -1154,16 +820,14 @@ mod tests { assert_eq!( block_chance_bp(sd(10, 13)), BLOCK_BP + 15 * BLOCK_BP_PER_SKILL - ); // +3 target - assert!(block_chance_bp(sd(10, 14)) > block_chance_bp(sd(10, 13))); // monotonic in the level gap - assert_eq!(block_chance_bp(sd(1, 1_000_000)), MAX_BLOCK_BP); // capped, never overflows the table - // Block scales identically to parry (both track defense-vs-weapon-skill, agility-independent). + ); + assert!(block_chance_bp(sd(10, 14)) > block_chance_bp(sd(10, 13))); + assert_eq!(block_chance_bp(sd(1, 1_000_000)), MAX_BLOCK_BP); assert_eq!(block_chance_bp(sd(10, 13)), parry_chance_bp(sd(10, 13))); } #[test] fn attack_table_inserts_block_after_glancing_before_crit() { - // A non-zero block band must sit exactly between glancing and crit in the walk. let t = AttackTable { miss: 900, dodge: 500, @@ -1173,17 +837,16 @@ mod tests { crit: 1000, crushing: 0, }; - let pre = t.miss + t.dodge + t.parry + t.glancing; // start of the block band - assert_eq!(attack_outcome(pre - 1, &t), HIT_GLANCING); // last bp of glancing - assert_eq!(attack_outcome(pre, &t), HIT_BLOCK); // first bp of block - assert_eq!(attack_outcome(pre + t.block - 1, &t), HIT_BLOCK); // last bp of block - assert_eq!(attack_outcome(pre + t.block, &t), HIT_CRIT); // first bp past block → crit - assert_eq!(attack_outcome(pre + t.block + t.crit, &t), HIT_NORMAL); // remainder is a hit + let pre = t.miss + t.dodge + t.parry + t.glancing; + assert_eq!(attack_outcome(pre - 1, &t), HIT_GLANCING); + assert_eq!(attack_outcome(pre, &t), HIT_BLOCK); + assert_eq!(attack_outcome(pre + t.block - 1, &t), HIT_BLOCK); + assert_eq!(attack_outcome(pre + t.block, &t), HIT_CRIT); + assert_eq!(attack_outcome(pre + t.block + t.crit, &t), HIT_NORMAL); } #[test] fn block_zero_band_is_baseline_identical() { - // With block=0 the walk is byte-identical to a no-block table: glancing abuts crit directly. let t = AttackTable { miss: 900, dodge: 500, @@ -1194,15 +857,11 @@ mod tests { crushing: 0, }; let after_glancing = t.miss + t.dodge + t.parry + t.glancing; - assert_eq!(attack_outcome(after_glancing, &t), HIT_CRIT); // no zero-width block band fires + assert_eq!(attack_outcome(after_glancing, &t), HIT_CRIT); assert_eq!(attack_outcome(after_glancing + t.crit, &t), HIT_NORMAL); } - // Test-fixture builder: one argument per `WorldEntity` column the regen vectors vary. #[allow(clippy::too_many_arguments)] - /// A hand-built `WorldEntity` for the pure-fn-adjacent wrapper tests below (`is_rage_user`, - /// `regen_entity_power`, `regen_entity_health`) — every field except the ones the test cares about - /// is a neutral zero/default, mirroring `build_creature_entity`'s field order. fn entity_for_regen( unit_bytes_0: u32, health: u32, @@ -1271,7 +930,7 @@ mod tests { owner_guid: 0, skinned: false, mana_regen_paused_until_ms, - death_expire_micros: 0, // not exercised by these regen/rage tests + death_expire_micros: 0, instance_id: 0, run_speed_mult_bp: 10_000, godmode: false, @@ -1328,7 +987,6 @@ mod tests { 1, 0, ); - // A creature's placeholder bytes (`0x0101` from `build_creature_entity`) pack power type 0 (MANA). let creature = entity_for_regen(0x0101, 42, 42, 0, 0, 0, 1, 0); assert!(is_rage_user(&warrior)); assert!(!is_rage_user(&mage)); @@ -1338,9 +996,8 @@ mod tests { #[test] fn rage_from_damage_is_asymmetric_dealt_vs_taken() { - // The attacker (dealing) gains more rage than the defender (taking) for the same damage. - assert_eq!(rage_from_damage(10, true), 80); // dealt: 10 * 8 - assert_eq!(rage_from_damage(10, false), 30); // taken: 10 * 3 + assert_eq!(rage_from_damage(10, true), 80); + assert_eq!(rage_from_damage(10, false), 30); assert_eq!(rage_from_damage(0, true), 0); assert!(rage_from_damage(10, true) > rage_from_damage(10, false)); } @@ -1348,7 +1005,6 @@ mod tests { #[test] fn regen_entity_power_wrapper_reads_fields_and_straddles_the_fsr_window() { use lyracore_shared::packing::{power_type, unit_bytes_0}; - // A mana entity with an FSR window ending at ms 5000, spirit 40 / level 10 (26/tick when clear). let mage = entity_for_regen( unit_bytes_0(1, 8, 0, power_type::MANA), 100, @@ -1359,23 +1015,19 @@ mod tests { 10, 5_000, ); - // Strictly inside the window → paused, no regen. assert_eq!( regen_entity_power(&mage, false, RegenWindow::new(4_999, 4_000), 0), 100 ); - // Straddling the boundary: `now_ms == paused_until_ms` is NOT `<`, so the window has just cleared. assert_eq!( regen_entity_power(&mage, false, RegenWindow::new(5_000, 4_000), 0), 100 + 26 ); - // Past the window, mid-combat → still regens (the FSR gate, not combat state, controls mana). assert_eq!( regen_entity_power(&mage, true, RegenWindow::new(6_000, 4_000), 0), 100 + 26 ); - // A rage (warrior) entity ignores the FSR field entirely — reads its own power-type byte. let warrior = entity_for_regen( unit_bytes_0(1, 1, 0, power_type::RAGE), 1, @@ -1389,11 +1041,11 @@ mod tests { assert_eq!( regen_entity_power(&warrior, false, RegenWindow::new(0, 4_000), 0), 1000 - 50 - ); // out of combat → decays + ); assert_eq!( regen_entity_power(&warrior, true, RegenWindow::new(0, 4_000), 0), 1000 - ); // in combat → holds + ); } #[test] @@ -1402,7 +1054,7 @@ mod tests { assert_eq!( regen_entity_health(&e, 0, RegenWindow::new(4_000, 4_000)), 100 + (30 + 10 + 1) - ); // spirit + level + 1 per tick + ); } #[test] @@ -1455,32 +1107,23 @@ mod tests { #[test] fn regen_power_default_branch_leaves_focus_and_happiness_unchanged() { use lyracore_shared::packing::power_type as pt; - // FOCUS (hunter) and HAPPINESS (pet) aren't ticked by this pass — the catch-all `_` arm holds - // the value steady regardless of combat state. assert_eq!(regen_power(pt::FOCUS, 50, 100, 40, 10, false, false), 50); assert_eq!(regen_power(pt::HAPPINESS, 50, 100, 40, 10, true, false), 50); } #[test] - // The expectations are written in the SHAPE of the formula they pin (`0 + (level/2 + 1)` = base - // plus the spirit-0 trickle). Folding the `0 +` away would make the vector unreadable against - // `mana_regen_per_tick`, which is the only reason these numbers are checkable by eye. #[allow(clippy::identity_op)] fn power_regen_by_type_and_combat_state() { use lyracore_shared::packing::power_type as pt; - // Helper aliases for readability: FSR window states. - const FSR_ACTIVE: bool = true; // within 5s of last mana spend → no mana regen - const FSR_CLEAR: bool = false; // 5s elapsed (or never cast) → regen proceeds + const FSR_ACTIVE: bool = true; + const FSR_CLEAR: bool = false; const IN_COMBAT: bool = true; const OUT_OF_COMBAT: bool = false; - // Mana: SPIRIT+level scaled toward max when FSR window is clear — in OR out of combat. - // spirit 40, level 10 → 40/2 + 10/2 + 1 = 26 per tick. assert_eq!( regen_power(pt::MANA, 100, 500, 40, 10, OUT_OF_COMBAT, FSR_CLEAR), 100 + 26 ); - // FSR active (just cast): mana paused regardless of combat state. assert_eq!( regen_power(pt::MANA, 100, 500, 40, 10, OUT_OF_COMBAT, FSR_ACTIVE), 100 @@ -1489,27 +1132,22 @@ mod tests { regen_power(pt::MANA, 100, 500, 40, 10, IN_COMBAT, FSR_ACTIVE), 100 ); - // FSR clear AND in combat: mana REGENS (vanilla 5-second rule — mid-fight regen after 5s). assert_eq!( regen_power(pt::MANA, 100, 500, 40, 10, IN_COMBAT, FSR_CLEAR), 100 + 26 ); - // Never overshoots max. assert_eq!( regen_power(pt::MANA, 490, 500, 40, 10, OUT_OF_COMBAT, FSR_CLEAR), 500 ); - // More spirit → faster mana regen (the headline invariant). let lo = regen_power(pt::MANA, 0, 5000, 20, 10, OUT_OF_COMBAT, FSR_CLEAR); let hi = regen_power(pt::MANA, 0, 5000, 200, 10, OUT_OF_COMBAT, FSR_CLEAR); assert!(hi > lo, "more spirit must regen more mana ({hi} !> {lo})"); - // Spirit 0 (a stat-less mana creature) still trickles a small, non-zero amount when FSR clear. assert_eq!( regen_power(pt::MANA, 0, 500, 0, 10, OUT_OF_COMBAT, FSR_CLEAR), 0 + (10 / 2 + 1) - ); // level/2 + 1 = 6 + ); assert!(regen_power(pt::MANA, 0, 500, 0, 1, OUT_OF_COMBAT, FSR_CLEAR) > 0); - // Energy: flat ENERGY_TICK (40) in AND out of combat (rogue), INDEPENDENT of spirit, capped. assert_eq!( regen_power(pt::ENERGY, 20, 100, 0, 10, IN_COMBAT, FSR_CLEAR), 60 @@ -1517,31 +1155,27 @@ mod tests { assert_eq!( regen_power(pt::ENERGY, 20, 100, 999, 10, OUT_OF_COMBAT, FSR_CLEAR), 60 - ); // spirit doesn't affect energy + ); assert_eq!( regen_power(pt::ENERGY, 95, 100, 0, 10, IN_COMBAT, FSR_CLEAR), 100 - ); // capped at max - // Rage: decays at RAGE_DECAY_PER_TICK (50 internal = 5 displayed per ~4s tick ≈ 1.25 rage/sec) - // out of combat, holds in combat, never underflows (saturating_sub). + ); assert_eq!( regen_power(pt::RAGE, 1000, 1000, 0, 10, OUT_OF_COMBAT, FSR_CLEAR), 1000 - 50 - ); // -RAGE_DECAY_PER_TICK + ); assert_eq!( regen_power(pt::RAGE, 1000, 1000, 0, 10, IN_COMBAT, FSR_CLEAR), 1000 - ); // held during a fight + ); assert_eq!( regen_power(pt::RAGE, 30, 1000, 0, 10, OUT_OF_COMBAT, FSR_CLEAR), 0 - ); // saturating, no underflow - // Partial step: 80 rage internal (8 displayed) decays by 50 → 30, not 0 (gradual, not a cliff). + ); assert_eq!( regen_power(pt::RAGE, 80, 1000, 0, 10, OUT_OF_COMBAT, FSR_CLEAR), 30 ); - // A creature with no power bar (max 0) never changes, regardless of spirit/level. assert_eq!( regen_power(pt::MANA, 0, 0, 40, 10, OUT_OF_COMBAT, FSR_CLEAR), 0 @@ -1551,15 +1185,12 @@ mod tests { #[test] fn fsr_gate_pins_mana_regen_window() { use lyracore_shared::packing::power_type as pt; - // spirit 40, level 10 → 26 per tick. let spirit = 40u32; let level = 10u32; - // FSR active (now_ms < paused_until_ms): no regen. assert_eq!( regen_power(pt::MANA, 100, 500, spirit, level, false, true), 100 ); - // FSR expired (now_ms >= paused_until_ms): regen proceeds regardless of combat flag. assert_eq!( regen_power(pt::MANA, 100, 500, spirit, level, true, false), 100 + 26 @@ -1571,52 +1202,36 @@ mod tests { } #[test] - // `0 + 10` is the formula's shape (spirit contribution + `level + 1`), kept verbatim so the - // vector reads against `health_regen_per_tick`. See the sibling power-regen test. #[allow(clippy::identity_op)] fn health_regen_is_spirit_and_level_scaled_and_clamped() { - // SPIRIT+level scaled: spirit 30, level 10 → 30 + 10 + 1 = 41 per tick. assert_eq!(regen_health(100, 1000, 30, 10), 100 + 41); - // More spirit → faster health regen. let lo = regen_health(0, 100_000, 10, 10); let hi = regen_health(0, 100_000, 100, 10); assert!(hi > lo, "more spirit must regen more health ({hi} !> {lo})"); - // Capped at max (no overshoot). assert_eq!(regen_health(995, 1000, 30, 10), 1000); - // Spirit 0 (every creature today) still heals a small, non-zero amount: level + 1. assert_eq!(regen_health(0, 1000, 0, 9), 0 + 10); assert!(regen_health(0, 1000, 0, 1) > 0); - // A unit with no HP bar (max 0) never changes. assert_eq!(regen_health(0, 0, 30, 10), 0); } #[test] fn combat_regen_is_partial_fraction_of_normal_tick() { - // Zero pct → no change (aura absent → today's behaviour). assert_eq!(regen_health_in_combat(100, 1000, 30, 10, 0), 100); - // 100% → identical to the full out-of-combat tick. assert_eq!( regen_health_in_combat(100, 1000, 30, 10, 100), regen_health(100, 1000, 30, 10) ); - // Troll Regeneration typical case: spirit 0, level 5 → full_tick = 5+0+1 = 6; 10% → 6*10/100 = 0 - // (integer floor — a tiny troll gets zero ticks at low level, expected at L5 with spirit=0). assert_eq!(regen_health_in_combat(100, 1000, 0, 5, 10), 100); - // Spirit 50, level 20: full_tick = 50+20+1 = 71; 10% → 71*10/100 = 7. assert_eq!(regen_health_in_combat(100, 1000, 50, 20, 10), 100 + 7); - // Two stacked auras (20% each = 40%) — caller sums and passes total pct. assert_eq!( regen_health_in_combat(100, 1000, 50, 20, 40), 100 + 71 * 40 / 100 ); - // Always monotonic: more pct → more or equal regen. assert!( regen_health_in_combat(0, 10000, 50, 20, 50) >= regen_health_in_combat(0, 10000, 50, 20, 10) ); - // Capped at max (no overshoot even for pct=100 with health close to max). assert_eq!(regen_health_in_combat(995, 1000, 50, 20, 100), 1000); - // max=0 (no HP bar) → unchanged. assert_eq!(regen_health_in_combat(0, 0, 30, 10, 10), 0); } @@ -1631,31 +1246,22 @@ mod tests { #[test] fn armor_mitigation_follows_the_vanilla_curve() { - // 0 armor → no reduction (every creature target today). assert_eq!(armor_mitigation_pct(0, 1), 0); - // 42 armor (a naked L2 warrior, agi 21*2) vs an L1 attacker: 42/(42+400+85) = 7.97% → 7%. assert_eq!(armor_mitigation_pct(42, 1), 7); - // Higher attacker level → the same armor mitigates LESS (denominator grows). assert!(armor_mitigation_pct(42, 60) < armor_mitigation_pct(42, 1)); - // Enormous armor is capped at 75% (the vanilla physical ceiling). assert_eq!(armor_mitigation_pct(1_000_000, 1), 75); } #[test] - // `.max(1)` is provably a no-op for THIS input (3*93/100 = 2), and that is the point: the - // expectation restates the production floor so the vector still reads correctly if the base - // range is ever retuned to a value the floor does bite on. #[allow(clippy::unnecessary_min_or_max)] fn swing_profile_applies_mitigation_to_the_range() { - // 0-armor target: profile is the base range untouched (mitigation 0). let p = swing_profile(5, 7, 0, 1); assert_eq!((p.base_min, p.base_max, p.mitigation_pct), (5, 7, 0)); assert_eq!((p.final_min, p.final_max), (5, 7)); - // 42-armor target (7%): a creature's 1–3 base shrinks but floors at 1 (a landed hit always lands). let q = swing_profile(1, 3, 42, 1); assert_eq!(q.mitigation_pct, 7); - assert_eq!(q.final_min, 1); // 1*93/100 = 0 → floored to 1 - assert_eq!(q.final_max, (3 * 93 / 100).max(1)); // 2 + assert_eq!(q.final_min, 1); + assert_eq!(q.final_max, (3 * 93 / 100).max(1)); } mod offhand_tests { @@ -1663,15 +1269,12 @@ mod tests { #[test] fn apply_offhand_penalty_halves_the_range() { - // A plain even range halves cleanly. assert_eq!(apply_offhand_penalty(10, 20), (5, 10)); - // Vanilla's 50% dual-wield penalty on a weapon-scaled range. assert_eq!(apply_offhand_penalty(2, 4), (1, 2)); } #[test] fn apply_offhand_penalty_floors_at_one() { - // A very low min/max never halves to 0 — an off-hand swing always lands at least 1. assert_eq!(apply_offhand_penalty(1, 1), (1, 1)); assert_eq!(apply_offhand_penalty(0, 1), (1, 1)); assert_eq!(apply_offhand_penalty(0, 0), (1, 1)); diff --git a/module/src/combo.rs b/module/src/combo.rs index 6cf904b4f..025e33ba5 100644 --- a/module/src/combo.rs +++ b/module/src/combo.rs @@ -1,17 +1,7 @@ -//! Combo points — the Rogue resource. A per-(owner, target) pool (0..=MAX_COMBO) that GENERATORS -//! (an `E_ADD_COMBO` effect — Sinister Strike / Gouge / Backstab) build on the target, and FINISHERS -//! (`E_FINISHER_DAMAGE` — Eviscerate) read to scale their output, then spend. STATE only: the spell -//! effect KINDS in `spell/cast.rs` operate it generically (no per-spell code; the importer's curated -//! correction tags the generator/finisher effects by name). Mirrors the `game_threat` table shape. -//! Per-target by design — vanilla combo points live on the rogue's CURRENT target. [entity] - use spacetimedb::{table, ReducerContext, Table}; -/// Vanilla combo-point cap. pub const MAX_COMBO: u32 = 5; -/// One (owner → target) combo-point pool. UPSERT by (owner, target); the small per-owner list is -/// scanned via `by_owner` exactly like the threat table's `by_creature`. #[table(accessor = game_combo_point, public, index(accessor = by_owner, btree(columns = [owner_guid])))] pub struct ComboPoint { #[primary_key] @@ -22,22 +12,14 @@ pub struct ComboPoint { pub points: u32, } -/// One generator tick: bump, capped at `MAX_COMBO`. Pure → unit-tested. pub(crate) fn capped_inc(points: u32) -> u32 { (points + 1).min(MAX_COMBO) } -/// Finisher damage: the per-point base × the combo points spent. Pure → unit-tested. pub(crate) fn finisher_damage(per_point: i32, combo: u32) -> i32 { per_point.saturating_mul(combo as i32) } -/// Slice-and-Dice-style combo-FINISHER duration: the spell header's base duration + 3s per combo point -/// (combo capped at `MAX_COMBO`). The +3000ms-per-point curve lives HERE (one named, tested place); the -/// base comes from the DBC header (`hdr.duration_ms`, kept honest at 6000 for SnD). Vanilla SnD: -/// 0→6000, 1→9000, 2→12000, 3→15000, 4→18000, 5→21000 (= 6 + 3·combo seconds). -/// 0 combo → base (no scale) — the spend then deletes nothing, so a 0-combo cast is a harmless base-duration -/// buff. Pure → unit-tested. pub(crate) fn finisher_duration_ms(base_ms: u32, combo: u32) -> u32 { base_ms + combo.min(MAX_COMBO) * 3_000 } @@ -50,14 +32,12 @@ fn find_row(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) -> Option u32 { find_row(ctx, owner_guid, target_guid) .map(|r| r.points) .unwrap_or(0) } -/// Generator effect: +1 combo point on (owner, target), capped at `MAX_COMBO`. pub(crate) fn add_combo_point(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) { let combos = ctx.db.game_combo_point(); if let Some(mut r) = find_row(ctx, owner_guid, target_guid) { @@ -73,7 +53,6 @@ pub(crate) fn add_combo_point(ctx: &ReducerContext, owner_guid: u64, target_guid } } -/// Finisher consume: drop (owner, target) back to empty (deletes the row). pub(crate) fn spend_combo(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) { let combos = ctx.db.game_combo_point(); if let Some(r) = find_row(ctx, owner_guid, target_guid) { @@ -96,20 +75,17 @@ mod tests { #[test] fn finisher_scales_with_points() { - assert_eq!(super::finisher_damage(5, 3), 15); // 5 per point × 3 combo - assert_eq!(super::finisher_damage(5, 0), 0); // no combo → no damage + assert_eq!(super::finisher_damage(5, 3), 15); + assert_eq!(super::finisher_damage(5, 0), 0); } #[test] fn finisher_duration_follows_the_vanilla_snd_curve() { - // Slice and Dice: header base 6000ms (1 combo point in the DBC) + 3s per combo point. - // Vanilla 1..5 combo → 9/12/15/18/21s. assert_eq!(super::finisher_duration_ms(6000, 1), 9_000); assert_eq!(super::finisher_duration_ms(6000, 2), 12_000); assert_eq!(super::finisher_duration_ms(6000, 3), 15_000); assert_eq!(super::finisher_duration_ms(6000, 4), 18_000); assert_eq!(super::finisher_duration_ms(6000, 5), 21_000); - // 0 combo → base (no scale), and combo above the cap saturates at the 5-point duration. assert_eq!(super::finisher_duration_ms(6000, 0), 6_000); assert_eq!(super::finisher_duration_ms(6000, 9), 21_000); } diff --git a/module/src/config.rs b/module/src/config.rs index 817570ed3..f3a265ebb 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -2,30 +2,22 @@ use spacetimedb::{log, reducer, table, ReducerContext, Table}; use crate::helpers::require_operator; -// =========================================================================================== -// Static-data tables [static] -// =========================================================================================== - -/// One row per realm shown in the realm list. [static] #[table(accessor = game_realm, public)] pub struct Realm { #[primary_key] pub id: u8, pub name: String, - pub address: String, // "ip:port" handed to the client + pub address: String, pub realm_type: u32, pub flags: u8, pub population: f32, pub timezone: u8, } -/// Canonical starting position per (race, class) — coords/map/zone for character creation. Loaded -/// from cmangos `playercreateinfo` by the importer's `--dump` mode (the demo seed has only the -/// Human-Warrior row; create_character falls back to it for unseeded combos). [static] #[table(accessor = game_start_position, public)] pub struct StartPosition { #[primary_key] - pub race_class: u16, // (race << 8) | class + pub race_class: u16, pub race: u8, pub class: u8, pub map_id: u32, @@ -37,98 +29,46 @@ pub struct StartPosition { pub display_id: u32, } -/// Server-wide tunables (singleton — `id` is always 0). `xp_rate` multiplies ALL XP gains (creature -/// kills + quest turn-ins) so a realm can speed up / slow down leveling for testing or a custom-rate -/// server. A missing row reads as 1.0× (see `xp::xp_rate`), so a fresh DB behaves Blizzlike. SQL-editable -/// (no Timestamp): `UPDATE game_config SET xp_rate = 2.0 WHERE id = 0`; `debug_set_xp_rate` is the harness -/// path. NOT player-callable — only the admin (SQL) / the debug reducer set it, so a client can't self-boost. #[table(accessor = game_config, public)] pub struct ServerConfig { #[primary_key] - pub id: u32, // singleton: always 0 + pub id: u32, pub xp_rate: f32, - // END-APPENDED: nav-grid consumption gate (chase pathing + aggro/cast/melee - // LoS). Default ON since 244 passed (benchmark: nav cost indistinguishable; live: all four - // wall/fence/chase/hold-fire scenarios). A world WITHOUT nav data imported behaves exactly - // as before either way (missing chunk = no obstacles known). Toggle: `debug_set_nav_enabled` - // or `UPDATE game_config SET nav_enabled = false WHERE id = 0`. #[default(true)] pub nav_enabled: bool, - // END-APPENDED: does THIS database host dungeon-instance POPULATIONS? Default `true` - // = every single-database realm behaves exactly as it always has (the portal spawns the dungeon - // where the player is standing). Set `false` on the OPEN-WORLD shard of a multi-database - // deployment: `create_instance_with_id` then files the `game_instance` row + - // binding as a LEASE and spawns nothing, so the world writer never pays for a dungeon whose run - // happens on another database — the instances shard, where this stays `true`, spawns the - // population when the gateway mirrors the id there via `ensure_instance`. - // - // Deliberately a WORLD POLICY, not a shard id: the module still knows nothing about shards; - // it only knows whether it hosts instance populations — the `nav_enabled` precedent. - // Operator-set, like `nav_enabled`: `UPDATE game_config SET hosts_instances = false WHERE id = 0`. - // Deliberate simplification: one SQL update rather than a gateway→module - // policy push. Ceiling: an operator who forgets it gets today's behavior (the dungeon spawns - // on the world shard and is evicted after the transfer) — degraded, never broken. Upgrade - // path: the gateway derives it from the shard map and asserts it at startup. #[default(true)] pub hosts_instances: bool, - // END-APPENDED: park every Package bot where it was spawned — the GOAL and COMBAT brains - // return immediately, so a crowd neither picks up quests nor grinds its way out of the zone. The - // WANDER pass deliberately keeps running (6 yd hops around home), because a launch-day crowd - // milling in a plaza is the movement load worth measuring. The load-test lever: bots that quest - // and fight measure content, not capacity, and drag the zone's creatures into the number. - // Operator-set like `nav_enabled`: `UPDATE game_config SET bots_idle = true WHERE id = 0`. #[default(false)] pub bots_idle: bool, #[default(false)] pub vmap_enabled: bool, - // END-APPENDED: let vmap-DERIVED nav coverage join path planning (`nav::fetcher` merges it - // into the imported grid). Separate from `vmap_enabled`, which gates the exact rays, and from - // `nav_enabled`, which gates the imported grid itself. Coverage belongs to one vmap generation - // and only an ACTIVE generation with a complete manifest is ever read, so flipping this on a - // map without prepared coverage changes nothing. Default OFF: preparation is an operator - // workflow rolled out per map, and this flag is the one-command rollback. Toggle: - // `debug_set_nav_coverage_enabled` or - // `UPDATE game_config SET nav_coverage_enabled = true WHERE id = 0`. #[default(false)] pub nav_coverage_enabled: bool, } -/// Starting items per (race, class) — the character-creation loadout. Loaded from the client -/// `CharStartOutfit.dbc` by the importer's `--dbc` mode (the cmangos dump's `playercreateinfo_item` is -/// EMPTY, so the outfit DBC is the source). `grant_starter_item` looks these up at character creation and -/// equips the equippable pieces / stows the rest; it falls back to the hand-authored Warrior loadout -/// when the table is empty (pre-import), so login never breaks. Multiple rows per (race, class). [static] #[table(accessor = game_start_item, public, index(accessor = by_race_class, btree(columns = [race_class])))] pub struct StartItem { #[primary_key] #[auto_inc] pub id: u64, - pub race_class: u16, // (race << 8) | class — same key as game_start_position + pub race_class: u16, pub item_entry: u32, } -/// Per-race display models + faction, loaded from the client `ChrRaces.dbc` by the importer's -/// `--dbc` mode (importer P1). `player_login` looks this up by the character's race to set the -/// gender-correct body model + nameplate faction, replacing the hardcoded `49`/`1` (it falls back to -/// those — the Human-Male values — when the table isn't loaded, so login never breaks). [static] #[table(accessor = game_race_info, public)] pub struct RaceInfo { #[primary_key] pub race: u8, - pub male_display: u32, // CreatureDisplayInfo id (== 49 for Human male) + pub male_display: u32, pub female_display: u32, - pub faction_template: u32, // ChrRaces.faction (== 1, Player|Alliance, for Human) + pub faction_template: u32, } -/// The legal (race, class) combinations, loaded from the client `CharBaseInfo.dbc` by the importer's -/// `--dbc` mode (importer P1). `create_character` rejects a combo absent from this table — server-side -/// defense-in-depth (the client already gates the UI). When the table is empty (unloaded), the gate -/// is skipped so character creation still works pre-import. PK packs `(race<<8)|class`. [static] #[table(accessor = game_char_base_info, public)] pub struct CharBaseInfo { #[primary_key] - pub race_class: u16, // (race << 8) | class + pub race_class: u16, pub race: u8, pub class: u8, } @@ -146,12 +86,6 @@ pub struct GameArea { pub name: String, } -/// One `AreaTrigger.dbc` row — a trigger volume (a sphere via `radius`, or a box via -/// `box_length`/`box_width`/`box_height`/`box_yaw`; vanilla trigger definitions use one shape or the -/// other, never both). Loaded from the client `AreaTrigger.dbc` by the importer's `--dbc` mode -/// (see `importer/src/dbc.rs::area_trigger_sql`). The geometric half of inn triggers -/// ("make this inn your home" needs the player standing inside the inn's trigger volume), -/// dungeon entrances, and quest explore objectives. No Timestamp → plain SQL. [static] #[table(accessor = game_area_trigger, public)] pub struct GameAreaTrigger { #[primary_key] @@ -167,11 +101,6 @@ pub struct GameAreaTrigger { pub box_yaw: f32, } -/// One `TaxiNodes.dbc` row: a named flight point and the two mount displays the 5875 client data -/// assigns to it. `id` is the server-side storage key; `client_node_id` is the unique one-based bit -/// position used by the vanilla 256-bit taxi mask. Imported rows use their DBC id for both, while -/// reserved fixtures retain high storage ids without leaking those values onto the wire. The DBC -/// mount array is Horde first, Alliance second. [static] #[table(accessor = game_taxi_node, public)] pub struct GameTaxiNode { #[primary_key] @@ -187,8 +116,6 @@ pub struct GameTaxiNode { pub mount_display_alliance: u32, } -/// One directed `TaxiPath.dbc` route. A reverse flight exists only when the DBC contains a separate -/// row in the opposite direction. Its ordered geometry lives in `game_taxi_path_node`. [static] #[table( accessor = game_taxi_path, public, @@ -200,15 +127,9 @@ pub struct GameTaxiPath { pub id: u32, pub source_node_id: u32, pub destination_node_id: u32, - /// Copper, copied from `TaxiPath.dbc::cost` after rejecting negative values. pub fare: u32, } -/// One `TaxiPathNode.dbc` row. `id` is the stable DBC key, while `(path_id, node_index)` is the -/// actual flight order. `flags` is the DBC's signed `int32` container, retained verbatim so all 32 -/// flag bits survive (including the sign bit); consumers that interpret bits must view its bit -/// pattern as `u32`. Delay is also an `int32` in the vanilla DBC contract, but negative delays are -/// rejected by the importer because elapsed time cannot be negative. [static] #[table( accessor = game_taxi_path_node, public, @@ -228,9 +149,6 @@ pub struct GameTaxiPathNode { pub delay_ms: i32, } -/// Restore the reserved catalogue and its map-0 flight master after the normal world ETL replaces -/// spatial fixture rows. The import script calls this only for a map-0 run, so restoring a test NPC -/// cannot contaminate a shard that owns another continent. #[reducer] pub fn restore_taxi_fixture(ctx: &ReducerContext) -> Result<(), String> { require_operator(ctx)?; @@ -238,12 +156,6 @@ pub fn restore_taxi_fixture(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -// =========================================================================================== -// Realm address [static] — the one writer for the row above -// =========================================================================================== - -/// The pure half of [`set_realm_address`]: `host:port`, trimmed, port in `1..=65535`. Blank is -/// refused rather than written — advertising nothing fails at realm select for every player at once. pub(crate) fn validate_realm_address(raw: &str) -> Result { let address = raw.trim(); if address.is_empty() { @@ -263,9 +175,6 @@ pub(crate) fn validate_realm_address(raw: &str) -> Result { } } -/// Set the address the realm list advertises (operator-only, like [`crate::gm::set_gm_level`]). -/// Operator-gated because this decides where every client opens its world connection — a -/// player-callable version would let any client redirect the realm. #[reducer] pub fn set_realm_address(ctx: &ReducerContext, address: String) -> Result<(), String> { require_operator(ctx)?; @@ -281,8 +190,6 @@ pub fn set_realm_address(ctx: &ReducerContext, address: String) -> Result<(), St Ok(()) } -/// Write the realm list row whole, creating it when absent. `lyracore dev up` calls this after -/// it claims the operator, so the advertised address follows the Gateway's client listener. #[reducer] pub fn provision_realm( ctx: &ReducerContext, diff --git a/module/src/corpse.rs b/module/src/corpse.rs index e4ad72b75..36b8c338c 100644 --- a/module/src/corpse.rs +++ b/module/src/corpse.rs @@ -1,95 +1,37 @@ -//! Player corpse object + reclaim. On Release Spirit the player becomes a ghost (see -//! `world::repop`) and a `game_corpse` row — the dead body — is left at the death location. The -//! gateway relays the corpse as a CORPSE-type CREATE_OBJECT (and DESTROY on delete); the ghost runs -//! back and `reclaim_corpse` resurrects at 50%. [entity]/[server] - use spacetimedb::{table, ReducerContext, Table, Timestamp}; use lyracore_shared::packing::unpack4; use crate::game_world_entity; -/// HIGHGUID_CORPSE high bits (0xF101): marks a guid as a corpse object for the 5875 client. The -/// corpse guid is `(HIGHGUID_CORPSE << 48) | owner_guid_low`, so one corpse per player at a time. pub const HIGHGUID_CORPSE: u64 = 0xF101; -/// The deterministic corpse guid for a player (one corpse per player). Shared by `repop` (spawn), -/// `reclaim_corpse` (the client sends this guid), and the relog/logout cleanup so they all agree. pub(crate) fn corpse_guid_for(owner_guid: u64) -> u64 { (HIGHGUID_CORPSE << 48) | (owner_guid & 0x0000_FFFF_FFFF_FFFF) } -/// Reclaim radius² — (39 yd)² (vanilla `CORPSE_RECLAIM_RADIUS`). CONFIRM the exact value. const RECLAIM_RADIUS_SQ: f32 = 1521.0; -// =========================================================================================== -// Reclaim-delay escalation — the behavioural spec this section implements -// =========================================================================================== -// -// Vanilla's corpse-reclaim penalty is publicly documented behaviour: dying repeatedly in a short -// span makes the ghost wait longer each time before it may take its body back. Restated as the -// contract the code below satisfies (it is written FROM this contract, not transcribed from any -// implementation of it): -// -// * A DEATH STREAK is tracked as a single durable deadline per character — while `now` is before -// it, the character is "still in the streak"; once it passes, the streak is over. -// * Each death banks one RUNG of streak credit (5 real-world minutes) onto that deadline, and -// reads its reclaim delay off the ladder at the rung now standing. -// * The ladder is 30s → 60s → 120s. Rung 1 is an ordinary, isolated death. -// * The streak saturates: once it is holding a full ladder's worth of credit, a further death -// re-pins the deadline instead of extending it, so a death spiral cannot bank an unbounded -// penalty (and the delay stays at the ladder's top rung). -// * A death after the deadline has lapsed — and a character's very first death, whose stored -// deadline is 0 — starts a fresh streak at rung 1, i.e. the plain 30s. -// -// The vocabulary below (streak, rung, banked credit, re-pin) is ours; only the observable numbers -// (30/60/120s, a 5-minute window, a 3-rung ladder) are the vanilla behaviour being matched. - -/// The reclaim-delay ladder, by streak rung: how long the ghost waits before it may reclaim. -/// Rung 1 (index 0) is an ordinary death; the last entry is the saturation ceiling. pub const CORPSE_RECLAIM_DELAY_SECS: [i64; 3] = [30, 60, 120]; -/// One rung of streak credit, in micros. Dying banks this much time onto the streak deadline, and -/// the streak is over once the deadline passes — so 5 quiet real-world minutes clear one rung. -const STREAK_RUNG_MICROS: i64 = 300 * 1_000_000; // 5 min +const STREAK_RUNG_MICROS: i64 = 300 * 1_000_000; -/// The most credit a streak may hold — derived from the ladder, so the two can never disagree: -/// once the streak is this deep, further deaths re-pin rather than extend it. const MAX_STREAK_RUNGS: i64 = CORPSE_RECLAIM_DELAY_SECS.len() as i64; -/// How long an unreclaimed corpse (still lootable-by-owner-in-spirit / a reclaim target) sits before -/// decaying to bones (cosmetic remains, no longer reclaimable). UNVERIFIED: vanilla's exact -/// corpse-decay tuning isn't pinned by any source reachable from here — this is OUR chosen value -/// (5 min), not a confirmed cmangos/vanilla constant. +// Corpse and bone lifetimes use chosen values; vanilla decay timing is unverified. pub(crate) const CORPSE_DECAY_MICROS: i64 = 300 * 1_000_000; -/// How long bones then linger before despawning entirely. Same caveat as `CORPSE_DECAY_MICROS`: -/// this is our chosen value (5 min), not a verified vanilla constant. pub(crate) const BONES_DECAY_MICROS: i64 = 300 * 1_000_000; -/// Bank this death onto the character's streak and read off the reclaim delay it earns — the whole -/// of the spec above, in three lines of accounting. -/// -/// Takes and returns the streak deadline stored on the player row -/// (`WorldEntity::death_expire_micros`; 0 for a character that has never died, which reads as -/// "lapsed" and so starts at rung 1). Returns `(streak_deadline_micros, delay_micros)`: the new -/// deadline to stamp back for the NEXT death to escalate from, and the delay stamped onto the -/// freshly-inserted `Corpse` row (`reclaim_delay_micros`), which the gateway reports to the client -/// via `SMSG_CORPSE_RECLAIM_DELAY`. pub(crate) fn escalated_reclaim(streak_deadline_micros: i64, now_micros: i64) -> (i64, i64) { let credit_remaining = streak_deadline_micros - now_micros; let deadline = if credit_remaining <= 0 { - // Lapsed (or never died): a fresh streak, one rung deep. now_micros + STREAK_RUNG_MICROS } else if credit_remaining / STREAK_RUNG_MICROS < MAX_STREAK_RUNGS { - // Still climbing: bank one more rung on top of what is already standing. streak_deadline_micros + STREAK_RUNG_MICROS } else { - // Saturated: re-pin a full ladder ahead rather than growing without bound. now_micros + MAX_STREAK_RUNGS * STREAK_RUNG_MICROS }; - // The rung the ladder is read at = the credit now standing, rounded UP (a part-spent rung still - // counts as that rung) and clipped to the ladder's ends. let rung = ((deadline - now_micros + STREAK_RUNG_MICROS - 1) / STREAK_RUNG_MICROS) .clamp(1, MAX_STREAK_RUNGS); ( @@ -98,20 +40,12 @@ pub(crate) fn escalated_reclaim(streak_deadline_micros: i64, now_micros: i64) -> ) } -/// Repack a player's appearance into the `CORPSE_FIELD_BYTES_1/2` layout. This layout is -/// DIFFERENT from `PLAYER_BYTES`: `BYTES_1 = 0 | race<<8 | gender<<16 | skin<<24`, -/// `BYTES_2 = face | hairstyle<<8 | haircolor<<16 | facialhair<<24`. race/gender come from -/// `unit_bytes_0` (bytes 0/2); skin/face/hair from `player_bytes`; facialhair from `player_bytes_2` -/// byte 0. Sending the raw `player_bytes` instead makes the 5875 client read `face` as the race → -/// null-deref the body model → crash (verified). Pure so it's unit-tested without a reducer. +// CORPSE_FIELD_BYTES_1 packs race<<8, gender<<16 and skin<<24, unlike PLAYER_BYTES. pub(crate) fn corpse_appearance_bytes( unit_bytes_0: u32, player_bytes: u32, player_bytes_2: u32, ) -> (u32, u32) { - // `unpack4` returns the four little-endian bytes (.0 = bits 0-7 ….3 = 24-31); take the same - // bytes the inline mask/shifts did. race/gender from `unit_bytes_0` bytes 0/2; skin/face/hair - // from `player_bytes` bytes 0-3; facialhair from `player_bytes_2` byte 0. let (race, _, gender, _) = unpack4(unit_bytes_0); let (skin, face, hairstyle, haircolor) = unpack4(player_bytes); let (facialhair, _, _, _) = unpack4(player_bytes_2); @@ -127,52 +61,31 @@ pub(crate) fn corpse_appearance_bytes( (bytes_1, bytes_2) } -/// The dead body left at a player's death location. A CORPSE-type world object: the gateway -/// relays its insert as CREATE_OBJECT and its delete as SMSG_DESTROY_OBJECT (like a creature). [entity] #[table(accessor = game_corpse, public)] pub struct Corpse { #[primary_key] - pub guid: u64, // HIGHGUID_CORPSE in the high bits + pub guid: u64, pub owner_guid: u64, pub map_id: u32, pub x: f32, pub y: f32, pub z: f32, pub orientation: f32, - pub display_id: u32, // the player's body model, so the corpse renders as them - pub bytes_1: u32, // CORPSE_FIELD_BYTES_1 (race/sex/skin) — from the player's player_bytes - pub bytes_2: u32, // CORPSE_FIELD_BYTES_2 (face/hair) — from player_bytes_2 - pub created_at: Timestamp, // for the reclaim delay + corpse decay-to-bones timer + pub display_id: u32, + pub bytes_1: u32, + pub bytes_2: u32, + pub created_at: Timestamp, - // The reclaim delay stamped by `escalated_reclaim` at insert time (repeated quick deaths climb - // the 30s/60s/120s ladder), superseding the old flat 30s comparison in `reclaim_corpse`. - // `#[default(30_000_000i64)]` (typed — an i64 column needs an explicitly-typed - // literal, same as the u64 fields elsewhere: a bare untyped literal encodes 4 bytes, publish - // needs 8) + end-appended so `publish` auto-migrates existing rows (the migration rule: - // column-add needs a default annotation AND end-append). #[default(30_000_000i64)] pub reclaim_delay_micros: i64, - // Body → bones state flip (the gc.rs reaper sets this once `CORPSE_DECAY_MICROS` elapses - // unreclaimed); bones are cosmetic remains — no longer a reclaim target — and reap on their own - // timer (`BONES_DECAY_MICROS` after the bones flip). `#[default(false)]` + end-appended so - // `publish` auto-migrates existing rows. #[default(false)] pub is_bones: bool, - // Which instance the death happened in: stamped from the dying - // player's own entity in `do_repop`; 0 = open world (every existing row auto-migrates to 0). - // Gates reclaim (below) and the gateway's corpse CREATE relay by viewer instance; the instance - // reap deletes any corpse left inside (the ghost's outcome is then spirit-healer-only — - // vanilla's expired-corpse rule). END-appended + `#[default(0u64)]` (danger-zones §2). - // After changing this subscribed row, run `./lyracore bindings` and update the - // `schema_parity.rs` field manifest. #[default(0u64)] pub instance_id: u64, } -/// The corpse-reclaim core, actor-explicit: everything [`reclaim_corpse`] does after -/// resolving WHOSE ghost is reclaiming. Takes the row because the reclaim mutates it in place. pub(crate) fn apply_reclaim_corpse( ctx: &ReducerContext, mut player: crate::WorldEntity, @@ -184,20 +97,12 @@ pub(crate) fn apply_reclaim_corpse( if !player.dead || player.player_flags & player_flags::GHOST == 0 { return Err("caller is not a ghost".to_string()); } - // Resolve the corpse from the CALLER, never the packet guid. In vanilla 1.12 the client fills - // CMSG_RECLAIM_CORPSE with its own PLAYER guid (bare low guid), not the 0xF101 corpse guid — so a - // `find(&packet_guid)` always misses and the reclaim silently fails (this bricked the death loop). - // There is exactly one corpse per player (`corpse_guid_for`), so derive it; `_corpse_guid` is ignored - // (kept as a self-sanity arg for wire-shape compatibility). This makes the owner check redundant. + // CMSG_RECLAIM_CORPSE carries the Character guid, so derive its corpse guid instead. let corpse_guid = corpse_guid_for(player.guid); let corpse = corpses .guid() .find(corpse_guid) .ok_or_else(|| "no such corpse".to_string())?; - // Map + instance gated (corpse rows carry `instance_id`): a ghost must - // corpse-run back into the SAME instance it died in (the areatrigger resolve re-binds it to - // that instance, so the run-back lands right); a ghost in another party's copy — or in the - // open world — can never reclaim through the wall. if corpse.map_id != player.map_id { return Err("corpse on another map".to_string()); } @@ -221,8 +126,6 @@ pub(crate) fn apply_reclaim_corpse( return Err("corpse reclaim delay not elapsed".to_string()); } - // Resurrect at 50% (vanilla corpse-reclaim percent). Clearing dead + the GHOST flags + restoring - // health replicates to the client, which leaves the ghost/death state (no "alive" opcode exists). player.health = (player.max_health / 2).max(1); let player_guid = player.guid; entities.guid().update(player); @@ -230,21 +133,6 @@ pub(crate) fn apply_reclaim_corpse( Ok(()) } -/// Player corpses: an unreclaimed body decays to bones (cosmetic remains, no longer a reclaim -/// target — `reclaim_corpse` above rejects `is_bones` rows) after `CORPSE_DECAY_MICROS`, then the -/// bones themselves despawn after `BONES_DECAY_MICROS` more. Keyed off the corpse's OWN -/// `created_at` — independent of the ghost's reclaim-escalation deadline -/// (`WorldEntity::death_expire_micros`), which only governs how long the NEXT death's delay is, not -/// this body's decay. The state flip is an in-place UPDATE (coords/appearance kept) so the -/// gateway's `on_update` relay can re-emit the CREATE with the bones flag; the despawn is a plain -/// delete (→ SMSG_DESTROY_OBJECT), same as reclaim. -/// -/// Called from `gc.rs`'s `reap_movement_events` tick — the same sibling-sweep pattern as -/// `spell::stacking::sweep_dr_state` / `loot::sweep_loot_rolls` (this pulled the inline block out of -/// `gc.rs`, which had no business knowing what bones are). -/// -/// Full scan is safe: the corpse table holds at most one row per RECENTLY-dead player (reclaim -/// deletes; this pass despawns the rest) — it stays tiny by construction. pub(crate) fn sweep_corpse_decay(ctx: &ReducerContext) { let t = ctx.db.game_corpse(); let now = ctx.timestamp.to_micros_since_unix_epoch(); @@ -283,16 +171,11 @@ mod tests { #[test] fn corpse_guid_puts_highguid_in_the_top_16_and_keeps_the_owner_low_48() { - // HIGHGUID_CORPSE lands in bits 48-63; the owner guid's low 48 bits survive verbatim, so - // the owner is recoverable from the corpse guid (and one owner → one deterministic corpse). assert_eq!(corpse_guid_for(42), 0xF101_0000_0000_002A); assert_eq!(corpse_guid_for(42) >> 48, HIGHGUID_CORPSE); assert_eq!(corpse_guid_for(42) & 0x0000_FFFF_FFFF_FFFF, 42); - // An owner guid carrying its own high bits (a player HIGHGUID) has them MASKED off — only - // the low 48 flow into the corpse guid, never a mangled top word. let owner = (0xABCD_u64 << 48) | 7; assert_eq!(corpse_guid_for(owner), 0xF101_0000_0000_0007); - // The full low-48 range is preserved (no accidental narrower mask). assert_eq!( corpse_guid_for(0x0000_FFFF_FFFF_FFFF), (HIGHGUID_CORPSE << 48) | 0x0000_FFFF_FFFF_FFFF @@ -300,22 +183,16 @@ mod tests { } #[test] - // `(0 << 16)` is the GENDER slot of the packed `unit_bytes_0` layout, spelled out so all four - // bytes of the fixture line up with the comment below it. Folding it away would hide the very - // slot this test exists to pin (the crash was race landing in the wrong byte). #[allow(clippy::identity_op)] fn corpse_bytes_put_race_gender_in_the_right_slots() { - // Human(race 1)/Male(gender 0): unit_bytes_0 = race | class<<8 | gender<<16 | power<<24. let unit_bytes_0 = 1 | (1 << 8) | (0 << 16) | (1 << 24); - let player_bytes = 5 | (6 << 8) | (7 << 16) | (8 << 24); // skin5 face6 hairstyle7 haircolor8 - let player_bytes_2 = 9; // facialhair9 + let player_bytes = 5 | (6 << 8) | (7 << 16) | (8 << 24); + let player_bytes_2 = 9; let (b1, b2) = corpse_appearance_bytes(unit_bytes_0, player_bytes, player_bytes_2); - // BYTES_1: byte0=0, byte1=race, byte2=gender, byte3=skin (the crash was race not landing here). assert_eq!(b1 & 0xFF, 0); assert_eq!((b1 >> 8) & 0xFF, 1, "byte1 must be race"); assert_eq!((b1 >> 16) & 0xFF, 0, "byte2 must be gender"); assert_eq!((b1 >> 24) & 0xFF, 5, "byte3 must be skin"); - // BYTES_2: face, hairstyle, haircolor, facialhair. assert_eq!(b2 & 0xFF, 6); assert_eq!((b2 >> 8) & 0xFF, 7); assert_eq!((b2 >> 16) & 0xFF, 8); @@ -324,21 +201,17 @@ mod tests { #[test] fn escalated_reclaim_climbs_the_ladder_then_caps_then_resets_past_expiry() { - let now: i64 = 1_800_000_000_000_000; // an arbitrary "real" epoch micros + let now: i64 = 1_800_000_000_000_000; let step = STREAK_RUNG_MICROS; - // First death ever: `death_expire_micros` starts at 0, so `now >= expire` — the reset - // branch — and the base 30s delay applies. let (expire1, delay1) = escalated_reclaim(0, now); assert_eq!(delay1, 30_000_000, "first death is the 30s base"); assert_eq!(expire1, now + step); - // Second death moments later, still well inside the first escalation window: steps up to 60s. - let now2 = now + 1_000_000; // 1s later + let now2 = now + 1_000_000; let (expire2, delay2) = escalated_reclaim(expire1, now2); assert_eq!(delay2, 60_000_000, "second quick death escalates to 60s"); - // Third death, again inside the (now much larger) window: steps up to the 120s cap. let now3 = now2 + 1_000_000; let (expire3, delay3) = escalated_reclaim(expire2, now3); assert_eq!( @@ -346,7 +219,6 @@ mod tests { "third quick death reaches the 120s cap" ); - // Fourth quick death: stays pinned at the cap, never exceeds it. let now4 = now3 + 1_000_000; let (_expire4, delay4) = escalated_reclaim(expire3, now4); assert_eq!( @@ -354,8 +226,6 @@ mod tests { "a fourth quick death stays capped at 120s, not beyond" ); - // A death after the escalation window has fully lapsed resets to the 30s base, regardless - // of how high the ladder had climbed. let now_later = expire3 + step + 1; let (expire_reset, delay_reset) = escalated_reclaim(expire3, now_later); assert_eq!( @@ -365,10 +235,6 @@ mod tests { assert_eq!(expire_reset, now_later + step); } - /// SATURATION + TOTALITY, across a long death spiral: every death's delay is a real ladder - /// value, never past the ceiling; the banked deadline always sits ahead of `now` and never runs - /// away (it is bounded by one rung past a full ladder, which is the re-pin invariant); and the - /// streak really does clear — a gap of more than the whole ladder is back to the 30s base. #[test] fn escalated_reclaim_saturates_and_never_leaves_the_ladder() { let step = STREAK_RUNG_MICROS; @@ -377,7 +243,7 @@ mod tests { let mut deadline = 0i64; let mut t: i64 = 1_800_000_000_000_000; for i in 0..500i64 { - t += (i * 37_000_000) % (step * 2); // death gaps from instant to two rungs + t += (i * 37_000_000) % (step * 2); let (next, delay) = escalated_reclaim(deadline, t); assert!( super::CORPSE_RECLAIM_DELAY_SECS.contains(&(delay / 1_000_000)), @@ -397,7 +263,6 @@ mod tests { ); deadline = next; } - // A quiet spell longer than the whole ladder clears the streak completely. let (_, after_a_long_quiet) = escalated_reclaim(deadline, deadline + 1); assert_eq!( after_a_long_quiet, 30_000_000, diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index fdaa883ae..5f602e83d 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -1,109 +1,53 @@ -//! Pure creature-AI geometry — flee / fear-flee / chase / return / wander step math — plus the AI -//! tuning constants they read. No `ReducerContext`, no tables: every fn here is a pure function of its -//! arguments, which is what makes them unit-testable (the tests live at the bottom of this file). The -//! `tick` pass pipeline calls these to compute each move leg. [pure] - -// =========================================================================================== -// Flee-at-low-health (vanilla creature AI) [pure helpers] -// =========================================================================================== - -/// HP percentage at/below which a meleeing creature breaks off and runs (vanilla mobs flee around -/// ~15% HP). Integer percentage avoids float threshold drift. const FLEE_HEALTH_PCT: u32 = 15; -/// Social aggro (vanilla pack behavior): same-faction creatures within this radius (yards) of a -/// creature that just aggroed will assist — join the fight against that creature's target, even ones -/// the player never got close enough to aggro directly. Measured from the AGGROER, not the player. const ASSIST_RADIUS: f32 = 10.0; -/// True iff `(ax,ay)` and `(bx,by)` are within `ASSIST_RADIUS` of each other (squared-distance -/// compare, `<=` so the boundary assists). The assist scan's geometry check, extracted pure so it's -/// unit-testable without a `ReducerContext`. Pure — unit-tested. pub(crate) fn within_assist_radius(ax: f32, ay: f32, bx: f32, by: f32) -> bool { let (dx, dy) = (ax - bx, ay - by); dx * dx + dy * dy <= ASSIST_RADIUS * ASSIST_RADIUS } -/// Is a unit alive (`health > 0`) AND below `pct`% of its max health? Integer cross-multiply -/// (`health * 100 < max_health * pct`) avoids float rounding and a divide. The products are widened -/// before multiplying so custom/load-test creatures near the `u32` health ceiling remain exact. -/// Shared by `should_flee` and the creature spell rotation's SELF_HP_BELOW_PCT condition (rank 20 — -/// "heal when low"). Pure — unit-tested. pub(crate) fn hp_pct_below(health: u32, max_health: u32, pct: u32) -> bool { health > 0 && (health as u64) * 100 < (max_health as u64) * (pct as u64) } -/// Should this creature flee? True only when it's alive AND below the flee threshold. Vanilla creature -/// AI: a mob in melee that drops to ~15% HP breaks off and runs. Pure — unit-tested. pub(crate) fn should_flee(health: u32, max_health: u32) -> bool { hp_pct_below(health, max_health, FLEE_HEALTH_PCT) } -/// Is this creature TYPE eligible to flee at low HP? Fleeing is SELECTIVE in vanilla, not universal: -/// HUMANOIDS rout to call for help, while BEASTS (wolves/boars), undead, elementals, etc. fight to the -/// death. The rout once fled EVERY near-dead creature, so a Northshire wolf (BEAST) ran away and -/// dropped combat ("not all enemies should flee"). This is the minimal heuristic gate (a per-template -/// `flags_extra` NO_FLEE flag from the DBC is the fuller fix). `creature_type` is the template column the -/// importer loads verbatim. Pure — unit-tested. [creatures] pub(crate) fn flee_eligible(creature_type: u8) -> bool { creature_type == crate::spell::HUMANOID_TYPE } -/// How long a rout runs (ms) before the creature turns and fights again. Seeded at cmangos' -/// `CreatureFamilyFleeDelay` default; a calibration knob for live observation. pub(crate) const ROUT_DURATION_MS: u32 = 10_000; -/// Is a rout still running, given the wall-clock ms its window closes at? `0` is the never-started -/// sentinel, so it is never open; a close time at or before `now_ms` means the rout is over AND spent -/// (the same field is what forbids a second rout). Pure — unit-tested. pub(crate) fn rout_window_open(now_ms: u32, rout_ends_ms: u32) -> bool { rout_ends_ms != 0 && now_ms < rout_ends_ms } -/// May a rout START now? Only for a flee-eligible creature (`eligible` = the HP threshold plus the -/// per-type gate) whose engagement has never stamped a window. A spent clock — open or long closed — -/// blocks every later rout in that engagement, which is what makes the rout once-per-engagement. -/// Pure — unit-tested. pub(crate) fn may_start_rout(eligible: bool, rout_ends_ms: u32) -> bool { eligible && rout_ends_ms == 0 } -/// The wall-clock ms at which a rout stamped at `now_ms` closes. Never 0: that value is the -/// never-started sentinel, so a window that wraps the u32 clock onto it would hand out a second rout. -/// Pure — unit-tested. +// Zero means never started; wrapping onto it would allow a second rout. pub(crate) fn rout_close_ms(now_ms: u32) -> u32 { now_ms.wrapping_add(ROUT_DURATION_MS).max(1) } -/// The wall-clock ms at which a leash refreshed at `now_ms` expires. Never 0: that is the -/// never-refreshed sentinel, so a deadline wrapping the u32 clock onto it would re-seed the row. -/// Pure — unit-tested. pub(crate) fn pursuit_deadline_ms(now_ms: u32) -> u32 { now_ms.wrapping_add(crate::combat::PURSUIT_WINDOW_MS).max(1) } -/// Is the target past the ABSOLUTE distance backstop from the creature? The one raw distance that still -/// ends a fight, so no engagement can persist across a zone. Pure — unit-tested. pub(crate) fn beyond_combat_backstop(dist_sq: f32) -> bool { dist_sq > crate::combat::COMBAT_BACKSTOP_SQ } -/// Should this engagement EVADE? Only on an expired pursuit deadline AND a target outside -/// `combat::LEASH_RADIUS_SQ` of the position remembered at the last damage exchange — distance alone no -/// longer ends a fight (short of [`beyond_combat_backstop`]). `0` is the never-refreshed sentinel and -/// never evades: an armed row must survive until the leash pass stamps its clock. Pure — unit-tested. pub(crate) fn should_evade(now_ms: u32, pursuit_ends_ms: u32, target_dist_sq: f32) -> bool { pursuit_ends_ms != 0 && now_ms >= pursuit_ends_ms && target_dist_sq > crate::combat::LEASH_RADIUS_SQ } -/// cmangos "wounded slowdown" — the mechanic that makes a fleeing (definitionally low-HP) mob -/// CATCHABLE. A creature under 30% HP moves slower: factor `1 − ((30 − min(hp%,30)) × 1.67)/100`, -/// floored ~0.5 (×1.0 at ≥30% HP, ×0.75 at 15%, ×0.67 at 10%, ~×0.5 near death). Applied to the flee -/// leg by the cycle's rout phase. Without it a fleer ran at the flat 7.0 yd/s player-run constant — exact parity, -/// so the chase gap never closed and the mob was uncatchable. -/// Pure — unit-tested. [creatures] pub(crate) fn wounded_slow_factor(health: u32, max_health: u32) -> f32 { if max_health == 0 { return 1.0; @@ -113,104 +57,35 @@ pub(crate) fn wounded_slow_factor(health: u32, max_health: u32) -> f32 { (1.0 - ((30.0 - capped) * 1.67) / 100.0).max(0.499) } -// =========================================================================================== -// Proximity aggro radius (vanilla level-scaled) [pure helper] -// =========================================================================================== - -/// Aggro radius (yards) at EQUAL level — vanilla's ~20-yard "notice you" distance. The level term -/// adjusts ±1 yd per level difference around this base. const BASE_AGGRO_RADIUS: f32 = 20.0; -/// A hostile creature always aggroes within this floor (yards) — until it's GREY, which zeroes it. const MIN_AGGRO_RADIUS: f32 = 5.0; -/// Cap so a much-higher-level creature has a large but bounded aggro radius. const MAX_AGGRO_RADIUS: f32 = 45.0; -/// "Grey" gap: when the player is this many levels (or more) ABOVE the creature, the mob is trivial and -/// does NOT initiate proximity aggro (a high-level player isn't swarmed walking through a low zone). const GREY_LEVEL_DIFF: i32 = 8; -/// Is this a position the world can actually hold? Guards the creature-movement writes the way -/// `movement_update` already guards the player one. -/// -/// A non-finite coordinate is not merely wrong, it is INVISIBLE: `grid_cell` casts `(MAP_COORD_MAX - -/// inf) / 50` to `i32::MIN`, so the entity lands in no active cell and the creature tick stops -/// visiting it entirely — it never leashes, never disengages, never returns home. But `tick_melee` -/// iterates MELEE ROWS, not cells, so it keeps swinging. The result is an attacker that cannot be -/// shaken off by any means the game offers: 109 such creatures were found live on 2026-07-29, most -/// of them locked onto one player, which is what made a GM undyingly mobbed. -/// -/// It also breaks diagnosis: infinity serialises as JSON `null`, so `spacetime sql` fails to decode -/// ANY query over the table with "invalid type: null, expected f32" — the entity table becomes -/// unreadable, exactly when you most need to read it. Pure — unit-tested. [pure] +// Infinite x or y saturates the grid index outside normal active cells. pub(crate) fn finite_point(x: f32, y: f32, z: f32) -> bool { x.is_finite() && y.is_finite() && z.is_finite() } -/// Is this player a proximity-aggro CANDIDATE at all, before any range maths? -/// -/// A dead player obviously is not. Neither is a GM in **godmode**: mangos makes mobs ignore a GM -/// outright, and the reason is exactly the failure this fixes. A godmoded GM never dies, so the -/// normal resolution of a pull — you die, they disengage and leash home — never happens. Creatures -/// therefore only ever ACCUMULATE on them: every mob that wanders inside the ~20 yd radius engages -/// and stays engaged forever. A GM standing in Northshire during a 300-bot load test collected -/// **103 simultaneous attackers**, which is every creature in the valley. -/// -/// Note what this is NOT: the radius is fine. It is `BASE_AGGRO_RADIUS + (creature - player) level`, -/// so a level-3 player draws level 1-6 fauna from 18-23 yd — vanilla-normal. Shrinking that would -/// break ordinary aggro everywhere and would not have stopped the pile-on, because the pile-on came -/// from accumulation over time, not from reach. Pure — unit-tested. [pure] pub(crate) fn is_aggro_candidate(dead: bool, godmode: bool) -> bool { !dead && !godmode } -/// Vanilla-style proximity-aggro radius (yards) for a creature noticing a player. An explicit -/// per-template override (`template_aggro_range > 0`, e.g. the tuned demo chicken's 8) WINS and is used -/// verbatim. Otherwise the radius scales with the level difference around `BASE_AGGRO_RADIUS`: a -/// higher-level creature notices you from farther, a lower-level one only up close, clamped to -/// `[MIN, MAX]`. A creature that is GREY to the player (player ≥ `GREY_LEVEL_DIFF` levels above it) does -/// NOT initiate aggro → returns 0. The aggro pass squares this per (creature, player) pair. Pure — -/// unit-tested. [pure] pub(crate) fn aggro_radius( creature_level: u32, player_level: u32, template_aggro_range: u32, ) -> f32 { if template_aggro_range > 0 { - return template_aggro_range as f32; // explicit hand-tuned override (e.g. seed chicken) + return template_aggro_range as f32; } let leveldiff = creature_level as i32 - player_level as i32; if -leveldiff >= GREY_LEVEL_DIFF { - return 0.0; // grey: the player out-levels the creature too far — no proximity aggro + return 0.0; } (BASE_AGGRO_RADIUS + leveldiff as f32).clamp(MIN_AGGRO_RADIUS, MAX_AGGRO_RADIUS) } -// =========================================================================================== -// Active-cell activation radius (grid-activation, only cells near players tick) -// [pure] -// =========================================================================================== - -/// The combat activation radius (yards) around a player within which a creature must be treated as -/// active for the aggro/assist passes this tick, rather than dormant. Two ceilings combine (the larger -/// wins), then the assist margin is added on top: -/// - `MAX_AGGRO_RADIUS` (45yd) — the vanilla level-scaled proximity-aggro ceiling that covers every -/// creature relying on the DEFAULT scaling (every imported cmangos creature: the ETL never sets -/// `aggro_range`, see importer/src/main.rs — `aggro_radius` above falls through to the scaled -/// formula, capped at this constant). -/// - `template_aggro_max` — the largest EXPLICIT per-template `aggro_range` override currently -/// loaded. `aggro_radius()` returns an override VERBATIM, UNCLAMPED, when > 0, so a future seed or -/// import setting one past 45 must still be covered. The caller (`tick::active_cell_radius`) feeds -/// this from a live scan of `game_creature_template` — a small reference table (tens of rows), not -/// the ~2500-creature live population this item exists to stop scanning — so the ceiling can never -/// go silently stale the way a hardcoded constant could. -/// -/// `ASSIST_RADIUS` (10yd) is added on top of whichever ceiling wins: the pack-assist scan measures -/// distance from the AGGROER, not the player, and the aggroer can itself sit up to the combined aggro -/// ceiling away from the player — without this margin a same-faction neighbor at the far edge of -/// `ASSIST_RADIUS` from an edge-case aggroer would be wrongly excluded from the active set. Pure — -/// unit-tested. [pure] -/// `const fn` so `CHASE_LEASH_SQ` can be defined from it — a hand-copied 55 would be exactly the kind of -/// silent staleness the live template scan above exists to prevent. Spelled as a comparison rather than -/// `f32::max` (not const-callable); the NaN branch agrees with `max`'s. pub(crate) const fn combat_active_radius(template_aggro_max: f32) -> f32 { let ceiling = if template_aggro_max > MAX_AGGRO_RADIUS { template_aggro_max @@ -220,82 +95,37 @@ pub(crate) const fn combat_active_radius(template_aggro_max: f32) -> f32 { ceiling + ASSIST_RADIUS } -/// The SMALLEST integer `game_creature_template.aggro_range` override that could make -/// `combat_active_radius` exceed `visibility_floor` — i.e. the only overrides that can change -/// `tick::active_cell_radius`'s answer at all. `combat_active_radius(t) > floor` ⟺ -/// `max(MAX_AGGRO_RADIUS, t) + ASSIST_RADIUS > floor` ⟺ `t > floor - ASSIST_RADIUS` (the -/// `MAX_AGGRO_RADIUS` branch is already covered whenever the floor dominates it, which it does at -/// today's 100yd vs 55yd). Perf catalog 1.19 uses this as the lower bound of a `by_aggro_range` index -/// probe, so the common "no override anywhere near the floor" case costs one probe instead of a fold -/// over every template. Pure — unit-tested. [pure] pub(crate) fn aggro_override_cutoff(visibility_floor: f32) -> u32 { let cutoff = visibility_floor - ASSIST_RADIUS; if cutoff < 0.0 { - return 0; // every override matters — probe from the bottom (degenerate, floor below the margin) + return 0; } - // `t > cutoff` over the integers is `t >= floor(cutoff) + 1`, for an integral cutoff too. cutoff.floor() as u32 + 1 } -// =========================================================================================== -// Stealth detection radius (graded, level-scaled detect range vs. a stealthed target) [pure] -// =========================================================================================== - -/// Base distance (yards) at which a creature sees through stealth at an equal-or-lower target level. const STEALTH_DETECT_BASE: f32 = 5.0; -/// Extra detect range (yards) per creature level ABOVE the stealthed target's level. const STEALTH_DETECT_PER_LEVEL: f32 = 1.0; -/// Cap on stealth-detection range (yards) — a much-higher-level mob still can't see a sneak from afar. const STEALTH_DETECT_MAX: f32 = 30.0; -/// Range (yards) within which a creature of `creature_level` detects a STEALTHED target of -/// `target_level`. A same/higher-level creature detects from a small base radius that GROWS with its -/// level advantage; a LOWER-level creature gets only the base (the per-level term floors at 0 — never -/// less than base, so even a low mob stepped on by a stealthed rogue notices it). Clamped to a sane max. -/// The aggro pass squares this and skips a stealthed candidate whose squared distance exceeds it — so a -/// stealthed target outside the detect range stays invisible to that creature. -/// Because the base (5) equals `MIN_AGGRO_RADIUS`, this only ever NARROWS the aggro radius for a -/// stealthed candidate, never widens it. Pure — unit-tested. [pure] pub(crate) fn stealth_detect_range(creature_level: u32, target_level: u32) -> f32 { let advantage = (creature_level as i32 - target_level as i32).max(0) as f32; (STEALTH_DETECT_BASE + advantage * STEALTH_DETECT_PER_LEVEL).min(STEALTH_DETECT_MAX) } -/// A point `dist` yards from the creature `(cx,cy)` directly AWAY from the target `(tx,ty)` — the -/// destination of a flee leg. Steps along the unit vector pointing from the target to the creature. -/// Degenerate case (creature and target share a position, `dx==dy==0`): there's no defined "away" -/// direction, so pick a fixed one (+x) rather than dividing by a zero length and producing NaN. Pure -/// — unit-tested. pub(crate) fn flee_step(cx: f32, cy: f32, tx: f32, ty: f32, dist: f32) -> (f32, f32) { let (dx, dy) = (cx - tx, cy - ty); let len = (dx * dx + dy * dy).sqrt(); if len == 0.0 { - // Coincident: arbitrary fixed bearing (+x) so the result is always finite. return (cx + dist, cy); } (cx + dx / len * dist, cy + dy / len * dist) } -// =========================================================================================== -// Fear flee (crowd control: a FEARED creature is force-walked away from the fear source) [pure] -// =========================================================================================== - -/// Per-tick FEAR flee distance (yards) — how far a feared creature dashes away from its fear source each -/// `tick_creatures`. A panicked run leg (RUN speed sets the leg's animation duration); paired with -/// `feared_flee_step`'s angular jitter it zig-zags rather than tracing a beeline. pub(crate) const FEAR_STEP: f32 = 15.0; -/// Max angular JITTER (radians) added to a fear flee leg's away-bearing — ±60° (`FRAC_PI_3`). Kept under -/// 90° so the away-component (`cos(jitter)`) is always POSITIVE: a feared creature always nets AWAY from -/// the source, never doubling back into it, while still zig-zagging enough to read as terror. +// Keep jitter below 90 degrees so every fear step moves away from the source. const FEAR_JITTER: f32 = std::f32::consts::FRAC_PI_3; -/// A FEAR flee leg — a point `dist` yards from the feared creature `(cx,cy)`, generally AWAY from the fear -/// source `(sx,sy)` but rotated by a random angular jitter in `[-FEAR_JITTER, +FEAR_JITTER]` so -/// successive legs zig-zag ("flees in terror"). `rand` (a full-span `u32` roll, e.g. `ctx.random()`) maps -/// linearly onto the jitter offset. Degenerate coincident case (source on top of the creature, -/// `dx==dy==0`): there is no defined "away" bearing, so fall back to +x (0 rad) before applying jitter, -/// so the result is always finite (never NaN) and still moves. Pure — unit-tested. pub(crate) fn feared_flee_step( cx: f32, cy: f32, @@ -317,37 +147,14 @@ pub(crate) fn feared_flee_step( pub(crate) const CHASE_MELEE_SQ: f32 = crate::combat::MELEE_RANGE_SQ; -/// Squared chase cutoff — a target beyond this is too far to pursue. An engaged creature chases while its -/// engagement is live, bounded by the active-cell radius (past it its own cell may be dormant); the -/// pursuit timer, not this, ends the fight. Derived from `combat_active_radius(0.0)` so the two cannot -/// drift, replacing the old lockstep with the evade constant that kept a mob from standing neither -/// chasing nor evading. pub(crate) const CHASE_LEASH_SQ: f32 = combat_active_radius(0.0) * combat_active_radius(0.0); -/// SPLINE chase LEAD (yd): a chaser aims this far PAST a TRANSLATING target along the chase line, so the -/// leg is long and the mob RIDES it for several ticks instead of re-throwing a tiny leg every 500ms tick -/// (which made the client visibly re-compute its heading each tick — the jitter). A straight-running -/// kiter keeps the mob on one committed heading. The lead is only ever thrown at a victim whose live -/// translation flags say it is running, and melee reach is read first, so a target standing inside reach -/// is turned to rather than led past. pub(crate) const CHASE_LEAD_YD: f32 = 8.0; -/// SPLINE chase RE-AIM threshold (cosine): while chasing, re-emit a new leg only when the target has -/// VEERED off the current leg's heading by more than this angle (~26° at 0.9) — a target running roughly -/// straight holds one heading, so the mob rides one leg without a per-tick re-aim. Lower = re-aim sooner -/// on turns; higher = hold the heading longer. pub(crate) const CHASE_REPATH_COS: f32 = 0.9; -/// SPLINE flee LEG length (yd): a fleeing mob COMMITS to one leg this far directly away from the threat -/// and re-rolls only once it finishes — instead of re-picking an away-direction every 500ms tick (which -/// snap-rotated the client's facing = the "flee spin"). Vanilla flee legs sit in a 28-38yd -/// quiet band; a single committed leg gives one stable travel tangent (one gentle turn per leg). pub(crate) const FLEE_LEG_YD: f32 = 28.0; -/// SPLINE interpolation parameter `t` (0..=1): the clamped fraction of a leg elapsed at `now_us`, given -/// the leg's `start_micros` and `dur_ms`. A 0-duration leg is complete (t=1, a snap). Computed ABSOLUTELY -/// from `now_us − start_micros` each tick — never accumulated — so any number of skipped/dormant ticks -/// never drifts the position. The caller lerps `start + (dest − start) × t`. Pure — unit-tested. pub(crate) fn spline_t(now_us: u64, start_micros: u64, dur_ms: u32) -> f32 { let dur_us = (dur_ms as u64) * 1000; if dur_us == 0 { @@ -356,13 +163,6 @@ pub(crate) fn spline_t(now_us: u64, start_micros: u64, dur_ms: u32) -> f32 { (now_us.saturating_sub(start_micros) as f32 / dur_us as f32).clamp(0.0, 1.0) } -/// A point stepping from the creature `(cx,cy)` toward the target `(tx,ty)`, moving at most `max_step` -/// yards but stopping `stop_dist` yards SHORT of the target — so a chase leg ends just inside melee -/// range, never on top of or past the target (which would overshoot, look wrong, and leave the -/// creature unable to swing). If already within `stop_dist` there's nothing to close, so return -/// `(cx,cy)` (no move). Degenerate coincident case (`dx==dy==0`, distance 0 ≤ any `stop_dist`) is -/// caught by the within-`stop_dist` early return, so we never divide by a zero length. Pure — -/// unit-tested. pub(crate) fn chase_step( cx: f32, cy: f32, @@ -373,47 +173,19 @@ pub(crate) fn chase_step( ) -> (f32, f32) { let (dx, dy) = (tx - cx, ty - cy); let len = (dx * dx + dy * dy).sqrt(); - // Already inside the stop band (covers the coincident len==0 case) → hold position. if len <= stop_dist { return (cx, cy); } - // Distance to actually travel: close the gap to `stop_dist` short, but never more than one - // tick's worth of run (`max_step`). let travel = (len - stop_dist).min(max_step); (cx + dx / len * travel, cy + dy / len * travel) } -// =========================================================================================== -// Return-to-spawn (vanilla creature AI: a displaced, non-engaged creature walks home) [const] -// =========================================================================================== - -/// Squared return LEASH — a creature within this of its spawn `(x,y)` is considered home-ENOUGH and -/// makes no return move; only one displaced FARTHER than this walks back. (15 yd)²; deliberately -/// roomy so the idle WANDER pass (which hops within `WANDER_RADIUS` = 6 yd of home, always inside -/// this leash) never trips return — the two are mutually exclusive (a creature inside the leash -/// wanders; one outside it returns). Pairs with the chase pass: pull → chase → evade → (here) walk -/// home. pub(crate) const RETURN_LEASH_SQ: f32 = 225.0; -// =========================================================================================== -// Idle wander (vanilla creature AI: an idle, home creature hops to nearby points) [const + pure] -// =========================================================================================== - -/// Radius (yards) of the wander circle around a creature's spawn HOME — an idle creature hops to a -/// random point within this of home. 6 yd, comfortably inside `RETURN_LEASH_SQ` ((15 yd)²) so a -/// wandering creature is ALWAYS home-enough and the return pass never fights it. pub(crate) const WANDER_RADIUS: f32 = 6.0; -/// Per-tick chance (percent) that an eligible idle creature picks a new wander hop. ~1/3 of ticks it -/// moves; the other ~2/3 it pauses where it is, so wander reads as idle loitering, not a constant -/// jog. Integer percent compared against `ctx.random::() % 100`. pub(crate) const WANDER_CHANCE_PCT: u32 = 35; -/// A random destination within `radius` yards of home `(home_x, home_y)` from two random `u32` rolls: -/// `rand_a` maps to an angle in `[0, 2π)`, `rand_b` to a radius in `[0, radius]`. Mapping both rolls -/// through the full `u32` span keeps the math float-only (no modulo bias to reason about) and the -/// result is provably within `radius` of home (the radius scalar is clamped to `[0, radius]`). Pure — -/// unit-tested. pub(crate) fn wander_point( home_x: f32, home_y: f32, @@ -426,39 +198,14 @@ pub(crate) fn wander_point( (home_x + r * angle.cos(), home_y + r * angle.sin()) } -// =========================================================================================== -// Movement tick cadence + the pure tick/segment helpers (mangos-parity smooth movement) [const + pure] -// =========================================================================================== - -/// The creature movement tick interval in SECONDS. Movement legs (chase/return step, patrol/wander -/// segment durations) are sized to one tick so successive legs chain seamlessly — the client finishes -/// interpolating one spline just as the next arrives. 0.5s ≈ mangos's chase recheck cadence. pub(crate) const MOVE_TICK_SECS: f32 = 0.5; -/// The same interval in MICROSECONDS — the `game_creature_move_schedule` Interval AND the sense-gate -/// divisor must use this so they stay in lockstep with `MOVE_TICK_SECS` (0.5s = 500_000us). pub(crate) const MOVE_TICK_MICROS: i64 = 500_000; -/// Aggro, casts, threat, regen and wandering keep a four-second sense cadence. -/// Movement and due-time population maintenance run every half-second catch-all firing. pub(crate) const SENSE_EVERY_N_TICKS: i64 = 8; -/// The target SENSE period in micros (~4s) — `MOVE_TICK_MICROS × SENSE_EVERY_N_TICKS`. A tick row of -/// ANY cadence quantizes its sensing passes to roughly this period (see -/// `is_sense_tick_for_interval`), so a tight per-instance tick smooths MOVEMENT -/// latency without also multiplying the expensive sensing scans or the tick-quantized effect rates -/// (wander hop chance, aggro re-checks) that assume the ~4s cadence. pub(crate) const SENSE_PERIOD_MICROS: i64 = MOVE_TICK_MICROS * SENSE_EVERY_N_TICKS; -/// Is this tick a "sensing" tick (run the ~4s passes)? Generalized to a tick row of ANY interval -/// (per-instance tick rows carry their own `ScheduleAt::Interval`): one firing in -/// every `SENSE_PERIOD_MICROS / interval` firings is a sense tick, so the sensing cadence stays ~4s no -/// matter how fast the row fires. At the seeded default (`interval == MOVE_TICK_MICROS`) the divisor -/// is exactly `SENSE_EVERY_N_TICKS` (one sense tick in 8). An interval LONGER than the sense period -/// makes every firing a sense tick (divisor floors at 1); a non-positive interval (a `ScheduleAt::Time` -/// row, or garbage) is clamped to the default so the math never divides by zero. Pure fn of the -/// scheduler timestamp in micros, so it needs no stored counter and survives across reducer -/// invocations. Pure — unit-tested. pub(crate) fn is_sense_tick_for_interval(now_micros: i64, interval_micros: i64) -> bool { let interval = if interval_micros > 0 { interval_micros @@ -478,10 +225,6 @@ pub(crate) fn tick_secs_for_interval(interval_micros: i64) -> f32 { interval as f32 / 1_000_000.0 } -/// The fear-flee dash distance for one firing of a row ticking every `tick_secs` — `FEAR_STEP` was -/// authored as "per 0.5s firing", so a faster row must scale it down proportionally or a feared -/// creature would dash `FEAR_STEP` yards PER FIRING (5× terror-speed at 100ms). At the default tick -/// this is exactly `FEAR_STEP` (ratio 1.0). Pure — unit-tested. pub(crate) fn fear_step_for_tick(tick_secs: f32) -> f32 { FEAR_STEP * (tick_secs / MOVE_TICK_SECS) } @@ -496,39 +239,16 @@ pub(crate) fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { (every_n * interval) as f32 / 1_000_000.0 } -// =========================================================================================== -// Per-instance tick scoping [pure] -// =========================================================================================== -// - pub(crate) const GLOBAL_TICK_INSTANCE: u64 = u64::MAX; -/// Which instances one FIRING of `tick_creatures` covers. Built per firing from the -/// firing row's `instance_id` + the full (tiny) schedule-row set: -/// - a DEDICATED row (`instance_id != GLOBAL_TICK_INSTANCE`) covers EXACTLY its instance; -/// - the CATCH-ALL row (`instance_id == GLOBAL_TICK_INSTANCE`) covers every instance EXCEPT those -/// with a dedicated row (so two rows never both tick one instance — coverage is a PARTITION, -/// proven by `tick_scope_partitions_every_instance_exactly_once`). -/// -/// ANTI-STRANDING GUARANTEE: an entity can sit in an instance with NO `game_instance` row and NO -/// dedicated tick row (`teleport_player` accepts an arbitrary `instance_id` today) — the -/// catch-all's "everything not dedicated" rule means such an entity is ALWAYS covered; a -/// strictly-enumerated instance list would freeze it forever. pub(crate) enum TickScope { - /// The catch-all row: covers every instance except the `dedicated` set (instances with rows of - /// their own). CatchAll { dedicated: std::collections::HashSet, }, - /// A dedicated row: covers exactly this instance. Only(u64), } impl TickScope { - /// Resolve the scope for the firing row with `row_instance_id`, given every schedule row's - /// `instance_id` (the schedule table is a handful of rows — one catch-all + one per dedicated - /// instance — so this is O(rows), NOT a creature/entity scan; the "covered-instance set" is a - /// PREDICATE over this tiny set, never a materialized enumeration of live instances). pub(crate) fn from_rows( row_instance_id: u64, all_row_instance_ids: impl IntoIterator, @@ -545,9 +265,6 @@ impl TickScope { } } - /// Does THIS firing cover `instance_id`? The one predicate every scoped pass gates on. With only - /// the seeded catch-all row (no dedicated rows) this is `true` for EVERY instance — the - /// equivalence case: the pass visits the identical candidate set it did before. pub(crate) fn covers(&self, instance_id: u64) -> bool { match self { TickScope::CatchAll { dedicated } => !dedicated.contains(&instance_id), @@ -555,38 +272,20 @@ impl TickScope { } } - /// Do the GLOBAL, due-time passes (decay/respawn/GO-respawn/regen/combat-drop/package ticks) run - /// on this firing? Catch-all only: those passes are due-TIME bookkeeping over tables that aren't - /// instance-partitioned (spawn timers, regen, players' combat flags) and they quantize their - /// effects per RUN (regen amount per sense tick) — running them from a second, faster row would - /// MULTIPLY their effects (double regen, double decay scans), the exact work-multiplication this - /// item forbids. They cover ALL instances (including dedicated-row ones) from the catch-all row, - /// at the unchanged global cadence — so a dedicated row is a movement/AI smoothing knob only, and - /// deleting it can never strand an instance's timers. COROLLARY: the catch-all row is - /// load-bearing — never delete it (`debug_repair_after_publish` restores it). pub(crate) fn runs_global_passes(&self) -> bool { matches!(self, TickScope::CatchAll { .. }) } } -/// Is a creature's in-flight move leg still playing? Its authoritative position is set to the leg's -/// destination at leg-start, while the client animates to `leg_ends_ms`; a SEGMENT pass (patrol/wander) -/// must not emit a new leg until the current one lands. `leg_ends_ms == 0` (fresh/respawned) is never in -/// flight. Pure — unit-tested. pub(crate) fn leg_in_flight(now_ms: u32, leg_ends_ms: u32) -> bool { now_ms < leg_ends_ms } -/// `to` is `speed` yards away from `from` in this many milliseconds. `speed` is yd/s, already -/// snare/wound-adjusted by the caller if its mover applies one. Pure. fn leg_duration_ms(from: (f32, f32), to: (f32, f32), speed: f32) -> u32 { let (dx, dy) = (to.0 - from.0, to.1 - from.1); ((dx * dx + dy * dy).sqrt() / speed * 1000.0) as u32 } -/// The tail every stepped mover shares once navigation has resolved the real landing point: drop a -/// degenerate zero-length step (the client rejects it), else give the landing point and the leg's -/// duration at `speed` yd/s. Pure. pub(crate) fn leg_toward(from: (f32, f32), to: (f32, f32), speed: f32) -> Option<(f32, f32, u32)> { if to == from { return None; @@ -594,8 +293,6 @@ pub(crate) fn leg_toward(from: (f32, f32), to: (f32, f32), speed: f32) -> Option Some((to.0, to.1, leg_duration_ms(from, to, speed))) } -/// The index AFTER `cur_idx` in a route of `len` waypoints, wrapping at the end (ordered patrol -/// traversal: 0→1→…→len-1→0). `len == 0` → 0 (caller guards `len >= 2`). Pure — unit-tested. pub(crate) fn next_waypoint_idx(cur_idx: usize, len: usize) -> usize { if len == 0 { 0 @@ -604,8 +301,6 @@ pub(crate) fn next_waypoint_idx(cur_idx: usize, len: usize) -> usize { } } -/// Index of the waypoint nearest `(cx, cy)` — the first patrol target when there's no live cursor -/// (fresh spawn / stale `wp_target`). Squared-distance compare, no sqrt. Empty → 0. Pure — unit-tested. pub(crate) fn nearest_waypoint_idx(cx: f32, cy: f32, pts: &[(f32, f32)]) -> usize { pts.iter() .enumerate() @@ -622,8 +317,7 @@ pub(crate) fn nearest_waypoint_idx(cx: f32, cy: f32, pts: &[(f32, f32)]) -> usiz mod tests { use super::*; use lyracore_shared::constants; - // The stat-roll tests live here too (lifted with the geometry tests); their subjects are in the - // sibling `spawn` module, reached via the parent `creatures` re-export. + use crate::creatures::{ rank_hp_multiplier_permille, rolled_creature_stats, scale_creature_damage_for_level, scale_health_for_rank, @@ -641,22 +335,17 @@ mod tests { #[test] fn hp_pct_below_threshold_and_flee_delegation() { - // Below / at / above the threshold (cross-multiply, no float). - assert!(hp_pct_below(49, 100, 50)); // 49% < 50% - assert!(!hp_pct_below(50, 100, 50)); // exactly 50% is NOT below + assert!(hp_pct_below(49, 100, 50)); + assert!(!hp_pct_below(50, 100, 50)); assert!(!hp_pct_below(80, 100, 50)); - // A dead unit (0 HP) is never "below" (the alive guard) — a corpse doesn't heal-cast. assert!(!hp_pct_below(0, 100, 50)); - // should_flee is hp_pct_below at the 15% flee threshold. - assert!(should_flee(14, 100)); // 14% < 15% + assert!(should_flee(14, 100)); assert!(!should_flee(15, 100)); - assert!(!should_flee(0, 100)); // dead → no flee + assert!(!should_flee(0, 100)); } #[test] fn hp_pct_below_widens_products_past_u32_overflow_boundary() { - // `health * 100` still fits in u32 at this value, then overflows one HP later. Full-health - // creatures on either side of that boundary are at 100%, never below the flee threshold. const LAST_SAFE_TIMES_100: u32 = u32::MAX / 100; assert!(!hp_pct_below( LAST_SAFE_TIMES_100, @@ -669,51 +358,30 @@ mod tests { FLEE_HEALTH_PCT, )); - // Both products exceed u32 here; widening must preserve full-health and genuinely-low-health - // answers all the way through the public input range. assert!(!hp_pct_below(u32::MAX, u32::MAX, FLEE_HEALTH_PCT)); assert!(hp_pct_below(u32::MAX / 10, u32::MAX, FLEE_HEALTH_PCT)); } #[test] fn aggro_radius_scales_with_level_and_greys_out() { - // Equal level → the ~20-yard base. assert_eq!(aggro_radius(10, 10, 0), BASE_AGGRO_RADIUS); - // A higher-level creature notices you from farther (±1 yd/level), capped at MAX. - assert_eq!(aggro_radius(13, 10, 0), 23.0); // +3 levels → 23 yd - assert_eq!(aggro_radius(99, 10, 0), MAX_AGGRO_RADIUS); // huge gap → clamped to MAX - // A lower-level creature only aggroes up close (−1 yd/level) until it greys out. - assert_eq!(aggro_radius(8, 14, 0), 14.0); // −6 levels → 14 yd - assert_eq!(aggro_radius(8, 15, 0), 13.0); // −7 levels (just under grey) → 13 yd - // GREY: player GREY_LEVEL_DIFF+ levels above the creature → no proximity aggro at all. - assert_eq!(aggro_radius(1, 1 + GREY_LEVEL_DIFF as u32, 0), 0.0); // exactly at the grey gap - assert_eq!(aggro_radius(1, 60, 0), 0.0); // a L60 isn't swarmed by L1 mobs - // An explicit template override WINS verbatim (the demo chicken's tuned 8), ignoring level scaling. - assert_eq!(aggro_radius(1, 60, 8), 8.0); // override beats the grey rule + assert_eq!(aggro_radius(13, 10, 0), 23.0); + assert_eq!(aggro_radius(99, 10, 0), MAX_AGGRO_RADIUS); + assert_eq!(aggro_radius(8, 14, 0), 14.0); + assert_eq!(aggro_radius(8, 15, 0), 13.0); + assert_eq!(aggro_radius(1, 1 + GREY_LEVEL_DIFF as u32, 0), 0.0); + assert_eq!(aggro_radius(1, 60, 0), 0.0); + assert_eq!(aggro_radius(1, 60, 8), 8.0); assert_eq!(aggro_radius(10, 10, 12), 12.0); } - // ========================================================================================= - // Active-cell activation math - // ========================================================================================= - #[test] fn combat_active_radius_covers_the_vanilla_ceiling_and_scales_with_template_overrides() { - // No template override in play (every imported creature: aggro_range == 0) → the vanilla - // MAX_AGGRO_RADIUS ceiling (45yd) plus the assist margin (10yd) = 55yd. assert_eq!(combat_active_radius(0.0), MAX_AGGRO_RADIUS + ASSIST_RADIUS); - // A template override BELOW the vanilla ceiling never NARROWS the radius (the ceiling still wins). assert_eq!(combat_active_radius(8.0), MAX_AGGRO_RADIUS + ASSIST_RADIUS); - // A template override ABOVE the vanilla ceiling (a future seed/import) WIDENS the radius — - // the live scan, not a hardcoded constant, is what keeps this honest. assert_eq!(combat_active_radius(100.0), 100.0 + ASSIST_RADIUS); } - /// Perf catalog 1.19 — the index-probe shortcut in `tick::active_cell_radius` is only safe if the - /// cutoff is EXACTLY the set of overrides that could beat the visibility floor. Sweep every integer - /// override around the boundary and assert the cutoff agrees with the real `combat_active_radius` - /// comparison: below the cutoff the floor provably wins (so skipping the fold is byte-identical), - /// at/above it the fold must run. #[test] fn aggro_override_cutoff_is_exactly_the_overrides_that_can_beat_the_visibility_floor() { let floor = lyracore_shared::spatial::BOX_HALF_SPAN as f32 @@ -731,16 +399,9 @@ mod tests { "override {t}: cutoff must agree with the real radius comparison" ); } - // A degenerate floor below the assist margin means every override is in play. assert_eq!(aggro_override_cutoff(ASSIST_RADIUS - 1.0), 0); } - /// - /// Distance no longer ends a fight, so the cutoff is no longer in lockstep with an evade constant: it - /// IS the active-cell radius, which is what keeps an engaged creature pursuing while its engagement - /// lives instead of freezing between a chase cutoff and an evade cutoff. Equality is inside, not on - /// the edge: `spatial::covering_cell_box` includes every point within the radius, and the - /// live radius takes the larger of this and the 100yd visibility floor. #[test] fn chase_leash_radius_stays_within_the_combat_active_radius() { assert_eq!( @@ -750,28 +411,15 @@ mod tests { ); } - /// The "mid-leg flee/return either finishes or freezes coherently" requirement: this - /// repo picks FREEZE (dormancy simply skips the pass, touching nothing). That choice is only - /// coherent because `chase_step` (the shared return/chase step primitive) is a pure function of the - /// CURRENT position and target — no elapsed-time or step-count argument — so pausing for any number - /// of ticks and resuming produces exactly the same trajectory as if it had been ticked continuously - /// to the same intermediate position. If `chase_step` ever gained hidden state this test would catch - /// the divergence. #[test] fn chase_step_is_position_only_so_freezing_between_calls_is_lossless() { let (home_x, home_y) = (100.0, 100.0); - let start = (130.0, 100.0); // 30yd out - // Two ticks, uninterrupted. + let start = (130.0, 100.0); let mut cont = start; for _ in 0..2 { cont = chase_step(cont.0, cont.1, home_x, home_y, 5.0, 0.0); } - // Two ticks computed identically, standing in for "then dormant for N ticks" — since dormancy - // means the pass isn't called at all, the position after re-entry is BY CONSTRUCTION identical - // to `cont` (nothing mutated it while dormant); the real claim under test is the next line. let frozen = cont; - // Resuming from the frozen position yields the SAME next step as continuing uninterrupted would - // have — chase_step reads only the position it's given, never a hidden tick counter. let next_after_resume = chase_step(frozen.0, frozen.1, home_x, home_y, 5.0, 0.0); let next_if_uninterrupted = chase_step(cont.0, cont.1, home_x, home_y, 5.0, 0.0); assert_eq!(next_after_resume, next_if_uninterrupted); @@ -780,13 +428,12 @@ mod tests { #[test] fn a_creature_displaced_far_from_home_steps_all_the_way_back() { let (home_x, home_y) = (-100.0_f32, 250.0_f32); - let step = constants::speeds::RUN * MOVE_TICK_SECS; // one movement tick of run - let start = 300.0_f32; // far past anything the old spawn tether could leave behind + let step = constants::speeds::RUN * MOVE_TICK_SECS; + let start = 300.0_f32; let dist_sq = |p: (f32, f32)| (p.0 - home_x).powi(2) + (p.1 - home_y).powi(2); let mut pos = (home_x + start, home_y); let mut prev_sq = dist_sq(pos); let mut ticks = 0u32; - // The loop condition IS the return pass's gate: while it holds, wander skips the guid. while dist_sq(pos) > RETURN_LEASH_SQ { let next = chase_step(pos.0, pos.1, home_x, home_y, step, 0.0); assert_ne!(next, pos, "return stalled after {ticks} ticks"); @@ -797,7 +444,6 @@ mod tests { ticks += 1; assert!(ticks < 10_000, "no arrival from {start} yd out"); } - // Derived, not hand-counted, so tuning any of the three constants can't fail this on arithmetic. assert_eq!( ticks, ((start - RETURN_LEASH_SQ.sqrt()) / step).ceil() as u32 @@ -806,26 +452,19 @@ mod tests { #[test] fn stealth_detect_range_grows_with_level_advantage_and_clamps() { - // Equal level → the small base detect radius. assert_eq!(stealth_detect_range(10, 10), 5.0); - // A higher-level creature sees through stealth from farther (+1 yd/level of advantage). - assert_eq!(stealth_detect_range(20, 10), 15.0); // +10 levels → 5 + 10 - assert_eq!(stealth_detect_range(11, 10), 6.0); // +1 level → 6 - // A LOWER-level creature is floored at base (never below 5, the per-level term floors at 0). + assert_eq!(stealth_detect_range(20, 10), 15.0); + assert_eq!(stealth_detect_range(11, 10), 6.0); assert_eq!(stealth_detect_range(5, 10), 5.0); assert_eq!(stealth_detect_range(1, 60), 5.0); - // Clamped to the max even with a huge level gap. assert_eq!(stealth_detect_range(99, 1), STEALTH_DETECT_MAX); - // Base equals the aggro floor → detection only ever NARROWS the aggro radius, never widens it. assert_eq!(stealth_detect_range(10, 10), MIN_AGGRO_RADIUS); } #[test] fn level_roll_respects_range_and_interpolates_health() { - // No range (max ≤ min, or the 0 default) → the min level/health verbatim, any rand. assert_eq!(rolled_creature_stats(3, 0, 42, 0, 12345), (3, 42)); assert_eq!(rolled_creature_stats(3, 3, 42, 60, 999), (3, 42)); - // A real range: level stays within [min, max] for any rand; the endpoints are reachable. for rand in 0..50u32 { let (lvl, _) = rolled_creature_stats(1, 3, 40, 80, rand); assert!( @@ -833,20 +472,16 @@ mod tests { "level {lvl} out of [1,3] for rand {rand}" ); } - assert_eq!(rolled_creature_stats(1, 3, 40, 80, 0).0, 1); // rand 0 → min - assert_eq!(rolled_creature_stats(1, 3, 40, 80, 2).0, 3); // rand 2 → max (span 2, 2%3==2) - // Health interpolates by level position: L1→40 (min), L3→80 (max), L2→midpoint 60. + assert_eq!(rolled_creature_stats(1, 3, 40, 80, 0).0, 1); + assert_eq!(rolled_creature_stats(1, 3, 40, 80, 2).0, 3); assert_eq!(rolled_creature_stats(1, 3, 40, 80, 0), (1, 40)); assert_eq!(rolled_creature_stats(1, 3, 40, 80, 1), (2, 60)); assert_eq!(rolled_creature_stats(1, 3, 40, 80, 2), (3, 80)); - // Health never below min even if max_health is missing (0) — falls back to min health. assert_eq!(rolled_creature_stats(1, 3, 50, 0, 2), (3, 50)); } #[test] fn rank_scales_health_and_rank0_is_identity() { - // Rank 0 (normal) is the IDENTITY — every current spawn is rank 0, so scaling must not change - // a single byte of its health for any base value. for base in [0u32, 1, 42, 1000, 250_000, u32::MAX] { assert_eq!( scale_health_for_rank(base, 0), @@ -854,49 +489,36 @@ mod tests { "rank-0 must be identity for base {base}" ); } - // Elite/rare/boss multipliers (permille math): a 100-HP base scales by the documented factors. - assert_eq!(scale_health_for_rank(100, 1), 150); // elite ×1.5 - assert_eq!(scale_health_for_rank(100, 2), 280); // rare-elite ×2.8 - assert_eq!(scale_health_for_rank(100, 3), 400); // boss ×4.0 - assert_eq!(scale_health_for_rank(100, 4), 180); // rare ×1.8 - // A realistic L1 mob (42 HP) as an elite → 42*1500/1000 = 63 (truncated integer math). + assert_eq!(scale_health_for_rank(100, 1), 150); + assert_eq!(scale_health_for_rank(100, 2), 280); + assert_eq!(scale_health_for_rank(100, 3), 400); + assert_eq!(scale_health_for_rank(100, 4), 180); assert_eq!(scale_health_for_rank(42, 1), 63); - // Unknown rank → normal (never amplify an unrecognized value). assert_eq!(scale_health_for_rank(100, 7), 100); assert_eq!(scale_health_for_rank(100, 255), 100); - // The permille table itself: rank 0 is exactly 1000 (the identity divisor) and elite > normal. assert_eq!(rank_hp_multiplier_permille(0), 1000); assert!(rank_hp_multiplier_permille(1) > rank_hp_multiplier_permille(0)); assert!(rank_hp_multiplier_permille(3) > rank_hp_multiplier_permille(1)); - // SATURATION: every >1× rank at the max base clamps to EXACTLY u32::MAX — the u64 product - // exceeds the u32 ceiling for all of them, and the clamp (not a wrapping cast) must win. - assert_eq!(scale_health_for_rank(u32::MAX, 1), u32::MAX); // elite - assert_eq!(scale_health_for_rank(u32::MAX, 2), u32::MAX); // rare-elite - assert_eq!(scale_health_for_rank(u32::MAX, 3), u32::MAX); // boss - assert_eq!(scale_health_for_rank(u32::MAX, 4), u32::MAX); // rare - // Large-but-fitting products stay EXACT (the clamp never fires early): one per >1× rank. - assert_eq!(scale_health_for_rank(2_000_000_000, 1), 3_000_000_000); // ×1.5 < u32::MAX - assert_eq!(scale_health_for_rank(1_000_000_000, 2), 2_800_000_000); // ×2.8 < u32::MAX - assert_eq!(scale_health_for_rank(1_000_000_000, 3), 4_000_000_000); // ×4.0 < u32::MAX - assert_eq!(scale_health_for_rank(2_000_000_000, 4), 3_600_000_000); // ×1.8 < u32::MAX + assert_eq!(scale_health_for_rank(u32::MAX, 1), u32::MAX); + assert_eq!(scale_health_for_rank(u32::MAX, 2), u32::MAX); + assert_eq!(scale_health_for_rank(u32::MAX, 3), u32::MAX); + assert_eq!(scale_health_for_rank(u32::MAX, 4), u32::MAX); + assert_eq!(scale_health_for_rank(2_000_000_000, 1), 3_000_000_000); + assert_eq!(scale_health_for_rank(1_000_000_000, 2), 2_800_000_000); + assert_eq!(scale_health_for_rank(1_000_000_000, 3), 4_000_000_000); + assert_eq!(scale_health_for_rank(2_000_000_000, 4), 3_600_000_000); } #[test] fn flee_threshold_is_alive_and_low_health() { - // 5/42 → 5*100=500 < 42*15=630 → flee. assert!(should_flee(5, 42)); - // 10/42 → 1000 < 630 is false → hold the line (above the threshold). assert!(!should_flee(10, 42)); - // 0 HP is dead, not fleeing (the death/loot path handles it, not the flee pass). assert!(!should_flee(0, 42)); - // Full HP never flees. assert!(!should_flee(42, 42)); } #[test] fn flee_eligible_is_humanoid_only() { - // Only HUMANOIDS (creature_type 7) rout at low HP — BEASTS (wolves/boars, type 1) fight to the - // death instead. assert!( flee_eligible(7), "humanoid (Defias militiaman) routs to call for help" @@ -917,13 +539,10 @@ mod tests { #[test] fn flee_step_runs_away_from_target() { - // Creature east of the target at (10,0), target at the origin → step further east to x≈22. let (x, y) = flee_step(10.0, 0.0, 0.0, 0.0, 12.0); assert!((x - 22.0).abs() < 0.01, "x {x} expected ~22.0"); assert!((y - 0.0).abs() < 0.01, "y {y} expected ~0.0"); - // Coincident creature/target: no defined "away" → fixed fallback bearing, always finite and - // exactly `dist` from the start point (never NaN, never a divide-by-zero). let (x, y) = flee_step(5.0, 5.0, 5.0, 5.0, 12.0); assert!( x.is_finite() && y.is_finite(), @@ -938,8 +557,6 @@ mod tests { #[test] fn feared_flee_step_moves_away_from_source() { - // Source at the origin, creature east at (10,0): away bearing is +x. The midpoint roll - // (u32::MAX/2) maps to zero jitter, so the leg lands straight out along +x at x≈22 (10+12). let (x, y) = feared_flee_step(10.0, 0.0, 0.0, 0.0, 12.0, u32::MAX / 2); assert!( (x - 22.0).abs() < 0.05, @@ -953,8 +570,6 @@ mod tests { #[test] fn feared_flee_step_always_nets_away() { - // Across the FULL jitter range the away-component stays POSITIVE (jitter capped under 90° → - // cos ≥ 0.5): a feared creature east of a source at the origin never doubles back toward it. let (cx, sx) = (10.0_f32, 0.0_f32); for r in [ 0u32, @@ -973,7 +588,6 @@ mod tests { #[test] fn feared_flee_step_coincident_is_finite() { - // Source on top of the creature: no defined away bearing → +x fallback, finite, and it moves. let (x, y) = feared_flee_step(5.0, 5.0, 5.0, 5.0, 12.0, 0); assert!( x.is_finite() && y.is_finite(), @@ -988,8 +602,6 @@ mod tests { #[test] fn chase_step_closes_to_just_inside_melee() { - // Target 20 yds east; one full run tick (RUN*4 = 28 yd cap) easily reaches but must STOP 4 yd - // short → lands at x≈16 (20 - 4), never on the target. let (x, y) = chase_step(0.0, 0.0, 20.0, 0.0, constants::speeds::RUN * 4.0, 4.0); assert!( (x - 16.0).abs() < 0.01, @@ -1000,8 +612,6 @@ mod tests { "y {y} expected ~0.0 (straight-line approach)" ); - // Far target (100 yds east) with the same cap → moves at most one tick of run (28 yd), so it - // advances to x≈28, NOT all the way (the per-tick cap, not stop_dist, bounds this leg). let (x, y) = chase_step(0.0, 0.0, 100.0, 0.0, constants::speeds::RUN * 4.0, 4.0); assert!( (x - 28.0).abs() < 0.01, @@ -1009,12 +619,9 @@ mod tests { ); assert!((y - 0.0).abs() < 0.01, "y {y} expected ~0.0"); - // Already within stop_dist (3 yds away, stop_dist 4) → no move (returns the start point), so a - // creature on top of its target never steps onto / past it. let (x, y) = chase_step(0.0, 0.0, 3.0, 0.0, 28.0, 4.0); assert_eq!((x, y), (0.0, 0.0), "within stop_dist must hold position"); - // Exactly at stop_dist (4 yds, stop_dist 4) → `len <= stop_dist` → no move (boundary holds). let (x, y) = chase_step(0.0, 0.0, 4.0, 0.0, 28.0, 4.0); assert_eq!( (x, y), @@ -1022,8 +629,6 @@ mod tests { "at the stop_dist boundary must hold position" ); - // Coincident creature/target (len 0 ≤ any stop_dist) → caught by the early return, no NaN / - // no divide-by-zero — holds position. let (x, y) = chase_step(5.0, 5.0, 5.0, 5.0, 28.0, 4.0); assert_eq!( (x, y), @@ -1032,8 +637,6 @@ mod tests { ); assert!(x.is_finite() && y.is_finite()); - // Diagonal approach: target at (30,40) is 50 yds away (3-4-5); stop 5 yd short → travel 45 yd - // along the unit vector (0.6, 0.8) → (27, 36). Confirms the direction normalization holds off-axis. let (x, y) = chase_step(0.0, 0.0, 30.0, 40.0, 100.0, 5.0); assert!((x - 27.0).abs() < 0.01, "x {x} expected ~27.0"); assert!((y - 36.0).abs() < 0.01, "y {y} expected ~36.0"); @@ -1042,10 +645,8 @@ mod tests { #[test] fn wander_point_stays_within_radius_of_home() { let (hx, hy) = (-100.0_f32, 250.0_f32); - let radius = WANDER_RADIUS; // 6.0 - let eps = 1e-3; // float slop on the angle/radius mapping - // A spread of `(rand_a, rand_b)` pairs across the u32 span: the destination is always within - // `radius` of home — the radius scalar is bounded to [0, radius], so distance never exceeds it. + let radius = WANDER_RADIUS; + let eps = 1e-3; let rolls = [ (0u32, 0u32), (u32::MAX, u32::MAX), @@ -1068,14 +669,11 @@ mod tests { "non-finite point for rolls ({ra},{rb})" ); } - // `rand_b == 0` → radius scalar 0 → the point IS home (any angle), the tightest hop. let (px, py) = wander_point(hx, hy, 9999, 0, radius); assert!( (px - hx).abs() < eps && (py - hy).abs() < eps, "rand_b 0 must land on home" ); - // Max radius roll → sits ~on the rim (≤ radius), confirming the upper bound is reached, not - // overshot. let (px, py) = wander_point(hx, hy, 0, u32::MAX, radius); let d = ((px - hx).powi(2) + (py - hy).powi(2)).sqrt(); assert!( @@ -1086,31 +684,22 @@ mod tests { #[test] fn assist_radius_is_inclusive_boundary() { - // 7 yds apart (along x) → inside the 10-yard radius → assists. assert!(within_assist_radius(0.0, 0.0, 7.0, 0.0)); - // Exactly 10 yds apart → boundary, `<=` → still assists. assert!(within_assist_radius(0.0, 0.0, 10.0, 0.0)); - // 12 yds apart → outside the radius → no assist. assert!(!within_assist_radius(0.0, 0.0, 12.0, 0.0)); - // Same point (0 distance) → assists. assert!(within_assist_radius(3.0, 4.0, 3.0, 4.0)); } #[test] fn wounded_slow_factor_curve() { let approx = |a: f32, b: f32| (a - b).abs() < 1e-3; - // ≥30% HP → no slow. assert!(approx(wounded_slow_factor(30, 100), 1.0)); assert!(approx(wounded_slow_factor(100, 100), 1.0)); - // cmangos curve below 30%: 15% → ×0.7495 (5.25 yd/s at 7.0 run < the 7.0 player → catchable), - // 10% → ×0.666, near-death floored ~0.5. assert!(approx(wounded_slow_factor(15, 100), 0.7495)); assert!(approx(wounded_slow_factor(10, 100), 0.666)); assert!(approx(wounded_slow_factor(1, 100), 0.5157)); - // A fleeing mob is always sub-15% HP → ALWAYS strictly slower than the 7.0 player. assert!(wounded_slow_factor(14, 100) < 1.0); - assert!(wounded_slow_factor(1, 100) >= 0.499); // never inverts - // Degenerate max_health 0 → safe ×1.0 (no divide-by-zero). + assert!(wounded_slow_factor(1, 100) >= 0.499); assert!(approx(wounded_slow_factor(0, 0), 1.0)); } @@ -1137,30 +726,22 @@ mod tests { #[test] fn spline_t_interpolates_and_clamps() { let approx = |a: f32, b: f32| (a - b).abs() < 1e-4; - // 1000ms leg starting at micros 0: t tracks elapsed fraction. assert!(approx(spline_t(0, 0, 1000), 0.0)); assert!(approx(spline_t(250_000, 0, 1000), 0.25)); assert!(approx(spline_t(500_000, 0, 1000), 0.5)); assert!(approx(spline_t(1_000_000, 0, 1000), 1.0)); - // Past the end clamps to 1.0 (lands exactly on dest, no overshoot). assert!(approx(spline_t(2_000_000, 0, 1000), 1.0)); - // now < start (clock skew / not-yet-begun) clamps to 0.0 via saturating_sub. assert!(approx(spline_t(0, 500_000, 1000), 0.0)); - // 0-duration leg = snap (t=1), no divide-by-zero. assert!(approx(spline_t(0, 0, 0), 1.0)); - // Lerp sanity: pos at t=0.25 over (0)->(10) = 2.5. let t = spline_t(250_000, 0, 1000); assert!(approx(0.0 + (10.0 - 0.0) * t, 2.5)); } #[test] fn chase_step_at_tick_scale_glues_to_a_fleeing_target() { - let run = constants::speeds::RUN * MOVE_TICK_SECS; // 3.5yd per 0.5s tick - // One step toward a 20yd target advances ~3.5yd — a short, per-tick leg. + let run = constants::speeds::RUN * MOVE_TICK_SECS; let (x, _) = chase_step(0.0, 0.0, 20.0, 0.0, run, 4.0); assert!((x - 3.5).abs() < 0.01, "x {x} expected ~3.5 (RUN*0.5 step)"); - // Chasing a target fleeing +0.5yd/tick: the creature closes and stays within ~one leg (~4-5yd) — - // the glued-pursuit property of re-pathing toward the live target every tick. let (mut cx, mut tx) = (0.0f32, 20.0f32); for _ in 0..20 { cx = chase_step(cx, 0.0, tx, 0.0, run, 4.0).0; @@ -1173,14 +754,6 @@ mod tests { ); } - // ========================================================================================= - // Per-instance tick scope + cadence-generalized sense/step math - // ========================================================================================= - - /// Pins the sentinel to u64::MAX — the schema `#[default(18_446_744_073_709_551_615u64)]` on - /// `CreatureMoveSchedule.instance_id` (tick.rs) is this value spelled as a literal (the macro - /// wants a literal); if either side ever drifts, the auto-migrated seeded row would silently stop - /// being the catch-all and every creature would freeze (no covering row). #[test] fn global_tick_instance_sentinel_is_u64_max() { assert_eq!(GLOBAL_TICK_INSTANCE, u64::MAX); @@ -1229,9 +802,6 @@ mod tests { assert!(scope.runs_global_passes()); } - /// The no-multiplication invariant: across ALL schedule rows, every instance id is covered by - /// EXACTLY ONE firing scope — a dedicated row DIVIDES the work, it never adds a second ticker for - /// the same creature (which would, e.g., double a chaser's speed). #[test] fn tick_scope_partitions_every_instance_exactly_once() { let rows = [GLOBAL_TICK_INSTANCE, 7, 9]; @@ -1248,14 +818,9 @@ mod tests { } } - /// The equivalence-spec pattern, applied to the per-instance tick scope: with ONLY the seeded catch-all row - /// and every entity at instance 0 (today's world), the scope filter admits the IDENTICAL visit - /// set — the passes behave byte-identically to an unscoped tick (the gate is `covers() == true` - /// for every candidate, and the global passes still run). #[test] fn tick_scope_default_config_visits_the_identical_candidate_set() { let scope = TickScope::from_rows(GLOBAL_TICK_INSTANCE, [GLOBAL_TICK_INSTANCE]); - // (guid, instance_id) candidates — all at instance 0, like every live entity today. let candidates: Vec<(u64, u64)> = (0..50).map(|g| (g, 0u64)).collect(); let visited: Vec<(u64, u64)> = candidates .iter() @@ -1272,14 +837,10 @@ mod tests { ); } - /// At the DEFAULT interval, the generalized sense gate must fire on exactly the documented boundary - /// — `(now / MOVE_TICK_MICROS) % SENSE_EVERY_N_TICKS == 0` — the "one movement tick in every N is a - /// sense tick" contract this fn exists to provide, asserted directly (not via a second, parallel - /// implementation) across aligned AND unaligned timestamps. #[test] fn sense_tick_for_interval_fires_on_the_documented_boundary_at_default_cadence() { for step in 0..200i64 { - let now = step * 137_777; // deliberately NOT a multiple of the tick interval + let now = step * 137_777; let expected = (now / MOVE_TICK_MICROS) % SENSE_EVERY_N_TICKS == 0; assert_eq!( is_sense_tick_for_interval(now, MOVE_TICK_MICROS), @@ -1291,16 +852,12 @@ mod tests { #[test] fn sense_tick_for_interval_keeps_the_4s_cadence_for_fast_and_slow_rows() { - // A 100ms row: exactly ONE sense firing per 4s window (40 firings), not five per window. let hits = (0..40i64) .filter(|t| is_sense_tick_for_interval(t * 100_000, 100_000)) .count(); assert_eq!(hits, 1, "a 100ms row senses once per ~4s, not per firing"); - // A row SLOWER than the sense period (10s): every firing senses (divisor floors at 1). assert!(is_sense_tick_for_interval(10_000_000, 10_000_000)); assert!(is_sense_tick_for_interval(20_000_000, 10_000_000)); - // A ScheduleAt::Time row / garbage interval clamps to the default cadence (never div-by-zero) — - // asserted by comparing against the SAME fn given the default interval explicitly. assert_eq!( is_sense_tick_for_interval(0, 0), is_sense_tick_for_interval(0, MOVE_TICK_MICROS) @@ -1311,16 +868,11 @@ mod tests { ); } - /// The cadence-invariant-speed spec: per-firing step seconds scale with the interval, so - /// (firings/sec × yards/firing) is constant — a tight tick smooths, it never speeds up. #[test] fn tick_secs_for_interval_is_exact_at_default_and_scales_linearly() { - // EXACT equality at the default (0.5 is representable; 500000/1000000 rounds to nothing) — - // this is what makes the default-row legs byte-identical. assert_eq!(tick_secs_for_interval(MOVE_TICK_MICROS), MOVE_TICK_SECS); assert_eq!(tick_secs_for_interval(100_000), 0.1); assert_eq!(tick_secs_for_interval(1_000_000), 1.0); - // Net speed check: legs/sec × yd/leg is the same at both cadences. let run = constants::speeds::RUN; let default_net = (1.0 / MOVE_TICK_SECS) * (run * tick_secs_for_interval(MOVE_TICK_MICROS)); let fast_net = (1.0 / 0.1) * (run * tick_secs_for_interval(100_000)); @@ -1328,7 +880,6 @@ mod tests { (default_net - fast_net).abs() < 1e-4, "net yd/s must be cadence-invariant" ); - // Non-positive clamps to the default (mirrors the sense-gate clamp). assert_eq!(tick_secs_for_interval(0), MOVE_TICK_SECS); assert_eq!(tick_secs_for_interval(-1), MOVE_TICK_SECS); } @@ -1348,22 +899,16 @@ mod tests { #[test] fn sense_period_matches_actual_sense_tick_spacing() { - // Default 500ms row: exactly the old 4s (byte-identical pet legs). assert_eq!( sense_period_secs_for_interval(MOVE_TICK_MICROS), MOVE_TICK_SECS * SENSE_EVERY_N_TICKS as f32 ); - // Fast rows quantize to exactly 4s (100ms × 40, 200ms × 20). assert_eq!(sense_period_secs_for_interval(100_000), 4.0); assert_eq!(sense_period_secs_for_interval(200_000), 4.0); - // A 2.5s row senses every firing (floor at 1) → its sense period IS 2.5s, not 4s. assert_eq!(sense_period_secs_for_interval(2_500_000), 2.5); - // A 10s row: every firing senses → 10s span (a slower row never emits overlapping legs). assert_eq!(sense_period_secs_for_interval(10_000_000), 10.0); - // A 3s row: floor(4s/3s)=1 → 3s. A 707ms row: floor(4s/707ms)=5 → 3.535s. assert_eq!(sense_period_secs_for_interval(3_000_000), 3.0); assert!((sense_period_secs_for_interval(707_000) - 3.535).abs() < 1e-4); - // Non-positive clamps to the default (mirrors the other cadence helpers). assert_eq!( sense_period_secs_for_interval(0), MOVE_TICK_SECS * SENSE_EVERY_N_TICKS as f32 @@ -1386,42 +931,32 @@ mod tests { #[test] fn leg_in_flight_gate() { - assert!(leg_in_flight(100, 600)); // mid-leg - assert!(!leg_in_flight(600, 600)); // exactly at ETA → arrived - assert!(!leg_in_flight(700, 600)); // past ETA - assert!(!leg_in_flight(100, 0)); // fresh / no leg + assert!(leg_in_flight(100, 600)); + assert!(!leg_in_flight(600, 600)); + assert!(!leg_in_flight(700, 600)); + assert!(!leg_in_flight(100, 0)); } #[test] fn rout_window_open_covers_the_whole_clock_lifecycle() { - // Never started (the zero sentinel) → not routing, at any `now`. assert!(!rout_window_open(0, 0)); assert!(!rout_window_open(50_000, 0)); - // Open: the close time is still ahead. assert!(rout_window_open(50_000, 60_000)); - // Just closed (exactly at the close time) and closed long ago → over. assert!(!rout_window_open(60_000, 60_000)); assert!(!rout_window_open(600_000, 60_000)); } #[test] fn a_rout_starts_once_per_engagement_and_only_when_eligible() { - // Eligible with an unstarted clock → the rout starts. assert!(may_start_rout(true, 0)); - // Eligible but the clock is already spent (a window that closed) → never a second rout, at - // any health. An open window is not a start either — the rout is already running. assert!(!may_start_rout(true, 60_000)); - // Ineligible (a BEAST, or above the HP threshold) never starts one. assert!(!may_start_rout(false, 0)); assert!(!may_start_rout(false, 60_000)); } #[test] fn a_rout_window_never_stamps_the_never_started_sentinel() { - // The close time is the stamp instant plus the duration. assert_eq!(rout_close_ms(50_000), 50_000 + ROUT_DURATION_MS); - // A stamp that wraps the u32 clock exactly onto 0 would read as "never started" and hand out - // a second rout, so it is nudged off the sentinel. assert_eq!(rout_close_ms(u32::MAX - ROUT_DURATION_MS + 1), 1); } @@ -1429,23 +964,17 @@ mod tests { fn evade_needs_both_an_expired_deadline_and_a_target_that_left_the_leash() { let near = crate::combat::LEASH_RADIUS_SQ - 1.0; let far = crate::combat::LEASH_RADIUS_SQ + 1.0; - // Deadline still live: the fight continues at ANY distance inside the backstop — the whole - // point of the timer model (a kited mob keeps chasing while damage flows). assert!(!should_evade(50_000, 60_000, near)); assert!(!should_evade(50_000, 60_000, far)); - // Deadline expired but the target is still standing by the remembered spot → keep fighting. assert!(!should_evade(60_000, 60_000, near)); - // Expired AND the target left the leash radius → the only evading corner. assert!(should_evade(60_000, 60_000, far)); } #[test] fn the_backstop_is_the_only_raw_distance_that_still_ends_a_fight() { let backstop = crate::combat::COMBAT_BACKSTOP_SQ; - assert!(!beyond_combat_backstop(backstop)); // exactly at the drop distance is still combat + assert!(!beyond_combat_backstop(backstop)); assert!(beyond_combat_backstop(backstop + 1.0)); - // A target FAR outside the leash radius but inside the backstop never evades on distance while - // its deadline is live — the whole point of dropping the hard cap. assert!(!should_evade(50_000, 60_000, backstop - 1.0)); } @@ -1455,13 +984,10 @@ mod tests { pursuit_deadline_ms(50_000), 50_000 + crate::combat::PURSUIT_WINDOW_MS ); - // A deadline that wraps the u32 clock exactly onto 0 would read as "never refreshed" — a row - // the leash pass would re-seed for another full window — so it is nudged off the sentinel. assert_eq!( pursuit_deadline_ms(u32::MAX - crate::combat::PURSUIT_WINDOW_MS + 1), 1 ); - // A stamped deadline is always in the future of the stamp, so a refresh always defers the evade. assert!(!should_evade( 50_000, pursuit_deadline_ms(50_000), @@ -1469,36 +995,23 @@ mod tests { )); } - /// The tuning constants are only coherent together, so pin the relations rather than the values. - /// `const` blocks: every relation is between constants, so a bad calibration fails the BUILD rather - /// than waiting for the run (and clippy rejects a runtime assert over constants). #[test] fn the_leash_constants_hold_their_invariants() { const { - // A rout must not outlive the engagement that carries it: a router whose pursuit window - // expired mid-rout would evade and heal to full — the reported bug, in timer form. assert!(ROUT_DURATION_MS < crate::combat::PURSUIT_WINDOW_MS); - // The leash radius is the small radius around the remembered spot; the backstop is the far - // "across a zone" cutoff. Inverted, the backstop would be unreachable. assert!(crate::combat::LEASH_RADIUS_SQ < crate::combat::COMBAT_BACKSTOP_SQ); - // The Auto Shot invariant, restated against the backstop: a pull from maximum ranged range - // must start inside the only distance that evades. The leash radius may sit below the pull - // range — the pursuit deadline, stamped on the pull, is what holds that engagement open. assert!(crate::combat::RANGED_RANGE_SQ < crate::combat::COMBAT_BACKSTOP_SQ); } } #[test] fn waypoint_cursor_traverses_in_order() { - // next wraps 0→1→2→3→0 — ordered patrol traversal. assert_eq!(next_waypoint_idx(0, 4), 1); assert_eq!(next_waypoint_idx(2, 4), 3); assert_eq!(next_waypoint_idx(3, 4), 0); - // nearest picks the closest waypoint as the first target when there's no live cursor. let pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (30.0, 0.0)]; assert_eq!(nearest_waypoint_idx(19.0, 0.0, &pts), 2); assert_eq!(nearest_waypoint_idx(-5.0, 0.0, &pts), 0); - // A full lap visits EVERY waypoint. let mut idx = nearest_waypoint_idx(0.0, 0.0, &pts); let mut seen = std::collections::HashSet::new(); for _ in 0..4 { diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 562028a97..d14079b22 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -1,9 +1,3 @@ -//! The ONE production adapter: a real `ReducerContext` wearing the whole cycle world. -//! -//! **This layer is the seam's own blind spot.** The harness substitutes an in-memory `Scenario` for -//! every line of it, so a no-op'd method here is invisible to every test in the crate. Keep every -//! method a pass-through. - use std::collections::HashSet; use lyracore_shared::{constants, spatial}; @@ -30,7 +24,6 @@ use crate::{ game_world_entity, MeleeAttack, WorldEntity, }; -/// `tick_creatures`' one call into the cycle. The adapter never leaves this module. pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome { let regen_window = crate::combat::RegenWindow::new( tick.now_micros / 1_000, @@ -49,13 +42,11 @@ pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome { struct CtxWorld<'a> { ctx: &'a ReducerContext, regen_window: crate::combat::RegenWindow, - /// Each leg advance this firing wrote: the stored row before it, and the row it wrote. advances: Vec<(WorldEntity, WorldEntity)>, } impl CtxWorld<'_> { - /// Put back each stored row whose advance [`tick::advance_needs_persist`] lets lag. Restoring - /// the stored row in the same transaction leaves no commit-log entry. + // Restoring the stored row in the same transaction leaves no commit-log entry. fn settle_advances(self) { let entities = self.ctx.db.game_world_entity(); let now_micros = self.ctx.timestamp.to_micros_since_unix_epoch() as u64; @@ -71,10 +62,7 @@ impl CtxWorld<'_> { } } - /// Move the creature's authoritative row to `at`, writing grid address and packed cell in the - /// SAME statement (a stale `cell` puts the row in the wrong AOI cell), and hand back the row as - /// written. `moved_ms` stamps the move clock; a halted creature passes `None` because it did not - /// travel. `orientation` turns it in that same statement, so a stop-and-turn costs one write. + // Position, grid address and packed cell must change in one entity write. fn place( &self, guid: u64, @@ -101,8 +89,6 @@ impl CtxWorld<'_> { } } -/// [`super::stop_on_leg`] for a stop outside a firing, on the mover's stored leg row. Only the -/// obstruction check reads the world, and it ignores the regen window. pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> Option { let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; let world = CtxWorld { @@ -113,8 +99,6 @@ pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> O super::stop_on_leg(&world, &as_leg(leg, false), now_micros) } -/// The spline row as the cycle reads a leg. `mover_gone` is the caller's to answer: the advance -/// phase has to reap orphans, while a chaser's leg belongs to a creature it just resolved. fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight { LegInFlight { guid: s.guid, @@ -143,20 +127,11 @@ fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight { } } -/// A unit's live translation flags as the chase decision reads them. A client-driven unit stamps its -/// own on every heartbeat; a creature stamps none, so the leg the MODULE is moving it along is what -/// says it travels — the same thing vanilla's spline launch records on the unit itself. Spline -/// advance runs first in the cycle and reaps a landed leg, so a surviving leg with a real duration is -/// still in flight, while a halt leaves a zero-duration row, which is a stop. fn translation_flags(unit: &WorldEntity, leg: Option) -> u32 { let carried = leg.is_some_and(|l| l.dur_ms > 0); unit.movement_flags | if carried { MOVE_FLAG_FORWARD } else { 0 } } -/// The longest ENEMY-cast range (yd) creature `entry` can bring to bear — the rotation nukes and -/// debuffs that target the melee victim, plus the legacy single-spell cast row, mapped through -/// `game_spell.range_yd`. 0 means it is no offensive caster (melee-only mobs, pure healers and -/// buff-bots), and chase then closes to melee. fn caster_hold_range_yd(ctx: &ReducerContext, entry: u32) -> f32 { let spells = ctx.db.game_spell(); let mut max_r = 0u32; @@ -385,7 +360,6 @@ impl IdleSink for CtxWorld<'_> { .or_else(|| distraction::expired_facing(self.ctx, guid)) } fn clear_pending_facing(&mut self, guid: u64) { - // Clear only the facing that played, so the other one plays on a later firing. if eventai::movement::facing(self.ctx, guid).is_some() { eventai::movement::clear_facing(self.ctx, guid); } else { @@ -518,8 +492,6 @@ impl IdleSink for CtxWorld<'_> { } impl EngageSink for CtxWorld<'_> { - // KNOWN DEBT, inherited verbatim from the aggro pass: there is no players-only index, so the - // snapshot reads every entity row once per sense firing. Budgeted in `tripwires.rs`. fn players(&self) -> Vec { self.ctx .db @@ -553,8 +525,6 @@ impl EngageSink for CtxWorld<'_> { .filter(|c| { !c.is_player() && !c.dead - // A PET never pulls or assists on its owner's behalf — the pet phase arms it off - // the owner's combat instead. && c.owner_guid == 0 && melee.attacker_guid().find(c.guid).is_none() }) @@ -608,7 +578,6 @@ impl EngageSink for CtxWorld<'_> { .iter() .filter_map(|a| { let attacker = entities.guid().find(a.attacker_guid); - // Both sides gone: nothing to flag, and `enter_combat` would have no-opped anyway. let instance_id = attacker .as_ref() .map(|e| e.instance_id) @@ -668,12 +637,7 @@ impl EngageSink for CtxWorld<'_> { } impl RegenSink for CtxWorld<'_> { - // KNOWN DEBT, inherited verbatim with the pass: no index answers "hurt, or carrying a power - // bar", so the candidates cost one entity read per sense firing. Budgeted in `tripwires.rs`. fn recovering(&self, flagged: &[u64]) -> Vec { - // Both halves of "in combat": every side of a live engagement, plus the flag the sweep - // harvested at the top of this cycle (a pure caster or a Bloodrage warrior carries the flag - // with no melee row at all). let mut in_combat = crate::combat::melee_combatant_guids(self.ctx); in_combat.extend_from_slice(flagged); self.ctx @@ -691,10 +655,6 @@ impl RegenSink for CtxWorld<'_> { }) .collect() } - // ponytail: each rate re-finds the unit's row, where the old pass computed from the one snapshot - // it had scanned. Ceiling: up to three PK point reads per recovering unit per sense firing. The - // alternative is carrying spirit, level, power type and the five-second-rule clock in the - // cycle's own vocabulary, which is the schema wearing a behavior hat. fn healed_to(&self, u: &Recovering) -> u32 { self.ctx .db @@ -743,12 +703,9 @@ impl RegenSink for CtxWorld<'_> { crate::combat::regen_entity_power(&e, u.in_combat, self.regen_window, mana_per_five) }) } - // ponytail: one row write per unit, where the old pass wrote health and power in two separate - // loops. Ceiling: a unit recovering both bars relays one update instead of two. fn restore(&mut self, guid: u64, health: Option, power: Option) { let entities = self.ctx.db.game_world_entity(); - // The LIVE row, never the snapshot the decision was made from: a movement write earlier in - // this cycle must not be reverted by writing a stale position back. + // Re-read the live row so regeneration cannot restore a stale position. let Some(mut live) = entities.guid().find(guid) else { return; }; @@ -764,7 +721,6 @@ impl RegenSink for CtxWorld<'_> { } impl CtxWorld<'_> { - /// The template this creature was spawned from — the key both spell tables are authored against. fn entry_of(&self, guid: u64) -> Option { self.ctx .db @@ -778,7 +734,6 @@ impl CtxWorld<'_> { impl CastSink for CtxWorld<'_> { fn casters(&self, scope: &TickScope) -> Vec { let entities = self.ctx.db.game_world_entity(); - // Same candidate discovery as chase: the engaged rows ARE the set, one per attacker. self.ctx .db .game_melee_attack() @@ -879,8 +834,6 @@ impl ThreatSink for CtxWorld<'_> { crate::threat::threat_of(self.ctx, creature, source) } fn retarget(&mut self, creature: u64, victim: u64) { - // The engagement keeps its PK and its swing clock — only the victim moves, so the creature - // swings at the new foe on its own cadence instead of restarting the swing timer. let melee = self.ctx.db.game_melee_attack(); if let Some(mut row) = melee.attacker_guid().find(creature) { row.target_guid = victim; @@ -900,8 +853,6 @@ impl PursuitSink for CtxWorld<'_> { fn pursuits(&self, scope: &TickScope) -> Vec { let entities = self.ctx.db.game_world_entity(); let splines = self.ctx.db.game_creature_spline(); - // The engaged rows ARE the candidate set (one per attacker, few), so this stays O(fights) - // rather than scanning the world. A player's own attack row is dropped by `movable_creature`. self.ctx .db .game_melee_attack() @@ -961,9 +912,6 @@ impl PursuitSink for CtxWorld<'_> { .map_or(0.0, |c| caster_hold_range_yd(self.ctx, c.entry)) } fn face(&mut self, guid: u64, at: Point, orientation: f32, spline_id: u32) { - // Position and facing land in ONE entity write, beside the ONE spline row the relay carries: - // `place` re-finds the live row rather than carrying the snapshot the decision was made - // from, so another write to it inside the same tick is not clobbered by a stale copy. let Some(e) = self.place(guid, at, None, Some(orientation)) else { return; }; @@ -981,15 +929,9 @@ impl PursuitSink for CtxWorld<'_> { } impl RoutSink for CtxWorld<'_> { - // ponytail: `creature_is_routing` re-derives eligibility that `eligible` already carries, because - // it is the ONE predicate chase, this phase and `combat::swing` share and forking it would let a - // creature be diverted out of chasing with nothing left to move it. Ceiling: one extra template - // read plus one aura scan per engaged creature per firing. fn routers(&self, scope: &TickScope) -> Vec { let entities = self.ctx.db.game_world_entity(); let splines = self.ctx.db.game_creature_spline(); - // The engaged rows ARE the candidate set — the rout clock lives on them, so the state is in - // hand with no extra lookup, and a creature whose row is gone can never have been routing. self.ctx .db .game_melee_attack() @@ -1030,8 +972,6 @@ impl RoutSink for CtxWorld<'_> { } impl FearSink for CtxWorld<'_> { - // One row per aura, not per feared creature, so a doubly-feared creature is resolved twice and - // the phase collapses it. A second `fear_source` probe preserves the first-caster tie break. fn panicked(&self, scope: &TickScope) -> Vec { let entities = self.ctx.db.game_world_entity(); let melee = self.ctx.db.game_melee_attack(); @@ -1042,8 +982,6 @@ impl FearSink for CtxWorld<'_> { .filter((crate::spell::A_CONTROL, crate::spell::M_FEAR)) .filter_map(|a| { let c = tick::movable_creature(self.ctx, a.target_guid, scope)?; - // Re-resolved rather than read off the row above, so which caster wins when a - // creature carries two fears stays `fear_source`'s own find order. let source = crate::spell::fear_source(self.ctx, c.guid)?; let at = entities.guid().find(source).or_else(|| { melee @@ -1090,7 +1028,6 @@ impl PetSink for CtxWorld<'_> { }, map_id: p.map_id, instance_id: p.instance_id, - // A dead owner reads the same as a missing one: the pet despawns either way. owner: entities .guid() .find(p.owner_guid) @@ -1129,7 +1066,6 @@ impl PetSink for CtxWorld<'_> { fn take_victim(&mut self, guid: u64, victim: u64) { let melee = self.ctx.db.game_melee_attack(); match melee.attacker_guid().find(guid) { - // Re-point at the owner's current foe WITHOUT resetting the swing cadence. Some(mut row) => { if row.target_guid != victim { row.target_guid = victim; @@ -1140,8 +1076,8 @@ impl PetSink for CtxWorld<'_> { melee.insert(MeleeAttack { attacker_guid: guid, target_guid: victim, - last_swing_ms: 0, // swing on the next melee tick - ranged_spell_id: 0, // an Imp's Firebolt is cast by the cast phase, not this field + last_swing_ms: 0, + ranged_spell_id: 0, last_offhand_swing_ms: 0, rout_ends_ms: 0, pursuit_ends_ms: 0, diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 1d31fbaff..1c76fd634 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -1,7 +1,3 @@ -//! In-memory creature world for the behavior cycle: no database, no `ReducerContext`. A test -//! describes creatures and their legs, runs one cycle, and reads back the authoritative state plus -//! the ordered movement effects a client would have received. - use super::*; use crate::combat::MOVE_FLAG_FORWARD; use crate::creatures::ai::ROUT_DURATION_MS; @@ -58,27 +54,20 @@ pub(crate) use eventai_legacy::{ TARGET_SELF, TARGET_SPAWNER, TARGET_TOP_THREAT, TARGET_TOP_THREAT_PLAYER, }; -/// A creature's authoritative state, as the cycle writes it. #[derive(Clone, Copy, PartialEq, Debug)] struct XCreature { - /// The static creature template entry. entry: u32, at: Point, grid: (i32, i32), cell: i64, last_move_ms: u32, - /// Where it looks. Only the facing turn writes it. orientation: f32, - /// The ETA gate an idle leg arms, and the route cursor patrol walks by. leg_ends_ms: u32, wp_target: u64, - /// What a heal-when-low rotation line reads. Full health unless a scenario hurts it. health: u32, max_health: u32, - /// The power bar regeneration ticks. `max_power` 0 — every creature — has no bar at all. power: u32, max_power: u32, - // --- what the engagement phases read; every one has a quiet default a builder overrides. level: u32, faction_template: u32, map_id: u32, @@ -89,8 +78,6 @@ struct XCreature { cannot_act: bool, } -/// One movement leg the cycle emitted — everything the relay carries to a client. A zero `dur_ms` -/// with `dest == start` is a stop. #[derive(Clone, Copy, PartialEq, Debug)] struct MoveEffect { guid: u64, @@ -107,15 +94,12 @@ struct MoveEffect { facing_angle: f32, } -/// What the pet phase did to one pet, in the order it did it. The follow leg is NOT here — it goes -/// through the shared leg writer and shows up in `effects()` like every other movement. #[derive(Clone, Copy, PartialEq, Debug)] enum PetEffect { Took(u64, u64), StoodDown(u64), Dismissed(u64), Restaged(u64, Point, u32, u64), - /// The owner's stale ATTACK order was cleared. OrderCleared(u64), } @@ -161,111 +145,58 @@ struct Scenario { creatures: RefCell>, legs: RefCell>, obstructed_paths: RefCell>, - /// FROZEN by crowd control — a stun, root or polymorph. Fear is the other axis, below. frozen: RefCell>, - /// Live fear auras, `(feared unit, caster)`, in the order the world reads them: one row each, so - /// a creature feared twice really is listed twice. fears: RefCell>, engaged: RefCell>, routes: RefCell>>, homes: RefCell>, - /// Determinism input: the firing clock every cycle reads through `TickContext`. now_micros: Cell, - /// Determinism input: the world's random stream, oldest roll first. rolls: RefCell>, - /// Determinism input: the imported ground under every landing point. `None` is an unimported - /// slice, where a leg keeps the height its decider fell back to. ground: Cell>, - /// Determinism input: navigation aims at this corner instead of the goal, i.e. the goal is - /// blocked and the walk has to go around. detours: RefCell>, - /// Ordered movement effects, oldest first. effects: RefCell>, - /// The last carrier row each creature wrote, and the firing clock it was written at. A creature - /// has ONE spline row and a subscriber sees only a transaction's net change, so a second write - /// in one firing would reach no client at all — [`Scenario::record`] refuses it. last_carrier: RefCell>, - /// The players in the world, in the order the world reads them. players: RefCell>, eventai_player_health: RefCell>, - /// Live melee engagements — what aggro arms, and what combat entry and chase read. fights: RefCell>, - /// Each engagement's rout clock, `creature -> the ms its window closes at`. Absent is the - /// never-routed sentinel; the rout phase is the only thing that writes it. rout_clock: RefCell>, - /// The longest offensive spell range a creature fights from; absent means melee-only. hold_ranges: RefCell>, - /// Determinism input: when each PLAYER last moved. A creature's own move clock is on its row. player_moved_ms: RefCell>, - /// Determinism input: each PLAYER's live movement flags, as its heartbeats stamp them. A creature - /// stamps none — the leg the module moves it along is what says it travels. player_move_flags: RefCell>, - /// Faction pairs at war, directed. Anything absent is not hostile, as missing data is. at_war: RefCell>, - /// `(looker, unseen)` pairs with something solid in between. blind: RefCell>, - /// Ordered engagements the cycle armed, oldest first. pulls: RefCell>, - /// Ordered units the cycle flagged in combat, oldest first. flagged: RefCell>, - /// The in-combat flag as state: `unit -> the ms its flag stops being earned`. Combat entry - /// stamps it, combat exit clears it, the way the world's own flag works. combat_flags: RefCell>, - /// Ordered units the cycle dropped out of combat, oldest first. unflagged: RefCell>, - /// Units carrying a combat-regen aura — the only ones that heal while they fight. combat_regen: RefCell>, - /// Authored spell rotations, per creature (the world keys them by template). rotations: RefCell>>, - /// Independent Creature Spell Lists, separate from template compatibility schedules. creature_spell_lists: RefCell>>, - /// The single spell a creature with no rotation falls back to. lone_spells: RefCell>, - /// `(unit, spell)` auras already on a unit — what a missing-aura condition reads. auras: RefCell>, - /// Creatures with a cast bar already running. casting: RefCell>, - /// Units holding the non-melee spell slot through an active channel. channeling: RefCell>, slowed: RefCell>, - /// Fault injection: spells the spell module refuses — on cooldown, unaffordable or out of range. not_ready: RefCell>, - /// Static spell ranges used by EventAI selection. Unlisted spells have no practical limit. eventai_spell_ranges: RefCell>, - /// Ordered casts the cycle began, oldest first. casts: RefCell>, - /// Typed EventAI spell starts, retained separately from the compatibility cast log. eventai_spell_starts: RefCell>, - /// The threat table, `(creature, source) -> threat`. threat: RefCell>, - /// Live taunt locks: the creature is pinned on this unit whatever the threat table says. taunts: RefCell>, - /// Live pets, `pet -> owner`. A pet is an ordinary creature row plus this link. pet_owners: RefCell>, - /// The pet bar per OWNER; an absent entry is the vanilla default (Follow + Defensive). pet_bars: RefCell>, - /// Creatures killed but not yet removed. Only the pet phase reads this: a pet has no spawn row, - /// so corpse decay never reaps it. corpses: RefCell>, - /// Ordered pet effects, oldest first. pet_effects: RefCell>, - /// Scenario input for the active-cell sweep. awake: RefCell, - /// Positions read BEFORE any pass ran, i.e. by the sweep — still the leg starts, because the - /// sweep runs ahead of spline advance. seen_by_sweep: RefCell>, maintenance_runs: Cell, package_runs: Cell, - /// Authored EventAI action rows, mirroring the Module-only static table. eventai_rows: RefCell>, - /// Native definitions used by revision and instruction-boundary scenarios. eventai_definitions: RefCell>, - /// Imported broadcast text, keyed by its stable catalogue id. eventai_text: RefCell>, eventai_creature_state: RefCell>, eventai_rule_state: RefCell>>, - /// Typed EventAI projection calls, retained so behavior tests can inspect the world seam. eventai_presentation: RefCell< Vec<( u64, @@ -294,7 +225,6 @@ struct Scenario { eventai_encounter_notifications: RefCell>, eventai_relay_starts: RefCell>, eventai_next_summon: Cell, - /// Ordered text effects emitted by authored rules. eventai_speech: RefCell>, eventai_speech_targets: RefCell>, eventai_emotes: RefCell>, @@ -310,8 +240,6 @@ impl Scenario { s } - /// Place a creature at `at`, with its grid address derived the way the world derives it. It is - /// level 10, of the creature faction, on the level-scaled aggro radius, and fit to fight. fn creature(self, guid: u64, at: Point) -> Self { let (gx, gy) = spatial::grid_cell(at.x, at.y); self.creatures.borrow_mut().insert( @@ -342,7 +270,6 @@ impl Scenario { self } - /// Edit one creature's sensing state. fn tweak(self, guid: u64, edit: impl FnOnce(&mut XCreature)) -> Self { edit(self.creatures.borrow_mut().get_mut(&guid).unwrap()); self @@ -597,7 +524,6 @@ impl Scenario { .collect() } - /// Compatibility ownership for this creature's current EventAI lifecycle. fn authored_combat(&self, creature_guid: u64) -> eventai::AuthoredCombat { if self.pet_owners.borrow().contains_key(&creature_guid) { return eventai::AuthoredCombat::default(); @@ -623,22 +549,18 @@ impl Scenario { authored } - /// The creature's template carries a hand-tuned aggro range instead of the level-scaled one. fn tuned_aggro_range(self, guid: u64, yards: u32) -> Self { self.tweak(guid, |c| c.aggro_range = Some(yards)) } - /// Mind Soothe: a signed yard change to this creature's own detection radius. fn soothed(self, guid: u64, yards: f32) -> Self { self.tweak(guid, |c| c.detect_range_mod = yards) } - /// Near death and of a kind that runs — it must not be pulled into a fight it would flee. fn near_death(self, guid: u64) -> Self { self.tweak(guid, |c| c.would_rout = true) } - /// Stunned, polymorphed or feared: it cannot act. fn crowd_controlled(self, guid: u64) -> Self { self.tweak(guid, |c| c.cannot_act = true) } @@ -651,7 +573,6 @@ impl Scenario { self.tweak(guid, |c| c.instance_id = instance_id) } - /// Put a living, visible, level-10 ALLIANCE player at `at`. fn player(self, guid: u64, at: Point) -> Self { self.players.borrow_mut().push(AggroTarget { guid, @@ -667,7 +588,6 @@ impl Scenario { self } - /// Edit one player. fn tweak_player(self, guid: u64, edit: impl FnOnce(&mut AggroTarget)) -> Self { edit( self.players @@ -690,7 +610,6 @@ impl Scenario { self } - /// A GM who cannot be killed. fn godmoded(self, guid: u64) -> Self { self.tweak_player(guid, |p| p.godmode = true) } @@ -703,19 +622,16 @@ impl Scenario { self.tweak_player(guid, |p| p.stealthed = true) } - /// These two factions are enemies, both ways round. fn at_war(self, a: u32, b: u32) -> Self { self.at_war.borrow_mut().extend([(a, b), (b, a)]); self } - /// Something solid stands between `looker` and `unseen`. fn wall_between(self, looker: u64, unseen: u64) -> Self { self.blind.borrow_mut().insert((looker, unseen)); self } - /// `attacker` is already swinging at `victim`. fn attacking(self, attacker: u64, victim: u64) -> Self { self.fights.borrow_mut().push(Engagement { attacker, @@ -726,31 +642,26 @@ impl Scenario { self } - /// The creature is already routing: wounded, of a kind that runs, and inside an open window. fn routing(self, guid: u64) -> Self { let ends_ms = (self.now_micros.get() / 1000) as u32 + ROUT_DURATION_MS; self.rout_clock.borrow_mut().insert(guid, ends_ms); self.wounded_runner(guid) } - /// Hurt past the rout threshold and of a kind that runs — `rout_eligible` in the world. fn wounded_runner(self, guid: u64) -> Self { self.hurt(guid, 10).near_death(guid) } - /// `caster` has fear up on `guid`. Called twice, the creature carries two live fear auras. fn feared_by(self, guid: u64, caster: u64) -> Self { self.fears.borrow_mut().push((guid, caster)); self } - /// The creature is an offensive caster: it fights from `yards` away instead of closing. fn caster(self, guid: u64, yards: f32) -> Self { self.hold_ranges.borrow_mut().insert(guid, yards); self } - /// Author one line of the creature's spell rotation. fn rotation_line(self, guid: u64, spell_id: u32, when: CastWhen, priority: u8) -> Self { let mut rotations = self.rotations.borrow_mut(); let lines = rotations.entry(guid).or_default(); @@ -783,19 +694,16 @@ impl Scenario { self } - /// The creature has no rotation, only the one authored spell. fn lone_spell(self, guid: u64, spell_id: u32) -> Self { self.lone_spells.borrow_mut().insert(guid, spell_id); self } - /// The unit already carries this spell's aura. fn carrying(self, guid: u64, spell_id: u32) -> Self { self.auras.borrow_mut().insert((guid, spell_id)); self } - /// The creature's cast bar is already running. fn mid_cast(self, guid: u64) -> Self { self.casting.borrow_mut().insert(guid); self @@ -806,7 +714,6 @@ impl Scenario { self } - /// Fault injection: the spell module refuses this spell — on cooldown, unaffordable, out of range. fn not_ready(self, spell_id: u32) -> Self { self.not_ready.borrow_mut().insert(spell_id); self @@ -823,7 +730,6 @@ impl Scenario { self.tweak(guid, |c| c.health = health) } - /// `source` has done enough to `creature` to hold this much of its threat table. fn threat(self, creature: u64, source: u64, threat: i64) -> Self { self.threat.borrow_mut().insert((creature, source), threat); self @@ -833,7 +739,6 @@ impl Scenario { self.threat.borrow().get(&(creature, source)).copied() } - /// A live taunt: the creature is pinned on `taunter` for the window's duration. fn taunted(self, creature: u64, taunter: u64) -> Self { self.taunts.borrow_mut().insert(creature, taunter); self @@ -843,7 +748,6 @@ impl Scenario { self.casts.borrow().clone() } - /// Who each creature is fighting now, guid-ordered — what retargeting rewrites. fn victims(&self) -> Vec<(u64, u64)> { let mut pairs: Vec<(u64, u64)> = self .fights @@ -855,8 +759,6 @@ impl Scenario { pairs } - /// The player RUNS to `at`: it stands there, its move clock reads now and it is holding the - /// forward key, so a chaser treats it as a kiter rather than a planted target. fn kiting(self, guid: u64, at: Point) -> Self { let now_ms = (self.now_micros.get() / 1000) as u32; self.player_moved_ms.borrow_mut().insert(guid, now_ms); @@ -866,15 +768,11 @@ impl Scenario { self.tweak_player(guid, |p| p.at = at) } - /// The player RELEASED the movement key where it stands: its translation flags go to zero while - /// its move clock keeps reading whenever it last ran. fn stopped(self, guid: u64) -> Self { self.player_move_flags.borrow_mut().insert(guid, 0); self } - /// The player is TURNING IN PLACE — spinning the camera, going nowhere. Vanilla does not count - /// this as movement, so neither may a chaser. fn turning(self, guid: u64) -> Self { let now_ms = (self.now_micros.get() / 1000) as u32; self.player_moved_ms.borrow_mut().insert(guid, now_ms); @@ -884,7 +782,6 @@ impl Scenario { self } - /// How long ago this player's last movement heartbeat landed. fn since_last_move_ms(&self, guid: u64) -> u32 { let now_ms = (self.now_micros.get() / 1000) as u32; now_ms.wrapping_sub( @@ -896,12 +793,10 @@ impl Scenario { ) } - /// The creature looks this way — what the facing turn measures its correction against. fn facing(self, guid: u64, orientation: f32) -> Self { self.tweak(guid, |c| c.orientation = orientation) } - /// A player's auto-attack toggle armed at something it has never reached. fn aiming_at(self, player: u64, victim: u64) -> Self { self.fights.borrow_mut().push(Engagement { attacker: player, @@ -920,15 +815,12 @@ impl Scenario { self.flagged.borrow().clone() } - /// The unit carries the in-combat flag until `until_ms`, and the sweep harvested it into this - /// firing's candidates — the two always travel together, because the sweep IS the flag scan. fn in_combat_until(self, guid: u64, until_ms: u64) -> Self { self.combat_flags.borrow_mut().insert(guid, until_ms); self.awake.borrow_mut().in_combat.push(guid); self } - /// Is this unit still flagged in combat? fn in_combat(&self, guid: u64) -> bool { self.combat_flags.borrow().contains_key(&guid) } @@ -937,7 +829,6 @@ impl Scenario { self.unflagged.borrow().clone() } - /// Give the unit a power bar, the way only a player has one. fn power(self, guid: u64, power: u32, max_power: u32) -> Self { self.tweak(guid, |c| { c.power = power; @@ -945,14 +836,11 @@ impl Scenario { }) } - /// A combat-regen aura (the Troll Regeneration racial): this unit heals even while it fights. fn combat_regen(self, guid: u64) -> Self { self.combat_regen.borrow_mut().insert(guid); self } - /// Summon `guid` as `owner`'s pet at `at`: a creature row plus the owner link, harvested into - /// this firing's pet candidates by the sweep. fn pet(self, guid: u64, owner: u64, at: Point) -> Self { let s = self.creature(guid, at); s.awake.borrow_mut().pets.push(guid); @@ -960,14 +848,11 @@ impl Scenario { s } - /// The owner's pet bar, as the player left it. An absent entry is the vanilla default - /// (Follow + Defensive), which is exactly what a pet did before the bar existed. fn pet_bar(self, owner: u64, command: PetCommand, react: PetReact) -> Self { self.pet_bars.borrow_mut().insert(owner, (command, react)); self } - /// The creature was killed and its row is still there. fn slain(self, guid: u64) -> Self { self.corpses.borrow_mut().insert(guid); self @@ -977,13 +862,11 @@ impl Scenario { self.pet_effects.borrow().clone() } - /// Wake these creatures: the active-cell sweep found them near a covered player this firing. fn awake(self, guids: impl IntoIterator) -> Self { self.awake.borrow_mut().active = guids.into_iter().collect(); self } - /// Give the creature a patrol route, `(waypoint id, point)` in route order. fn route(self, guid: u64, waypoints: &[(u64, Point)]) -> Self { self.routes.borrow_mut().insert( guid, @@ -995,7 +878,6 @@ impl Scenario { self } - /// Point the route cursor at the waypoint the creature is walking TO. fn walking_to(self, guid: u64, waypoint_id: u64) -> Self { self.creatures .borrow_mut() @@ -1005,8 +887,6 @@ impl Scenario { self } - /// Give the creature a spawn post; `wanders` is cmangos RANDOM movement (an IDLE creature holds - /// its post instead). fn home(self, guid: u64, at: Point, wanders: bool) -> Self { self.homes.borrow_mut().insert(guid, Home { at, wanders }); self @@ -1017,7 +897,6 @@ impl Scenario { self } - /// The creature's current idle leg animates until `leg_ends_ms`. fn mid_leg(self, guid: u64, leg_ends_ms: u32) -> Self { self.creatures .borrow_mut() @@ -1032,13 +911,11 @@ impl Scenario { self } - /// Imported terrain: every landing point sits on this ground height. fn ground(self, z: f32) -> Self { self.ground.set(Some(z)); self } - /// The straight line to this creature's goal is blocked; navigation heads for `corner` instead. fn detour(self, guid: u64, corner: (f32, f32)) -> Self { self.detours.borrow_mut().insert(guid, corner); self @@ -1048,7 +925,6 @@ impl Scenario { self.now_micros.set(self.now_micros.get() + micros); } - /// Put `guid` mid-flight from `start` to `dest`, launched at `started_micros`. fn flying( self, guid: u64, @@ -1081,7 +957,6 @@ impl Scenario { self } - /// This engagement's rout clock, as the phase left it. fn rout_ends_ms(&self, guid: u64) -> u32 { self.rout_clock.borrow().get(&guid).copied().unwrap_or(0) } @@ -1110,8 +985,6 @@ impl Scenario { } } - /// One firing of a schedule row ticking every `interval_micros`, with the cadence resolved from - /// that interval exactly as `tick_creatures` resolves it, and the scenario clock moved to it. fn firing(&self, now_micros: u64, interval_micros: i64, scope: TickScope) -> TickContext { self.now_micros.set(now_micros); TickContext { @@ -1170,18 +1043,12 @@ impl MotionSink for Scenario { } impl Scenario { - /// The crowd-control lattice, composed the way the world composes it — `spell::cc_blocks` over - /// this scenario's frozen set and its fear rows: `(action blocked, movement blocked, self - /// movement suppressed)`. fn cc(&self, guid: u64) -> (bool, bool, bool) { let frozen = self.frozen.borrow().contains(&guid); let feared = self.fears.borrow().iter().any(|(unit, _)| *unit == guid); crate::spell::cc_blocks(frozen, false, feared, false) } - /// Is this creature ACTIVELY routing — the world's `creature_is_routing`: wounded and of a kind - /// that runs, inside an open window, and free to move. Chase, the rout leg and the swing pass all - /// read this one verdict, so the harness answers it in one place too. fn is_routing(&self, guid: u64) -> bool { let entitled = self .creatures @@ -1193,8 +1060,6 @@ impl Scenario { entitled && rout_window_open(now_ms, self.rout_ends_ms(guid)) && !self.cc(guid).2 } - /// The state mirror behind every position write, matching `CtxWorld::place`: the row moves, and - /// the move clock and the facing move with it only when the caller says so. fn place( &self, guid: u64, @@ -1217,9 +1082,6 @@ impl Scenario { Some(*c) } - /// The one place a `MoveEffect` is recorded, and the rule production gets from the schema: a - /// creature has ONE `game_creature_spline` row, so two writes for it in one firing reach a - /// client as the second alone. The Fake refuses rather than recording a relay that cannot happen. fn record(&self, effect: MoveEffect) { let now = self.now_micros.get(); if let Some((wrote_at, first)) = self.last_carrier.borrow().get(&effect.guid) { @@ -1237,9 +1099,6 @@ impl Scenario { self.effects.borrow_mut().push(effect); } - /// The zero-duration row a stop leaves behind. It REPLACES the leg it interrupts (the writer - /// upserts one row per mover), so the next cycle reads it as landed and reaps it instead of - /// resuming the old destination. fn park_leg(&self, guid: u64, at: Point, map_id: u32, instance_id: u64) { let mut legs = self.legs.borrow_mut(); legs.retain(|l| l.guid != guid); @@ -1256,10 +1115,6 @@ impl Scenario { }); } - /// A fight's victim wherever it stands — a creature row or a player — as its position and live - /// translation flags. Chase reads both: a kiter is run down, a planted one is stood next to and - /// faced. A creature carries no flags of its own, so the leg the cycle is moving it along is what - /// says it travels, exactly as the production adapter derives it. fn unit(&self, guid: u64) -> Option<(Point, u32)> { if let Some(c) = self.creatures.borrow().get(&guid) { let carried = self @@ -1277,8 +1132,6 @@ impl Scenario { .map(|p| (p.at, flags.unwrap_or(0))) } - /// A pet's owner as the pet reads it. `None` for an owner that logged out or died — the two are - /// the same despawn to a pet. fn pet_owner(&self, guid: u64) -> Option { let (at, map_id, instance_id) = self .players @@ -1293,9 +1146,6 @@ impl Scenario { .filter(|_| !self.corpses.borrow().contains(&guid)) .map(|c| (c.at, c.map_id, c.instance_id)) })?; - // A live melee row IS the owner's fight. The in-combat-selection fallback the world also - // reads (`pet::owner_combat_target`) needs no scenario shape of its own — either way this - // is "the enemy the owner is fighting". let combat_target = self .fights .borrow() @@ -1602,8 +1452,6 @@ impl EventAiWorld for Scenario { .map(|expiry| expiry.summoner_guid) } - /// The world prunes by grid cell before its exact distance check; this Fake hands back the - /// whole partition, guid-ordered, and lets the shared logic apply the radius. fn eventai_units_near(&self, center: &EventAiUnit, _radius_yd: f32) -> Vec { let mut guids: Vec = self.creatures.borrow().keys().copied().collect(); guids.extend(self.players.borrow().iter().map(|p| p.guid)); @@ -1665,8 +1513,6 @@ impl EventAiWorld for Scenario { self.casting.borrow().contains(&guid) || self.channeling.borrow().contains(&guid) } - /// The scenario world has no faction table, so kinship is template equality, exactly the - /// fallback the world uses when the table is empty. fn eventai_factions_friendly(&self, first: u32, second: u32) -> bool { first == second } @@ -1894,8 +1740,6 @@ impl EventAiWorld for Scenario { .expect("the scenario ran out of EventAI random rolls") } - /// The say/yell chokepoint the world speaks through: a corpse says nothing and the line is - /// trimmed and length-capped, as `apply_send_chat` does it. fn eventai_deliver_line( &mut self, speaker_guid: u64, @@ -2388,8 +2232,6 @@ impl EventAiWorld for Scenario { } } -// The in-memory idle world. A scenario holds only LIVE creature rows, so the "no players, no -// corpses" half of `CtxWorld::idle_creatures` has nothing to reject here. impl IdleSink for Scenario { fn idle_creatures(&self, active: &HashSet) -> Vec { let creatures = self.creatures.borrow(); @@ -2523,8 +2365,6 @@ impl IdleSink for Scenario { }) .unwrap_or(WANDER_RADIUS) } - /// Either side of a live fight is engaged, the way a melee row makes it so in the world — plus - /// whatever a scenario marked engaged without arming a fight. fn engaged(&self, guid: u64) -> bool { self.engaged.borrow().contains(&guid) || self @@ -2568,8 +2408,6 @@ impl IdleSink for Scenario { )), ); } - /// The one leg writer: ground-snap, relay, and start the spline the NEXT cycle advances along — - /// the production writer's three jobs in one place, exactly as `emit_creature_leg` does them. fn commit_leg(&mut self, guid: u64, leg: Leg, now_ms: u32) { let Some(from) = self.creatures.borrow().get(&guid).copied() else { return; @@ -2580,9 +2418,9 @@ impl IdleSink for Scenario { z: self.ground.get().unwrap_or(leg.z_fallback), }; if !finite_point(dest.x, dest.y, dest.z) { - return; // the writer refuses a corrupt leg rather than writing it onto the creature + return; } - let dur_ms = leg.dur_ms.max(1); // a zero-duration lerp would divide by zero + let dur_ms = leg.dur_ms.max(1); self.record(MoveEffect { guid, start: from.at, @@ -2619,8 +2457,6 @@ impl IdleSink for Scenario { } } -// The in-memory engagement world. A scenario holds only live, non-pet creature rows, so the -// "no players, no corpses, no pets" half of `CtxWorld::sensing_creatures` has nothing to reject. impl EngageSink for Scenario { fn players(&self) -> Vec { self.players.borrow().clone() @@ -2655,9 +2491,6 @@ impl EngageSink for Scenario { fn hostile(&self, faction_template: u32, other: u32) -> bool { self.at_war.borrow().contains(&(faction_template, other)) } - /// The scenario blocks sight between two UNITS, so the sighted point is resolved back to the - /// unit standing on it — unambiguous in a scenario, and it keeps the assist scan's habit of - /// sighting a caller at the neighbor's own height out of the test's way. fn line_of_sight(&self, looker: u64, at: Point) -> bool { let seen = self .creatures @@ -2693,8 +2526,6 @@ impl EngageSink for Scenario { }); eventai::evaluate(self, request); } - /// The instance is the ATTACKER's, read live — the world reads it off the attacker's own entity - /// row, so a scenario that puts a creature in an instance puts its fight there too. fn engagements(&self) -> Vec { let creatures = self.creatures.borrow(); let mut engagements: Vec = self @@ -2805,14 +2636,9 @@ impl EngageSink for Scenario { } } -/// What a full out-of-combat tick recovers here. The RATES are `combat::tables`' in production; -/// this stands in for them, because what the cycle decides is WHO recovers and WHEN, never how much. const REGEN_TICK: u32 = 5; -/// What a combat-regen aura leaves of that tick — a fraction, as the in-combat rate is. const COMBAT_REGEN_TICK: u32 = 1; -// The in-memory regeneration world. A scenario holds only creature rows, so a unit with a power bar -// stands in for the player half the production pass also covers. impl RegenSink for Scenario { fn recovering(&self, flagged: &[u64]) -> Vec { let fights = self.fights.borrow(); @@ -2830,8 +2656,6 @@ impl RegenSink for Scenario { max_health: c.max_health, power: c.power, max_power: c.max_power, - // Both halves the world reads: either side of a live fight, plus the flag the sweep - // harvested for a unit with no melee row of its own. in_combat: flagged.contains(guid) || fights .iter() @@ -2863,8 +2687,6 @@ impl RegenSink for Scenario { } } -// The in-memory casting world. The spell module is a fake: `begin_cast` records the action and -// reports "not ready" for the spells a scenario put on cooldown, which is all the cycle reads back. impl CastSink for Scenario { fn casters(&self, scope: &TickScope) -> Vec { let creatures = self.creatures.borrow(); @@ -2924,8 +2746,6 @@ impl CastSink for Scenario { } } -// The in-memory threat world. The compare and the taunt window are the real `crate::threat`'s in -// production; here a scenario states the table and the lock outright. impl ThreatSink for Scenario { fn fighters(&self, scope: &TickScope) -> Vec { let creatures = self.creatures.borrow(); @@ -2949,7 +2769,6 @@ impl ThreatSink for Scenario { .borrow() .iter() .filter(|((creature, _), _)| *creature == guid) - // Highest threat wins; the lowest guid breaks a tie, as the threat table does. .max_by(|a, b| a.1.cmp(b.1).then(b.0 .1.cmp(&a.0 .1))) .map(|((_, source), _)| *source) } @@ -2969,8 +2788,6 @@ impl ThreatSink for Scenario { } } -// The in-memory chase world. A fight whose attacker is not a creature row is a PLAYER's own attack -// row, which the production adapter drops for the same reason: chase moves creatures. impl PursuitSink for Scenario { fn pursuits(&self, scope: &TickScope) -> Vec { let creatures = self.creatures.borrow(); @@ -3042,9 +2859,6 @@ impl PursuitSink for Scenario { } self.hold_ranges.borrow().get(&guid).copied().unwrap_or(0.0) } - /// The stop AND the turn, as one carrier row: the creature is planted at `at` looking at - /// `orientation`, and the zero-duration facing row replaces the leg it was riding, exactly as - /// the production writer's upsert does. fn face(&mut self, guid: u64, at: Point, orientation: f32, spline_id: u32) { let Some(c) = self.place(guid, at, None, Some(orientation)) else { return; @@ -3067,8 +2881,6 @@ impl PursuitSink for Scenario { } } -// The in-memory rout world. A fight whose attacker is not a creature row is a PLAYER's own attack -// row, which the production adapter drops for the same reason: only creatures rout. impl RoutSink for Scenario { fn routers(&self, scope: &TickScope) -> Vec { let creatures = self.creatures.borrow(); @@ -3098,7 +2910,6 @@ impl RoutSink for Scenario { } } -// The in-memory fear world: one candidate per live fear aura, as the world's aura scan yields them. impl FearSink for Scenario { fn panicked(&self, scope: &TickScope) -> Vec { let creatures = self.creatures.borrow(); @@ -3107,11 +2918,7 @@ impl FearSink for Scenario { .iter() .filter_map(|(guid, _)| { let c = creatures.get(guid)?; - // Which caster wins is the FIRST fear row on the unit, not this row's — the same - // tie-break `spell::fear_source` makes. let (_, caster) = fears.iter().find(|(unit, _)| unit == guid)?; - // The caster if it is still in the world; failing that, whatever the creature was - // fighting — it still flees SOMETHING. let source = self.unit(*caster).map(|(at, _)| at).or_else(|| { let victim = self .fights @@ -3132,8 +2939,6 @@ impl FearSink for Scenario { } } -// The in-memory pet world. An owner is a player in production; the harness resolves a creature row -// too, and a gone-or-dead owner reads as `None` either way — which is what despawns the pet. impl PetSink for Scenario { fn pets(&self, scope: &TickScope, candidates: &[u64]) -> Vec { let creatures = self.creatures.borrow(); @@ -3170,15 +2975,10 @@ impl PetSink for Scenario { if target == 0 || target == pet.guid || target == owner.guid { return false; } - // Only a live CREATURE row is a valid foe: a player, a corpse and another pet are all out. - // The FACTION half of the guard is the world's (`pet::may_attack`) — a scenario states who - // exists, not who is friendly to whom. self.creatures.borrow().contains_key(&target) && !self.corpses.borrow().contains(&target) && !self.pet_owners.borrow().contains_key(&target) } - /// The seek RADIUS is the world's (`pet::nearest_hostile_near`); what the cycle decides is WHEN - /// to ask, so the scenario answers with the nearest foe it holds at all. fn nearest_hostile_to(&self, pet: &Pet) -> Option { let creatures = self.creatures.borrow(); creatures @@ -3250,14 +3050,11 @@ impl PetSink for Scenario { } const WOLF: u64 = 0x0000_0000_0000_0BEE; -/// A second and third wolf of the same pack, guid-ordered after `WOLF`. const PACK_MATE: u64 = WOLF + 1; const FAR_MATE: u64 = WOLF + 2; const HUNTER: u64 = 0x0000_0000_0000_0A11; const RANGER: u64 = HUNTER + 1; -/// A warlock's summoned Imp, and the guid its owner carries. const IMP: u64 = 0x0000_0000_0000_1117; -/// An authored offensive spell, a heal, a debuff and a self-buff. const NUKE: u32 = 100; const HEAL: u32 = 101; const DEBUFF: u32 = 102; @@ -3266,10 +3063,8 @@ const BEASTS: u32 = 14; const ALLIANCE: u32 = 1; const MAP: u32 = 0; const INSTANCE: u64 = 0; -/// A one-second leg launched at t=0, sampled half way through. const LEG_MS: u32 = 1000; const HALF_WAY: u64 = 500_000; -/// The 1.12 turn-left bit — a movement flag that is deliberately NOT translation. const MOVE_FLAG_TURN_LEFT: u32 = 0x10; fn p(x: f32, y: f32, z: f32) -> Point { @@ -3280,15 +3075,12 @@ fn catch_all() -> TickScope { TickScope::from_rows(crate::creatures::GLOBAL_TICK_INSTANCE, []) } -/// A wolf half way through a 10-yard leg. fn wolf_mid_flight(now_micros: u64) -> Scenario { Scenario::new(now_micros) .creature(WOLF, p(0.0, 0.0, 10.0)) .flying(WOLF, p(0.0, 0.0, 10.0), p(10.0, 0.0, 10.0), 0, LEG_MS) } -/// A route-less wolf awake near a player, with its spawn post at the origin: the shape both -/// walking home and loitering decide on. fn idle_wolf(at: Point, wanders: bool) -> Scenario { Scenario::new(HALF_WAY) .creature(WOLF, at) @@ -3331,8 +3123,6 @@ fn a_creature_mid_flight_moves_to_the_point_its_client_renders() { #[test] fn a_later_pass_reads_the_advanced_position_not_the_leg_start() { - // The wolf renders at (5, 0) half way through its leg. The waypoint BEHIND it is the nearer one - // from the leg start, the waypoint AHEAD is the nearer one from where it actually is. let mut w = wolf_mid_flight(HALF_WAY) .awake([WOLF]) .route(WOLF, &[(1, p(-4.0, 0.0, 10.0)), (2, p(11.0, 0.0, 10.0))]); @@ -3371,7 +3161,7 @@ fn a_patrolling_creature_walks_the_next_segment_of_its_route() { guid: WOLF, start: p(0.0, 0.0, 10.0), dest: p(10.0, 0.0, 10.0), - dur_ms: 4000, // 10 yd at WALK + dur_ms: 4000, spline_id: now_ms, run: false, map_id: MAP, @@ -3394,7 +3184,6 @@ fn a_patrolling_creature_walks_the_next_segment_of_its_route() { #[test] fn a_badly_displaced_patroller_steps_toward_its_waypoint_one_firing_at_a_time() { - // Combat dragged the wolf 300 yd off its route while it was already walking to waypoint 2. let route = [(1, p(5.0, 5.0, 10.0)), (2, p(0.0, 0.0, 10.0))]; let mut w = Scenario::new(HALF_WAY) .creature(WOLF, p(0.0, 300.0, 10.0)) @@ -3421,8 +3210,6 @@ fn a_badly_displaced_patroller_steps_toward_its_waypoint_one_firing_at_a_time() #[test] fn a_displaced_patroller_converges_then_resumes_ordinary_patrolling() { - // Just past the displaced threshold: one stepped firing closes enough of the gap that the next - // one falls back to an ordinary, held-to-completion leg straight to the waypoint. let route = [(1, p(5.0, 5.0, 10.0)), (2, p(0.0, 0.0, 10.0))]; let mut w = Scenario::new(HALF_WAY) .creature(WOLF, p(0.0, 41.0, 10.0)) @@ -3431,7 +3218,7 @@ fn a_displaced_patroller_converges_then_resumes_ordinary_patrolling() { .walking_to(WOLF, 2); let tick = w.tick(true, catch_all()); run_cycle(&mut w, tick); - w.advance_clock(500_000); // the stepped leg's own duration — it has landed + w.advance_clock(500_000); let tick = w.tick(true, catch_all()); run_cycle(&mut w, tick); @@ -3499,11 +3286,10 @@ fn a_creature_that_runs_home_does_not_also_wander_in_the_same_firing() { #[test] fn a_creature_that_reaches_home_loiters_again() { - // Just outside the leash: one run leg lands it back inside, so the NEXT cycle loiters instead. let mut w = idle_wolf(p(0.0, 16.0, 10.0), true).rolls([0, 0, 0]); let tick = w.tick(true, catch_all()); run_cycle(&mut w, tick); - w.advance_clock(500_000); // the run leg's own duration — it has landed + w.advance_clock(500_000); let tick = w.tick(true, catch_all()); run_cycle(&mut w, tick); @@ -3522,7 +3308,6 @@ fn a_creature_that_reaches_home_loiters_again() { #[test] fn an_idle_creature_loiters_near_its_post_on_about_a_third_of_firings() { - // roll 0: below WANDER_CHANCE_PCT, so it hops. Angle 0 and a full radius roll aim due east. for (chance_roll, hops) in [(0u32, true), (99, false)] { let mut w = idle_wolf(p(0.0, 0.0, 10.0), true).rolls([chance_roll, 0, u32::MAX]); let tick = w.tick(true, catch_all()); @@ -3618,7 +3403,6 @@ fn an_engaged_or_suppressed_creature_is_moved_by_no_idle_behavior() { let holds: [fn(Scenario, u64) -> Scenario; 2] = [Scenario::fighting, Scenario::rooted]; for held in holds { for patrols in [true, false] { - // Displaced past its leash and RANDOM-movement, so all three idle movers would want it. let mut w = Scenario::new(HALF_WAY) .creature(WOLF, p(0.0, 20.0, 10.0)) .awake([WOLF]) @@ -3733,8 +3517,6 @@ fn a_new_obstruction_stops_a_retained_path_before_committing_the_next_position() assert_eq!(w.effects()[0].dur_ms, 0); } -/// A wolf the last firing stored at `stored`, 1.5 s along a Route Path from (0, 0) through -/// (10, 0) to (10, 10): 20 yd in 4 s, so its first segment ends 2 s after the start. fn wolf_on_a_route_path(stored: Point) -> Scenario { let w = Scenario::new(1_500_000).creature(WOLF, stored).flying( WOLF, @@ -3756,7 +3538,6 @@ fn assert_near(at: Point, want: Point) { #[test] fn a_stop_after_a_segment_end_holds_the_rendered_point_facing_the_next_segment() { - // The first segment ended at 2 s and the stop comes at 2.3 s, before the next firing. let w = wolf_on_a_route_path(p(7.5, 0.0, 10.0)); let leg = w.legs.borrow()[0].clone(); let stop = stop_on_leg(&w, &leg, 2_300_000).expect("a finite stop"); @@ -3915,8 +3696,6 @@ fn world_maintenance_and_package_passes_run_on_every_catch_all_firing() { } } -/// An awake wolf at the origin and a hostile player it may notice. Both are level 10, so the -/// level-scaled aggro radius is the flat 20 yards. fn wolf_and_player(player_at: Point) -> Scenario { Scenario::new(HALF_WAY) .creature(WOLF, p(0.0, 0.0, 10.0)) @@ -3925,13 +3704,10 @@ fn wolf_and_player(player_at: Point) -> Scenario { .player(HUNTER, player_at) } -/// A scenario twist a table-driven test applies before it runs the cycle. type Twist = fn(Scenario) -> Scenario; -/// One cast the cycle began: who cast, which spell, and at whom. type CastEffect = (u64, u32, u64); -/// A passive pack mate: it answers a call but never notices a player by itself. fn pack_mate(w: Scenario, guid: u64, at: Point) -> Scenario { w.creature(guid, at).tuned_aggro_range(guid, 1) } @@ -3985,7 +3761,6 @@ fn a_creature_outside_every_active_cell_notices_nobody() { #[test] fn a_stealthed_player_is_noticed_only_from_inside_the_detection_range() { - // Level 10 against level 10: a 20-yard aggro radius, but only 5 yards of seeing through stealth. for (dist, noticed) in [(4.0f32, true), (9.0, false)] { let mut w = wolf_and_player(p(dist, 0.0, 10.0)).stealthed(HUNTER); let tick = w.tick(true, catch_all()); @@ -4230,12 +4005,8 @@ fn a_creature_that_pulls_is_in_combat_before_the_same_cycle_ends() { ); } -/// A clock late enough that a unit which has never moved reads as STANDING STILL: chase plants a -/// creature only next to a victim whose move clock has gone quiet for `CHASE_TARGET_MOVING_MS`. const SETTLED: u64 = 4_000_000; -/// A wolf at the origin and a hostile player, on the settled clock — the shape every fight scenario -/// starts from. The wolf is NOT awake: chase is driven by the engagements, not by the sweep. fn wolf_fighting(player_at: Point) -> Scenario { Scenario::new(SETTLED) .creature(WOLF, p(0.0, 0.0, 10.0)) @@ -4302,7 +4073,6 @@ fn a_chaser_rides_one_committed_leg_until_its_victim_veers() { let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); - // The kiter keeps running straight: the chaser is already pointed at it. w.advance_clock(500_000); w = w.kiting(HUNTER, p(23.0, 0.0, 10.0)); let tick = w.tick(false, catch_all()); @@ -4315,7 +4085,6 @@ fn a_chaser_rides_one_committed_leg_until_its_victim_veers() { the committed leg exists to remove" ); - // The kiter cuts away: the held heading no longer points at it. w.advance_clock(500_000); w = w.kiting(HUNTER, p(23.0, 20.0, 10.0)); let tick = w.tick(false, catch_all()); @@ -4336,7 +4105,6 @@ fn a_chaser_rides_one_committed_leg_until_its_victim_veers() { #[test] fn a_creature_that_reaches_a_standing_victim_stops_and_faces_it() { - // Half way through a leg thrown at the player, who has since stopped two yards ahead of it. let launched = SETTLED - 500_000; let mut w = wolf_fighting(p(7.0, 0.0, 10.0)) .facing(WOLF, std::f32::consts::PI) @@ -4386,7 +4154,6 @@ fn a_creature_that_reaches_a_standing_victim_stops_and_faces_it() { #[test] fn a_creature_whose_victim_kites_out_of_reach_keeps_chasing() { - // Inside melee reach, but running: vanilla mobs run a kiter down and swing on the move. let mut w = wolf_fighting(p(4.0, 0.0, 10.0)) .kiting(HUNTER, p(4.0, 0.0, 10.0)) .attacking(WOLF, HUNTER); @@ -4405,12 +4172,8 @@ fn a_creature_whose_victim_kites_out_of_reach_keeps_chasing() { ); } -/// The window the DISPLACED rule read the victim's move clock over. A stop packet lands well inside -/// it, which is why a clock cannot answer "is this player still running". const STALE_MOVE_WINDOW_MS: u32 = 700; -/// The bearings a player walks up to a creature from. Nothing about the answer may depend on which -/// side they stand on. const APPROACHES: [f32; 8] = [ 0.0, std::f32::consts::FRAC_PI_4, @@ -4495,8 +4258,6 @@ fn a_kiter_inside_reach_is_chased_without_a_stop_between_firings() { let mut w = wolf_fighting(p(4.0, 0.0, 10.0)) .kiting(HUNTER, p(4.0, 0.0, 10.0)) .attacking(WOLF, HUNTER); - // Three firings of a straight-line kite: the victim keeps its heading, so the committed leg - // keeps its own. for step in 0..3 { let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); @@ -4525,8 +4286,6 @@ fn a_lead_leg_is_stopped_where_it_renders_once_the_victim_stops() { let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); - // The player releases the key half a firing into the lead leg, inside melee reach of where the - // creature now renders. w.advance_clock(500_000); w = w.stopped(HUNTER); let tick = w.tick(false, catch_all()); @@ -4630,8 +4389,6 @@ fn a_lead_leg_is_replaced_when_the_victim_stops_short_of_reach() { let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); - // The player releases the key at 23 yd, still well outside melee reach, half a firing into - // the lead leg the first cycle threw at the old heading. w.advance_clock(500_000); w = w.kiting(HUNTER, p(23.0, 0.0, 10.0)).stopped(HUNTER); let tick = w.tick(false, catch_all()); @@ -4668,8 +4425,6 @@ fn a_lead_leg_is_replaced_when_the_victim_stops_short_of_reach() { lead leg through the player" ); - // Ride the replacement out: the creature must never pass the victim it stopped short of, and - // the approach must end on the one facing spline the stop writes at the stop point. for _ in 0..8 { w.advance_clock(500_000); let tick = w.tick(false, catch_all()); @@ -4699,10 +4454,6 @@ fn a_lead_leg_is_replaced_when_the_victim_stops_short_of_reach() { #[test] fn a_creature_victim_with_a_leg_in_flight_is_chased_like_a_kiting_player() { - // A creature or pet victim carries no move flags of its own — the leg the cycle is already - // moving it along is what says it travels. Same shape as a kiting player: run it down with a - // lead rather than planting into attack stance, which is the run/stand/run flicker chase exists - // to remove. let mut w = Scenario::new(SETTLED) .creature(WOLF, p(0.0, 0.0, 10.0)) .creature(PACK_MATE, p(4.0, 0.0, 10.0)) @@ -4781,8 +4532,6 @@ fn an_offensive_caster_holds_at_its_spell_range() { #[test] fn a_caster_whose_victim_stops_inside_reach_turns_and_halts_its_lead() { - // Half way through a leg thrown at the player, who has since stopped two yards ahead of it, - // well inside the 30 yd the caster would otherwise hold at. let launched = SETTLED - 500_000; let mut w = wolf_fighting(p(7.0, 0.0, 10.0)) .caster(WOLF, 30.0) @@ -4843,8 +4592,6 @@ fn a_blocked_chase_goes_around_instead_of_through() { ); } -/// A wolf fighting the hunter with ONE authored spell and 30 yards of spell range — the caster shape -/// the cast phase decides on. The cast phase is a sensing phase, so its scenarios fire `sense`. fn wolf_casting(player_at: Point) -> Scenario { wolf_fighting(player_at) .attacking(WOLF, HUNTER) @@ -4905,7 +4652,6 @@ fn a_caster_that_cannot_act_or_is_already_casting_begins_nothing() { #[test] fn the_rotation_fires_its_highest_priority_ready_action() { - // A heal it only wants below half health, over a nuke it can always throw. let rotation = |w: Scenario| { w.rotation_line(WOLF, HEAL, CastWhen::Hurt(50), 10) .rotation_line(WOLF, NUKE, CastWhen::Always, 1) @@ -5095,13 +4841,10 @@ fn a_taunt_pins_the_target_whatever_the_threat_table_says() { } } -/// A wolf swinging at a hunter standing INSIDE melee reach and already faced: chase decides to stand -/// still, so every movement effect in these scenarios is the rout's or fear's own. fn wolf_cornered() -> Scenario { wolf_fighting(p(3.0, 0.0, 10.0)).attacking(WOLF, HUNTER) } -/// What a healthy creature's run leg over the committed rout distance would take. fn healthy_rout_ms() -> u32 { (FLEE_LEG_YD / lyracore_shared::constants::speeds::RUN * 1000.0) as u32 } @@ -5140,7 +4883,6 @@ fn a_wounded_runner_breaks_off_and_sprints_away_from_its_victim() { #[test] fn a_creature_that_fights_to_the_death_never_enters_the_rout_path() { - // Identically wounded, but a beast/undead/elemental or a pet: it stands and swings. let mut w = wolf_cornered().hurt(WOLF, 10); let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); @@ -5174,7 +4916,6 @@ fn a_router_rides_its_committed_leg_and_re_rolls_only_once_it_ends() { "a router must stay ENGAGED while it runs, or it can be neither chased down nor killed" ); - // The dash lands, and the window is still open: the next one is rolled from where it stopped. w.advance_clock(dur_ms as u64 * 1000); let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); @@ -5193,7 +4934,6 @@ fn a_spent_rout_returns_the_creature_to_chasing_and_never_opens_a_second() { run_cycle(&mut w, tick); let opened_at = w.rout_ends_ms(WOLF); - // Past the window: the dash has landed and the creature is an ordinary attacker again. w.advance_clock(ROUT_DURATION_MS as u64 * 1000 + 1_000_000); let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); @@ -5309,7 +5049,7 @@ fn a_crowd_controlled_router_keeps_its_state_and_resumes_when_the_control_ends() window here is a creature that stands still for good once the control lifts" ); - w.frozen.borrow_mut().remove(&WOLF); // the root expires + w.frozen.borrow_mut().remove(&WOLF); w.advance_clock(500_000); let tick = w.tick(false, catch_all()); run_cycle(&mut w, tick); @@ -5321,8 +5061,6 @@ fn a_crowd_controlled_router_keeps_its_state_and_resumes_when_the_control_ends() ); } -/// A warlock at the origin with its Imp beside it, on the settled clock. The pet phase is a SENSING -/// phase, so every scenario below fires `sense`. fn warlock_and_imp(imp_at: Point) -> Scenario { Scenario::new(SETTLED) .player(HUNTER, p(0.0, 0.0, 10.0)) @@ -5612,13 +5350,10 @@ fn a_pet_whose_owner_changed_instance_is_snapped_across_rather_than_walked() { ); } -/// The `SETTLED` clock in ms — what a combat-drop deadline is measured against. const SETTLED_MS: u64 = SETTLED / 1000; #[test] fn a_creature_that_engaged_and_chased_this_cycle_is_not_healed_by_the_same_cycle() { - // The fight is armed by the aggro phase of THIS cycle, so regeneration's gate can only see it - // by running after that phase. let mut w = wolf_fighting(p(15.0, 0.0, 10.0)) .awake([WOLF]) .hurt(WOLF, 10); @@ -5670,8 +5405,6 @@ fn a_unit_in_combat_heals_only_through_a_combat_regen_aura() { ("carrying Troll Regeneration", |w| w.combat_regen(WOLF), 11), ]; for (case, twist, health) in cases { - // Flagged in combat with no melee row of its own: a pure caster, which is the half of the - // in-combat verdict the engagement rows do not answer. let mut w = twist( Scenario::new(SETTLED) .creature(WOLF, p(0.0, 0.0, 10.0)) @@ -5732,8 +5465,6 @@ fn combat_entry_outlasts_combat_exit_in_the_same_cycle() { #[test] fn a_creature_the_leash_dropped_walks_home_and_leaves_combat_in_one_cycle() { - // The leash pass in `tick_melee` already evaded this creature: its engagement is gone and its - // combat deadline has run out. What is left is what the cycle owns. let mut evaded = Scenario::new(SETTLED) .creature(WOLF, p(0.0, 20.0, 10.0)) .awake([WOLF]) @@ -5758,7 +5489,6 @@ fn a_creature_the_leash_dropped_walks_home_and_leaves_combat_in_one_cycle() { healed by the same firing that released it recovers a tick early for free" ); - // The same creature with the pursuit still live: the fight owns it, so nothing walks it home. let mut pursuing = Scenario::new(SETTLED) .creature(WOLF, p(0.0, 20.0, 10.0)) .awake([WOLF]) @@ -5818,17 +5548,9 @@ fn regeneration_and_combat_exit_run_only_on_the_catch_all_sense_firing() { } } -// ================================================================================================ -// COVERAGE, CADENCE AND COST — what one firing is allowed to touch, and how often -// ================================================================================================ - -/// The instance that has a schedule row of its own in the partition scenarios. const DEDICATED: u64 = 7; -/// A third player, so each instance's fight has its own victim. const WARDEN: u64 = RANGER + 1; -/// One fight per instance — the open world, the dedicated-row instance, and a third instance with -/// no row of its own. Every creature stands 15 yards from its victim, so chase moves all three. fn one_fight_per_instance() -> Scenario { Scenario::new(SETTLED) .creature(WOLF, p(0.0, 0.0, 10.0)) @@ -5862,7 +5584,6 @@ fn the_catch_all_firing_covers_every_instance_exactly_once() { #[test] fn a_dedicated_row_and_the_catch_all_stay_a_strict_partition() { let mut w = one_fight_per_instance(); - // The catch-all skips the instance holding a row of its own; that row then fires for it. let tick = w.tick( false, TickScope::from_rows( @@ -5901,12 +5622,9 @@ fn a_dedicated_row_and_the_catch_all_stay_a_strict_partition() { #[test] fn movement_runs_every_firing_while_sensing_holds_the_four_second_cadence() { - // A 100ms dedicated row and the seeded 500ms world row, over the SAME closed four seconds - // (t = 0 through 4s), so both are compared at the same instant. let mut settled_at = Vec::new(); for (interval_micros, firings) in [(100_000i64, 41u64), (crate::creatures::MOVE_TICK_MICROS, 9)] { - // A minute-long leg, so there is movement work left on every firing of the window. let mut w = Scenario::new(0).creature(WOLF, p(0.0, 0.0, 10.0)).flying( WOLF, p(0.0, 0.0, 10.0), @@ -5943,9 +5661,6 @@ fn movement_runs_every_firing_while_sensing_holds_the_four_second_cadence() { #[test] fn discovery_stays_on_the_narrow_candidate_universes() { - // A crowded world: twelve creatures, of which two are awake near the player and exactly one is - // fighting. The counts below are what an operator greps for; a phase that regressed to a - // full-world scan reports twelve here and keeps every behavior test green. let mut w = Scenario::new(SETTLED) .creature(WOLF, p(0.0, 0.0, 10.0)) .creature(PACK_MATE, p(60.0, 0.0, 10.0)) diff --git a/module/src/creatures/cycle/harness/eventai_combat.rs b/module/src/creatures/cycle/harness/eventai_combat.rs index 87eab4fee..d6375fec1 100644 --- a/module/src/creatures/cycle/harness/eventai_combat.rs +++ b/module/src/creatures/cycle/harness/eventai_combat.rs @@ -1,4 +1,3 @@ -//! Engaged EventAI scenarios. use super::*; use crate::creatures::eventai::*; @@ -1496,8 +1495,6 @@ fn a_refused_interrupting_start_preserves_the_pending_cast() { #[test] fn an_authored_flee_runs_a_creature_the_fixed_rout_would_leave_standing() { - // A beast at 30% health: below no flee threshold and of a kind that fights to the death, so - // nothing but the authored rule can break it off. let mut scenario = world().hurt(CREATURE, 30).eventai_row(row( 509_0211, 509_0211, @@ -1515,8 +1512,6 @@ fn an_authored_flee_runs_a_creature_the_fixed_rout_would_leave_standing() { "the authored action is the only thing that can open this creature's rout window" ); - // The rout pass runs BEFORE the eventai pass in the firing order, so the window opened above is - // read on the next firing. fire(&mut scenario); let legs = scenario.effects(); @@ -1534,9 +1529,6 @@ fn an_authored_flee_runs_a_creature_the_fixed_rout_would_leave_standing() { #[test] fn a_repeat_authored_flee_runs_the_creature_again_later_in_the_fight() { - // The rule repeats every 15 s and each rout window runs 10 s, so the second firing lands on a - // window that is stamped but SPENT. It must re-stamp rather than treat the spent window as the - // fixed rout's once per Engagement. let mut scenario = world().hurt(CREATURE, 30).eventai_row(row( 509_0214, 509_0214, @@ -1548,13 +1540,13 @@ fn a_repeat_authored_flee_runs_the_creature_again_later_in_the_fight() { [0; 3], )); - fire(&mut scenario); // the rule stamps the first window - fire(&mut scenario); // the rout pass reads it: the first flee leg + fire(&mut scenario); + fire(&mut scenario); - scenario.advance_clock(15_000_000); // the first window and the repeat wait are both over - fire(&mut scenario); // the rule fires onto the spent window and re-stamps it - scenario.advance_clock(5_000_000); // the chase leg thrown while the window was spent lands - fire(&mut scenario); // the re-stamped window runs the creature again + scenario.advance_clock(15_000_000); + fire(&mut scenario); + scenario.advance_clock(5_000_000); + fire(&mut scenario); let flee_legs: Vec<_> = scenario .effects() diff --git a/module/src/creatures/cycle/harness/eventai_death.rs b/module/src/creatures/cycle/harness/eventai_death.rs index 01b3e2204..da9b07803 100644 --- a/module/src/creatures/cycle/harness/eventai_death.rs +++ b/module/src/creatures/cycle/harness/eventai_death.rs @@ -1,5 +1,3 @@ -//! EventAI death-authority scenarios. - use super::*; use crate::creatures::eventai::*; diff --git a/module/src/creatures/cycle/harness/eventai_edges.rs b/module/src/creatures/cycle/harness/eventai_edges.rs index cc40df623..3e3b0dc2b 100644 --- a/module/src/creatures/cycle/harness/eventai_edges.rs +++ b/module/src/creatures/cycle/harness/eventai_edges.rs @@ -1,4 +1,3 @@ -//! Spawn and death EventAI scenarios. use super::*; use crate::creatures::eventai::*; use crate::creatures::presentation::NpcFlagsProjection; @@ -496,7 +495,6 @@ fn an_overlong_line_is_capped_like_any_other_chat() { edge(&mut scenario, EventKind::OnAggro, false); - // Vanilla caps client chat input around 255 characters; the stored row is bounded to match. assert_eq!( scenario .eventai_speech() diff --git a/module/src/creatures/cycle/harness/eventai_legacy.rs b/module/src/creatures/cycle/harness/eventai_legacy.rs index 6bbc6e254..e8288992f 100644 --- a/module/src/creatures/cycle/harness/eventai_legacy.rs +++ b/module/src/creatures/cycle/harness/eventai_legacy.rs @@ -1,5 +1,3 @@ -//! Compatibility helpers for tests that still construct the retired flat migration row. - use crate::creatures::eventai::{CreatureAiEvent, EventKind}; pub(crate) const EVENT_ON_AGGRO: u8 = 0; @@ -297,9 +295,6 @@ impl Rule { } else { event_params(first) }; - // A timer rule waits its initial window before its first firing; every recurring kind - // carries its repeat window in params 3 and 4. An inverted window is a bad row, not a - // fixed cadence: left to the roller it wraps the delay to weeks, so it surfaces here. let inverted_initial = event == EventKind::TimedInCombat && decoded_event_params[0] > decoded_event_params[1]; let inverted_repeat = diff --git a/module/src/creatures/cycle/harness/eventai_mobility.rs b/module/src/creatures/cycle/harness/eventai_mobility.rs index a84ed5932..4eafcf218 100644 --- a/module/src/creatures/cycle/harness/eventai_mobility.rs +++ b/module/src/creatures/cycle/harness/eventai_mobility.rs @@ -1,5 +1,3 @@ -//! EventAI summon and ranged-posture scenarios. - use super::*; use crate::creatures::eventai::*; @@ -237,9 +235,6 @@ fn a_summon_guid_carries_the_entry_and_the_first_sequence_of_the_eventai_band() let [summoned] = summoned_guids.as_slice() else { panic!("expected one summon"); }; - // The wave-guid layout: the template entry in the high 24 bits of the low 48, the low 24 bits - // for the band and sequence. The EventAI band is 0x40_0000 and this world's first summon takes - // sequence 1. assert_eq!((summoned >> 24) & 0xFF_FFFF, u64::from(SUMMON_ENTRY)); assert_eq!(summoned & 0xFF_FFFF, 0x40_0000 | 1); } @@ -327,8 +322,6 @@ fn missing_summon_catalogue_or_template_fails_without_a_partial_creature() { #[test] fn a_creature_standing_on_its_authored_hold_point_turns_to_its_victim() { - // Placed exactly where the 10 yd / 90° posture holds it, looking the other way: the shape a - // victim who ran a quarter circle around it leaves behind. let mut scenario = Scenario::new(1_000_000) .creature(CREATURE, point(20.0, 10.0)) .entry(CREATURE, ENTRY) diff --git a/module/src/creatures/cycle/harness/eventai_profile.rs b/module/src/creatures/cycle/harness/eventai_profile.rs index 3ad3a795f..2e46294de 100644 --- a/module/src/creatures/cycle/harness/eventai_profile.rs +++ b/module/src/creatures/cycle/harness/eventai_profile.rs @@ -1,5 +1,3 @@ -//! Canonical-profile EventAI capability scenarios. - use super::*; use crate::creatures::eventai::*; diff --git a/module/src/creatures/cycle/harness/eventai_quest_credit.rs b/module/src/creatures/cycle/harness/eventai_quest_credit.rs index 6f5083941..4be6e25bb 100644 --- a/module/src/creatures/cycle/harness/eventai_quest_credit.rs +++ b/module/src/creatures/cycle/harness/eventai_quest_credit.rs @@ -1,5 +1,3 @@ -//! Quest-credit EventAI scenarios. - use super::*; use crate::creatures::eventai::*; use crate::quest::{ diff --git a/module/src/creatures/cycle/harness/eventai_tracer.rs b/module/src/creatures/cycle/harness/eventai_tracer.rs index 939c92528..ba1d761c0 100644 --- a/module/src/creatures/cycle/harness/eventai_tracer.rs +++ b/module/src/creatures/cycle/harness/eventai_tracer.rs @@ -1024,8 +1024,8 @@ fn an_ordinary_refused_cast_spends_its_repeat_opportunity() { )) .not_ready(92); - fire(&mut scenario); // arms the timer - fire(&mut scenario); // due, and the cast is refused + fire(&mut scenario); + fire(&mut scenario); assert!(scenario.casts().is_empty()); assert_eq!( diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index e8a871d12..cfe6d81bc 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -1,13 +1,3 @@ -//! The creature BEHAVIOR CYCLE: one scheduled firing's complete ordered transition, from creature -//! world state to updated state plus emitted movement effects. -//! -//! [`run_cycle`] is the only place that knows the pass order. `tick_creatures` resolves -//! authorization, coverage and cadence, builds a [`TickContext`], calls this once, and logs the -//! [`CycleOutcome`]. Nothing outside this module may compose or reorder behavior passes. -//! -//! Two adapters wear the world trait and neither escapes: [`ctx::CtxWorld`] over a real -//! `ReducerContext`, and `harness::Scenario` in memory for the tests. - use std::collections::HashSet; use lyracore_shared::constants; @@ -32,7 +22,6 @@ mod harness; pub(crate) use ctx::{run, stop_on_stored_leg}; -/// A world point a behavior decision moves a creature to. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Point { pub x: f32, @@ -40,8 +29,6 @@ pub(crate) struct Point { pub z: f32, } -/// A creature's movement leg, currently playing on the client. `mover_gone` marks an ORPHAN: the -/// creature despawned mid-leg, so the leg is only waiting to be reaped. #[derive(Clone, PartialEq, Debug)] pub(crate) struct LegInFlight { pub guid: u64, @@ -56,8 +43,6 @@ pub(crate) struct LegInFlight { } impl LegInFlight { - /// Where the mover RENDERS at `now_micros` (the same lerp the client runs), and whether the leg - /// has landed. Computed absolutely from the leg start, so skipped firings never drift it. fn rendered_at(&self, now_micros: u64) -> (Point, bool) { let t = spline_t(now_micros, self.started_micros, self.dur_ms); if !self.waypoints.is_empty() { @@ -83,8 +68,6 @@ impl LegInFlight { (at, t >= 1.0) } - /// The heading from `at` toward the waypoint the mover walks to at `now_micros`, as a leg - /// advance turns it. `None` off a Route Path and once the path has landed. fn heading_on_path(&self, at: Point, now_micros: u64) -> Option { let (_, next) = lyracore_shared::movement_path::sample( (self.start.x, self.start.y, self.start.z), @@ -97,19 +80,12 @@ impl LegInFlight { } } -/// Where a stop between firings leaves a mover. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Stop { pub at: Point, - /// The heading along the Route Path segment the mover is on. `None` keeps its heading. pub heading: Option, } -/// Where a stop at `now_micros` leaves `leg`'s mover, by the rules a leg advance follows then. The -/// mover stops on the point the client renders, facing along its Route Path. If the advance would -/// halt the path instead (a blocked segment or changed navigation inputs), the stop takes the halt -/// point, so it never lands past an obstruction. `None` for a non-finite point: the mover keeps -/// its stored position. pub(crate) fn stop_on_leg(w: &impl MotionSink, leg: &LegInFlight, now_micros: u64) -> Option { let (at, _) = leg.rendered_at(now_micros); if let Some(halted) = w.path_obstruction(leg, now_micros) { @@ -124,159 +100,103 @@ pub(crate) fn stop_on_leg(w: &impl MotionSink, leg: &LegInFlight, now_micros: u6 }) } -/// Everything ONE firing knows before any behavior runs: when it fires, how far a creature travels -/// in it, whether it senses, and which instances it covers. It never names a pass. pub(crate) struct TickContext { pub now_micros: u64, pub now_ms: u32, - /// Movement step length for this firing, derived from the firing row's own interval. pub tick_secs: f32, - /// Do the ~4s-quantized sensing passes run this firing? pub sense: bool, - /// Seconds between this row's SENSE firings. A leg decided on a sensing pass has to bridge to - /// the next one, so it is sized from this rather than from `tick_secs`. pub sense_secs: f32, pub scope: TickScope, } -/// What one cycle changed, as the operator log reads it: how many creatures the active-cell sweep -/// woke, and the rows each pass did real per-candidate work on, in pass order. A candidate-set -/// regression shows up here as a count that grows where it must not. pub(crate) struct CycleOutcome { pub awake: usize, pub rows_visited: Vec<(&'static str, u64)>, } -/// Spline advance's surface: read every leg in flight, then move, halt or forget it. pub(crate) trait MotionSink { fn legs_in_flight(&self) -> Vec; - /// A retained path blocked by current geometry stops at its last authoritative position. fn path_obstruction(&self, leg: &LegInFlight, now_micros: u64) -> Option; - /// Is this creature rooted, stunned, polymorphed or fear-frozen — unable to move itself? fn movement_suppressed(&self, guid: u64) -> bool; - /// Move the creature to `at` — position, grid address and packed cell in one write — and stamp - /// its move clock at `moved_ms`. Every later pass reads the moved row; the production adapter - /// may keep a short advance out of the stored row when the firing ends - /// (`tick::advance_needs_persist`). fn commit_position(&mut self, guid: u64, at: Point, moved_ms: u32); - /// Stop this mover at `at` and tell the client to halt there: the position moves, the move clock - /// does not, and the emitted leg has zero duration, REPLACING the leg it interrupts so the next - /// cycle reads it as landed. A mover frozen mid-leg by crowd control ends this way; a chaser - /// that reached its victim stops through [`PursuitSink::face`], which also turns it. fn halt(&mut self, leg: &LegInFlight, at: Point, spline_id: u32); - /// Forget this creature's leg — arrived, orphaned or refused. fn drop_leg(&mut self, guid: u64); } -/// A creature the idle movers may consider this firing: awake in an active cell, alive, and not a -/// player. `patrols` picks the anchor — a creature with a route walks it, one without falls back to -/// its spawn post. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct IdleCreature { pub guid: u64, pub at: Point, - /// The route segment or wander hop still animating; no new idle leg starts until it lands. pub leg_ends_ms: u32, - /// Route cursor — the waypoint this creature walks TO. Unset or stale re-acquires the nearest. pub wp_target: u64, pub patrols: bool, } -/// One waypoint of a patrol route. `id` is the cursor's identity and the route's traversal order. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Waypoint { pub id: u64, pub at: Point, } -/// A creature's spawn post: where it belongs, and whether it free-wanders around it. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Home { pub at: Point, pub wanders: bool, } -/// How a creature travels a leg. Idle movement walks; going home runs. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum Gait { Walk, Run, } -/// One movement leg a behavior decided, ready to send. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Leg { pub to: (f32, f32), - /// Landing height where terrain is unimported — the waypoint's or the post's own z. pub z_fallback: f32, pub dur_ms: u32, pub gait: Gait, - /// Hold the leg to completion (the idle ETA gate) instead of re-deciding it next firing. pub hold_until_landed: bool, } -/// Idle movement's surface: walk a route, walk home, loiter near the post. pub(crate) trait IdleSink { - /// The awake creatures idle movement may move at all: alive, not players. Read fresh per phase — - /// the passes between patrol and the return/wander phase engage creatures and move them. fn idle_creatures(&self, active: &HashSet) -> Vec; - /// This creature's patrol route; empty for a creature that does not patrol. fn route_of(&self, guid: u64) -> Vec; - /// A paused patrol retains its route cursor and emits no leg until resumed. fn patrol_paused(&self, _guid: u64) -> bool { false } - /// An authored stationary intent suppresses spawn loitering without changing the spawn row. fn idle_stationary(&self, _guid: u64) -> bool { false } - /// A pending evade return outranks every authored idle intent. fn returning_home(&self, _guid: u64) -> bool { false } - /// The live position of the unit this creature is following. fn follow_target_at(&self, _guid: u64) -> Option { None } - /// One queued facing operation, resolved to an orientation in the creature's partition. fn pending_facing(&self, _guid: u64) -> Option { None } fn clear_pending_facing(&mut self, _guid: u64) {} - /// Replace the default gait only for the movement generator named by the authored operation. fn idle_gait(&self, _guid: u64, default: Gait) -> Gait { default } fn wander_radius(&self, _guid: u64) -> f32 { WANDER_RADIUS } - /// Where this creature spawned; `None` for one with no post to return to. fn home_of(&self, guid: u64) -> Option; - /// The spawn post used by evade return, before an authored idle anchor can resume. fn return_home_of(&self, guid: u64) -> Option { self.home_of(guid) } - /// Is this creature in a fight? Chase and rout own its movement if so. fn engaged(&self, guid: u64) -> bool; - /// Yards per second at `gait`, after snares. fn speed_of(&self, guid: u64, gait: Gait) -> f32; - /// Aim from where the creature stands toward `to`, travelling at most `max_step` yards and - /// stepping around whatever blocks the straight line. fn navigate(&self, guid: u64, to: (f32, f32), max_step: f32) -> (f32, f32); - /// One roll from the world's random stream. fn roll(&self) -> u32; - /// Move the route cursor to `waypoint_id` without moving the creature. fn aim_at_waypoint(&mut self, guid: u64, waypoint_id: u64); - /// Complete a pending evade return after the creature enters its home radius. fn reached_home(&mut self, guid: u64); - /// Send the creature on `leg`: ground-snap the landing point, relay the leg to the clients that - /// can see it, and stamp the move clock (plus the ETA gate for a held leg). fn commit_leg(&mut self, guid: u64, leg: Leg, now_ms: u32); } -/// A player a creature could notice this firing. `dead` and `godmode` travel on the view rather -/// than being filtered away by the adapter, because "who is worth noticing" is a behavior rule the -/// cycle owns — see [`is_aggro_candidate`]. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct AggroTarget { pub guid: u64, @@ -287,12 +207,9 @@ pub(crate) struct AggroTarget { pub instance_id: u64, pub dead: bool, pub godmode: bool, - /// Stealthed: seen only from inside this creature's own, much shorter, detection range. pub stealthed: bool, } -/// A creature that may start a fight this firing: awake in an active cell, alive, not a pet, and -/// not already swinging at someone. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Sensor { pub guid: u64, @@ -301,149 +218,85 @@ pub(crate) struct Sensor { pub faction_template: u32, pub map_id: u32, pub instance_id: u64, - /// The template's hand-tuned aggro range (0 = the level-scaled default), or `None` when the - /// template row is missing: such a creature notices nobody, but still answers a pack call. pub aggro_range: Option, - /// Mind Soothe and friends — a signed yard change to this creature's own detection radius. pub detect_range_mod: f32, - /// Near death and of a kind that runs. Engaging it would only drag it back into the fight it - /// is about to flee. pub would_rout: bool, - /// Stunned, polymorphed or feared. It cannot act, so it neither pulls nor answers a call. pub cannot_act: bool, - /// The authored EventAI reaction policy for sight pulls and pack assistance. pub react_state: crate::creatures::CreatureReactState, } -/// Why a creature entered a fight, as the world hook reports it. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum Pull { - /// It noticed the player itself. Noticed, - /// It answered a pack mate's call. Assisted, } -/// One live melee engagement, as combat entry reads it. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Engagement { pub attacker: u64, pub victim: u64, - /// The pair's instance. Both sides share one by construction, so either side resolves it. pub instance_id: u64, - /// A player's auto-attack toggle that has never landed a swing. Aiming at something out of - /// range is not combat, and stopping the toggle leaves the player as un-engaged as before. pub player_never_swung: bool, } -/// A unit still carrying the in-combat flag, and the moment that flag stops being earned. Every -/// hostile action pushes the deadline forward, so a live fight never reaches it. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Combatant { pub guid: u64, pub combat_until_ms: u64, } -/// Engagement's surface: who can notice whom, the fight that follows, and both ends of the combat -/// flag that fight raises. pub(crate) trait EngageSink { - /// Every player in the world, with stealth already resolved. fn players(&self) -> Vec; - /// The awake creatures that could start a fight. Read FRESH per phase: a creature that pulled - /// during the aggro phase is now swinging, so it must not also answer a pack call. fn sensing_creatures(&self, active: &HashSet) -> Vec; - /// Do these two faction templates count each other as enemies? Missing data is never hostile. fn hostile(&self, faction_template: u32, other: u32) -> bool; - /// Can `looker` see the point `at`? A wall between them stops a pull and a pack call alike. fn line_of_sight(&self, looker: u64, at: Point) -> bool; - /// Arm the fight: the creature swings at `victim`, faces it, and the world is told. fn engage(&mut self, creature: u64, victim: u64, pull: Pull); - /// Every live melee engagement, player-started ones included. fn engagements(&self) -> Vec; - /// Flag the unit in combat and push its combat-drop deadline out. fn enter_combat(&mut self, guid: u64); - /// Which of `candidates` still carry the flag, and until when. Read LIVE: the list was harvested - /// before this cycle's combat entry ran, so a unit on it may have been re-stamped since. fn flagged_in_combat(&self, candidates: &[u64]) -> Vec; - /// Drop the combat flag, and nothing else: the unit keeps its target, its threat and any fight - /// it is still in. Only the flag observers read is cleared. fn leave_combat(&mut self, guid: u64); } -/// A unit regeneration considers this firing: the two bars, and whether a fight gates them. Health -/// and power ride ONE candidate because they read the same in-combat verdict. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Recovering { pub guid: u64, pub health: u32, pub max_health: u32, pub power: u32, - /// 0 for a creature — only a unit that has a bar ticks power at all. pub max_power: u32, - /// In a live engagement, or flagged in combat without one (a pure caster, a Bloodrage warrior). pub in_combat: bool, } -/// Regeneration's surface. The RATES stay in `combat::tables`: the cycle owns when recovery happens -/// and which bar is due, never how much of it. PLAYERS are candidates too — their bars are not -/// creature behavior, but nothing else ticks them. pub(crate) trait RegenSink { - /// Every live unit that could recover this firing: hurt, or carrying a power bar. `flagged` is - /// the sweep's in-combat harvest, which the world completes with the live engagement rows. fn recovering(&self, flagged: &[u64]) -> Vec; - /// The health this unit recovers to out of combat, at its own spirit- and level-scaled rate. fn healed_to(&self, u: &Recovering) -> u32; - /// The health it recovers to while fighting through a combat-regen aura or flat item HP5. - /// `None` means the unit has neither source. fn combat_healed_to(&self, u: &Recovering) -> Option; - /// The power it ticks to, up or down: mana once the five-second rule lapses, energy always, - /// rage decaying out of combat. fn powered_to(&self, u: &Recovering) -> u32; - /// Write recovered health and power onto the LIVE row — those two fields and nothing else. A leg - /// committed earlier in this cycle has to survive, and writing back a whole snapshot row would - /// revert the position it moved to. fn restore(&mut self, guid: u64, health: Option, power: Option); } -/// An engaged creature the cast phase considers, with everything a rotation condition reads. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Caster { pub guid: u64, - /// The unit it is swinging at — every offensive line of the rotation aims here. pub victim: u64, - /// Where the victim stands, for the one line-of-sight verdict cast and chase share. `None` when - /// the victim row is gone: nothing blocks a line to a unit that is not there. pub victim_at: Option, - /// Spell rank comes from the caster's level. pub level: u8, pub health: u32, pub max_health: u32, - /// Stunned, polymorphed or feared. A ROOTED caster still casts — ranged needs no movement. pub cannot_act: bool, - /// Already mid-cast. Beginning a second cast deletes and restarts the first, so a creature that - /// re-entered every firing would never finish one. pub casting: bool, } -/// What one authored rotation line waits for before it fires. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum CastWhen { - /// A nuke or debuff at the victim: eligible whenever the spell itself is ready. Always, - /// A heal on itself, below this percentage of its own health. Hurt(u32), - /// A debuff the victim does not carry yet. VictimLacksIt, - /// A buff the caster has let lapse. SelfLacksIt, - /// A condition this server does not understand. The line is authored but never fires, and it - /// still counts as a rotation — a creature that has one does not fall back to its lone spell. Never, } impl CastWhen { - /// Read an authored rotation condition. Kept here rather than in the adapter so the whole - /// rotation grammar, unknown discriminants included, is one cycle-owned decision. fn of(condition: u8, condition_value: u8) -> CastWhen { match condition { cast_condition::ALWAYS => CastWhen::Always, @@ -455,41 +308,25 @@ impl CastWhen { } } -/// One line of a creature's spell rotation: which spell, and what it waits for. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct SpellOption { pub spell_id: u32, pub when: CastWhen, - /// Tried highest first. pub priority: u8, - /// Authoring order — the tie-break that keeps a same-priority pair deterministic. pub authored: u64, } -/// Casting's surface: who is engaged, what it may cast, and "begin this cast". The cycle picks WHICH -/// spell and WHETHER to cast; resolution, cooldown, power cost and aura application stay behind -/// [`CastSink::begin_cast`], in the spell module. pub(crate) trait CastSink { - /// Every engaged creature this firing covers whose attacker could cast — the same engagement - /// rows and the same gate ladder chase opens with. fn casters(&self, scope: &TickScope) -> Vec; - /// This creature's authored rotation, in no particular order; empty for one with no rotation. fn rotation_of(&self, guid: u64) -> Vec; - /// An independent Creature Spell List. The current world has no supplier, but EventAI casting - /// acceptance must not suppress one when a source adds it. fn creature_spell_list_of(&self, _guid: u64) -> Vec { Vec::new() } - /// The single spell a creature with no rotation casts at its victim. fn lone_spell(&self, guid: u64) -> Option; - /// Does this unit already carry `spell_id`'s aura? The missing-aura conditions read it. fn carries(&self, guid: u64, spell_id: u32) -> bool; - /// Begin `spell_id` at `target`. `false` means the spell was not ready — global cooldown, its own - /// cooldown, power cost or range — so the next line of the rotation gets its turn. fn begin_cast(&mut self, caster: &Caster, spell_id: u32, target: u64) -> bool; } -/// One engaged creature and the unit it currently fights — the pairing threat may overturn. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Fighter { pub guid: u64, @@ -497,51 +334,27 @@ pub(crate) struct Fighter { } pub(crate) trait ThreatSink { - /// Every engaged creature this firing covers, with the unit it is fighting now. fn fighters(&self, scope: &TickScope) -> Vec; - /// The unit a live taunt lock pins this creature on, regardless of the threat table. fn taunted_onto(&self, guid: u64) -> Option; - /// Its highest-threat valid source; `None` for an empty or all-invalid table. fn top_threat(&self, guid: u64) -> Option; - /// How much threat `source` holds on `creature`; a source absent from the table holds none. fn threat_on(&self, creature: u64, source: u64) -> i64; - /// Point the creature at `victim` — the engagement and the creature's own target together, and - /// nothing else: the swing clock stays where it was, so a retarget does not refresh the swing. fn retarget(&mut self, creature: u64, victim: u64); } -/// One creature closing on the unit it fights, with everything that decision reads: where both -/// stand, whether the victim is travelling, and the leg the chaser is already running. #[derive(Clone, PartialEq, Debug)] pub(crate) struct Pursuit { pub guid: u64, pub at: Point, - /// Where the creature currently looks — the facing turn is idempotent against it. pub orientation: f32, pub victim_at: Point, - /// Where the victim looks. An authored nonzero ranged angle is relative to this facing. pub victim_orientation: f32, - /// The victim's LIVE movement flags, read through `combat::is_translating`. A kiter is run down - /// on the move; one that is standing — turning in place included — is stood next to and faced. - /// A move CLOCK cannot answer this: it still reads "just moved" for most of a second after the - /// player released the key, which is long enough to throw a lead leg straight through them. pub victim_movement_flags: u32, - /// Inside an open rout window: the rout leg is this creature's sole mover this firing. pub routing: bool, - /// The leg it is already riding. Both the re-aim decision and the stop read it. pub leg: Option, } -/// Chase's surface: the fights worth closing, how far a caster fights from, and the stop-and-turn -/// that ends an approach. It shares the movers the idle phases use — `speed_of`, `navigate`, -/// `commit_leg` — so a chase leg is written by the one leg writer like every other. pub(crate) trait PursuitSink { - /// Every live engagement this firing covers whose attacker is a creature that could move and - /// whose victim is still in the world. NOT scoped to the active-cell sweep: a fight dragged out - /// of every active cell must keep running. fn pursuits(&self, scope: &TickScope) -> Vec; - /// The EventAI hold distance and angle, in yards and radians. `Some((0, 0))` explicitly - /// restores melee posture; `None` leaves the creature's existing caster behavior unchanged. fn authored_ranged_posture(&self, guid: u64) -> Option<(f32, f32)>; fn combat_movement_enabled(&self, _guid: u64) -> bool { true @@ -550,116 +363,71 @@ pub(crate) trait PursuitSink { Gait::Run } fn target_not_reachable(&mut self, _guid: u64) {} - /// The longest range this creature can bring an offensive spell to bear from; 0 for anything - /// that has to close to melee. A creature whose casting an imported script has taken over reads - /// 0 too: its flat rotation is off, so holding at that rotation's range would leave it standing - /// there casting nothing. fn caster_hold_range(&self, guid: u64) -> f32; - /// STOP the creature at `at` and turn it to `orientation`, in ONE carrier row and one entity - /// write. A spline is the only thing a client derives creature facing from, so the stop is a - /// zero-duration facing spline at `at`: it replaces whatever leg the creature was riding, and - /// the next spline advance reads it as arrived and reaps it. One row because a subscriber sees - /// only a transaction's net change — a stop written beside a turn would never reach a client. fn face(&mut self, guid: u64, at: Point, orientation: f32, spline_id: u32); } -/// A creature that could break off and run this firing: its fight, its wounds, and its rout clock. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Router { pub guid: u64, pub at: Point, - /// The unit it is fighting — what it runs AWAY from, and the other half of the combat re-stamp. pub victim: u64, - /// Where that unit stands. `None` when its row is gone: there is no direction to run in, and the - /// one rout must not be spent standing still. pub victim_at: Option, - /// The wounded-slow rule reads these: a creature this hurt runs slower, so it stays catchable. pub health: u32, pub max_health: u32, - /// Wounded past the rout threshold AND of a kind that runs. Decides whether a rout may START. pub eligible: bool, - /// This engagement's rout clock: 0 = never routed, otherwise the ms the window closes at. The - /// spent value is what makes the rout once per engagement. pub rout_ends_ms: u32, - /// Already routing — the shared `creature_is_routing` verdict chase, this phase and the swing - /// pass all read, so none of them can divert a creature nothing then moves. pub routing: bool, - /// Still riding its committed rout leg. The next leg is rolled only once this one has ended. pub committed: bool, } -/// The rout's surface: the fights that could break, and the once-per-engagement clock bounding them. pub(crate) trait RoutSink { - /// Every live engagement this firing covers whose attacker is a creature that could move. NOT - /// scoped to the active-cell sweep: a rout dragged out of every active cell must still finish. fn routers(&self, scope: &TickScope) -> Vec; - /// Open this engagement's rout window, closing at `ends_ms`. This phase is its ONLY writer, so - /// "eligible and unstamped" is the single moment a rout can begin. fn start_rout(&mut self, guid: u64, ends_ms: u32); } -/// A creature fear has taken over this firing: where it stands, and what it runs from. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Panicked { pub guid: u64, pub at: Point, - /// Where the terror comes from — the caster if it is still in the world, else the unit the - /// creature was fighting. `None` leaves it running from its own feet, i.e. a fixed bearing. pub source_at: Option, - /// A stun, root or polymorph OUTRANKS fear: the creature stays frozen instead of running. pub frozen: bool, } -/// Fear's surface: who is panicking. The leg itself goes through the one leg writer like every other. pub(crate) trait FearSink { - /// One entry per live fear aura this firing covers — a creature carrying two is listed twice, and - /// the phase is what collapses that into one panicking creature. fn panicked(&self, scope: &TickScope) -> Vec; } -/// A summoned pet the pet phase decides for this firing, and the owner state that decision reads. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct Pet { pub guid: u64, pub at: Point, pub map_id: u32, pub instance_id: u64, - /// Its owner. `None` covers BOTH "logged out" and "dead": a pet never outlives its owner, so - /// either one despawns it. pub owner: Option, - /// The pet's own corpse. A pet has no spawn row, so corpse decay never reaps it — this phase - /// does, or a killed Imp lingers forever. pub dead: bool, - /// Stunned, rooted, polymorphed or feared: it neither engages nor trails its owner this firing. pub suppressed: bool, pub command: PetCommand, pub react: PetReact, - /// Already holding a melee row — what standing down has to drop. pub fighting: bool, } -/// The owner as its pet reads it: where to trail, and what to pile onto. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) struct PetOwner { pub guid: u64, pub at: Point, pub map_id: u32, pub instance_id: u64, - /// The enemy the owner is fighting, or 0 when it is idle. pub combat_target: u64, } -/// The pet bar's movement/attack order (`CMSG_PET_ACTION`). #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum PetCommand { - /// Hold position. The pet still fights when it engages; STAY only stops it trailing the owner. Stay, Follow, - /// The player pointed the pet at this unit explicitly. Attack(u64), } -/// The pet bar's auto-engage stance. #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum PetReact { Passive, @@ -668,9 +436,6 @@ pub(crate) enum PetReact { } impl PetCommand { - /// Read a stored bar command. Kept here rather than in the adapter so the whole bar grammar is - /// one cycle-owned decision. DISMISS despawns the pet outright and is never stored, and an - /// unreadable id falls back to Follow — the vanilla default an absent bar row already means. fn of(command: u8, command_target: u64) -> PetCommand { match command { pet::CMD_STAY => PetCommand::Stay, @@ -681,7 +446,6 @@ impl PetCommand { } impl PetReact { - /// Read a stored bar stance; an unreadable id falls back to Defensive, the vanilla default. fn of(react: u8) -> PetReact { match react { pet::REACT_PASSIVE => PetReact::Passive, @@ -691,34 +455,17 @@ impl PetReact { } } -/// The pet's surface: the pets this firing covers, the two questions a pet asks about a would-be -/// foe, and the things a pet decision does. Movement reuses the shared movers — `speed_of`, -/// `navigate` and `commit_leg` — so a follow leg is written by the one leg writer like every other. pub(crate) trait PetSink { - /// The live pets among `candidates` whose own instance this firing covers. `candidates` is the - /// `owner_guid != 0` list the pre-pass sweep already harvested, in table order — never a scan. fn pets(&self, scope: &TickScope, candidates: &[u64]) -> Vec; - /// May this pet attack `target` on its owner's behalf: in the world, alive, not a player, not - /// the pet or the owner itself, and not friendly to the owner. Target 0 is never valid. fn pet_may_attack(&self, pet: &Pet, target: u64) -> bool; - /// The nearest hostile an AGGRESSIVE pet finds around ITSELF. The seek radius is the world's; - /// what the cycle decides is WHEN to ask. fn nearest_hostile_to(&self, pet: &Pet) -> Option; - /// Drop a stale ATTACK order so the pet resumes following its owner. fn cancel_attack_order(&mut self, owner: u64); - /// Point the pet at `victim` — its melee row and its own target together, WITHOUT resetting the - /// swing cadence, so re-pointing a pet mid-fight does not refresh its swing. fn take_victim(&mut self, pet: u64, victim: u64); - /// Stop fighting: drop the pet's own attack row and the threat it holds. fn stand_down(&mut self, pet: u64); - /// Delete the pet and its bar state. fn dismiss(&mut self, pet: u64); - /// Re-place the pet on its owner's map and instance — position, grid address and packed cell in - /// one write. A snap, not a leg: `SMSG_MONSTER_MOVE` cannot cross either. fn restage(&mut self, pet: u64, at: Point, map_id: u32, instance_id: u64, now_ms: u32); } -/// The whole world one cycle touches. pub(crate) trait CreatureWorld: MotionSink + IdleSink @@ -731,41 +478,12 @@ pub(crate) trait CreatureWorld: + PetSink + RegenSink { - /// The creatures near a covered player this firing, plus candidate lists for passes due at this - /// cadence. Read once per cycle and shared by every pass that scopes to it. fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep; - /// Corpse decay, creature respawn and gameobject respawn. Not behavior — the cycle only - /// SEQUENCES them. Returns the rows each visited, for the operator log. fn run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)>; - /// Evaluate authored creature rules for this firing. The operation owns rule loading, grouping, - /// gates, ordered effects and durable rule state. fn evaluate_eventai(&mut self, request: EventAiRequest<'_>) -> u64; - /// Every registered package tick pass, after all core behavior. fn run_package_passes(&mut self); } -/// ONE firing's complete behavior transition. The order below is load-bearing: -/// 1. advance splines FIRST — every range read must see where a creature renders, not its leg end. -/// 2. aggro and pet engagement before chase — a creature aggroed this sense tick closes the same -/// tick; EventAI gets first casting authority, then compatibility casts and threat retarget -/// precede chase (cast instead of close; move at the newly selected victim). -/// 3. aggro before assist before combat entry — a pack answers a call raised this firing, and -/// every creature that pulled carries its combat flag before the firing ends. -/// 4. chase before regen — regen's in-combat gate must see the still-engaged chaser. -/// 5. walking home before loitering — one idle leg per creature per firing, home wins. -/// 6. regen before combat exit — a unit that leaves the fight this firing recovers from the NEXT -/// one — and combat entry before both, which always wins: entry stamps a deadline in the -/// future, exit only clears one already behind us. -/// 7. rout and fear movement LAST, after regen. -/// 8. decay before respawn (inside world maintenance — decay arms a future respawn). -/// 9. package passes after every core pass. -/// -/// The active-cell sweep runs once, before all passes, and its candidate set is shared: a creature -/// absent from it is dormant this firing for every pass that scopes to it (patrol, aggro/assist, -/// idle movement). The engaged, table-driven passes ignore the sweep and gate on `scope.covers` -/// instead, so a player can drag a creature anywhere without freezing it. The due-time passes -/// (decay, respawn, gameobject respawn, regen, combat drop) and the package passes run on the -/// catch-all firing only, and still cover every instance. pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> CycleOutcome { let TickSweep { active, @@ -802,7 +520,6 @@ pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> Cycle rows.push(("regen*", regenerate(w, &in_combat) as u64)); rows.push(("combat_drop*", combat_exit(w, &tick, &in_combat) as u64)); } - // EventAI combat precedence extends at this call without adding a second rout pass. rows.push(("rout", rout(w, &tick) as u64)); rows.push(("fear", fear(w, &tick) as u64)); if !tick.sense { @@ -824,10 +541,6 @@ pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> Cycle } } -/// SPLINE ADVANCE — the first phase of every cycle. Moves each in-flight leg to the point the client -/// renders it at, so the passes below read where the creature IS instead of leading by a whole leg. -/// A suppressed mover freezes and the client is told to stop (no rooted slide into melee); arrived, -/// orphaned and non-finite legs are forgotten. Returns the legs actually advanced. fn advance_legs(w: &mut W, tick: &TickContext) -> usize { let mut visited = 0usize; for leg in w.legs_in_flight() { @@ -840,9 +553,6 @@ fn advance_legs(w: &mut W, tick: &TickContext) -> usize { w.halt(&leg, stopped, tick.now_ms); continue; } - // A leg whose endpoints went bad would write the corruption onto the creature every firing, - // and a creature at an infinite grid cell is in no active cell ever again — unkillable while - // its melee row keeps swinging. Refuse it and leave the creature where it was. if !finite_point(at.x, at.y, at.z) { log::error!( "refused a non-finite spline advance for guid {} — dropping the leg", @@ -851,8 +561,6 @@ fn advance_legs(w: &mut W, tick: &TickContext) -> usize { w.drop_leg(leg.guid); continue; } - // The halt does NOT forget the leg: the zero-duration stop replaces it and reaps itself on - // the next cycle, when it reads as arrived. if w.movement_suppressed(leg.guid) { w.halt(&leg, at, tick.now_ms); continue; @@ -866,40 +574,24 @@ fn advance_legs(w: &mut W, tick: &TickContext) -> usize { visited } -/// How close to a waypoint counts as standing on it. Below this the segment leg would be -/// zero-length and the client rejects it, so the cursor advance is the whole move. +// The client refuses zero-length legs below the waypoint arrival threshold. const WAYPOINT_ARRIVE_YD: f32 = 0.5; -/// Above this a patrol leg is a creature dragged off its route by combat, not an ordinary hop -/// between waypoints. (40 yd)². Below it one leg walks the segment; above it, bounded steps. const PATROL_DISPLACED_SQ: f32 = 1600.0; -/// The hop a wander stroll is sized for: this phase only rolls on a sense firing, so the leg must -/// span the whole ~4s sense cadence, not one movement firing. -/// ponytail: a schedule row slower than half the sense period senses on EVERY firing, so its -/// wanderers hop a leg longer than the gap to the next roll. Verbatim pre-cycle behavior. +// Wander legs span the sense cadence, rather than one movement firing. const WANDER_HOP_SECS: f32 = MOVE_TICK_SECS * SENSE_EVERY_N_TICKS as f32; -/// The idle phases visit candidates in guid order, so one firing's legs are emitted in the same -/// order every time no matter how the sweep's set enumerates. fn idle_order(w: &impl IdleSink, active: &HashSet) -> Vec { let mut candidates = w.idle_creatures(active); candidates.sort_unstable_by_key(|c| c.guid); candidates } -/// May this creature move itself at all? A fight hands it to chase and rout; crowd control leaves -/// fear as its only mover. fn moves_itself(w: &W, guid: u64) -> bool { !w.engaged(guid) && !w.movement_suppressed(guid) } -/// PATROL — an idle creature with a route walks it IN ORDER, one segment per leg. The route cursor -/// (`wp_target`) names the waypoint it walks TO; an unset or stale cursor re-acquires the nearest -/// waypoint first, so a fresh spawn joins its route at the closest point instead of the last one. -/// -/// The ETA gate is what makes a segment play to completion: while the current leg animates the -/// creature is skipped, so a fast schedule row cannot re-throw the leg every firing. fn patrol(w: &mut W, tick: &TickContext, active: &HashSet) -> usize { let mut visited = 0usize; for c in idle_order(w, active) { @@ -918,12 +610,10 @@ fn patrol(w: &mut W, tick: &TickContext, active: &Hash } let mut route = w.route_of(c.guid); if route.len() < 2 { - continue; // a single waypoint is a post, not a route + continue; } route.sort_unstable_by_key(|wp| wp.id); let points: Vec<(f32, f32)> = route.iter().map(|wp| (wp.at.x, wp.at.y)).collect(); - // Keep aiming at the cursor's waypoint until it is reached, so a displaced creature walks - // toward it over many firings. Reached, unset or stale advances to the next in route order. let cur = route.iter().position(|wp| wp.id == c.wp_target); let next = match cur { Some(i) if dist_sq(route[i].at, c.at) >= WAYPOINT_ARRIVE_YD * WAYPOINT_ARRIVE_YD => i, @@ -935,7 +625,7 @@ fn patrol(w: &mut W, tick: &TickContext, active: &Hash let (dx, dy, dz) = (wp.at.x - c.at.x, wp.at.y - c.at.y, wp.at.z - c.at.z); let dist_sq = dx * dx + dy * dy + dz * dz; if dist_sq < WAYPOINT_ARRIVE_YD * WAYPOINT_ARRIVE_YD { - continue; // already there — next firing walks to the one after it + continue; } let leg = if dist_sq <= PATROL_DISPLACED_SQ { let gait = w.idle_gait(c.guid, Gait::Walk); @@ -948,13 +638,11 @@ fn patrol(w: &mut W, tick: &TickContext, active: &Hash hold_until_landed: true, } } else { - // Badly displaced: step toward the waypoint one tick at a time, ground-snapped per - // leg like `walk_home`, so the creature no longer slides home through terrain. let gait = w.idle_gait(c.guid, Gait::Walk); let speed = w.speed_of(c.guid, gait); let step = w.navigate(c.guid, (wp.at.x, wp.at.y), speed * tick.tick_secs); let Some((x, y, dur_ms)) = leg_toward((c.at.x, c.at.y), step, speed) else { - continue; // nothing to close — the client rejects a zero-length leg + continue; }; Leg { to: (x, y), @@ -969,8 +657,6 @@ fn patrol(w: &mut W, tick: &TickContext, active: &Hash visited } -/// One creature's pull this firing: who it engaged, and where it stood when it called for help. -/// Internal cycle state — it never leaves the aggro-to-assist handoff. #[derive(Clone, Copy)] struct Called { guid: u64, @@ -981,19 +667,13 @@ struct Called { victim: u64, } -/// The engagement phases visit candidates in guid order, so one firing arms its fights in the same -/// order every time no matter how the sweep's set enumerates. fn sensing_order(w: &impl EngageSink, active: &HashSet) -> Vec { let mut candidates = w.sensing_creatures(active); candidates.sort_unstable_by_key(|c| c.guid); candidates } -/// AGGRO — a creature notices a hostile player and engages it on sight. Collect-then-arm: the -/// on-aggro hook runs package code, so nothing may fire while the scan is still deciding. -/// -/// Returns this firing's pulls, which are the assist phase's whole input, plus the candidates -/// visited. With nobody worth noticing in the world the phase costs one player read and stops. +// Decide every pull before hooks run Package code that can change the candidates. fn aggro(w: &mut W, active: &HashSet) -> (Vec, usize) { let targets: Vec = w .players() @@ -1030,31 +710,28 @@ fn aggro(w: &mut W, active: &HashSet) -> (Vec, usize (called, sensors.len()) } -/// The nearest player this creature notices: same map and instance, hostile, inside the -/// level-scaled aggro radius (shrunk by a soothe, graded down again for a stealthed target), and -/// in plain sight. `None` means nothing to pull. fn nearest_noticed(w: &W, s: &Sensor, targets: &[AggroTarget]) -> Option { - let tuned = s.aggro_range?; // no template row, no proximity aggro + let tuned = s.aggro_range?; targets .iter() .filter(|p| p.map_id == s.map_id && p.instance_id == s.instance_id) .filter_map(|p| { let radius = (aggro_radius(s.level, p.level, tuned) + s.detect_range_mod).max(0.0); if radius <= 0.0 { - return None; // grey to this player, or soothed all the way down + return None; } let d2 = dist_sq(s.at, p.at); if d2 > radius * radius { return None; } if p.stealthed && d2 > stealth_detect_range(s.level, p.level).powi(2) { - return None; // sneaking past, outside what this creature can see through + return None; } if !w.hostile(s.faction_template, p.faction_template) { return None; } if !w.line_of_sight(s.guid, p.at) { - return None; // a hostile behind the abbey wall is not "seen" + return None; } Some((p.guid, d2)) }) @@ -1067,20 +744,11 @@ fn dist_sq(a: Point, b: Point) -> f32 { dx * dx + dy * dy + dz * dz } -/// ASSIST — pack behavior: a same-faction neighbor of a creature that pulled THIS firing piles onto -/// the same player, even a passive one the player never got close enough to notice. Range is -/// measured from the CALLER, not from the player, so a far-flung pack mate is not dragged in, and -/// the neighbor's own `aggro_range` is deliberately not consulted. -/// -/// Nobody pulled means no scan at all — the overwhelmingly common firing. Calls are answered lowest -/// caller guid first, so a neighbor standing between two of them always joins the same fight. fn assist(w: &mut W, active: &HashSet, mut calls: Vec) -> usize { if calls.is_empty() { return 0; } calls.sort_unstable_by_key(|c| c.guid); - // Fresh read: every creature that pulled above is now swinging, so it is no longer a candidate - // here — one creature answers at most one call per firing by construction. let neighbors = sensing_order(w, active); let mut answered = Vec::new(); for n in &neighbors { @@ -1096,8 +764,6 @@ fn assist(w: &mut W, active: &HashSet, mut calls: Vec(w: &mut W, active: &HashSet, mut calls: Vec(w: &mut W, tick: &TickContext, candidates: &[u64]) -> usize { let pets = w.pets(&tick.scope, candidates); let visited = pets.len(); @@ -1170,8 +813,6 @@ fn pet_behavior(w: &mut W, tick: &TickContext, candidates actions.push((pet.guid, PetAction::Despawn)); continue; } - // Crowd control owns the pet this firing: its own chase and swing already respect it, and - // the follow leg below must too. if pet.suppressed { continue; } @@ -1188,7 +829,7 @@ fn pet_behavior(w: &mut W, tick: &TickContext, candidates continue; } if pet.command == PetCommand::Stay { - continue; // parked: it still fights, it just does not trail + continue; } if let Some(action) = follow(w, tick, pet, &owner) { actions.push((pet.guid, action)); @@ -1210,11 +851,6 @@ fn pet_behavior(w: &mut W, tick: &TickContext, candidates visited } -/// Who this pet swings at, or `None` to stand down. An explicit ATTACK order wins; otherwise the -/// react stance governs auto-engage — PASSIVE never engages, DEFENSIVE and AGGRESSIVE both assist -/// the owner's fight, and AGGRESSIVE additionally seeks a hostile near itself while the owner is -/// idle. A stale ATTACK order, whose foe died or vanished, is cleared here so the pet resumes -/// following rather than standing over a corpse. fn pet_victim(w: &mut W, pet: &Pet, owner: &PetOwner) -> Option { if let PetCommand::Attack(target) = pet.command { if w.pet_may_attack(pet, target) { @@ -1234,18 +870,6 @@ fn pet_victim(w: &mut W, pet: &Pet, owner: &PetOwner) -> Option } } -/// FOLLOW — the pet trails its owner. Inside the follow band it holds; outside it, it runs ONE leg -/// aimed `PET_TRAIL_YD` short of the owner. The leg spans this row's whole SENSING period, because -/// that is when this phase next decides: a one-firing step would let a moving owner outrun the pet, -/// and a hardcoded span would overlap itself on a row that senses every firing. -/// -/// An owner that changed map or instance is snapped to instead — `SMSG_MONSTER_MOVE` crosses -/// neither, and the AOI relay's create/destroy carries the pet over. -/// -/// ponytail: the leg rides `commit_leg`, the writer every other mover uses, rather than the pet's -/// own. Ceiling: two deltas from the pre-cycle pet writer, both in the direction of the rest of the -/// tick — the landing point is now ground-snapped, and the pet's row rides the spline instead of -/// jumping to the leg end and being pulled back by the next advance. fn follow( w: &W, tick: &TickContext, @@ -1260,12 +884,12 @@ fn follow( }); } if dist_sq(pet.at, owner.at) <= PET_FOLLOW_BAND_SQ { - return None; // close enough — hold position + return None; } let (dx, dy) = (owner.at.x - pet.at.x, owner.at.y - pet.at.y); let gap = (dx * dx + dy * dy).sqrt(); if gap <= PET_TRAIL_YD { - return None; // already inside the trailing band on the ground plane + return None; } let leg_len = gap - PET_TRAIL_YD; let aim = (pet.at.x + dx / gap * leg_len, pet.at.y + dy / gap * leg_len); @@ -1274,8 +898,6 @@ fn follow( let (x, y, dur_ms) = leg_toward((pet.at.x, pet.at.y), step, run)?; Some(PetAction::Follow(Leg { to: (x, y), - // The owner's height is only the fallback: the writer snaps the landing point to the ground - // under it, which matters wherever the pet's detour corner is nowhere near the owner. z_fallback: owner.at.z, dur_ms, gait: Gait::Run, @@ -1283,24 +905,11 @@ fn follow( })) } -/// CAST — an engaged caster creature begins ONE action from its rotation. It runs before chase, so a -/// caster that can cast casts instead of merely closing, and both phases reach the SAME line-of-sight -/// verdict about the same line: with a wall in the way the offensive half of the rotation is dropped -/// here and chase closes rather than holding at spell range. -/// -/// A creature with rotation lines uses them, highest priority first; one with none falls back to its -/// single authored spell; one with neither never casts. Eligibility is only the CONDITION half — the -/// spell module owns cooldown, power cost and range, and reports "not ready" by refusing the cast, at -/// which point the next line down gets its turn. At most one cast per creature per firing. -/// -/// The engagement rows are the candidate set, so this stays O(fights running). Visited in guid order. -/// Returns the covered candidates considered. fn cast(w: &mut W, scope: &TickScope) -> usize { let mut casters = w.casters(scope); casters.sort_unstable_by_key(|c| c.guid); let visited = casters.len(); - // Collect-then-cast: a cast applies auras and moves health, which the conditions above read. One - // interleaved loop would let the first creature's cast decide the second creature's rotation. + // Choose every cast before applying one so earlier effects cannot change later choices. let mut ready: Vec<(Caster, Vec<(u32, u64)>)> = Vec::new(); for c in casters { if c.cannot_act || c.casting { @@ -1311,7 +920,7 @@ fn cast(w: &mut W, scope: &TickScope) -> usize { let mut candidates: Vec<(u32, u64)> = if rotation.is_empty() { match w.lone_spell(c.guid) { Some(spell_id) => vec![(spell_id, c.victim)], - None => continue, // no rotation and no spell — it only ever melees + None => continue, } } else { rotation.sort_unstable_by(|a, b| { @@ -1324,8 +933,6 @@ fn cast(w: &mut W, scope: &TickScope) -> usize { .filter_map(|o| eligible_cast(w, &c, o)) .collect() }; - // A hostile cast needs a clear line to its victim. Blocked, only the self-cast half survives - // and the creature melees instead — the verdict chase reads again to close the distance. if candidates.iter().any(|&(_, t)| t != c.guid) { let blocked = c.victim_at.is_some_and(|at| !w.line_of_sight(c.guid, at)); if blocked { @@ -1339,15 +946,13 @@ fn cast(w: &mut W, scope: &TickScope) -> usize { for (c, candidates) in ready { for (spell_id, target) in candidates { if w.begin_cast(&c, spell_id, target) { - break; // one action per firing; the rest of the rotation waits + break; } } } visited } -/// One rotation line's verdict: `Some((spell, target))` when the world matches what the line waits -/// for. A heal or buff is aimed at the caster, a nuke or debuff at its victim. fn eligible_cast(w: &W, c: &Caster, option: &SpellOption) -> Option<(u32, u64)> { let target = match option.when { CastWhen::Always => c.victim, @@ -1359,29 +964,16 @@ fn eligible_cast(w: &W, c: &Caster, option: &SpellOption) -> Option Some((option.spell_id, target)) } -/// THREAT RETARGET — an engaged creature re-points at whoever it hates most. It runs before chase, so -/// the movement leg in the same firing follows the newly chosen victim rather than trailing a firing -/// behind it. Choosing a target is neither moving nor acting, so crowd control does not gate it: a -/// stunned creature still tracks who it will swing at when the stun lifts. -/// -/// A live taunt lock pins the creature outright. Otherwise the switch needs STRICTLY more threat than -/// the current victim holds, which is the hysteresis that stops a tie flapping the target: an empty -/// table, or one source, leaves a proximity pull exactly where it was. -/// -/// ponytail: this walks the engagement rows the cast phase just walked, rather than sharing one -/// candidate list. Ceiling: a second pass over the engaged rows per sense firing. A shared snapshot -/// would be read before the casts above land, which is exactly what a fresh read exists to avoid. fn threat_retarget(w: &mut W, scope: &TickScope) -> usize { let fighters = w.fighters(scope); let visited = fighters.len(); - // Collect-then-repoint: retargeting writes the engagement rows this list was read from. let mut switches: Vec<(u64, u64)> = Vec::new(); for f in &fighters { if let Some(pinned) = w.taunted_onto(f.guid) { if pinned != f.victim { switches.push((f.guid, pinned)); } - continue; // pinned — the threat compare is suspended for the taunt window + continue; } let Some(top) = w.top_threat(f.guid) else { continue; @@ -1396,26 +988,14 @@ fn threat_retarget(w: &mut W, scope: &TickScope) -> usize { visited } -/// How far short of a STANDING victim a closing leg lands: just inside the 5-yard melee band, so the -/// next swing connects and the leg never walks onto the victim. const MELEE_STOP_SHORT_YD: f32 = 4.0; -/// A caster holds inside this fraction of its longest spell range, so a victim's small strafes do -/// not yo-yo it across the boundary. const CASTER_HOLD_MARGIN: f32 = 0.9; -/// How close (yd², so half a yard) counts as STANDING ON an authored hold point. Inside it the -/// creature has arrived and turns to its victim instead of re-throwing a leg it could not complete. const RANGED_HOLD_ARRIVED_SQ: f32 = 0.25; -/// The least bearing drift (radians, ~17°) that is worth turning for. Below it the correction would -/// be imperceptible and would re-throw a facing packet every firing; the epsilon is what makes "have -/// I already turned to face it" idempotent firing over firing. const FACING_EPSILON_RAD: f32 = 0.3; -/// Shortest signed angular distance from `from` to `to`, wrapped into `(-PI, PI]` — so a bearing that -/// crosses the ±PI seam (orientation 3.0, target bearing -3.0) reads as a small turn rather than a -/// near-full circle. Pure — unit-tested below. fn angle_diff(from: f32, to: f32) -> f32 { let two_pi = std::f32::consts::TAU; let mut d = (to - from) % two_pi; @@ -1427,26 +1007,6 @@ fn angle_diff(from: f32, to: f32) -> f32 { d } -/// CHASE — an engaged creature closes on the unit it fights. It commits to ONE long run leg and -/// rides it for several firings, re-aiming only when the leg lands, the victim veers off the -/// heading it was thrown along, or a victim that stops runs the leg past where the stop point now -/// sits; an offensive caster holds at spell range instead of face-tanking; and a creature that -/// reaches a victim standing inside melee reach stops and turns to face it. -/// -/// Melee reach is read BEFORE the lead AND before the caster hold, and the victim's live translation -/// flags decide which of the two applies, so a player who released the movement key inside reach is -/// turned to rather than led eight yards past — the sideways overshoot of running to their flank. -/// A caster is no exception: facing is also what halts a leg, so one that skipped the reach check -/// would slide through a victim that stopped on top of it and never turn. -/// -/// An authored ranged posture outranks both. It is the script's own stance, so it replaces the melee -/// approach outright and the caster hold never applies while one is set. -/// -/// The engagements are the candidate set, so this phase ignores the active-cell sweep — a player -/// cannot freeze a fight by dragging it away — and stays O(fights running). A routing or -/// crowd-controlled creature is skipped, leaving the rout leg and fear as its sole movers, and every -/// chaser gets at most one leg per firing: a second would share the first's `spline_id` and the -/// client would throw it away. Visited in guid order. Returns the covered pursuits considered. fn chase( w: &mut W, tick: &TickContext, @@ -1459,42 +1019,26 @@ fn chase( continue; } let gap_sq = dist_sq(c.at, c.victim_at); - // Past the pursuit cutoff the creature stops closing: the engagement's own timer ends the - // fight, distance does not, and walking home is the return phase's job afterwards. if gap_sq > CHASE_LEASH_SQ { continue; } let posture = w.authored_ranged_posture(c.guid); - // An authored ranged posture REPLACES the whole melee approach, so it is read first and the - // caster hold below is skipped whenever one is set. A posture of zero distance is the - // script asking for the plain melee chase back. if let Some(aim) = posture.and_then(|(distance, angle)| { (distance > 0.0 && w.line_of_sight(c.guid, c.victim_at)) .then(|| ranged_hold_point(&c, distance, angle)) }) { if dist_sq(c.at, aim) <= RANGED_HOLD_ARRIVED_SQ { - // Standing on the hold point is an arrival like reaching melee: the creature turns - // to what it is shooting at, and a lead still carrying it is halted there. stand_and_face(w, tick, &c); } else { commit_chase_leg(w, &c, aim, Some((aim.x, aim.y)), tick.now_ms); } continue; } - // Melee reach FIRST, then the victim's live translation state: a creature plants into attack - // stance for a victim that is standing, and runs a kiter down continuously — the swing pass - // hits on the move — which is what removes the run/attack-stance/run flicker of chasing - // someone who never stands still. It precedes the caster hold because facing is what also - // HALTS an in-flight leg: a caster whose victim stopped on top of it would otherwise never - // turn, and its lead would slide it past the player. let victim_moving = crate::combat::is_translating(c.victim_movement_flags); if gap_sq <= CHASE_MELEE_SQ && !victim_moving { stand_and_face(w, tick, &c); continue; } - // An offensive caster stands and casts (the cast phase already fired this firing) while its - // victim is inside spell range and in plain sight. A wall-blocked one closes instead, which - // is the verdict the cast phase reached about that same line. if posture.is_none() { let hold = w.caster_hold_range(c.guid) * CASTER_HOLD_MARGIN; if hold > 0.0 && gap_sq <= hold * hold && w.line_of_sight(c.guid, c.victim_at) { @@ -1506,8 +1050,6 @@ fn chase( (c.victim_at.x - c.at.x) / gap, (c.victim_at.y - c.at.y) / gap, ); - // Aim PAST a moving victim, so one leg carries several firings of a straight-line kite; aim - // at melee reach for a standing one, so the stand above ends the approach. let leg_len = if victim_moving { gap + CHASE_LEAD_YD } else { @@ -1518,21 +1060,12 @@ fn chase( y: c.at.y + dir.1 * leg_len, z: c.victim_at.z, }; - // For a standing victim `aim` IS the stop point, so a leg still in flight past it is a lead - // thrown at the victim's older, moving position and has to be replaced there. let stop_at = (!victim_moving).then_some((aim.x, aim.y)); commit_chase_leg(w, &c, aim, stop_at, tick.now_ms); } visited } -/// Throw ONE run leg at `aim`. The melee approach and the authored ranged posture both land here, -/// so a chaser gets at most one leg per firing whichever posture it is in. Nothing is written when -/// navigation refuses to move at all, when the leg in flight is still good enough -/// (`needs_new_leg`), or when the step collapses to zero length, which the client rejects. -/// -/// `aim.z` is only the FALLBACK height: the leg writer snaps the landing point to the ground under -/// it, which matters on a slope and where the point is a detour corner nowhere near the victim. fn commit_chase_leg( w: &mut W, c: &Pursuit, @@ -1584,13 +1117,6 @@ fn ranged_hold_point(pursuit: &Pursuit, distance: f32, angle: f32) -> Point { } } -/// Does this chaser need a NEW leg? One whose leg has landed does. One still riding a leg keeps it -/// while the victim stays roughly on the heading the leg was thrown along, which is what stops the -/// client re-computing its path every firing — UNLESS `stop_at` is set and the leg would land at or -/// past it. `stop_at` is the point this firing would aim at, and the caller sets it only for a -/// victim that has stopped translating: a lead thrown at a kiter who then stops short of reach must -/// be replaced at the stop point rather than ridden eight yards through them, while a still-kiting -/// victim rides its one committed leg exactly as before. fn needs_new_leg(leg: Option<&LegInFlight>, dir: (f32, f32), stop_at: Option<(f32, f32)>) -> bool { let Some(leg) = leg else { return true; @@ -1605,35 +1131,19 @@ fn needs_new_leg(leg: Option<&LegInFlight>, dir: (f32, f32), stop_at: Option<(f3 len <= 0.001 || (lx / len) * dir.0 + (ly / len) * dir.1 < CHASE_REPATH_COS } -/// The victim is inside melee reach and standing still. ONE facing spline at the point the chaser -/// renders both stops it — a lead aimed past a now-stopped victim must not carry it through them — -/// and turns it to what it is swinging at, which is the only thing a client derives creature facing -/// from. The two cannot be separate rows: a creature has ONE spline row, so a stop written beside a -/// turn inside one transaction reaches the client as the turn alone, and the client rides the -/// interrupted leg on while the module holds the creature at the halt point. -/// -/// A creature riding a leg is stopped whatever way it looks; a stopped one is only turned when the -/// bearing has drifted past the epsilon. Both are idempotent — the row reaps itself on the next -/// advance — so a settled fight goes silent. fn stand_and_face(w: &mut W, tick: &TickContext, c: &Pursuit) { let bearing = (c.victim_at.y - c.at.y).atan2(c.victim_at.x - c.at.x); let turning = angle_diff(c.orientation, bearing).abs() > FACING_EPSILON_RAD; if c.leg.is_none() && !turning { - return; // standing where it stands, already looking at what it swings at + return; } w.face(c.guid, c.at, bearing, tick.now_ms); } -/// COMBAT ENTRY — every unit in a live engagement this firing covers gets the in-combat flag and a -/// refreshed combat-drop deadline. It covers a pure caster too: casting at a creature makes the -/// creature retaliate, so the caster is the VICTIM of an engagement and is flagged from here. -/// -/// The engaged set is small — it scales with the fights running, not with the world. fn combat_entry(w: &mut W, scope: &TickScope) -> usize { let mut visited = 0usize; let mut units: Vec = Vec::new(); for e in w.engagements() { - // Equivalence: with only the catch-all schedule row, every pair is covered. if !scope.covers(e.instance_id) || e.player_never_swung { continue; } @@ -1649,14 +1159,6 @@ fn combat_entry(w: &mut W, scope: &TickScope) -> usize { visited } -/// IDLE MOVEMENT — walking home and loitering, as ONE decision per creature. A creature displaced -/// past the leash walks back to its post; one that is home-enough may hop around it. The two were -/// separate passes sharing a "moved already this firing" set; making them one branch is what keeps -/// the rule true by construction — two legs in a firing share a `spline_id` and the client rejects -/// the second. -/// -/// Patrollers are the patrol phase's, engaged creatures are chase's, and only a RANDOM-movement -/// creature loiters (an IDLE one holds its post: quest givers, vendors, guards). fn idle_movement( w: &mut W, tick: &TickContext, @@ -1739,14 +1241,11 @@ fn follow_authored(w: &mut W, tick: &TickContext, c: &IdleCreature, ); } -/// One firing's worth of run toward the post, landing ON it rather than short of it. Re-stepped -/// every firing from the CURRENT position, so any number of dormant firings in between only pauses -/// the walk home. fn walk_home(w: &mut W, tick: &TickContext, c: &IdleCreature, home: Home) { let run = w.speed_of(c.guid, Gait::Run); let step = w.navigate(c.guid, (home.at.x, home.at.y), run * tick.tick_secs); let Some((x, y, dur_ms)) = leg_toward((c.at.x, c.at.y), step, run) else { - return; // nothing to close — the client rejects a zero-length leg + return; }; let leg = Leg { to: (x, y), @@ -1758,10 +1257,6 @@ fn walk_home(w: &mut W, tick: &TickContext, c: &IdleCreature, home: w.commit_leg(c.guid, leg, tick.now_ms); } -/// A stroll to a random point near the post on about a third of sense firings — the other two -/// thirds the creature stands still, which is what reads as loitering instead of a constant jog. -/// The hop is anchored on HOME, not on the creature, so a loiterer provably never drifts off its -/// post and never trips the walk-home leash. fn loiter(w: &mut W, tick: &TickContext, c: &IdleCreature, home: Home) { if !home.wanders || leg_in_flight(tick.now_ms, c.leg_ends_ms) { return; @@ -1787,23 +1282,12 @@ fn loiter(w: &mut W, tick: &TickContext, c: &IdleCreature, home: Ho w.commit_leg(c.guid, leg, tick.now_ms); } -/// REGENERATION — every unit recovers health and power once per sense firing. Out of combat a hurt -/// unit heals freely; in a fight only combat-regen auras and flat item HP5 remain active. The rates -/// are `combat::tables`', and players are candidates too, since nothing else ticks their bars. -/// -/// It runs AFTER chase, so the gate sees a creature that closed on its victim this very firing and -/// leaves it unhealed, and BEFORE rout and fear, whose legs it must not revert: only the two bars -/// are written, on the live row, so a position decided earlier in the cycle survives. -/// -/// Catch-all firing only. The per-firing amount is quantized to the sense cadence, so a second, -/// faster schedule row running this would literally multiply everyone's recovery rate. Returns the -/// candidates considered. fn regenerate(w: &mut W, flagged: &[u64]) -> usize { let units = w.recovering(flagged); let visited = units.len(); for u in &units { let health = match (u.health < u.max_health, u.in_combat) { - (false, _) => None, // already full + (false, _) => None, (true, true) => w.combat_healed_to(u), (true, false) => Some(w.healed_to(u)), } @@ -1818,18 +1302,8 @@ fn regenerate(w: &mut W, flagged: &[u64]) -> usize { visited } -/// COMBAT EXIT — a unit whose combat-drop deadline has passed loses the in-combat flag, which is how -/// a fight ends about six seconds after the last hostile action rather than the instant the swinging -/// stops. It is combat entry's exact counterpart, and entry ALWAYS wins in a firing they both touch: -/// entry stamps a deadline in the future, exit only clears one already behind us. -/// -/// The candidates are the sweep's flag harvest, so this costs no scan of its own; the flag and the -/// deadline are re-read from the live row, because entry ran earlier in this same cycle. It covers -/// PLAYERS as well as creatures — nothing else ticks a player, so a player's flag would otherwise -/// stick forever. Catch-all firing only, still covering every instance. Returns the candidates. fn combat_exit(w: &mut W, tick: &TickContext, flagged: &[u64]) -> usize { let now_ms = tick.now_micros / 1000; - // Collect-then-clear: dropping a flag writes the rows this list was read from. let expired: Vec = w .flagged_in_combat(flagged) .into_iter() @@ -1842,61 +1316,36 @@ fn combat_exit(w: &mut W, tick: &TickContext, flagged: &[u64]) -> flagged.len() } -/// ROUT — a creature wounded past the flee threshold, of a kind that runs, breaks off and sprints -/// AWAY from the unit it fights without leaving the fight: routing is a shared combat state, so both -/// sides stay engaged, keep their target, and the runner can still be caught and killed. -/// -/// The rout is BOUNDED and once per engagement. This phase is the ONLY writer of the window, so -/// "eligible and never stamped" is the single moment one can begin; once the window closes the -/// creature is an ordinary attacker again — chase closes the gap, the swing pass lands its blows — -/// and no later health drop opens a second one. The leg is ONE committed `FLEE_LEG_YD` dash, re-rolled -/// only after the previous one ended: re-picking an away-bearing every firing snap-rotated the -/// client's facing, which is the "flee spin". -/// -/// It runs after regen, whose in-combat gate skips the still-engaged runner — otherwise regen would -/// re-write the row from its own snapshot and REVERT the fled position every firing, pinning the -/// runner in aggro range. The engagements are the candidate set, so this ignores the active-cell -/// sweep and stays O(fights running). Visited in guid order. Returns the covered candidates. -/// -/// ponytail: on the ONE firing a window opens, chase has already decided from `routing == false`, so -/// a distant chaser throws a leg this phase then overwrites. Ceiling: one wasted row write per rout -/// started — the relay carries a single spline row per creature, so the client only ever plays the -/// rout leg. Verbatim pre-cycle behavior. fn rout(w: &mut W, tick: &TickContext) -> usize { let mut routers = w.routers(&tick.scope); routers.sort_unstable_by_key(|c| c.guid); let visited = routers.len(); for c in routers { - // Crowd control: a FROZEN creature stays engaged and locked in place, and a FEARED one is - // moved by the fear phase below instead — skipping it here is what stops the double move. - // The window is left exactly as it was, so the rout resumes when the control ends. + // Crowd control owns this firing's movement; fear must not move the creature twice. if w.movement_suppressed(c.guid) { continue; } let starting = may_start_rout(c.eligible, c.rout_ends_ms); if !starting && !c.routing { - continue; // not eligible, or the one window is spent — it stands and fights + continue; } - // Resolved BEFORE the window is stamped, so a victim that vanished cannot spend the rout - // without a leg ever being run. let Some(victim_at) = c.victim_at else { continue; }; if starting { w.start_rout(c.guid, rout_close_ms(tick.now_ms)); } - // Both sides re-stamp EVERY firing, mid-leg included: without it the combat-drop deadline - // fires during the long committed run and the pair simply untargets each other. + // Refresh both combat deadlines mid-leg so a long rout cannot end the engagement. w.enter_combat(c.guid); w.enter_combat(c.victim); if c.committed { - continue; // still riding the last dash — one stable travel tangent, no re-aim + continue; } let run = w.speed_of(c.guid, Gait::Run) * wounded_slow_factor(c.health, c.max_health); let away = flee_step(c.at.x, c.at.y, victim_at.x, victim_at.y, FLEE_LEG_YD); let step = w.navigate(c.guid, away, FLEE_LEG_YD); let Some((x, y, dur_ms)) = leg_toward((c.at.x, c.at.y), step, run) else { - continue; // nowhere to run — the client rejects a zero-length leg + continue; }; let leg = Leg { to: (x, y), @@ -1910,20 +1359,9 @@ fn rout(w: &mut W, tick: &Tick visited } -/// FEAR — a feared creature does not steer itself at all: it is force-walked away from whatever -/// feared it, one jittered dash per firing, until the aura lapses. Every other mover skips a feared -/// creature, so this phase is its SOLE mover and it gets exactly one leg per firing. -/// -/// Fear does NOT disengage: the melee row is untouched, so when the aura expires the creature turns -/// and fights the unit it was already fighting. A stun, root or polymorph outranks fear and freezes -/// it in place instead. Running LAST, after regen, is what keeps regen from reverting the dash. -/// -/// The dash scales with the firing's own interval, so a fast schedule row does not multiply terror -/// speed. Visited in guid order. Returns the covered creatures considered. fn fear(w: &mut W, tick: &TickContext) -> usize { let mut panicked = w.panicked(&tick.scope); - // Two casters fearing one creature is still ONE panicking creature: a second leg in the firing - // would share the first's spline id and the client would throw one of them away. + // Collapse multiple fear auras to one movement leg per creature per firing. panicked.sort_unstable_by_key(|c| c.guid); panicked.dedup_by_key(|c| c.guid); let visited = panicked.len(); @@ -1931,8 +1369,6 @@ fn fear(w: &mut W, tick: &TickContext) -> usize { if c.frozen { continue; } - // No source left to run from leaves the creature running from its own feet, which the step - // turns into a fixed bearing — a feared creature always moves. let from = c.source_at.unwrap_or(c.at); let (fx, fy) = feared_flee_step( c.at.x, @@ -1942,9 +1378,6 @@ fn fear(w: &mut W, tick: &TickContext) -> usize { fear_step_for_tick(tick.tick_secs), w.roll(), ); - // ponytail: the flat RUN speed and no navigation, alone among the movers — a snared feared - // creature should panic slower and a wall should turn it, and neither happens. Pre-cycle - // behavior, carried verbatim. let Some((x, y, dur_ms)) = leg_toward((c.at.x, c.at.y), (fx, fy), constants::speeds::RUN) else { continue; @@ -1961,9 +1394,6 @@ fn fear(w: &mut W, tick: &TickContext) -> usize { visited } -/// The facing correction's precise edges. A scenario test proves that a creature reaching a standing -/// victim turns to face it; these pin the seam-crossing and epsilon arithmetic that decides it, -/// which is cheaper and sharper to state directly than through a fight. #[cfg(test)] mod facing_tests { use super::*; @@ -1982,8 +1412,6 @@ mod facing_tests { #[test] fn the_pi_seam_takes_the_short_way_round() { - // orientation just past +PI, target bearing just past -PI: only ~0.2 rad apart going - // "outward" across the seam, NOT the ~2*PI-0.2 rad the naive subtraction would give. let from = PI - 0.1; let to = -PI + 0.1; let d = angle_diff(from, to); @@ -2008,16 +1436,11 @@ mod facing_tests { } } -/// `needs_new_leg`'s re-aim boundary. A scenario test proves a stopped-short victim gets a -/// replacement leg landing at the stop point; these pin exactly where that boundary sits along the -/// approach heading, cheaper and sharper than through a fight. #[cfg(test)] mod chase_leg_tests { use super::*; const DIR: (f32, f32) = (1.0, 0.0); - /// Where `chase` aims once its victim stops: four yards short of a victim standing at x = 23. - /// A victim still translating gets `None` instead, and no stop-point test at all. const STOP_AT: Option<(f32, f32)> = Some((19.0, 0.0)); fn leg_to(dest_x: f32) -> LegInFlight { @@ -2050,7 +1473,6 @@ mod chase_leg_tests { #[test] fn a_stopped_victim_keeps_a_leg_that_still_lands_short_of_the_stop_point() { - // Still approaching, so there is nothing to replace yet. assert!(!needs_new_leg(Some(&leg_to(10.0)), DIR, STOP_AT)); } @@ -2061,15 +1483,11 @@ mod chase_leg_tests { #[test] fn a_stopped_victim_replaces_a_leg_that_would_overshoot_the_stop_point() { - // Thrown as a lead past the victim's OLD position, this leg would carry the creature - // through where the victim now stands rather than stopping short of it. assert!(needs_new_leg(Some(&leg_to(28.0)), DIR, STOP_AT)); } #[test] fn a_still_translating_victim_keeps_the_same_overshooting_leg() { - // A straight-line kite rides one committed lead leg: there is no stop point to test - // against while the victim is still moving. assert!(!needs_new_leg(Some(&leg_to(28.0)), DIR, None)); } diff --git a/module/src/creatures/distraction.rs b/module/src/creatures/distraction.rs index 2e8a90841..f9ba744bc 100644 --- a/module/src/creatures/distraction.rs +++ b/module/src/creatures/distraction.rs @@ -1,9 +1,3 @@ -//! The Distraction: a Creature faces one ground point and holds its idle movement until the expiry. -//! -//! Only the creature behavior cycle reads it, through `IdleSink`: patrol and wander hold while it is -//! active, and after the expiry the cycle plays one facing back to the spawn orientation and ends it. -//! A new Engagement, death and despawn end it at once. - use lyracore_shared::constants::unit_flags; use spacetimedb::{table, ReducerContext, Table}; @@ -14,11 +8,9 @@ use crate::{game_creature_spawn, game_world_entity}; pub struct CreatureDistraction { #[primary_key] pub creature_guid: u64, - /// Epoch milliseconds. Not a `Timestamp`, so a durable test can move it with plain SQL. pub ends_ms: u64, } -/// The effect amount is the length in seconds. A non-positive amount distracts nobody. fn length_ms(seconds: i32) -> Option { u64::try_from(seconds) .ok() @@ -30,8 +22,6 @@ fn now_ms(ctx: &ReducerContext) -> u64 { (ctx.timestamp.to_micros_since_unix_epoch() / 1_000) as u64 } -/// Start or refresh a Distraction on `creature_guid`: stop it where the client renders it, face -/// `dest`, and hold for `seconds`. An ineligible unit is left unchanged. pub(crate) fn distract( ctx: &ReducerContext, creature_guid: u64, @@ -45,9 +35,6 @@ pub(crate) fn distract( let Some(mut creature) = entities.guid().find(creature_guid) else { return; }; - // Only an idle Creature that can act turns: a fight, the walk home after it, or crowd control - // already owns its movement. It must also see the point, as vmangos requires of an area target at - // a destination. if creature.is_player() || creature.dead || creature.unit_flags & unit_flags::IN_COMBAT != 0 @@ -86,7 +73,6 @@ fn ends_ms(ctx: &ReducerContext, guid: u64) -> Option { .map(|row| row.ends_ms) } -/// Is `guid` distracted now? Patrol and wander hold while this is true. pub(crate) fn active(ctx: &ReducerContext, guid: u64) -> bool { ends_ms(ctx, guid).is_some_and(|ends| now_ms(ctx) < ends) } @@ -95,8 +81,6 @@ fn expired(ctx: &ReducerContext, guid: u64) -> bool { ends_ms(ctx, guid).is_some_and(|ends| now_ms(ctx) >= ends) } -/// The orientation to turn back to once the Distraction has expired: the spawn orientation, or the -/// current one for a Creature without a spawn row. `None` while it is active or absent. pub(crate) fn expired_facing(ctx: &ReducerContext, guid: u64) -> Option { if !expired(ctx, guid) { return None; @@ -115,14 +99,12 @@ pub(crate) fn expired_facing(ctx: &ReducerContext, guid: u64) -> Option { }) } -/// End an expired Distraction after its turn back has played. An active one is kept. pub(crate) fn end_expired(ctx: &ReducerContext, guid: u64) { if expired(ctx, guid) { clear(ctx, guid); } } -/// End the Distraction now: a new Engagement, death or despawn. pub(crate) fn clear(ctx: &ReducerContext, guid: u64) { ctx.db .game_creature_distraction() diff --git a/module/src/creatures/eventai/combat.rs b/module/src/creatures/eventai/combat.rs index 8cd7e127a..ea04ddf26 100644 --- a/module/src/creatures/eventai/combat.rs +++ b/module/src/creatures/eventai/combat.rs @@ -1,7 +1,3 @@ -//! Engaged EventAI conditions, target selection and combat actions. The decisions here are pure -//! logic over the [`EventAiWorld`] Seam, shared by the durable world and the test Scenario. Only -//! `definition_for` and `authored_combat` read durable definitions directly. - use std::cmp::Ordering; use std::collections::HashSet; @@ -62,8 +58,7 @@ fn contexts_for_actors(world: &W, actors: Vec) -> V .collect() } -/// Checks the rule's event condition and may name the actor selected by that condition. -#[allow(clippy::too_many_lines)] // One arm per EventAI event kind. +#[allow(clippy::too_many_lines)] pub(crate) fn condition( world: &W, context: &EventContext, @@ -394,10 +389,6 @@ pub(super) fn execute( let Some(rout_ends_ms) = world.eventai_rout_ends_ms(context.creature_guid) else { return ActionResult::Refused; }; - // A spent window bars the FIXED low-health rout, which is once per engagement. An - // authored flee is not: the source re-runs the action every time its rule fires, so the - // window re-opens once the previous run has finished. Re-stamping an OPEN window would - // instead extend one flee forever. if !rout_window_open(context.now_ms as u32, rout_ends_ms) { world.stamp_eventai_rout( context.creature_guid, @@ -562,20 +553,13 @@ pub(super) fn cast( } } -/// Which compatibility behaviors EventAI owns for this creature. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct AuthoredCombat { - /// An engaged cycle rule owns casting for this creature. pub casting: bool, - /// The definition marks at least one spell for authored ranged-mode posture. T8 consumes this - /// metadata with the Set Ranged Mode action. pub main_spell_posture: bool, - /// The script owns breaking off: the fixed low-health rout is off, and the authored window runs - /// the creature whatever its health and whatever its creature type. pub flee: bool, } -/// Read compatibility ownership from the creature's composed definition. pub(crate) fn authored_combat(ctx: &ReducerContext, creature_guid: u64) -> AuthoredCombat { let mut authored = AuthoredCombat::default(); let definition = definition_for(ctx, creature_guid); @@ -764,8 +748,6 @@ fn actor_is_behind_target(actor: &EventAiUnit, target: &EventAiUnit) -> bool { (direction - target.orientation).cos() < 0.0 } -/// The creature's threat list, hostiles first by threat then by guid, with every target the -/// action could never take already gone. fn ranked_threat( world: &W, creature: &EventAiUnit, @@ -1007,7 +989,6 @@ fn call_for_help( ActionResult::Applied } -/// One candidate at random, or `None` when there are none to choose between. fn pick(choice: u64, candidates: &[T]) -> Option { if candidates.is_empty() { return None; @@ -1024,7 +1005,6 @@ fn distance_sq(first: &EventAiUnit, second: &EventAiUnit) -> f32 { dx * dx + dy * dy + dz * dz } -/// The source zone-combat pulse admits living Characters throughout a dungeon instance. pub(super) fn zone_combat(world: &mut W, creature_guid: u64) -> ActionResult { let Some(creature) = world .eventai_unit(creature_guid) diff --git a/module/src/creatures/eventai/death.rs b/module/src/creatures/eventai/death.rs index 2af32f021..c0211a1c4 100644 --- a/module/src/creatures/eventai/death.rs +++ b/module/src/creatures/eventai/death.rs @@ -1,5 +1,3 @@ -//! EventAI requests owned by combat death authority. - use super::engine::EventAiWorld; use super::{ActionResult, CreatureInstruction, EventContext}; diff --git a/module/src/creatures/eventai/edges.rs b/module/src/creatures/eventai/edges.rs index 74d617e79..32527bc31 100644 --- a/module/src/creatures/eventai/edges.rs +++ b/module/src/creatures/eventai/edges.rs @@ -1,5 +1,3 @@ -//! EventAI edge dispatch and lifecycle resets. - use std::collections::HashSet; use spacetimedb::ReducerContext; @@ -12,14 +10,10 @@ use crate::{ WorldEntity, }; -/// Who runs its entry's EventAI at all. A Character carries no rules, and a pet answers its -/// owner's commands rather than the wild entry it was built from, so a tamed beast must neither -/// evaluate its entry's rules nor keep rule state of its own. pub(crate) fn runs_eventai(creature: &WorldEntity) -> bool { !creature.is_player() && creature.owner_guid == 0 } -/// One unit whose engagement reset waits until the current creature-death hooks finish. #[table(accessor = game_creature_ai_reset_deferral)] pub struct CreatureAiResetDeferral { #[primary_key] @@ -27,7 +21,6 @@ pub struct CreatureAiResetDeferral { pub creature_guid: u64, } -/// A creature whose evade return has not reached its spawn post. #[table(accessor = game_creature_ai_returning_home)] pub struct CreatureAiReturningHome { #[primary_key] @@ -386,11 +379,6 @@ pub(crate) fn eventai_on_summoned( super::evaluate_context(ctx, EventAiRequest::Edge(context)); } -/// Start the creature's next engagement: bump `engagement_id`, drop the phase and ranged posture, -/// and clear the rule state every engagement rule earned in the fight that just ended. Once-only -/// aggro rules re-arm and timed rules wait their initial window again on the next pull. Spawn and -/// death rules keep their state, moved onto the new engagement, because they belong to the -/// lifecycle instead. Called however a fight ends, so no path can leave a creature half-armed. pub(crate) fn reset_engagement(ctx: &ReducerContext, creature_guid: u64) { if ctx .db diff --git a/module/src/creatures/eventai/engine.rs b/module/src/creatures/eventai/engine.rs index d41af2e0a..dc3d03c9c 100644 --- a/module/src/creatures/eventai/engine.rs +++ b/module/src/creatures/eventai/engine.rs @@ -20,13 +20,8 @@ use crate::{ game_melee_attack, game_threat, game_world_entity, WorldEntity, }; -/// The Seam between the EventAI engine and a world: facts read world state, effects change it. -/// Conditions, target selection and action logic live ABOVE this Seam, in `engine`, `combat` and -/// `mobility`, so `DatabaseWorld` and the test Fake run the same decisions. pub(crate) trait EventAiWorld { - // Facts. fn eventai_now_ms(&self) -> u64; - /// The live melee fights whose attacker runs its entry's EventAI, within `scope`. fn eventai_fights(&self, scope: &TickScope) -> Vec; fn eventai_cycle_actors(&self, scope: &TickScope, active: &HashSet) -> Vec; fn eventai_definition(&self, creature_guid: u64) -> EventAiDefinition; @@ -34,10 +29,7 @@ pub(crate) trait EventAiWorld { fn eventai_rule_state(&self, creature_guid: u64, rule_id: u64) -> Option; fn eventai_unit(&self, guid: u64) -> Option; fn eventai_spawner_guid(&self, creature_guid: u64) -> Option; - /// Candidates around `center` in its partition, coarsely: the shared logic re-checks the - /// exact distance. fn eventai_units_near(&self, center: &EventAiUnit, radius_yd: f32) -> Vec; - /// The raw threat rows one creature holds, as `(source guid, threat)`, unordered. fn eventai_threat(&self, creature_guid: u64) -> Vec<(u64, i64)>; fn eventai_has_aura(&self, guid: u64, spell_id: u32) -> bool; fn eventai_aura_stacks(&self, guid: u64, spell_id: u32) -> u32; @@ -52,16 +44,13 @@ pub(crate) trait EventAiWorld { fn eventai_matches_predicate(&self, guid: u64, predicate: EventPredicate) -> bool; fn eventai_in_zone_or_area(&self, unit: &EventAiUnit, zone_or_area_id: u32) -> bool; fn eventai_combat_action_ready(&self, guid: u64) -> bool; - /// The rout clock on this creature's own melee row; `None` without a fight to break off from. fn eventai_rout_ends_ms(&self, creature_guid: u64) -> Option; fn eventai_broadcast(&self, id: u32) -> Option; fn eventai_summon_location(&self, id: u32) -> Option; fn eventai_summon_template_exists(&self, entry: u32) -> bool; fn eventai_roll(&self) -> u32; - // Effects. fn set_eventai_phase(&mut self, creature_guid: u64, phase: u8); - /// Apply one named, reversible creature presentation change for the active lifecycle. fn apply_eventai_presentation( &mut self, creature_guid: u64, @@ -69,7 +58,6 @@ pub(crate) trait EventAiWorld { revision: DefinitionRevision, instruction: super::CreaturePresentationInstruction, ) -> bool; - /// Adopt one normalized definition and clear only reversible state owned by the old revision. fn adopt_eventai_revision( &mut self, creature_guid: u64, @@ -77,10 +65,7 @@ pub(crate) trait EventAiWorld { ) -> CreatureState; fn put_eventai_rule_state(&mut self, creature_guid: u64, rule_id: u64, state: RuleState); fn delete_eventai_rule_state(&mut self, creature_guid: u64, rule_id: u64); - /// Remove state for missing rules on this evaluated creature. Lifecycle edges clean state for - /// creatures that no longer produce evaluation contexts. fn reap_eventai_rule_state(&mut self, creature_guid: u64, valid_rule_ids: &HashSet); - /// Deliver one line through the say/yell chokepoint; `true` when it was spoken. fn eventai_deliver_line( &mut self, speaker_guid: u64, @@ -90,7 +75,6 @@ pub(crate) trait EventAiWorld { message: String, ) -> bool; fn eventai_deliver_emote(&mut self, source_guid: u64, emote_id: u32, target_guid: u64) -> bool; - /// Start the typed cast; `false` is the spell tier's Refusal. fn eventai_start_spell( &mut self, caster: &EventAiUnit, @@ -100,11 +84,8 @@ pub(crate) trait EventAiWorld { interrupt_previous: bool, admission: super::SpellCasterAdmission, ) -> bool; - /// Scale one selected source's existing threat through threat authority. fn eventai_scale_selected_threat(&mut self, operation: crate::threat::ScaleSelectedThreat); - /// Scale every existing threat row through threat authority. fn eventai_scale_all_threat(&mut self, operation: crate::threat::ScaleAllThreat); - /// Route a typed quest-credit instruction through quest authority. fn eventai_credit_quest( &mut self, request: EventAiQuestCredit, @@ -112,17 +93,13 @@ pub(crate) trait EventAiWorld { ) -> QuestCreditOutcome; fn stamp_eventai_rout(&mut self, creature_guid: u64, ends_ms: u32); fn set_eventai_ranged_posture(&mut self, creature_guid: u64, distance_yd: f32, angle_rad: f32); - /// Apply one named movement operation. The cycle remains the only movement-leg authority. fn apply_eventai_movement( &mut self, creature_guid: u64, operation: super::MovementOperation, ) -> bool; - /// The idle friend joins the fight against `victim_guid` as an assist. fn eventai_engage_assist(&mut self, creature_guid: u64, victim_guid: u64); - /// Reserve the next summon sequence number and its lifetime bookkeeping. fn eventai_claim_summon_sequence(&mut self, lifetime_ms: u32) -> u64; - /// Give back a claimed sequence whose summon was refused. fn eventai_release_summon_sequence(&mut self, sequence: u64); fn eventai_place_summon( &mut self, @@ -132,7 +109,6 @@ pub(crate) trait EventAiWorld { location: &SummonLocation, summoner: &EventAiUnit, ); - /// The fresh summon joins the fight against `target_guid`. fn eventai_engage_summon(&mut self, summon_guid: u64, target_guid: u64); fn eventai_start_attack(&mut self, creature_guid: u64, target_guid: u64) -> bool; fn eventai_remove_aura(&mut self, target_guid: u64, spell_id: u32) -> bool; @@ -330,8 +306,7 @@ fn evaluate_rule( }; world.put_eventai_rule_state(context.creature_guid, rule.source_rule_id, open_state); - // Chance is one saved decision per authored opportunity. A miss spends both once-only and - // repeating opportunities; a repeating rule then waits its next window. + // A chance miss spends the saved opportunity and starts the next repeat window. if rule.chance_pct < 100 && invocation_choice(seed, branch, 0) % 100 >= u64::from(rule.chance_pct) { @@ -355,8 +330,7 @@ fn evaluate_rule( let result = execute_instruction(world, &context, instruction, linked_choice); match result { ActionResult::Applied => {} - // A Refusal from a LATER action does not rewind the actions already applied, so it - // spends the opportunity like any other outcome. + // A later action's Refusal keeps earlier effects and spends the opportunity. ActionResult::Refused if index == 0 && rule.execution == ExecutionPolicy::CombatAction => { @@ -575,9 +549,6 @@ fn execute_instruction( } } -/// Resolve one authored speech action into a line and deliver it. A broadcast text carries its own -/// supported chat type; the authored mode is the fallback for other source types. The broadcast -/// text's emote belongs to the line, so a refusal at delivery silences both. fn speak( world: &mut W, context: &EventContext, @@ -658,9 +629,7 @@ fn finish_opportunity( world.put_eventai_rule_state(context.creature_guid, rule.source_rule_id, state); } -/// A Refusal from a combat action's primary instruction keeps its saved opportunity open. The next -/// firing retries the same chance and linked choices. Ordinary rules spend a Refusal, and later -/// instructions never rewind effects already applied. +// A primary combat Refusal retains the saved chance and linked choices for the next firing. fn hold_opportunity_open( world: &mut W, context: &EventContext, @@ -676,8 +645,6 @@ fn hold_opportunity_open( world.put_eventai_rule_state(context.creature_guid, rule.source_rule_id, state); } -/// The rule state a repeat rule takes after an opportunity: its window rolled from now. `None` -/// when the rule has no next opportunity to wait for, which spends it instead. fn repeat_state( world: &W, context: &EventContext, @@ -717,8 +684,6 @@ fn repeat_state( } } -/// Random milliseconds in `[min, max]`. A fixed window costs no roll. The loader refuses an -/// inverted window, and this guard keeps an invalid test definition from wrapping the span. fn roll_window(world: &W, min: u32, max: u32) -> u64 { if max <= min { return min as u64; @@ -1370,8 +1335,6 @@ impl EventAiWorld for DatabaseWorld<'_> { let Some(creature) = self.ctx.db.game_world_entity().guid().find(speaker_guid) else { return false; }; - // The say/yell chokepoint owns the dead-speaker Gate and the length cap; a creature line - // goes through it like a player's rather than writing the event row itself. crate::chat::apply_send_chat_to( self.ctx, creature, @@ -1681,9 +1644,6 @@ impl EventAiWorld for DatabaseWorld<'_> { ) { Ok(_) => true, Err(error) => { - // A relay that cannot start is a catalogue or world-state failure, not a gameplay - // Refusal. The caller can only report it as a refusal, so name it here — otherwise a - // combat rule re-attempts a missing definition every tick with nothing in the log. spacetimedb::log::error!( "eventai: relay {relay_id} failed to start for creature {source_guid} \ (catalogue {catalogue_version}): {error}" diff --git a/module/src/creatures/eventai/fixtures.rs b/module/src/creatures/eventai/fixtures.rs index 8f07bcd35..d4a6db003 100644 --- a/module/src/creatures/eventai/fixtures.rs +++ b/module/src/creatures/eventai/fixtures.rs @@ -28,7 +28,6 @@ const BOSS_CASTS: [(u32, u32, u32, u32, u32, u32); 6] = [ (646, 6432, 6_000, 10_000, 12_000, 17_000), ]; -/// Replace every definition owned by the small development fixture catalogue. pub(crate) fn seed_on_aggro_fixtures(ctx: &ReducerContext) { let definitions = ctx.db.game_creature_ai_definition(); let mut cleared = HashSet::new(); diff --git a/module/src/creatures/eventai/loader.rs b/module/src/creatures/eventai/loader.rs index 36d9a3257..ae515499a 100644 --- a/module/src/creatures/eventai/loader.rs +++ b/module/src/creatures/eventai/loader.rs @@ -1,5 +1,3 @@ -//! Operator-only loading of normalized EventAI definitions. - use std::collections::HashSet; use spacetimedb::{reducer, ReducerContext, Table}; @@ -33,14 +31,12 @@ use crate::{ game_creature_ai_rule_state, game_creature_ai_state, game_melee_attack, game_world_entity, }; -/// Replace all normalized definitions and load the first batch. #[reducer] pub fn import_creature_ai_definitions(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; load_definition_batch(ctx, &packed, true) } -/// Append a validated batch after [`import_creature_ai_definitions`]. #[reducer] pub fn import_creature_ai_definitions_append( ctx: &ReducerContext, @@ -480,7 +476,7 @@ fn parse_recurrence(encoded: &str) -> Result { } } -#[allow(clippy::too_many_lines)] // One arm per instruction kind. +#[allow(clippy::too_many_lines)] fn parse_instruction(encoded: &str) -> Result { let fields: Vec<&str> = encoded.split(':').collect(); match fields.as_slice() { @@ -1053,7 +1049,6 @@ fn parse_threat_percent(value: &str) -> Result { Ok(percent) } -/// Load one definition and keep its creature in combat for the durable integration test. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_stage_eventai_revision_fixture( @@ -1101,7 +1096,6 @@ pub fn debug_stage_eventai_revision_fixture( Ok(()) } -/// Verify the durable fixture from a separate Module call without exposing private tables. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_verify_eventai_revision_fixture( @@ -1412,8 +1406,6 @@ mod tests { assert!(parse_instruction("no-effect:missing-text-template:0").is_err()); } - /// Source chat type 2 is a narrative line, so the importer encodes `emote` beside `say` and - /// `yell`. The loader must round-trip all three or an imported emote silently degrades. #[test] fn every_encoded_speech_mode_round_trips() { for (encoded, expected) in [ diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index a6f4e0041..e330e137a 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -1,5 +1,3 @@ -//! EventAI summon actions and ranged posture. - use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table, TimeDuration}; use super::engine::EventAiWorld; @@ -13,7 +11,6 @@ const SUMMON_CHECK_MS: u32 = 500; const SUMMON_LOW_BAND: u64 = 0x40_0000; const SUMMON_SEQUENCE_MASK: u64 = SUMMON_LOW_BAND - 1; -/// One temporary EventAI summon waiting for its out-of-combat lifetime to finish. Module only. #[table( accessor = game_creature_ai_summon_expiry, scheduled(expire_eventai_summon) @@ -28,17 +25,11 @@ pub struct CreatureAiSummonExpiry { pub lifetime_ms: u32, pub remaining_ms: u32, pub last_checked_ms: u64, - /// Which summon life holds `creature_guid`. A summon guid repeats, because the summon sequence - /// wraps, so the guid alone cannot tell two summon lives apart. This carries the FIRST - /// `scheduled_id` this summon was claimed with, which `auto_inc` never issues twice. It cannot - /// be `scheduled_id` itself: the lifetime check re-inserts this row, so that column names one - /// check rather than one life. - /// END-appended + defaulted (migration rule). + // Summon guids repeat when the sequence wraps; this id distinguishes their lives. #[default(0u64)] pub life_seq: u64, } -/// The EventAI creature that created one temporary summon. Module only. #[table(accessor = game_creature_ai_summon_origin)] pub struct CreatureAiSummonOrigin { #[primary_key] @@ -47,7 +38,6 @@ pub struct CreatureAiSummonOrigin { pub summoner_guid: u64, } -/// One authored delayed source despawn. Module only. #[table( accessor = game_creature_ai_forced_despawn, scheduled(fire_eventai_forced_despawn) @@ -331,9 +321,6 @@ fn applied(applied: bool) -> ActionResult { } } -/// The authored ranged posture holding this creature at `(distance, angle)` from its victim, if -/// one is active. Behind `runs_eventai` like every other EventAI read: a tamed creature answers -/// its owner, never a posture its wild entry's state row may still carry. pub(crate) fn ranged_posture(ctx: &ReducerContext, creature_guid: u64) -> Option<(f32, f32)> { let creature = ctx.db.game_world_entity().guid().find(creature_guid)?; if !super::runs_eventai(&creature) { @@ -347,7 +334,6 @@ pub(crate) fn ranged_posture(ctx: &ReducerContext, creature_guid: u64) -> Option .map(|state| (state.ranged_distance, state.ranged_angle)) } -/// Forget a despawned creature's pending summon-lifetime check. pub(crate) fn drop_summon_expiry(ctx: &ReducerContext, creature_guid: u64) { ctx.db .game_creature_ai_summon_expiry() @@ -406,9 +392,6 @@ fn summon( ActionResult::Applied } -/// Reserve the durable expiry slot whose id is the summon's sequence number. -/// The life number of the summon holding `creature_guid`, or zero when no summon does. Reached -/// through the unique guid index, so this is one probe rather than a scan. pub(crate) fn summon_life_seq(ctx: &ReducerContext, creature_guid: u64) -> u64 { ctx.db .game_creature_ai_summon_expiry() @@ -427,13 +410,11 @@ pub(super) fn claim_summon_sequence(ctx: &ReducerContext, lifetime_ms: u32) -> u lifetime_ms, remaining_ms: lifetime_ms, last_checked_ms: timestamp_ms(ctx), - // `place_summon` stamps this with the id `auto_inc` assigns to this very row. life_seq: 0, }) .scheduled_id } -/// Give back a claimed slot whose summon was refused. pub(super) fn release_summon_sequence(ctx: &ReducerContext, sequence: u64) { ctx.db .game_creature_ai_summon_expiry() @@ -452,8 +433,7 @@ pub(super) fn place_summon( let expiry_table = ctx.db.game_creature_ai_summon_expiry(); if let Some(mut expiry) = expiry_table.scheduled_id().find(sequence) { expiry.creature_guid = guid; - // The claim's own id, pinned before any lifetime check can re-insert the row and take a - // new one. This is the number that names this summon life for as long as it stands. + // Capture the claim id before re-insertion can assign a different lifetime-check id. expiry.life_seq = sequence; expiry_table.scheduled_id().update(expiry); } @@ -521,7 +501,6 @@ pub(super) fn place_relay_summon( Ok(guid) } -/// Place a temporary creature through the shared summon identity, origin and expiry lifecycle. pub(crate) fn place_temporary_summon( ctx: &ReducerContext, caster: &crate::WorldEntity, @@ -569,9 +548,6 @@ fn next_check_ms(remaining_ms: u32) -> u32 { remaining_ms.clamp(1, SUMMON_CHECK_MS) } -/// One lifetime check on a temporary summon: the out-of-combat ms it has left after `elapsed_ms`, -/// or `None` when its time is up and it despawns. A fight refills the whole lifetime, so an -/// engaged summon never runs out mid-swing. pub(crate) fn summon_lifetime_after( engaged: bool, lifetime_ms: u32, @@ -613,8 +589,7 @@ fn apply_summon_expiry(ctx: &ReducerContext, expiry: CreatureAiSummonExpiry) { if current.creature_guid != expiry.creature_guid || current.life_seq != expiry.life_seq { return; } - // The scheduled row still holds its unique creature key during this transaction. Remove - // this check before inserting the next one; an obsolete callback must not remove a newer row. + // Remove only this check before rearming; an obsolete callback must not remove its successor. expiries.scheduled_id().delete(expiry.scheduled_id); let Some(creature) = ctx.db.game_world_entity().guid().find(expiry.creature_guid) else { crate::creatures::reset_creature_lifecycle(ctx, expiry.creature_guid); @@ -648,7 +623,6 @@ fn apply_summon_expiry(ctx: &ReducerContext, expiry: CreatureAiSummonExpiry) { lifetime_ms: expiry.lifetime_ms, remaining_ms, last_checked_ms: now_ms, - // Carried, not re-taken: the summon is the same life across its lifetime checks. life_seq: expiry.life_seq, }); } diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index e31d14303..38dae0977 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -1,5 +1,3 @@ -//! Native creature EventAI rules and their durable state. - mod engine; mod fixtures; mod loader; @@ -104,7 +102,6 @@ pub use tables::{ use spacetimedb::ReducerContext; -/// Run one explicit EventAI request against durable Module state. pub(crate) fn evaluate_context(ctx: &ReducerContext, request: EventAiRequest) -> u64 { engine::evaluate(&mut engine::DatabaseWorld::new(ctx), request) } diff --git a/module/src/creatures/eventai/model.rs b/module/src/creatures/eventai/model.rs index 640c775c6..70ab960b1 100644 --- a/module/src/creatures/eventai/model.rs +++ b/module/src/creatures/eventai/model.rs @@ -257,7 +257,6 @@ impl EventCondition { pub enum RecurrencePolicy { Once, Repeat(TimeWindow), - /// Every distinct edge may fire. There is no authored cooldown window. RepeatOnEvent, } @@ -340,9 +339,6 @@ pub(crate) enum SpellCastTarget { pub enum SpeechMode { Say, Yell, - /// Source chat type 2: a third-person narrative line. It always carries the universal language - /// and reaches clients as a monster emote rather than speech. Appended last so an existing - /// encoded rule keeps its variant index. Emote, BossEmote, ZoneYell, @@ -381,7 +377,6 @@ pub struct EmoteInstruction { #[derive(spacetimedb::SpacetimeType, Clone, Debug, Eq, PartialEq)] pub struct RandomEmoteInstruction { - /// Exactly three source choices. A negative choice is the authored empty branch. pub emote_ids: Vec, } @@ -462,7 +457,6 @@ pub struct SetReactStateInstruction { #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub struct RemoveGuardiansInstruction { - /// Zero removes every owned summon. A nonzero entry removes the first matching summon. pub creature_entry: u32, } @@ -748,9 +742,6 @@ pub(crate) enum EventKind { } impl EventKind { - /// Whether a new firing of this event may wait behind the previous firing's repeat window. - /// Aggro, death, evade, spawn, reached-home, and unreachable have no authored cooldown. - /// `RepeatOnEvent` can still re-arm any of those rules for its next edge. pub(crate) fn supports_repeat_cooldown(self) -> bool { !matches!( self, @@ -796,8 +787,6 @@ pub(crate) enum EventAiRequest<'a> { Edge(EventContext), } -/// One live melee fight the engaged pass evaluates: the creature whose rules run, and the victim -/// its contexts point at. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct EngagedFight { pub creature_guid: u64, @@ -811,7 +800,6 @@ pub(crate) struct CycleActor { pub engaged: bool, } -/// One unit as EventAI conditions and target selection read it, free of any table shape. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct EventAiUnit { pub guid: u64, @@ -835,8 +823,6 @@ pub(crate) struct EventAiUnit { pub owner_guid: u64, } -/// One imported broadcast text as speech reads it: the line, the chat type it carries, and the -/// animation emote that travels with it. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct BroadcastLine { pub text: String, @@ -845,7 +831,6 @@ pub(crate) struct BroadcastLine { pub emote: u32, } -/// One authored summon location, with the summon's out-of-combat lifetime. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct SummonLocation { pub x: f32, diff --git a/module/src/creatures/eventai/movement.rs b/module/src/creatures/eventai/movement.rs index 862aaa363..9f7dac636 100644 --- a/module/src/creatures/eventai/movement.rs +++ b/module/src/creatures/eventai/movement.rs @@ -1,5 +1,3 @@ -//! Durable authored movement intent and its named operations. - use spacetimedb::{table, ReducerContext, Table}; use super::edges::game_creature_ai_returning_home; @@ -26,9 +24,6 @@ pub enum AuthoredWalkingMode { WalkWhileChasing, } -/// Runtime movement state belongs to one creature lifecycle and one definition revision. An -/// engagement reset clears only combat-scoped immobilization. Death, despawn, respawn, and a -/// definition revision drop the full row. #[table(accessor = game_creature_ai_movement_intent)] #[derive(Clone)] pub struct CreatureAiMovementIntent { @@ -59,8 +54,6 @@ pub struct CreatureAiMovementIntent { pub facing_target_guid: u64, } -/// An explicit authored patrol path for one creature entry on one map. Path zero never reads this -/// table. It always means that spawn's existing `game_creature_waypoint` route. #[table( accessor = game_creature_ai_movement_path_waypoint, index(accessor = by_subject, btree(columns = [creature_entry, map_id, path_id])) diff --git a/module/src/creatures/eventai/quest_credit.rs b/module/src/creatures/eventai/quest_credit.rs index b0c52c2e6..75e2798c1 100644 --- a/module/src/creatures/eventai/quest_credit.rs +++ b/module/src/creatures/eventai/quest_credit.rs @@ -1,5 +1,3 @@ -//! EventAI's typed path into quest authority. - use super::engine::EventAiWorld; use super::{ActionResult, CreatureInstruction, EventContext}; use crate::quest::{EventAiQuestCreditContext, QuestCreditOutcome}; diff --git a/module/src/creatures/eventai/relay.rs b/module/src/creatures/eventai/relay.rs index 8573b0b7d..255b1fe61 100644 --- a/module/src/creatures/eventai/relay.rs +++ b/module/src/creatures/eventai/relay.rs @@ -283,11 +283,6 @@ pub struct RelayRun { pub concurrency: RelayConcurrency, pub lifetime: RelayLifetime, pub parent_run_id: u64, - /// Which life of `source_guid` started this run. The guid is reused when the creature respawns, - /// so a run that outlives its source would otherwise resolve to whichever creature holds the - /// guid next. Cleanup at the respawn boundary removes such a run; this field is what makes a - /// run that survives anyway refuse to act instead of acting on the wrong creature. - /// END-appended + defaulted (migration rule). #[default(0u64)] pub source_life_seq: u64, } @@ -305,7 +300,6 @@ pub struct RelayContinuation { pub run_id: u64, } -/// A dynamic movement completion that starts another relay from the same catalogue. Module only. #[table( accessor = game_creature_ai_relay_arrival, index(accessor = by_instance, btree(columns = [instance_id])), @@ -405,7 +399,6 @@ pub(crate) fn replace_single_relay_for_debug( replace_relays_for_debug(ctx, &[(relay_id, "source>source", instruction)]) } -/// Replace the catalogue with one-step relays, each `(relay_id, participants, instruction)`. #[cfg(feature = "debug_reducers")] pub(crate) fn replace_relays_for_debug( ctx: &ReducerContext, @@ -1272,10 +1265,7 @@ fn advance_run(ctx: &ReducerContext, run_id: u64) -> Result<(), String> { let Some(mut run) = runs.id().find(run_id) else { return Ok(()); }; - // The source creature keeps its guid across death and respawn. A run started by an earlier life - // must not act on the creature standing here now, so it ends here rather than continuing against - // the wrong one. Cleanup at the respawn boundary normally removes such a run first; this is what - // holds if one reaches this point anyway. + // A respawn reuses its guid; an earlier life's Relay Run must end here. if run.source_life_seq != crate::creatures::current_life_seq(ctx, run.source_guid) { cancel_run_tree(ctx, run_id); reap_unused_definitions(ctx); @@ -1317,9 +1307,6 @@ fn advance_run(ctx: &ReducerContext, run_id: u64) -> Result<(), String> { let present = subject_present(ctx, &fresh, terminate.subject); let terminate_now = termination_matches(terminate.when, present); if terminate_now { - // An authored terminate ends the whole invocation, not just this run: its - // nested relays and pending arrivals belong to the same invocation and - // would otherwise keep playing after the script said stop. cancel_run_tree(ctx, run_id); return Ok(()); } @@ -1395,7 +1382,7 @@ fn apply_leaf( Ok(()) } -#[allow(clippy::too_many_lines)] // One arm per relay leaf kind. +#[allow(clippy::too_many_lines)] fn apply_leaf_for_pair( ctx: &ReducerContext, run: &mut RelayRun, @@ -1716,9 +1703,7 @@ fn schedule_arrival( parent_run_id: run.id, relay_id, catalogue_version: run.catalogue_version, - // The cleanup policy recorded here must be the one the arrival will actually run under. - // `start_relay` takes it from the arrival relay's own definition, so reading the parent - // run's lifetime would let cleanup apply a policy the resulting run never has. + // Record the arrival relay's own cleanup policy, which may differ from its parent. lifetime: arrival_lifetime, saved_random_state: child_state, spline_id, @@ -1729,15 +1714,10 @@ fn schedule_arrival( Ok(()) } -/// Where a due Relay Arrival finds its mover. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ArrivalCheck { - /// The mover died, left the partition, took a newer leg or stands off the destination. Stale, - /// The mover's live leg is still the arrival leg. It has landed, but the creature tick has not - /// stored its end yet. LegLanded, - /// The mover has no leg of its own and stands on the destination. OnDestination, } @@ -1756,7 +1736,6 @@ fn check_arrival( ) } -/// A newer leg carries another spline id, so a matching leg needs no time check. fn arrival_motion( source_position: (f32, f32, f32), current_spline_id: Option, @@ -1777,7 +1756,6 @@ fn arrival_motion( } } -/// Source dynamic movement is accepted but does not replace an active combat movement generator. fn dynamic_move_should_start(engaged: bool) -> bool { !engaged } @@ -1790,7 +1768,6 @@ pub fn resume_relay_arrival(ctx: &ReducerContext, arrival: RelayArrival) { run_relay_arrival(ctx, arrival); } -/// Start the arrival relay if the mover still waits on this arrival. pub(crate) fn run_relay_arrival(ctx: &ReducerContext, arrival: RelayArrival) { let Some(mut source) = ctx.db.game_world_entity().guid().find(arrival.source_guid) else { reap_unused_definitions(ctx); @@ -1808,8 +1785,7 @@ pub(crate) fn run_relay_arrival(ctx: &ReducerContext, arrival: RelayArrival) { } ArrivalCheck::OnDestination => {} ArrivalCheck::LegLanded => { - // Land the leg as the creature tick would, so the arrival relay reads the mover on the - // destination and not on a moving leg. + // Land the leg before the arrival relay reads the mover's position. crate::creatures::tick::place_stopped( &mut source, crate::creatures::cycle::Stop { @@ -2147,12 +2123,6 @@ pub(crate) fn cancel_relay_runs_for_instance(ctx: &ReducerContext, instance_id: reap_unused_definitions(ctx); } -/// End every Relay Run and arrival held by one source creature, whatever its lifetime. -/// -/// A creature respawns under the guid it already had, so a run started by an earlier life would -/// otherwise resolve its source to the new creature and play the rest of the previous life's script -/// against it. Death is the wrong boundary for this: a death-triggered relay is authored to act on -/// the corpse, and the corpse window is the whole point. Respawn is the boundary no run may cross. pub(crate) fn cancel_relay_runs_for_new_life(ctx: &ReducerContext, source_guid: u64) { let arrivals = ctx.db.game_creature_ai_relay_arrival(); let mut removed_reference = false; @@ -2628,7 +2598,6 @@ mod tests { y: destination.1, z: destination.2, }; - // (mover position, mover's spline id, the arrival leg's spline id, expected check) let cases = [ (destination, Some(7), 7, LegLanded), (away, Some(7), 7, LegLanded), diff --git a/module/src/creatures/eventai/tables.rs b/module/src/creatures/eventai/tables.rs index 8b68c4b8c..8e67faf37 100644 --- a/module/src/creatures/eventai/tables.rs +++ b/module/src/creatures/eventai/tables.rs @@ -2,8 +2,7 @@ use spacetimedb::table; use super::EventAiRule; -/// Retained flat EventAI migration row. Runtime and importer code use -/// [`CreatureAiDefinition`]; this schema stays unchanged for additive migration safety. +// Retain this flat schema for additive migration; runtime uses CreatureAiDefinition. #[table( accessor = game_creature_ai_event, index(accessor = by_entry, btree(columns = [creature_entry])), @@ -66,7 +65,6 @@ pub struct CreatureAiEvent { pub cast_options: u32, } -/// One normalized native EventAI definition for an entry or one remapped creature guid. #[table( accessor = game_creature_ai_definition, index(accessor = by_entry, btree(columns = [creature_entry])), @@ -82,7 +80,6 @@ pub struct CreatureAiDefinition { pub rules: Vec, } -/// Broadcast text referenced by EventAI actions. Module only. #[table(accessor = game_creature_ai_broadcast_text)] pub struct CreatureAiBroadcastText { #[primary_key] @@ -99,7 +96,6 @@ pub struct CreatureAiBroadcastText { pub emote_id_3: u32, } -/// Spell metadata used only while EventAI selects an event subject. #[table(accessor = game_creature_ai_spell_metadata)] pub struct CreatureAiSpellMetadata { #[primary_key] @@ -107,7 +103,6 @@ pub struct CreatureAiSpellMetadata { pub exclude_caster: bool, } -/// Creature-wide EventAI state. Edge resets advance its lifecycle identities. Module only. #[table( accessor = game_creature_ai_state, index(accessor = by_active_object, btree(columns = [active_object])) @@ -124,17 +119,14 @@ pub struct CreatureAiState { pub ranged_posture_active: bool, #[default(0u64)] pub definition_revision: u64, - /// Source `SetActiveObjectState`: an active creature remains in the cycle outside player cells. #[default(false)] pub active_object: bool, - /// Passive 0, defensive 1, aggressive 2. Fresh EventAI creatures are aggressive. #[default(2u8)] pub react_state: u8, #[default(true)] pub melee_enabled: bool, } -/// Timing and consumption state for one creature and one effective source rule. Module only. #[table( accessor = game_creature_ai_rule_state, index(accessor = by_creature, btree(columns = [creature_guid])) @@ -149,22 +141,19 @@ pub struct CreatureAiRuleState { pub consumed: bool, pub lifecycle_id: u64, pub engagement_id: u64, - /// Saved for every linked choice while an authored opportunity remains open. #[default(0u64)] pub invocation_seed: u64, #[default(false)] pub invocation_started: bool, - /// Prevents a synchronous spell or action hook from re-entering this rule. + // Prevent synchronous spell or action hooks from re-entering this rule. #[default(false)] pub executing: bool, #[default(0u32)] pub invocation_branch: u32, - /// When an authored phase gate stopped this rule's timer. Zero means the timer is running. #[default(0u64)] pub paused_at_ms: u64, } -/// Imported EventAI summon placement. Module only. #[table(accessor = game_creature_ai_summon)] pub struct CreatureAiSummon { #[primary_key] diff --git a/module/src/creatures/eventai/threat.rs b/module/src/creatures/eventai/threat.rs index 581fc9fe6..5bc0ec130 100644 --- a/module/src/creatures/eventai/threat.rs +++ b/module/src/creatures/eventai/threat.rs @@ -1,5 +1,3 @@ -//! EventAI percent-threat instructions. - use super::engine::EventAiWorld; use super::{ ActionResult, CreatureInstruction, EventContext, ScaleAllThreatInstruction, diff --git a/module/src/creatures/hostile_summon.rs b/module/src/creatures/hostile_summon.rs index b6af4222d..a9a434565 100644 --- a/module/src/creatures/hostile_summon.rs +++ b/module/src/creatures/hostile_summon.rs @@ -5,7 +5,6 @@ use crate::{ game_creature_template, game_gameobject, game_gameobject_template, GameObject, WorldEntity, }; -/// Resolve the required focus in the caster's partition before a cast spends anything. pub(crate) fn check_hostile_summon( ctx: &ReducerContext, caster: &WorldEntity, diff --git a/module/src/creatures/hunter_pet.rs b/module/src/creatures/hunter_pet.rs index 92fb62df1..4366e19e0 100644 --- a/module/src/creatures/hunter_pet.rs +++ b/module/src/creatures/hunter_pet.rs @@ -1,5 +1,3 @@ -//! Durable Hunter-pet identity and the wild-creature-to-live-pet transition. - use lyracore_shared::pet::pet_guid_for; use spacetimedb::{table, ReducerContext, Table, TimeDuration, Timestamp}; @@ -11,8 +9,6 @@ use crate::{ const CLASS_HUNTER: u8 = 3; const INITIAL_HAPPINESS: u32 = 166_500; -/// The one durable Hunter-pet identity currently owned by a character. The table is module-private; -/// callers read a pet through its owner lookup below. #[derive(Clone)] #[table( accessor = game_hunter_pet, @@ -36,8 +32,6 @@ pub struct HunterPet { pub loyalty_updated_at: Timestamp, } -/// Client-facing Hunter-pet state. This intentionally omits care timestamps, loyalty progress and -/// rename eligibility; the gateway needs only these fields to render the owner's live pet. #[table( accessor = game_hunter_pet_protocol, public, @@ -79,13 +73,10 @@ crate::character_owned!(transfer, fn sweep_transfer_game_hunter_pet_protocol(ctx keep_key, }); -/// Explicit kind for a live owned creature. A missing row is the safe legacy default: any old -/// `owner_guid != 0` entity remains a summoned pet. #[table(accessor = game_live_pet_kind)] pub struct LivePetKind { #[primary_key] pub pet_guid: u64, - /// Zero means summoned; a non-zero value names the durable Hunter identity. pub hunter_pet_id: u64, } @@ -110,8 +101,6 @@ pub(crate) fn hunter_pet_for_owner(ctx: &ReducerContext, owner_guid: u64) -> Opt .next() } -/// Publish the bounded protocol projection after a durable identity write. Care/progression modules -/// call this at their identity-update seam so the gateway sees one owner-addressed delta. pub(crate) fn publish_hunter_pet_protocol( ctx: &ReducerContext, pet: &HunterPet, @@ -251,7 +240,6 @@ fn eligibility( } } -/// Read-only tame gate used by spell completion before cost or any other write. pub(crate) fn validate_tame( ctx: &ReducerContext, caster: &WorldEntity, @@ -263,10 +251,6 @@ pub(crate) fn validate_tame( .map_err(str::to_string) } -/// Convert the existing wild row into the Hunter's live pet and create its durable identity. A -/// durable row left by a despawned pet (logout, owner death, Dismiss, pet death) is replaced — -/// retaming is the slice's only recovery path. All validation is repeated immediately before the -/// first write, so a delayed completion cannot use stale target or owner state. pub(crate) fn tame_creature( ctx: &ReducerContext, caster_guid: u64, @@ -292,8 +276,6 @@ pub(crate) fn tame_creature( let family_id = template.creature_family as u32; let pet_id = caster.guid; - // The preserved row retains entry/family-derived template, display, level and exact - // current/max health ratio. target.owner_guid = caster.guid; target.faction_template = caster.faction_template; target.target_guid = 0; @@ -303,10 +285,7 @@ pub(crate) fn tame_creature( target.combat_until_ms = 0; target.unit_flags &= !lyracore_shared::constants::unit_flags::IN_COMBAT; - // Replace the wild guid with the existing owner-derived pet guid. Keep the authored spawn and - // arm its next appearance in the future: taming retires this individual creature, not the world - // spawn point that produced it. The live pet reaches the normal creature-create relay, including - // summon fields and pet bar. + // Taming retires this wild life; preserve its authored spawn and arm its future appearance. let wild_guid = target.guid; let pet_guid = pet_guid_for(caster.guid); let creature_entry = target.entry; @@ -345,7 +324,6 @@ pub(crate) fn tame_creature( care_updated_at: now, loyalty_updated_at: now, }; - // Implicit abandon on retame: drop the orphaned identity so the keyed insert cannot collide. ctx.db.game_hunter_pet().pet_id().delete(pet_id); let hunter_pet = ctx.db.game_hunter_pet().insert(hunter_pet); ctx.db.game_live_pet_kind().insert(LivePetKind { @@ -383,7 +361,6 @@ mod tests { assert_eq!(eligible().validate(), Ok(())); } - /// One gate broken in an otherwise valid tame. type BreakGate = Box; #[test] diff --git a/module/src/creatures/mod.rs b/module/src/creatures/mod.rs index 8d248ca60..230af32d4 100644 --- a/module/src/creatures/mod.rs +++ b/module/src/creatures/mod.rs @@ -1,17 +1,3 @@ -//! Tier 2 (NPCs): server-authored creatures. Static template + patrol waypoints, the broadcast -//! creature-move event the gateway relays as `SMSG_MONSTER_MOVE`, and the scheduled patrol tick. -//! The live creature is a `game_world_entity` row of type Unit (see `world.rs`). [static]/[event] -//! -//! Split into submodules — every `crate::creatures::` path still resolves via the re-exports -//! below: -//! - [`spawn`] — the creature tables + entity builders + stat rolls + the content importer -//! - [`ai`] — pure movement/flee/chase/wander geometry + the AI tuning consts (+ the unit tests) -//! - [`tick`] — the move-event tables + the `tick_creatures` pass pipeline -//! - [`pet`] — the warlock pet system (summon/despawn/follow), riding the creature machinery above -//! - [`hunter_pet`] — durable Hunter-pet identity and the completed-tame transition -//! - [`pet_progression`] — Hunter-pet XP, levels, live-stat refresh and loyalty -//! - [`distraction`]: the Distraction a Distract cast puts on an idle Creature - mod ai; pub(crate) mod cycle; pub(crate) mod distraction; diff --git a/module/src/creatures/pet.rs b/module/src/creatures/pet.rs index 683a415a4..e1f93d3ad 100644 --- a/module/src/creatures/pet.rs +++ b/module/src/creatures/pet.rs @@ -1,27 +1,3 @@ -//! Warlock PET system (Tier 3b) — a SUMMONED pet is a normal creature `game_world_entity` (no PLAYER -//! bit) with `owner_guid` set to the controlling player. It rides the EXISTING creature machinery: the -//! chase/melee/swing/relay passes carry it for free; the only pet-specific code is here — -//! - `summon_pet` : the E_SUMMON_PET effect handler — despawn the old pet, then spawn the new -//! one at the caster (owner_guid = caster). ONE pet per owner. -//! - `build_pet_entity`: wraps `build_creature_entity` (template → row) + stamps owner_guid + -//! positions the pet at the owner. It inserts ONLY the live entity — NO `game_creature_spawn` -//! row, so the respawn/decay/return-home passes never touch a pet. -//! - `despawn_pets` : delete the owner's pet (free its melee row + threat). Called on owner -//! logout/death + on a re-summon. -//! -//! The PER-TICK decision (take the owner's foe, stand down, trail the owner, despawn) is NOT here — -//! it is the behavior cycle's pet phase (`creatures::cycle`), which reads this module through the -//! small `pub(crate)` queries below. What stays here is the pet's identity: its tables, its bar -//! state, its guid encoding, and summon/despawn. -//! -//! Everything is GENERIC over `owner_guid != 0` + the `E_SUMMON_PET` kind — NO per-spell-id branch. The -//! Imp CASTS Firebolt (3110) via DATA, not code: the importer emits a `game_creature_cast` row keyed to -//! the Imp's entry (416) from cmangos `creature_template_spells`, and the cycle's cast phase (run right -//! after the pet phase in the same sense tick) fires it at the pet's melee target with ZERO pet-specific -//! engine code — the SAME engine + SAME cast table the wild caster mobs use. The Imp KEEPS its melee row -//! (armed by the pet phase) so it still chases/swings as a fallback; `ranged_spell_id` stays 0 (the cast -//! is independent of the wand/ranged-auto-attack field). [entity] - use lyracore_shared::constants; use lyracore_shared::pet::pet_guid_for; #[cfg(feature = "debug_reducers")] @@ -38,45 +14,29 @@ use super::spawn::{build_creature_entity, CreatureSpawn}; pub struct PetCommand { #[primary_key] pub owner_guid: u64, - /// COMMAND_* — governs movement + explicit attack. pub command: u8, - /// REACT_* — governs auto-engage. pub react: u8, pub command_target: u64, } -// Character-owned sweep: a pet-command row is ephemeral per-owner state keyed by the owner character's -// guid, removed with the character. Private/module-only (no `owner_identity`) → `delete` only. crate::character_owned!(delete, fn sweep_delete_game_pet_command(ctx, character_guid) { ctx.db.game_pet_command().owner_guid().delete(character_guid); }); -// CROSS-DATABASE transport: the row is EPHEMERAL bar state for a live pet ENTITY, and -// the pet entity does not cross the boundary (it is a `game_world_entity`, not character-owned) — -// it despawns with the owner and is re-summoned on the far side, which re-derives the default -// Follow+Defensive the absent row already means. Not transported, by decision. crate::character_owned!(not_transported, fn sweep_transfer_game_pet_command()); -// Wire numbering — gtker PetCommandState / mangos CommandStates. MUST match CMSG_PET_ACTION.data + the -// SMSG_PET_SPELLS bar packing (build_pet_spells); do NOT reorder. `pub(crate)` for the behavior -// cycle's pet phase, which reads a stored id back into its own command/react enums. +// Match CMSG_PET_ACTION.data and SMSG_PET_SPELLS command numbering. pub(crate) const CMD_STAY: u8 = 0; const CMD_FOLLOW: u8 = 1; pub(crate) const CMD_ATTACK: u8 = 2; const CMD_DISMISS: u8 = 3; -// gtker PetReactState / mangos ReactStates. pub(crate) const REACT_PASSIVE: u8 = 0; const REACT_DEFENSIVE: u8 = 1; pub(crate) const REACT_AGGRESSIVE: u8 = 2; -// The flag byte (high 8 bits) of the packed CMSG_PET_ACTION.data (mangos ActiveStates). const ACT_COMMAND: u8 = 0x07; const ACT_REACTION: u8 = 0x06; -/// How near (yd², squared) an AGGRESSIVE idle pet proactively seeks a hostile around ITSELF. (10 yd)². const PET_AGGRO_SEEK_SQ: f32 = 100.0; -/// The owner's stored pet command/react state, or the vanilla default (Follow + Defensive, no target) -/// when no row exists — the absent-row default is exactly the derived behavior pets had before the -/// bar existed. Read by the behavior cycle's pet phase. [entity] pub(crate) fn pet_command_state(ctx: &ReducerContext, owner_guid: u64) -> (u8, u8, u64) { ctx.db .game_pet_command() @@ -86,7 +46,6 @@ pub(crate) fn pet_command_state(ctx: &ReducerContext, owner_guid: u64) -> (u8, u .unwrap_or((CMD_FOLLOW, REACT_DEFENSIVE, 0)) } -/// Upsert the owner's pet command/react state. fn set_pet_command( ctx: &ReducerContext, owner_guid: u64, @@ -108,8 +67,6 @@ fn set_pet_command( } } -/// A valid thing for a pet to attack: exists, alive, not the pet/owner/a player, and not friendly to -/// the owner. Mirrors the inline guard the original auto-assist used. [entity] fn is_valid_pet_target( ctx: &ReducerContext, owner: &WorldEntity, @@ -129,9 +86,6 @@ fn is_valid_pet_target( } } -/// For an AGGRESSIVE pet whose owner is idle: the nearest alive hostile (non-player, non-pet, -/// non-friendly) creature within `PET_AGGRO_SEEK_SQ` of the PET, on the pet's map/instance. `None` if -/// nothing is in range. A full-table scan (pets are rare — one per online warlock). [entity] fn nearest_hostile_near( ctx: &ReducerContext, owner: &WorldEntity, @@ -140,7 +94,7 @@ fn nearest_hostile_near( let mut best: Option<(u64, f32)> = None; for e in ctx.db.game_world_entity().iter() { if e.guid == pet.guid || e.owner_guid != 0 || e.is_player() || e.dead { - continue; // skip the pet itself, other pets, players, corpses + continue; } if e.map_id != pet.map_id || e.instance_id != pet.instance_id { continue; @@ -157,45 +111,35 @@ fn nearest_hostile_near( best.map(|(g, _)| g) } -/// Shared core (also driven by `debug_pet_command` for headless tests): decode + apply one pet-bar -/// action for `owner_guid`. Errors (no pet, unknown id) are transient — the gateway swallows them. pub(crate) fn apply_pet_command( ctx: &ReducerContext, owner_guid: u64, data: u32, target_guid: u64, ) -> Result<(), String> { - // Ownership gate: the caller must actually have a pet. pet_of(ctx, owner_guid).ok_or_else(|| "no pet".to_string())?; let flag = ((data >> 24) & 0xFF) as u8; - let id = (data & 0x00FF_FFFF) as u8; // command/react ids are small (0..3) + let id = (data & 0x00FF_FFFF) as u8; let (cmd, react, cmd_target) = pet_command_state(ctx, owner_guid); match flag { ACT_COMMAND => match id { CMD_STAY => set_pet_command(ctx, owner_guid, CMD_STAY, react, 0), CMD_FOLLOW => set_pet_command(ctx, owner_guid, CMD_FOLLOW, react, 0), CMD_ATTACK => set_pet_command(ctx, owner_guid, CMD_ATTACK, react, target_guid), - CMD_DISMISS => despawn_pets(ctx, owner_guid), // despawns the pet + clears the row + CMD_DISMISS => despawn_pets(ctx, owner_guid), other => return Err(format!("unknown pet command id {other}")), }, ACT_REACTION => { if id > REACT_AGGRESSIVE { return Err(format!("unknown pet react id {id}")); } - // Change react; keep the current command + its attack target. set_pet_command(ctx, owner_guid, cmd, id, cmd_target); } - // A bar spell-cast / autocast toggle (ACT_ENABLED/DISABLED/PASSIVE) — v1 no-op (the Imp casts - // Firebolt via the data-driven game_creature_cast path, not the bar). _ => {} } Ok(()) } -/// Headless drive of a pet-bar action for `owner_guid` — the machine-test counterpart to the -/// `pet_command` reducer (which is `ctx.sender`-bound and so undrivable from the CLI identity). `data` -/// packed values (decimal): STAY=117440512, FOLLOW=117440513, ATTACK=117440514 (+target), -/// DISMISS=117440515, PASSIVE=100663296, DEFENSIVE=100663297, AGGRESSIVE=100663298. [entity] #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_pet_command( @@ -207,9 +151,6 @@ pub fn debug_pet_command( apply_pet_command(ctx, owner_guid, data, target_guid) } -/// The owner's pet, if any. Pet GUIDs are deterministic, so this is one primary-key read followed by -/// an owner check. A missing row or a row that names another owner fails closed. `owner_guid == 0` -/// returns before looking in the wild-creature namespace. [entity] pub(crate) fn pet_of(ctx: &ReducerContext, owner_guid: u64) -> Option { if owner_guid == 0 { return None; @@ -221,43 +162,26 @@ pub(crate) fn pet_of(ctx: &ReducerContext, owner_guid: u64) -> Option Option { let tmpl = ctx.db.game_creature_template().entry().find(entry)?; let pet_guid = pet_guid_for(owner.guid); - // Transient spawn at the owner's position — feeds the builder, never inserted (no respawn/decay row). let spawn = CreatureSpawn { guid: pet_guid, entry, @@ -268,22 +192,17 @@ fn build_pet_entity(ctx: &ReducerContext, owner: &WorldEntity, entry: u32) -> Op orientation: owner.orientation, respawn_at: ctx.timestamp, despawn_at: ctx.timestamp, - movement_type: 0, // IDLE — irrelevant (a pet has no spawn row, so the wander/return passes skip it) - respawn_secs: 0, // irrelevant — this transient spawn is never inserted, so no decay/respawn pass ever reads it + movement_type: 0, + respawn_secs: 0, life_seq: 0, source_guid: 0, instance_id: owner.instance_id, }; let mut pet = build_creature_entity(&spawn, &tmpl, ctx.random(), owner.instance_id); - pet.owner_guid = owner.guid; // THIS is what makes it a pet (the cycle's pet phase keys on owner_guid != 0) + pet.owner_guid = owner.guid; Some(pet) } -/// E_SUMMON_PET handler: summon a persistent pet `entry` owned by `caster_guid` (Summon Imp → an Imp). -/// Despawn any existing pet FIRST (one pet per owner — a re-summon replaces it), then build + insert the -/// new pet at the caster. No-op if the caster left the world or the creature template isn't loaded (logs). -/// Generic over the kind — `entry` is the effect's `p0` (a game_creature_template entry); the engine never -/// names Summon Imp. [entity] pub(crate) fn summon_pet(ctx: &ReducerContext, caster_guid: u64, entry: u32) { if entry == 0 { spacetimedb::log::info!( @@ -293,9 +212,8 @@ pub(crate) fn summon_pet(ctx: &ReducerContext, caster_guid: u64, entry: u32) { } let entities = ctx.db.game_world_entity(); let Some(caster) = entities.guid().find(caster_guid) else { - return; // caster left the world mid-cast + return; }; - // Re-summon: replace any existing pet of this owner (vanilla — one pet at a time). despawn_pets(ctx, caster_guid); match build_pet_entity(ctx, &caster, entry) { Some(pet) => { @@ -309,10 +227,6 @@ pub(crate) fn summon_pet(ctx: &ReducerContext, caster_guid: u64, entry: u32) { } } -/// The enemy `owner` is fighting, or 0 when it is idle — what a DEFENSIVE or AGGRESSIVE pet piles -/// onto. A live `game_melee_attack` row IS the fight, so its target wins; a bare selection counts -/// only while the owner carries the in-combat flag, so a pet does not jump a peacefully targeted -/// NPC. [entity] pub(crate) fn owner_combat_target(ctx: &ReducerContext, owner: &WorldEntity) -> u64 { if let Some(row) = ctx.db.game_melee_attack().attacker_guid().find(owner.guid) { return row.target_guid; @@ -323,9 +237,6 @@ pub(crate) fn owner_combat_target(ctx: &ReducerContext, owner: &WorldEntity) -> 0 } -/// May this pet attack `target_guid` on its owner's behalf? Resolves the owner, then applies the -/// exists/alive/not-a-player/not-friendly guard — the pet's swing path has no faction gate of its -/// own, so this is load-bearing. [entity] pub(crate) fn may_attack( ctx: &ReducerContext, owner_guid: u64, @@ -339,8 +250,6 @@ pub(crate) fn may_attack( .is_some_and(|owner| is_valid_pet_target(ctx, &owner, pet_guid, target_guid)) } -/// The nearest hostile an AGGRESSIVE pet seeks out around ITSELF, within `PET_AGGRO_SEEK_SQ`. -/// `None` when either row is gone or nothing is in range. [entity] pub(crate) fn nearest_hostile_for( ctx: &ReducerContext, owner_guid: u64, @@ -352,15 +261,11 @@ pub(crate) fn nearest_hostile_for( nearest_hostile_near(ctx, &owner, &pet) } -/// Drop a stale ATTACK order — its foe died or vanished — keeping the react stance, so the pet -/// resumes following instead of standing over a corpse. [entity] pub(crate) fn cancel_attack_order(ctx: &ReducerContext, owner_guid: u64) { let (_, react, _) = pet_command_state(ctx, owner_guid); set_pet_command(ctx, owner_guid, CMD_FOLLOW, react, 0); } -/// Despawn one pet BY ITS OWN GUID — the cycle's despawn action. Resolve the row once, then validate -/// its owner and deterministic identity before cleanup. A wild or malformed row fails closed. [entity] pub(crate) fn despawn_pet(ctx: &ReducerContext, pet_guid: u64) { let Some(pet) = ctx.db.game_world_entity().guid().find(pet_guid) else { return; diff --git a/module/src/creatures/pet_care.rs b/module/src/creatures/pet_care.rs index da214415e..ea8754d4e 100644 --- a/module/src/creatures/pet_care.rs +++ b/module/src/creatures/pet_care.rs @@ -1,5 +1,3 @@ -//! Hunter-pet feeding, happiness decay and white-swing damage scaling. - use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{ @@ -7,14 +5,10 @@ use crate::{ game_live_pet_kind, game_spell_effect, game_world_entity, HunterPet, WorldEntity, }; -/// One Vanilla happiness band. The three visible states are `[0, band)`, `[band, 2*band)` and -/// `[2*band, max]`. pub const HAPPINESS_BAND: u32 = 333_000; pub const MAX_HAPPINESS: u32 = 1_000_000; pub const CARE_INTERVAL_MICROS: i64 = 7_500_000; -/// The single global care clock. Each durable pet carries its own last-applied timestamp, so a late -/// firing catches up by whole intervals and produces the same result as regular firings. #[table(accessor = game_pet_care_schedule, scheduled(tick_pet_care))] pub struct PetCareSchedule { #[primary_key] @@ -53,8 +47,6 @@ fn scale_damage(range: (u32, u32), happiness: u32) -> (u32, u32) { (range.0 * pct / 100, range.1 * pct / 100) } -/// Apply happiness only to the live white-swing range of a durable Hunter pet. Missing and explicit -/// summoned kinds are intentionally identical to ordinary creatures. pub(crate) fn scale_hunter_pet_swing( ctx: &ReducerContext, attacker_guid: u64, @@ -76,7 +68,6 @@ pub(crate) fn scale_hunter_pet_swing( } fn decay_per_interval(loyalty_level: u8, in_combat: bool) -> u32 { - // CMaNGOS: (140 >> loyalty) * 125 each 7.5s, with its documented 1.5x combat estimate. let normal = (140u32 >> loyalty_level.min(7)) * 125; if in_combat { normal.saturating_mul(3) / 2 @@ -85,8 +76,6 @@ fn decay_per_interval(loyalty_level: u8, in_combat: bool) -> u32 { } } -/// Returns `(new happiness, whole elapsed intervals consumed)`. Remainders stay on the durable -/// timestamp so many regular calls and one catch-up call are exactly equivalent. #[cfg(test)] fn decay_elapsed( happiness: u32, @@ -111,9 +100,6 @@ fn update_care(ctx: &ReducerContext, mut pet: HunterPet) { .find(live.guid) .is_some_and(|kind| kind.hunter_pet_id == pet.pet_id) }) else { - // Care pauses while no live pet is materialized — no write, so an absent pet does not - // churn its row every tick. A retame replaces the row with a fresh anchor; any future - // restore path must reset `care_updated_at` itself or the gap becomes a catch-up penalty. return; }; let in_combat = live.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0; @@ -146,8 +132,6 @@ fn update_care(ctx: &ReducerContext, mut pet: HunterPet) { .find(pet_id) .expect("care pet remains present during its transaction"); - // At both lower bounds every later interval is the same no-op. Advance the time anchor in - // one write instead of looping over an arbitrarily long scheduler outage. if pet.happiness == old_happiness && !loyalty_changed { pet.care_updated_at = Timestamp::from_micros_since_unix_epoch( last.saturating_add(intervals.saturating_mul(CARE_INTERVAL_MICROS)), @@ -226,8 +210,6 @@ impl FeedEligibility { } } -/// Resolve a generic item-target spell. The spell effect kind, never its id, selects feeding; the -/// target slot names the exact stack consumed after every pet and food gate succeeds. pub(crate) fn apply_item_target_spell( ctx: &ReducerContext, actor_guid: u64, @@ -312,8 +294,6 @@ fn feed_item(ctx: &ReducerContext, actor_guid: u64, slot: u8) -> Result<(), Stri Ok(()) } -/// Hunter pets lose one full happiness band at the shared pet-death chokepoint. The live entity is -/// deleted immediately afterwards, so this can run only once for that death. pub(crate) fn on_pet_death(ctx: &ReducerContext, pet_guid: u64) { let Some(kind) = ctx .db @@ -349,7 +329,6 @@ mod tests { } } - /// One gate broken in an otherwise valid feed. type BreakGate = Box; #[test] diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index 88c3770ac..b0c4a869b 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -1,5 +1,3 @@ -//! Hunter-pet XP, level-dependent live stats and loyalty progression. - use spacetimedb::ReducerContext; use crate::{game_creature_template, game_hunter_pet, game_world_entity, HunterPet}; @@ -8,8 +6,6 @@ const MAX_LOYALTY_LEVEL: u8 = 6; const LOYALTY_LEVEL_UP: [u32; 6] = [5_500, 11_500, 17_000, 23_500, 31_000, 39_500]; const LOYALTY_LEVEL_START: [u32; 6] = [2_000, 4_500, 7_000, 10_000, 13_500, 17_500]; -/// The care state T2 passes after updating happiness. Keeping the state explicit leaves happiness -/// thresholds and scheduling in the care module while progression owns only loyalty transitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PetHappinessState { Happy, @@ -23,7 +19,6 @@ struct PetProgress { xp: u32, } -/// Vanilla Hunter pets need one quarter of the player XP for the same level transition. pub(crate) fn pet_xp_to_next_level(level: u32) -> u32 { crate::xp::xp_to_next_level(level) / 4 } @@ -99,8 +94,6 @@ fn kill_loyalty_bonus(pet_level: u32, loyalty_level: u8) -> u32 { ((100 - pet_level) / 10) + u32::from(MAX_LOYALTY_LEVEL - loyalty_level.min(6)) } -/// Apply one care tick's loyalty delta to a durable Hunter pet. T2 owns when the tick happens and -/// how happiness maps to the explicit state; this function owns all threshold and bound behavior. pub(crate) fn update_pet_loyalty_from_care( ctx: &ReducerContext, pet_id: u64, @@ -181,9 +174,6 @@ fn eligible_live_pet(pet_id: u64, kind: super::PetKind, dead: bool) -> bool { !dead && kind == (super::PetKind::Hunter { pet_id }) } -/// Advance the active Hunter pet belonging to one authoritative kill-credit recipient. Summoned, -/// dead, missing and mismatched live pets are excluded before any durable write, and a kill gray -/// to the owner awards nothing. pub(crate) fn award_hunter_pet_kill_progression( ctx: &ReducerContext, owner_guid: u64, @@ -202,7 +192,6 @@ pub(crate) fn award_hunter_pet_kill_progression( let Some(owner) = ctx.db.game_world_entity().guid().find(owner_guid) else { return; }; - // Pet XP shares the owner's gray clamp: a kill worth no XP to the owner advances no pet. if crate::xp::xp_for_kill(mob_level, owner.level) == 0 { return; } diff --git a/module/src/creatures/population.rs b/module/src/creatures/population.rs index 77c43d774..e63513037 100644 --- a/module/src/creatures/population.rs +++ b/module/src/creatures/population.rs @@ -1,5 +1,3 @@ -//! Source population choices survive import; each Instance keeps its first selection. - use std::collections::{BTreeMap, BTreeSet}; use spacetimedb::{log, table, ReducerContext, SpacetimeType, Table}; @@ -14,7 +12,6 @@ pub struct CreaturePopulation { pub world_state_condition: u32, pub world_state_expression: u32, pub flags: u32, - /// Rows of entry,min_count,max_count,chance. Zero chance shares the remaining probability. pub entries: String, #[default(false)] pub is_pool: bool, @@ -29,7 +26,6 @@ pub struct CreaturePopulationMember { pub entry: u32, pub choices: String, pub chance_bp: u32, - /// Positive ids require an active game event; negative ids remove a spawn during that event. pub event_ids: String, } @@ -113,7 +109,6 @@ fn decode_member(member: &CreaturePopulationMember) -> Result { }) } -/// Check source references before the first spawn batch changes the live population. pub(crate) fn check_definitions(ctx: &ReducerContext) -> Result<(), String> { let templates = ctx.db.game_creature_template(); let populations = ctx.db.game_creature_population(); @@ -174,7 +169,6 @@ pub(crate) fn check_definitions(ctx: &ReducerContext) -> Result<(), String> { } fn choose_index(len: usize, draw: &mut impl FnMut() -> u64) -> usize { - // Every caller checks for an empty candidate list first. (draw() % len as u64) as usize } @@ -192,8 +186,7 @@ fn select_pool( eligible.swap(index, other); } if limit == 1 { - // The source compares every explicit chance with the same roll, then falls back to - // an equal-chance member or the first explicit member. It is not a cumulative roll. + // Source chances share one roll; they are not cumulative probability ranges. let roll = draw() % 10_000; let chosen = eligible .iter() @@ -244,7 +237,6 @@ fn select( if population.max_count > 0 { eligible.truncate(population.max_count as usize); } - // Fixed and independent random entries consume group quotas before group-selected entries. eligible.sort_by_key(|member| member.entry == 0 && member.choices.is_empty()); let mut counts = BTreeMap::::new(); let mut selected = Vec::new(); diff --git a/module/src/creatures/presentation.rs b/module/src/creatures/presentation.rs index a6104928d..d748eae2e 100644 --- a/module/src/creatures/presentation.rs +++ b/module/src/creatures/presentation.rs @@ -1,5 +1,3 @@ -//! Reversible EventAI creature projection. - use spacetimedb::{table, ReducerContext, Table}; use super::eventai::RelayFlagOperation; @@ -50,11 +48,6 @@ pub struct CreatureRelayTemporaryFaction { pub creature_guid: u64, } -/// Apply one named EventAI instruction and refresh the WorldEntity projection. -/// -/// State survives evade because that resets Engagement rather than creature lifecycle. A canonical -/// lifecycle reset clears it before the next spawn rule, and definition adoption clears the state -/// owned by the previous revision. Respawn starts from the creature template. pub(crate) fn apply_eventai_instruction( ctx: &ReducerContext, creature_guid: u64, @@ -162,7 +155,6 @@ pub(crate) fn apply_eventai_instruction( true } -/// Clear all effects at the lifecycle boundary and restore the static creature projection. pub(crate) fn clear_eventai_presentation(ctx: &ReducerContext, creature_guid: u64) { ctx.db .game_creature_relay_temporary_faction() @@ -171,7 +163,6 @@ pub(crate) fn clear_eventai_presentation(ctx: &ReducerContext, creature_guid: u6 clear_if(ctx, creature_guid, |_| true); } -/// A definition replacement removes only presentation that belongs to the old definition. pub(crate) fn clear_for_definition_revision( ctx: &ReducerContext, creature_guid: u64, @@ -188,9 +179,6 @@ pub(crate) fn apply_relay_faction( faction_template: u32, restore_on_combat_stop: bool, ) -> Result<(), String> { - // Fall back to the spawn point's current life, not a constant: a creature that has not built - // EventAI state yet still belongs to a definite life, and tagging it 1 would let a row written - // before a respawn match the creature standing here now. let lifecycle_id = ctx .db .game_creature_ai_state() @@ -369,9 +357,6 @@ pub(crate) fn apply_relay_unit_flags( .guid() .find(creature_guid) .ok_or_else(|| format!("relay unit-flag subject {creature_guid} is unavailable"))?; - // Fall back to the spawn point's current life, not a constant: a creature that has not built - // EventAI state yet still belongs to a definite life, and tagging it 1 would let a row written - // before a respawn match the creature standing here now. let lifecycle_id = ctx .db .game_creature_ai_state() diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 053090d4a..2b56a96cd 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -1,6 +1,3 @@ -//! Creature data tables (template / cast / waypoint / spawn), the live-entity builders for both -//! creatures and players, the level/rank stat rolls, and the dev content importer. [static] - use lyracore_shared::{constants, packing, spatial}; use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; @@ -8,12 +5,6 @@ use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; use crate::creatures::tick::game_creature_spline; use crate::{game_active_taxi_flight, game_race_info, game_world_entity, Character, WorldEntity}; -// =========================================================================================== -// Creature data [static] -// =========================================================================================== - -/// Static creature definition, keyed by creature entry. Feeds the Unit CREATE_OBJECT fields and -/// the `CMSG_CREATURE_QUERY` reply. The live creature is a `game_world_entity` row (type Unit). [static] #[table( accessor = game_creature_template, public, @@ -31,77 +22,38 @@ pub struct CreatureTemplate { pub faction_template: u32, pub npc_flags: u32, pub unit_flags: u32, - pub creature_type: u8, // e.g. 8 = Critter + pub creature_type: u8, pub creature_family: u8, pub type_flags: u32, pub rank: u8, pub scale: f32, - pub base_attack_time_ms: u32, // melee swing interval, mirrored to UNIT_FIELD_BASEATTACKTIME - // Copper loot range rolled onto the corpse on the killing blow. `#[default(0)]` so - // adding these columns auto-migrates existing rows (no `-c` wipe). + pub base_attack_time_ms: u32, #[default(0)] pub money_min: u32, #[default(0)] pub money_max: u32, - // Level variance: cmangos MaxLevel / MaxLevelHealth. `level`/`health` above are the MIN; a spawn - // rolls a level in `[level, max_level]` and the matching health (so a pack isn't uniformly L1). - // 0 = "no range" (use the min) — the default, so existing rows auto-migrate and un-imported - // creatures keep a fixed level. `#[default(0)]` + end-appended (migration rule). #[default(0)] pub max_level: u32, #[default(0)] pub max_level_health: u32, - // Proximity aggro (vanilla creature AI): a hostile creature whose `aggro_range` (yards) covers a - // player self-engages on sight — the aggro pass in `tick_creatures` arms a creature→player melee - // row so the swing tick makes it attack. 0 = passive (engages only when attacked). Template-only: - // read at tick time, never mirrored onto the live entity. - // `#[default(0)]` + end-appended so adding it auto-migrates existing rows (no `-c` wipe) and every - // un-imported / un-updated creature stays passive — baseline-safe. #[default(0)] pub aggro_range: u32, - // Per-creature melee swing damage (parity): cmangos MinMeleeDmg/MaxMeleeDmg, rounded to int. Lets - // an L5 Garrick (6-7) hit harder than an L1 wolf (2) instead of every mob sharing the flat 1-3. - // `damage_max == 0` ⇒ "not imported" → `swing_range_ctx` falls back to CREATURE_MELEE_MIN/MAX, so - // existing rows + the seed chicken stay byte-identical. `#[default(0)]` + end-appended (migration rule). #[default(0)] pub damage_min: u32, #[default(0)] pub damage_max: u32, - // Creature armor (parity: `armor_mitigation_pct` already reads the live entity's armor for BOTH - // attack directions, but every spawn hardcoded 0 — so player physical damage against creatures was - // never mitigated). cmangos `creature_template.armor`. 0 ⇒ "not imported" → unmitigated, matching the - // old hardcoded behavior — baseline-safe. `#[default(0)]` + end-appended (migration rule). #[default(0)] pub armor: u32, - // Loot-family completeness: the creature's PICKPOCKET and SKIN loot-table ids, - // cmangos `creature_template.PickpocketLootId` / `SkinLootId`, sitting immediately after `LootId` - // in the real schema (the importer's `ct::PICKPOCKET_LOOT_ID`/`ct::SKIN_LOOT_ID`, `[V]` — confirm - // against your own dump). `pickpocket_loot_id` keys `game_pickpocket_loot` directly by CREATURE - // entry (collapsed like `LootId`); `skin_loot_id` keys `game_skinning_loot` (NOT collapsed — many - // creatures of the same level band share one skin table). 0 on either ⇒ "not imported" — E_PICKPOCKET - // grants copper only (unchanged) and `skin_corpse` falls back to the flat Light Leather — so every - // existing row auto-migrates byte-identical. `#[default(0u32)]` + - // end-appended (migration rule). #[default(0u32)] pub pickpocket_loot_id: u32, #[default(0u32)] pub skin_loot_id: u32, - // Which class a trainer serves. `creature_template.TrainerType`/`TrainerClass`, columns 71/73 — - // verified against the DDL of the dump `importer/scripts/classic-db.lock` pins, along with every - // other `ct::` anchor. - // - // `trainer_type` is CLASS 0 · MOUNTS 1 · TRADESKILLS 2 · PETS 3. 0 is a real value AND this - // column's default, so most templates read CLASS without being trainers at all — never gate on - // it alone (danger-zones §1.2: a default that is a valid value). - // - // `trainer_class` is a class ID, not a mask; 0 means "serves everyone", which is what keeps the - // gate fail-open on a world that has not been re-imported. #[default(0u8)] pub trainer_type: u8, #[default(0u8)] @@ -118,20 +70,13 @@ pub struct CreatureFamily { pub category: i32, } -/// Per-creature gossip menu: maps a creature template entry to the `npc_text` id shown in -/// `SMSG_GOSSIP_MESSAGE`. The lookup is: `game_world_entity.entry` → `game_gossip_menu.entry` -/// → `game_gossip_menu.text_id` → `game_npc_text.text`. Keyed by `entry` (the creature template -/// entry, NOT the cmangos `gossip_menu.entry`); this collapses the indirection. A creature with -/// no row falls back to `GOSSIP_GREETING_TEXT_ID = 1` and the generic greeting. No Timestamp → -/// SQL-seedable. Importer-owned (clear+reload). [static] #[table(accessor = game_gossip_menu, public)] pub struct GossipMenu { #[primary_key] - pub entry: u32, // creature_template.entry (the live creature's entry field) - pub text_id: u32, // → game_npc_text.text_id (the npc_text row to show) + pub entry: u32, + pub text_id: u32, } -/// A source gossip menu retained by menu id for per-creature runtime selection. [static] #[table(accessor = game_gossip_menu_profile, public)] pub struct GossipMenuProfile { #[primary_key] @@ -139,7 +84,6 @@ pub struct GossipMenuProfile { pub text_id: u32, } -/// One imported option belonging to a runtime-selectable gossip menu. [static] #[table( accessor = game_gossip_menu_profile_option, public, @@ -159,7 +103,6 @@ pub struct GossipMenuProfileOption { pub cond_value2: u32, } -/// The active per-creature menu selection. Public so the gossip reader can resolve by creature guid. #[table( accessor = game_creature_gossip_menu_override, public, @@ -173,7 +116,6 @@ pub struct CreatureGossipMenuOverride { pub instance_id: u64, } -/// Install or remove one exact creature's runtime gossip menu. Packages and relays share this owner. pub(crate) fn set_creature_gossip_menu( ctx: &ReducerContext, creature_guid: u64, @@ -221,85 +163,38 @@ pub(crate) fn clear_gossip_menu_overrides_for_instance(ctx: &ReducerContext, ins } } -/// Per-id NPC greeting text: the body of the gossip window's title panel, resolved by the client -/// via `CMSG_NPC_TEXT_QUERY` → `SMSG_NPC_TEXT_UPDATE`. `text_id` matches `game_gossip_menu.text_id` -/// and the `title_text_id` in `SMSG_GOSSIP_MESSAGE`. `text` is the first non-empty slot from the -/// cmangos `npc_text` row (or its `broadcast_text` reference). Importer-owned (clear+reload). -/// Answers ANY queried id — rows not imported here fall back to the generic greeting at the gateway. -/// No Timestamp → SQL-seedable. [static] -/// #[table(accessor = game_npc_text, public)] pub struct NpcText { #[primary_key] pub text_id: u32, - pub text: String, // slot 0, male (back-compat) + pub text: String, } -/// The remaining weighted npc_text slots (vanilla ships 8 greeting variants; the -/// CLIENT does the random weighted pick from `SMSG_NPC_TEXT_UPDATE`'s 8-slot array, no server RNG, -/// see `gateway::codec::build_npc_text_update`). A SEPARATE table from `NpcText`, NOT an end-append -/// of it: SpacetimeDB 2.5's `#[table]` macro cannot default a `String` column — `#[default(String::new())]` -/// fails to compile (`error[E0493]`: `String`'s `Drop` can't run inside the macro's compile-time -/// type-check, which is a plain `const { .. }` block, this is a hard Rust limitation, not a repo -/// convention, so it applies to ANY end-appended `String` column, verified in this pass). A brand -/// NEW table sidesteps it entirely (zero existing rows ⇒ nothing to backfill ⇒ no column needs a -/// default at all), and this is the SAME one-row-plus-child-rows shape already used everywhere else -/// in this file for a one-to-many relation (`CreatureWaypoint` by `by_creature`, `CreatureSpell` by -/// `by_entry`) — so despite the plan's original "child-table alt rejected" note (written before this -/// macro constraint was hit), this is not a departure from house style. -/// -/// One row per `(text_id, slot_index)`, `slot_index` 0..=7. When the dump's slot 0 is non-empty, -/// its male line DUPLICATES `NpcText.text` (a reader who only touches this table still gets a -/// self-contained slot 0; when slot 0 is empty, `NpcText.text` instead carries the first NON-empty -/// slot for back-compat and no slot-0 row exists here — empty slots are never emitted). A -/// `text_id` with NO rows here at all (an existing row, or any id nobody has re-imported -/// since) is read by the gateway as "legacy single-slot": `NpcText.text` in both genders at -/// probability 1.0, every other slot silent. Importer-owned -/// (clear+reload). No Timestamp → SQL-seedable. [static] #[table(accessor = game_npc_text_slot, public, index(accessor = by_text_id, btree(columns = [text_id])))] pub struct NpcTextSlot { #[primary_key] #[auto_inc] pub id: u64, - pub text_id: u32, // -> NpcText.text_id - pub slot_index: u8, // 0..=7 — the client's weighted-pick slot + pub text_id: u32, + pub slot_index: u8, pub text_male: String, pub text_female: String, pub probability: f32, } -/// Per-creature gossip MENU OPTION: a clickable line in `SMSG_GOSSIP_MESSAGE` -/// (browse-goods / make-home / "Train me" / plain gossip text / submenu link), imported from cmangos -/// `gossip_menu_option`. Keyed by creature template `entry` (NOT the cmangos `gossip_menu_option.menu_id` -/// — the importer collapses the same menu→entry indirection `GossipMenu` already collapses for the -/// title text, since a spawned creature only ever shows ITS OWN entry's menu). `option_index` is the -/// importer's DENSE 0-based position among a creature's options (matches cmangos `gossip_menu_option.id` -/// order) — the gateway sorts by it before assigning wire `gossip_list_id`s, and the SAME sort must be -/// used on both `CMSG_GOSSIP_HELLO` (render) and `CMSG_GOSSIP_SELECT_OPTION` (re-derive the click) or the -/// two fall out of alignment (see `gateway::world`'s HELLO/SELECT_OPTION dispatch note). -/// -/// `action` is the cmangos `OptionType`/`option_id` verbatim — see `lyracore_shared::constants::gossip_option` -/// for what each value means; only VENDOR(3)/INNKEEPER(8)/TRAINER(5) route to a real system today, the -/// rest render but stay inert (submenu navigation deferred — `action_menu_id` is stored but never -/// followed). `cond_type`/`cond_value1`/`cond_value2` gate visibility — see -/// `lyracore_shared::constants::gossip_condition`; an option the importer can't classify gets `cond_type = 0` -/// (fail-open, always shown) rather than silently hidden, and is logged at import time. -/// -/// A NEW table → auto-migrates with no `-c`. No Timestamp → SQL-seedable, -/// importer-owned (clear+reload each ETL run, like its sibling `GossipMenu`). [static] #[table(accessor = game_gossip_option, public, index(accessor = by_entry, btree(columns = [entry])))] pub struct GossipOption { #[primary_key] - pub row_id: u32, // dense importer-assigned id (unique across every creature's options) - pub entry: u32, // creature_template.entry (collapsed from cmangos menu_id, like GossipMenu.entry) - pub option_index: u32, // dense 0-based position among THIS creature's options (render + select order) - pub icon: u32, // cmangos OptionIcon (gossip icon glyph); truncated to u8 on the wire - pub text: String, // resolved option label (direct npc_text-style string, or via broadcast_text) - pub action: u32, // cmangos OptionType/option_id — see `lyracore_shared::constants::gossip_option` - pub action_menu_id: u32, // cmangos ActionMenuId (submenu target) — stored INERT, never navigated - pub cond_type: u32, // see `lyracore_shared::constants::gossip_condition` (0 = always show) - pub cond_value1: u32, // cond_type's primary operand (a quest id for the QUEST_* conditions) - pub cond_value2: u32, // reserved (unused by the current minimal condition set) + pub row_id: u32, + pub entry: u32, + pub option_index: u32, + pub icon: u32, + pub text: String, + pub action: u32, + pub action_menu_id: u32, + pub cond_type: u32, + pub cond_value1: u32, + pub cond_value2: u32, } #[table(accessor = game_creature_cast, public)] @@ -309,14 +204,6 @@ pub struct CreatureCast { pub spell_id: u32, } -/// A caster creature's spell ROTATION (rank 20): multiple conditional spells per entry, tried by -/// `priority` (high → low) each cast tick, the first whose `condition` holds and that is off cooldown -/// firing. Generalises the single-spell `game_creature_cast` into state-driven AI (heal-when-low / -/// buff-self / debuff / nuke). `by_entry` (not a unique PK) so an entry carries several rows — a NEW -/// table, so it auto-migrates with no `-c`. A creature with rotation rows uses them; one with NONE falls -/// back to `game_creature_cast` (baseline-safe — existing single-spell casters are unchanged). The cast -/// TARGET is derived from the condition (self for heal/buff, the melee target for nuke/debuff). No -/// Timestamp → SQL-seedable. [static] #[table(accessor = game_creature_spell, public, index(accessor = by_entry, btree(columns = [creature_entry])))] pub struct CreatureSpell { #[primary_key] @@ -324,23 +211,18 @@ pub struct CreatureSpell { pub id: u64, pub creature_entry: u32, pub spell_id: u32, - pub priority: u8, // higher is tried first; ties broken by id (deterministic) - /// 0 ALWAYS (nuke, → melee target) · 1 SELF_HP_BELOW_PCT (heal, → self, gated by `condition_value`%) · - /// 2 TARGET_MISSING_AURA (debuff, → melee target, only if it lacks this spell's aura) · - /// 3 SELF_MISSING_AURA (buff, → self, only if self lacks this spell's aura). + pub priority: u8, pub condition: u8, - pub condition_value: u8, // the HP% threshold for SELF_HP_BELOW_PCT; ignored by the other conditions + pub condition_value: u8, } -/// `game_creature_spell.condition` discriminants — the creature-AI rotation gates. [static] pub mod cast_condition { - pub const ALWAYS: u8 = 0; // unconditional (the nuke / default), cast at the melee target - pub const SELF_HP_BELOW_PCT: u8 = 1; // self HP% < condition_value → cast at self (heal) - pub const TARGET_MISSING_AURA: u8 = 2; // melee target lacks this spell's aura → cast at it (debuff) - pub const SELF_MISSING_AURA: u8 = 3; // self lacks this spell's aura → cast at self (buff) + pub const ALWAYS: u8 = 0; + pub const SELF_HP_BELOW_PCT: u8 = 1; + pub const TARGET_MISSING_AURA: u8 = 2; + pub const SELF_MISSING_AURA: u8 = 3; } -/// A patrol waypoint for a creature. The creature walks between its waypoints. [static] #[table(accessor = game_creature_waypoint, public, index(accessor = by_creature, btree(columns = [creature_guid])))] pub struct CreatureWaypoint { #[primary_key] @@ -352,9 +234,6 @@ pub struct CreatureWaypoint { pub z: f32, } -/// cmangos `creature.MovementType`, deciding which idle (non-engaged) movement pass a creature runs: -/// IDLE holds its spawn post + orientation, RANDOM free-wanders near home, WAYPOINT follows a -/// `game_creature_waypoint` patrol. Most world NPCs (quest givers, vendors, guards) are IDLE. pub const MOVEMENT_IDLE: u8 = 0; pub const MOVEMENT_RANDOM: u8 = 1; @@ -379,11 +258,11 @@ pub fn debug_normalize_spawn_timers(ctx: &ReducerContext, limit: u32) -> Result< let is_corpse = entity.map(|e| e.dead).unwrap_or(false); let mut touched = false; if alive && s.respawn_at <= ctx.timestamp { - s.respawn_at = never; // alive: nothing to respawn + s.respawn_at = never; touched = true; } if !is_corpse && s.despawn_at <= ctx.timestamp { - s.despawn_at = never; // not a corpse: nothing to decay + s.despawn_at = never; touched = true; } if touched { @@ -398,12 +277,6 @@ pub fn debug_normalize_spawn_timers(ctx: &ReducerContext, limit: u32) -> Result< Ok(()) } -/// Retire every creature whose SPAWN point sits inside the given cell box on THIS database — -/// spawn row, live entity, spline leg, and (globally, like `import_creature_spawns`) the threat -/// table. Regions are single-owner (creatures are region-static, spec): when a region is -/// assigned to another shard, the non-owner runs this so exactly one database holds the -/// population. Keyed by SPAWN position, not the entity's current one — a wanderer belongs to the -/// region of its home. Recoverable only by re-import; operator- and debug-feature-gated on purpose. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_retire_region_creatures( @@ -431,8 +304,6 @@ pub fn debug_retire_region_creatures( splines.guid().delete(g); spawns.guid().delete(g); } - // Same posture as `import_creature_spawns`' full wipe: threat rows are keyed by creature guid - // and ephemeral; a scoped sweep is not worth the code when the retire is an ops-time action. if !doomed.is_empty() { crate::threat::clear_all(ctx); } @@ -452,7 +323,6 @@ pub(crate) fn timer_never(ctx: &spacetimedb::ReducerContext) -> spacetimedb::Tim .unwrap_or(ctx.timestamp) } -/// Maps represented by the current source import. Runtime copies do not change this catalogue. #[table(accessor = game_creature_spawn_map, public)] pub struct CreatureSpawnMap { #[primary_key] @@ -462,10 +332,6 @@ pub struct CreatureSpawnMap { #[table( accessor = game_creature_spawn, public, - // Perf: the due-time range scans behind `pass_respawn` / `pass_decay`. Plain INDEX ADDs over - // already-existing columns — no new column, no default, no data migration (same shape as - // `game_aura`'s `by_expiry`, and `gateway/tests/schema_parity.rs` checks columns/bindings, not - // indexes, so the gateway is unaffected). index(accessor = by_respawn_at, btree(columns = [respawn_at])), index(accessor = by_despawn_at, btree(columns = [despawn_at])), index(accessor = by_entry_map, btree(columns = [entry, map_id])), @@ -483,44 +349,22 @@ pub struct CreatureSpawn { pub y: f32, pub z: f32, pub orientation: f32, - pub respawn_at: Timestamp, // when a despawned creature should re-spawn; ignored until the corpse despawns - pub despawn_at: Timestamp, // when a corpse should despawn (DESTROY); ignored while alive - /// cmangos MovementType (0 idle / 1 random-wander / 2 waypoint — see the `MOVEMENT_*` consts). Gates - /// the idle-movement passes: the wander pass runs ONLY for RANDOM; IDLE creatures hold their post. - /// END-appended + defaulted so the column auto-migrates existing rows to IDLE (stationary) until a - /// re-import populates the real per-creature values. + pub respawn_at: Timestamp, + pub despawn_at: Timestamp, #[default(0)] pub movement_type: u8, - /// cmangos `spawntimesecsmin`/`spawntimesecsmax` (this importer takes the min leg — see - /// `importer/src/main.rs` `cr::SPAWN_TIME_SECS`): the real per-spawn respawn delay, in seconds, - /// counted from the creature's DEATH (not from corpse-decay). `0` = "not imported" → the decay - /// pass (`creatures::tick::pass_decay`) falls back to the flat `RESPAWN_MICROS` timer (15s after - /// decay, i.e. ~75s after death) so every existing/un-imported row stays byte-identical. - /// END-appended + defaulted (migration rule) — no `-c` wipe needed. #[default(0)] pub respawn_secs: u32, - /// Which life of this spawn point is current. A creature keeps its guid across death and - /// respawn, so a guid alone cannot tell two lives apart, and a durable row keyed on the guid can - /// outlive the creature that made it. `pass_respawn` advances this counter as it materialises - /// the next life, inside the write that disarms the respawn timer. - /// - /// This row is the only per-creature state that survives death, which is why the counter lives - /// here rather than on the entity or on `game_creature_ai_state`. - /// END-appended + defaulted (migration rule). #[default(0u64)] pub life_seq: u64, - /// Zero for source rows and shared-world populations; nonzero for an Instance copy. #[default(0u64)] pub instance_id: u64, - /// The authored spawn guid for a runtime copy. Zero means this row is the source. #[default(0u64)] pub source_guid: u64, } -/// Read stored spawn evidence for one creature entry without walking the world spawn table. -/// Callers still inspect `game_world_entity` when they need a live target they can act on now. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn creature_spawn_evidence( ctx: &ReducerContext, @@ -537,15 +381,6 @@ pub(crate) fn creature_spawn_evidence( .collect() } -/// Which life of `creature_guid` is standing right now. -/// -/// A creature keeps its guid across death and respawn, so a durable row keyed on the guid needs this -/// number to tell the life that wrote it from the life reading it. Compare the stored number with -/// this one and refuse the row when they differ. -/// -/// A summon has no spawn row. It carries its own life number on `game_creature_ai_summon_expiry`, -/// which stands for as long as the summon does, so it answers here too. Zero means no creature of -/// either kind holds this guid right now, which is itself a life a stored number cannot match. pub(crate) fn current_life_seq(ctx: &spacetimedb::ReducerContext, creature_guid: u64) -> u64 { if let Some(spawn) = ctx.db.game_creature_spawn().guid().find(creature_guid) { return spawn.life_seq; @@ -553,11 +388,6 @@ pub(crate) fn current_life_seq(ctx: &spacetimedb::ReducerContext, creature_guid: crate::creatures::eventai::summon_life_seq(ctx, creature_guid) } -/// Roll a creature's `(level, health)` within its template's `[min, max]` range from a random `u32`. -/// `max_level <= min_level` (or 0, the default) means "no range" → the min level/health verbatim. -/// Otherwise the level is uniform in `[min_level, max_level]` and the health is interpolated linearly -/// between `min_health` and `max_health` by the level's position in the span (so a higher-level spawn -/// is correspondingly beefier — no L3-mob-with-L1-HP inconsistency). Pure — unit-tested. pub(crate) fn rolled_creature_stats( min_level: u32, max_level: u32, @@ -570,7 +400,6 @@ pub(crate) fn rolled_creature_stats( } let span = max_level - min_level; let level = min_level + rand % (span + 1); - // Linear interpolation of health by how far `level` sits into the span (integer math). let health = if max_health > min_health { min_health + (max_health - min_health) * (level - min_level) / span } else { @@ -579,39 +408,22 @@ pub(crate) fn rolled_creature_stats( (level, health) } -/// Per-1000 HP multiplier for a creature's classification `rank`, applied to the rolled base health -/// when the live entity is built (`build_creature_entity`). cmangos `creature_template.rank` values: -/// `0`=normal, `1`=elite, `2`=rare-elite, `3`=boss/world-boss, `4`=rare. The multipliers approximate -/// vanilla's elite HP scaling (an elite of a given level carries several times a normal mob's HP): -/// normal 1.0×, elite 1.5×, rare-elite 2.8×, boss 4.0×, rare 1.8×. Stored as PERMILLE (×1000) so the -/// scaling is exact integer math (`hp * permille / 1000`) — no float drift, no platform-dependent -/// rounding, and rank 0 (`1000`) is the identity (`hp * 1000 / 1000 == hp`), so every current rank-0 -/// spawn stays BYTE-IDENTICAL. An unknown/out-of-range rank falls back to the normal multiplier (safe -/// default — never amplifies an unrecognized value). Pure — unit-tested. pub(crate) fn rank_hp_multiplier_permille(rank: u8) -> u32 { match rank { - 0 => 1000, // normal — identity (baseline) - 1 => 1500, // elite - 2 => 2800, // rare-elite - 3 => 4000, // boss / world boss - 4 => 1800, // rare - _ => 1000, // unknown rank → treat as normal (never amplify an unrecognized value) + 0 => 1000, + 1 => 1500, + 2 => 2800, + 3 => 4000, + 4 => 1800, + _ => 1000, } } -/// Apply the rank HP multiplier to a base health value (integer permille math, saturating so a huge -/// base × a high multiplier can't wrap a `u32`). Shared by `build_creature_entity` and its unit test -/// so the live scaling and the asserted numbers never drift. Pure. pub(crate) fn scale_health_for_rank(base_health: u32, rank: u8) -> u32 { let permille = rank_hp_multiplier_permille(rank); - // Clamp the u64 product to the u32 ceiling BEFORE the cast — a bare `as u32` would silently - // truncate (wrap) a huge base × >1000‰ product instead of saturating as documented. ((base_health as u64 * permille as u64) / 1000).min(u32::MAX as u64) as u32 } -/// Scale a template's minimum-level damage range to a live creature level. The level-matching case -/// is the identity, so ordinary spawns are unchanged; the swing fold uses this to give an advancing -/// Hunter pet a monotonic, overflow-safe damage refresh. pub(crate) fn scale_creature_damage_for_level( min: u32, max: u32, @@ -628,15 +440,7 @@ pub(crate) fn scale_creature_damage_for_level( (scale(min).max(1), scale(max).max(1)) } -/// Insert a freshly-built creature `game_world_entity` row and fire the `on_creature_spawn` notify -/// hook. The SINGLE chokepoint every creature-entity insert routes through — world -/// seed, respawn pass, debug spawn, pet summon, tame — so a package hook sees every creature that -/// enters the world without any core dispatch-site edits. Player-entity inserts (login, -/// debug_spawn_player_entity) deliberately do NOT come through here. pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut entity: WorldEntity) { - // Stand on a model floor (bridge, WMO interior deck) instead of the imported spawn.z - // when one's imported at/below this spawn point — `floor_z` is `None` off vmap-slice/gate, so - // an unimported map spawns byte-identical to before this line existed. if let Some(floor) = crate::vmap::floor_z( ctx, entity.map_id, @@ -655,7 +459,7 @@ pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut enti z: entity.z, }; ctx.db.game_world_entity().insert(entity); - // AFTER the insert: handlers may look the row up by guid (documented on the payload). + // Fire after insertion because handlers may resolve the creature by guid. crate::hooks::fire_on_creature_spawn(ctx, &payload); } @@ -673,9 +477,6 @@ pub(crate) fn build_creature_entity( tmpl.max_level_health, rand, ); - // ELITE/RARE/BOSS scaling: the level-rolled base HP is multiplied by the template's classification - // rank. Rank 0 (every current spawn) → ×1.0, so this spawn is byte-identical to before; only a - // template explicitly marked elite/rare/boss gets the beefier HP pool. let health = scale_health_for_rank(rolled_health, tmpl.rank); WorldEntity { guid: spawn.guid, @@ -690,7 +491,7 @@ pub(crate) fn build_creature_entity( grid_y, cell: lyracore_shared::spatial::grid_cell_id(grid_x, grid_y), last_move_ms: 0, - type_mask: constants::type_mask::CREATURE, // 0x9 (no PLAYER bit) + type_mask: constants::type_mask::CREATURE, entry: spawn.entry, scale_x: tmpl.scale, health, @@ -713,36 +514,33 @@ pub(crate) fn build_creature_entity( xp: 0, next_level_xp: 0, target_guid: 0, - money: 0, // no loot until the killing blow rolls it onto the corpse - unit_bytes_1: 0, // creatures aren't ghosts - unit_bytes_2: 0, // sheath state UNARMED — a creature spawns with its weapon stowed - // A creature is a Unit — no character sheet — so the five player attributes are 0. + money: 0, + unit_bytes_1: 0, + unit_bytes_2: 0, strength: 0, agility: 0, stamina: 0, intellect: 0, spirit: 0, - // UNIT_NPC_FLAGS straight from the cmangos template (gossip/vendor/questgiver/trainer icons). npc_flags: tmpl.npc_flags, - armor: tmpl.armor, // cmangos creature_template.armor — feeds armor_mitigation_pct for both directions - leg_ends_ms: 0, // fresh spawn: no in-flight leg, no waypoint cursor (re-acquire on first patrol tick) + armor: tmpl.armor, + leg_ends_ms: 0, wp_target: 0, - movement_flags: 0, // freshly spawned, standing still - combat_until_ms: 0, // not in combat until it aggros / is attacked - pickpocketed: false, // fresh spawn: pockets intact — re-set to false here gives the free per-life reset - next_swing_spell: 0, // creatures don't queue on-next-swing abilities - overpower_until_ms: 0, // no react window (creatures don't cast Overpower) - revenge_until_ms: 0, // no react window (creatures don't cast Revenge) - stance: 0, // creatures have no Warrior stance (0 = Battle default, never switched) - owner_guid: 0, // a normal (wild) creature is NOT a pet — the pet builder stamps this - skinned: false, // fresh spawn: corpse not yet skinned — re-set here gives the free per-life reset - mana_regen_paused_until_ms: 0, // creatures have no mana pool; FSR never fires (max_power == 0) - death_expire_micros: 0, // creatures have no corpse-reclaim escalation (player-only field) - instance_id, // every caller passes 0 (open world) - run_speed_mult_bp: 10_000, // 1× — GM `.speed` targets players only - godmode: false, // GM `.god` targets players only - resting: false, // creatures never rest - // A creature never calls `recompute_sheet`, so its sheet fields stay 0. + movement_flags: 0, + combat_until_ms: 0, + pickpocketed: false, + next_swing_spell: 0, + overpower_until_ms: 0, + revenge_until_ms: 0, + stance: 0, + owner_guid: 0, + skinned: false, + mana_regen_paused_until_ms: 0, + death_expire_micros: 0, + instance_id, + run_speed_mult_bp: 10_000, + godmode: false, + resting: false, sheet_str_bonus: 0, sheet_agi_bonus: 0, sheet_sta_bonus: 0, @@ -753,29 +551,15 @@ pub(crate) fn build_creature_entity( sheet_dmg_min: 0, sheet_dmg_max: 0, sheet_crit_bp: 0, - bank_bag_slots: 0, // a creature owns no bank slots - mount_display_id: 0, // creatures do not use the player taxi presentation field - zone_id: 0, // resolved at emission for zone-wide creature speech + bank_bag_slots: 0, + mount_display_id: 0, + zone_id: 0, sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, } } -/// Build the live `game_world_entity` row for a PLAYER from its durable `game_character` row — the -/// single source of truth shared by `world::player_login` and `debug::debug_spawn_player_entity` -/// (the player counterpart to `build_creature_entity`). Resolves the power type, the gender-correct -/// per-race display + faction from the imported ChrRaces data (falling back to the Human-Male values -/// — display 49 / faction 1 — when the table isn't loaded), and the full stat curve via -/// `stats::apply_level_stats` (the same writer the ding loop and a GM level-set use); health == -/// max_health on a fresh build, mana classes start full while rage/energy start empty -/// (`stats::starting_power`). -/// -/// `owner_identity` is the ONLY genuine difference between the two call sites: login passes -/// `ctx.sender()` (the connection's bound identity), while the debug materialize passes the -/// character's persisted `owner_identity` (a `spacetime call` runs as the CLI identity, which owns no -/// player binding). `account_id` is taken from the character row — login gates on -/// `character.account_id == account.id` before building, so the two are identical there. pub(crate) fn build_player_entity( ctx: &ReducerContext, character: &Character, @@ -783,9 +567,6 @@ pub(crate) fn build_player_entity( ) -> WorldEntity { let level = character.level as u32; let power_type = packing::power_type::for_class(character.class); - // Per-race display + faction from the imported ChrRaces data (importer P1), gender-correct. - // Falls back to the Human-Male values (display 49 / faction 1) when the table isn't loaded, so - // login never breaks and a Human Male is identical either way. let race_info = ctx.db.game_race_info().race().find(character.race); let display = match &race_info { Some(ri) if character.gender != 0 => ri.female_display, @@ -797,9 +578,6 @@ pub(crate) fn build_player_entity( .map(|ri| ri.faction_template) .unwrap_or(1); let (grid_x, grid_y) = spatial::grid_cell(character.x, character.y); - // Stat-block fields (strength..spirit, armor, max_health, max_power) are placeholders here — - // `apply_level_stats` fills them right after construction (below). health/power are then - // resolved from the persisted value against the freshly-written max. let mut entity = WorldEntity { guid: character.guid, owner_identity, @@ -821,7 +599,7 @@ pub(crate) fn build_player_entity( power: 0, max_power: 0, level, - faction_template: faction, // per-race from ChrRaces (importer P1); fallback 1 (Human) + faction_template: faction, unit_bytes_0: packing::unit_bytes_0( character.race, character.class, @@ -831,7 +609,7 @@ pub(crate) fn build_player_entity( display_id: display, native_display_id: display, unit_flags: constants::unit_flags::PLAYER_CONTROLLED, - base_attack_time_ms: crate::combat::DEFAULT_ATTACK_TIME_MS, // unarmed 2.0s + base_attack_time_ms: crate::combat::DEFAULT_ATTACK_TIME_MS, dynamic_flags: 0, dead: false, player_bytes: packing::player_bytes( @@ -840,9 +618,6 @@ pub(crate) fn build_player_entity( character.hair_style, character.hair_color, ), - // Rest state: bake the RESTED byte if this character logged out in an inn, so it logs - // back in already showing the zzz icon + blue XP bar (no post-login relay needed). Byte 2 - // carries the persisted bank bag slot count so a purchase survives logout without a relog. player_bytes_2: packing::player_bytes_2_with_rest( character.facial_hair, character.bank_bag_slots, @@ -853,54 +628,40 @@ pub(crate) fn build_player_entity( xp: character.xp, next_level_xp: character.next_level_xp, target_guid: 0, - money: character.money, // load the persisted purse - bank_bag_slots: character.bank_bag_slots, // and the slots bought with it - // Warriors start in Battle Stance (form 17 in UNIT_FIELD_BYTES_1[2]) so the action bar - // shows the stance bar from login without requiring a manual stance cast. RAGE = Warrior. + money: character.money, + bank_bag_slots: character.bank_bag_slots, unit_bytes_1: if power_type == packing::power_type::RAGE { 17u32 << 16 } else { 0 }, - // Sheath state UNARMED. Deliberately NOT persisted across logout: vanilla rebuilds a - // player with weapons stowed, and the client re-sends `CMSG_SETSHEATHED` when the player - // draws again, so there is nothing to restore. unit_bytes_2: 0, strength: 0, agility: 0, stamina: 0, intellect: 0, spirit: 0, - npc_flags: 0, // a player is not an NPC (no gossip/vendor flags) - armor: 0, // item armor folds in later - leg_ends_ms: 0, // players don't run the creature movement passes; the columns are inert for them + npc_flags: 0, + armor: 0, + leg_ends_ms: 0, wp_target: 0, - movement_flags: 0, // set live by movement_update on the player's first move - combat_until_ms: 0, // set when the player attacks / is attacked (enter_combat) - pickpocketed: false, // never read on a player (pickpocket targets creatures only) - next_swing_spell: 0, // no queued strike at login (set by an E_NEXT_SWING cast, cleared by the swing) - overpower_until_ms: 0, // no react window at login (armed by a dodge in resolve_swing) - revenge_until_ms: 0, // no react window at login (armed by a dodge/parry/block in resolve_swing) - stance: 0, // every character logs in in Battle Stance (the L1 baseline); switched by a stance cast - owner_guid: 0, // a player is never a pet (pets are owner-stamped creatures) - skinned: false, // never read on a player (skinning targets beast corpses only) - mana_regen_paused_until_ms: 0, // no FSR window at login (stamped when mana is first spent in a cast) - // Threaded from the DURABLE column: the reclaim-escalation ladder must survive a - // disconnect/relog, or die-relog-die resets every death to the 30s floor. + movement_flags: 0, + combat_until_ms: 0, + pickpocketed: false, + next_swing_spell: 0, + overpower_until_ms: 0, + revenge_until_ms: 0, + stance: 0, + owner_guid: 0, + skinned: false, + mana_regen_paused_until_ms: 0, + // Retain reclaim escalation across relog so repeated deaths cannot reset the delay. death_expire_micros: character.death_expire_micros, instance_id: character.pending_instance_id, - // GM playtest fields, threaded from the DURABLE carry columns - // (the `death_expire_micros` precedent). They are NOT durable settings: `persist_entity` clears - // them on a real logout/disconnect and carries them across a despawn/rebuild WITHIN a session - // (`persisted_gm_playtest`), so a login still starts at 1× speed / not-godmode while a - // CROSS-MAP `.tele` — which despawns the entity and rebuilds it right here — no longer drops - // `.god`/`.speed` on the far side. run_speed_mult_bp: character.pending_run_speed_mult_bp, godmode: character.pending_godmode, - resting: character.resting, // 196: restore the live rest flag (relog into an inn shows rested) - // Placeholder — `spell::recompute_sheet` (called right after login, alongside `recompute_vitals`) - // fills these in from the real base stats `apply_level_stats` writes below. + resting: character.resting, sheet_str_bonus: 0, sheet_agi_bonus: 0, sheet_sta_bonus: 0, @@ -912,19 +673,12 @@ pub(crate) fn build_player_entity( sheet_dmg_max: 0, sheet_crit_bp: 0, mount_display_id: 0, - // WORLD ENTRY resolves the zone from the position this row is built at — login, the - // WORLDPORT_ACK rebuild after a cross-map teleport, and a Transfer arrival's first login all - // come through here, so every world-entry path yields a fresh zone before the Gateway reads - // the row. Off the imported terrain slice it falls back to the durable zone the character - // logged out with, which is 0 for a character that has never resolved one. zone_id: crate::terrain::zone_id_at(ctx, character.map_id, character.x, character.y) .unwrap_or(character.zone_id), sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, }; - // An interrupted connection does not cancel a paid flight. Restore its presentation while the - // scheduler continues from its original authoritative timestamp. if let Some(flight) = ctx .db .game_active_taxi_flight() @@ -934,19 +688,8 @@ pub(crate) fn build_player_entity( entity.mount_display_id = flight.mount_display_id; entity.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; } - // The level-derived stat block — the five base attributes, armor, and max health/power — from the - // real class/level curve (importer P3), via the ONE shared writer also used by the ding loop and a - // GM level-set. Falls back to the flat placeholder/zeros when the curve isn't loaded, so an - // L1 character is 60 HP either way. crate::stats::apply_level_stats(ctx, &mut entity, character.race, character.class, level); - // Resume at the persisted vitals rather than always healing to full. - // `character.health == 0` is the sentinel for "nothing persisted yet" (a fresh character, or an - // existing row that predates this column) — spawn at full health/starting power in that case. - // A real persisted health is clamped into `1..=max_health` in case the level's - // max_health curve changed (e.g. a stat/curve reimport) since the last logout. Power only persists - // for MANA — ENERGY and RAGE are non-persisted in vanilla (energy always 100/100, rage always 0 at - // login; see `starting_power`'s doc comment), so every relog still routes those through - // `starting_power` regardless of the health sentinel. + // Persisted health zero means uninitialized; ghosts retain their separate death state. entity.health = if character.health == 0 { entity.max_health } else { @@ -979,26 +722,6 @@ mod tests { } } -// =========================================================================================== -// Content import (dev) — replace the live creature roster from a parsed cmangos slice -// =========================================================================================== - -/// Replace the live creature roster with an imported content slice (see `importer/`). Resets live -/// state — deletes every CREATURE `game_world_entity` (never a player) and every `game_creature_spawn` -/// — then loads the supplied spawns. The slow `tick_creatures` respawn pass rebuilds the live -/// entities from these spawns + their templates (loaded separately, via SQL — they have no Timestamp -/// columns), so we never hand-encode the entity row here. -/// -/// Spawns go through a reducer rather than SQL because `game_creature_spawn` has `Timestamp` columns -/// (`respawn_at`/`despawn_at`) and SpacetimeDB 2.5 SQL has no Timestamp literal; `ctx.timestamp` here -/// is a valid in-the-past time, so the very next tick builds the entities at once. -/// -/// `packed` is the importer payload: rows separated by `;`, fields by `,`, in the order -/// `guid,entry,map_id,x,y,z,orientation,movement_type`. (u64 guids exceed JSON's safe-integer range, so the payload -/// is one delimited string — no per-field JSON number to lose precision.) -/// -/// Privileged in production (restrict to the operator/gateway identity); permissive here for dev, -/// matching `provision_account`/`create_character`. #[reducer] pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1007,7 +730,6 @@ pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<() let entities = ctx.db.game_world_entity(); let spawns = ctx.db.game_creature_spawn(); - // 1) Reset: drop every creature entity (PLAYER bit absent → never a player) and every spawn. let creature_guids: Vec = entities .iter() .filter(|e| e.type_mask == constants::type_mask::CREATURE) @@ -1023,8 +745,6 @@ pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<() } spawns.guid().delete(guid); } - // The threat tables are keyed by the creature guids we just deleted — wipe them so a re-import - // doesn't orphan stale aggro rows (the fresh roster starts with empty threat). crate::threat::clear_all(ctx); let maps = ctx.db.game_creature_spawn_map(); @@ -1032,18 +752,12 @@ pub fn import_creature_spawns(ctx: &ReducerContext, packed: String) -> Result<() maps.map_id().delete(map.map_id); } - // Shared-world spawns arm their first population. Instance source rows stay dormant. if load_spawn_batch(ctx, &packed)? == 0 { return Err("import payload was empty".to_string()); } Ok(()) } -/// Append a batch of packed spawns WITHOUT the reset — paired with `import_creature_spawns` (which -/// clears the roster ONCE + loads the first batch), so a whole zone can load across several calls (a -/// single `spacetime call` string arg can't hold ~2k spawns). note: the importer sends batch 0 via -/// `import_creature_spawns` and the remaining batches via this; the respawn tick then builds the -/// shared-world entities. Instance source rows stay dormant until creation. No reset here. #[reducer] pub fn import_creature_spawns_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1051,12 +765,6 @@ pub fn import_creature_spawns_append(ctx: &ReducerContext, packed: String) -> Re Ok(()) } -/// Parse `packed` (`;`-separated rows of `guid,entry,map,x,y,z,o,mt[,respawn_secs]`) into -/// `game_creature_spawn` rows stamped at `ctx.timestamp`, returning the count loaded. Shared by the -/// clear+load reducer above and the append reducer. The trailing `respawn_secs` field is OPTIONAL (9 -/// fields with it, 8 without) so an older importer payload without it still parses — a row -/// missing it defaults to `0` (falls back to the flat `RESPAWN_MICROS` timer, see `CreatureSpawn`'s -/// doc comment). fn load_spawn_batch(ctx: &ReducerContext, packed: &str) -> Result { let spawns = ctx.db.game_creature_spawn(); let now = ctx.timestamp; @@ -1082,21 +790,18 @@ fn load_spawn_batch(ctx: &ReducerContext, packed: &str) -> Result { y: pf32(f[4])?, z: pf32(f[5])?, orientation: pf32(f[6])?, - // Instance maps retain source rows without creating an instance-zero population. respawn_at: if lyracore_shared::instance::has_instance_population(map_id) { timer_never(ctx) } else { now }, - despawn_at: timer_never(ctx), // not a corpse — nothing to decay yet + despawn_at: timer_never(ctx), movement_type: pu8(f[7])?, respawn_secs: if f.len() == 9 { pu32(f[8])? } else { 0 }, life_seq: 0, source_guid: 0, instance_id: 0, }; - // try_insert: a duplicate guid within the payload (shouldn't happen — guids are unique per - // spawn) fails the run cleanly rather than panicking the reducer. spawns .try_insert(spawn) .map_err(|e| format!("duplicate spawn guid {}: {e}", f[0]))?; diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index a677bfa57..9b9be4e8e 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -1,48 +1,11 @@ -//! The creature lifecycle passes (split of tick.rs): the canonical despawn checklist -//! (every teardown path in the crate routes through `despawn_creature_entity`), then -//! decay/respawn/GO-respawn — the due-time passes that run on schedule regardless of proximity. See -//! `tick/mod.rs`'s module doc for the pipeline's load-bearing pass ORDER; this split does not change -//! it (every pass here keeps its original name and signature, called by bare name via `tick/mod.rs`'s -//! `use lifecycle::*;`). - use spacetimedb::{ReducerContext, TimeDuration}; use crate::{game_corpse_loot, game_gameobject, game_gameobject_pool_member, game_loot_roll}; use super::*; -// =========================================================================================== -// The canonical creature-despawn checklist -// =========================================================================================== - -/// Delete a live creature entity AND every satellite row keyed on its guid. **This is the one -/// checklist** — every teardown path routes through it: `instance::teardown_instance_inner` (step 1, -/// the instance reap), `encounter::despawn_tracked` (wave adds on an encounter reset/wipe) and -/// [`pass_decay`] (corpse decay). Each caller adds only its OWN extras on top — the persistent -/// `game_creature_spawn` row and the encounter tracking/equip rows for the first two, the respawn-timer -/// re-arm for the third — and none of them re-implements the list below. -/// -/// Those three had each grown a private copy and DIVERGED. `despawn_tracked` deleted -/// `game_corpse_loot` by corpse guid with no `!withheld` filter — the exact delete the fix removed -/// from the other two — so a mid-roll wipe on a sharded realm silently ate the winner's item (the roll -/// is authoritative on realm-core and invisible here; `settle_loot_roll`'s grant then finds no row and -/// no-ops). It also skipped the rest of the loot family and the taunt lock. Adding a satellite table -/// now means editing this fn, not three-and-hope. -/// -/// NOT on the list: `game_creature_move_event`. Two of the three copies scanned that whole table for -/// the mover's pending legs; nothing has written it since perf 2.3 moved legs onto the per-creature -/// `game_creature_spline` row (retired the last writers, and the tripwire below forbids new -/// ones — `gc.rs` stopped reaping it on the same grounds). A full-table scan per despawned creature, -/// to delete from a provably empty table, teaches the next reader that the table is live. -/// -/// The order is the safe one: free the engagement first (`disengage` also clears threat BOTH ways via -/// `threat::clear_for_unit`, retargets the attackers and drops their combat), then the per-guid rows, -/// then the entity itself last — its `on_delete` relay is what fires `SMSG_DESTROY_OBJECT`. Idempotent: -/// every step is a keyed delete or an indexed collect-then-delete, so calling it on an already-gone -/// guid is a no-op (`pass_decay` reaching a creature `kill_creature` already disengaged is normal). -/// [entity] pub(crate) fn despawn_creature_entity(ctx: &ReducerContext, guid: u64) { - crate::combat::disengage(ctx, guid); // melee rows + threat (clear_for_unit) + stale selections + crate::combat::disengage(ctx, guid); crate::threat::clear_taunt_lock(ctx, guid); crate::combat::clear_lethal_damage_floor(ctx, guid); crate::creatures::cancel_relay_runs_for_source(ctx, guid); @@ -50,36 +13,20 @@ pub(crate) fn despawn_creature_entity(ctx: &ReducerContext, guid: u64) { crate::creatures::drop_summon_expiry(ctx, guid); let _ = crate::creatures::set_creature_gossip_menu(ctx, guid, None); crate::loot::tag::clear(ctx, guid); - ctx.db.game_creature_spline().guid().delete(guid); // the LIVE leg row (perf 2.3) - crate::motion::drop_pending(ctx, guid); // The staged, not-yet-republished payload too - ctx.db.game_entity_motion().guid().delete(guid); // motion row dies with the entity (perf 2.1) - crate::loot::reap_corpse_loot_family(ctx, guid); // item rows (withheld-safe) + eligibility + rolls + votes - ctx.db.game_world_entity().guid().delete(guid); // last — the on_delete relay destroys the object + ctx.db.game_creature_spline().guid().delete(guid); + crate::motion::drop_pending(ctx, guid); + ctx.db.game_entity_motion().guid().delete(guid); + crate::loot::reap_corpse_loot_family(ctx, guid); + // Delete last; its Relay destroys the client object. + ctx.db.game_world_entity().guid().delete(guid); } -/// Pass 3 — decay (runs before respawn): a corpse whose decay window elapsed is DESTROYed and its -/// respawn timer armed to a FUTURE time (so respawn does NOT re-create it the same tick). Reaps the -/// corpse's item-loot rows. -/// -/// Stays GLOBAL: `despawn_at` is a due-time, not a proximity concern, and a -/// corpse must decay on schedule whether or not a player is anywhere near it. Runs on the -/// catch-all firing only, which covers ALL instances (see -/// `TickScope::runs_global_passes`). Returns spawn rows scanned. pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; let entities = ctx.db.game_world_entity(); let spawns = ctx.db.game_creature_spawn(); - // Decay pass: a corpse whose decay window elapsed is DESTROYed (the on_delete relay - // fires SMSG_DESTROY_OBJECT, so it vanishes) and its respawn timer armed; the respawn pass below - // then re-creates it. Gated on the live entity being `dead` so a freshly-spawned creature (whose - // stale despawn_at is in the past) is never decayed. let mut visited = 0usize; - // RANGE SCAN, not a table scan: `by_despawn_at` walks the index from its low end and stops at - // `now`, so it visits only corpses whose decay window has actually elapsed — normally none. - // This works only because `despawn_at` is parked in the far future while a creature is NOT a - // corpse (`creatures::timer_never`); when every row carried a past stamp, `<= now` matched the - // entire table and the index would have narrowed nothing. let decaying: Vec = spawns .by_despawn_at() .filter(..=now_ts) @@ -98,19 +45,6 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { crate::loot::sweep_loot_rolls(ctx); } for guid in decaying { - // Belt over the sweep's braces: if a roll on this corpse is somehow STILL unresolved (a - // future constant change could put deadlines past decay), keep the corpse one more tick - // rather than discard a live roll — the sweep resolves it next pass. - // - // Fix: `sweep_loot_rolls` and the `game_loot_roll` check above only see rolls - // whose STATE lives on THIS database. In a sharded deployment a roll promoted to realm-core - // clears its local staging row within ~200ms of kill-time, so neither of the above can see - // it anymore even though it is still open there — decay would otherwise reap this corpse's - // `game_corpse_loot` row (below) before `settle_loot_roll` ever runs, silently losing the - // winner's item (the relay's grant no-ops when it finds no row left to grant against). The - // corpse's OWN `withheld` bit is a plane-agnostic signal for "a roll still owns this row" — - // it is set at roll start and cleared only by a grant or an all-pass unlock, on whichever - // database resolves the roll — so gate on it too, unconditionally, local table or not. if ctx .db .game_loot_roll() @@ -129,12 +63,6 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { crate::creatures::despawn_creature_entity(ctx, guid); if let Some(mut spawn) = spawns.guid().find(guid) { - // Honor the per-spawn cmangos respawn timer (respawn_secs, seconds from DEATH) over the - // flat RESPAWN_MICROS default. `respawn_secs == 0` means "not imported" and falls back to - // the flat timer (byte-identical for every un-imported spawn). The creature's death time is - // recovered as `despawn_at - CORPSE_DECAY_MICROS` (kill_creature arms - // `despawn_at = death + CORPSE_DECAY_MICROS`), so a real `respawn_secs` is measured from - // DEATH, matching cmangos, not from decay. spawn.respawn_at = if spawn.respawn_secs == 0 { now_ts .checked_add(TimeDuration::from_micros(crate::combat::RESPAWN_MICROS)) @@ -151,18 +79,13 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { spawn.respawn_secs as i64 * 1_000_000, )) .unwrap_or(now_ts); - // Never arm a respawn in the past (a respawn_secs shorter than the corpse-decay window - // would otherwise instant-respawn the same tick the corpse decays) — clamp to now. + // Arm respawn in the future so decay cannot respawn the creature in this firing. if armed > now_ts { armed } else { now_ts } }; - // DISARM the corpse timer — read AFTER `death_ts` above, which needs the armed value. - // The corpse is gone; leaving `despawn_at` in the past would keep this row in every - // future decay range scan forever, which is exactly the cost this pass just stopped - // paying. spawn.despawn_at = crate::creatures::timer_never(ctx); spawns.guid().update(spawn); } @@ -170,13 +93,6 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { visited } -/// Pass 2 — respawn: re-create any creature whose live entity is gone and whose `respawn_at` -/// elapsed, from its persistent spawn record + template. Runs AFTER decay (decay arms a future -/// `respawn_at`, so a just-decayed creature isn't re-spawned this tick). -/// -/// Stays GLOBAL: `respawn_at` is a due-time, and a spawn point must -/// repopulate on schedule whether or not a player is nearby to see it happen. Runs on the -/// catch-all firing only, which covers ALL instances. Returns spawn rows scanned. pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; let entities = ctx.db.game_world_entity(); @@ -184,8 +100,6 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let templates = ctx.db.game_creature_template(); let mut visited = 0usize; - // RANGE SCAN (see `pass_decay`): only spawns whose timer has actually fired. A live creature's - // `respawn_at` sits in the far future (`creatures::timer_never`), so it is not visited at all. const MAX_RESPAWNS_PER_TICK: usize = 256; let due: Vec = spawns .by_respawn_at() @@ -196,7 +110,6 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let mut next_waypoint_id = None; for mut spawn in due { let guid = spawn.guid; - // Disarm legacy source timers too, so they leave the due-time index after one visit. if (spawn.instance_id == 0 && lyracore_shared::instance::has_instance_population(spawn.map_id)) || entities.guid().find(guid).is_some() @@ -229,14 +142,8 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { } let guid = spawn.guid; crate::combat::clear_lethal_damage_floor(ctx, guid); - // The new life reuses this guid, so nothing an earlier life scheduled may reach it. crate::creatures::cancel_relay_runs_for_new_life(ctx, guid); - // ADVANCE + DISARM, before the entity exists, so every reader this tick already sees the new - // life. The advance is what lets a durable row keyed on this guid tell the life that made it - // from the one standing here now. The disarm is what keeps the range-scan index useful — a - // fired-but-still-past timer would put the row back in every subsequent scan, and it is also - // how a freshly IMPORTED spawn (which arms `respawn_at = now` deliberately, so it - // materialises on the first tick) leaves the scan after that first tick. + // Advance and disarm the spawn before the new entity exposes this life. if let Some(mut s) = spawns.guid().find(guid) { s.respawn_at = crate::creatures::timer_never(ctx); s.life_seq = s.life_seq.saturating_add(1); @@ -252,32 +159,10 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { visited } -/// GATHER-node respawn (the POOL model): for every DEPLETED node (state==1) -/// whose armed respawn time has elapsed, EITHER re-roll its pool (a POOLED point — the node ROAMS to a -/// different inactive point, holding MAX_ACTIVE) OR flip it state 1→0 in place (a STANDALONE node, the -/// pool_id==0 path). Runs on each catch-all firing through the due-time index. -/// `respawn_at_micros` is a defaulted u64 (NOT a Timestamp — that table -/// is live/populated, so a Timestamp column would abort the publish; teleport-core lesson). The due -/// decision is the unit-tested `gameobject::respawn_due` predicate. Filters to depleted-only inline; -/// collect-then-mutate so we never write `game_gameobject` while iterating it. -/// Perf catalog 1.21 (was: "deliberate simplification: full-table `iter()` every sense tick, -/// defer it"): the scan is now a `by_respawn_at` range probe over armed-and-elapsed rows only. A -/// `by_state` index would still have visited every depleted node every tick; the due-time range -/// visits only the ones actually due. -/// -/// Stays GLOBAL: `respawn_at_micros` is a due-time (like creature -/// respawn/decay), not proximity, and GAMEOBJECTS are outside the creature-ticking scope. Runs on the -/// catch-all firing only, which covers ALL instances. Returns GO rows scanned. pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { let now = ctx.timestamp.to_micros_since_unix_epoch() as u64; let gos = ctx.db.game_gameobject(); let members = ctx.db.game_gameobject_pool_member(); - // Collect the due guids FIRST (collect-then-mutate) so a reroll's delete/insert never writes - // game_gameobject while we iterate it, and two due rerolls can't race the same pool slot. - // Perf catalog 1.21: `by_respawn_at` range-scans ARMED-and-elapsed rows only (`0` = not armed, so - // the `1..=now` range skips every ready node and every non-gather GO for free). `respawn_due` stays - // as the post-filter, so the predicate — and the result set — are unchanged. `visited` now counts - // the index candidates rather than the whole table; that IS the evidence number. let mut visited = 0usize; let due: Vec = gos .by_respawn_at() @@ -287,11 +172,6 @@ pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { .map(|g| g.guid) .collect(); for guid in due { - // POOLED point? A guid TAGGED as a pool point (structural test, collision-proof — NOT a bare - // low-48 lookup, which collided with standalone GO guids) → re-roll the pool (deactivate this - // point, activate a different inactive one) so the active set ROTATES across the zone. A - // STANDALONE node (tag bit clear → never queries the member table) falls through to the EXISTING - // in-place state 1→0 flip — byte-identical, no regression. match crate::gameobject::pool_point_id_of(guid).and_then(|pid| members.point_id().find(pid)) { Some(member) => crate::gameobject::reroll_pool(ctx, member.pool_id, guid), diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 386408a02..2f50f5778 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -1,19 +1,3 @@ -//! The broadcast creature-move event + the creature tick schedule and the scheduled `tick_creatures` -//! shell. [server]/[event] -//! -//! No behavior pass lives here. `tick_creatures` authorizes the firing, resolves coverage and -//! cadence, and runs ONE behavior cycle; the pass list and its load-bearing order are -//! [`crate::creatures::cycle`]'s alone. -//! -//! - `mod.rs` (this file) — the two tables + the schedule table, the `tick_creatures` shell, the -//! active-cell sweep and rows-visited evidence logs, the shared candidate gate -//! `movable_creature`, the rout predicates, and the one spline writer -//! (`emit_move_spline`/`emit_creature_leg`) every movement decision funnels through, with -//! the stop between firings (`stop_where_rendered`) and the persist gate for a leg advance -//! (`advance_needs_persist`). -//! - [`lifecycle`] — the canonical despawn checklist + decay/respawn/GO-respawn, the -//! due-time passes that run regardless of proximity. - use lyracore_shared::spatial; use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; @@ -26,20 +10,10 @@ use super::*; mod lifecycle; -// The behavior cycle (`creatures::cycle`) owns WHEN each pass runs, so it needs to name them. These -// three are not behavior — they keep their owner here and the cycle only sequences them. pub(crate) use lifecycle::{pass_decay, pass_gameobject_respawn, pass_respawn}; -// Encounter and instance removal share the same creature despawn operation. pub(crate) use lifecycle::despawn_creature_entity; -// =========================================================================================== -// Creature movement event [event] — broadcast (public, no RLS) -// =========================================================================================== - -/// A server-driven creature movement leg to relay as `SMSG_MONSTER_MOVE`. Broadcast (public, no -/// RLS) — the gateway fans it out to every in-world client, matching the global `game_world_entity` -/// visibility placeholder. [event] #[table(accessor = game_creature_move_event, public)] pub struct CreatureMoveEvent { #[primary_key] @@ -53,58 +27,30 @@ pub struct CreatureMoveEvent { pub dest_y: f32, pub dest_z: f32, pub duration_ms: u32, - pub spline_id: u32, // strictly increasing per creature (the client rejects a stale spline) + pub spline_id: u32, pub created_at: Timestamp, - // RUN_MODE flag for the relayed SMSG_MONSTER_MOVE: true = run animation (chase/return/flee/fear at - // RUN speed), false = walk (patrol/wander at WALK). Without it every leg walk-animated → RUN-speed - // legs moonwalked. `#[default(false)]` + end-appended → auto-migrates (existing rows read as walk). #[default(false)] pub run: bool, } -/// SPLINE MODEL — the active movement leg for a creature. The client interpolates the emitted -/// `SMSG_MONSTER_MOVE` (start→dest over `dur_ms`) on its own; the SERVER advances the authoritative -/// `game_world_entity` position by lerping this SAME spline each tick in `pass_advance_splines`, instead -/// of snapping the row to the leg END at leg-start (the old "leg-lead" that made every range/melee/aggro -/// check read the creature ahead of where it renders — the "movement feels off vs vanilla" cause). One -/// row per moving creature; cleared on arrival (t≥1), on CC (halt), or when the creature is gone. -/// **PUBLIC and grid-scoped since the creature-relay change** — this row now IS the creature-movement -/// relay, replacing the per-move `game_creature_move_event` insert (the same move perf catalog 2.1 -/// made for player movement, applied to creatures as 2.3 asks). -/// -/// Why: `game_creature_move_event` was a globally-subscribed table (`SELECT *`), so EVERY creature -/// leg was delivered to EVERY connected player and then discarded by the gateway's `created`-set -/// guard. Measured at 100 dispersed players: 121.7 inserts/s + 121.6 reaps/s, each fanned to all 100 -/// sessions. This row already existed, is already one-per-creature, and is already written on every -/// leg — so carrying the relay on it costs nothing extra and makes the delivery grid-scoped. -/// -/// The grid columns mirror `game_world_entity`'s so the AOI tracker can subscribe this table with the -/// identical 5×5 box query it already builds for entities and motion. #[table( accessor = game_creature_spline, public, index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])), - // The AOI cell index — exactly 3 columns, all matched by equality terms, which is the - // only shape SpacetimeDB 2.7.1's subscription planner can serve (see the `cell` column). + // AOI subscriptions require three equality columns in SpacetimeDB 2.7.1. index(accessor = by_cell, btree(columns = [map_id, instance_id, cell])) )] pub struct CreatureSpline { #[primary_key] pub guid: u64, - pub start_micros: u64, // ctx.timestamp micros at leg-start + pub start_micros: u64, pub dur_ms: u32, pub sx: f32, pub sy: f32, - pub sz: f32, // leg START (authoritative pos at emit) + pub sz: f32, pub dx: f32, pub dy: f32, - pub dz: f32, // leg DEST (snapped landing) - // --- END-APPENDED, all defaulted (migration rule): the relay half. --- - /// The mover's grid address at leg-start — the AOI box predicate. - // TYPED literals, not a bare `0`: `#[default(0)]` on a u64 encodes as 4 bytes and the publish - // fails with "data too short for u64: Expected 8, given 4". Caught by the preflight check that - // runs before every publish, which is the only thing that checks default ENCODINGS — the - // compiler and the test suites are both blind to it (`docs/danger-zones.md` §2). + pub dz: f32, #[default(0u32)] pub map_id: u32, #[default(0u64)] @@ -113,115 +59,36 @@ pub struct CreatureSpline { pub grid_x: i32, #[default(0i32)] pub grid_y: i32, - /// `SMSG_MONSTER_MOVE`'s spline id (the old event's `spline_id`, = `now_ms` at emit) and gait. - /// A client keys its interpolation off the id, so it must CHANGE per leg — that is also what - /// makes an in-place update observable as a new leg rather than a no-op. + // The client keys interpolation by spline id; it must change for every replacement leg. #[default(0u32)] pub spline_id: u32, #[default(false)] pub run: bool, - /// `(grid_x, grid_y)` packed into ONE indexed value — the AOI subscription's cell key. - /// - /// SpacetimeDB 2.7.1's subscription planner can only serve a query from an index when EVERY - /// column of that index is matched by an equality term, and it skips any index with more than 3 - /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all - /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition - /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the - /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN - /// serve, and the AOI box becomes 25 point probes instead of a scan. - /// - /// ALWAYS written from `spatial::grid_cell_id(grid_x, grid_y)` in the SAME statement that writes - /// `grid_x`/`grid_y` — a stale value here does not merely slow a query down, it puts the row in - /// the wrong cell and shows players the wrong world. `module/src/tripwires.rs::grid_cell_tripwire` - /// is the enforcement. - /// - /// `#[default(0i64)]` (typed — an i64 column needs an explicitly-typed literal) + END-appended so - /// `publish` auto-migrates. **The default is cell (0, 0), not "unset"**, so a pre-existing row is - /// mis-addressed until its next leg re-stamps it (sub-second for anything actually moving); - /// `backfill_cell_ids` covers it for completeness. #[default(0i64)] pub cell: i64, - /// This leg is a FACING-ONLY packet (the mover does NOT move — `sx/sy/sz` == `dx/dy/dz`, - /// `dur_ms` is 0) and `facing_angle` is the new heading the client should snap to - /// (`SMSG_MONSTER_MOVE`'s `FacingAngle` variant). A stationary stand-and-swing creature never - /// throws a normal leg (nothing to interpolate), so without this the client never learns its - /// heading changed — the "keeps its pre-combat orientation until you move" bug. `false`/`0.0` - /// reproduces the `Normal`-type stop exactly, so every other caller - /// of [`emit_move_spline`] is unaffected. END-appended + defaulted (migration rule). #[default(false)] pub facing: bool, #[default(0.0f32)] pub facing_angle: f32, - /// Intermediate points and destination for one linear ground path. None retains one-leg behavior. #[default(None::)] pub path: Option, } -// =========================================================================================== -// Patrol scheduling [server] -// =========================================================================================== - -/// Drives the creature tick, one row per FIRING SCOPE. The seeded row is the -/// GLOBAL/CATCH-ALL ticker (`instance_id == GLOBAL_TICK_INSTANCE`); an optional DEDICATED row per -/// instance makes that instance tick at its own cadence while the catch-all skips it (`TickScope` in -/// ai.rs is the coverage rule — a partition, never an overlap, so a second row DIVIDES the per-firing -/// work instead of multiplying it). -/// -/// HONEST BOUND: SpacetimeDB serializes every reducer on ONE commit stream, this is -/// LATENCY SMOOTHING + WORK AVOIDANCE, **NOT parallelism**. Each extra row's firings preempt the -/// shared stream (10 instances at 100ms = 100 extra transactions/sec), so tight per-instance -/// cadences are a knob to use sparingly, measured via the per-pass rows-visited log below. -/// -/// PER-INSTANCE `tick_ms` KNOB: the cadence IS `scheduled_at` (`ScheduleAt::Interval(tick_ms)`) on -/// the dedicated row. There is no separate `tick_ms` column, because it would duplicate -/// `scheduled_at`. `create_instance` inserts the dedicated row -/// `{scheduled_id: 0, scheduled_at: ScheduleAt::Interval(tick_ms), instance_id: N}` and the instance -/// reap deletes it, which returns coverage of N to the catch-all. The operator retunes a row via -/// `debug_arm_instance_tick` (`debug/mod.rs`). [server] #[table(accessor = game_creature_move_schedule, scheduled(tick_creatures))] pub struct CreatureMoveSchedule { #[primary_key] #[auto_inc] pub scheduled_id: u64, pub scheduled_at: ScheduleAt, - // END-APPENDED defaulted column (additive auto-migration — danger-zones §2). Which instance this - // row ticks. The literal default MUST equal `GLOBAL_TICK_INSTANCE` (u64::MAX; the `#[default]` - // macro wants a literal). NOTE: no test can pin this attribute literal itself — it is migration - // metadata, not Rust `Default`; ai.rs's `global_tick_instance_sentinel_is_u64_max` pins only the - // CONST — so editing this literal alone reds nothing: treat it as hand-synced with ai.rs so the - // EXISTING seeded row auto-migrates into the catch-all — every live creature keeps exactly one - // ticker, no re-seed needed. This table is NOT gateway-subscribed (no entry in connection.rs's - // subscription list or gateway/tests/schema_parity.rs's manifest) → no binding hand-sync needed - // (playbook failure-mode, the "No" branch). #[default(18_446_744_073_709_551_615u64)] pub instance_id: u64, } -/// World tick (scheduled, scheduler-only): fires every `MOVE_TICK_SECS` (0.5s) on the seeded -/// catch-all row, plus once per dedicated instance row at that row's own cadence. This reducer is a -/// SHELL: it authorizes the firing, resolves which instances the firing covers and how long its -/// movement step is, then runs ONE behavior cycle. The pass list and its load-bearing order live in -/// `creatures::cycle::run_cycle` and nowhere else. -/// -/// MOVEMENT EVERY FIRING, SENSING QUANTIZED: the expensive O(N)-scan sensing passes run about once -/// per 4s (`is_sense_tick_for_interval`) no matter how fast this row fires, so HP-regen rate, wander -/// frequency and respawn cadence stay vanilla; the movement step (`tick_secs_for_interval`) scales -/// with the interval so creature speed is cadence-invariant. Both are byte-identical at the seeded -/// 0.5s row. This is mangos's one-loop-with-recheck-timers model on one scheduled reducer — a single -/// tick, so no cross-scheduler `spline_id` collision. -/// -/// INSTANCE SCOPE (latency smoothing + work avoidance, NOT parallelism; see the -/// `CreatureMoveSchedule` doc): every firing resolves a `TickScope` from ITS OWN schedule row. The -/// catch-all row covers every instance without a dedicated row; a dedicated row covers exactly its -/// instance. Coverage is a PARTITION, so no creature is ever ticked by two rows. #[reducer] pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { if ctx.sender() != ctx.database_identity() { return; } - // The scope build scans only the schedule table itself (one catch-all + one row per dedicated - // instance — a handful), never a creature/entity table. let scope = TickScope::from_rows( schedule.instance_id, ctx.db @@ -231,7 +98,6 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { ); let interval_micros = match &schedule.scheduled_at { ScheduleAt::Interval(d) => d.to_micros(), - // A one-shot Time row (nothing inserts one today) falls back to the default cadence math. ScheduleAt::Time(_) => MOVE_TICK_MICROS, }; let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); @@ -243,7 +109,6 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { sense_secs: sense_period_secs_for_interval(interval_micros), scope, }; - // Kept for the evidence lines below, which outlive the cycle the context is moved into. let (sense, global, scope_label) = ( tick.sense, tick.scope.runs_global_passes(), @@ -253,7 +118,6 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { let outcome = crate::creatures::cycle::run(ctx, tick); if global { - // World counters exclude dedicated rows, which report their own scoped pass counts. log_active_cell_stats(ctx, outcome.awake); log_narrowed_pass_stats(ctx); } @@ -266,23 +130,8 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { ); } -// =========================================================================================== -// Active cells [server]: grid-activation; only cells near players tick -// =========================================================================================== - -/// Rough heartbeat period (micros) for the active-cell rows-visited log line. NOT every tick (would spam `RUST_LOG=info` at the 0.5s movement cadence); -/// roughly once a minute is plenty to eyeball the before/after ratio on a live node. const ACTIVE_CELL_LOG_PERIOD_MICROS: i64 = 60_000_000; -/// The active-cell footprint radius (yards) for THIS tick: the larger of the combat activation radius -/// (`ai::combat_active_radius` — ~55yd at today's template data, aggro+assist) and the AOI visibility -/// radius the gateway subscribes to (`BOX_HALF_SPAN * GRID_CELL_SIZE`, 100yd guaranteed-visible). The -/// visibility floor matters for the MOVEMENT passes (patrol/return/wander): without it a creature -/// outside the (smaller) combat radius but still inside a player's view would visibly FREEZE mid- -/// route — an observable divergence the item's "byte-identical... anything a player could observe" -/// forbids. Taking the max means one active-cell set safely serves both concerns. -/// `game_creature_template` is a small reference table (tens of rows), not the ~2500-creature live -/// population this item exists to stop scanning — reading it once per tick is cheap. fn active_cell_radius(ctx: &ReducerContext) -> f32 { let visible = spatial::BOX_HALF_SPAN as f32 * spatial::GRID_CELL_SIZE; @@ -298,9 +147,6 @@ fn active_cell_radius(ctx: &ReducerContext) -> f32 { combat_active_radius(template_aggro_max).max(visible) } -/// Read each occupied neighborhood once, preserving map and instance isolation. -/// Authored active objects stay awake without a nearby Character. Pets and combat -/// candidates retain their separate sense-firing discovery. An Idle Bot wakes no cell. pub(crate) fn active_cell_creatures( ctx: &ReducerContext, scope: &TickScope, @@ -315,9 +161,6 @@ pub(crate) fn active_cell_creatures( let mut pets: Vec = Vec::new(); let mut in_combat: Vec = Vec::new(); - // Pet behavior consumes its candidates only on sense firings. The global combat-drop and regen - // passes consume IN_COMBAT candidates on global sense firings. Keep their shared table-order scan - // on that cadence; non-sense movement firings no longer read the world to build unused lists. if sense { let global = scope.runs_global_passes(); for e in entities.iter() { @@ -373,12 +216,8 @@ fn active_object_enters_scope(is_player: bool, partition_covered: bool, active: !is_player && partition_covered && active } -/// How long a Package-controlled Character stands still out of combat before it is an Idle Bot. const IDLE_BOT_AFTER_MS: u32 = 30_000; -/// Is this Character an Idle Bot? `package_controlled` reports a Sessionless Action Consent row and -/// runs last because it is a lookup. A leg advance stamps the move clock every firing, so a -/// Character on a movement leg is never idle. fn is_idle_bot( character: &WorldEntity, now_ms: u32, @@ -389,26 +228,17 @@ fn is_idle_bot( && package_controlled() } -/// One firing's active-cell creature set plus the sense-cadence pet and in-combat candidate lists. #[derive(Default)] pub(crate) struct TickSweep { - /// Creatures within `active_cell_radius` of at least one covered Character that is not an Idle - /// Bot. pub(crate) active: std::collections::HashSet, - /// Live pets (`owner_guid != 0`), in table order — the cycle's pet-phase candidate list. pub(crate) pets: Vec, - /// Units carrying `UNIT_FLAG_IN_COMBAT`, in table order — the candidate list the cycle's combat - /// exit sweeps, and the flag half of regeneration's in-combat verdict. pub(crate) in_combat: Vec, } -/// Evidence log for the active-cell sweep: the rows-visited/total ratio, roughly once a -/// minute. `total` (a full non-player-entity count) is deliberately gated behind the SAME rare window -/// so the O(N) count itself never reintroduces the per-tick cost the active-cell sweep removes. fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { let us = ctx.timestamp.to_micros_since_unix_epoch(); if us.rem_euclid(ACTIVE_CELL_LOG_PERIOD_MICROS) >= MOVE_TICK_MICROS { - return; // only the one tick per period that lands in the window logs + return; } let total = ctx .db @@ -419,17 +249,10 @@ fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { log::info!("tick_creatures active-cell: {awake}/{total} creatures visited this tick"); } -/// Evidence log for the narrowed passes: the cast/rout/fear rows-visited drop, in -/// the SAME rare window `log_active_cell_stats` uses (reusing its throttle — no extra per-tick cost). -/// `melee_rows` is the candidate universe BOTH the cycle's cast and rout phases outer-loop (identical -/// gate: "currently the attacker in `game_melee_attack`"); `fear_rows` is what the fear phase -/// outer-loops (the `A_CONTROL(M_FEAR)` aura rows). `total_all` is a full `game_world_entity` count -/// (players included) — what EVERY ONE of the three fully `entities.iter()`-scanned before this item, -/// so it's the honest "before" denominator the rows-visited ratio is measured against. fn log_narrowed_pass_stats(ctx: &ReducerContext) { let us = ctx.timestamp.to_micros_since_unix_epoch(); if us.rem_euclid(ACTIVE_CELL_LOG_PERIOD_MICROS) >= MOVE_TICK_MICROS { - return; // only the one tick per period that lands in the window logs + return; } let total_all = ctx.db.game_world_entity().iter().count(); let melee_rows = ctx.db.game_melee_attack().iter().count(); @@ -445,7 +268,6 @@ fn log_narrowed_pass_stats(ctx: &ReducerContext) { ); } -/// How the pass rows-visited line names this firing's coverage. fn scope_label(scope: &TickScope) -> String { match scope { TickScope::CatchAll { dedicated } => { @@ -455,8 +277,6 @@ fn scope_label(scope: &TickScope) -> String { } } -/// Sample per-pass row visits once per minute without adding diagnostic table writes. -/// Catch-all samples include the sense pass; dedicated samples report whether it ran. fn log_pass_stats( ctx: &ReducerContext, scope_label: &str, @@ -478,34 +298,6 @@ fn log_pass_stats( ); } -/// The ONE shared creature move-leg writer: every movement decision (the cycle's -/// idle and chase legs, flee, fear-flee) funnels its ALREADY-STEPPED landing point through here, so a -/// single ground-snap / anti-desync fix applies to ALL of them at once. The per-pass -/// STEP is computed by the caller BEFORE this call (different math per pass — waypoint segment / chase -/// step / walk-home / wander hop / flee dash); this owns only what every pass shares: -/// 1. ground-snap the landing point (`snap_z`) — off-slice / unimported areas fall back to the -/// caller's `z_fallback` (target z / home z / current z), byte-identical to the pre-terrain leg; -/// 2. insert the `CreatureMoveEvent` (leg START = the mover's CURRENT position, DEST = the snapped -/// landing point, `spline_id = now_ms` — one leg per creature per tick); -/// 3. advance the authoritative row to the leg END (x/y/z/grid/last_move), and — for the ETA-gated -/// IDLE passes only (`set_leg_ends`: patrol + wander) — arm `leg_ends_ms` so the leg plays to -/// completion instead of being re-thrown next tick (the RUN passes re-step every tick, no gate). -/// -/// The mover row `e` is taken BY VALUE (every caller owns a freshly-found row and needs nothing back — -/// so no `&mut` refetch/clone dance) and written once. `run` picks the SMSG_MONSTER_MOVE walk/run -/// animation. Anything pass-specific (patrol's `wp_target`, return's `moved_this_tick`, flee's combat -/// re-stamp) stays in the caller — this writer is purely the shared leg emission. -/// Write ONE movement leg for `guid` as the AOI-scoped spline row — the single relay path since -/// perf 2.3, and the only thing a client ever sees a non-player move through. -/// -/// `dur_ms == 0` with `dest == start` is a STOP (snap-and-hold): the client halts where it is. -/// Separate from [`emit_creature_leg`] because the stop/rush callers do their own entity bookkeeping -/// (a CC freeze writes the render point, Charge moves the caster) and only need the relay. -/// -/// EXISTS BECAUSE 2.3 CONVERTED ONE WRITER AND LEFT FOUR: `game_creature_move_event` inserts stayed -/// in the CC freeze, the chase-stop, `encounter::move_creature_to` and Charge, while the same commit -/// removed the gateway's subscription to that table ("nothing writes this any more"). Every one of -/// them went silently undelivered — the server moved, the client did not. Route new movement here. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_move_spline( ctx: &ReducerContext, @@ -547,13 +339,6 @@ pub(crate) fn emit_move_spline( } } -/// Write a FACING-ONLY spline row — `guid` doesn't move (start == dest, `dur_ms` 0) but its -/// heading changes to `angle_rad`. Routes through the SAME `game_creature_spline` relay carrier as -/// every other creature leg (one AOI-scoped table, one gateway subscription) rather than a new one; -/// the gateway distinguishes it by the `facing` flag and emits `SMSG_MONSTER_MOVE`'s `FacingAngle` -/// variant instead of `Normal`. Callers own the epsilon gate (don't call this every tick — see -/// the cycle's chase phase) and the entity row's `orientation` write; this is purely -/// the relay half, mirroring [`emit_move_spline`]'s split. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_facing_spline( ctx: &ReducerContext, @@ -606,9 +391,6 @@ pub struct CreaturePathPoint { pub z: f32, } -/// Publish the already checked path as one movement, shared by server advance and client relay. -/// A mover on a leg starts the path where [`stop_where_rendered`] would stop it, so a renewal -/// between two advance firings does not move the client back. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn emit_creature_path( ctx: &ReducerContext, @@ -627,7 +409,6 @@ pub(crate) fn emit_creature_path( return; } if length <= 0.0 { - // Already drawn on the destination: end the old leg here, as `move_to_point` does. stop_where_rendered(ctx, &mut mover); ctx.db.game_world_entity().guid().update(mover); return; @@ -661,7 +442,6 @@ pub(crate) fn emit_creature_path( ctx.db.game_world_entity().guid().update(mover); } -// A movement leg's full geometry (from/to/speed/timing); a struct built at the one call site and destructured here would be write-only. #[allow(clippy::too_many_arguments)] pub(crate) fn emit_creature_leg( ctx: &ReducerContext, @@ -673,12 +453,7 @@ pub(crate) fn emit_creature_leg( now_ms: u32, set_leg_ends: bool, ) { - // Ground-snap THIS leg's landing point, one snap now covers every pass. let nz = crate::terrain::snap_z(ctx, e.map_id, e.instance_id, to.0, to.1, z_fallback); - // REFUSE a non-finite leg (see `ai::finite_point`). Writing one makes the creature invisible to - // this very tick — its grid cell casts to `i32::MIN`, so no active cell ever contains it again — - // while `tick_melee` keeps swinging off the melee row. It becomes an unshakeable attacker. Loud, - // because the source of the corruption is still unknown and this log is what will name it. if !crate::creatures::ai::finite_point(to.0, to.1, nz) { spacetimedb::log::error!( "refused a non-finite leg for guid {} -> ({}, {}, {}) — creature left at its last good \ @@ -687,44 +462,25 @@ pub(crate) fn emit_creature_leg( ); return; } - // ONE WRITER: funnel the row build + upsert through `emit_move_spline`, the - // SAME call the cycle's spline-advance halt and its chase stop - // already use, so "one spline writer" is a fact the type system enforces, not doctrine repeated at - // each call site. Was: a `game_creature_move_event` INSERT (globally subscribed — so every leg was - // delivered to EVERY connected session and then discarded by most of them via the `created`-set - // guard) PLUS a hand-rolled spline DELETE+INSERT here. Measured at 100 dispersed players: 121.7 - // inserts/s and 121.6 reaps/s of the event table alone — see `emit_move_spline`'s own doc for the - // AOI-box-scoped replacement. - // - // SPLINE MODEL (unchanged): the client interpolates start→dest over `duration_ms`; the SERVER - // advances the authoritative position along the SAME spline each tick in `pass_advance_splines` - // rather than snapping to the leg END here. e.x/e.y/e.z stay at the leg START. emit_move_spline( ctx, e.guid, (e.x, e.y, e.z), (to.0, to.1, nz), - duration_ms.max(1), // avoid /0 in the lerp; a 0-dur snap just completes next advance + duration_ms.max(1), run, - now_ms, // spline id: must CHANGE per leg (see the field doc) — `now_ms` at emit time + now_ms, e.map_id, e.instance_id, (e.grid_x, e.grid_y), ); e.last_move_ms = now_ms; if set_leg_ends { - // ETA gate (patrol + wander): hold this leg until it lands, no mid-leg re-emit. e.leg_ends_ms = now_ms + duration_ms; } ctx.db.game_world_entity().guid().update(e); } -/// Stop `mover`'s leg where the client renders it now, facing along its Route Path. The row moves -/// to that point and a zero-duration stop there replaces the leg, so the stored position and the -/// stop packet agree with the client. Without this, a stop between two advance firings holds the -/// position the last firing wrote, behind the client. A blocked Route Path segment or changed -/// navigation inputs halt the stop where a leg advance would halt, so it never lands past an -/// obstruction. The caller writes `mover`. A mover with no leg is unchanged. pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) { let Some(previous) = place_where_rendered(ctx, mover) else { return; @@ -745,11 +501,8 @@ pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) ); } -/// Stop `mover` where the client renders it, then turn it in place to face `point`. The facing row -/// replaces the stop, so it takes a newer spline id. The caller writes `mover`. pub(crate) fn stop_facing(ctx: &ReducerContext, mover: &mut WorldEntity, point: (f32, f32)) { stop_where_rendered(ctx, mover); - // A stopped leg is over, so a patrol must not wait for its old ETA. mover.leg_ends_ms = 0; mover.orientation = (point.1 - mover.y).atan2(point.0 - mover.x); let replaced = ctx @@ -770,16 +523,11 @@ pub(crate) fn stop_facing(ctx: &ReducerContext, mover: &mut WorldEntity, point: ); } -/// Prepare `mover` for a new leg that replaces its current one: place it where a stop now would -/// leave it, and return the spline id the new leg takes. The caller emits the leg and writes -/// `mover`. pub(crate) fn begin_leg(ctx: &ReducerContext, mover: &mut WorldEntity) -> u32 { let now_ms = now_ms(ctx); place_where_rendered(ctx, mover).map_or(now_ms, |previous| next_spline_id(now_ms, previous)) } -/// Move `mover` to where a stop now would leave it on its leg, and return that leg's spline id. -/// `None` for a mover with no leg, which stays unchanged. The caller writes `mover`. fn place_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) -> Option { let leg = ctx.db.game_creature_spline().guid().find(mover.guid)?; let spline_id = leg.spline_id; @@ -789,20 +537,15 @@ fn place_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) -> Option Some(spline_id) } -/// The id for a leg that replaces one with id `previous`. The client ignores an id that does not -/// exceed the one it replaces, so a second leg in the same millisecond takes `previous + 1`. +// The client ignores a replacement id that does not exceed the previous spline id. pub(crate) fn next_spline_id(now_ms: u32, previous: u32) -> u32 { now_ms.max(previous.wrapping_add(1)) } -/// The transaction's clock as the wrapping millisecond count that spline ids and `last_move_ms` -/// use. pub(crate) fn now_ms(ctx: &ReducerContext) -> u32 { (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32 } -/// Move `mover` to `stop`: position, grid address and packed cell together, and the stop's heading -/// when it has one. pub(crate) fn place_stopped(mover: &mut WorldEntity, stop: super::cycle::Stop) { let (grid_x, grid_y) = spatial::grid_cell(stop.at.x, stop.at.y); mover.x = stop.at.x; @@ -816,11 +559,6 @@ pub(crate) fn place_stopped(mover: &mut WorldEntity, stop: super::cycle::Stop) { } } -/// Must a leg advance stay in the creature's stored row once the firing ends? The cycle's passes -/// always read the advanced row; only the commit log may skip it. `stored` is the row before the -/// advance, `advanced` the row it wrote, `settled` the row at the end of the firing, and `leg` the -/// mover's spline row then. Arrival, a new leg, a halt, any other write, a cell change, a turn, -/// combat and a Character all keep the advance. pub(crate) fn advance_needs_persist( stored: &WorldEntity, advanced: &WorldEntity, @@ -828,8 +566,6 @@ pub(crate) fn advance_needs_persist( leg: Option<&CreatureSpline>, now_micros: u64, ) -> bool { - // Range and line-of-sight checks outside the cycle may read a walking creature up to this far - // behind, the trade Characters' own heartbeat gate makes. let max_drift = crate::world::PERSIST_MAX_DRIFT_YD; let drift_sq = (advanced.x - stored.x).powi(2) + (advanced.y - stored.y).powi(2) @@ -844,15 +580,6 @@ pub(crate) fn advance_needs_persist( || drift_sq > max_drift * max_drift } -/// The shared gate ladder every ENGAGED/table-driven phase (cast, threat retarget, chase, rout and -/// fear) opens its per-candidate loop with: resolve `guid` to a live CREATURE (no PLAYER bit, not -/// dead) whose instance THIS firing's `scope` covers. `None` collapses each site's `let Some(c) = ... -/// else { continue }; if c.is_player() || c.dead { continue }; if !scope.covers(c.instance_id) { -/// continue }` into one check — every call site still increments its own `visited` counter only on -/// `Some`, matching the existing "gate first, then count" order everywhere. -/// -/// `pub(crate)` for the cycle's production adapter, which opens the cast, threat-retarget and chase -/// candidate lists with it. pub(crate) fn movable_creature( ctx: &ReducerContext, guid: u64, @@ -865,12 +592,6 @@ pub(crate) fn movable_creature( Some(c) } -/// May this creature rout at all? The HP threshold (`should_flee`) plus the per-TYPE gate -/// (`flee_eligible` — only HUMANOIDS rout; BEASTS/undead/elementals fight to the death). It decides -/// whether a rout may START, never whether one is running — that is `creature_is_routing`. The two -/// non-engaged sites (the cycle's aggro and assist phases) ask THIS question: they act on creatures -/// with no engagement row, which therefore have no rout clock to read. A missing template ⇒ not eligible (safe default). -/// [server] pub(crate) fn rout_eligible(ctx: &ReducerContext, c: &WorldEntity) -> bool { should_flee(c.health, c.max_health) && ctx @@ -881,23 +602,6 @@ pub(crate) fn rout_eligible(ctx: &ReducerContext, c: &WorldEntity) -> bool { .is_some_and(|t| flee_eligible(t.creature_type)) } -/// Is this creature ACTIVELY routing — inside an open rout window on its own engagement row, entitled -/// to that window, and able to move? The ONE place that question is answered, so the three engaged -/// sites agree: the chase→rout divert, the rout leg itself, and the swing pass. If only some of them -/// knew about the window, a creature would be diverted out of chasing yet never routed — standing -/// FROZEN instead of fighting. A spent window means not routing, so a creature that has used its rout -/// chases and swings like any other attacker. CC counts as not routing for the same reason: the rout -/// leg is suppressed for a rooted/stunned/feared creature, so it must keep swinging rather than stand -/// silent. [server] -/// -/// Two things entitle a creature to the window. The fixed rout's own gate (`rout_eligible`: below the -/// flee threshold AND of a kind that runs) opens it for the ordinary near-death humanoid. An authored -/// flee opens it for anyone the script says flees, a beast at 30% health included, because the -/// script's window is the only thing that stamped it, and the fixed gate is switched OFF for such a -/// creature. Reading only the fixed gate left an authored flee with an open window and nothing that -/// would move it. -/// -/// `pub(crate)` because the swing pass in `combat::swing` is one of the three sites. pub(crate) fn creature_is_routing(ctx: &ReducerContext, c: &WorldEntity) -> bool { let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; ctx.db @@ -976,7 +680,6 @@ mod advance_persist_gate { const NOW_MICROS: u64 = 60_000_000; const NOW_MS: u32 = 60_000; - /// An out-of-combat creature at `(x, 0)` whose move clock reads `moved_ms`. fn creature_at(x: f32, moved_ms: u32) -> WorldEntity { let (gx, gy) = spatial::grid_cell(x, 0.0); let mut creature = crate::helpers::tests::entity(7, 0, 0, gx, gy); @@ -1009,8 +712,6 @@ mod advance_persist_gate { } } - /// One advance to judge: stored at `from_x`, advanced to `to_x` one second into an eight-second - /// leg, with nothing else writing the row this firing. struct Walk { stored: WorldEntity, advanced: WorldEntity, @@ -1121,7 +822,6 @@ mod stop_between_firings { use crate::WorldEntity; use lyracore_shared::spatial; - /// A creature the last firing stored at (`x`, 0, 0), in the cell that point lies in. fn creature_stored_at(x: f32) -> WorldEntity { let (grid_x, grid_y) = spatial::grid_cell(x, 0.0); let mut creature = crate::helpers::tests::entity(42, 0, 0, grid_x, grid_y); @@ -1132,7 +832,6 @@ mod stop_between_firings { #[test] fn a_stop_past_a_cell_edge_moves_the_creature_into_the_next_cell() { - // x = 16.67 is a cell edge. The last firing stored the creature short of it. let mut creature = creature_stored_at(15.0); let before = (creature.grid_x, creature.cell); place_stopped( @@ -1188,7 +887,6 @@ mod idle_bot { const NOW_MS: u32 = 3_600_000; - /// An out-of-combat Character whose move clock last ticked `still_ms` ago. fn character_still_for(still_ms: u32) -> WorldEntity { let mut character = crate::helpers::tests::entity(42, 0, 0, 0, 0); character.type_mask = type_mask::PLAYER_BIT; @@ -1204,7 +902,6 @@ mod idle_bot { #[test] fn a_character_without_consent_is_never_an_idle_bot() { - // Humans and test fixture Characters have no Sessionless Action Consent row. let human = character_still_for(600_000); assert!(!is_idle_bot(&human, NOW_MS, || false)); } @@ -1218,7 +915,6 @@ mod idle_bot { #[test] fn a_bot_on_a_movement_leg_is_not_idle() { - // The leg advance stamped the move clock on the last firing. assert!(!is_idle_bot(&character_still_for(500), NOW_MS, || true)); } diff --git a/module/src/debug/audit.rs b/module/src/debug/audit.rs index 3eac8fa29..d3ca6e10b 100644 --- a/module/src/debug/audit.rs +++ b/module/src/debug/audit.rs @@ -1,25 +1,13 @@ -//! Content audits: the 1-20 class-kit audit (which learnable abilities actually DO something) and -//! the 1-20 quest-chain integrity audit (which quests would stall a leveling playthrough). Both are -//! pure reads that log a report; neither mutates state. - use spacetimedb::{log, reducer, ReducerContext, Table}; use crate::{ game_creature_template, game_item_template, game_quest_template, game_spell, game_spell_effect, }; -/// 1-20 leveling audit (2026-07-17): for each of the 6 human-class trainers, resolve EVERY offering -/// through the server's own `resolve_learn_target` (wrapper → rank) and classify the rank spell's -/// effects — LIVE (has a real handler), HOLLOW (all effects are unmapped E_SCRIPTED no-ops, script_id -/// 0 — the spell "exists" but does nothing), or NO-EFFECT (learn-only/passive, no effect rows). Logs a -/// per-class band summary (L1-9 / L10-19 / L20) + the concrete HOLLOW list (class · req_level · id · -/// name) — the actionable "which class abilities don't work through 1-20" wall list. Read the module -/// log after calling. Pure read, no state change. #[reducer] pub fn debug_audit_class_kits(ctx: &ReducerContext) { use crate::game_trainer_spell; use crate::spell::{A_FLAG, E_SCRIPTED}; - // (class label, trainer creature entry): the Goldshire class trainers. const TRAINERS: &[(&str, u32)] = &[ ("Warrior", 913), ("Paladin", 927), @@ -28,10 +16,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { ("Mage", 328), ("Warlock", 906), ]; - // PASSIVE combat skills whose mechanic lives in the COMBAT ENGINE, not as a spell effect - // (verified 2026-07-17: combat/tables.rs parry/block in the attack table, combat/mod.rs off-hand - // swings). Their spell has no live effect but the ability WORKS — so they are NOT hollow. Matched - // by name (rank-suffix stripped by the resolver). Keeps the HOLLOW list actionable. const ENGINE_PASSIVES: &[&str] = &[ "Parry", "Dodge", @@ -44,8 +28,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { "Slow Fall", "Unending Breath", ]; - // Classify a resolved rank spell: Some(true)=LIVE, Some(false)=HOLLOW (unmapped no-op), None=no - // effects, or the passive-engine bucket via the returned name check at the call site. let classify = |rank: u32| -> Option { let effects: Vec<_> = ctx .db @@ -56,8 +38,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { if effects.is_empty() { return None; } - // A_FLAG passive markers don't count as "does something"; an E_SCRIPTED with script_id 0 is - // the unmapped no-op. Any other effect kind (or a scripted handler with a real id) is live. let live = effects .iter() .any(|e| e.kind != A_FLAG && !(e.kind == E_SCRIPTED && e.script_id == 0)); @@ -74,7 +54,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { }; log::info!("=== CLASS-KIT AUDIT (1-20 leveling) — LIVE / HOLLOW / NO-EFFECT per band ==="); for (label, trainer) in TRAINERS { - // [band][0]=live [1]=hollow [2]=no-effect let mut counts = [[0u32; 3]; 3]; let mut hollow: Vec<(u8, u32, String)> = Vec::new(); for off in ctx @@ -97,7 +76,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { .unwrap_or_default(); match classify(rank) { Some(true) => counts[b][0] += 1, - // An engine-implemented passive counts as LIVE (the mechanic works), not hollow. _ if ENGINE_PASSIVES.contains(&name.as_str()) => counts[b][0] += 1, Some(false) => { counts[b][1] += 1; @@ -126,12 +104,6 @@ pub fn debug_audit_class_kits(ctx: &ReducerContext) { log::info!("=== END CLASS-KIT AUDIT ==="); } -/// 1-20 quest-chain integrity audit (2026-07-17): for every quest with `quest_level` in 1..=20, -/// check the four things a leveling player needs to actually COMPLETE it, and log the broken ones — -/// the "which quests would stall a 1-20 playthrough" wall list. Checks: (1) a START giver exists (a -/// creature or GO offers it — else the quest is unreachable), (2) an END giver exists (else it can't -/// be turned in), (3) `prev_quest_id`, if set, resolves to a real quest (else a dangling chain), (4) -/// every KILL/COLLECT objective target + every reward item references an existing template. Pure read. #[reducer] pub fn debug_audit_quest_chains(ctx: &ReducerContext) { use crate::quest::quest_role; @@ -154,7 +126,7 @@ pub fn debug_audit_quest_chains(ctx: &ReducerContext) { } }; let mut ok = [0u32; 3]; - let mut broken: Vec<(u32, u32, String, String)> = Vec::new(); // (level, entry, title, reasons) + let mut broken: Vec<(u32, u32, String, String)> = Vec::new(); log::info!("=== QUEST-CHAIN AUDIT (1-20) — completability per band ==="); for q in ctx.db.game_quest_template().iter() { if q.quest_level == 0 || q.quest_level > 20 { @@ -192,7 +164,7 @@ pub fn debug_audit_quest_chains(ctx: &ReducerContext) { .entry() .find(o.target_entry) .is_none(), - _ => false, // USE_GAMEOBJECT / EXPLORE: target validated elsewhere + _ => false, }; if missing { reasons.push("bad objective target"); diff --git a/module/src/debug/auth.rs b/module/src/debug/auth.rs index 79ab388ee..df4ea3993 100644 --- a/module/src/debug/auth.rs +++ b/module/src/debug/auth.rs @@ -2,7 +2,6 @@ use spacetimedb::{reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::auth::{game_session, game_session_reaper_schedule}; -/// Exercise the real recurring scheduler without waiting for the production interval. #[reducer] pub(crate) fn debug_accelerate_session_reaper(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -16,7 +15,6 @@ pub(crate) fn debug_accelerate_session_reaper(ctx: &ReducerContext) -> Result<() Ok(()) } -/// Expire an Account's Session so the handshake and scheduled reaper can be verified. #[reducer] pub fn debug_expire_session(ctx: &ReducerContext, account_id: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -30,7 +28,6 @@ pub fn debug_expire_session(ctx: &ReducerContext, account_id: u64) -> Result<(), Ok(()) } -/// Drive `package_account::create_package_character` for `package_name` without a Package present. #[reducer] pub fn debug_create_package_character( ctx: &ReducerContext, diff --git a/module/src/debug/encounter.rs b/module/src/debug/encounter.rs index b7bd150f6..51f422201 100644 --- a/module/src/debug/encounter.rs +++ b/module/src/debug/encounter.rs @@ -1,12 +1,5 @@ -//! Encounter kernel levers, operator stand-ins until the Deadmines package -//! consumes the primitives for real. Each is a thin `?`-wrapper over the `crate::encounter` fn it -//! names, so the runbook can exercise every primitive on a live node without an encounter package. - use spacetimedb::{log, reducer, ReducerContext}; -/// Register an HP-threshold watch (`encounter::watch_hp_threshold`). NOTE: a watch alone fires -/// nothing — `on_hp_threshold` handlers are compile-time registrations (`game_hook!` markers), so -/// without one anywhere in the build the kernel's damage probe stays on its zero-cost early-out. #[reducer] pub(crate) fn debug_encounter_watch_hp( ctx: &ReducerContext, @@ -17,8 +10,6 @@ pub(crate) fn debug_encounter_watch_hp( crate::encounter::watch_hp_threshold(ctx, entry, pct) } -/// Set one encounter's state + payload (`encounter::set_encounter_state`/`set_encounter_data`) — -/// readable back via `spacetime sql "select * from game_encounter_state"`. #[reducer] pub fn debug_encounter_set_state( ctx: &ReducerContext, @@ -33,7 +24,6 @@ pub fn debug_encounter_set_state( Ok(()) } -/// Flip every DOOR/BUTTON of `go_entry` open (`encounter::open_door`); logs the flip count. #[reducer] pub fn debug_encounter_open_door( ctx: &ReducerContext, @@ -46,8 +36,7 @@ pub fn debug_encounter_open_door( Ok(()) } -/// Spawn `count` adds of `entry` as one tracked wave (`encounter::spawn_wave`); logs the guids. -#[allow(clippy::too_many_arguments)] // a debug lever mirrors the primitive's full signature +#[allow(clippy::too_many_arguments)] #[reducer] pub fn debug_encounter_spawn_wave( ctx: &ReducerContext, @@ -83,7 +72,6 @@ pub fn debug_encounter_spawn_wave( Ok(()) } -/// Write a creature's client-visible virtual-item slots (`encounter::equip_swap`). #[reducer] pub fn debug_encounter_equip( ctx: &ReducerContext, @@ -96,7 +84,6 @@ pub fn debug_encounter_equip( crate::encounter::equip_swap(ctx, creature_guid, main_hand, off_hand, ranged) } -/// Send a creature on one move leg (`encounter::move_to_point`) — the Smite rack-run motion. #[reducer] pub fn debug_encounter_move( ctx: &ReducerContext, @@ -110,8 +97,6 @@ pub fn debug_encounter_move( crate::encounter::move_to_point(ctx, creature_guid, x, y, z, run) } -/// Reset one encounter (`encounter::encounter_reset`): state → NotStarted, tracked wave despawned. -/// HP fired-marks are entry-keyed, so clear them separately: `debug_encounter_reset_hp_fired`. #[reducer] pub fn debug_encounter_reset( ctx: &ReducerContext, @@ -123,7 +108,6 @@ pub fn debug_encounter_reset( Ok(()) } -/// Clear the per-instance HP fired-marks for `entry` (`encounter::reset_hp_fired`). #[reducer] pub fn debug_encounter_reset_hp_fired( ctx: &ReducerContext, @@ -135,9 +119,6 @@ pub fn debug_encounter_reset_hp_fired( Ok(()) } -/// Sweep EVERY kernel row for an instance (`encounter::sweep_encounter_state`) — kept as the -/// narrow kernel-only lever; the FULL instance reap (which calls this sweep as its 228 splice) -/// is `debug_reap_instance` (`debug/instance.rs`). #[reducer] pub fn debug_sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/debug/eventai.rs b/module/src/debug/eventai.rs index 08b83b1b1..bb4c930a3 100644 --- a/module/src/debug/eventai.rs +++ b/module/src/debug/eventai.rs @@ -1,5 +1,3 @@ -//! EventAI production-boundary verifiers for standalone tests. - use spacetimedb::{reducer, ReducerContext, Table}; use crate::{ @@ -9,7 +7,6 @@ use crate::{ const FIXTURE_OWNER_ENTRY: u32 = 51_000; const FIXTURE_OWNER_GUID: u64 = (0xF130_u64 << 48) | ((FIXTURE_OWNER_ENTRY as u64) << 24) | 1; -/// Prove action 56 reaches spell-created guardians, which have an owner but no EventAI summon row. #[reducer] pub fn debug_verify_eventai_spell_guardian_cleanup(ctx: &ReducerContext) -> Result<(), String> { crate::creatures::replace_relay_catalogue_for_debug(ctx, "")?; @@ -92,8 +89,6 @@ pub fn debug_verify_eventai_spell_guardian_cleanup(ctx: &ReducerContext) -> Resu Ok(()) } -/// Create a replacement summon, deliver its prior life's callback, and leave a short-lived -/// disengaged summon for the real scheduler to expire. #[reducer] pub fn debug_verify_eventai_summon_expiry(ctx: &ReducerContext) -> Result<(), String> { let owner = ctx @@ -105,9 +100,6 @@ pub fn debug_verify_eventai_summon_expiry(ctx: &ReducerContext) -> Result<(), St crate::creatures::verify_summon_expiry_boundaries_for_debug(ctx, &owner, FIXTURE_OWNER_ENTRY) } -/// Start a relay that runs `source_guid` to `selected_guid` and, once the leg lands, equips item 50 -/// through an arrival relay. The test reads the equipment row to see whether the arrival fired. -/// It replaces the entire relay catalogue, so never call it on a live realm. #[reducer] pub fn debug_start_relay_move( ctx: &ReducerContext, @@ -118,9 +110,6 @@ pub fn debug_start_relay_move( start_relay_move(ctx, source_guid, selected_guid) } -/// [`debug_start_relay_move`], then run its arrival at once. The arrival must put the mover on the -/// destination, reap the leg and run the arrival relay. It replaces the entire relay catalogue, so -/// never call it on a live realm. #[reducer] pub fn debug_verify_relay_arrival_placement( ctx: &ReducerContext, diff --git a/module/src/debug/fingerprint.rs b/module/src/debug/fingerprint.rs index e83d546ad..ce03f7688 100644 --- a/module/src/debug/fingerprint.rs +++ b/module/src/debug/fingerprint.rs @@ -1,14 +1,3 @@ -//! Catalogue parity fingerprint — content-hash the tables that are supposed to be -//! IDENTICAL on every shard (spells, items, DBC-derived reference tables), so a partial or stale -//! re-import shows up as a loud mismatch instead of "this spell behaves differently in Durotar". -//! Row counts alone are not enough (a count matches trivially while contents differ): every row is -//! BSATN-encoded — the same canonical byte encoding SpacetimeDB already uses to store/diff rows -//! (`spacetimedb::sats::bsatn`), so this needs no hand-written per-table serializer — and the SORTED -//! set of encodings for a table is folded into one hash with `DefaultHasher` (stdlib, no new -//! dependency; deterministic for identical input on the SAME compiled wasm, which is exactly the -//! "did every shard run the same import?" question that the internal cross-database parity check -//! uses this table to answer). - use spacetimedb::{log, reducer, table, ReducerContext, Table}; use crate::spell::stacking::{game_spell_group, game_spell_group_rule}; @@ -21,24 +10,15 @@ use crate::{ game_spell_reagent, game_start_item, game_start_position, game_talent, game_talent_tab, }; -/// A family's content fingerprint, recomputed wholesale by [`debug_catalogue_fingerprint`]. Public so -/// `spacetime sql` (or an equivalent cross-database parity check) can read it per database. [reference] #[table(accessor = game_catalogue_fingerprint, public)] pub struct CatalogueFingerprint { #[primary_key] pub family: String, pub row_count: u64, pub fingerprint: u64, - /// Comma-separated table list actually hashed — so a family definition drifting between two - /// module versions (a table added/renamed here but not there) shows up as a visible diff - /// instead of a silent apples-to-oranges compare. pub tables: String, } -/// Fold one table's rows into `hasher`/`count`: BSATN-encode every row, SORT the encodings (content -/// order, not iteration order — SpacetimeDB does not guarantee `.iter()` order is stable across -/// deployments/inserts), then hash the table name followed by the sorted encodings. The table name is -/// hashed first so two families with the same total content but a table added/renamed still differ. fn fold_table( hasher: &mut std::collections::hash_map::DefaultHasher, count: &mut u64, @@ -65,10 +45,6 @@ macro_rules! fold_family { }}; } -/// Recompute all catalogue fingerprints (clear+reinsert — idempotent, call any time). Driven once per -/// connected shard as part of the deploy/verify path by an internal cross-database parity check. The -/// family list below is deliberately curated — see the per-family comments for why each table is (or, -/// for the excluded ones, is not) included. #[reducer] pub fn debug_catalogue_fingerprint(ctx: &ReducerContext) { use std::collections::hash_map::DefaultHasher; @@ -79,11 +55,6 @@ pub fn debug_catalogue_fingerprint(ctx: &ReducerContext) { out.family().delete(&row.family); } - // SPELLS — Spell.dbc (`importer/src/spell.rs::run_spells`) plus its box-INDEPENDENT dump - // companions (spell_chain/spell_learn_spell/createinfo_spell — none of their `build_*_sql` - // functions in `importer/src/main.rs` take a box/local-entry argument), plus the hand-seeded - // stacking-group starter set (`module/src/seed.rs`), which can independently skew if - // `debug_reseed_stacking_groups` was run on one shard's auto-migration and not another's. { let mut h = DefaultHasher::new(); let mut n = 0u64; @@ -105,8 +76,6 @@ pub fn debug_catalogue_fingerprint(ctx: &ReducerContext) { }); } - // ITEMS — item_template is imported WHOLE regardless of box (`importer/src/main.rs`: "3) - // item_template rows — the FULL vanilla item set"); every shard should carry the identical set. { let mut h = DefaultHasher::new(); let mut n = 0u64; @@ -119,11 +88,6 @@ pub fn debug_catalogue_fingerprint(ctx: &ReducerContext) { }); } - // DBC_REFERENCE — every table the client-DBC importer (`importer/src/dbc.rs`) produces, plus the - // handful of cmangos-dump tables that are ALSO box-independent (`build_start_position_sql` / - // `build_graveyard_zone_sql` / `build_areatrigger_teleport_sql` / `build_createinfo_action_sql`, - // none of which take a box/local-entry argument) — world-wide reference data with no geographic - // scoping, so every shard should carry the identical set. { let mut h = DefaultHasher::new(); let mut n = 0u64; diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 85afe8497..d535e2be5 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -1,9 +1,5 @@ -//! Dungeon-instancing fixture + operator levers, plus the nav-probe and -//! one-time-backfill levers that accreted next to them (no section of their own). - use spacetimedb::{log, reducer, ReducerContext, Table}; -// trait imports — VmapChunk/NavChunk are module-private, no crate-root glob re-export use crate::creatures::build_creature_entity; use crate::nav::game_nav_chunk; use crate::vmap::game_vmap_chunk; @@ -12,24 +8,6 @@ use crate::{ game_world_entity, CreatureSpawn, ServerConfig, }; -/// THE FIXTURE DUNGEON (headless, zero imports): resolve-or-create an instance of -/// the character's CURRENT map through the REAL production path (`resolve_or_create_instance`: -/// own binding → party's live instance → create, 5-cap enforced) and same-map-teleport them into -/// it in place. On the dev map this instances the SEEDED world — the chicken/wolf/trainer roster -/// copies in as the per-instance population, so two ungrouped characters get DISJOINT populations -/// (distinct instance ids, distinct creature guids) and two GROUPED characters land in the SAME -/// one. Call it twice for one character → the second call RESOLVES the binding (same id back). -/// Verify: `spacetime sql "SELECT * FROM game_instance"` / `"... FROM game_instance_binding"`, -/// and the log line below carries the id. -/// -/// CLIENT-VIEW CAVEAT (dev-only, inherent to the same-map hop): a same-map instance switch is a -/// MSG_MOVE_TELEPORT_ACK teleport — the gateway's per-viewer `created` dedup set only resets on a -/// CROSS-map transfer (the enter_world resubscribe), so a live 1.12 client keeps rendering the -/// instance-0 objects it already saw (frozen — their relays are now instance-gated) until relog. -/// SERVER-side isolation is full either way (module gates + relay gates); wire assertions about -/// "sees only its own population" should therefore be made on a FRESH login after the switch -/// (login rebuilds into `pending_instance_id` with a fresh created set + initial-apply sweep). -/// The real dungeon flow (areatrigger portal) is cross-map and has no such caveat. #[reducer] pub fn debug_create_fixture_instance( ctx: &ReducerContext, @@ -39,7 +17,6 @@ pub fn debug_create_fixture_instance( let player = crate::helpers::live_entity(ctx, character_guid)?; let map_id = player.map_id; let instance_id = crate::instance::resolve_or_create_instance(ctx, character_guid, map_id)?; - // Same-map teleport in place: the in-place branch stamps entity + Character with the instance. crate::world::teleport_player( ctx, character_guid, @@ -54,10 +31,6 @@ pub fn debug_create_fixture_instance( Ok(()) } -/// Place a character into an EXISTING instance on their current map (bypasses binding/resolve — -/// the raw placement lever for cap/isolation experiments). Same-map only: a cross-map placement -/// would land them at their current coords on a foreign map (garbage) — use the areatrigger path -/// or `debug_teleport` + `debug_enter_instance` in two steps for cross-map trips. #[reducer] pub fn debug_enter_instance( ctx: &ReducerContext, @@ -93,9 +66,6 @@ pub fn debug_enter_instance( Ok(()) } -/// Force-reap an instance NOW (the full teardown: population → encounter sweep → tick row -/// → bindings → row) without waiting out the 30min empty window. It refuses the same live-player, -/// pending-intent, and Escrow claims as the scheduled reaper. The headless reap-verification lever. #[reducer] pub fn debug_reap_instance(ctx: &ReducerContext, instance_id: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -120,9 +90,6 @@ pub fn debug_reap_instance(ctx: &ReducerContext, instance_id: u64) -> Result<(), Ok(()) } -/// Drive the player-facing `reset_instance` core by explicit guid (the CLI identity owns no -/// entity): flags every UNOCCUPIED instance the character may reset; the reaper (60s) tears each -/// down on its next firing. Returns Err when nothing was eligible, mirroring the player reducer. #[reducer] pub fn debug_reset_instance(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -131,8 +98,6 @@ pub fn debug_reset_instance(ctx: &ReducerContext, character_guid: u64) -> Result }) } -/// 241 spot-probe: log walkability + obstruction at (x, y) on `map`. The rasterizer's -/// server-side verification hook (nav has no client-visible readback until 243 wires it). #[reducer] pub fn debug_nav_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32) { let walk = crate::nav::walkable(ctx, map, x, y); @@ -143,8 +108,6 @@ pub fn debug_nav_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32) { ); } -/// Toggle nav-grid consumption (`game_config.nav_enabled`), upserts row 0 like -/// `debug_set_xp_rate`. OFF = straight-line movement + wall-blind aggro/casts. #[reducer] pub fn debug_set_nav_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { let cfg = ctx.db.game_config(); @@ -168,12 +131,9 @@ pub fn debug_set_nav_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), Ok(()) } -/// 243 spot-probe: log the nav-aware leg from (x0,y0) to (x1,y1) — the exact query -/// `nav_step` runs. Shows the string-pulled waypoints (detour) or the fast/fallback shape. #[reducer] pub fn debug_nav_leg(ctx: &ReducerContext, map: u32, x0: f32, y0: f32, x1: f32, y1: f32) { let enabled = crate::nav::nav_enabled(ctx); - // LoS/collision endpoints stand on the ground like real units (falls back to z=0 off-slice). let z0 = crate::terrain::ground_z(ctx, map, x0, y0).unwrap_or(0.0); let z1 = crate::terrain::ground_z(ctx, map, x1, y1).unwrap_or(0.0); let step = crate::nav::nav_step(ctx, map, 0, (x0, y0), (x1, y1), 4.0, 0.0, z0); @@ -185,8 +145,6 @@ pub fn debug_nav_leg(ctx: &ReducerContext, map: u32, x0: f32, y0: f32, x1: f32, ); } -/// Toggle vmap ray consumption (`game_config.vmap_enabled`) — upserts row 0 like -/// `debug_set_nav_enabled`. OFF = every ray query returns clear (no vmap data consulted). #[reducer] pub fn debug_set_vmap_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { let cfg = ctx.db.game_config(); @@ -210,9 +168,6 @@ pub fn debug_set_vmap_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), Ok(()) } -/// Toggle vmap-derived nav coverage in path planning (`game_config.nav_coverage_enabled`) — -/// upserts row 0 like `debug_set_vmap_enabled`. OFF = planning reads the imported nav grid alone. -/// This is the one-command rollback for a coverage rollout. #[reducer] pub fn debug_set_nav_coverage_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { let cfg = ctx.db.game_config(); @@ -236,9 +191,8 @@ pub fn debug_set_nav_coverage_enabled(ctx: &ReducerContext, enabled: bool) -> Re Ok(()) } -/// Probe both ray flavors in instance 0. Preserve the original Operator command signature. #[reducer] -#[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. +#[allow(clippy::too_many_arguments)] pub fn debug_vmap_ray( ctx: &ReducerContext, map: u32, @@ -252,10 +206,8 @@ pub fn debug_vmap_ray( debug_vmap_ray_instance(ctx, map, x0, y0, z0, x1, y1, z1, 0); } -/// Probe both ray flavors in one instance, independent of the gameplay Gate. -/// Static doodads affect collision only; closed DOOR/BUTTON geometry affects both flavors. #[reducer] -#[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. +#[allow(clippy::too_many_arguments)] pub fn debug_vmap_ray_instance( ctx: &ReducerContext, map: u32, @@ -276,10 +228,6 @@ pub fn debug_vmap_ray_instance( ); } -/// 526 spot-probe: log the model floor at `(x, y)` for `map`, both the raw `vmap::floor_z` and -/// `terrain::snap_z`'s combined `max(terrain, floor)` — the server-side verification hook for a -/// bridge/WMO-interior coordinate where the two disagree (done-when: returns the model floor, not -/// the terrain underneath it). Analogue of `debug_nav_probe`. #[reducer] pub fn debug_floor_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32, probe_z: f32) { let enabled = crate::vmap::vmap_enabled(ctx, map); @@ -291,11 +239,6 @@ pub fn debug_floor_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32, probe_z ); } -/// 527 spot-probe: log the WMO area-info at `(x, y, z)` for `map` — which group (if any) contains -/// the point and whether it's indoor. Analogue of `debug_vmap_ray`/`debug_floor_probe`; vmap has -/// no client-visible readback, so this is the server-side verification hook (decision §query -/// surface). Done-when's live check: a point inside the Northshire abbey logs `indoor=true` with a -/// group id, a point outside logs `indoor=None`/no group. #[reducer] pub fn debug_vmap_area_info(ctx: &ReducerContext, map: u32, x: f32, y: f32, z: f32) { let enabled = crate::vmap::vmap_enabled(ctx, map); @@ -315,10 +258,10 @@ pub fn debug_assert_floor_snap( ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - const ORIGIN: (f32, f32) = (2600.0, 2600.0); // an unimported-terrain patch: ground_z is None - const FLOOR_Z: f32 = 20.0; // the synthetic "bridge deck" - const PROBE_UNDERSHOOT_YD: f32 = 0.5; // inside floor_z's FLOOR_PROBE_UP_YD margin - const TERRAIN_FALLBACK: f32 = FLOOR_Z - PROBE_UNDERSHOOT_YD; // what a spawn/movement candidate near (but not exactly on) the deck carries + const ORIGIN: (f32, f32) = (2600.0, 2600.0); + const FLOOR_Z: f32 = 20.0; + const PROBE_UNDERSHOOT_YD: f32 = 0.5; + const TERRAIN_FALLBACK: f32 = FLOOR_Z - PROBE_UNDERSHOOT_YD; let tris = [ lyracore_shared::vmap::VmapTri { @@ -361,7 +304,7 @@ pub fn debug_assert_floor_snap( continue; } let row = vmap_chunks.insert(crate::vmap::VmapChunk { - id: 0, // auto_inc + id: 0, key: lyracore_shared::terrain::cell_key(map_id, cx, cy), map_id, cell_x: cx, @@ -392,20 +335,12 @@ pub fn debug_assert_floor_snap( } } - // (1) the raw floor probe, from above the deck looking down. let floor = crate::vmap::floor_z(ctx, map_id, 0, ORIGIN.0, ORIGIN.1, FLOOR_Z + 5.0); - // (2) the combined snap a movement/targeting call site actually uses. let snapped = crate::terrain::snap_z(ctx, map_id, 0, ORIGIN.0, ORIGIN.1, TERRAIN_FALLBACK); let ground = crate::terrain::ground_z(ctx, map_id, ORIGIN.0, ORIGIN.1); - // (3) spawn a creature AT the terrain-less fallback z (i.e. what an imported spawn row with - // no floor awareness would carry) through the real insert chokepoint. const HIGHGUID_UNIT: u64 = 0xF130; let entry_hi = (creature_entry as u64) << 24; - // Max over BOTH spawn rows AND live entities of this entry, one past it — never a fixed - // low-24 suffix. A spawn-rows-only scan (or a fixed suffix) can re-collide with a lingering - // entity from a prior crashed run and panic on insert (see debug/mod.rs's - // debug_spawn_test_creature, live find 2026-07-10). let spawn_max = ctx .db .game_creature_spawn() @@ -423,7 +358,7 @@ pub fn debug_assert_floor_snap( .max() .unwrap_or(0); let next_low = spawn_max.max(entity_max) + 1; - let new_guid = (HIGHGUID_UNIT << 48) | entry_hi | next_low; // scratch guid, cleaned up below + let new_guid = (HIGHGUID_UNIT << 48) | entry_hi | next_low; let mut spawned_entity_guid = None; let spawn_result: Result<(), String> = (|| { let tmpl = ctx @@ -459,8 +394,6 @@ pub fn debug_assert_floor_snap( .and_then(|g| ctx.db.game_world_entity().guid().find(g)) .map(|e| e.z); - // Cleanup FIRST — never leave synthetic geometry, a spawn/entity row or a flipped flag behind, - // even on assert failure. for id in inserted_ids { ctx.db.game_vmap_chunk().id().delete(id); } @@ -506,23 +439,6 @@ pub fn debug_assert_floor_snap( Ok(()) } -/// Done-when: server-side proof that Blink's collision clamp lands SHORT of an obstacle -/// plane instead of teleporting through it — the headless substitute for a client screenshot at -/// the abbey columns. Wires a synthetic 2-triangle vertical wall straight out of -/// `lyracore_shared::vmap::encode` (the SAME codec `import_vmap_chunks` writes — not a shortcut -/// codec) directly into `game_vmap_chunk`, duplicated into every cell the probe's bounding box -/// could touch (mirrors the importer's AABB-duplication contract `vmap::cast_ray`'s doc comment -/// relies on, so the exact ~33.3yd cell boundary the fixed probe point happens to land on never -/// matters). Force-enables `vmap_enabled` for the probe and restores whatever it found after — -/// never leaves a live database in a different state than it found it. Teleports -/// `character_guid` to a fixed point 10yd short of the wall facing it (orientation 0, +x), then -/// calls `spell::blink_forward` — the EXACT function `E_BLINK`'s cast dispatch calls, not a -/// reimplementation of it — at Blink's real DBC radius (20yd, so an uncapped Blink would overshoot -/// the wall by 10yd if the clamp did nothing). Asserts the landed position moved meaningfully -/// forward (the clamp isn't just "gave up and stayed put") AND stopped short of the wall's x-plane -/// (the clamp isn't just "ignored the wall"). `Err` (never a panic) carries the actual coordinates -/// on failure — a debuggable assert, not a crash. Synthetic rows + the config flag are cleaned up -/// before returning either way. #[reducer] pub fn debug_assert_blink_clamp(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -534,7 +450,7 @@ pub fn debug_assert_blink_clamp(ctx: &ReducerContext, character_guid: u64) -> Re let map_id = start.map_id; const ORIGIN: (f32, f32, f32) = (1200.0, 1200.0, 0.0); - const WALL_X: f32 = ORIGIN.0 + 10.0; // 10yd ahead — inside Blink's 20yd reach + const WALL_X: f32 = ORIGIN.0 + 10.0; const BLINK_YD: f32 = 20.0; let tris = [ @@ -578,7 +494,7 @@ pub fn debug_assert_blink_clamp(ctx: &ReducerContext, character_guid: u64) -> Re continue; } let row = vmap_chunks.insert(crate::vmap::VmapChunk { - id: 0, // auto_inc + id: 0, key: lyracore_shared::terrain::cell_key(map_id, cx, cy), map_id, cell_x: cx, @@ -617,13 +533,12 @@ pub fn debug_assert_blink_clamp(ctx: &ReducerContext, character_guid: u64) -> Re ORIGIN.0, ORIGIN.1, ORIGIN.2, - 0.0, // orientation 0 = facing +x, straight at the wall + 0.0, ); crate::spell::blink_forward(ctx, character_guid, BLINK_YD); let landed = ctx.db.game_world_entity().guid().find(character_guid); - // Cleanup FIRST — never leave synthetic geometry or a flipped flag behind, even on assert failure. for id in inserted_ids { ctx.db.game_vmap_chunk().id().delete(id); } @@ -655,19 +570,6 @@ pub fn debug_assert_blink_clamp(ctx: &ReducerContext, character_guid: u64) -> Re Ok(()) } -/// Done-when: server-side proof that a COMMITTED `nav_step` move stops at a known column -/// instead of walking through it — the headless substitute for driving a live chase and eyeballing -/// the client. Same synthetic-wall rig as `debug_assert_blink_clamp` (the exact -/// `lyracore_shared::vmap::encode` codec, duplicated across every cell the wall's AABB could -/// touch), but probes `nav::nav_step` — the ONE choke point every movement pass (chase/return/ -/// wander/flee/pet-follow) calls through — instead of `blink_forward`. Doesn't move -/// `character_guid`'s own entity: it reads that guid only to resolve a live map/instance to test -/// against, then calls `nav_step` directly as a pure query (`cur` fixed short of the wall, `dest` -/// far beyond it, `max_step` large enough to reach `dest` in one step if nothing blocked it). -/// Asserts the returned step advanced meaningfully forward (the gate isn't just "gave up") AND -/// stopped short of the wall's x-plane (the gate isn't just "ignored the wall"). Force-enables -/// `vmap_enabled` for the probe and restores whatever it found after — never leaves the database in -/// a different state than it found it. #[reducer] pub fn debug_assert_chase_stops_at_column( ctx: &ReducerContext, @@ -683,9 +585,9 @@ pub fn debug_assert_chase_stops_at_column( let map_id = start.map_id; const ORIGIN: (f32, f32, f32) = (1400.0, 1400.0, 0.0); - const WALL_X: f32 = ORIGIN.0 + 15.0; // the "column" - const DEST_X: f32 = ORIGIN.0 + 30.0; // 30yd forward — well past the wall if the gate did nothing - const STEP_YD: f32 = 100.0; // one committed step can cover the whole leg if nothing blocked it + const WALL_X: f32 = ORIGIN.0 + 15.0; + const DEST_X: f32 = ORIGIN.0 + 30.0; + const STEP_YD: f32 = 100.0; let tris = [ lyracore_shared::vmap::VmapTri { @@ -728,7 +630,7 @@ pub fn debug_assert_chase_stops_at_column( continue; } let row = vmap_chunks.insert(crate::vmap::VmapChunk { - id: 0, // auto_inc + id: 0, key: lyracore_shared::terrain::cell_key(map_id, cx, cy), map_id, cell_x: cx, @@ -770,7 +672,6 @@ pub fn debug_assert_chase_stops_at_column( ORIGIN.2, ); - // Cleanup FIRST — never leave synthetic geometry or a flipped flag behind, even on assert failure. for id in inserted_ids { ctx.db.game_vmap_chunk().id().delete(id); } @@ -801,8 +702,6 @@ pub fn debug_assert_chase_stops_at_column( Ok(()) } -/// Proves that an unreachable grid-tier goal cannot make a unit cross a synthetic wall. -/// The probe restores the nav row and consumption flags it temporarily replaces. #[reducer] pub fn debug_assert_unreachable_goal_stops_at_wall( ctx: &ReducerContext, @@ -822,27 +721,23 @@ pub fn debug_assert_unreachable_goal_stops_at_wall( .ok_or_else(|| format!("no live entity for guid {character_guid}"))?; let map_id = start.map_id; - // Wall down the walk-nx=32 line (obs ox=16) of the cell at (1400, 1400) — same probe corner - // All positions derive from the cell so nothing straddles a cell boundary. let (cx, cy) = ( cell_index(1400.0).ok_or("probe corner off map")?, cell_index(1400.0).ok_or("probe corner off map")?, ); let wall_x = sub_center(cx, 32, WALK_DIM); - let cur = (sub_center(cx, 8, WALK_DIM), sub_center(cy, 32, WALK_DIM)); // ~12.5 yd before the wall - let dest = (wall_x, cur.1); // ON the wall line — unwalkable goal, `find_leg` returns None + let cur = (sub_center(cx, 8, WALK_DIM), sub_center(cy, 32, WALK_DIM)); + let dest = (wall_x, cur.1); let mut walk = vec![0xFFu8; WALK_BYTES]; let mut obs = vec![OBS_NONE; OBS_BYTES]; for ny in 0..WALK_DIM { walk_set(&mut walk, 32, ny, false); } - // A short wall catches an extra height offset in the grid collision query. for oy in 0..OBS_DIM { obs_raise(&mut obs, 0.0, 16, oy, 2.5); } - // Stash-and-replace any REAL nav row at the probe cell (dev imports could cover it). let key = cell_key(map_id, cx, cy); let nav_chunks = ctx.db.game_nav_chunk(); let prior_row = nav_chunks @@ -885,7 +780,6 @@ pub fn debug_assert_unreachable_goal_stops_at_wall( let stepped = crate::nav::nav_step(ctx, map_id, start.instance_id, cur, dest, 100.0, 0.0, 0.0); - // Cleanup FIRST — never leave synthetic nav data or flipped flags behind, even on assert failure. nav_chunks.key().delete(key); if let Some((base_z, walk, obs)) = prior_row { nav_chunks.insert(crate::nav::NavChunk { @@ -906,7 +800,7 @@ pub fn debug_assert_unreachable_goal_stops_at_wall( } } - let advanced = cur.0 - stepped.0; // the step walks toward -x (higher walk index = lower coord) + let advanced = cur.0 - stepped.0; let clearance = stepped.0 - wall_x; log::info!( "debug_assert_unreachable_goal_stops_at_wall: stepped ({:.2},{:.2}) advanced={advanced:.2}yd clearance={clearance:.2}yd (wall at x={wall_x:.1})", @@ -926,20 +820,8 @@ pub fn debug_assert_unreachable_goal_stops_at_wall( Ok(()) } -/// Benchmark harness: run `directions` synthetic LoS queries around EVERY real creature -/// (`type_mask::CREATURE`) inside `[min_x,max_x]×[min_y,max_y]` on `map` — one probe segment per -/// creature per compass direction at `radius` yd, i.e. exactly the (creature × in-range-candidate) -/// shape `best_aggro_target` walks on a sense tick (`sense.rs`), scaled to a box of real, -/// live-imported creature positions instead of synthetic ones. `exact=false` times the grid -/// `nav::has_los` baseline (§8); `exact=true` times the new `vmap::los_ray` (§10). Both flavors -/// query the SAME segments so the comparison is apples-to-apples. Deliberately a single reducer -/// call per batch: total wall time for the whole batch is readable off the node's own -/// `spacetime_reducer_plus_query_duration_sec{reducer="debug_bench_los"}` metric (docs/testing -/// has no in-module clock — `std::time::Instant` does not exist on `wasm32-unknown-unknown`), so -/// this reducer does no timing itself — it only produces the query volume and logs a checksum -/// (`hits`) so two runs can be diffed for correctness, not just speed. #[reducer] -#[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. +#[allow(clippy::too_many_arguments)] pub fn debug_bench_los( ctx: &ReducerContext, map: u32, @@ -954,9 +836,6 @@ pub fn debug_bench_los( use lyracore_shared::constants; use std::f32::consts::TAU; - // Grid-indexed radius scan (open world, instance 0) covering the box, not a raw table scan — - // `entities_near` is the shardable/scale-safe fetch (`tripwires::no_unwhitelisted_raw_spatial_scans`); - // the exact box + creature-type filter below is a cheap in-memory pass over its (small) result. let (cx, cy) = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0); let half_diag = (((max_x - min_x) / 2.0).powi(2) + ((max_y - min_y) / 2.0).powi(2)).sqrt(); let creatures: Vec<(f32, f32, f32)> = @@ -996,7 +875,7 @@ pub fn debug_bench_los( } #[reducer] -#[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. +#[allow(clippy::too_many_arguments)] pub fn debug_bench_collision_gate( ctx: &ReducerContext, map: u32, @@ -1044,16 +923,11 @@ pub fn debug_bench_collision_gate( ); } -/// Backfill the imported default action bar onto an EXISTING character, -/// chars created before the `game_createinfo_action` import have no `game_player_action` rows and -/// fall back to the gateway's known-spells synth (which slots passives and misses the stance -/// pages). Idempotent per button (the grant skips occupied ones). Takes effect next login. #[reducer] pub fn debug_grant_default_actions( ctx: &ReducerContext, character_name: String, ) -> Result<(), String> { - // REFUSE verdict — harness writers get the same fence as production ones. let c = crate::helpers::character_by_name(ctx, &character_name) .ok_or_else(|| format!("no character named {character_name}"))?; crate::action_bar::grant_createinfo_actions(ctx, c.guid, c.owner_identity, c.race, c.class); @@ -1061,8 +935,6 @@ pub fn debug_grant_default_actions( Ok(()) } -/// One-time backfill: stamp grid_x/grid_y on every existing gameobject row from its (x, y) -/// — imported/seeded rows predate the columns. Idempotent. #[reducer] pub fn debug_backfill_go_grid(ctx: &ReducerContext) { let gos = ctx.db.game_gameobject(); @@ -1071,7 +943,6 @@ pub fn debug_backfill_go_grid(ctx: &ReducerContext) { for guid in all { if let Some(mut g) = gos.guid().find(guid) { let (gx, gy) = lyracore_shared::spatial::grid_cell(g.x, g.y); - // `cell` too — see `debug_regrid`'s note; a migrated row's grid is already right. if g.grid_x != gx || g.grid_y != gy || g.cell != lyracore_shared::spatial::grid_cell_id(gx, gy) @@ -1087,14 +958,10 @@ pub fn debug_backfill_go_grid(ctx: &ReducerContext) { log::info!("debug_backfill_go_grid: {n} rows stamped"); } -/// The Instance Removal fixture's Deadmines instance. const REMOVAL_FIXTURE_INSTANCE: u64 = 509_295_100; -/// Inside Deadmines, at the entrance trigger's arrival point. const REMOVAL_FIXTURE_INSIDE: (u32, f32, f32, f32) = (36, -16.4, -383.07, 61.78); -/// Every fixture Character's hearthstone home: Northshire Abbey, zone 12. const REMOVAL_FIXTURE_HOME: (u32, f32, f32, f32, u32) = (0, -8949.95, -132.493, 83.5312, 12); -/// One fixture Character: guid, name, GM level, standing inside the instance, World Session. const REMOVAL_FIXTURE_CHARACTERS: [(u64, &str, u8, bool, bool); 6] = [ (509_295_001, "Removalalpha", 0, true, true), (509_295_002, "Removalbravo", 0, true, true), @@ -1104,10 +971,6 @@ const REMOVAL_FIXTURE_CHARACTERS: [(u64, &str, u8, bool, bool); 6] = [ (509_295_006, "Removalfoxtrot", 0, true, false), ]; -/// Stage the Instance Removal fixture: a Deadmines instance owned by `party_id`, and six live -/// Characters cloned from the seeded Tester. Four stand inside with a World Session, one of them a -/// GM. One stands outside at home. One stands inside with no World Session, as a session-less bot -/// does. Every home is Northshire. Membership is left to the caller. Reruns replace the rows. #[reducer] pub fn debug_stage_instance_removal_fixture( ctx: &ReducerContext, @@ -1157,8 +1020,6 @@ pub fn debug_stage_instance_removal_fixture( Ok(()) } -/// Push every running Instance Removal a day ahead, so a test fires one only through -/// [`debug_expire_instance_removal`] and never races the real 60 s. #[reducer] pub fn debug_hold_instance_removals(ctx: &ReducerContext) -> Result<(), String> { use crate::game_instance_removal; @@ -1168,7 +1029,6 @@ pub fn debug_hold_instance_removals(ctx: &ReducerContext) -> Result<(), String> ctx.timestamp.to_micros_since_unix_epoch() + DAY_MICROS, ); let removals = ctx.db.game_instance_removal(); - // Delete and insert, as the Mail Timer re-arms, so the scheduler takes the new due time. for mut removal in removals.iter().collect::>() { removals.scheduled_id().delete(removal.scheduled_id); removal.scheduled_id = 0; @@ -1178,7 +1038,6 @@ pub fn debug_hold_instance_removals(ctx: &ReducerContext) -> Result<(), String> Ok(()) } -/// Run `character_guid`'s Instance Removal expiry now, as if its countdown had run out. #[reducer] pub fn debug_expire_instance_removal( ctx: &ReducerContext, @@ -1196,7 +1055,6 @@ pub fn debug_expire_instance_removal( Ok(()) } -/// Log `character_guid` out through the same core a disconnect takes. #[reducer] pub fn debug_logout_character(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/debug/mail.rs b/module/src/debug/mail.rs index b3156ae73..0de37b50f 100644 --- a/module/src/debug/mail.rs +++ b/module/src/debug/mail.rs @@ -1,9 +1,3 @@ -//! Mail Timer fixtures for `module/tests/mail_expiry.rs` and `module/tests/mail_delivery.rs`. -//! Each stage reducer writes its letters -//! through `mail::insert_letter` and then backdates them, so the timers they arm fire at once. -//! The checks compare armed instants with the vanilla lifetimes written out below, not with the -//! Module's own expiry function. - use lyracore_shared::mail::{ MailSender, CHECK_MASK_COD_PAYMENT, CHECK_MASK_COPIED, CHECK_MASK_RETURNED, }; @@ -17,7 +11,6 @@ const SENDER: u64 = 509_0070; const RECIPIENT: u64 = 509_0071; const DELAYED_RECIPIENT: u64 = 509_0072; const LEGACY_RECIPIENT: u64 = 509_0073; -/// The seeded Tester. The take and COD fixtures pay out into its live purse and bags. const TAKER: u64 = 1; const ITEM: ItemSnapshot = ItemSnapshot { entry: 509_0070, @@ -28,7 +21,6 @@ const ITEM: ItemSnapshot = ItemSnapshot { random_property_id: 0, item_text_id: 0, }; -/// A real template, so a payout can grant it. const BLADE: ItemSnapshot = ItemSnapshot { entry: crate::seed::FIXTURE_BLADE, stack_count: 1, @@ -41,11 +33,8 @@ const BLADE: ItemSnapshot = ItemSnapshot { const SECOND_MICROS: i64 = 1_000_000; const MINUTE_MICROS: i64 = 60 * SECOND_MICROS; const DAY_MICROS: i64 = 86_400 * SECOND_MICROS; -/// A Mail lives 30 days after it arrives, 3 with a COD price (cmangos `Mail.cpp:309-313`), and a -/// Mail returned at expiry lives 30 days from the return (cmangos `ObjectMgr.cpp:6214-6215`). const LIFE_MICROS: i64 = 30 * DAY_MICROS; const COD_LIFE_MICROS: i64 = 3 * DAY_MICROS; -/// The delayed letter arrives this long after staging. const DELAY_MICROS: i64 = 2 * SECOND_MICROS; const RETURNS: &str = "timer: returns"; @@ -63,7 +52,6 @@ const TAKE_MONEY: &str = "timer: take money"; const TAKE_ITEM: &str = "timer: take item"; const COD_RACE: &str = "timer: cod race"; -/// One fixture letter and how old it is when staged. struct Staged { subject: &'static str, recipient_guid: u64, @@ -90,8 +78,6 @@ impl Staged { } } -/// Insert `staged` as a new letter, then move its creation back by its age and arm its timer -/// again. Returns the backdated row. fn stage(ctx: &ReducerContext, staged: Staged, deliver_micros: i64) -> Mail { let id = crate::mail::insert_letter( ctx, @@ -117,7 +103,6 @@ fn stage(ctx: &ReducerContext, staged: Staged, deliver_micros: i64) -> Mail { age(ctx, row, staged.age_micros) } -/// Make `mail` `age_micros` old and arm its timer again. fn age(ctx: &ReducerContext, mail: Mail, age_micros: i64) -> Mail { let aged = ctx.db.game_mail().id().update(Mail { created_at: Timestamp::from_micros_since_unix_epoch( @@ -129,7 +114,6 @@ fn age(ctx: &ReducerContext, mail: Mail, age_micros: i64) -> Mail { aged } -/// Delete every Mail `recipient_guid` holds under one of `subjects`, with its timer. fn clear(ctx: &ReducerContext, recipient_guid: u64, subjects: &[&str]) { let stale: Vec = ctx .db @@ -173,7 +157,6 @@ fn armed_micros(ctx: &ReducerContext, mail_id: u64) -> Option { } } -/// `mail`'s timer fires `life_micros` after `arrived_micros`, to the second. fn check_armed( ctx: &ReducerContext, mail: &Mail, @@ -190,8 +173,6 @@ fn check_armed( } } -/// `subject` came back to SENDER as a Returned Mail with its item, no price and 30 days from the -/// return. fn check_returned(ctx: &ReducerContext, subject: &str) -> Result<(), String> { let back = find(ctx, SENDER, subject).ok_or_else(|| format!("{subject} did not return"))?; if back.sender() != MailSender::Character(RECIPIENT) @@ -206,9 +187,6 @@ fn check_returned(ctx: &ReducerContext, subject: &str) -> Result<(), String> { check_armed(ctx, &back, back.deliver_micros, LIFE_MICROS) } -/// Stage one letter for each Mail Expiry outcome, two letters inside their life, and one letter -/// delivered in about 2 s. Refuses unless every visible letter sent exactly one Mail Arrival and -/// the delayed one sent none. #[reducer] pub fn debug_stage_mail_expiry_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -286,8 +264,6 @@ pub fn debug_stage_mail_expiry_fixture(ctx: &ReducerContext) -> Result<(), Strin Ok(()) } -/// Refuses until every staged letter has met its Mail Expiry outcome and the delayed letter has -/// been delivered. #[reducer] pub fn debug_verify_mail_expiry_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -341,13 +317,9 @@ pub fn debug_verify_mail_expiry_fixture(ctx: &ReducerContext) -> Result<(), Stri Ok(()) } -/// Run the timer body again for every Mail id the expiry fixture used, with a row that is no -/// longer armed. Refuses if that changes a Mail, a timer or the Mail Arrivals. #[reducer] pub fn debug_replay_mail_timer_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - // The stage inserts RETURNS first and DELAYED last in one transaction, so the ids between - // them are exactly the fixture's, deleted ones included. let first = find(ctx, SENDER, RETURNS).ok_or("stage and settle the fixture first")?; let last = find(ctx, DELAYED_RECIPIENT, DELAYED).ok_or("stage the fixture first")?; let state = |ctx: &ReducerContext, id: u64| -> Result, String> { @@ -380,8 +352,6 @@ pub fn debug_replay_mail_timer_fixture(ctx: &ReducerContext) -> Result<(), Strin Ok(()) } -/// Stage two letters as they were before the Mail Timer existed: one past its life and one inside -/// it, neither with a timer. #[reducer] pub fn debug_stage_mail_legacy_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -405,7 +375,6 @@ pub fn debug_stage_mail_legacy_fixture(ctx: &ReducerContext) -> Result<(), Strin Ok(()) } -/// Refuses until the repair pass has armed both legacy letters and the expired one has returned. #[reducer] pub fn debug_verify_mail_legacy_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -424,8 +393,6 @@ pub fn debug_verify_mail_legacy_fixture(ctx: &ReducerContext) -> Result<(), Stri ) } -/// Stage three letters for the Tester: 77 copper, a blade, and a blade for a 250-copper COD price. -/// The tests fence a take or a payment first and age the letter past its life after. #[reducer] pub fn debug_stage_mail_take_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -459,8 +426,6 @@ pub fn debug_stage_mail_take_fixture(ctx: &ReducerContext) -> Result<(), String> Ok(()) } -/// Make the fixture letter `subject` for `recipient_guid` `age_secs` old, so a timer past its life -/// fires at once. #[reducer] pub fn debug_age_mail_fixture( ctx: &ReducerContext, @@ -480,9 +445,6 @@ pub fn debug_age_mail_fixture( Ok(()) } -/// Bring the delayed letter `subject` for `recipient_guid` forward to arrive 1 µs from now, so its -/// Mail Timer fires at once and sends the Mail Arrival. Only for a "delivery: " letter that -/// `module/tests/mail_delivery.rs` committed with a Delivery Delay, and that has not arrived. #[reducer] pub fn debug_deliver_mail_fixture( ctx: &ReducerContext, @@ -494,8 +456,7 @@ pub fn debug_deliver_mail_fixture( .filter(|mail| mail.subject.starts_with("delivery: ")) .filter(|mail| !mail.is_delivered(ctx.timestamp)) .ok_or_else(|| format!("no delayed fixture letter {subject} for {recipient_guid}"))?; - // A timer delivers only when it fires at exactly `deliver_micros`, so the instant must stay - // ahead of this transaction. + // Delivery requires the timer to fire at deliver_micros, so keep it after this transaction. let arrives = ctx.db.game_mail().id().update(Mail { deliver_micros: ctx.timestamp.to_micros_since_unix_epoch() + 1, ..mail @@ -504,7 +465,6 @@ pub fn debug_deliver_mail_fixture( Ok(()) } -/// Refuses unless `recipient_guid` holds the fixture letter `subject` as `held` says. #[reducer] pub fn debug_verify_mail_fixture_held( ctx: &ReducerContext, diff --git a/module/src/debug/meeting_stone.rs b/module/src/debug/meeting_stone.rs index e5d29bb99..5994e130a 100644 --- a/module/src/debug/meeting_stone.rs +++ b/module/src/debug/meeting_stone.rs @@ -1,10 +1,3 @@ -//! Meeting Stone Queue fixtures for the durable tests. Fixture ids sit in `509_6000`-`509_6099`. -//! -//! The levers stage the world a Meeting Stone needs: a stone and its spawned GameObject, Characters -//! with a chosen race, class and level, each with a live entity and a live Account Claim, the same -//! claim alone on Realm-core, and a Party or Raid led by one of them. Two more move a Seeker's or a queued party's time back, so a -//! test never waits on the clock. - use spacetimedb::{reducer, ReducerContext, Table, TimeDuration}; use lyracore_shared::constants::go_type; @@ -18,13 +11,8 @@ use crate::{ game_group_member, game_world_entity, AccountClaim, Group, GroupMember, }; -/// How long a fixture Account Claim lives. Longer than any durable test runs. const FIXTURE_CLAIM_MICROS: i64 = 60 * 60 * 1_000_000; -/// Stage Meeting Stone template `entry` for `area_id` and levels `min_level..=max_level`, spawned -/// as GameObject `go_guid` at `(x, y, z)` on `map_id` in the open world. Pass a `go_guid` below -/// 2^53: `spacetime call` reads integer arguments through a double. A second call replaces all -/// three rows. #[allow(clippy::too_many_arguments)] #[reducer] pub fn debug_stage_meeting_stone( @@ -89,10 +77,6 @@ pub fn debug_stage_meeting_stone( Ok(()) } -/// Stage Character `guid` with `race`, `class` and `level`, in the world at `(x, y, z)` on `map_id`, -/// with a live Account Claim of `account_id`. Its World Session Token is -/// `{account_id, generation: 1, request_nonce: guid}`. A second call replaces the Character, its -/// entity and the claim, and leaves its group and queue rows alone. #[allow(clippy::too_many_arguments)] #[reducer] pub fn debug_stage_meeting_stone_character( @@ -129,9 +113,6 @@ pub fn debug_stage_meeting_stone_character( stage_claim(ctx, guid, account_id) } -/// Stage only the live Account Claim [`debug_stage_meeting_stone_character`] writes, with the same -/// World Session Token. On a sharded realm the Character lives on a World Shard and its claim on -/// Realm-core, which holds no Character rows. A second call replaces the claim. #[reducer] pub fn debug_stage_meeting_stone_claim( ctx: &ReducerContext, @@ -161,9 +142,6 @@ fn stage_claim(ctx: &ReducerContext, guid: u64, account_id: u64) -> Result<(), S Ok(()) } -/// Stage a Party, or a Raid when `raid` is set, led by `leader_guid` with `member_guids` after the -/// leader in join order. Writes the group rows directly, so it fires no group event. Refuses a -/// Character already in a group. #[reducer] pub fn debug_stage_meeting_stone_group( ctx: &ReducerContext, @@ -219,7 +197,6 @@ pub fn debug_stage_meeting_stone_group( Ok(()) } -/// Move Seeker `character_guid`'s `queued_at` back by `secs`. #[reducer] pub fn debug_backdate_meeting_stone_seeker( ctx: &ReducerContext, @@ -240,8 +217,6 @@ pub fn debug_backdate_meeting_stone_seeker( Ok(()) } -/// Move queued party `group_id`'s `queued_at` and `next_reminder_at` back by `secs`, so it is the -/// older party and its reminder can fall due. #[reducer] pub fn debug_backdate_meeting_stone_party( ctx: &ReducerContext, diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 2b4168423..8922eb9a4 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1,29 +1,3 @@ -//! Reducers that stage deterministic scenarios for client automation and operator diagnostics. -//! Teleport, health, spawn, cast, and inventory operations accept explicit Character guids. -//! -//! # Feature gate -//! `lib.rs` declares `mod debug;` behind `#[cfg(feature = "debug_reducers")]`. A default build -//! compiles this directory out. Every `lyracore publish`, production included, builds with the -//! feature on, so these reducers exist on every published database: -//! -//! ```text -//! spacetime publish -s local --build-options='--features=debug_reducers' lyracore -//! ``` -//! -//! `docs/danger-zones.md` is authoritative on that posture. The post-publish repair pass lives in -//! the ungated `operations` module. -//! -//! # Caller identity -//! A `spacetime call` runs as the **CLI identity**, which is NOT a player's bound identity. So every -//! reducer here takes an **explicit `character_guid`/`guid`** and operates on that row directly — it -//! MUST NOT use `entity_by_owner(ctx, ctx.sender())` (that only resolves the gateway's per-player -//! connection identity). This is the single most important difference from the player reducers. -//! -//! # Layout -//! This file holds the world, combat and item levers and the shared `equip_into` helper. `readout` is -//! the `DebugReadout` table and its writers, `audit` the class-kit and quest-chain content audits, -//! `encounter` and `instance` the operator levers, and `fingerprint` the catalogue-parity hash. - mod audit; mod auth; mod encounter; @@ -54,10 +28,6 @@ use crate::{ CreatureMoveSchedule, CreatureSpawn, GroundArea, ItemInstance, ServerConfig, }; -/// Teleport `character_guid` to `(map_id, x, y, z, o)` via the shared `world::teleport_player` core: it -/// updates the LIVE entity (position + grid cell) AND the durable `game_character` AND emits the -/// `game_teleport_event` that makes the gateway send `MSG_MOVE_TELEPORT_ACK` so the player's camera -/// actually follows. Keyed off the explicit guid (the CLI identity owns no entity). Errors if not in world. #[reducer] pub fn debug_teleport( ctx: &ReducerContext, @@ -81,16 +51,6 @@ pub fn debug_teleport( Ok(()) } -/// Record a Transfer Intent for `character_guid`: the session-less Shard crossing a Package's -/// bot decides for itself, driven by hand. -/// -/// The lever exists because the acceptance test for that crossing must NOT need a Package installed. -/// It calls the same `transfer::emit_bot_transfer_intent` the Package calls, so what it exercises is -/// the real contract — the placement and the intent row in one transaction, then the Gateway's -/// durable Transfer dispatcher, and never a second way to ask for a crossing. -/// -/// The shared writer applies the ordinary session-less ownership Gate, so a Character with a live -/// Session is refused before placement or intent creation. #[reducer] #[allow(clippy::too_many_arguments)] pub fn debug_bot_transfer( @@ -135,23 +95,11 @@ pub fn debug_bot_transfer( Ok(()) } -/// Deal `amount` DIRECT damage to a live entity through the REAL shared damage pipeline — the same -/// `fold_incoming_damage` → `final_damage` → `apply_hit` every swing and spell routes through, as a -/// MAIN-HAND hit. So a debug poke drives everything a real melee swing drives: the attacker's rage -/// and weapon skill-up, absorb, the defender's rage and defense skill-up, break-on-damage, cast pushback, threat, -/// Retaliation and the proc pass. That last one is why it routes here rather than writing health: a -/// scripted 100-hit run against a Proc finishes in seconds instead of waiting on swing timers. -/// Unlike `debug_set_health` (a raw field write, NO side effects), this behaves like being hit. Never -/// lethal: the damage is capped at `health - 1` BEFORE the pipeline, so the kill fork is unreachable -/// from here (use `debug_kill_nearest` for that). #[reducer] pub fn debug_apply_damage( ctx: &ReducerContext, target_guid: u64, amount: u32, - // The attributed attacker (0 = anonymous): threads through to `on_damage_taken` so - // attacker-reactive systems (a Package defend hook, Retaliation, the proc pass) see a real - // source. attacker_guid: u64, ) -> Result<(), String> { let e = ctx @@ -163,8 +111,6 @@ pub fn debug_apply_damage( if e.dead { return Err("target is dead".to_string()); } - // Clamp at 1 HP up front: the pipeline's own lethal fork must never be reachable from a debug poke, - // and a clamp applied afterwards could not un-kill anything. let capped = amount.min(e.health.saturating_sub(1)); let (dmg, _absorbed) = crate::combat::fold_incoming_damage(ctx, attacker_guid, target_guid, capped); @@ -179,11 +125,6 @@ pub fn debug_apply_damage( Ok(()) } -/// Set `guid`'s live health (clamped to `max_health`). `health == 0` drives the SAME death path the -/// combat killing blow uses (`combat::tick_melee`): a PLAYER enters the release/ghost path -/// (`health = 0` + `dead = true`, then the client shows Release Spirit — vanilla 1.12 has no death -/// opcode, `UNIT_FIELD_HEALTH == 0` is the sole signal); a CREATURE becomes a corpse (`dead = true`, -/// disengaged, decay timer armed, loot rolled onto the corpse). #[reducer] pub fn debug_set_health(ctx: &ReducerContext, guid: u64, health: u32) -> Result<(), String> { let entities = ctx.db.game_world_entity(); @@ -203,53 +144,32 @@ pub fn debug_set_health(ctx: &ReducerContext, guid: u64, health: u32) -> Result< return Ok(()); } - // health == 0 → death. Branch exactly like the combat killing blow (combat::tick_melee). if e.is_player() { - // PLAYER death through the SHARED kill path (combat::kill_player) so a debug kill is - // byte-identical to a real killing blow, including channel teardown and combat-deadline - // zeroing. No killer on this path. crate::combat::kill_player(ctx, guid, 0); log::info!("debug_set_health: player {guid} killed (dead, awaiting release)"); return Ok(()); } - // CREATURE death: route through the SHARED kill path so a debug kill produces the exact same - // corpse + money/item loot + decay as a real killing blow (no drift between the paths). No XP - // credit — there's no player killer here (`award_xp` needs an attacker). crate::combat::kill_creature(ctx, guid, None); log::info!("debug_set_health: creature {guid} killed (corpse via kill_creature)"); Ok(()) } -/// Drive Release Spirit by explicit guid (the `CMSG_REPOP_REQUEST` / `repop` path) — the CLI identity -/// owns no entity, so this calls the shared `world::do_repop` against the row directly. Leaves a corpse, -/// becomes a ghost (health 1, GHOST flags), and teleports to the Northshire graveyard, exactly like a -/// real Release click. Lets the parent drive die → release → spirit-heal headless. Errors if not dead. #[reducer] pub fn debug_repop(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::actor::repop(ctx, character_guid) } -/// Spirit-Healer resurrect by explicit guid (the `CMSG_SPIRIT_HEALER_ACTIVATE` / `spirit_healer_res` -/// path) — drives the new graveyard-res reducer's body against the row directly (the CLI identity owns -/// no entity), so the parent can verify the feature without the mouse-only spirit-healer dialog. Gates -/// on the entity being a ghost; res's in place at 50% health + 50% mana and lands Resurrection Sickness. #[reducer] pub fn debug_spirit_healer_res(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::actor::spirit_res(ctx, character_guid) } -/// Use the Self-Resurrection Option by explicit guid (the `CMSG_SELF_RES` path). Errors if the -/// Character is alive or holds no option. #[reducer] pub fn debug_self_resurrect(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::actor::self_resurrect(ctx, character_guid) } -/// Answer a pending resurrect offer by explicit guid (`CMSG_RESURRECT_RESPONSE` / `resurrect_response` -/// path) — drives the shared `do_resurrect_response` core against the row directly (the CLI -/// identity owns no entity), so the parent can verify the accept-prompt handshake headlessly. Errors if -/// `character_guid` has no pending offer. #[reducer] pub fn debug_resurrect_response( ctx: &ReducerContext, @@ -259,12 +179,6 @@ pub fn debug_resurrect_response( crate::spell::do_resurrect_response(ctx, character_guid, accept) } -/// Spawn a creature of `creature_entry` at the player's feet (`player.x + offset`, same y/z/map) so a -/// combat test doesn't have to walk to a mob. Reads the player's live entity, looks up the template, -/// builds the live `game_world_entity` via the shared `build_creature_entity` (same construction -/// `seed` and the creature-respawn pass use) with a fresh unique guid, and inserts a matching `game_creature_spawn` so the normal -/// decay/respawn lifecycle owns it. The reducer can't return a value to the CLI, so the new guid is -/// logged (`grep` the module log, or read it back from `game_world_entity` by entry). #[reducer] pub fn debug_spawn_at_feet( ctx: &ReducerContext, @@ -284,14 +198,8 @@ pub fn debug_spawn_at_feet( .find(creature_entry) .ok_or_else(|| format!("no creature template for entry {creature_entry}"))?; - // A unique creature guid carrying HIGHGUID_UNIT (0xF130) + the entry in bits 24..47 (like the - // seed's chicken), with a low part one past the current max so it never collides with an existing - // spawn of the same entry. const HIGHGUID_UNIT: u64 = 0xF130; let entry_hi = (creature_entry as u64) << 24; - // Max over BOTH the spawn rows AND the live entities of this entry: an operator can delete a - // spawn row (test cleanup) while its entity still lingers (corpse decay), and a spawn-rows-only - // scan then re-allocates that entity's guid → duplicate-unique-column PANIC (live find 2026-07-10). let spawn_max = ctx .db .game_creature_spawn() @@ -311,13 +219,6 @@ pub fn debug_spawn_at_feet( let next_low = spawn_max.max(entity_max) + 1; let new_guid = (HIGHGUID_UNIT << 48) | entry_hi | next_low; - // Place the creature `offset` yards in the player's FACING direction (not a - // fixed +X). The harness drives targeting via the client's TargetNearestEnemy / - // Tab / `/target`, which are FACING-CONE limited — a creature spawned behind the - // player is unselectable, so a fixed +X offset made combat tests flaky. Spawning - // along the player's orientation puts it directly in front (client facing == this - // server orientation once the player has turned), so the harness can reliably - // select + melee it. WoW orientation 0 = +X, so (cos, sin) is the facing vector. let (sin_o, cos_o) = player.orientation.sin_cos(); let spawn = CreatureSpawn { guid: new_guid, @@ -327,21 +228,14 @@ pub fn debug_spawn_at_feet( y: player.y + offset * sin_o, z: player.z, orientation: player.orientation, - // NOT-ARMED (`creatures::timer_never`): the live entity is inserted immediately below, so a - // past stamp would only keep this row in every due-time range scan forever. respawn_at: crate::creatures::timer_never(ctx), despawn_at: crate::creatures::timer_never(ctx), - // IDLE so a debug test creature holds where it's spawned (predictable for combat readback; the - // wander pass interfered with verification — see the harness memory). movement_type: crate::creatures::MOVEMENT_IDLE, - respawn_secs: 0, // not imported — falls back to the flat legacy respawn timer (a debug spawn's death is a one-off harness action) + respawn_secs: 0, life_seq: 0, source_guid: 0, instance_id: player.instance_id, }; - // Build the live entity from a reference (fixed roll 0 → deterministic min level/health) BEFORE - // moving the spawn into its table, so we never depend on the row type deriving Clone. The - // entity insert fires the gateway's on_insert → CREATE_OBJECT so the creature appears at feet. let entity = build_creature_entity(&spawn, &tmpl, 0, player.instance_id); ctx.db.game_creature_spawn().insert(spawn); crate::creatures::insert_creature_entity(ctx, entity); @@ -351,49 +245,20 @@ pub fn debug_spawn_at_feet( Ok(()) } -/// Materialize a PLAYER's live `game_world_entity` from its durable `game_character` row — SERVER-SIDE, -/// with no game client. This is the harness keystone for the items/equipment/progression chain: a -/// schema-changing `publish` tears down the in-world player entity (the live `game_world_entity`), and -/// the client can't be driven headless to log back in, so reducers that require a live entity -/// (`debug_compute_swing`, `debug_equip_weapon`, the item/XP/durability paths) have nothing to act on. -/// This rebuilds that entity from the persisted character so those reducers are testable again. -/// -/// Idempotent: if a live entity for the guid already exists it's left untouched and we return Ok (a -/// real session may own it — we never clobber a live player). Otherwise we build + insert the entity -/// through the SAME shared `build_player_entity` `world::player_login` uses (the player counterpart to -/// `build_creature_entity`), so the stat curve, packing, and display/faction resolution are identical -/// by construction. Position/level/appearance/xp/money come from the character row; vitals are filled -/// to the new max. -/// -/// CLI-identity caveat (file header §): a `spacetime call` runs as the CLI identity, which owns no -/// player binding, so we key off the explicit `character_guid` and stamp the entity's `owner_identity` -/// to the character's persisted `owner_identity` (NOT `ctx.sender()`) — the per-owner RLS that scopes -/// items to a connection then matches what a real login would have set. #[reducer] pub fn debug_spawn_player_entity(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { let entities = ctx.db.game_world_entity(); - // Idempotent: a live entity already exists (a real session, or a prior call) — never clobber it. if entities.guid().find(character_guid).is_some() { log::info!("debug_spawn_player_entity: guid {character_guid} already live (no-op)"); return Ok(()); } - // REFUSE verdict. This is `player_login`'s RE-MATERIALISATION path wearing a harness - // hat: it builds a live entity straight off the durable row, so without the fence it is a second - // way to put a live copy back on a shard the character has left — the dual-liveness dupe the - // escrow exists to prevent. `player_login` fences it; so must this. let character = crate::helpers::require_character(ctx, character_guid) .map_err(|_| format!("no such character: {character_guid}"))?; - // Build the live entity from the durable character row — the SAME construction - // `world::player_login` runs, factored into the shared `build_player_entity`. The ONLY difference: - // `owner_identity` is the character's PERSISTED binding (NOT `ctx.sender()` — a `spacetime call` - // runs as the CLI identity, which owns no player binding), so per-owner RLS matches a real login. let entity = crate::creatures::build_player_entity(ctx, &character, character.owner_identity); let (level, max_health) = (entity.level, entity.max_health); entities.insert(entity); - // Same rebuild contract as `player_login`: a land mount lives in the aura set, so a materialize - // must re-derive the display and mounted speed rather than resurrect the rider on foot. crate::mount::restore_mount_on_rebuild(ctx, character_guid); log::info!( "debug_spawn_player_entity: materialized player guid {character_guid} (lvl {level}, hp {max_health})" @@ -401,12 +266,6 @@ pub fn debug_spawn_player_entity(ctx: &ReducerContext, character_guid: u64) -> R Ok(()) } -/// Force-apply the tracer aura on `character_guid` (the ⭐ aura wire-check without needing rage / the -/// spellbook UI). Reuses `spell::apply_tracer_aura` — the exact aura-upsert + `SpellCastEvent` that -/// `cast_spell` runs — keyed off the explicit guid instead of `ctx.sender()`. Accepts ANY `spell_id` -/// (a debug actuator; the player-facing `cast_spell` still gates on the one tracer spell). The client -/// renders whatever icon its `Spell.dbc` has for the id, so passing several distinct ids stacks -/// several auras in distinct slots — the multi-aura wire test for the raw-send path. #[reducer] pub fn debug_force_cast( ctx: &ReducerContext, @@ -414,8 +273,6 @@ pub fn debug_force_cast( spell_id: u32, ) -> Result<(), String> { let e = crate::helpers::live_entity(ctx, character_guid)?; - // Route through the SAME resolved-cast path the player reducer uses, so the debug cast enforces - // the spell's mana cost and runs its effect dispatch (self-aura vs heal-self) — not just the aura. crate::spell::resolve_cast(ctx, character_guid, spell_id, e.level as u8) } @@ -426,8 +283,6 @@ fn resolve_debug_cast_at( target_guid: u64, ) -> Result<(), String> { let caster = crate::helpers::live_entity(ctx, caster_guid)?; - // Fixture spells can be staged without being learned. Keep that debug-only bypass while using - // the owning synchronous resolver for costs, targets, effects, and other cast Gates. crate::spell::resolve_cast_at( ctx, caster_guid, @@ -450,32 +305,16 @@ pub fn debug_force_cast_at( resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid) } -/// Set `character_guid`'s level and recompute `max_health`/`max_power` from the real stat curve -/// (`stats::max_health_for`/`max_power_for`, the SAME helpers `player_login` uses — not reimplemented). -/// Health/power are refilled to the new max for the test. Also recomputes the five base attributes so -/// the character sheet stays consistent with the new level. Thin wrapper over the shared -/// `stats::set_character_level` core, which `gm::gm_command`'s `.level` also uses, so both run the -/// SAME recompute (see that fn's doc). #[reducer] pub fn debug_set_level( ctx: &ReducerContext, character_guid: u64, level: u32, ) -> Result<(), String> { - // REFUSE verdict — the `debug_set_money` sibling. `set_character_level` writes - // `Character.level` (an `ExportBlob` field) and `Character.xp` on the DURABLE row and needs no - // live entity to do it (it errors only when neither an entity nor a character row exists), so - // by-guid it reaches an escrowed character. Fenced here, not in `stats`: `gm_command`'s caller - // resolves through `entity_by_owner` already, and the core also serves guids with no character - // row at all. crate::helpers::require_character(ctx, character_guid)?; crate::stats::set_character_level(ctx, character_guid, level) } -/// Delete every CREATURE-type live `game_world_entity` on `map_id` (clean slate before a combat test). -/// Never touches a PLAYER (the PLAYER bit gates it), mirroring `import_creature_spawns`' reset pass. -/// The persistent `game_creature_spawn` rows are left intact, so the normal respawn pass will re-create -/// seeded creatures later — this clears the live roster, it doesn't unseed the world. #[reducer] pub fn debug_clear_creatures(ctx: &ReducerContext, map_id: u32) -> Result<(), String> { let entities = ctx.db.game_world_entity(); @@ -486,7 +325,6 @@ pub fn debug_clear_creatures(ctx: &ReducerContext, map_id: u32) -> Result<(), St .collect(); let n = guids.len(); for g in &guids { - // Free any combat the creature was in so no orphan engagement lingers, then delete it. crate::combat::disengage(ctx, *g); entities.guid().delete(g); } @@ -494,10 +332,6 @@ pub fn debug_clear_creatures(ctx: &ReducerContext, map_id: u32) -> Result<(), St Ok(()) } -/// Stamp a `game_ground_area` at `caster_guid`'s position from `spell_id`'s area effect (test hook): -/// looks up the spell header + its periodic-area effect (the one with a radius + period) and inserts the -/// zone, exactly as an `E_PERSISTENT_AREA` cast would — so the tick engine can be verified server-side -/// without a live client cast. `tick_ground_areas` then damages hostiles inside for `duration_ms`. #[reducer] pub fn debug_spawn_ground_area( ctx: &ReducerContext, @@ -524,7 +358,6 @@ pub fn debug_spawn_ground_area( (hdr.duration_ms.max(1) as i64) * 1000, )) .ok_or("duration overflow")?; - // Perf catalog 1.20: the tick schedule is demand-armed (it disarms when the area table drains). crate::spell::arm_ground_area_tick(ctx); ctx.db.game_ground_area().insert(GroundArea { id: 0, @@ -554,17 +387,11 @@ pub fn debug_spawn_ground_area( Ok(()) } -/// Use (consume) the item in `character_guid`'s inventory `slot` — drives the player `use_item` path -/// by explicit guid (the CLI identity owns no entity), so consumable effects are machine-verifiable -/// without the mouse-only inventory UI. Delegates to the SAME `apply_item_use` the player reducer uses. #[reducer] pub fn debug_use_item(ctx: &ReducerContext, character_guid: u64, slot: u8) -> Result<(), String> { crate::actor::use_item(ctx, character_guid, slot).map_err(crate::items::refused) } -/// Grant `count`x item `entry` into `character_guid`'s bags — the test lever for item-dependent flows -/// (cooking reagents etc.) that the mouse-only loot/vendor UI and a SQL insert (game_item_instance has a -/// `created_at` Timestamp) can't drive. Delegates to the SAME `items::grant_item` the loot/quest paths use. #[reducer] pub fn debug_grant_item( ctx: &ReducerContext, @@ -575,25 +402,18 @@ pub fn debug_grant_item( crate::items::grant_item(ctx, character_guid, entry, count) } -/// Bind `character_guid`'s hearthstone home to its current position — drives the player `bind_home` -/// (innkeeper gossip) by explicit guid for the harness, via the shared `world::set_home`. #[reducer] pub fn debug_bind_home(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::world::set_home(ctx, character_guid); Ok(()) } -/// Recall `character_guid` to its hearthstone home — drives the hearthstone-use path by explicit guid -/// for the harness, via the shared `world::recall_to_home`. A live Character gets a -/// game_teleport_event to home; an offline one has its durable row moved home. #[reducer] pub fn debug_use_hearthstone(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::world::recall_to_home(ctx, character_guid); Ok(()) } -/// Move/swap `character_guid`'s item from `from_slot` to `to_slot` — drives the player `move_item` -/// path by explicit guid (the mouse-only drag-drop is undrivable), via the shared `apply_item_move`. #[reducer] pub fn debug_move_item( ctx: &ReducerContext, @@ -605,10 +425,6 @@ pub fn debug_move_item( .map_err(crate::items::refused) } -/// Equip `character_guid`'s item in inventory `from_slot` into its matching `EQUIPMENT_SLOT_*` (auto- -/// resolved from the item's `inventory_type`, including the first-free of a finger/trinket pair) — -/// drives the player `equip_item` path by explicit guid (the mouse-only right-click-equip is -/// undrivable), via the shared `apply_equip_item`. Rejects a non-equippable item (e.g. food/junk). #[reducer] pub fn debug_equip_item( ctx: &ReducerContext, @@ -618,8 +434,6 @@ pub fn debug_equip_item( crate::actor::equip_item(ctx, character_guid, from_slot).map_err(crate::items::refused) } -/// Unequip `character_guid`'s item in equipment `from_slot` (0..=18) into the first free backpack slot — -/// drives the player `unequip_item` path by explicit guid, via the shared `apply_unequip_item`. #[reducer] pub fn debug_unequip_item( ctx: &ReducerContext, @@ -629,9 +443,6 @@ pub fn debug_unequip_item( crate::items::apply_unequip_item(ctx, character_guid, from_slot).map_err(crate::items::refused) } -/// Auto-bank/auto-store-bank `character_guid`'s item in `slot` — drives the right-click-to-bank and -/// right-click-to-withdraw paths by explicit guid, via the shared `apply_auto_bank_item` (the -/// direction is inferred from `slot`). #[reducer] pub fn debug_auto_bank_item( ctx: &ReducerContext, @@ -641,8 +452,6 @@ pub fn debug_auto_bank_item( crate::items::apply_auto_bank_item(ctx, character_guid, slot) } -/// Repair `character_guid`'s item in `slot` to full durability — drives the shared `apply_repair_item` -/// (the future vendor/player repair) by explicit guid, so durability wear/break/repair is verifiable. #[reducer] pub fn debug_repair_item( ctx: &ReducerContext, @@ -652,9 +461,6 @@ pub fn debug_repair_item( crate::items::apply_repair_item(ctx, character_guid, slot) } -/// Sell `character_guid`'s item in `slot` (whole stack) to `vendor_guid` — drives the player `sell_item` -/// path by explicit guid, via the shared `apply_item_sell` (now vendor-gated like buy: credits money, -/// removes the item, requires standing at a real vendor). #[reducer] pub fn debug_sell_item( ctx: &ReducerContext, @@ -665,11 +471,6 @@ pub fn debug_sell_item( crate::actor::sell_item(ctx, character_guid, vendor_guid, slot) } -/// Enter AreaTrigger `trigger_id` as `character_guid` — drives `enter_areatrigger`'s shared core -/// (`apply_enter_areatrigger`) by explicit guid for the harness: credits any "explore" quest tied to -/// the trigger, AND routes through a cross-map teleport if `trigger_id` is an imported -/// `game_areatrigger_teleport` row — the same live runbook this debug twin drives for 224 exercises the -/// Deadmines portal end-to-end without a physical CMSG_AREATRIGGER from the client. #[reducer] pub fn debug_enter_areatrigger( ctx: &ReducerContext, @@ -680,9 +481,8 @@ pub fn debug_enter_areatrigger( Ok(()) } -/// Replay the Package's session-less AreaTrigger operation through its ordinary Core Gate. #[reducer] -#[allow(clippy::too_many_arguments)] // The expected crossing is the fixture assertion. +#[allow(clippy::too_many_arguments)] pub fn debug_replay_sessionless_areatrigger( ctx: &ReducerContext, character_guid: u64, @@ -710,8 +510,6 @@ pub fn debug_replay_sessionless_areatrigger( Ok(()) } -/// Use the gameobject `go_guid` as `character_guid` — drives `use_gameobject` by explicit guid for the -/// harness (CHEST rolls its loot, GOOBER grants quest credit), via the shared `apply_use_gameobject`. #[reducer] pub fn debug_use_gameobject( ctx: &ReducerContext, @@ -721,9 +519,6 @@ pub fn debug_use_gameobject( crate::actor::use_gameobject(ctx, character_guid, go_guid) } -/// Use the FIRST spawned gameobject of template `go_entry` as `character_guid` — same as -/// `debug_use_gameobject` but resolves by template entry, so `spacetime call` can drive it with SMALL -/// args (it mangles guids > 2^53). The parent uses this to server-verify the seeded chest/goober. #[reducer] pub fn debug_use_gameobject_entry( ctx: &ReducerContext, @@ -739,19 +534,7 @@ pub fn debug_use_gameobject_entry( crate::actor::use_gameobject(ctx, character_guid, go.guid) } -// `#[reducer]`: SpacetimeDB reducers take their arguments FLAT off the wire (`spacetime call`), so a parameter struct is not available. #[allow(clippy::too_many_arguments)] -/// Spawn a gameobject (creating its template if absent) for harness verification — the seed only runs -/// on `init`, so a re-published DB has no seed GOs, and `game_gameobject`'s Timestamp column blocks a -/// SQL insert. Idempotent (deletes any prior GO at the derived HIGHGUID_GAMEOBJECT guid first). -/// -/// `data1` (required skill level) + `gather_skill_line` (MINING 186 / HERBALISM 182) + `respawn_secs` -/// (per-node respawn window in seconds, 0 ⇒ 3-min fallback) + `gather_gray` (skill-up gray ceiling, 0 ⇒ -/// the always-skill sentinel) are END-APPENDED args (the gateway tolerates extra END-appended reducer -/// args, per the marathon note) so the ONE spawn lever can mint a GATHER node (`type_id` 25) as well as a -/// CHEST/GOOBER — no dup lever. For a non-gather GO pass `... data1 0 gather_skill_line 0 respawn_secs 0 -/// gather_gray 0`; for a 30s-respawn Copper Vein pass `... data0 2770 data1 1 skill 186 respawn_secs 30 -/// gather_gray 0` (2770 = the real imported Copper Ore the vein grants). #[reducer] pub fn debug_spawn_gameobject( ctx: &ReducerContext, @@ -765,8 +548,8 @@ pub fn debug_spawn_gameobject( z: f32, data1: u32, gather_skill_line: u32, - respawn_secs: u32, // END-appended (respawn-variance slice): 0 ⇒ the 3-min fallback; else the node's own window - gather_gray: u32, // END-appended (skill-up-difficulty slice): 0 ⇒ the always-skill sentinel + respawn_secs: u32, + gather_gray: u32, ) -> Result<(), String> { if ctx .db @@ -788,13 +571,11 @@ pub fn debug_spawn_gameobject( respawn_secs, gather_gray, lock_id: 0, - size: 0.0, // no dump size, the gateway renders this at 1.0 + size: 0.0, }); } let guid = (0xF110u64 << 48) | template_entry as u64; ctx.db.game_gameobject().guid().delete(guid); - // Clear any prior Pick-Lock unlock for this DERIVED guid so a re-spawn of a locked - // template is deterministically LOCKED again (the verify's negative case relies on this). ctx.db.game_gameobject_unlocked().go_guid().delete(guid); let go = ctx .db @@ -809,15 +590,11 @@ pub fn debug_spawn_gameobject( orientation: 0.0, state: 0, created_at: ctx.timestamp, - respawn_at_micros: 0, // a freshly-spawned node is ready (no pending respawn) - instance_id: 0, // debug spawns land in the open world + respawn_at_micros: 0, + instance_id: 0, grid_x: lyracore_shared::spatial::grid_cell(x, y).0, grid_y: lyracore_shared::spatial::grid_cell(x, y).1, cell: lyracore_shared::spatial::cell_id_at(x, y), - // All-zero: the debug spawn lever doesn't carry a spawn quaternion this - // slice — the codec's yaw-only derive-from-orientation fallback covers it (orientation - // is also 0.0 here, so the derived quaternion is the identity rotation, byte-consistent - // with a hand-seeded fixture that never set these). rotation_0: 0.0, rotation_1: 0.0, rotation_2: 0.0, @@ -827,11 +604,6 @@ pub fn debug_spawn_gameobject( Ok(()) } -/// Force the FIRST depleted gameobject of template `go_entry` to respawn NOW — flips it state 0 + -/// clears its `respawn_at_micros` INLINE so the verify is deterministic with no wait for the ~4s sense -/// tick (`pass_gameobject_respawn` would do the same flip once the armed time elapses). Resolves by -/// template entry so `spacetime call` can drive it with SMALL args (it mangles guids > 2^53), mirroring -/// `debug_use_gameobject_entry`. The professions verify lever. #[reducer] pub fn debug_force_gameobject_respawn(ctx: &ReducerContext, go_entry: u32) -> Result<(), String> { let gos = ctx.db.game_gameobject(); @@ -845,32 +617,6 @@ pub fn debug_force_gameobject_respawn(ctx: &ReducerContext, go_entry: u32) -> Re Ok(()) } -/// Stand up a TEST gathering POOL with no re-import (the POOL-model verify lever). Ensures each member's -/// GATHER template exists, (re)creates the pool header + member point rows, then ARMS the pool to exactly -/// `max_active` weighted-distinct active rows — so init already shows the rotation property and the -/// MAX_ACTIVE invariant. Idempotent on `pool_id` (clears that pool's prior members + their live rows -/// first). Args packed as a STRING (the `spacetime call` u64-mangling + the existing packed-import -/// convention): `members` is `;`-separated points, each -/// `template_entry,display_id,data0,data1,gather_skill_line,respawn_secs,map,x,y,z,o,weight` (12 fields). -/// A higher-tier member is just a row with a different `template_entry` + a small `weight`. -/// -/// `in_place` sets the pool's KIND (the tier-variety column): false = a ROAMING pool (gather → instant -/// reroll, the node WANDERS — the importer's single-tier spatial pools); true = an IN-PLACE TIER point -/// (`max_active` 1, members CO-LOCATED at one coord but of differing tiers — gather flips+arms the timer -/// and the respawn pass weighted-RE-ROLLS the tier in place, so the point mostly re-presents the common -/// tier and occasionally a rarer one, NO wander). Existing callers pass false (unchanged behavior). -/// -/// Example A — a ROAMING pool (in_place false; pool 1, max 2, five Copper at weight 20 + one Tin at weight -/// 2, 5s respawn, by the spawn; data0 2770 = the real imported Copper Ore the vein grants): -/// spacetime call debug_setup_gather_pool 1 2 false \ -/// '9999,259,2770,1,186,5,0,-100,-100,0,0,20;9999,259,2770,1,186,5,0,-110,-100,0,0,20;\ -/// 9999,259,2770,1,186,5,0,-120,-100,0,0,20;9999,259,2770,1,186,5,0,-130,-100,0,0,20;\ -/// 9999,259,2770,1,186,5,0,-140,-100,0,0,20;9998,259,2770,1,186,5,0,-150,-100,0,0,2' -/// Example B — the IN-PLACE TIER demonstrator (in_place true; pool 2, max 1, Copper 1731 w85 + Tin 1732 -/// w15 CO-LOCATED at the real Goldshire coord — both members share x,y,z so the rolled tier never wanders): -/// spacetime call debug_setup_gather_pool 2 1 true \ -/// '1731,259,2770,1,186,300,0,-9620.11,-46.3336,47.3641,2.04204,85;\ -/// 1732,259,2771,65,186,300,0,-9620.11,-46.3336,47.3641,2.04204,15' #[reducer] pub fn debug_setup_gather_pool( ctx: &ReducerContext, @@ -885,8 +631,6 @@ pub fn debug_setup_gather_pool( let tmpl_tbl = ctx.db.game_gameobject_template(); let gos = ctx.db.game_gameobject(); - // CLEAR this pool's prior member rows + any live row at their derived guids (idempotent re-setup — - // mirrors the import's clear-then-load, but scoped to THIS pool so it never touches others). let prior: Vec = member_tbl.iter().filter(|m| m.pool_id == pool_id).collect(); for m in &prior { @@ -895,7 +639,6 @@ pub fn debug_setup_gather_pool( member_tbl.point_id().delete(m.point_id); } - // POOL header (upsert). `in_place` carries the pool's KIND through to the deplete/respawn branch. pool_tbl.pool_id().delete(pool_id); pool_tbl.insert(crate::gameobject::GameObjectPool { pool_id, @@ -903,7 +646,6 @@ pub fn debug_setup_gather_pool( in_place, }); - // MEMBER points: parse + ensure each GATHER template exists + insert the point row (auto_inc point_id). let pu32 = |s: &str| s.trim().parse::().map_err(|_| format!("bad u32: {s}")); let pf32 = |s: &str| s.trim().parse::().map_err(|_| format!("bad f32: {s}")); let mut count = 0u32; @@ -924,7 +666,6 @@ pub fn debug_setup_gather_pool( let map_id = pu32(f[6])?; let (x, y, z, o) = (pf32(f[7])?, pf32(f[8])?, pf32(f[9])?, pf32(f[10])?); let weight = pu32(f[11])?; - // Ensure the GATHER template (type 25) — so a brand-new test entry needs no separate spawn call. if tmpl_tbl.entry().find(template_entry).is_none() { tmpl_tbl.insert(crate::gameobject::GameObjectTemplate { entry: template_entry, @@ -937,11 +678,11 @@ pub fn debug_setup_gather_pool( respawn_secs, gather_gray: 0, lock_id: 0, - size: 0.0, // no dump size, the gateway renders this at 1.0 + size: 0.0, }); } member_tbl.insert(crate::gameobject::GameObjectPoolMember { - point_id: 0, // auto_inc + point_id: 0, pool_id, template_entry, map_id, @@ -956,16 +697,10 @@ pub fn debug_setup_gather_pool( if count == 0 { return Err("pool setup had no members".to_string()); } - // ARM: insert exactly max_active weighted-distinct live rows (the only place the active count is SET). crate::gameobject::arm_pool(ctx, pool_id); Ok(()) } -/// Force a pool RE-ROLL NOW for a deterministic no-wait verify (the ~4s sense tick would do the same on -/// the armed timer). Deactivates `gathered_guid` and activates a different inactive weighted-chosen -/// point — the active count stays `max_active`. `gathered_guid` is the live guid of the point to roll; -/// resolve it from the SQL `SELECT guid FROM game_gameobject WHERE ...` (it's < 2^53 only for small -/// point_ids — pass it after a `--` if negative-looking, though GO guids are large/unsigned). #[reducer] pub fn debug_force_pool_reroll( ctx: &ReducerContext, @@ -985,8 +720,6 @@ pub fn debug_force_pool_reroll( Ok(()) } -/// Buy `count`× `item_entry` for `character_guid` — drives the player `buy_item` path by explicit -/// guid, via the shared `apply_buy_item` (debits money, grants the item in a free backpack slot). #[reducer] pub fn debug_buy_item( ctx: &ReducerContext, @@ -998,9 +731,6 @@ pub fn debug_buy_item( crate::actor::buy_item(ctx, character_guid, vendor_guid, item_entry, count) } -/// Buy the next bank bag slot for `character_guid` at `banker_guid` — drives the purchase by explicit -/// guid, via the shared `apply_buy_bank_slot`. Refusals carry the same `[N]` wire code the gateway -/// reducer returns. #[reducer] pub fn debug_buy_bank_slot( ctx: &ReducerContext, @@ -1014,8 +744,6 @@ pub fn debug_buy_bank_slot( )) } -/// Split `count` units off `character_guid`'s stack in `slot` into the empty `to_slot` — drives the -/// player `split_item` path by explicit guid, via the shared `apply_item_split`. #[reducer] pub fn debug_split_item( ctx: &ReducerContext, @@ -1028,10 +756,6 @@ pub fn debug_split_item( .map_err(crate::items::refused) } -/// Arm a melee engagement `attacker_guid` → `target_guid` directly (insert/retarget a `game_melee_attack` -/// row) — the machine-test counterpart to the player `start_attack` (which is `ctx.sender`-bound and so -/// undrivable from the CLI identity). Lets a test put a CREATURE into combat so the threat-retarget pass -/// considers it, without a live client swing. No validation (dev only): the caller picks valid guids. #[reducer] pub fn debug_engage( ctx: &ReducerContext, @@ -1055,8 +779,6 @@ pub fn debug_engage( } else { melee.insert(row); } - // Point the entity at its target too (real aggro arming sets `target_guid`; mirror it so the live - // row and the melee row agree before any retarget pass runs). let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(attacker_guid) { e.target_guid = target_guid; @@ -1065,9 +787,6 @@ pub fn debug_engage( Ok(()) } -/// Cast `spell_id` from `caster_guid` AT `target_guid` — drives the target-aware `resolve_cast_at` -/// (the player `cast_spell` resolves the target from the client; here it's explicit) so a direct-damage -/// spell is machine-verifiable. Sources the caster level from its entity, like `debug_force_cast`. #[reducer] pub fn debug_cast_at( ctx: &ReducerContext, @@ -1075,14 +794,9 @@ pub fn debug_cast_at( spell_id: u32, target_guid: u64, ) -> Result<(), String> { - // Debug direct cast resolves synchronously — not a timed-cast completion (instant packet - // sequence). It bypasses player spellbook admission so fixtures can cast rows they just staged. resolve_debug_cast_at(ctx, caster_guid, spell_id, target_guid) } -/// Begin a (possibly cast-timed) cast from `caster_guid` AT `target_guid` — drives `begin_cast` so a -/// spell with `cast_time_ms > 0` schedules a `PendingCast` that resolves when the cast finishes (the -/// effect lands AFTER the bar, not instantly). Sources the caster level from its entity. #[reducer] pub fn debug_begin_cast( ctx: &ReducerContext, @@ -1102,9 +816,6 @@ pub fn debug_begin_cast( ) } -/// Cast a GROUND-TARGETED spell at an explicit world point by caster guid — server-side proof of the -/// ground-target dest plumbing (the CLI owns no entity, so it can't drive the real `cast_spell_at`). Drives the -/// full `begin_cast(Some(dest))` path, so a TIMED ground spell also exercises the PendingCast dest carry. #[reducer] pub fn debug_cast_spell_at( ctx: &ReducerContext, @@ -1132,15 +843,6 @@ pub fn debug_cast_spell_at( ) } -/// Seed `count` SYNTHETIC filler auras directly into `target_guid`'s buff (`debuff=false`) or debuff -/// (`debuff=true`) `UNIT_FIELD_AURA` range, at slots `[0, count)` / `[BUFF_SLOT_COUNT, BUFF_SLOT_COUNT + -/// count)` — the harness lever for proving the 32-buff/16-debuff cap end to end without -/// hunting down that many distinct real spells. This is SETUP: it writes `game_aura` rows directly and -/// skips `apply_group_conflict`/`pick_aura_slot` entirely, on purpose — the probe's own cast (a real -/// `debug_cast_at`/player cast against the now-full range) is what exercises the policy boundary under -/// test. Synthetic ids (`9_000_000 + slot`) so a real spell used by the probe cast can never collide -/// with a filler row. Errors if `count` exceeds the range's real slot count (32 buff / 16 debuff) — the -/// same geometry `pick_aura_slot` enforces, not an arbitrary harness limit. #[reducer] pub fn debug_fill_aura_slots( ctx: &ReducerContext, @@ -1168,7 +870,7 @@ pub fn debug_fill_aura_slots( let auras = ctx.db.game_aura(); let now = ctx.timestamp; let expires = now - .checked_add(TimeDuration::from_micros(3_600_000_000)) // 1h — outlives any probe session + .checked_add(TimeDuration::from_micros(3_600_000_000)) .unwrap_or(now); for i in 0..count { let slot = base_slot + i; @@ -1198,7 +900,7 @@ pub fn debug_fill_aura_slots( next_tick_micros: 0, channel_target: 0, enters_combat: false, - proc_flags: 0, // a filler aura is not a Proc + proc_flags: 0, proc_chance: 0, proc_ppm: 0.0, proc_ex: 0, @@ -1213,9 +915,6 @@ pub fn debug_fill_aura_slots( Ok(()) } -/// Log the reputation vendor discount `player_guid` gets at a creature whose FactionTemplate is -/// `faction_template_id` — runs the real `vendor_discount_pct` resolution against live data (server-side -/// proof without needing to drive a full buy, whose vendor guid >2^53 mangles through `spacetime call`). #[reducer] pub fn debug_log_vendor_discount( ctx: &ReducerContext, @@ -1227,7 +926,6 @@ pub fn debug_log_vendor_discount( Ok(()) } -// `#[reducer]`: SpacetimeDB reducers take their arguments FLAT off the wire (`spacetime call`), so a parameter struct is not available. #[allow(clippy::too_many_arguments)] #[reducer] pub fn debug_score_movement( @@ -1247,7 +945,7 @@ pub fn debug_score_movement( &crate::world::MovementDelta { old_x, old_y, - old_z: 0.0, // unused here: `score_and_log_movement` reads only x/y; `.moved()` is movement_update-only + old_z: 0.0, x, y, z, @@ -1258,9 +956,6 @@ pub fn debug_score_movement( Ok(()) } -/// Take the item in `corpse_guid`'s loot slot `loot_slot` into `character_guid`'s bags — drives the -/// player `take_loot` path by explicit guid (the mouse-only loot window is undrivable), via the shared -/// `apply_take_loot`. #[reducer] pub fn debug_take_loot( ctx: &ReducerContext, @@ -1271,12 +966,6 @@ pub fn debug_take_loot( crate::actor::take_loot(ctx, character_guid, corpse_guid, loot_slot) } -/// Equip (or swap) `character_guid`'s main-hand weapon to `item_entry` — drives the weapon-damage-in -/// -swing test without the mouse-only equip UI (the same DirectInput wall as loot/equip clicks). -/// Upserts the owned item instance in the main-hand slot (15) to the given template entry, stamping -/// the live entity's owner identity so the per-owner RLS filter still shows it. The swing-damage -/// lookup joins `game_item_template`, so a stronger seeded weapon immediately feeds `roll_swing` / -/// `debug_compute_swing`. Durability seeds from the template (0 if the template isn't loaded). [entity] #[reducer] pub fn debug_equip_weapon( ctx: &ReducerContext, @@ -1291,10 +980,6 @@ pub fn debug_equip_weapon( ) } -/// Equip (or swap) `character_guid`'s OFF-HAND to `item_entry` — the off-hand twin of -/// [`debug_equip_weapon`], for shield-block testing without the mouse-only equip UI. Equip a shield -/// (e.g. seed entry 53 "Battered Buckler") here and `effective_block_value`/`effective_block_bp` arm the -/// block band, verifiable via `debug_compute_swing` or a live fight with no client interaction. [entity] #[reducer] pub fn debug_equip_offhand( ctx: &ReducerContext, @@ -1309,10 +994,6 @@ pub fn debug_equip_offhand( ) } -/// Shared core of the `debug_equip_*` reducers: upsert `character_guid`'s owned item instance in `slot` -/// to `item_entry`, stamping the live entity's owner identity (so the per-owner RLS filter still shows -/// it) and seeding durability from the template. Find existing gear by slot, since moving it -/// preserves its GUID. fn equip_into( ctx: &ReducerContext, character_guid: u64, @@ -1327,8 +1008,6 @@ fn equip_into( .transpose()? .unwrap_or(0); let durability = tmpl.as_ref().map(|t| t.max_durability).unwrap_or(0); - // This helper places the item DIRECTLY on the body (bypassing the pickup->equip two-step apply_item_move - // drives), so either a BoP or a BoE template binds immediately here — mirroring "the item is now worn". let soulbound = tmpl .as_ref() .map(|t| crate::items::binds_on_grant(t.bonding) || crate::items::binds_on_equip(t.bonding)) @@ -1353,7 +1032,7 @@ fn equip_into( stack_count: 1, durability, created_at: ctx.timestamp, - enchant_id: 0, // debug-granted item — unenchanted + enchant_id: 0, soulbound, random_property_id, item_text_id: 0, @@ -1362,9 +1041,6 @@ fn equip_into( Ok(()) } -/// Accept quest `quest_entry` from giver `giver_guid` for `character_guid` — drives the player -/// `accept_quest` path by explicit guid (the gossip/quest UI is mouse-only / not yet wired), via the -/// shared `apply_accept_quest`. The harness positions the player in range of the giver first. #[reducer] pub fn debug_accept_quest( ctx: &ReducerContext, @@ -1375,10 +1051,6 @@ pub fn debug_accept_quest( crate::actor::accept_quest(ctx, character_guid, giver_guid, quest_entry) } -/// Relay-stress: ONE transaction — the relay-carried rows (a quest -/// kill credit for `victim_entry` and an item grant). The wire test asserts the client still -/// receives SMSG_QUESTUPDATE_ADD_KILL and SMSG_ITEM_PUSH_RESULT out of this fat transaction — the -/// delivery guarantee the coordinator relay migration exists to provide. #[reducer] pub fn debug_stress_relay( ctx: &ReducerContext, @@ -1398,10 +1070,6 @@ pub fn debug_stress_relay( Ok(()) } -/// Grant quest `quest_entry` to `character_guid` with NO giver — stages a quest in the log for live -/// testing (verifying the quest-log and kill-feedback paths). Bypasses the giver validation that the -/// player/`debug_accept_quest` path needs, because the giver's creature guid is a u64 > 2^53 that the CLI -/// `spacetime call` mangles via JSON f64. Same insert as a real accept, minus the gates. #[reducer] pub fn debug_grant_quest( ctx: &ReducerContext, @@ -1411,24 +1079,16 @@ pub fn debug_grant_quest( crate::actor::stage_quest(ctx, character_guid, quest_entry) } -/// Force-expire `character_guid`'s `quest_entry`, the harness twin of the timed-quest -/// tick (`quest::quest_timer_pass`), for the runbook: verify `SMSG_QUESTUPDATE_FAILEDTIMER` fires on -/// the wire and the quest is re-acceptable, without waiting out `limit_time` seconds live. #[reducer] pub fn debug_expire_quest( ctx: &ReducerContext, character_guid: u64, quest_entry: u32, ) -> Result<(), String> { - // REFUSE verdict: the write lands in `game_character_quest`, a MANIFEST table — the - // export blob's own enumeration — so post-begin it is a lost write cross-database. crate::helpers::require_character(ctx, character_guid)?; crate::quest::debug_force_expire(ctx, character_guid, quest_entry) } -/// Share `quest_entry` from `character_guid` to its party, drives -/// `push_quest_to_party` by explicit guid (the CLI identity owns no entity to resolve via -/// `ctx.sender()`). #[reducer] pub fn debug_push_quest( ctx: &ReducerContext, @@ -1438,9 +1098,6 @@ pub fn debug_push_quest( crate::quest::apply_push_quest_to_party(ctx, character_guid, quest_entry) } -/// Fire the `on_gossip_select` notify-hook by explicit guid — the harness twin of -/// the player `gossip_select` reducer (the CLI identity owns no entity, and a real gossip click is -/// mouse-only). Pure notification, no gossip behavior. #[reducer] pub fn debug_gossip_select( ctx: &ReducerContext, @@ -1461,9 +1118,6 @@ pub fn debug_gossip_select( Ok(()) } -/// Turn quest `quest_entry` in to giver `giver_guid` for `character_guid` — drives the player -/// `turn_in_quest` path by explicit guid, via the shared `apply_turn_in_quest` (grants money/XP/items). -/// `reward_index` picks the choice reward (pick-1-of-N); pass 0 for a quest with no choice rewards. #[reducer] pub fn debug_turn_in_quest( ctx: &ReducerContext, @@ -1475,8 +1129,6 @@ pub fn debug_turn_in_quest( crate::actor::turn_in_quest(ctx, character_guid, giver_guid, quest_entry, reward_index) } -/// Start or renew a one-waypoint Route Path for `guid` to (`x`, `y`, `z`) through -/// `creatures::tick::emit_creature_path`, the call a Package runner makes. #[reducer] pub fn debug_creature_path( ctx: &ReducerContext, @@ -1492,11 +1144,6 @@ pub fn debug_creature_path( Ok(()) } -/// Kill creature `target_guid` crediting player `killer_guid` — drives the SHARED killing-blow path -/// (`combat::kill_creature` with a killer), so it produces the exact same corpse + loot AND the XP + -/// quest-objective credit a real player kill does. The machine-test counterpart to fighting a mob to -/// death: lets a test advance a kill quest deterministically without the combat tick. Errors only if -/// nothing was killed (missing guid / a player / an already-dead unit). #[reducer] pub fn debug_kill_creature( ctx: &ReducerContext, @@ -1518,8 +1165,6 @@ pub fn debug_kill_nearest( ) -> Result<(), String> { let killer = crate::helpers::live_entity(ctx, killer_guid) .map_err(|_| format!("killer {killer_guid} not in world"))?; - // Same-partition scan (crate::helpers::nearest_entity) — a raw squared distance across - // maps/instances is meaningless, "nearest" could otherwise resolve to another continent. let target_guid = crate::helpers::nearest_entity(ctx, &killer, |e| { e.entry == creature_entry && e.guid != killer_guid && !e.dead }) @@ -1532,20 +1177,10 @@ pub fn debug_kill_nearest( } } -/// SKIN the NEAREST skinnable BEAST CORPSE to `character_guid` (Skinning, professions) — the -/// small-arg test lever (a creature corpse's own guid is a u64 > 2^53 the CLI `spacetime call` mangles, so -/// the big corpse guid is resolved SERVER-SIDE here, mirroring `debug_kill_nearest`'s nearest-search). It -/// finds the closest dead non-player creature whose template `creature_type == BEAST_TYPE` and isn't yet -/// `skinned`, then funnels through the shared `loot::skin_corpse` core (auto-learn → grant leather → -/// climb Skinning → mark skinned). A future `CMSG`-routed skin over the open corpse will share that core. #[reducer] pub fn debug_skin_nearest(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { let looter = crate::helpers::live_entity(ctx, character_guid) .map_err(|_| format!("skinner {character_guid} not in world"))?; - // A skinnable target: a dead non-player BEAST corpse not yet skinned, in the looter's OWN - // (map, instance) partition (mirrors `loot::can_skin`'s live gate, so the - // debug lever can never find a corpse the real skin path would refuse). Same-partition scan - // via `crate::helpers::nearest_entity`. let corpse_guid = crate::helpers::nearest_entity(ctx, &looter, |e| { e.dead && !e.is_player() && !e.skinned && crate::loot::entry_is_beast(ctx, e.entry) }) @@ -1554,29 +1189,16 @@ pub fn debug_skin_nearest(ctx: &ReducerContext, character_guid: u64) -> Result<( crate::loot::skin_corpse(ctx, character_guid, corpse_guid) } -/// FISH at `character_guid`'s spot (FISHING, completing the 13) — the small-arg test twin of the `fish` -/// player reducer (the CLI identity owns no entity, so the guid is explicit). Drives the shared -/// `loot::apply_fish` core (auto-learn → grant a random fish from the pool → climb Fishing). Verify: a fish -/// item (6291/6303/6289) appears in the backpack + the Fishing skill row climbs. #[reducer] pub fn debug_fish(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { crate::loot::apply_fish(ctx, character_guid) } -/// DISENCHANT the item in equipment `slot` for `character_guid` (ENCHANTING, completing the 13) — the -/// small-arg test twin of the `disenchant` player reducer. Drives `loot::apply_disenchant` (consume the -/// item → grant Strange Dust 10940 → climb Enchanting). Verify: the item row is DELETED, mats granted, -/// Enchanting +1. #[reducer] pub fn debug_disenchant(ctx: &ReducerContext, character_guid: u64, slot: u8) -> Result<(), String> { crate::loot::apply_disenchant(ctx, character_guid, slot) } -/// ENCHANT the item in `target_slot` for `character_guid` with `enchant_id` (ENCHANTING, completing the -/// 13) — the small-arg test twin of the `enchant_item` player reducer. Drives `loot::apply_enchant_item` -/// (validate id → consume mats → stamp the per-instance overlay → climb Enchanting). Verify: -/// `item_instance.enchant_id == enchant_id`, then `debug_compute_swing` shows the swing/strength readout -/// rose by the enchant's +stat (server-real); a Stamina enchant moves max-HP via recompute_vitals. #[reducer] pub fn debug_enchant_item( ctx: &ReducerContext, @@ -1587,10 +1209,6 @@ pub fn debug_enchant_item( crate::loot::apply_enchant_item(ctx, character_guid, target_slot, enchant_id) } -/// Start `attacker_guid`'s RANGED auto-attack on the NEAREST live creature, with `spell_id` -/// (75 Auto Shot / 5019 wand Shoot) — the small-arg test hook (a creature's own guid is a u64 > 2^53 the -/// CLI `spacetime call` mangles). Requires a ranged weapon equipped (the shared gate enforces it). Mirrors -/// `debug_kill_nearest`'s nearest-search, then arms the engagement via `apply_start_ranged_attack`. #[reducer] pub fn debug_ranged_attack_nearest( ctx: &ReducerContext, @@ -1599,7 +1217,6 @@ pub fn debug_ranged_attack_nearest( ) -> Result<(), String> { let attacker = crate::helpers::live_entity(ctx, attacker_guid) .map_err(|_| format!("attacker {attacker_guid} not in world"))?; - // Same-partition scan (crate::helpers::nearest_entity) — see debug_kill_nearest. let target_guid = crate::helpers::nearest_entity(ctx, &attacker, |e| { e.guid != attacker_guid && !e.dead && !e.is_player() }) @@ -1608,10 +1225,6 @@ pub fn debug_ranged_attack_nearest( crate::actor::ranged_attack(ctx, attacker_guid, target_guid, spell_id) } -/// Teach `character_guid` the spell `spell_id` (test hook — e.g. Auto Shot 75 on a non-Hunter so the -/// client's spellbook gets it and `CastSpellByName("Auto Shot")` works). Drives the shared -/// `spell::learn_spell` by explicit guid (the player path is `ctx.sender`-bound). Needs a relog for the -/// learned spell to reach the client spellbook (sent in SMSG_INITIAL_SPELLS at login). #[reducer] pub fn debug_learn_spell( ctx: &ReducerContext, @@ -1623,8 +1236,6 @@ pub fn debug_learn_spell( Ok(()) } -/// Set the realm XP-rate (`game_config` singleton) for testing — upserts row 0 (the seed default may be -/// absent on a re-published DB). Negative clamps to 0. Production realms can also `UPDATE game_config`. #[reducer] pub fn debug_set_xp_rate(ctx: &ReducerContext, rate: f32) -> Result<(), String> { let rate = rate.max(0.0); @@ -1649,22 +1260,6 @@ pub fn debug_set_xp_rate(ctx: &ReducerContext, rate: f32) -> Result<(), String> Ok(()) } -/// The REQUIRED post-publish step for the `cell` column migration. -/// -/// `cell` was END-appended to the four AOI-scoped tables with `#[default(0i64)]`, and 0 is not a -/// sentinel: it is the legitimate id of cell (0, 0). So on the first publish EVERY -/// pre-existing row claims to live in that one cell, and the AOI subscription — which now probes -/// `cell` by equality — finds nothing where those rows actually are. Moving entities self-heal on -/// their next heartbeat or spline leg (sub-second), but **gameobjects are static and never re-stamp -/// themselves**: without this sweep, every imported GO in the world stays invisible forever. -/// -/// Run once per shard immediately after publishing, on all five databases: -/// `spacetime call debug_backfill_cell_ids` -/// -/// Idempotent and safe to re-run: it recomputes `cell` from each row's own already-correct -/// `grid_x`/`grid_y` (NOT from `x`/`y` — re-deriving the grid here would silently double as a -/// re-grid, which is `debug_regrid`'s job and a different decision) and writes only rows that differ. -/// Collect-then-update so the table is not mutated mid-iteration. #[reducer] pub fn debug_backfill_cell_ids(ctx: &ReducerContext) -> Result<(), String> { let mut totals: Vec<(&str, usize)> = Vec::new(); @@ -1731,10 +1326,6 @@ pub fn debug_backfill_cell_ids(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Re-stamp `grid_x`/`grid_y` on every `game_world_entity` from its live `(x, y)` with the CURRENT -/// `GRID_CELL_SIZE`. Run after changing the AOI cell size: the columns are baked, so a constant change -/// alone leaves stationary entities at stale cells (moving ones self-correct on their next move). -/// Collect-then-update so we don't mutate the table mid-iteration. Idempotent full sweep. #[reducer] pub fn debug_regrid(ctx: &ReducerContext) -> Result<(), String> { let entities = ctx.db.game_world_entity(); @@ -1742,8 +1333,6 @@ pub fn debug_regrid(ctx: &ReducerContext) -> Result<(), String> { .iter() .filter_map(|e| { let (gx, gy) = spatial::grid_cell(e.x, e.y); - // `cell` too — a migrated row has the RIGHT grid_x/grid_y and a stale cell of 0, - // so comparing only the grid columns would skip every row this sweep exists to fix. (e.grid_x != gx || e.grid_y != gy || e.cell != spatial::grid_cell_id(gx, gy)) .then_some((e.guid, gx, gy)) }) @@ -1761,10 +1350,6 @@ pub fn debug_regrid(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Grant `amount` reputation for `character_guid` with `faction_id` (server-verify hook — quest-rep is -/// the only live caller, and the player turn-in path is `ctx.sender`-bound). Drives `reputation::grant_reputation` -/// directly; no-ops if the faction has no rep bar / is unknown. Verify via `SELECT * FROM game_player_reputation` -/// and the relayed `SMSG_SET_FACTION_STANDING`. Faction ids are small (< 2^53) so `spacetime call` carries them fine. #[reducer] pub fn debug_grant_reputation( ctx: &ReducerContext, @@ -1772,19 +1357,11 @@ pub fn debug_grant_reputation( faction_id: u32, amount: i32, ) -> Result<(), String> { - // REFUSE verdict: `game_player_reputation` is a MANIFEST table — see - // `debug_expire_quest` for the same reasoning. crate::helpers::require_character(ctx, character_guid)?; crate::reputation::grant_reputation(ctx, character_guid, faction_id, amount); Ok(()) } -/// Buy spell `spell_id` from the trainer whose creature-template `trainer_entry` is spawned in-world, for -/// `character_guid` — drives the validated `trainer::buy_trainer_spell` by explicit small args (the player -/// path is `ctx.sender`-bound, and a trainer GUID is > 2^53 so `spacetime call` would mangle it; the -/// template entry is small). Resolves the FIRST spawned non-player entity of that entry, then runs the -/// SAME buy logic (range/flag/level/cost/known gates → charge + learn_spell). Verify via -/// `SELECT * FROM game_player_spell` (row appears) + the caster's `money` dropping. #[reducer] pub fn debug_buy_trainer_spell( ctx: &ReducerContext, @@ -1801,10 +1378,6 @@ pub fn debug_buy_trainer_spell( crate::actor::trainer_buy(ctx, character_guid, trainer.guid, spell_id) } -/// Learn a talent for `character_guid` (drives the validated `talent::do_learn_talent` by explicit guid — -/// the player `learn_talent` is `ctx.sender`-bound and undrivable from the CLI identity). Applies the -/// talent's passive aura at the new rank immediately (no relog) so the combat effect is verifiable via -/// `debug_compute_swing`. Errors on the same gates as the player path (no points / max rank / tier / prereq). #[reducer] pub fn debug_learn_talent( ctx: &ReducerContext, @@ -1815,11 +1388,6 @@ pub fn debug_learn_talent( crate::talent::do_learn_talent(ctx, character_guid, e.owner_identity, talent_id).map(|_| ()) } -/// Reset every learned talent for `character_guid` at `trainer_guid` (drives the validated -/// `talent::do_reset_talents` by explicit guid — the player `reset_talents` is `ctx.sender`-bound and -/// undrivable from the CLI identity). Same trainer/range gates + escalating gold cost as the player -/// path. Verify via `SELECT * FROM game_character_talent` (rows gone), `game_aura` (passive auras gone), -/// `game_player_spell` (taught abilities gone), and `game_character.money`/`respec_count`. #[reducer] pub fn debug_reset_talents( ctx: &ReducerContext, @@ -1829,27 +1397,10 @@ pub fn debug_reset_talents( crate::talent::do_reset_talents(ctx, character_guid, trainer_guid).map(|_| ()) } -/// Floor for a dedicated per-instance tick interval. Every firing is a transaction on the ONE -/// serialized commit stream: 10 instances at 100ms is already 100 extra transactions/sec preempting -/// player actions. Anything tighter than 50ms has no gameplay payoff at 1.12 animation cadences. const INSTANCE_TICK_MS_FLOOR: u64 = 50; -/// Ceiling for `debug_arm_instance_tick`: bounds `tick_ms` so `tick_ms * 1000` never overflows into -/// a negative interval, which would refire continuously. const INSTANCE_TICK_MS_CEIL: u64 = 600_000; -/// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms`: that -/// instance's creature passes then fire on THIS row at its own cadence, and the catch-all row skips -/// the instance (coverage is a partition — see `TickScope` in creatures/ai.rs). The global due-time -/// passes (decay/respawn/regen/combat-drop) STAY on the catch-all row for all instances, so this knob -/// only smooths movement/AI latency. It buys NO parallel throughput, and -/// each firing taxes the shared serialized commit stream; use tight cadences sparingly and read the -/// once-a-minute "pass rows-visited" log line to see what each row actually scans. -/// -/// `instance::create_instance` inserts exactly this row shape (500ms default) and -/// `instance::teardown_instance` deletes it, as `debug_disarm_instance_tick` does. This reducer is -/// the operator RETUNE lever for a live instance's cadence. -/// Idempotent per instance (replaces any existing dedicated row). #[reducer] pub fn debug_arm_instance_tick( ctx: &ReducerContext, @@ -1868,7 +1419,6 @@ pub fn debug_arm_instance_tick( return Err(format!("tick_ms {tick_ms} above the {INSTANCE_TICK_MS_CEIL}ms ceiling (overflow guard — see the const's doc)")); } let sched = ctx.db.game_creature_move_schedule(); - // Replace any existing dedicated row for this instance (re-arm = retune the cadence). let stale: Vec = sched .iter() .filter(|r| r.instance_id == instance_id) @@ -1885,10 +1435,6 @@ pub fn debug_arm_instance_tick( Ok(()) } -/// Remove the dedicated creature-tick row for `instance_id`, coverage of that -/// instance returns to the catch-all row on its next firing (the `TickScope` rebuild is per firing), -/// so its creatures keep ticking at the global 0.5s cadence; nothing is ever stranded. This is also -/// the future instance-reap's tick-row cleanup. #[reducer] pub fn debug_disarm_instance_tick(ctx: &ReducerContext, instance_id: u64) -> Result<(), String> { if instance_id == crate::creatures::GLOBAL_TICK_INSTANCE { @@ -1909,22 +1455,6 @@ pub fn debug_disarm_instance_tick(ctx: &ReducerContext, instance_id: u64) -> Res Ok(()) } -/// Server-side integration probe for the `A_COMBAT_HEALTH_REGEN_PCT` aura (combat health regen -/// in combat). Reads the LIVE `game_aura` table for `character_guid`, sums every active -/// `A_COMBAT_HEALTH_REGEN_PCT` aura effect (exactly as the cycle's regeneration phase does), then -/// computes the -/// expected per-tick delta using the same `regen_health_in_combat` path. Logs the result at INFO. -/// -/// Returns `Ok` when the pct sum AND the tick delta are both non-zero (the aura is present AND is -/// large enough to move health by at least 1 HP per tick). Returns `Err` with a diagnostic otherwise -/// so the caller can distinguish "no aura" from "aura too small for the entity's stats". -/// -/// **Usage** (verify a Human has the regen passive and will tick in combat): -/// ```text -/// spacetime call debug_verify_combat_regen '[]' -/// spacetime logs | grep verify_combat_regen -/// ``` -/// The logs show: `pct=10 full_tick=71 partial=7 health=950 max=1000 → would_heal=957` #[reducer] pub fn debug_verify_combat_regen(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { let e = crate::helpers::live_entity(ctx, character_guid)?; @@ -1951,10 +1481,6 @@ pub fn debug_verify_combat_regen(ctx: &ReducerContext, character_guid: u64) -> R Ok(()) } -/// Cast `spell_id` as `character_guid` THROUGH the spellbook gate (`knows_spell`) — the by-guid mirror of -/// the sender-bound `cast_spell`, so a test can verify the learnable-spell gate: the cast is -/// rejected until the spell is in the baseline kit OR learned (e.g. via an ability talent), then accepted. -/// Unlike `debug_cast_at` (which drives `resolve_cast_at` and BYPASSES the gate), this exercises the gate. #[reducer] pub fn debug_cast_spell( ctx: &ReducerContext, @@ -1982,11 +1508,6 @@ pub fn debug_cast_spell( ) } -/// Read back the two `build.rs`-generated character-owned-sweep registries -/// LIVE, inside the actual wasm runtime (not just `cargo test`, which never proved the build.rs codegen -/// survives the `wasm32-unknown-unknown` compile + the SpacetimeDB module-init path). Logs both slice -/// lengths and errors out if either is unexpectedly empty — a silently-empty registry (the wasm-ld/ -/// codegen regression this whole mechanism has to guard against) must be LOUD, not a no-op sweep. #[reducer] pub fn debug_sweep_slice_lens(_ctx: &ReducerContext) -> Result<(), String> { let delete_len = crate::CHARACTER_OWNED_DELETE_SWEEPS.len(); @@ -2003,24 +1524,12 @@ pub fn debug_sweep_slice_lens(_ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// (Re)seed the scenario-runner fixtures: quest 50900 + questgiver 51003 + vendor -/// 51004 + faction 79 rep bar + a Lesser Heal trainer offering on 51001. Idempotent -/// (`seed::seed_scenario_fixtures` inserts only if absent); same precedent as -/// `debug_seed_pw_shield_fixture`. The scenario orchestrators call this before every run. #[reducer] pub fn debug_seed_scenario_fixtures(ctx: &ReducerContext) { crate::seed::seed_scenario_fixtures(ctx); - // Post-ETL one-call restore (2026-07-16): the spell-fixture seeders ride along so a single - // reducer re-arms EVERY suite fixture after an import pass (Test Regeneration 50137 and - // Test PW:Shield 50072 were found missing live — only the scenario NPC/quest slice was being - // reseeded, and the suite's combat-regen probe hard-failed on "unknown spell 50137"). crate::seed::seed_regen_fixture(ctx); crate::seed::seed_pw_shield_fixture(ctx); crate::seed::seed_stacking_probe_fixture(ctx); - // row_count: quest 50900 (1) + how many of the 3 scenario NPC templates (questgiver 51003, - // vendor 51004, weapon master 51005) are present after this call — an anchor-row proxy for the - // fixture's full spread across quest/text/objective/reward/vendor/trainer tables, not an exact - // total. let quest = ctx .db .game_quest_template() @@ -2034,8 +1543,6 @@ pub fn debug_seed_scenario_fixtures(ctx: &ReducerContext) { crate::import_meta::stamp(ctx, "debug_seed_scenario_fixtures", "", "", quest + npcs); } -/// Set a character's purse to an EXACT copper amount (live entity + persisted row) so scenario money -/// assertions are deterministic run-over-run — idempotent by design. #[reducer] pub fn debug_set_money( ctx: &ReducerContext, @@ -2043,7 +1550,6 @@ pub fn debug_set_money( copper: u32, ) -> Result<(), String> { let chars = ctx.db.game_character(); - // REFUSE verdict — harness writers get the same fence as production ones. let mut c = crate::helpers::require_character(ctx, character_guid)?; c.money = copper; chars.guid().update(c); @@ -2055,10 +1561,6 @@ pub fn debug_set_money( Ok(()) } -/// Set a live entity's power (mana/rage/energy) for scenario staging, lifting `max_power` when -/// needed — a no-import sandbox has no mana curve (`game_level_stats` empty), so a wire-created -/// caster logs in with 0/0 mana and every costed cast would fail its power gate. Mirrors -/// `debug_set_health`'s live-entity contract. #[reducer] pub fn debug_set_power(ctx: &ReducerContext, guid: u64, power: u32) -> Result<(), String> { let entities = ctx.db.game_world_entity(); diff --git a/module/src/debug/package_config.rs b/module/src/debug/package_config.rs index ef08b1812..1c5cda0a3 100644 --- a/module/src/debug/package_config.rs +++ b/module/src/debug/package_config.rs @@ -1,14 +1,5 @@ -//! Package Config lever: an operator stand-in for the seeding half of -//! `crate::package_config`, until an in-tree Package calls `ensure_package_config_default` for -//! real. A thin `?`-wrapper over that fn, so a live node can be seeded and re-seeded from the -//! runbook without a Package present, the same role `debug/encounter.rs`'s levers play for -//! `crate::encounter`'s package-facing primitives. - use spacetimedb::{reducer, ReducerContext}; -/// Seed `(package_name, key) = value` if absent (`package_config::ensure_package_config_default`). -/// Idempotent: re-running this never overwrites a value already set, whether that value came from a -/// prior seed or from an Operator's own `set_package_config` edit. #[reducer] pub(crate) fn debug_seed_package_config( ctx: &ReducerContext, diff --git a/module/src/debug/readout.rs b/module/src/debug/readout.rs index ec3d21839..ca55e94bf 100644 --- a/module/src/debug/readout.rs +++ b/module/src/debug/readout.rs @@ -1,90 +1,45 @@ -//! The combat-readback scratchpad table + its two writers: `debug_compute_swing` (melee) and -//! `debug_compute_spell` (spell crit/damage). Both write `game_debug_readout` so the harness can -//! assert combat-stat features via SQL with no live fight/cast. - use spacetimedb::{reducer, table, ReducerContext, Table}; use crate::{game_spell, game_spell_effect, game_world_entity}; -/// A read-only scratchpad the combat-readback harness reads via SQL (the keystone that flips -/// combat-stat features from "needs a finicky live kill" to machine-solo). `debug_compute_swing` -/// writes the deterministic swing profile here; the harness asserts on it without a fight. Public so -/// `spacetime sql` can read it; debug-only (the whole module is behind `debug_reducers`). [server] #[table(accessor = game_debug_readout, public)] pub struct DebugReadout { #[primary_key] - pub key: String, // a single "swing" slot today; keyed so future readouts can coexist + pub key: String, pub base_min: u32, pub base_max: u32, pub mitigation_pct: u32, pub final_min: u32, pub final_max: u32, pub note: String, - /// The target's chance (basis points) to dodge the attacker's swing, from its EFFECTIVE agility - /// (base + any A_MOD_STAT(AGI) buff) — the avoidance readback. END-appended + defaulted so adding - /// it auto-migrates the existing row. #[default(0)] pub dodge_bp: u32, - /// The attacker's EFFECTIVE crit chance (basis points): `CRIT_BP` + any A_MOD_COMBAT(CRIT) aura — - /// the crit-rating readback. END-appended + defaulted so adding it auto-migrates the existing row. #[default(0)] pub crit_bp: u32, - /// The attacker's EFFECTIVE miss chance (basis points) vs the target: level-derived `miss_chance_bp` - /// MINUS any A_MOD_COMBAT(HIT) aura (hit rating reduces miss) — the hit-rating readback. END-appended - /// + defaulted so adding it auto-migrates the existing row. #[default(0)] pub hit_miss_bp: u32, - /// The target's chance (basis points) to PARRY the attacker's swing: `PARRY_BP` floor plus the - /// defense-vs-weapon-skill term (a higher-level defender parries more), agility-independent — the - /// parry-scaling readback. END-appended + defaulted so adding it auto-migrates the existing row. #[default(0)] pub parry_bp: u32, - /// The attacker's EFFECTIVE swing interval (ms): `base_attack_time_ms` adjusted by any - /// `A_MOD_SPEED(SPEED_SWING)` aura (haste shortens, an attack-speed slow lengthens) — the swing-speed - /// readback. END-appended + defaulted. #[default(0)] pub attack_time_ms: u32, - /// SPELL crit readbacks (written by `debug_compute_spell` to the "spell" row). The caster's spell - /// crit chance (bp) = `SPELL_CRIT_BP` + any A_MOD_COMBAT(CRIT) aura. END-appended + defaulted. #[default(0)] pub spell_crit_bp: u32, - /// The spell's NON-crit damage (post spell-power scaling + magic resistance) — the baseline hit. - /// END-appended + defaulted. #[default(0)] pub spell_hit_normal: u32, - /// The spell's CRIT damage (×1.5 of the scaled base, then magic resistance) — proves the crit fold. - /// END-appended + defaulted. #[default(0)] pub spell_hit_crit: u32, - /// The TARGET's incoming-damage modifier (signed %, A_MOD_DAMAGE_TAKEN) folded into the swing's - /// final_min/final_max — so a defensive cooldown like Shield Wall (−75) shows the swing landing for - /// 25% here, with no fight. 0 = no such aura (final_min/max are armor-only). END-appended + defaulted. #[default(0)] pub damage_taken_pct: i32, - /// The TARGET's chance (basis points) to BLOCK the attacker's swing: 0 unless it has a shield - /// equipped, else `block_chance_bp` (the defense-vs-weapon-skill band). The block-chance readback, - /// the avoidance/mitigation twin of `parry_bp`. END-appended + defaulted (auto-migrates). [server] #[default(0)] pub block_bp: u32, - /// The TARGET's flat shield BLOCK VALUE (the damage a blocked swing absorbs): the shield's base - /// `block_value` + Str/20, or 0 if unshielded — so a shield's effect is verifiable with no fight. - /// END-appended + defaulted (auto-migrates). [server] #[default(0)] pub block_value: u32, - /// The ATTACKER's effective weapon skill (equipped weapon's line, else level*5) and the TARGET's - /// effective defense skill (else level*5). Their difference drives the miss/dodge/parry/block bands — - /// the skill readback, so a below-cap skill set via `debug_set_skill` is verifiable with no fight. - /// END-appended + defaulted (auto-migrates). [server] #[default(0)] pub attacker_weapon_skill: u32, #[default(0)] pub defender_defense_skill: u32, } -/// Compute the SERVER's swing profile for `attacker` vs `target` (the exact `swing_range_ctx` + -/// `armor_mitigation_pct` live combat uses) and write it to `game_debug_readout` — so combat-stat -/// features (functional armor, weapon damage, …) are verifiable via SQL, with NO live kill. Keyed by -/// "swing" (each call overwrites). No-op if either guid is absent. #[reducer] pub fn debug_compute_swing(ctx: &ReducerContext, attacker_guid: u64, target_guid: u64) { let entities = ctx.db.game_world_entity(); @@ -95,45 +50,28 @@ pub fn debug_compute_swing(ctx: &ReducerContext, attacker_guid: u64, target_guid return; }; let (bmin, bmax) = crate::combat::swing_range_ctx(ctx, &attacker); - // EFFECTIVE armor (base + any A_MOD_RESISTANCE(armor) aura) — the exact value live combat mitigates - // against, so an armor buff shows up in mitigation_pct here without a fight. let eff_armor = crate::combat::effective_armor(ctx, &target); let p = crate::combat::swing_profile(bmin, bmax, eff_armor, attacker.level); - // EFFECTIVE crit/miss (base + any A_MOD_COMBAT(CRIT)/(HIT) aura on the attacker) — the exact bands - // roll_swing builds the attack table from, so a crit/hit-rating buff shows up here without a fight. let crit_bp = crate::combat::effective_crit_bp(ctx, &attacker); - // The skill difference (defender defense skill − attacker weapon skill) feeding the skill-based bands, - // exactly as roll_swing computes it; plus the raw weapon/defense skill values for the readback. The - // defense skill now folds A_MOD_COMBAT(COMBAT_DEFENSE) auras (Anticipation), so it shifts sd here. let sd = crate::skill::skill_diff_ctx(ctx, &attacker, &target); let attacker_weapon_skill = crate::skill::effective_weapon_skill(ctx, &attacker); let defender_defense_skill = crate::skill::effective_defense_skill(ctx, &target); let hit_miss_bp = crate::combat::effective_miss_bp(ctx, &attacker, sd); - // Defender dodge/parry/block bands — the EXACT effective bands roll_swing uses (the agility/skill base - // PLUS any A_MOD_COMBAT(DODGE/PARRY/BLOCK) talent aura), so a Deflection/Shield-Spec buff shows up here - // without a fight. Block is shielded-only. let dodge_bp = crate::combat::effective_dodge_bp(ctx, &target, sd); let parry_bp = crate::combat::effective_parry_bp(ctx, &target, sd); let block_value = crate::combat::effective_block_value(ctx, &target); let block_bp = crate::combat::effective_block_bp(ctx, &target, sd, block_value); - // Effective swing interval (ms): base attack time shortened by any melee-haste aura on the attacker. let attack_time_ms = crate::combat::effective_swing_time(ctx, &attacker); - // Outgoing-damage modifier on the ATTACKER (stance + A_MOD_COMBAT(COMBAT_DMG_DONE) — e.g. Curse of - // Weakness) folded into the armor-mitigated range FIRST, exactly as resolve_swing/apply_target_damage - // apply it, so a cursed attacker's swing readout shows the reduced hit — server-verifiable with no fight. let damage_done_pct = crate::spell::stance_damage_done_pct(attacker.stance) + crate::spell::combat_field_bonus(ctx, attacker_guid, crate::spell::COMBAT_DMG_DONE); let outgoing_min = crate::spell::apply_damage_pct(p.final_min, damage_done_pct); let outgoing_max = crate::spell::apply_damage_pct(p.final_max, damage_done_pct); - // Incoming-damage modifier on the TARGET (A_MOD_DAMAGE_TAKEN — e.g. Shield Wall −75%) folded in NEXT, - // exactly as live combat applies it after armor (combat tick_melee). So a shielded target's swing - // readout shows the reduced hit — server-verifiable with no fight. let damage_taken_pct = crate::spell::damage_taken_bonus(ctx, target_guid); let final_min = crate::spell::apply_damage_pct(outgoing_min, damage_taken_pct); let final_max = crate::spell::apply_damage_pct(outgoing_max, damage_taken_pct); let readouts = ctx.db.game_debug_readout(); let key = "swing".to_string(); - readouts.key().delete(&key); // upsert + readouts.key().delete(&key); readouts.insert(DebugReadout { key, base_min: p.base_min, @@ -151,7 +89,7 @@ pub fn debug_compute_swing(ctx: &ReducerContext, attacker_guid: u64, target_guid parry_bp, attack_time_ms, damage_taken_pct, - spell_crit_bp: 0, // melee row — spell fields unused + spell_crit_bp: 0, spell_hit_normal: 0, spell_hit_crit: 0, block_bp, @@ -161,12 +99,6 @@ pub fn debug_compute_swing(ctx: &ReducerContext, attacker_guid: u64, target_guid }); } -/// Compute the SERVER's SPELL hit profile for `caster` casting `spell_id` at `target` and write it to -/// `game_debug_readout` (key "spell") — so spell crit is verifiable via SQL with NO live cast/RNG. Reports -/// the caster's spell crit chance + the NORMAL and CRIT (×1.5) damage, both through the exact -/// spell-power-scale + magic-resistance path `apply_effect`'s E_DAMAGE arm uses (die variance excluded — -/// the readout uses the effect's authored `base_points` so the crit ratio is exact). Uses the spell's -/// FIRST E_DAMAGE effect. No-op if a guid / the spell / a damage effect is absent. Each call overwrites. #[reducer] pub fn debug_compute_spell( ctx: &ReducerContext, @@ -184,10 +116,7 @@ pub fn debug_compute_spell( let Some(hdr) = ctx.db.game_spell().spell_id().find(spell_id) else { return; }; - // Binary spell-miss chance for this caster→target level pair — surfaced in the note so it's - // server-verifiable via SQL with no live cast (no readout column needed). let spell_miss_bp = crate::spell::spell_miss_chance_bp(caster.level, target.level); - // The spell's first direct-damage (E_DAMAGE) effect — the one the crit fold applies to. let Some(eff) = ctx .db .game_spell_effect() @@ -197,8 +126,6 @@ pub fn debug_compute_spell( else { return; }; - // The SAME scale → crit → resist pipeline as the live E_DAMAGE arm, minus the random die + crit roll: - // use the authored base_points so NORMAL vs CRIT is an exact ×1.5 readback. let sp = crate::spell::spell_power(ctx, caster_guid, hdr.school_mask); let scaled = crate::spell::compose_magnitude(eff.base_points, sp, crate::spell::SPELL_POWER_COEFF_PCT); @@ -210,7 +137,7 @@ pub fn debug_compute_spell( let spell_crit_bp = crate::spell::spell_crit_bp(ctx, caster_guid); let readouts = ctx.db.game_debug_readout(); let key = "spell".to_string(); - readouts.key().delete(&key); // upsert + readouts.key().delete(&key); readouts.insert(DebugReadout { key, base_min: 0, @@ -227,11 +154,11 @@ pub fn debug_compute_spell( hit_miss_bp: 0, parry_bp: 0, attack_time_ms: 0, - damage_taken_pct: 0, // spell row — the swing-row field + damage_taken_pct: 0, spell_crit_bp, spell_hit_normal: normal.max(0) as u32, spell_hit_crit: crit.max(0) as u32, - block_bp: 0, // spell row — the swing-row fields + block_bp: 0, block_value: 0, attacker_weapon_skill: 0, defender_defense_skill: 0, diff --git a/module/src/debug/runtime_script.rs b/module/src/debug/runtime_script.rs index 29530526c..c3f7bb7bb 100644 --- a/module/src/debug/runtime_script.rs +++ b/module/src/debug/runtime_script.rs @@ -1,19 +1,7 @@ -//! Operator lever for the Runtime Script Host: run one Runtime Script against the live world. -//! -//! Package storage and event bindings are separate work, so the source arrives as a reducer -//! argument. This is the only path that exercises the host against real gameplay operations -//! instead of a Fake, which makes it the way to eyeball the host on a realm. - use spacetimedb::{log, reducer, ReducerContext}; use crate::runtime_script::{run_event, CoreEffects, EntityView, RuntimeScript, ScriptEvent}; -/// Run `source` as a Runtime Script for `event`, with `actor_guid` and `target_guid` as the -/// event's participants. Commits what the script stages if it succeeds; refuses with the bounded -/// diagnostic if it does not. -/// -/// A guid of 0 — or one naming nothing in the world — leaves that participant absent, which is how -/// an operator exercises the "no actor" and "no target" paths a script has to handle. #[reducer] pub(crate) fn debug_run_runtime_script( ctx: &ReducerContext, @@ -47,8 +35,6 @@ pub(crate) fn debug_run_runtime_script( match diagnostics.first() { Some(diagnostic) => Err(diagnostic.to_string()), None => { - // The compilation count is how an operator sees the compiler cache working: call this - // twice with the same source and it must not move. log::info!( "debug_run_runtime_script: `{script_name}` on `{}` committed \ ({compilations} chunks compiled since this module instance started)", diff --git a/module/src/duel.rs b/module/src/duel.rs index ea979abbb..f8371ddd9 100644 --- a/module/src/duel.rs +++ b/module/src/duel.rs @@ -1,5 +1,3 @@ -//! Server-authoritative Duel lifecycle and its recipient-keyed gateway relay. - use spacetimedb::{reducer, table, Identity, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::game_world_entity; @@ -14,7 +12,6 @@ pub(crate) const DUEL_BOUNDARY_YARDS: f32 = 50.0; pub(crate) const FLEE_GRACE_MICROS: i64 = 10_000_000; pub(crate) const REQUEST_TIMEOUT_MICROS: i64 = 60_000_000; -/// One live Duel, indexed from either participant. Clients learn it only through [`DuelEvent`]. #[table( accessor = game_duel, index(accessor = by_initiator, btree(columns = [initiator_guid])), @@ -37,15 +34,11 @@ pub struct Duel { pub flag_orientation: f32, pub state: u8, pub countdown_deadline_micros: i64, - // Reserved by the boundary slice. End-appending later would be migration-safe, but landing - // the fields with the lifecycle row lets both participants share one authoritative timer. pub initiator_out_of_bounds_deadline_micros: i64, pub challenged_out_of_bounds_deadline_micros: i64, pub created_at_micros: i64, } -/// One lifecycle edge for one recipient. The full Duel snapshot makes every packet projection -/// independent of the private row, including the completion edge emitted after that row is gone. #[table( accessor = game_duel_event, public, @@ -77,7 +70,6 @@ pub struct DuelEvent { pub loser_name: String, } -/// The module clock that advances accepted Duels after their three-second countdown. #[table(accessor = game_duel_schedule, scheduled(tick_duels))] pub struct DuelSchedule { #[primary_key] @@ -137,7 +129,6 @@ impl From<&crate::WorldEntity> for RequestParticipant { } } -/// Pure request gate. The reducer supplies membership facts from both Duel indexes. pub(crate) fn request_verdict( initiator: Option, challenged: Option, @@ -159,7 +150,6 @@ pub(crate) fn request_verdict( if initiator.map_id != challenged.map_id || initiator.instance_id != challenged.instance_id { return Err(RequestDenied::DifferentPartition); } - // Vanilla only duels within one team; same race-team rule as the mail gate. if !lyracore_shared::faction::same_team(initiator.race, challenged.race) { return Err(RequestDenied::CrossFaction); } @@ -169,7 +159,6 @@ pub(crate) fn request_verdict( Ok(()) } -/// The Duel containing `guid`, from either participant index. pub(crate) fn duel_involving(ctx: &ReducerContext, guid: u64) -> Option { let duels = ctx.db.game_duel(); duels @@ -179,13 +168,10 @@ pub(crate) fn duel_involving(ctx: &ReducerContext, guid: u64) -> Option { .or_else(|| duels.by_challenged().filter(&guid).next()) } -/// Whether these two characters are the two sides of the same active Duel. This is the sole -/// friendly-fire exception: requested and countdown Duels deliberately do not authorize damage. pub(crate) fn active_opponents(ctx: &ReducerContext, attacker_guid: u64, target_guid: u64) -> bool { active_duel_between(ctx, attacker_guid, target_guid).is_some() } -/// The active Duel shared by this exact pair, if any. pub(crate) fn active_duel_between( ctx: &ReducerContext, attacker_guid: u64, @@ -204,9 +190,7 @@ fn active_pair(duel: &Duel, attacker_guid: u64, target_guid: u64) -> bool { fn duel_flag_guid(initiator_guid: u64, duel_id: u64) -> Option { const HIGHGUID_GAMEOBJECT: u64 = 0xF110u64 << 48; const DUEL_FLAG_BAND: u64 = 1u64 << 45; - // Character GUID range slots are realm-wide and Duel ids are shard-local. Packing both makes - // the arbiter unique across shards and across successive Duels, so a stale client action can - // never name a later Duel. Refuse after a billion Duels on one shard instead of wrapping. + // Shard-local Duel ids need the realm-wide GUID slot to make their arbiters unique. let range_size = crate::realm_core::GUID_RANGE_SIZE; if duel_id >= range_size { return None; @@ -261,8 +245,6 @@ fn push_both(ctx: &ReducerContext, duel: &Duel, kind: u8) { push_event(ctx, duel, duel.challenged_guid, kind, 0, 0, 0); } -/// Spell effect 83's state mutation. Invalid requests are benign no-ops: the cast itself still -/// resolves normally, but no Duel or duel flag is created. pub(crate) fn request_duel( ctx: &ReducerContext, initiator_guid: u64, @@ -318,7 +300,6 @@ pub(crate) fn accept_allowed(duel: &Duel, actor_guid: u64, flag_guid: u64) -> bo && duel.flag_guid == flag_guid } -/// Accept only the proposal addressed to the challenged character and only for its exact arbiter. pub(crate) fn accept_duel(ctx: &ReducerContext, actor_guid: u64, flag_guid: u64) { let Some(mut duel) = duel_involving(ctx, actor_guid) else { return; @@ -466,7 +447,6 @@ fn apply_boundary_transition( } } -/// The one teardown path. Deleting before emitting makes repeated calls idempotent. pub(crate) fn complete_duel( ctx: &ReducerContext, duel_id: u64, @@ -519,8 +499,6 @@ fn cancellation_result(duel: &Duel, actor_guid: u64) -> (u8, u64, u64) { (duel_completion_kind::FLED, winner, actor_guid) } -/// Before activation cancel means interruption. Once active it is a fled result, with the other -/// participant already recorded as winner for the combat slice to project. pub(crate) fn cancel_duel(ctx: &ReducerContext, actor_guid: u64, flag_guid: u64) { let Some(duel) = duel_involving(ctx, actor_guid) else { return; @@ -564,8 +542,6 @@ pub fn tick_duels(ctx: &ReducerContext, _schedule: DuelSchedule) { continue; } - // Tie rule: if both flee deadlines expire on the same tick, the initiator is - // processed first and loses. let initiator_transition = boundary_transition( outside_duel_boundary( &duel, @@ -603,7 +579,7 @@ mod tests { dead: false, map_id, instance_id, - race: 1, // Human + race: 1, } } @@ -673,7 +649,7 @@ mod tests { fn request_gate_rejects_cross_faction_pairs() { let a = entity(1, 0, 4); let mut b = entity(2, 0, 4); - b.race = 2; // Orc + b.race = 2; assert_eq!( request_verdict(Some(a), Some(b), false, false), Err(RequestDenied::CrossFaction) diff --git a/module/src/encounter.rs b/module/src/encounter.rs index 58e34ab01..ef6cfaa64 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -1,34 +1,3 @@ -//! Encounter kernel, the mangos `InstanceScript` analogue as a PACKAGE-SUPPORT -//! layer. Ships ZERO encounter content: dungeons' choreography lives in drop-in packages -//! (`packages/dungeons/`, first consumer: 227 Deadmines), which consume three things from here: -//! -//! 1. **State** — `game_encounter_state`, one row per `(instance_id, encounter_id)` (the playbook's -//! PK-pair pattern: synthetic `#[auto_inc]` PK + a btree on the pair, like -//! `game_player_reputation`'s `(character_guid, faction_id)`). `state` is the mangos 4-state -//! machine (NotStarted/InProgress/Done/Failed), `payload` the `SetData`/`GetData` u32. NOT -//! gateway-subscribed — packages and core read it server-side only (verified absent from -//! `gateway/src/stdb/connection.rs`'s subscription list; nothing client-side needs it). -//! 2. **Hooks** — three new notify-hook events on the EXISTING build.rs marker-scan substrate -//! (`game_hook!` + `HOOK_EVENTS` row + payload struct in hooks.rs — NOT a second mechanism): -//! `on_creature_death` (entry-carrying, instance-stamped; fired from `combat::kill_creature`), -//! `on_go_used` (fired from `gameobject::apply_use_gameobject`'s success exit), and -//! `on_hp_threshold` (fired from THIS module's `encounter_hp_probe`, itself a `game_hook!` on -//! the existing `on_damage_taken` event — the damage chokepoint needed no new dispatch line). -//! `on_hp_threshold` fires ONCE per watched threshold per instance (heal-and-re-cross never -//! re-fires until reset), deduped via a RESERVED `game_encounter_state` row (see -//! [`hp_fired_key`]). ZERO-COST when unused: the probe's first instruction bails on the -//! compile-time-const `GAME_HOOKS_ON_HP_THRESHOLD.is_empty()`, so a tree with no registered -//! consumer pays one predictable branch per damage event and nothing else. -//! 3. **Primitives** — thin package-callable choreography verbs: [`open_door`], [`spawn_wave`] -//! (with despawn-on-reset bookkeeping via `game_encounter_spawn`), [`equip_swap`] (durable -//! virtual-item display projection), [`move_to_point`] (one -//! leg through `creatures::tick::emit_move_spline`; the creature tick advances the stored -//! position), and [`encounter_reset`]. -//! -//! Reset/sweep: the instance reap deletes per-instance kernel state via -//! [`sweep_encounter_state`], called from `instance::teardown_instance_inner` right before the -//! `game_instance` row itself goes (the splice point is documented on the fn). [server] - use spacetimedb::{table, ReducerContext, Table}; use lyracore_shared::constants; @@ -40,7 +9,6 @@ use crate::{ game_instance, game_world_entity, }; -/// Package-owned encounter selected by the EventAI import boundary. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub enum EncounterBinding { BlackfathomDeepsKelris, @@ -100,7 +68,6 @@ impl EncounterBinding { } } -/// Named notification delivered to the encounter package that owns [`EncounterBinding`]. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub enum EncounterSignal { Begin, @@ -111,8 +78,6 @@ pub enum EncounterSignal { SendMandokirDownstairs, } -/// Route one EventAI notification through encounter authority. The binding checks both package -/// ownership and map scope before any state is changed. pub(crate) fn notify_from_eventai( ctx: &ReducerContext, source_guid: u64, @@ -155,8 +120,6 @@ pub(crate) fn notify_from_eventai( )); } let Some(handler) = package_handler_for(binding, crate::GAME_ENCOUNTER_PACKAGES)? else { - // The mangos SetData-without-instance-script semantic: the DATA names an encounter - // boundary, but no installed Package claims it — the notification has no consumer. spacetimedb::log::warn!( "encounter binding {binding:?} has no installed package authority — {signal:?} not consumed" ); @@ -196,10 +159,6 @@ fn encounter_state_for_signal(signal: EncounterSignal) -> Option { } } -// =========================================================================================== -// Encounter state machine values (mangos EncounterState analogue) -// =========================================================================================== - pub const ENCOUNTER_NOT_STARTED: u8 = 0; #[allow(dead_code, reason = "Packages select the encounter states they need")] pub const ENCOUNTER_IN_PROGRESS: u8 = 1; @@ -208,25 +167,11 @@ pub const ENCOUNTER_DONE: u8 = 2; #[allow(dead_code, reason = "Packages select the encounter states they need")] pub const ENCOUNTER_FAILED: u8 = 3; -/// Encounter ids with this bit set are RESERVED for kernel bookkeeping (today: the per-entry -/// HP-threshold fired-marks, [`hp_fired_key`]). Packages must key their encounters below it — -/// vanilla creature entries are < 2^24, so the namespaces can never collide. +// Encounter ids with this bit set belong to kernel bookkeeping. pub const RESERVED_ENCOUNTER_ID_BIT: u32 = 0x8000_0000; -/// Sentinel for "no HP threshold has fired yet" in a reserved dedup row's `payload` — one past the -/// highest possible percentage, so every watch `pct` (0..=100) compares strictly below it. pub const HP_FIRED_NONE: u32 = 101; -// =========================================================================================== -// Tables (all server-side only — none is gateway-subscribed) -// =========================================================================================== - -/// Per-(instance, encounter) state + scratch data — the `InstanceScript::SetData/GetData` store. -/// Logical key `(instance_id, encounter_id)` via an `#[auto_inc]` PK + the `by_pair` btree (the -/// PK-pair pattern; see `game_player_reputation`). A missing row reads as -/// (`ENCOUNTER_NOT_STARTED`, payload 0). `encounter_id`s with [`RESERVED_ENCOUNTER_ID_BIT`] set -/// are kernel bookkeeping rows (HP dedup), not package encounters. Swept per instance by -/// [`sweep_encounter_state`]. [server] #[table(accessor = game_encounter_state, index(accessor = by_pair, btree(columns = [instance_id, encounter_id])))] pub struct EncounterState { #[primary_key] @@ -238,10 +183,6 @@ pub struct EncounterState { pub payload: u32, } -/// A registered HP-threshold watch: "fire `on_hp_threshold` when a creature of `entry` drops to -/// `pct`% or below". GLOBAL (template-scoped, not per-instance — the per-instance once-only dedup -/// lives in the reserved `game_encounter_state` rows), registered idempotently by packages via -/// [`watch_hp_threshold`]. Never swept with an instance. [server] #[table(accessor = game_encounter_hp_watch, index(accessor = by_entry, btree(columns = [entry])))] pub struct EncounterHpWatch { #[primary_key] @@ -251,12 +192,6 @@ pub struct EncounterHpWatch { pub pct: u8, } -/// Despawn-on-reset bookkeeping for [`spawn_wave`]: one row per wave-spawned creature, so -/// [`encounter_reset`] / [`sweep_encounter_state`] can tear the adds (entity + spawn row + legs + -/// loot) back down. CRITICAL: wave adds get real `game_creature_spawn` rows (so corpse decay works) -/// which are NOT instance-tagged — an untracked leftover spawn row would eventually respawn into -/// instance 0 (`pass_respawn` builds at instance 0), so every reset path MUST delete via this -/// table. [server] #[table(accessor = game_encounter_spawn, index(accessor = by_instance, btree(columns = [instance_id])))] pub struct EncounterSpawn { #[primary_key] @@ -277,23 +212,14 @@ pub struct EncounterEquip { pub ranged: u32, } -// =========================================================================================== -// Pure decisions (unit-tested below) -// =========================================================================================== - -/// The reserved `encounter_id` carrying the per-instance HP-threshold fired-mark for creature -/// `entry` (its `payload` = lowest pct fired so far, [`HP_FIRED_NONE`] when virgin). Pure. pub(crate) fn hp_fired_key(entry: u32) -> u32 { RESERVED_ENCOUNTER_ID_BIT | entry } -/// Is `encounter_id` in the kernel-reserved namespace? Pure. pub(crate) fn is_reserved_encounter_id(encounter_id: u32) -> bool { encounter_id & RESERVED_ENCOUNTER_ID_BIT != 0 } -/// Current HP as an integer percentage (floor), u64 math so `health * 100` can never wrap. -/// `max == 0` (malformed row) reads as 100% — nothing fires. Pure. pub(crate) fn hp_pct(health: u32, max_health: u32) -> u32 { if max_health == 0 { return 100; @@ -301,13 +227,6 @@ pub(crate) fn hp_pct(health: u32, max_health: u32) -> u32 { ((health as u64 * 100) / max_health as u64) as u32 } -/// THE crossing-dedup decision: which watched thresholds fire now that the creature sits at -/// `new_pct`%, given `lowest_fired` (the lowest pct already fired this instance, or -/// [`HP_FIRED_NONE`]). A watch `w` fires iff `new_pct <= w` (crossed) AND `w < lowest_fired` -/// (never fired — this is the once-per-instance memory: a heal back above `w` and a re-drop -/// changes nothing, because `lowest_fired` only ratchets DOWN). A single huge hit crossing several -/// watches fires them all, ordered high→low (66 before 33 — phase order). Duplicate watch rows -/// collapse to one firing. Returns the fired pcts (desc). Pure — unit-tested. pub(crate) fn crossed_thresholds(watches: &[u8], lowest_fired: u32, new_pct: u32) -> Vec { let mut fired: Vec = watches .iter() @@ -319,33 +238,19 @@ pub(crate) fn crossed_thresholds(watches: &[u8], lowest_fired: u32, new_pct: u32 fired } -/// Wave-spawn guid: `HIGHGUID_UNIT` high bits + `entry` in 24..47 + a unique low 24 — the same -/// namespace layout the importer/seed/debug spawns use, so the 5875 client classifies the add as a -/// Unit and the low part can't collide with an existing spawn of the same entry. Pure. pub(crate) fn wave_guid(entry: u32, next_low: u64) -> u64 { const HIGHGUID_UNIT: u64 = 0xF130; (HIGHGUID_UNIT << 48) | ((entry as u64) << 24) | (next_low & 0x00FF_FFFF) } -/// Deterministic per-add spread around the wave anchor so a multi-add wave doesn't stack on one -/// point: a compact 3-wide grid, 2 yd pitch, row-major. Pure. #[allow(dead_code, reason = "retained Package API encounter choreography")] pub(crate) fn wave_offset(index: usize) -> (f32, f32) { (2.0 * ((index % 3) as f32 - 1.0), 2.0 * ((index / 3) as f32)) } -/// `[V]` The `game_gameobject.state` value that renders a DOOR/BUTTON as OPEN on the 5875 client. -/// The in-repo convention (gameobject.rs `toggle_state`'s test labels) is -/// 0 = closed/ready, 1 = open/used, and the gateway relays the raw value -/// (`set_gameobject_state(go.state)`); no live client in this sandbox to confirm the rendering — -/// if a live check shows doors inverted, flip THIS const (one place). #[allow(dead_code, reason = "retained Package API encounter state")] pub const DOOR_OPEN_STATE: u8 = 1; -// =========================================================================================== -// State accessors (SetData / GetData) -// =========================================================================================== - fn state_row(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> Option { ctx.db .game_encounter_state() @@ -354,7 +259,6 @@ fn state_row(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> Optio .next() } -/// Upsert the full row (internal — the public accessors preserve the other column). fn upsert_state_row( ctx: &ReducerContext, instance_id: u64, @@ -381,7 +285,6 @@ fn upsert_state_row( } } -/// Current state of `(instance_id, encounter_id)` — `ENCOUNTER_NOT_STARTED` when no row exists. #[allow(dead_code, reason = "retained Package API operation")] pub(crate) fn get_encounter_state(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> u8 { state_row(ctx, instance_id, encounter_id) @@ -406,7 +309,6 @@ pub(crate) fn set_encounter_state( Ok(()) } -/// `GetData`: the row's scratch u32 — 0 when no row exists. #[allow(dead_code, reason = "retained Package API encounter choreography")] pub(crate) fn get_encounter_data(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> u32 { state_row(ctx, instance_id, encounter_id) @@ -414,8 +316,6 @@ pub(crate) fn get_encounter_data(ctx: &ReducerContext, instance_id: u64, encount .unwrap_or(0) } -/// `SetData`: set the scratch u32, preserving `state`. Upserts. Same reserved-namespace refusal -/// as [`set_encounter_state`]. #[allow(dead_code, reason = "retained Package API operation")] pub(crate) fn set_encounter_data( ctx: &ReducerContext, @@ -433,15 +333,6 @@ pub(crate) fn set_encounter_data( Ok(()) } -// =========================================================================================== -// HP-threshold watch + probe -// =========================================================================================== - -/// Register "fire `on_hp_threshold` when a creature of `entry` reaches `pct`% or below" — the -/// package-side half of the hook (the `game_hook!(on_hp_threshold, ...)` handler is the other). -/// Idempotent (check-before-insert, the `learn_spell` grant idiom): packages typically call this -/// from their seed/fixture reducer or an `on_creature_spawn` handler, either of which may run -/// repeatedly. `pct` must be 1..=99 (0 is the death hook's job; 100 would fire on any scratch). #[allow(dead_code, reason = "retained Package API encounter choreography")] pub(crate) fn watch_hp_threshold(ctx: &ReducerContext, entry: u32, pct: u8) -> Result<(), String> { if !(1..=99).contains(&pct) { @@ -454,16 +345,12 @@ pub(crate) fn watch_hp_threshold(ctx: &ReducerContext, entry: u32, pct: u8) -> R } let t = ctx.db.game_encounter_hp_watch(); if t.by_entry().filter(&entry).any(|w| w.pct == pct) { - return Ok(()); // already registered + return Ok(()); } t.insert(EncounterHpWatch { id: 0, entry, pct }); Ok(()) } -/// Forget the per-instance fired-marks for `entry` so its thresholds can fire again — a package -/// calls this from its wipe/evade reset alongside [`encounter_reset`] (the boss healed to full; -/// the next attempt's crossings must re-fire). Instance-scoped: other instances' marks are -/// untouched. The instance reap sweeps these rows wholesale ([`sweep_encounter_state`]). #[allow(dead_code, reason = "retained Package API operation")] pub(crate) fn reset_hp_fired(ctx: &ReducerContext, instance_id: u64, entry: u32) { let t = ctx.db.game_encounter_state(); @@ -472,30 +359,18 @@ pub(crate) fn reset_hp_fired(ctx: &ReducerContext, instance_id: u64, entry: u32) } } -// The kernel's own damage-side probe, registered on the EXISTING `on_damage_taken` event via the -// same marker-scan substrate packages use — so the damage chokepoint (`spell::break_auras_on_damage`, -// where the target's post-damage health is already committed: every caller updates the row first, -// and same-transaction read-your-writes holds) needed NO new dispatch line. ZERO-COST GUARD: the -// first instruction bails on the compile-time-const registry — a tree where no package registered -// an `on_hp_threshold` handler pays one predictable branch per damage event, no table reads. crate::game_hook!(on_damage_taken, fn encounter_hp_probe(ctx, payload) { if crate::GAME_HOOKS_ON_HP_THRESHOLD.is_empty() { - return; // no consumer anywhere in the build — free. + return; } probe_hp_thresholds(ctx, payload.target_guid); }); -/// The ctx half of the HP-threshold hook: read the (already-committed) post-damage health, join it -/// against the entry's registered watches, dedup via the reserved state row, and fire -/// `on_hp_threshold` once per newly-crossed watch (high→low). The fired-mark is written BEFORE the -/// handlers run (mark-before-roll, playbook §2): a handler that itself deals damage re-enters this -/// probe with the ratchet already advanced, so it can never double-fire. +// Save fired thresholds before callbacks so reentrant damage cannot fire them twice. fn probe_hp_thresholds(ctx: &ReducerContext, target_guid: u64) { let Some(e) = ctx.db.game_world_entity().guid().find(target_guid) else { return; }; - // Creatures only (players/pets are never encounter bosses); `on_damage_taken` already implies - // the target survived, but a dead row is re-checked for safety. if e.is_player() || e.owner_guid != 0 || e.dead || e.max_health == 0 { return; } @@ -507,7 +382,7 @@ fn probe_hp_thresholds(ctx: &ReducerContext, target_guid: u64) { .map(|w| w.pct) .collect(); if watches.is_empty() { - return; // registered consumers exist, but not for this entry — one btree probe spent. + return; } let key = hp_fired_key(e.entry); let lowest_fired = state_row(ctx, e.instance_id, key) @@ -515,9 +390,8 @@ fn probe_hp_thresholds(ctx: &ReducerContext, target_guid: u64) { .unwrap_or(HP_FIRED_NONE); let fired = crossed_thresholds(&watches, lowest_fired, hp_pct(e.health, e.max_health)); let Some(&new_lowest) = fired.last() else { - return; // nothing newly crossed + return; }; - // Ratchet FIRST (see fn doc), then notify. upsert_state_row( ctx, e.instance_id, @@ -585,7 +459,6 @@ pub(crate) fn open_door( Ok(opened) } -// A wave's full placement (entries, count, anchor position, instance, encounter id). #[allow(clippy::too_many_arguments)] #[allow(dead_code, reason = "retained Package API encounter choreography")] pub(crate) fn spawn_wave( @@ -605,8 +478,6 @@ pub(crate) fn spawn_wave( spacetimedb::log::warn!("spawn_wave: no creature template {entry} — skipped"); continue; }; - // Unique low 24 bits: one past the current max for this entry (the debug_spawn_at_feet - // scheme). Recomputed per add — a wave of N identical entries gets consecutive lows. let next_low = ctx .db .game_creature_spawn() @@ -634,9 +505,9 @@ pub(crate) fn spawn_wave( u32::MAX as i64 * 1_000_000, )) .unwrap_or(ctx.timestamp), - despawn_at: crate::creatures::timer_never(ctx), // not armed until this add actually dies - movement_type: crate::creatures::MOVEMENT_IDLE, // holds its post until aggro - respawn_secs: u32::MAX, // "never" — see the fn doc + despawn_at: crate::creatures::timer_never(ctx), + movement_type: crate::creatures::MOVEMENT_IDLE, + respawn_secs: u32::MAX, life_seq: 0, source_guid: 0, instance_id, @@ -656,10 +527,6 @@ pub(crate) fn spawn_wave( guids } -/// Write a creature's virtual-item slots (Mr. Smite: sword+board → two-hander → dual maces). -/// Authored values are item entries; the durable row carries the corresponding display ids expected -/// by `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY`. Zero unequips a slot. Upserts; errs for a missing creature -/// or item template so a relay never reports success without an observable projection. pub(crate) fn equip_swap( ctx: &ReducerContext, creature_guid: u64, @@ -706,12 +573,6 @@ pub(crate) fn equip_swap( Ok(()) } -/// Send a creature on one movement leg to `(x, y, z)`, the Smite rack-run motion. The leg starts -/// where the client draws the mover now, and the creature tick advances the stored position along -/// it as along any other leg, so a reader mid-leg never sees the destination. The duration comes -/// from the snare-aware `combat::effective_move_speed` over the 2D distance. Errs for a missing, -/// player or dead mover. An immobilized mover is a no-op Ok. A mover the client already draws on -/// the point stops there and takes no new leg. pub(crate) fn move_to_point( ctx: &ReducerContext, creature_guid: u64, @@ -743,7 +604,6 @@ pub(crate) fn move_to_point( let (dx, dy) = (x - e.x, y - e.y); let dist = (dx * dx + dy * dy).sqrt(); if dist <= f32::EPSILON { - // End the old leg here, so a relay arrival sees a leg of its own land on the point. crate::creatures::tick::stop_where_rendered(ctx, &mut e); entities.guid().update(e); return Ok(()); @@ -765,11 +625,6 @@ pub(crate) fn move_to_point( Ok(()) } -/// Reset one encounter: state → `ENCOUNTER_NOT_STARTED` (payload zeroed — the `SetData` scratch is -/// per-attempt), and despawn this encounter's tracked wave spawns (entity + spawn row + legs + -/// corpse loot + equip + tracking row). Does NOT touch the HP fired-marks — those are keyed by -/// creature ENTRY, not encounter id, so the package's reset handler calls [`reset_hp_fired`] for -/// its boss entries alongside this (documented on that fn). #[allow(dead_code, reason = "retained Package API operation")] pub(crate) fn encounter_reset(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) { upsert_state_row(ctx, instance_id, encounter_id, ENCOUNTER_NOT_STARTED, 0); @@ -799,12 +654,6 @@ pub(crate) fn encounter_reset_full( } } -/// Tear down tracked wave spawns: the canonical creature-despawn checklist -/// ([`crate::creatures::despawn_creature_entity`] — engagement/threat/taunt lock, spline + motion -/// rows, the whole loot family, then the live entity, whose delete relays `SMSG_DESTROY_OBJECT`; -/// it may already be decay-deleted, which is fine), plus this path's OWN extras: the spawn row (the -/// row that would otherwise respawn into instance 0 — see [`EncounterSpawn`]), any equip row, and the -/// tracking row itself. fn despawn_tracked(ctx: &ReducerContext, tracked: &[EncounterSpawn]) { for t in tracked { crate::creatures::despawn_creature_entity(ctx, t.guid); @@ -814,15 +663,7 @@ fn despawn_tracked(ctx: &ReducerContext, tracked: &[EncounterSpawn]) { } } -/// Sweep EVERY kernel row for a dying instance: tracked wave spawns (full teardown — this is what -/// keeps their untagged `game_creature_spawn` rows from ever respawning into instance 0), all -/// `game_encounter_state` rows (package encounters AND the reserved HP fired-marks), and all -/// equip rows. HP WATCHES are global template config and deliberately survive. -/// -/// SPLICE POINT: the instance reap's population sweep calls -/// `crate::encounter::sweep_encounter_state(ctx, instance_id)` right next to its entity/corpse/GO-copy -/// deletes, before the `game_instance` row itself goes (`instance::teardown_instance_inner`). Also -/// exercised directly by the debug-feature lever `debug_sweep_encounter_state`. +// Delete tracked spawn rows too so Instance teardown cannot respawn them in the open world. pub(crate) fn sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) { let tracked: Vec = ctx .db @@ -852,18 +693,10 @@ pub(crate) fn sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) { } } -// =========================================================================================== -// Tests — every pure decision above -// =========================================================================================== - #[cfg(test)] mod tests { use super::*; - // The binding catalog is data-derived (dump-verified `ACTION_T_SET_INSTANCE_DATA` rows), so - // it ships with the engine even when no installed Package claims a binding — an unclaimed - // notification is a logged no-op, verified below. - #[test] fn package_lookup_skips_unclaimed_and_refuses_duplicate_authority() { fn handler( @@ -875,8 +708,6 @@ mod tests { } let binding = EncounterBinding::BlackfathomDeepsKelris; - // Unclaimed is a valid install state (the data names the boundary, no Package consumes - // it), so the lookup yields nothing rather than an error — notify no-ops on it. assert!(matches!(package_handler_for(binding, &[]), Ok(None))); let claimed = [(binding, handler as EncounterPackageHandler)]; @@ -893,20 +724,15 @@ mod tests { #[test] fn crossing_fires_each_watch_once_high_to_low_and_never_refires_after_heal_redrop() { - // Smite's 66/33 pattern. First hit to 60%: 66 fires, 33 doesn't. let watches = [66u8, 33u8]; assert_eq!(crossed_thresholds(&watches, HP_FIRED_NONE, 60), vec![66]); - // Ratchet now 66. Heal to 90%, re-drop to 60%: NOTHING re-fires (once per instance). assert_eq!(crossed_thresholds(&watches, 66, 60), Vec::::new()); - // Drop on to 30%: only 33 fires. assert_eq!(crossed_thresholds(&watches, 66, 30), vec![33]); - // Ratchet 33: nothing left. assert_eq!(crossed_thresholds(&watches, 33, 5), Vec::::new()); } #[test] fn one_giant_hit_crossing_several_watches_fires_all_desc_and_dedups_duplicates() { - // 100% → 10% in one hit, with a duplicate 66 row registered twice. let watches = [33u8, 66u8, 66u8, 90u8]; assert_eq!( crossed_thresholds(&watches, HP_FIRED_NONE, 10), @@ -917,36 +743,31 @@ mod tests { #[test] fn crossing_boundary_is_at_or_below_and_higher_watch_never_fires_after_lower_ratchet() { let watches = [66u8]; - // Exactly 66% counts as crossed (mangos HealthBelowPct-with-equal semantics). assert_eq!(crossed_thresholds(&watches, HP_FIRED_NONE, 66), vec![66]); - // 67% does not. assert_eq!( crossed_thresholds(&watches, HP_FIRED_NONE, 67), Vec::::new() ); - // A 66 watch with the ratchet already at 33 (fired during a burst) can never fire late. assert_eq!(crossed_thresholds(&[66u8, 33u8], 33, 20), Vec::::new()); } #[test] fn hp_pct_floors_uses_u64_math_and_treats_zero_max_as_full() { - assert_eq!(hp_pct(664, 1000), 66); // floor, not round + assert_eq!(hp_pct(664, 1000), 66); assert_eq!(hp_pct(0, 1000), 0); assert_eq!(hp_pct(1000, 1000), 100); - assert_eq!(hp_pct(u32::MAX, u32::MAX), 100); // health*100 would wrap u32 - assert_eq!(hp_pct(5, 0), 100); // malformed row → nothing fires + assert_eq!(hp_pct(u32::MAX, u32::MAX), 100); + assert_eq!(hp_pct(5, 0), 100); } #[test] fn reserved_hp_key_namespace_is_disjoint_from_package_encounter_ids() { - // Every vanilla creature entry (< 2^24) maps into the reserved namespace... assert!(is_reserved_encounter_id(hp_fired_key(1))); - assert!(is_reserved_encounter_id(hp_fired_key(646))); // Mr. Smite + assert!(is_reserved_encounter_id(hp_fired_key(646))); assert_eq!(hp_fired_key(646), 0x8000_0000 | 646); assert!(!is_reserved_encounter_id(0)); assert!(!is_reserved_encounter_id(1)); assert!(!is_reserved_encounter_id(0x7FFF_FFFF)); - // Distinct entries map to distinct keys (bit-or, no folding). assert_ne!(hp_fired_key(639), hp_fired_key(646)); } @@ -956,8 +777,6 @@ mod tests { assert_eq!(ENCOUNTER_IN_PROGRESS, 1); assert_eq!(ENCOUNTER_DONE, 2); assert_eq!(ENCOUNTER_FAILED, 3); - // The virgin sentinel strictly exceeds any pct a watch may hold (1..=99) AND 100, - // so `w < HP_FIRED_NONE` is true for every legal watch — the first crossing always fires. const { assert!(HP_FIRED_NONE > 100) }; } @@ -972,7 +791,7 @@ mod tests { #[test] fn wave_guid_carries_highguid_unit_entry_bits_and_a_bounded_low() { - let g = wave_guid(636, 7); // a Defias Miner add, low 7 + let g = wave_guid(636, 7); assert_eq!( g >> 48, 0xF130, @@ -980,7 +799,6 @@ mod tests { ); assert_eq!((g >> 24) & 0xFF_FFFF, 636); assert_eq!(g & 0xFF_FFFF, 7); - // A low that would overflow 24 bits is masked, never corrupting the entry bits. assert_eq!((wave_guid(636, 0x0100_0000 + 5) >> 24) & 0xFF_FFFF, 636); assert_eq!(wave_guid(636, 0x0100_0000 + 5) & 0xFF_FFFF, 5); } @@ -990,8 +808,7 @@ mod tests { assert_eq!(wave_offset(0), (-2.0, 0.0)); assert_eq!(wave_offset(1), (0.0, 0.0)); assert_eq!(wave_offset(2), (2.0, 0.0)); - assert_eq!(wave_offset(3), (-2.0, 2.0)); // second row - // No two of the first 9 collide. + assert_eq!(wave_offset(3), (-2.0, 2.0)); let pts: Vec<(i32, i32)> = (0..9) .map(|i| { let (x, y) = wave_offset(i); diff --git a/module/src/exploration.rs b/module/src/exploration.rs index 7a5017d3e..2ce57925c 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -1,10 +1,3 @@ -//! Exploration / discovery XP (the "Discovered: " toast + map fog). Entering a subzone for the FIRST time awards discovery XP and sends TWO packets — this is -//! retail parity, not a duplicate (see `check_area_exploration`'s doc comment for the -//! cited source): `SMSG_EXPLORATION_EXPERIENCE` (the toast + fog-clear, off the -//! `game_character_explored` insert) and `SMSG_LOG_XPGAIN` (the "+N experience" text, off a non-kill -//! `game_xp_event` insert — the same relay kill/quest XP use). Hooked into `movement_update` -//! (world.rs) on a grid-cell crossing, so it runs ~once per 50 yd, not per heartbeat. - use spacetimedb::{table, ReducerContext, Table}; #[cfg(feature = "debug_reducers")] @@ -12,23 +5,6 @@ use crate::game_world_entity; use crate::xp::XpEvent; use crate::{game_xp_event, WorldEntity}; -/// Per-character record of an explored area — the dedup + persistence store so a subzone awards -/// discovery XP exactly ONCE, ever, AND the gateway's source for the client fog. PUBLIC so the gateway -/// can (a) `on_insert`-relay a fresh discovery → `SMSG_EXPLORATION_EXPERIENCE` + the map fog-clear, and -/// (b) read the full set on login to restore the fog bitmask. Keyed by `character_guid` (stable across -/// relogs) + indexed for the dedup + the gateway's per-character scan. `area_id`/`experience` ride along -/// for the discovery packet (the `area_bit` alone drives the fog). [entity] -/// -/// NO RLS FILTER, deliberately, and the scaling half of that is now solved on the GATEWAY side (292 -/// second pass): the per-player subscription is `... WHERE character_guid = ` (see -/// `gateway/src/stdb/subscriptions.rs`'s `explored_query`), so a session caches only its own fog rows -/// instead of every character's. That gets the row reduction a `#[client_visibility_filter]` would, -/// without adding an `owner_identity` column (every RLS filter in this module is -/// ` = :sender`, so adding one means an end-appended identity column + a restamp sweep + -/// a publish/gateway-restart lockstep). The gateway ALSO keeps its `character_guid == self` guard in -/// each `on_insert`: the -/// coordinator's leg subscribes this table globally (owner token, RLS-bypassed anyway), so that guard — -/// not the query — is what keeps one character's fog off another's client. #[table(accessor = game_character_explored, public, index(accessor = by_character, btree(columns = [character_guid])))] pub struct CharacterExplored { #[primary_key] @@ -36,50 +12,24 @@ pub struct CharacterExplored { pub id: u64, pub character_guid: u64, pub area_bit: i32, - /// The AreaTable id (game_area.id) — for SMSG_EXPLORATION_EXPERIENCE's "Discovered: ". #[default(0)] pub area_id: u32, - /// The discovery XP granted (0 for a non-XP area) — the number in the discovery packet. #[default(0)] pub experience: u32, } -// Character-owned sweep: exploration/discovery is durable per-character map-fog data, deleted along with -// its character (mirrors the reputation/skill/quest sweeps). Public (no `owner_identity`, no per-owner -// RLS) → `delete` only, no `restamp`. crate::character_owned!(delete, fn sweep_delete_game_character_explored(ctx, character_guid) { let ex = ctx.db.game_character_explored(); for r in ex.by_character().filter(&character_guid).collect::>() { ex.id().delete(r.id); } }); -// CROSS-DATABASE transport: the fog-of-war map is durable progression — losing it at a -// shard boundary would re-fog zones and re-pay discovery XP. `id` is a surrogate PK, re-minted. crate::character_owned!(transfer, fn sweep_transfer_game_character_explored(ctx, character_guid, io) { table = game_character_explored, by = by_character, remint = id, }); -/// On a grid-cell crossing (called from `movement_update`), award discovery XP if `mover` just entered -/// a subzone it has never explored. `area` is the crossing's already-resolved `game_area` row — the -/// caller looks the position up once and drives both this and the zone transition off it. Mutates -/// `mover` (XP/level) — the caller's single `update` persists it. A graceful no-op when: the area -/// isn't discoverable (`area_bit < 0` or `exploration_level == 0`), the discovery XP rounds to 0 -/// (grey / capped), or the area was already explored. [entity] -/// -/// A paying discovery inserts BOTH `game_character_explored` (→ the gateway's -/// `SMSG_EXPLORATION_EXPERIENCE` "Discovered: " toast, carrying `experience`) AND -/// `game_xp_event` (→ `SMSG_LOG_XPGAIN`, the "+N experience" text) — two packets for one grant, not a -/// double-count. This is retail parity, not an artifact of this codebase: confirmed against vmangos -/// (an authentic-protocol vanilla server) — `Player::CheckAreaExploreAndOutdoor` (Player.cpp) calls -/// `GiveXP(xp, nullptr)`, whose body UNCONDITIONALLY calls `SendLogXPGain` (SMSG_LOG_XPGAIN) for -/// every XP grant regardless of source, and only THEN calls `SendExplorationExperience(area, xp)` -/// (SMSG_EXPLORATION_EXPERIENCE). Quest turn-in does the identical two-packet thing -/// (`Player::RewardQuest` calls `GiveXP` then `SendQuestReward`, whose SMSG_QUESTGIVER_QUEST_COMPLETE -/// also carries the xp number) — see `quest.rs`'s `reward_quest`, which mirrors it. XP itself is -/// granted exactly once either way (`grant_xp`, below); only the FEEDBACK is deliberately doubled, -/// matching what a real client receives. pub(crate) fn check_area_exploration( ctx: &ReducerContext, mover: &mut WorldEntity, @@ -88,7 +38,6 @@ pub(crate) fn check_area_exploration( if area.area_bit < 0 { return; } - // Dedup: already explored this area_bit? (indexed by_character scan — only this char's own rows.) let already = ctx .db .game_character_explored() @@ -98,9 +47,6 @@ pub(crate) fn check_area_exploration( if already { return; } - // Discovery XP (0 for a non-discoverable / grey / capped area → recorded but no XP). Computed - // BEFORE the insert so it rides in the row — the gateway's on_insert reads it for the - // SMSG_EXPLORATION_EXPERIENCE "Discovered: " toast below. let xp = if area.exploration_level > 0 { crate::xp::rated_xp( ctx, @@ -117,13 +63,7 @@ pub(crate) fn check_area_exploration( experience: xp, }); if xp > 0 { - crate::xp::grant_xp(ctx, mover, xp); // folds the GM .xprate, spends level thresholds - // "+N experience" feedback via the existing SMSG_LOG_XPGAIN relay (non-kill form) — a SECOND, - // deliberately overlapping packet, not a duplicate: retail sends both a discovery toast AND - // SMSG_LOG_XPGAIN for one discovery (see this fn's doc comment for the vmangos - // citation). The map fog-clear and the "Discovered: " toast both ride the - // game_character_explored insert above (the gateway's on_insert sets the PLAYER_EXPLORED_ZONES - // bit and reads `experience` for the toast). + crate::xp::grant_xp(ctx, mover, xp); ctx.db.game_xp_event().insert(XpEvent { id: 0, recipient_identity: mover.owner_identity, @@ -136,11 +76,6 @@ pub(crate) fn check_area_exploration( } } -/// Headless trigger for the exploration hook at an EXPLICIT position — the machine-test counterpart to -/// the movement_update hook (debug_teleport writes position directly and does NOT call movement_update, -/// so it never fires exploration). Sets the entity's position to (map,x,y) and runs the check -/// ATOMICALLY inside this reducer, so a concurrent stay-session movement heartbeat can't race the -/// position (the whole reducer is one transaction). Fetch → set position → check → persist. [entity] #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_explore_at( diff --git a/module/src/faction.rs b/module/src/faction.rs index 10b927ae8..7ab5e831e 100644 --- a/module/src/faction.rs +++ b/module/src/faction.rs @@ -1,40 +1,13 @@ -//! Faction relations. The vanilla client gates the attack cursor using its OWN -//! `FactionTemplate.dbc`; the server needs the same data to decide hostility authoritatively (today -//! combat is unconditional — you can `/startattack` a friendly NPC server-side). `game_faction_template` -//! is loaded from the operator's client `FactionTemplate.dbc` by the importer's `--dbc` mode (see -//! `importer/src/dbc.rs`); `compute_hostile` reads the relationship the DBC's own columns encode -//! (explicit enemy/friend slots overriding the coarse group masks) against our flattened schema. -//! -//! SCOPE: this is the DATA + the pure hostility predicate + tests. It is intentionally **not yet -//! wired** into combat attack-initiation — gating combat changes player-facing behavior that needs -//! live client verification, so flipping the gate is a deliberate follow-up once that live-test is -//! done. [static] -//! -//! VERIFIED against the real client's 314 templates (2026-06-18): the human player template (id 1: -//! faction_group Player|Alliance, enemy_group Horde|Monster) is NOT hostile to guards/vendors -//! (Alliance group 11/12) and IS hostile to Defias/Kobolds (Monster group 17/25) — matching what the -//! client's own cursor shows, since the client reads the SAME DBC. -//! -//! WIRING GUIDANCE (for when combat gates on this): "can A auto-attack B" is `compute_hostile(A,B) || -//! compute_hostile(B,A)` (a unit hostile to me in EITHER direction is a valid auto-attack target); -//! purely-neutral units (e.g. some beast templates with all-zero group masks) need explicit -//! `/startattack`. Full reaction (rep-based con colors, faction standing) additionally needs -//! `Faction.dbc` BaseReputation — a later refinement; `FactionTemplate` hostility is the combat primitive. - use spacetimedb::{table, ReducerContext}; -/// One `FactionTemplate.dbc` row, flattened (no Timestamp → SQL-loadable via `spacetime sql`). -/// `faction` is the parent `Faction.dbc` id that OTHER templates match in their `enemies`/`friends` -/// lists; the `*_group` fields are `FactionGroup` bitmasks; `enemy_*`/`friend_*` are up to four -/// explicit `faction` ids each (`[u32; 4]` isn't a `SpacetimeType`, so they're flattened to columns). #[table(accessor = game_faction_template, public)] pub struct FactionTemplate { #[primary_key] pub id: u32, - pub faction: u32, // parent Faction.dbc id (what others match in their enemy/friend lists) - pub faction_group: u32, // groups WE belong to (tested against others' enemy/friend group masks) - pub friend_group: u32, // group mask we are friendly toward - pub enemy_group: u32, // group mask we are hostile toward + pub faction: u32, + pub faction_group: u32, + pub friend_group: u32, + pub enemy_group: u32, pub enemy_0: u32, pub enemy_1: u32, pub enemy_2: u32, @@ -54,29 +27,14 @@ impl FactionTemplate { } } -/// One `Faction.dbc` parent faction's reputation metadata (Elwynn faction system). `reputation_index` -/// is the slot in the client's reputation pane (-1 = NOT player-facing, e.g. wildlife like the wolf's -/// faction 29). `base_standing` is the starting reputation a HUMAN character has with this faction -/// (the test race; per-race generalization is a follow-up — we store the primary race-group's base for -/// now). Loaded by `importer --dbc`. Public so the gateway can build a real `SMSG_INITIALIZE_FACTIONS` -/// (the player's actual standings) instead of the all-neutral placeholder. [static] #[table(accessor = game_faction, public)] pub struct Faction { #[primary_key] pub faction_id: u32, - pub reputation_index: i32, // -1 (== 0xFFFFFFFF in the DBC) when the faction has no reputation bar - pub base_standing: i32, // starting rep for a Human (raw reputation points; 0 = bottom of Neutral) + pub reputation_index: i32, + pub base_standing: i32, } -/// Resolve ONE direction of a `FactionTemplate.dbc` relationship — the shape both `compute_hostile` -/// and `compute_friendly` are instances of, so the precedence rule is written once. -/// -/// The row's own columns dictate the precedence: the four explicit `enemy_*` / `friend_*` slots name -/// specific PARENT factions and therefore override the coarse group masks, and a template with no -/// parent faction (`faction == 0`) cannot be named by anyone's list, so for it only the masks apply. -/// `asserting` is the list that PROVES the relation asked about, `vetoing` the opposite list that -/// disproves it, and `mask` the corresponding group mask tested against the other side's -/// `faction_group`. fn relation_holds( asserting: &[u32; 4], vetoing: &[u32; 4], @@ -92,22 +50,14 @@ fn relation_holds( ) } -/// Is `a` hostile to `b`? The enemy direction of the relationship rule: an explicit enemy entry -/// proves it, an explicit friend entry disproves it, otherwise `a`'s enemy-group mask against `b`'s -/// faction group decides. pub(crate) fn compute_hostile(a: &FactionTemplate, b: &FactionTemplate) -> bool { relation_holds(&a.enemies(), &a.friends(), a.enemy_group, b) } -/// Is `a` friendly to `b`? The friend direction of the same rule. Used for the "can I attack" gate — -/// a unit you are NOT friendly to is attackable (hostile AND neutral), only friendly units are -/// protected. pub(crate) fn compute_friendly(a: &FactionTemplate, b: &FactionTemplate) -> bool { relation_holds(&a.friends(), &a.enemies(), a.friend_group, b) } -/// Look up two faction templates by id and decide hostility. Missing data → not hostile (a safe -/// default). (`compute_hostile`/`compute_friendly` are the pure predicates.) pub(crate) fn is_hostile(ctx: &ReducerContext, attacker_faction: u32, target_faction: u32) -> bool { let ft = ctx.db.game_faction_template(); match (ft.id().find(attacker_faction), ft.id().find(target_faction)) { @@ -116,10 +66,6 @@ pub(crate) fn is_hostile(ctx: &ReducerContext, attacker_faction: u32, target_fac } } -/// Does the attacker consider the target FRIENDLY? `combat::start_attack` rejects an attack only when -/// this is true — so hostile (red) AND neutral (yellow, e.g. Elwynn wolves) targets stay attackable, -/// matching vanilla; only friendly (green) units are protected. Missing data → not friendly (so -/// combat is never blocked when the table isn't loaded; the gate is additionally count-guarded). pub(crate) fn is_friendly( ctx: &ReducerContext, attacker_faction: u32, @@ -165,15 +111,10 @@ mod tests { #[test] fn can_attack_neutral_and_hostile_but_not_friendly() { - // The "can I attack" gate rejects only FRIENDLY targets. Mirrors the live Northshire pairs: - // player (friend_group Alliance=0x2) vs an Alliance guard = friendly (protected); vs a neutral - // beast (all-zero groups, e.g. an Elwynn wolf) = NOT friendly (attackable); vs a Monster mob = - // NOT friendly (attackable). Gating on `hostile` instead of `friendly` would wrongly block - // neutral mobs, since a neutral target is neither hostile nor friendly. - let player = ft(1, 1, 0x3, 0x2, 0xC, [0; 4], [0; 4]); // group Player|Alliance, friendly→Alliance - let guard = ft(11, 72, 0x3, 0x2, 0xC, [0; 4], [0; 4]); // Alliance - let wolf = ft(32, 29, 0x0, 0x0, 0x0, [0; 4], [0; 4]); // neutral beast (all-zero groups) - let kobold = ft(25, 25, 0x8, 0x0, 0x0, [0; 4], [0; 4]); // Monster + let player = ft(1, 1, 0x3, 0x2, 0xC, [0; 4], [0; 4]); + let guard = ft(11, 72, 0x3, 0x2, 0xC, [0; 4], [0; 4]); + let wolf = ft(32, 29, 0x0, 0x0, 0x0, [0; 4], [0; 4]); + let kobold = ft(25, 25, 0x8, 0x0, 0x0, [0; 4], [0; 4]); assert!( compute_friendly(&player, &guard), "player is friendly to an Alliance guard" @@ -190,8 +131,6 @@ mod tests { #[test] fn compute_friendly_explicit_lists_override_group_masks() { - // Explicit FRIEND wins even though the group masks alone would say "not friendly": a's - // friend_group is 0 (no mask overlap possible), but b's faction is explicitly listed as a friend. let a = ft(1, 0, 0, 0x0, 0, [0; 4], [55, 0, 0, 0]); let b = ft(2, 55, 0x4, 0, 0, [0; 4], [0; 4]); assert!( @@ -199,8 +138,6 @@ mod tests { "explicit friend list wins even with a zero/mismatched friend_group mask" ); - // Explicit ENEMY wins even though the group masks alone would say "friendly": c's friend_group - // (0x4) DOES intersect d's faction_group (0x4), but d's faction is explicitly listed as an enemy. let c = ft(3, 0, 0, 0x4, 0, [77, 0, 0, 0], [0; 4]); let d = ft(4, 77, 0x4, 0, 0, [0; 4], [0; 4]); assert!( @@ -211,14 +148,13 @@ mod tests { #[test] fn explicit_enemy_faction_is_hostile() { - let a = ft(1, 0, 0x2, 0, 0, [59, 0, 0, 0], [0; 4]); // a lists faction 59 as an enemy - let b = ft(2, 59, 0x1, 0, 0, [0; 4], [0; 4]); // b's parent faction is 59 + let a = ft(1, 0, 0x2, 0, 0, [59, 0, 0, 0], [0; 4]); + let b = ft(2, 59, 0x1, 0, 0, [0; 4], [0; 4]); assert!(compute_hostile(&a, &b)); } #[test] fn explicit_friend_overrides_hostile_group_mask() { - // a's enemy_group would match b's faction_group (→ hostile by mask), but b is an explicit friend. let a = ft(1, 0, 0, 0, 0xFFFF_FFFF, [0; 4], [77, 0, 0, 0]); let b = ft(2, 77, 0x1, 0, 0, [0; 4], [0; 4]); assert!( @@ -229,18 +165,17 @@ mod tests { #[test] fn group_mask_decides_with_no_explicit_entry() { - let a = ft(1, 0, 0, 0, 0x4, [0; 4], [0; 4]); // hostile toward group 0x4 - let hostile_b = ft(2, 0, 0x4, 0, 0, [0; 4], [0; 4]); // is in group 0x4 - let neutral_b = ft(3, 0, 0x2, 0, 0, [0; 4], [0; 4]); // group 0x2 — not in a's enemy mask + let a = ft(1, 0, 0, 0, 0x4, [0; 4], [0; 4]); + let hostile_b = ft(2, 0, 0x4, 0, 0, [0; 4], [0; 4]); + let neutral_b = ft(3, 0, 0x2, 0, 0, [0; 4], [0; 4]); assert!(compute_hostile(&a, &hostile_b)); assert!(!compute_hostile(&a, &neutral_b)); } #[test] fn zero_faction_skips_explicit_lists() { - // b.faction == 0 → a's explicit enemy/friend lists are not consulted; only the masks apply. - let a = ft(1, 0, 0, 0, 0, [99, 0, 0, 0], [0; 4]); // would list faction 99, but b.faction is 0 - let b = ft(2, 0, 0x1, 0, 0, [0; 4], [0; 4]); // enemy_group 0 → not hostile + let a = ft(1, 0, 0, 0, 0, [99, 0, 0, 0], [0; 4]); + let b = ft(2, 0, 0x1, 0, 0, [0; 4], [0; 4]); assert!(!compute_hostile(&a, &b)); } } diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index b5c27900c..8f69c45a1 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -1,64 +1,28 @@ -//! Gameobjects — the world's interactive props. A spawned `game_gameobject` is relayed to clients as a -//! GameObject CREATE_OBJECT (gateway); `use_gameobject` (CMSG_GAMEOBJ_USE) dispatches by template type: -//! a CHEST rolls its real `game_gameobject_loot` table into the shared -//! `game_corpse_loot` table KEYED ON THE GO GUID (so the existing corpse loot window + take path serve -//! it unchanged) — falling back to the legacy single `data0` drop when the chest has no data-driven -//! table (the seed/demo chest) — a GOOBER grants quest credit for a USE_GAMEOBJECT objective, a -//! DOOR/BUTTON toggles open/closed. Every OTHER in-scope cmangos GO type imports -//! (template + spawn) but is INERT — `use` is a benign no-op, not an error (see `go_type::*` + -//! `apply_use_gameobject`'s catch-all arm). A lock with a real opener blocks CHEST, GOOBER, -//! DOOR and BUTTON use until `apply_pick_lock` records it as unlocked. New tables use additive -//! auto-migration. [entity] - use std::collections::HashSet; use spacetimedb::{reducer, table, ReducerContext, Table, TimeDuration, Timestamp}; use crate::creatures::game_creature_spawn_map; use crate::game_gameobject_loot; -use crate::game_player_skill; // GATHER skill-gate reads the gather skill row (accessor trait) +use crate::game_player_skill; use crate::game_world_entity; -use crate::nav::game_nav_chunk; // arm_pool's map-fence — neither is wildcard-exported at the crate root +use crate::nav::game_nav_chunk; use crate::terrain::game_terrain_chunk; pub mod go_type { pub const DOOR: u8 = 0; - /// Button/lever prop — cmangos models a lever as a differently-DISPLAYED door; the toggle - /// semantics are identical to DOOR (same match arm). cmangos type 1 (GAMEOBJECT_TYPE_BUTTON). pub const BUTTON: u8 = 1; - /// Quest-giver prop (Wanted Poster, a corpse that offers/completes a quest, …). Its giver/role - /// gate lives entirely in `game_gameobject_quest` (see `quest::validate_giver`); `use_gameobject` - /// still does not dispatch on this type SERVER-SIDE (opening the quest window is presentation, not - /// a state mutation — the gateway's `CMSG_GAMEOBJ_USE` handler does that dispatch itself, - /// module and gateway copies of this value can never drift. cmangos type 2 - /// (GAMEOBJECT_TYPE_QUESTGIVER). #[cfg(any(test, feature = "debug_reducers"))] #[cfg_attr(not(has_packages), allow(dead_code))] pub const QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; - /// Loot container — `use` rolls its drop into the loot window. cmangos type 3. pub const CHEST: u8 = lyracore_shared::constants::go_type::CHEST; - /// Spell trap. Relay activation uses its imported spell and cooldown metadata. pub const TRAP: u8 = 6; - /// Quest-use object such as a lever or totem. Unlocked `use` grants USE_GAMEOBJECT quest credit. - /// This is cmangos type 10. pub const GOOBER: u8 = 10; - /// Gather node (mining vein / herb bush). `use` skill-gates then DIRECT-grants the ore/herb - /// (skinning-style, NOT the loot window — the gtker loot-window codec is incomplete). cmangos models - /// these as locked CHESTs; we give them a distinct synthetic type so the gather path is unambiguous - /// and never collides with the CHEST loot-window path. `data0` = item entry granted; `data1` = - /// required skill level; `gather_skill_line` = MINING/HERBALISM the use requires. - pub const GATHER: u8 = 25; // synthetic; vanilla has no type 25 — our gather marker - // COLLISION NOTE: real cmangos type 25 IS assigned (GAMEOBJECT_TYPE_FISHINGHOLE), - // it just happens to collide with this synthetic marker. The importer's TYPE-25 COLLISION GUARD - // (`importer/src/main.rs::classify_go_type`) is the ONLY place that decides what gets stored as - // module type 25: a real FISHINGHOLE row is dropped from import, never stored here as GATHER. + pub const GATHER: u8 = 25; + // Source type 25 is FISHINGHOLE; import drops it before using GATHER. } -/// Pure skill-gate for a gather node (ctx-free, unit-tested): a character may gather iff it has LEARNED -/// the required gather skill (`learned > 0`) AND its level meets the node's `required`. `learned` is the -/// character's current skill in the node's `gather_skill_line` (0 = not learned — no skill row). Mirrors -/// `loot::can_skin`'s decision/test split. Returns the reject reason the use reducer surfaces, else `Ok`. pub(crate) fn can_gather(learned: u32, required: u32) -> Result<(), String> { if learned == 0 { return Err("you have not learned this gathering skill".to_string()); @@ -69,56 +33,35 @@ pub(crate) fn can_gather(learned: u32, required: u32) -> Result<(), String> { Ok(()) } -/// A gameobject definition (cmangos `gameobject_template` subset). Public + read-only, no Timestamp → -/// SQL-loadable like the other template tables. [static] #[table(accessor = game_gameobject_template, public)] pub struct GameObjectTemplate { #[primary_key] pub entry: u32, - pub type_id: u8, // go_type::* - pub display_id: u32, // visual model (the client resolves it from its own DBC) + pub type_id: u8, + pub display_id: u32, pub name: String, - pub data0: u32, // CHEST: item entry it drops; GATHER: item entry it grants (deliberately a single drop) - pub data1: u32, // GATHER: required skill level to gather; CHEST/GOOBER: reserved (lock id / spell) - // END-APPENDED defaulted column (additive auto-migration, same discipline as trainer.rs - // `learn_skill_line`): which gather skill a GATHER `use` requires. 0 = not a gather node. + pub data0: u32, + pub data1: u32, #[default(0u32)] - pub gather_skill_line: u32, // 0 = none; else MINING (186) / HERBALISM (182) - // END-APPENDED (respawn variance). Per-node respawn window in SECONDS (NOT a - // Timestamp, NOT micros — keeps the SQL seed human-readable; scaled to micros at runtime). 0 ⇒ fall - // back to RESPAWN_WINDOW_MICROS (3 min), so every EXISTING node is byte-identical (default-migrated 0). + pub gather_skill_line: u32, #[default(0u32)] pub respawn_secs: u32, - // END-APPENDED (skill-up difficulty). The GATHER node's "gray" difficulty ceiling: - // at/above this skill a gather no longer skills up. 0 ⇒ the always-skill sentinel (orange=data1, - // gray=0 → skillup_chance_bp returns 10000), so existing nodes deterministically +1 (byte-identical). - // The "orange" floor is the node's existing `data1` (required skill). Real tables (gray>0) are DEFERRED. #[default(0u32)] pub gather_gray: u32, - // END-APPENDED. Uses additive auto-migration, with the same discipline as the columns above. - // The cmangos lockId is hand-synced positionally into the gateway's - // `game_object_template_type.rs` binding (decode-only — the gateway never READS this field, only - // decodes past it). CHEST/GOOBER source it from the dump's `data0`; DOOR/BUTTON from `data1` (see - // `importer/src/dbc.rs`'s `go_template_row`). `game_lock` holds the Lock.dbc requirements for this - // id. A real opener gates CHEST, GOOBER, DOOR and BUTTON use. 0 = unlocked or not carried for this - // type. Existing rows default to 0. #[default(0u32)] pub lock_id: u32, #[default(0f32)] pub size: f32, } -/// Source trap fields needed by the relay-use authority. Module only. [static] #[table(accessor = game_gameobject_trap)] pub struct GameObjectTrap { #[primary_key] pub entry: u32, pub spell_id: u32, - /// Zero selects the source default of four seconds. pub cooldown_secs: u32, } -/// Per-spawn trap cooldown after a successful relay activation. Module only. [entity] #[table( accessor = game_gameobject_trap_cooldown, index(accessor = by_instance, btree(columns = [instance_id])) @@ -131,32 +74,20 @@ pub struct GameObjectTrapCooldown { pub ready_at: Timestamp, } -/// Lock.dbc data in `game_lock`. `apply_use_gameobject` reads this table to gate supported locked -/// types. One Lock.dbc row packs up to 8 PARALLEL alternative ways to open it, such as the right key -/// or enough Lockpicking. Each non-`LockType::None` alternative un-packs into its own row here, -/// `index`-numbered 0..8 (see `importer/src/dbc.rs::lock_sql` — a lock with 1 real requirement emits -/// exactly 1 row). Look up by [`GameObjectTemplate::lock_id`] via `by_lock`. `kind`: 1 = ITEM (open -/// with the key item `property` names) — 2 = SKILL (need SkillLine `property` at >= `required_skill`; -/// Lockpicking, SkillLine 633, is itself just another skill line, so a lockpicking-only lock is a -/// plain `kind==2` row keyed on 633). Public + read-only, no Timestamp → SQL-loadable clear+reload -/// like `game_creature_family`/`game_skill_line` — no reducer needed. UNSUBSCRIBED (no gateway -/// binding files, no gateway subscription) — the gateway never reads this table, following the -/// `game_creature_family` precedent for a module-only reference table. [static] #[table(accessor = game_lock, public, index(accessor = by_lock, btree(columns = [lock_id])))] pub struct GameLock { #[primary_key] #[auto_inc] pub id: u64, - pub lock_id: u32, // == GameObjectTemplate.lock_id (the Lock.dbc row id) - pub index: u8, // which of the lock's up-to-8 parallel alternatives this is (0..8) - pub kind: u8, // 1 = item (property is an item entry); 2 = skill (property is a SkillLine) - pub property: u32, // kind 1: required key item entry; kind 2: required SkillLine id - pub required_skill: u32, // kind 2: the skill VALUE needed; kind 1: unused (Lock.dbc's own 0/garbage) + pub lock_id: u32, + pub index: u8, + pub kind: u8, + pub property: u32, + pub required_skill: u32, } -/// `game_lock.kind` values: the two opener kinds [`GameLock`] models. -pub(crate) const LOCK_KIND_ITEM: u8 = 1; // property = the key item entry the opener must HOLD -pub(crate) const LOCK_KIND_SKILL: u8 = 2; // property = a SkillLine id (Lockpicking 633 / Herbalism 182 / Mining 186) +pub(crate) const LOCK_KIND_ITEM: u8 = 1; +pub(crate) const LOCK_KIND_SKILL: u8 = 2; pub(crate) fn is_real_lock_opener(kind: u8, property: u32) -> bool { (kind == LOCK_KIND_SKILL && property != 0) || kind == LOCK_KIND_ITEM @@ -168,8 +99,6 @@ pub struct GameObjectUnlocked { pub go_guid: u64, } -/// A spawned gameobject in the world. Public (broadcast like creatures/corpses); relayed to clients as -/// a GameObject CREATE_OBJECT. `state`: 0 = ready (chest closed), 1 = used (chest looted). [entity] #[table( accessor = game_gameobject, public, @@ -181,14 +110,13 @@ pub struct GameObjectUnlocked { accessor = by_template_scope, btree(columns = [template_entry, map_id, instance_id]) ), - // The AOI cell index — exactly 3 columns, all matched by equality terms, which is the - // only shape SpacetimeDB 2.7.1's subscription planner can serve (see the `cell` column). + // SpacetimeDB 2.7.1 serves AOI subscriptions with three equality-indexed columns. index(accessor = by_cell, btree(columns = [map_id, instance_id, cell])) )] pub struct GameObject { #[primary_key] pub guid: u64, - pub template_entry: u32, // -> GameObjectTemplate.entry + pub template_entry: u32, pub map_id: u32, pub x: f32, pub y: f32, @@ -196,66 +124,17 @@ pub struct GameObject { pub orientation: f32, pub state: u8, pub created_at: Timestamp, - /// When a DEPLETED (state==1) gather node becomes gatherable again, as micros since the unix epoch - /// (NOT a Timestamp — adding one to this live, populated table would abort the publish; teleport-core - /// lesson). 0 = no pending respawn (a ready node, or a non-gather GO). Set at gather-deplete from - /// ctx.timestamp + RESPAWN_WINDOW_MICROS; cleared (back to 0) when the respawn pass flips state 0. #[default(0u64)] pub respawn_at_micros: u64, - /// Which instance this GO belongs to. 0 = open world, EVERY static - /// imported/seeded/pool/debug row stays 0; only the per-instance COPIES `instance::create_instance` - /// makes of a dungeon's DOOR/BUTTON/CHEST/GOOBER rows carry a live instance id (GO_COPY_BAND - /// guids), and the instance reap deletes them. END-appended + `#[default(0u64)]` (danger-zones §2 - /// additive migration). After changing this subscribed row, run `./lyracore bindings` - /// and update the `schema_parity.rs` field manifest. #[default(0u64)] pub instance_id: u64, - /// AOI grid cell: stamped from (x, y) at insert — GOs are static, no re-stamp. The - /// per-player GO subscription scopes to the viewer's grid box exactly like game_world_entity - /// (the un-scoped table made the client draw all ~4.6k zone GOs at once — the felt frame - /// hitch). END-appended + defaulted → auto-migrates; backfill via debug_backfill_go_grid. #[default(0i32)] pub grid_x: i32, #[default(0i32)] pub grid_y: i32, - /// `(grid_x, grid_y)` packed into ONE indexed value — the AOI subscription's cell key. - /// - /// SpacetimeDB 2.7.1's subscription planner can only serve a query from an index when EVERY - /// column of that index is matched by an equality term, and it skips any index with more than 3 - /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all - /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition - /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the - /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN - /// serve, and the AOI box becomes 25 point probes instead of a scan. - /// - /// ALWAYS written from `spatial::grid_cell_id(grid_x, grid_y)` in the SAME statement that writes - /// `grid_x`/`grid_y` — a stale value here does not merely slow a query down, it puts the row in - /// the wrong cell and shows players the wrong world. `module/src/tripwires.rs::grid_cell_tripwire` - /// is the enforcement. - /// - /// `#[default(0i64)]` (typed — an i64 column needs an explicitly-typed literal) + END-appended so - /// `publish` auto-migrates. **The default is cell (0, 0), not "unset"**, so every pre-existing row - /// is mis-addressed until `backfill_cell_ids` re-stamps it — and gameobjects are STATIC, so - /// nothing ever re-stamps them on its own. That backfill is a REQUIRED post-publish step. #[default(0i64)] pub cell: i64, - /// The cmangos spawn quaternion (`gameobject.rotation0..3`). The client renders a - /// static prop's ORIENTATION from this 4-float quaternion (`GAMEOBJECT_ROTATION`, wire index 10), - /// not from `orientation` alone: rot2/rot3 carry yaw, rot0/rot1 carry the terrain pitch/roll a - /// bench flush against a sloped wall needs. `orientation` above still drives movement-facing math - /// and the CREATE's `MovementBlock_UpdateFlag_Living` heading; these four are ADDITIONAL, wire-only. - /// END-appended + `#[default(0.0f32)]` (additive auto-migration; f32 defaults fine per danger-zones - /// §1.2, unlike a `String` end-append). All-zero (every pre-migration row, and any hand-seeded - /// fixture that never set these) is the codec's signal to DERIVE a yaw-only quaternion from - /// `orientation` instead of sending a degenerate zero rotation — see - /// `gateway/src/codec/gameobject.rs::build_gameobject_rotation_values`. - /// ⚠ Field spelling: SpacetimeDB normalizes column names to snake_case, so a Rust field - /// `rotation0` is STORED as column `rotation_0`. The import shipped it as `rotation0..3` (stored - /// `rotation_0..3`); a parallel session saw the normalized live columns, took them for orphans, - /// and re-declared them as `rotation_0..3` — same four columns, two spellings, briefly published - /// as a duplicate-name type that broke row decode. The merge collapsed everything onto the - /// stored spelling. Keep field name == column name here; never re-introduce `rotation0`. + // SpacetimeDB normalizes rotation0 to rotation_0; keep field and stored column names identical. #[default(0.0f32)] pub rotation_0: f32, #[default(0.0f32)] @@ -266,9 +145,6 @@ pub struct GameObject { pub rotation_3: f32, } -/// Read stored GameObject destination evidence through an exact template and partition index. -/// State and distance decide whether a returned object can be used now. They do not erase the -/// stored destination when every matching object is depleted or far away. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn gameobject_destination_evidence( ctx: &ReducerContext, @@ -286,53 +162,15 @@ pub(crate) fn gameobject_destination_evidence( .collect() } -// =========================================================================================== -// Gathering POOL model (cmangos pool_template + pool_gameobject) — MODEL A (delete/insert) -// =========================================================================================== -// -// A POOL has many spawn POINTS and a fixed MAX_ACTIVE count. On a pooled node being gathered + its -// respawn firing, the pool RE-ROLLS: the gathered point DEACTIVATES (its game_gameobject row is -// DELETEd → gateway relays SMSG_DESTROY_OBJECT) and a DIFFERENT inactive point ACTIVATES (a fresh -// game_gameobject row is INSERTed → gateway relays CREATE_OBJECT), so the active set ROTATES across -// the zone — nodes roam; you cannot camp one spot. Higher-tier falls out FOR FREE: a higher-tier -// template is just a low-WEIGHT member, so the weighted pick makes it rare (NO tier-reroll logic). -// -// MODEL A is the lazier choice because the gateway already relays a live GO INSERT (on_go_insert, -// subscriptions.rs:516) symmetric to its DELETE relay (on_go_delete, :525). All pool state lives on -// the TEMPLATE + these two NEW module-only tables; the SPAWN row (GameObject) is UNCHANGED, so the -// gateway's hand-matched BSATN-positional binding (game_object_type.rs) stays byte-identical — zero -// gateway edits. Model B (a `dormant` column on the spawn row) would force that hand-match + a new -// on_go_update closure (the `subscriptions.rs:513` no-on_update note). The reroll's −1 delete / +1 -// insert is exactly the path import_gameobjects already exercises (clear → reload). - -/// A gathering pool HEADER (cmangos `pool_template` subset). Public + SQL-loadable (no Timestamp) — -/// the gateway never subscribes to or reads this; it's module-only pool state. `max_active` member -/// POINTS are active (have a `game_gameobject` row) at any instant; the reroll holds that invariant. #[table(accessor = game_gameobject_pool, public)] pub struct GameObjectPool { #[primary_key] pub pool_id: u32, - /// Exactly this many member points are active (have a live `game_gameobject` row) at any time. pub max_active: u32, - // END-APPENDED defaulted column (additive auto-migration, same discipline as the GameObjectTemplate - // defaulted cols). The POOL's KIND, which decides the gather DEPLETE timing (NOT `max_active` — that - // is NOT a safe signal: `pool_max_active(3)==pool_max_active(4)==1`, so a small roaming pool collides - // with the `max_active==1` tier signal). false = a ROAMING pool (the importer's single-tier spatial - // pools): gather → INSTANT `reroll_pool` + return, so the node vanishes here and a sibling appears - // elsewhere (wander-to-find). true = an IN-PLACE TIER point: - // `max_active==1`, members CO-LOCATED (identical x,y,z) but of differing TIERS (e.g. Copper w85 / Tin - // w15); gather flips state + arms the respawn timer (exactly the standalone path) and - // `pass_gameobject_respawn` weighted-RE-ROLLS the tier IN PLACE (co-located ⇒ no wander, repeats - // allowed ⇒ the common tier recurs). Defaults false so EVERY existing/importer pool is byte-identical. #[default(false)] pub in_place: bool, } -/// A pool member POINT (cmangos `pool_gameobject` subset). A *potential* spawn location: a point is -/// ACTIVE iff a `game_gameobject` row exists at `pool_point_guid(point_id)`, INACTIVE otherwise, the -/// active/inactive distinction is the EXISTENCE OF A ROW, not a column (so the spawn row is untouched). -/// `template_entry` chooses which GO spawns here (a higher-tier template = a rarer member); `weight` is -/// the relative selection weight (0 = never auto-selected). Public + SQL-loadable (no Timestamp). #[table(accessor = game_gameobject_pool_member, public, index(accessor = by_pool, btree(columns = [pool_id])))] pub struct GameObjectPoolMember { @@ -340,33 +178,22 @@ pub struct GameObjectPoolMember { #[auto_inc] pub point_id: u64, pub pool_id: u32, - pub template_entry: u32, // the GO template to spawn at this point (higher-tier template = rarer member) + pub template_entry: u32, pub map_id: u32, pub x: f32, pub y: f32, pub z: f32, pub orientation: f32, - pub weight: u32, // relative selection weight; 0 = never auto-selected + pub weight: u32, } -/// Bit 47 of the low-48 marks a guid as a POOL point. cmangos `gameobject.guid` db_guids and the GO -/// seed ordinals are small integers (all well under 2^47), so this tag bit is ALWAYS free for them — -/// pool points live in a DISJOINT sub-namespace. Without it, a pool `point_id` (auto_inc from 1) collided -/// with a standalone GO at the same low-48 (seeded Copper Vein = `GO_HIGH | 3` == pool point 3), so arming -/// a pool silently deleted/clobbered real nodes and a standalone respawn misrouted into a foreign pool. +// Bit 47 keeps pool-point guids disjoint from imported and standalone gameobjects. pub const POOL_TAG: u64 = 1 << 47; -/// Derive a pool point's `game_gameobject` guid from its `point_id` — HIGHGUID_GAMEOBJECT (0xF110 in bits -/// 48..63, the same scheme as `debug_spawn_gameobject` and the GO seed) | [`POOL_TAG`] | point_id. The tag -/// bit keeps the point's live row findable/deletable by the reroll while staying disjoint from standalone -/// GO guids (see [`pool_point_id_of`] — the standalone-vs-pooled discriminator). pub(crate) fn pool_point_guid(point_id: u64) -> u64 { (0xF110u64 << 48) | POOL_TAG | (point_id & 0x7FFF_FFFF_FFFF) } -/// Recover the `point_id` from a guid IFF it is a tagged pool point (HIGHGUID_GAMEOBJECT + [`POOL_TAG`]). -/// `None` for a standalone GO (tag bit clear) → the respawn pass never even queries the member table for it -/// and always takes the in-place branch. A STRUCTURAL test, collision-proof — not a member-row lookup. pub(crate) fn pool_point_id_of(guid: u64) -> Option { if (guid >> 48) == 0xF110 && (guid & POOL_TAG) != 0 { Some(guid & 0x7FFF_FFFF_FFFF) @@ -375,12 +202,6 @@ pub(crate) fn pool_point_id_of(guid: u64) -> Option { } } -/// Pure WEIGHTED-PICK over a pool's inactive candidates (ctx-free, unit-tested): given each candidate's -/// `weight` and a `roll` in 0..total (total = Σweights), return the index of the picked candidate via -/// cumulative-weight bands (`roll < cumulative` → that band) — the same shape as `loot::group_pick`, -/// but over an arbitrary total (not basis-points), so a higher-weight member occupies a wider band. -/// `roll` is always < total at the call site (`ctx.random::() % total`), so this never returns the -/// past-the-end `None` band; isolated so the deterministic "seeded index → chosen member" is testable. pub(crate) fn weighted_pick(weights: &[u32], roll: u32) -> Option { let mut cum = 0u32; for (i, &w) in weights.iter().enumerate() { @@ -392,60 +213,27 @@ pub(crate) fn weighted_pick(weights: &[u32], roll: u32) -> Option { None } -/// RE-ROLL a pool (MODEL A) after one of its points was gathered and its respawn fired. Holds the -/// MAX_ACTIVE invariant as a strict −1 / +1: DEACTIVATE the gathered point (DELETE its row, the −1) -/// then ACTIVATE one currently-INACTIVE weighted-chosen point (INSERT a row, the +1) → the active -/// count is CONSERVED. The gateway relays the delete as SMSG_DESTROY_OBJECT and the insert as -/// CREATE_OBJECT, so in-range clients see the node ROAM. Collect-then-mutate (snapshot candidates -/// before any insert) so two due rerolls in one pass never race the same slot and we never write the -/// `game_gameobject` table while iterating it. -/// -/// Edge case (no inactive member): if EVERY member is already active (`max_active >= eligible point -/// count`), in-place respawn the gathered point instead of dropping below `max_active` — re-insert it -/// at its own guid so the active count never dips. A misconfigured pool (every member `weight == 0`) -/// has no candidate at all; the gathered point stays deleted (the safe fail), which is not an invariant -/// break of a *correctly configured* pool. pub(crate) fn reroll_pool(ctx: &ReducerContext, pool_id: u32, gathered_guid: u64) { let gos = ctx.db.game_gameobject(); let members = ctx.db.game_gameobject_pool_member(); - // 1. DEACTIVATE the gathered point (the −1). The just-freed point is itself eligible again below. crate::go_collider::remove(ctx, gathered_guid); gos.guid().delete(gathered_guid); - // 2. Candidate set = pool members with weight > 0 that are currently INACTIVE (no live row at their - // derived guid). The just-deleted gathered point is now inactive → eligible to be re-picked (a - // small pool can legitimately re-roll the same spot; MAX_ACTIVE still holds). - // LOAD-BEARING INVARIANT (tier-variety): the delete-THEN-collect order RE-INCLUDES the freed point - // in the candidate set. For an in_place `max_active==1` tier pool that is the WHOLE mechanism — - // every member is inactive after the delete, so the weighted pick spans ALL tiers and the common - // tier CAN repeat. Do NOT "optimize" this to exclude the gathered point: that would silently turn a - // co-located {Copper,Tin} tier pool into a DETERMINISTIC Copper↔Tin FLIP (guaranteed Tin every other - // gather), destroying the ~85/15 weighting. let candidates: Vec = members .by_pool() .filter(&pool_id) .filter(|m| m.weight > 0 && gos.guid().find(pool_point_guid(m.point_id)).is_none()) .collect(); if candidates.is_empty() { - // Misconfigured pool (all members weight==0) → no candidate. Leave the gathered point deleted; - // a correctly configured pool always has ≥1 candidate (the freed point itself), so this branch - // is only the degenerate fail. (The "all active" no-rotate case lands in step 3 via re-pick.) return; } - // 3. WEIGHTED pick over the inactive candidates (reuse the loot weighting shape). `total > 0` here - // because every candidate has weight > 0, so the `% total` is safe and `weighted_pick` always hits. let weights: Vec = candidates.iter().map(|m| m.weight).collect(); let total: u32 = weights.iter().sum(); let roll = ctx.random::() % total; let chosen = &candidates[weighted_pick(&weights, roll).expect("roll < total ⇒ a band always hits")]; - // 4. ACTIVATE the chosen point (the +1) — INSERT a fresh ready row at its derived guid (gateway - // relays CREATE_OBJECT → it spawns live). state 0 = ready, no pending respawn. activate_point(ctx, chosen); } -/// ACTIVATE a pool point, insert its READY `game_gameobject` row at the derived guid. Shared by -/// `arm_pool` (initial fill) and `reroll_pool` (rotation). Idempotent-by-guid: the caller guarantees -/// the point is currently inactive (no row at its guid), so this is a plain insert. fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { let go = ctx.db.game_gameobject().insert(GameObject { guid: pool_point_guid(m.point_id), @@ -458,12 +246,10 @@ fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, - instance_id: 0, // pools are open-world machinery (never instanced) + instance_id: 0, grid_x: lyracore_shared::spatial::grid_cell(m.x, m.y).0, grid_y: lyracore_shared::spatial::grid_cell(m.x, m.y).1, cell: lyracore_shared::spatial::cell_id_at(m.x, m.y), - // Pool members carry no spawn quaternion (cmangos pool_gameobject has none either) — the - // codec derives a yaw-only quaternion from `orientation` for the all-zero case. rotation_0: 0.0, rotation_1: 0.0, rotation_2: 0.0, @@ -472,8 +258,6 @@ fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { crate::go_collider::register(ctx, &go); } -/// Maps with imported creature placements, terrain, or navigation. Instance imports record their -/// creature maps without a single-floor heightmap, so geometry alone cannot identify a fresh Shard. fn imported_spatial_maps(ctx: &ReducerContext) -> HashSet { ctx.db .game_terrain_chunk() @@ -508,10 +292,6 @@ pub(crate) fn eligible_pool_candidates( .collect() } -/// ARM a pool to exactly `max_active` live rows: clear any existing live rows at this pool's points, -/// then activate `max_active` weighted-distinct INACTIVE points (the only place the active count is -/// SET; every later reroll conserves it). If the pool has fewer eligible (weight>0) points than -/// `max_active`, all eligible points activate (the achievable max) — never duplicates a point. pub(crate) fn arm_pool(ctx: &ReducerContext, pool_id: u32) { let gos = ctx.db.game_gameobject(); let members = ctx.db.game_gameobject_pool_member(); @@ -523,14 +303,10 @@ pub(crate) fn arm_pool(ctx: &ReducerContext, pool_id: u32) { .find(pool_id) .map(|p| p.max_active) .unwrap_or(0); - // Clear any prior live rows at THIS pool's points (idempotent arming — a re-setup re-fills cleanly). for m in members.by_pool().filter(&pool_id) { crate::go_collider::remove(ctx, pool_point_guid(m.point_id)); gos.guid().delete(pool_point_guid(m.point_id)); } - // Activate max_active weighted-distinct points: re-query the inactive eligible set each iteration (a - // chosen point becomes active → excluded next iteration via `active_point_guids` → no point activates - // twice). Re-querying (not a cloned snapshot) also keeps the table row type Clone-free. for _ in 0..max_active { let pool_members: Vec = members.by_pool().filter(&pool_id).collect(); let active_point_guids: HashSet = pool_members @@ -541,7 +317,7 @@ pub(crate) fn arm_pool(ctx: &ReducerContext, pool_id: u32) { let candidates = eligible_pool_candidates(pool_members, &active_point_guids, &imported_maps); if candidates.is_empty() { - break; // fewer eligible points than max_active → activate all there are + break; } let weights: Vec = candidates.iter().map(|m| m.weight).collect(); let total: u32 = weights.iter().sum(); @@ -552,13 +328,6 @@ pub(crate) fn arm_pool(ctx: &ReducerContext, pool_id: u32) { } } -/// ARM every loaded pool to its `max_active` live rows — the import-time fill. `seed::init` does NOT -/// re-run on a plain (auto-migrate) publish, and the importer writes the pool HEADER + MEMBER rows via -/// SQL but no live `game_gameobject` rows (those carry a Timestamp), so something must turn members into -/// active spawns once the SQL load lands. The ETL calls this after the load (importer/scripts/import-world.sh), -/// exactly like the creature-tick rearm folded into `debug_repair_after_publish`. Idempotent: `arm_pool` clears each pool's prior live rows -/// first, so re-running it never drifts the count. Operator-gated (a world-open privileged reducer). -/// Collect the ids first (house style — though `arm_pool` mutates `game_gameobject`, not the pool header). #[reducer] pub fn arm_all_pools(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -574,18 +343,10 @@ pub fn arm_all_pools(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Max distance to use a gameobject — reuse the loot range (10 yd)²; the client walks into range itself -/// before sending CMSG_GAMEOBJ_USE, so this only rejects clearly-out-of-range abuse (mirrors loot/vendor). const USE_RANGE_SQ: f32 = crate::loot::LOOT_RANGE_SQ; -/// Gather-node respawn window FALLBACK: a depleted GATHER node with `respawn_secs == 0` becomes gatherable -/// again this long after it's gathered. Per-template variance now overrides it (`respawn_secs`); this is -/// the default every existing node uses (so they're byte-identical). cmangos respawn RANGES are DEFERRED. -const RESPAWN_WINDOW_MICROS: u64 = 180_000_000; // 3 min +const RESPAWN_WINDOW_MICROS: u64 = 180_000_000; -/// Pure respawn-window lookup (ctx-free, unit-tested): the micros a depleted node stays down, from its -/// template's `respawn_secs`. 0 ⇒ the `RESPAWN_WINDOW_MICROS` fallback (existing nodes, byte-identical); -/// else the per-template seconds scaled to micros. Isolated so the fallback-vs-scaled choice is testable. pub(crate) fn respawn_window_micros(respawn_secs: u32) -> u64 { if respawn_secs == 0 { RESPAWN_WINDOW_MICROS @@ -594,18 +355,10 @@ pub(crate) fn respawn_window_micros(respawn_secs: u32) -> u64 { } } -/// Pure respawn-due predicate (ctx-free, unit-tested): a gameobject is due to respawn iff it is DEPLETED -/// (state==1), has an ARMED timer (respawn_at_micros != 0), and that armed time has elapsed (now >= -/// respawn_at_micros). A never-armed node (respawn_at_micros==0) and a not-yet-due node both return false. -/// This is exactly the filter the respawn pass branches on; isolated so the timer logic is testable. pub(crate) fn respawn_due(state: u8, respawn_at_micros: u64, now: u64) -> bool { state == 1 && respawn_at_micros != 0 && now >= respawn_at_micros } -/// Does the lock `lock_id` require opening before a CHEST/GOOBER/DOOR use? True iff `game_lock` -/// holds a REAL opener row for it, either a skill line to meet or a key item. A lock -/// with ONLY degenerate `property==0` slots (unmapped LockTypes = open-by-hand) is NOT gated: it opens -/// freely, so widening the import's lock coverage never bricks a hand-open chest. fn lock_requires_opening(ctx: &ReducerContext, lock_id: u32) -> bool { ctx.db .game_lock() @@ -614,9 +367,6 @@ fn lock_requires_opening(ctx: &ReducerContext, lock_id: u32) -> bool { .any(|r| is_real_lock_opener(r.kind, r.property)) } -/// Is the GO `go_guid` with template `lock_id` locked shut for the use-gate? True iff it -/// carries a lock_id with a real opener AND no `game_gameobject_unlocked` row exists yet. The shared -/// gate for the CHEST, GOOBER and DOOR/BUTTON arms. GATHER and QUESTGIVER carry lock_id 0. fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool { lock_id != 0 && lock_requires_opening(ctx, lock_id) @@ -628,8 +378,6 @@ fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool { .is_none() } -/// The typed GameObject target Gate shared by lock picking and both use adapters. It resolves the -/// GameObject, template and live Character, then applies partition and range Gates once. pub(crate) fn usable_go( ctx: &ReducerContext, caster_guid: u64, @@ -680,14 +428,6 @@ pub(crate) fn usable_go( Ok((go, tmpl)) } -/// Pick the lock on a locked GameObject (Pick Lock 1804, gateway-intercepted by the -/// E_OPEN_LOCK effect kind). Gated same-map/instance + range like [`apply_use_gameobject`] (the caster -/// walked up to click it). Reads the GO template's `lock_id`, then scans `game_lock` for a SATISFIABLE -/// real opener — a SKILL row (kind 2, `property != 0`) the caster's `game_player_skill(property)` meets, -/// or an ITEM row (kind 1) whose key item the caster holds. On success: record the GO unlocked (the -/// use-gate then admits it) + climb the satisfied skill line one step (`gain_profession_skill` — a -/// Lockpicking lock skills up on use). Emits NO game_spell_cast_event — the gateway sends the -/// START/OK/GO handshake itself (the enchant/fish precedent). [entity] pub(crate) fn apply_pick_lock( ctx: &ReducerContext, caster_guid: u64, @@ -697,7 +437,6 @@ pub(crate) fn apply_pick_lock( if tmpl.lock_id == 0 { return Err("it is not locked".to_string()); } - // Idempotent: re-casting Pick Lock on an already-open lock is a no-op success, not an error. if ctx .db .game_gameobject_unlocked() @@ -707,13 +446,11 @@ pub(crate) fn apply_pick_lock( { return Ok(()); } - // Scan the lock's up-to-8 parallel openers for the FIRST the caster satisfies. `climb` carries the - // satisfied SKILL line (Some) to step up on success; an ITEM opener climbs nothing (None). let mut satisfied = false; - let mut climb: Option<(u32, u32)> = None; // (skill_line, required_skill) + let mut climb: Option<(u32, u32)> = None; for row in ctx.db.game_lock().by_lock().filter(&tmpl.lock_id) { if !is_real_lock_opener(row.kind, row.property) { - continue; // degenerate property==0 slot / unmodeled kind — Pick Lock can't use it + continue; } if row.kind == LOCK_KIND_SKILL { let current = ctx @@ -742,18 +479,12 @@ pub(crate) fn apply_pick_lock( ctx.db .game_gameobject_unlocked() .insert(GameObjectUnlocked { go_guid }); - // Skill-up the satisfied SKILL line one step: orange = the required floor (always skills below it), - // gray = 0 (the always-skill sentinel → a deterministic +1 each pick, like a default-band gather - // node). A no-op when the line has no row (an item opener, or an un-learned line). [entity] if let Some((line, required)) = climb { crate::skill::gain_profession_skill(ctx, caster_guid, line, required, 0); } Ok(()) } -/// Shared use-a-gameobject core for Character and debug paths. [`usable_go`] requires a live -/// Character, a loaded GameObject template, the same map and instance, and use range before -/// type-specific effects run. Supported lock-bearing types also apply [`locked_shut`] first. [entity] #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn request_use_gameobject( ctx: &ReducerContext, @@ -769,9 +500,6 @@ pub(crate) fn apply_use_gameobject( caster_guid: u64, go_guid: u64, ) -> Result<(), String> { - // Map + instance gated (GO rows carry `instance_id`): a player may only use - // a GO in THEIR OWN instance (dungeon doors/chests are per-instance copies; the static - // instance-0 originals on a dungeon map are the copy SOURCES, unreachable from inside a run). let (go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?; use_resolved_gameobject(ctx, caster_guid, go, tmpl) } @@ -782,17 +510,10 @@ fn use_resolved_gameobject( mut go: GameObject, tmpl: GameObjectTemplate, ) -> Result<(), String> { - // Snapshot for the on_go_used notify hook fired at the success exit below — `go` is moved into - // an update (or deleted by a pool reroll) inside the dispatch arms. The instance is the GO - // ROW'S OWN (equal to the user's after the gate - // above, but the row is the authoritative source). let (used_go_guid, used_go_entry, used_go_instance) = (go.guid, go.template_entry, go.instance_id); match tmpl.type_id { go_type::CHEST => { - // LOCK gate: a CHEST whose lock has a REAL opener (`locked_shut`) refuses - // the loot roll until Pick Lock (`apply_pick_lock`) records a `game_gameobject_unlocked` row. - // lock_id 0 / a hand-open (property==0-only) lock opens freely (unchanged). BEFORE the loot. if locked_shut(ctx, go.guid, tmpl.lock_id) { return Err("it is locked".to_string()); } @@ -825,20 +546,9 @@ fn use_resolved_gameobject( crate::quest::on_gameobject_used(ctx, caster_guid, go.template_entry); } go_type::GATHER => { - // GATHER node (mining vein / herb bush): skill-gate → DIRECT-grant the ore/herb → climb the - // gather skill → DEPLETE. This is the skinning core's shape (`loot::skin_corpse`) reused on the - // already-present GO `state` marker instead of a per-table `skinned` bool. - // - // DEPLETION = the chest's `state` flip (no new lifecycle code): a depleted node stays `state=1` - // FOREVER — the GO system has no respawn machinery by design (no spawn table / respawn tick), so - // re-testing a depleted node needs a re-spawn (debug lever) or a re-import. RESPAWN is DEFERRED. if go.state != 0 { return Err("node already gathered".to_string()); } - // SKILL-GATE: the char must have LEARNED the node's gather skill at >= the required level - // (data1). Unlike skinning there is NO auto-learn here — Mining/Herbalism is learned at the - // trainer first, so a fresh char without the skill row fails the `learned == 0` gate (the - // intentional negative case). The pure decision lives in `can_gather` (unit-tested). let line = tmpl.gather_skill_line; let learned = ctx .db @@ -849,13 +559,8 @@ fn use_resolved_gameobject( .map(|s| s.current as u32) .unwrap_or(0); can_gather(learned, tmpl.data1)?; - // DIRECT-grant (skinning-style, NOT the loot window). `?`-rollback: a full bag returns Err HERE, - // BEFORE the `state` flip, so the whole tx rolls back and the node stays gatherable — byte-identical - // to `skin_corpse`'s leather grant (loot.rs:315) and the cooking reagent gate. + // Grant before depletion so a full bag leaves the node gatherable. crate::items::grant_item(ctx, caster_guid, tmpl.data0, 1)?; - // Skill-up — ROLL the node's difficulty band: orange = `data1` (the required floor, already - // gated above), gray = `gather_gray` (0 ⇒ the always-skill sentinel → byte-identical +1 for - // existing nodes; a real table with gray>0 makes the climb taper as it nears the ceiling). crate::skill::gain_profession_skill( ctx, caster_guid, @@ -863,40 +568,9 @@ fn use_resolved_gameobject( tmpl.data1, tmpl.gather_gray, ); - // DEPLETE — a THREE-WAY split on the structural POOL_TAG discriminator + the pool's `in_place` - // KIND (collision-proof, NOT a bare low-48 lookup): - // - // (1) ROAMING pool (a pooled point whose pool is `in_place == false`) → the rotation IS the - // respawn: `reroll_pool` NOW (delete this point, activate a DIFFERENT inactive one) and RETURN - // — no state flip, no armed timer (reroll_pool deleted the row, so pass_gameobject_respawn - // never sees this guid → no double-respawn). Deleting the point relays SMSG_DESTROY_OBJECT - // (vanishes on gather = correct feedback) and the new point's INSERT relays CREATE_OBJECT (the - // node roams visible now). Active count stays max_active. DEBUG-ONLY: vanilla Elwynn is not - // spatially pooled, so nothing in production emits `in_place=false` pools — only the - // `debug_setup_gather_pool` lever does (spatial wander was rejected for in-place tier variety). - // - // (2) IN-PLACE TIER point (a pooled point whose pool is `in_place == true` — a `max_active==1` - // co-located multi-tier point) → the SAME tail as a standalone: flip state + arm the timer. - // pass_gameobject_respawn fires `reroll_pool` when the timer elapses, which (because - // `max_active==1` ⇒ every member inactive after the delete) WEIGHTED-rolls over ALL the - // point's CO-LOCATED tiers, repeats allowed, IN PLACE (identical x,y,z ⇒ NO wander). So a - // Copper point mostly re-presents Copper and ~15% presents Tin. Rolling at timer-fire (not - // now) reuses the already-wired pass branch, preserves authentic downtime, and cannot - // double-respawn (the depleted row is the one reroll_pool deletes before insert). - // - // (3) STANDALONE node (tag bit clear → never queries the member table) → the EXISTING in-place - // state-flip + armed respawn timer: gatherable again `respawn_window_micros(tmpl.respawn_secs)` - // from now (per-template window, default 3 min), flipped back to state 0 by - // pass_gameobject_respawn (the sense tick). UNCHANGED. - // - // (2) and (3) share the flip+arm tail (the `_ =>` arm). For BOTH, the state 0→1 flip is not - // relayed live (no GO on_update relay), so the depleted node stays visible client-side until its - // timer fires (then the reroll's DELETE/INSERT, or relog, refreshes it) — cosmetic, pre-existing. - // respawn_at_micros is a defaulted u64 (NOT a Timestamp — that would abort the live-table publish). match pool_point_id_of(go.guid) .and_then(|pid| ctx.db.game_gameobject_pool_member().point_id().find(pid)) { - // (1) ROAMING pool → instant reroll + return (the importer's wander pools). Some(member) if !ctx .db @@ -908,8 +582,6 @@ fn use_resolved_gameobject( { reroll_pool(ctx, member.pool_id, go.guid); } - // (2) in-place TIER point OR (3) standalone node → flip + arm the respawn timer; the - // respawn pass re-rolls the tier in place (pooled) or flips state 0 (standalone). _ => { go.state = 1; go.respawn_at_micros = (ctx.timestamp.to_micros_since_unix_epoch() as u64) @@ -919,27 +591,14 @@ fn use_resolved_gameobject( } } go_type::DOOR | go_type::BUTTON => { - // DOOR/BUTTON toggle: open↔closed is state 0↔1, the same field the - // gateway already relays in the CREATE_OBJECT (`gameobject_state`) — see the new - // `on_go_update` relay (subscriptions.rs) that re-emits it live, mirroring the corpse - // body→bones re-emit. LOCK gate: a DOOR/BUTTON whose lock has a REAL opener - // (`locked_shut`) refuses the toggle until Pick Lock records a `game_gameobject_unlocked` - // row. lock_id 0 / a hand-open (property==0-only) lock toggles freely (unchanged). if locked_shut(ctx, go.guid, tmpl.lock_id) { return Err("it is locked".to_string()); } go.state = toggle_state(go.state); ctx.db.game_gameobject().guid().update(go); } - _ => { - // INERT type (widened import): every in-scope cmangos GO type now imports - // (template + spawn), but only CHEST/GOOBER/GATHER/DOOR/BUTTON dispatch above — everything - // else (SPELL_FOCUS, MAILBOX until, etc.) is a benign no-op so the object is - // at least usable/clickable without a hard error, not a silent capability promise. - } + _ => {} } - // Encounter kernel notify hook: every SUCCESSFUL use, the gates above (range, - // already-looted, skill) `?`-returned before this line, so a rejected use never fires. crate::hooks::fire_on_go_used( ctx, &crate::hooks::GoUsedPayload { @@ -1062,10 +721,6 @@ pub(crate) fn clear_relay_trap_cooldowns_for_instance(ctx: &ReducerContext, inst } } -/// Pure DOOR/BUTTON open/closed TOGGLE (ctx-free, unit-tested): the same binary `state` field a CHEST -/// uses for looted/unlooted, just flipped both directions instead of one-way. Total over every `u8` -/// input (not just 0/1) so a corrupt/foreign state value can never wedge the reducer — anything -/// nonzero is treated as "open" and flips closed; only exact 0 flips to 1. pub(crate) fn toggle_state(state: u8) -> u8 { if state == 0 { 1 @@ -1074,9 +729,6 @@ pub(crate) fn toggle_state(state: u8) -> u8 { } } -/// Pick the lock on the FIRST spawned gameobject of template `go_entry` as `character_guid` — same core -/// as `pick_lock` but resolves by template entry so `spacetime call` can drive it with SMALL args (it -/// mangles guids > 2^53), mirroring `debug_use_gameobject_entry`. The headless Pick Lock verify lever. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_pick_lock_entry( @@ -1093,20 +745,6 @@ pub fn debug_pick_lock_entry( apply_pick_lock(ctx, character_guid, go.guid) } -/// Replace the live gameobject set with an imported content slice (see `importer/`). CLEARS every -/// `game_gameobject` (the gateway relays those deletes as DESTROY), then loads the batch. Unlike -/// creatures, a `game_gameobject` row IS the live object (no separate spawn table + respawn tick), so -/// the gateway's on_insert relay sends each CREATE_OBJECT directly. Loads via a reducer (not SQL) -/// because `game_gameobject` has a `created_at: Timestamp`. Mirrors `import_creature_spawns`. -/// -/// `packed`: rows separated by `;`, fields by `,`, in the order -/// `guid,template_entry,map_id,x,y,z,orientation,initial_state,rot0,rot1,rot2,rot3`. `created_at` = -/// `ctx.timestamp`. `initial_state` is an END field of the string protocol between the importer and -/// this reducer, not a table migration or a reducer arg. It lets a DOOR/BUTTON spawn already-open -/// (cmangos `startOpen`); every other type's importer row carries 0 (ready/closed). `rot0..3` (END -/// fields, the cmangos spawn quaternion) ride AFTER `initial_state` rather than beside `orientation`, -/// so older packed-row builders in this file's tests keep working unmodified. A hand-built row that -/// omits them fails loudly (field-count check below) rather than silently importing a zero quaternion. #[reducer] pub fn import_gameobjects(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1122,9 +760,6 @@ pub fn import_gameobjects(ctx: &ReducerContext, packed: String) -> Result<(), St Ok(()) } -/// Append a batch of packed gameobjects WITHOUT the clear — paired with `import_gameobjects` (which -/// clears ONCE + loads batch 0), so a whole zone can load across several `spacetime call`s. note: no -/// clear here, so re-calling this alone never wipes the set. #[reducer] pub fn import_gameobjects_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1161,8 +796,8 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { orientation: pf32(f[6])?, state: pu8(f[7])?, created_at: now, - respawn_at_micros: 0, // a freshly-imported node is ready (no pending respawn) - instance_id: 0, // imported static rows are open-world (dungeon copies are runtime) + respawn_at_micros: 0, + instance_id: 0, grid_x: lyracore_shared::spatial::grid_cell(gx_src, gy_src).0, grid_y: lyracore_shared::spatial::grid_cell(gx_src, gy_src).1, @@ -1179,9 +814,6 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { Ok(loaded) } -// =========================================================================================== -// Tests [server] — the gather-node slice's non-trivial pieces (the live arm composes these) -// =========================================================================================== #[cfg(test)] mod tests { use super::*; @@ -1194,15 +826,11 @@ mod tests { } use crate::skill::{next_skill, skill_line, APPRENTICE_CAP}; - /// The GATHER type id is wire-facing (it rides into the client's UpdateGameObject `type_id`), so guard - /// the literal against a silent edit and confirm it can't collide with the two real cmangos types. #[test] fn gather_type_id_is_the_synthetic_marker_25() { assert_eq!(go_type::GATHER, 25); assert_ne!(go_type::GATHER, go_type::CHEST); assert_ne!(go_type::GATHER, go_type::GOOBER); - // DOOR/BUTTON sit at cmangos' real 0/1, guard them too, and confirm none of - // the five LIVE type ids collide with each other. assert_eq!(go_type::DOOR, 0); assert_eq!(go_type::BUTTON, 1); let live = [ @@ -1223,9 +851,6 @@ mod tests { } } - /// `QUESTGIVER` is re-exported from `lyracore_shared::constants::go_type` (not a local - /// literal) so the module and gateway copies can never drift — pin BOTH the cmangos value and the - /// identity with the shared source in one assertion. #[test] fn questgiver_type_id_matches_the_shared_constant_and_is_cmangos_type_2() { assert_eq!(go_type::QUESTGIVER, 2); @@ -1235,24 +860,14 @@ mod tests { ); } - /// DOOR/BUTTON TOGGLE, the pure decision `apply_use_gameobject`'s DOOR|BUTTON arm - /// calls. Total over every `u8` (not just 0/1): only exact 0 flips to 1, anything else (a corrupt/ - /// foreign value) flips to 0 rather than panicking or wedging the reducer. #[test] fn toggle_state_flips_zero_and_one_and_is_total() { assert_eq!(toggle_state(0), 1, "closed -> open"); assert_eq!(toggle_state(1), 0, "open -> closed"); - // A never-expected third value still flips deterministically (never panics). assert_eq!(toggle_state(2), 0); assert_eq!(toggle_state(255), 0); } - /// LOCK opener classification, the pure decision the use-gate + pick path share. A - /// SKILL row (kind 2) is a REAL opener only with a real SkillLine (`property != 0`): the importer - /// stores an UNMAPPED LockType.dbc id as `property 0` (LOCKTYPE_OPEN/blasting/disarm), which must NOT - /// gate a hand-open chest and which Pick Lock can't satisfy. An ITEM row (kind 1) always opens; an - /// unmodeled kind never does. This is exactly the property=0 escape-hatch the live locks (5/23/24) - /// carry ALONGSIDE their real 633 gate — the 633 row still gates, the property=0 rows are ignored. #[test] fn is_real_lock_opener_ignores_property_zero_skill_slots() { assert!( @@ -1279,62 +894,42 @@ mod tests { !is_real_lock_opener(9, 633), "an unmodeled lock kind is never an opener" ); - // A lock carrying BOTH a 633 row AND a property==0 slot (the live 5/23/24 shape) still has a real - // opener → it stays gated; the property==0 slot does not make it "already openable". assert!( is_real_lock_opener(LOCK_KIND_SKILL, 633) && !is_real_lock_opener(LOCK_KIND_SKILL, 0) ); } - /// PIECE 1 — the skill-GATE admits a learned-and-high-enough skill and rejects both the not-learned and - /// too-low cases (the live arm's first decision, `can_gather(learned, data1)`). #[test] fn can_gather_admits_learned_and_rejects_unlearned_or_too_low() { - // PASS: a freshly-learned 1/75 skill clears a required-level-1 node (the seeded data1=1). assert!(can_gather(1, 1).is_ok()); assert!(can_gather(75, 1).is_ok()); - // REJECT — never learned (no skill row → learned == 0): the negative case the verify recipe drives. assert_eq!( can_gather(0, 1).unwrap_err(), "you have not learned this gathering skill" ); - // REJECT — learned but below the node's required level. assert_eq!( can_gather(5, 25).unwrap_err(), "your skill is too low to gather this node" ); } - /// PIECE 2 (re-scoped, was `gather_grants_one_and_climbs_the_skill_one_step`) — only the REAL-fn half: - /// the skill GATE passes for a freshly-learned char on a required-1 node (`can_gather`), and the - /// skill-up climbs toward the Apprentice cap via the shared `next_skill`. The "grants one" half (the - /// live arm's `grant_item(.., tmpl.data0, 1)` call) is a ctx fn — not exercised here, so it's dropped - /// rather than modeled as a bare local (`let granted = 1; assert_eq!(granted, 1)` asserted nothing). #[test] fn gather_gate_passes_and_skill_climbs_toward_apprentice_cap() { - let cap = APPRENTICE_CAP as u32; // 75 — the profession cap, NOT level*5 - // Gate passes for a learned 1/75 char on a required-1 node. + let cap = APPRENTICE_CAP as u32; assert!(can_gather(1, 1).is_ok()); - // The skill-up: 1 -> 2 (one step, capped at the apprentice 75 — independent of level). assert_eq!(next_skill(1, cap), 2); - // Mining is a real profession line, climbs past the level-1 combat cap (5) toward 75. assert_eq!(skill_line::MINING, 186); assert_eq!(next_skill(74, cap), 75); - assert_eq!(next_skill(75, cap), 75); // at the apprentice cap → no further climb + assert_eq!(next_skill(75, cap), 75); } - /// PIECE 5 (respawn variance) — the per-template RESPAWN-WINDOW lookup. A node with `respawn_secs == 0` - /// (every EXISTING node, default-migrated) keeps the 3-min `RESPAWN_WINDOW_MICROS` fallback (byte- - /// identical); a node with a seeded `respawn_secs` uses its OWN window, scaled seconds→micros. #[test] fn respawn_window_micros_defaults_to_the_const_else_scales_seconds() { - // Default (0) ⇒ the 3-min const fallback — the regression guard for existing nodes. assert_eq!(respawn_window_micros(0), RESPAWN_WINDOW_MICROS); assert_eq!(respawn_window_micros(0), 180_000_000); - // Seeded seconds ⇒ scaled to micros (the verify recipe seeds a 30s node). assert_eq!(respawn_window_micros(30), 30_000_000); assert_eq!(respawn_window_micros(1), 1_000_000); - assert_eq!(respawn_window_micros(180), RESPAWN_WINDOW_MICROS); // 180s == the const, no surprise + assert_eq!(respawn_window_micros(180), RESPAWN_WINDOW_MICROS); } #[test] @@ -1354,9 +949,6 @@ mod tests { ); } - /// Once a real import has landed terrain/nav content for at least one map, a pool member on THAT - /// map is live, and a member on any OTHER map is foreign — the exact defect this issue reported: - /// a map-1 shard's `arm_all_pools` call must not resurrect `init`'s map-0 Northshire tier point. #[test] fn pool_member_map_is_live_gates_by_map_once_something_is_really_imported() { let map_1_imported: HashSet = [1].into_iter().collect(); @@ -1372,8 +964,6 @@ mod tests { !pool_member_map_is_live(36, &map_1_imported), "any other foreign map is refused too" ); - // A database that legitimately hosts more than one map (e.g. a continent + its --include-map - // instance interior) admits every one of them, not just the first. let multi_map: HashSet = [0, 36].into_iter().collect(); assert!(pool_member_map_is_live(0, &multi_map)); assert!(pool_member_map_is_live(36, &multi_map)); @@ -1397,10 +987,10 @@ mod tests { } let live_map: HashSet = [1].into_iter().collect(); let members = vec![ - member(1, 1, 5), // live map, weighted, inactive → ELIGIBLE - member(2, 1, 0), // live map, weight 0 → excluded - member(3, 0, 5), // foreign map (init's map-0 fixture) → excluded - member(4, 1, 5), // live map, weighted, but already active → excluded + member(1, 1, 5), + member(2, 1, 0), + member(3, 0, 5), + member(4, 1, 5), ]; let active_point_guids: HashSet = [pool_point_guid(4)].into_iter().collect(); let out = eligible_pool_candidates(members, &active_point_guids, &live_map); @@ -1412,13 +1002,9 @@ mod tests { ); } - /// PIECE 4 — the RESPAWN-DUE predicate the sense-tick pass branches on. A depleted node - /// with an armed timer flips ONLY once its time has elapsed; a not-yet-due node, a never-armed node - /// (respawn_at_micros==0), and a ready node (state 0) all stay put. #[test] fn respawn_due_flips_only_a_depleted_armed_and_elapsed_node() { let armed_at = 1_000u64; - // DUE — depleted, armed, and the armed time has passed (now == and now > both flip). assert!( respawn_due(1, armed_at, armed_at), "due exactly at the armed time" @@ -1427,53 +1013,35 @@ mod tests { respawn_due(1, armed_at, armed_at + 1), "due once past the armed time" ); - // NOT due — armed but the window has not elapsed yet (the pass must NOT flip it early). assert!( !respawn_due(1, armed_at, armed_at - 1), "before the armed time → not due" ); - // NOT due — a depleted node with NO armed timer (respawn_at_micros == 0): never respawns on its own. assert!( !respawn_due(1, 0, u64::MAX), "never-armed depleted node stays depleted" ); - // NOT due — a READY node (state 0) is irrelevant to the respawn pass regardless of the timer/now. assert!( !respawn_due(0, armed_at, armed_at + 1), "a ready node is never a respawn candidate" ); } - /// POOL PIECE A — the WEIGHTED pick is deterministic given a seeded roll index. The cumulative-band - /// math must put a higher-weight member in a wider band and land each roll in the right band, exactly - /// like the reroll's `ctx.random::() % total` feeds it. This is the "weighted pick is - /// deterministic given a seeded index" guarantee. #[test] fn weighted_pick_lands_each_roll_in_its_cumulative_band() { - // Five base members at weight 20 + one higher-tier at weight 2 → total 102. Bands: [0,20), - // [20,40), [40,60), [60,80), [80,100) are the bases; [100,102) is the rare higher-tier. let w = [20u32, 20, 20, 20, 20, 2]; - assert_eq!(weighted_pick(&w, 0), Some(0)); // band start - assert_eq!(weighted_pick(&w, 19), Some(0)); // band end (half-open) - assert_eq!(weighted_pick(&w, 20), Some(1)); // next band start - assert_eq!(weighted_pick(&w, 99), Some(4)); // last BASE band - assert_eq!(weighted_pick(&w, 100), Some(5)); // the rare higher-tier band start - assert_eq!(weighted_pick(&w, 101), Some(5)); // …and its (narrow) end → only 2/102 rolls hit it - // A zero-weight member occupies NO band — it is never chosen (the "weight 0 = never auto-selected" - // rule the candidate filter also enforces). Here member 1 (weight 0) is skipped entirely. + assert_eq!(weighted_pick(&w, 0), Some(0)); + assert_eq!(weighted_pick(&w, 19), Some(0)); + assert_eq!(weighted_pick(&w, 20), Some(1)); + assert_eq!(weighted_pick(&w, 99), Some(4)); + assert_eq!(weighted_pick(&w, 100), Some(5)); + assert_eq!(weighted_pick(&w, 101), Some(5)); let w0 = [10u32, 0, 10]; assert_eq!(weighted_pick(&w0, 9), Some(0)); - assert_eq!(weighted_pick(&w0, 10), Some(2)); // jumps PAST the zero-weight member 1 - // A roll past the total lands in no band (None) — the reroll never feeds such a roll (% total), - // but the predicate is honest about it. + assert_eq!(weighted_pick(&w0, 10), Some(2)); assert_eq!(weighted_pick(&[5u32, 5], 10), None); } - /// POOL PIECE C — the STANDALONE-vs-POOLED discriminator is a STRUCTURAL guid test (the [`POOL_TAG`] - /// bit), collision-proof against standalone GO guids. A pooled point round-trips point_id <-> guid via - /// `pool_point_guid` / `pool_point_id_of`; a STANDALONE node's guid (`0xF110<<48 | small db_guid/seed - /// ordinal`, tag bit CLEAR) yields `None` → it never queries the member table → in-place respawn (the - /// no-regression guarantee). Pins the exact namespace collision the tag fixes. #[test] fn pool_point_guid_tagged_namespace_is_disjoint_from_standalone() { const GO_HIGH: u64 = 0xF110u64 << 48; @@ -1491,9 +1059,6 @@ mod tests { "round-trips to point_id" ); } - // The exact bug: a seeded standalone Copper Vein at GO_HIGH|3 shares point_id 3's OLD namespace. - // With the tag bit it is NOT a pool guid (None) → in-place respawn, never clobbered/misrouted; and - // its guid differs from the real pooled point 3. for standalone in [GO_HIGH | 3, GO_HIGH | 4, GO_HIGH | 50102, GO_HIGH | 1234] { assert_eq!( pool_point_id_of(standalone), @@ -1508,33 +1073,20 @@ mod tests { } } - /// POOL PIECE D — the GATHER deplete branch ROUTES on the structural POOL_TAG discriminator AND (for a - /// pooled point) the pool's `in_place` KIND, a THREE-WAY split: (1) a ROAMING pooled point (in_place - /// FALSE) → reroll-and-return (the node WANDERS, no state flip, no armed timer, so - /// `pass_gameobject_respawn` never double-respawns it); (2) an IN-PLACE TIER pooled point (in_place - /// TRUE) AND (3) a STANDALONE node (`None`, tag bit clear) → the SAME flip + armed respawn-timer tail - /// (the `_ =>` arm). This pins the STRUCTURAL half (pooled-vs-standalone); the in_place half is a pool - /// header lookup the live branch does. The live count-conservation of the reroll is the arithmetic in - /// `reroll_conserves_the_active_count` + the server verify recipe. #[test] fn gather_deplete_routes_pooled_to_reroll_and_standalone_to_in_place() { - // A pooled point (the importer's base-1_000_000 point_id, tag bit set) → the member-table lookup - // succeeds; whether it rerolls (roaming) or arms a timer (in-place tier) is then the pool's in_place. let pooled = pool_point_guid(1_000_000); assert_eq!( pool_point_id_of(pooled), Some(1_000_000), "pooled point → consult the pool's in_place" ); - // A standalone imported gather node (HIGHGUID_GAMEOBJECT | small db_guid, tag CLEAR) → in-place. let standalone = (0xF110u64 << 48) | 50102; assert_eq!( pool_point_id_of(standalone), None, "standalone gather node → in-place respawn timer" ); - // …and its in-place tail still arms a positive future window (the standalone branch is unchanged): - // a 300s (real vanilla) node arms 300_000_000 micros out; the default-0 node keeps the 3-min const. assert_eq!( respawn_window_micros(300), 300_000_000, @@ -1547,31 +1099,21 @@ mod tests { ); } - /// TIER-VARIETY A — the demonstrator's ~85/15 Copper/Tin weighting, and the load-bearing REPEAT-ALLOWED - /// property that distinguishes a tier RE-ROLL from a deterministic flip. The in-place tier reroll feeds - /// `weighted_pick([85, 15], roll)` with `roll = ctx.random() % 100`: Copper (member 0) owns the wide - /// band [0,85) and Tin (member 1) the narrow [85,100) — so over many rolls Tin presents ~15% of the - /// time. Crucially a roll already inside the Copper band re-selects Copper (member 0): the common tier - /// CAN repeat (this is the whole point — a co-located pool does NOT flip Copper↔Tin every gather). #[test] fn tier_variety_weighted_reroll_is_eightyfive_fifteen_and_repeats_the_common_tier() { - let w = [85u32, 15]; // Copper w85, Tin w15 → total 100 - // Copper owns [0,85): every roll there re-presents Copper (REPEAT-ALLOWED — not a forced flip). + let w = [85u32, 15]; assert_eq!(weighted_pick(&w, 0), Some(0), "low roll → Copper"); assert_eq!( weighted_pick(&w, 84), Some(0), "band end (half-open) → still Copper" ); - // Tin owns the narrow [85,100): only ~15/100 rolls present the rarer tier. assert_eq!(weighted_pick(&w, 85), Some(1), "the Tin band starts at 85"); assert_eq!( weighted_pick(&w, 99), Some(1), "…and ends at 99 → 15/100 rolls hit Tin" ); - // COUNT the Copper band width over the full 0..100 roll space = exactly 85 (the ~85% the verify - // recipe samples), and Tin = 15. This is the deterministic distribution the seeded roll produces. let copper = (0..100u32) .filter(|&r| weighted_pick(&w, r) == Some(0)) .count(); @@ -1583,10 +1125,7 @@ mod tests { (85, 15), "the band split IS the weight split (no flip, repeats allowed)" ); - // Co-located members differ ONLY in template_entry/weight, so whichever index the roll picks, the - // SPAWNED coords are identical → the node never wanders. Model the two members' coords and assert - // the picked coord is invariant across BOTH possible picks (the "same coords" guarantee). - let coords = [(-9620.11f32, -46.3336f32, 47.3641f32); 2]; // {Copper, Tin} co-located + let coords = [(-9620.11f32, -46.3336f32, 47.3641f32); 2]; for roll in [0u32, 50, 84, 85, 99] { let idx = weighted_pick(&w, roll).unwrap(); assert_eq!( @@ -1596,14 +1135,9 @@ mod tests { } } - /// TIER-VARIETY B — the SKILL-GATE still blocks a rolled higher tier for a low char (the authentic - /// "richer node you can't tap yet" teaser). The demonstrator's tiers carry the real vanilla required - /// Mining: Copper req 1, Tin req 65. A Mining-50 char can always work a rolled Copper but is rejected - /// off a rolled Tin until Mining reaches 65 — the EXISTING `can_gather`, unchanged by tier-variety. #[test] fn tier_variety_skill_gate_blocks_a_rolled_tin_for_a_low_char() { - let (copper_req, tin_req) = (1u32, 65u32); // the demonstrator's real Mining requirements - // A Mining-50 char: a rolled Copper (req 1) gathers; a rolled Tin (req 65) is gated. + let (copper_req, tin_req) = (1u32, 65u32); assert!( can_gather(50, copper_req).is_ok(), "a rolled Copper is always tappable" @@ -1613,12 +1147,10 @@ mod tests { "your skill is too low to gather this node", "a rolled Tin teases a low char until Mining 65" ); - // Once the char reaches Mining 65 the rolled Tin opens up (the gate is `learned >= required`). assert!( can_gather(65, tin_req).is_ok(), "Mining 65 unlocks the rolled Tin" ); - // A never-learned char fails BOTH tiers (the learned==0 case), tier-variety irrelevant. assert_eq!( can_gather(0, copper_req).unwrap_err(), "you have not learned this gathering skill" diff --git a/module/src/gc.rs b/module/src/gc.rs index 4eda6cbc9..66b4ade64 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -1,11 +1,5 @@ -//! Scheduled garbage collection: the event reaper that GCs short-lived broadcast/relay event rows -//! (movement relays + creature-move legs) once their TTL elapses. [server] - use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table}; -// `breath_relay`, `channel`, `guild`, `mail_timer`, `realm_chat` and `rest` are not glob -// re-exported at crate scope, so their accessors need their own imports. Every other event table's -// accessor trait rides a glob. use crate::breath_relay::game_breath_relay_event; use crate::channel::game_chat_channel_notice_event; use crate::guild::game_guild_event; @@ -21,10 +15,7 @@ use crate::{ game_trade_session, game_whisper_event, game_xp_event, EVENT_TTL_MICROS, INVITE_TTL_MICROS, }; use crate::{game_party_command_intent, game_party_command_receipt}; -// `game_movement_event` / `game_creature_move_event` are deliberately NOT imported: nothing writes -// either table any more, so the reaper no longer touches them (see the notes in `reap_events`). -/// Drives the movement-event GC tick. [server] #[table(accessor = game_event_reaper_schedule, scheduled(reap_movement_events))] pub struct EventReaperSchedule { #[primary_key] @@ -33,28 +24,13 @@ pub struct EventReaperSchedule { pub scheduled_at: ScheduleAt, } -// NO per-firing delete cap here — perf catalog 1.5 tier 1 is PARKED, not landed. A flat cap -// (a `.take(N)` on the stale list) bounds the transaction but SILENTLY DROPS the reaper's drain -// guarantee: the reaper fires 1×/s over a 1s TTL, so it drains only while production ≤ N rows/s -// per table. The catalog's own arithmetic puts movement-event production at ~20k rows/s at C≈100 — -// four times a 5k cap — i.e. the cap starts leaking at exactly the crowd size the rest of this -// catalog exists to reach, and `game_movement_event` (public, per-player subscribed) then grows -// without bound. Bounding the stall needs a mechanism that still drains (catch-up firings, or the -// tier-3 restructure that stops modeling movement as insert+delete rows); a bare cap is a -// behaviour change dressed as a perf win. - #[reducer] pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule) { - // Only the scheduler should invoke this. if ctx.sender() != ctx.database_identity() { return; } let cutoff = ctx.timestamp.to_micros_since_unix_epoch() - EVENT_TTL_MICROS; - // All three event tables share the same shape (`id: u64` PK + `created_at: Timestamp`) and TTL, - // so one macro reaps each: collect stale ids (created_at < cutoff), then delete by id. A generic - // `fn` is awkward here — each table's `.id()` is a distinct generated accessor type — so a local - // macro keyed on the table accessor is the direct fit. macro_rules! reap { ($accessor:ident) => {{ let t = ctx.db.$accessor(); @@ -68,46 +44,30 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } }}; } - // `game_movement_event` is not reaped: peer movement rides the per-mover `game_entity_motion` - // row, which is updated in place and needs no reaping. - // `game_creature_move_event` is not reaped because nothing writes it. Creature legs ride the - // per-creature `game_creature_spline` row, which is updated in place. The table stays in the - // schema (dropping one is a separate destructive migration) and stays empty. - reap!(game_combat_event); // melee swing logs - reap!(game_xp_event); // per-kill XP awards - reap!(game_levelup_event); // level-up dings - reap!(game_spell_cast_event); // spell-cast visuals (aura tracer) - reap!(game_spell_impact_event); // deferred projectile-impact damage logs - reap!(game_chat_event); // say/yell broadcast lines - reap!(game_emote_event); // emote broadcast (text + animation) - // Nothing writes whisper rows any more. The reap stays for the build that retires them, so - // rows still inside the TTL at publish are deleted. It can go in the next build. + reap!(game_combat_event); + reap!(game_xp_event); + reap!(game_levelup_event); + reap!(game_spell_cast_event); + reap!(game_spell_impact_event); + reap!(game_chat_event); + reap!(game_emote_event); reap!(game_whisper_event); - reap!(game_realm_chat_event); // Realm Chat Lines - reap!(game_chat_channel_notice_event); // Channel Notices - reap!(game_system_message_event); // private Package-originated System Messages - reap!(game_addon_message); // addon-bridge UI messages (RLS-scoped) - reap!(game_roll_event); // unwritten since /roll became a Group Broadcast - reap!(game_group_event); // group invite/roster notifications (RLS-scoped) - reap!(game_trade_event); // trade-status relay rows (RLS-scoped) - reap!(game_duel_event); // Duel lifecycle relay rows (RLS-scoped) - reap!(game_bot_invite_intent); // bot-decided invites awaiting gateway pickup - reap!(game_auction_notice); // live outbid/won/sold/expired/new-bid notices to an online seller or bidder - reap!(game_movement_violation); // recent anti-cheat diagnostics - // Rest-area zzz/blue-bar relay rows. Caught missing by the gc_reap_tripwire: this - // table carries the same `id: u64` + `created_at: Timestamp` TTL shape as every table above but - // had no reap line — every inn threshold crossing for the lifetime of a character left one more - // row behind. The durable rest state (`Character.resting`/`rested_xp`) lives elsewhere; this row - // is only the one-shot PLAYER_BYTES_2 relay. + reap!(game_realm_chat_event); + reap!(game_chat_channel_notice_event); + reap!(game_system_message_event); + reap!(game_addon_message); + reap!(game_roll_event); + reap!(game_group_event); + reap!(game_trade_event); + reap!(game_duel_event); + reap!(game_bot_invite_intent); + reap!(game_auction_notice); + reap!(game_movement_violation); reap!(game_rest_state_event); - reap!(game_breath_relay_event); // breath timer edges + drowning damage relay - reap!(game_guild_event); // Guild Events (sign-on, MOTD, membership) - reap!(game_mail_arrival); // Mail Arrivals, relayed on insert + reap!(game_breath_relay_event); + reap!(game_guild_event); + reap!(game_mail_arrival); - // Never-answered pending invites. Same id+created_at shape as the event tables, but on - // the longer INVITE_TTL (a human is looking at the invite dialog). After that the row is dead - // weight: accepting a reaped invite fails as "no pending invite", exactly like accepting one - // that was never sent. { let t = ctx.db.game_group_invite(); let invite_cutoff = ctx.timestamp.to_micros_since_unix_epoch() - INVITE_TTL_MICROS; @@ -121,9 +81,6 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // Never-answered pending Guild Invites, same TTL and shape as `game_group_invite` above, but - // keyed on the target's own guid (`target_guid` is the primary key: at most one invite per - // target). { let t = ctx.db.game_guild_invite(); let invite_cutoff = ctx.timestamp.to_micros_since_unix_epoch() - INVITE_TTL_MICROS; @@ -137,10 +94,6 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // Idle Trade Sessions: `created_at` is bumped by every trade action, so the pure - // policy (`trade::session_is_stale`) measures idleness — a live negotiation is never reaped. - // Torn down THROUGH `cancel_trade_for`, never a bare delete, so both clients hear - // `TradeCanceled` instead of keeping stale windows open. { let sessions = ctx.db.game_trade_session(); let now = ctx.timestamp.to_micros_since_unix_epoch(); @@ -156,16 +109,10 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // CC diminishing-returns windows: rows whose 15s post-removal window has - // elapsed are dead state — the next same-category CC starts fresh at level 1 anyway. crate::spell::stacking::sweep_dr_state(ctx); - // Need/greed roll deadlines: resolves any roll whose 60s window lapsed with - // votes outstanding (absent members auto-pass) — the deadline half of resolve-exactly-once. crate::loot::sweep_loot_rolls(ctx); - // Teleport handshake relays. Same TTL, but the time column is a defaulted u64 (`created_micros`, - // not a `Timestamp` — so the column-add auto-migrated), so it can't use the `reap!` macro. { let t = ctx.db.game_teleport_event(); let stale: Vec = t @@ -178,9 +125,7 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // Gateway reconciles an expired pending command against every target receipt before recording - // its result. Pending rows keep result_reap_micros at i64::MAX; finish sets the finite deadline, - // so the bounded index prefix below contains only terminal rows under Core's write contract. + // Pending commands retain i64::MAX; only terminal results enter this bounded expiry prefix. const COMMAND_REAP_LIMIT: usize = 64; let now = ctx.timestamp.to_micros_since_unix_epoch(); let intents = ctx.db.game_party_command_intent(); @@ -204,7 +149,5 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule receipts.id().delete(receipt.id); } - // Player corpses: unreclaimed body → bones → despawn, on its own decay policy (own the bones, - // not this reaper). See `corpse::sweep_corpse_decay`'s doc for the timers and the relay shape. crate::corpse::sweep_corpse_decay(ctx); } diff --git a/module/src/ghost.rs b/module/src/ghost.rs index 950a02916..5a203455a 100644 --- a/module/src/ghost.rs +++ b/module/src/ghost.rs @@ -1,5 +1,3 @@ -//! Released ghost state and its racial aura effects. - use spacetimedb::ReducerContext; use crate::{game_aura, game_corpse, game_world_entity}; @@ -12,7 +10,6 @@ pub(crate) fn is_ghost_spell(spell_id: u32) -> bool { matches!(spell_id, GHOST | WISP_SPIRIT) } -/// Reapply the released Character's effects after release, login or Transfer. pub(crate) fn restore(ctx: &ReducerContext, guid: u64) { let Some(character) = ctx.db.game_world_entity().guid().find(guid) else { return; @@ -27,7 +24,6 @@ pub(crate) fn restore(ctx: &ReducerContext, guid: u64) { crate::spell::recompute_display(ctx, guid); } -/// Complete every resurrection after its caller restores health and power. pub(crate) fn finish_resurrection(ctx: &ReducerContext, guid: u64) { if let Some(mut character) = ctx.db.game_world_entity().guid().find(guid) { character.dead = false; diff --git a/module/src/gm.rs b/module/src/gm.rs index 431c8797d..e7e46b7e5 100644 --- a/module/src/gm.rs +++ b/module/src/gm.rs @@ -1,32 +1,8 @@ -//! GM playtest dot-commands: `.speed/.god/.xprate/.level/.money/.heal/.kill/.tele` -//! from Say chat. The gateway's `CMSG_MESSAGECHAT` Say arm intercepts any message starting with `.` -//! BEFORE the normal chat relay/insert and forwards the raw text to this ONE generic reducer -//! (`gm_command`) — module-side parsing keeps the command set data-free and easily extended (a new -//! command is a new `match` arm here, no gateway/binding change). A nonzero `Character.gm_level` -//! authorizes the full set. Account-owned Alpha Test Tools authorize the classified subset, which -//! contains only `.speed` and `.tele`. The Gateway reads that Account authority from Realm-core on -//! every command and conveys it to the Home Shard; this Module remains the final Gate. -//! -//! `.kill` deliberately takes NO explicit target argument: the caller's live `WorldEntity.target_guid` -//! is already tracked server-side by `world::set_target` (`CMSG_SET_SELECTION`) every time the client -//! changes selection, so the reducer just reads its own caller's current target — no extra gateway -//! plumbing (and no guid-through-JSON hazard) needed. - use spacetimedb::{log, reducer, table, ReducerContext, Table}; use crate::helpers::require_operator; use crate::{game_character, game_world_entity}; -// =========================================================================================== -// `.xprate` config singleton [static] — module-only, NEVER gateway-subscribed (no binding work) -// =========================================================================================== - -/// A single named world tunable, keyed by `key` (today just `"xprate"`). Module-only — no client -/// field maps to any row here, so it is never added to the gateway's subscription list and needs no -/// binding/parity work despite being a brand-new table (docs/danger-zones.md §1.6: a `String` -/// PRIMARY KEY is fine; only a `String` `#[default(...)]` on an END-appended column of an EXISTING -/// table is impossible — this is a FRESH table with no existing rows, so no column needs a default -/// at all). Public so `spacetime sql` can read/tune it directly, like `game_config`/`game_debug_readout`. #[table(accessor = game_world_config, public)] pub struct GmWorldConfig { #[primary_key] @@ -34,14 +10,9 @@ pub struct GmWorldConfig { pub value: u32, } -/// The `game_world_config` key for the `.xprate` basis-points multiplier (10000 = 1.0×, the same -/// scale `WorldEntity::run_speed_mult_bp` uses). const XPRATE_KEY: &str = "xprate"; -/// Missing row ⇒ 1× (Blizzlike) — read-with-default, so an untouched/fresh DB behaves exactly as if -/// no GM had ever run `.xprate`. const XPRATE_DEFAULT_BP: u32 = 10_000; -/// Read the current `.xprate` basis-points multiplier (10000 = 1×). Missing row ⇒ the default. pub(crate) fn xprate_bp(ctx: &ReducerContext) -> u32 { ctx.db .game_world_config() @@ -51,11 +22,6 @@ pub(crate) fn xprate_bp(ctx: &ReducerContext) -> u32 { .unwrap_or(XPRATE_DEFAULT_BP) } -// =========================================================================================== -// `.tele` destination table [pure] — a small const list, not a DB table (nothing to migrate) -// =========================================================================================== - -/// A named `.tele` destination. Case-insensitive name match in [`TeleSpot::parse`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TeleSpot { Goldshire, @@ -69,15 +35,10 @@ pub(crate) enum TeleSpot { Darnassus, SentinelHill, WestfallCoast, - /// The ORC start, on map 1 — and the only `.tele` that crosses a continent. Kalimdor lives on - /// its own database, so this destination drives a full escrowed transfer rather than a - /// same-map hop; see `coords`. ValleyOfTrials, } impl TeleSpot { - /// Case-insensitive name → spot. `None` for an unrecognized name (the caller turns that into the - /// "unknown location" `Err`). Pure. fn parse(name: &str) -> Option { match name.to_ascii_lowercase().as_str() { "goldshire" => Some(Self::Goldshire), @@ -96,9 +57,7 @@ impl TeleSpot { } } - /// `(map_id, x, y, z, orientation)`. Map 0 destinations use the same-map path. Map 1 - /// destinations use the established cross-map Transfer path. - #[allow(clippy::excessive_precision)] // The Spec supplies these f32 coordinates verbatim. + #[allow(clippy::excessive_precision)] pub(crate) fn coords(self) -> (u32, f32, f32, f32, f32) { match self { Self::Northshire => (0, -8949.95, -132.493, 83.5312, 0.0), @@ -117,34 +76,17 @@ impl TeleSpot { } } -// =========================================================================================== -// Command parsing [pure] — module-side, data-free (the gateway hands over the raw dot-string) -// =========================================================================================== - -/// One parsed `.command`, ready to dispatch. `God(None)` = toggle (no arg); `Speed`/`XpRate`/`Level` -/// carry their ALREADY-CLAMPED value (parsing clamps silently rather than rejecting an out-of-range -/// number — only a non-numeric/missing arg or an unknown command name is a hard `Err`). #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum GmCommand { - /// Run-speed multiplier, clamped to `0.5..=10.0`. Speed(f32), - /// `None` = toggle; `Some(v)` = the explicit `on`/`off` state. God(Option), - /// XP-rate multiplier (whole number), clamped to `1..=20`. XpRate(u32), - /// Target level, clamped to `1..=60`. Level(u32), - /// Purse amount in copper (unclamped — any `u32` is a legal purse). Money(u32), Heal, Kill, - /// Report the caller's own position (map/x/y/z/orientation) back as a system line — a read-only - /// world-building lever (place NPCs/spawns at coords you're standing on). Reported via the Err - /// channel since an accepted command has no success reply. Gps, Tele(TeleSpot), - /// Grant `count` of item `entry` to the caller's bags (playtest lever — testing gear/stat - /// changes without vendor runs). Count defaults to 1, clamped to `1..=20`. AddItem(u32, u32), } @@ -274,22 +216,10 @@ pub(crate) fn parse_gm_command(text: &str) -> Result { } } -// =========================================================================================== -// Reducers -// =========================================================================================== - -/// Grant/revoke GM level (operator-only CLI reducer): `level` gates -/// [`gm_command`] (`0` = no access — the moderation-facing per-level distinctions beyond "has -/// access at all" are out of scope). Looked up by character NAME -/// (case-insensitive, as `/w bob` reaches Bob) since an operator drives this via -/// `spacetime call`, which has no live guid to hand. `require_operator` gates it exactly like the -/// importer reducers — never player-callable. #[reducer] pub fn set_gm_level(ctx: &ReducerContext, character_name: String, level: u8) -> Result<(), String> { require_operator(ctx)?; let chars = ctx.db.game_character(); - // REFUSE verdict: an in-transit character reads as absent, so this operator write - // cannot land on a source copy the destination already serialized past. let mut c = crate::helpers::character_by_name(ctx, &character_name) .ok_or_else(|| format!("no player named {character_name}"))?; let guid = c.guid; @@ -299,8 +229,6 @@ pub fn set_gm_level(ctx: &ReducerContext, character_name: String, level: u8) -> Ok(()) } -/// The shared core behind the gateway's `gw_gm_command`. The Character's current GM level and the -/// Account authority conveyed by the Gateway meet at this final Module Gate. pub(crate) fn apply_gm_command( ctx: &ReducerContext, caller: crate::WorldEntity, @@ -340,7 +268,7 @@ pub(crate) fn apply_gm_command( GmCommand::XpRate(mult) => { let cfg = ctx.db.game_world_config(); let bp = mult * 10_000; - cfg.key().delete(XPRATE_KEY.to_string()); // upsert + cfg.key().delete(XPRATE_KEY.to_string()); cfg.insert(GmWorldConfig { key: XPRATE_KEY.to_string(), value: bp, @@ -365,16 +293,12 @@ pub(crate) fn apply_gm_command( entities.guid().update(caller); } GmCommand::Gps => { - // Read-only: report position via the Err channel (the only caller-facing text path — an - // accepted command has no success reply). No mutation, so the Err rollback is a no-op. return Err(format!( "Position: map {} | x {:.2} y {:.2} z {:.2} | o {:.3}", caller.map_id, caller.x, caller.y, caller.z, caller.orientation )); } GmCommand::AddItem(entry, count) => { - // Reuses the debug/quest grant path — bag-space + stack rules enforced there; an - // unknown entry or full bags surface as the Err the sender sees as a system message. crate::items::grant_item(ctx, caller.guid, entry, count)?; } GmCommand::Kill => { @@ -395,10 +319,6 @@ pub(crate) fn apply_gm_command( } GmCommand::Tele(spot) => { let (map_id, x, y, z, o) = spot.coords(); - // `map_id` comes from the destination, so a cross-map `.tele` (ValleyOfTrials, map 1) - // rides the same primitive and becomes a full escrowed transfer to whichever database - // owns that map. Deadmines is still deliberately absent: map 36 has no imported world - // data, so it would .tele into an empty room. crate::world::teleport_player(ctx, caller.guid, map_id, 0, x, y, z, o); } } @@ -470,8 +390,6 @@ mod tests { assert_eq!(gm_authority(1, true), GmAuthority::FullGm); } - // ---- Parsing: happy path, one per command ---- - #[test] fn parses_speed_in_range() { assert_eq!(parse_gm_command(".speed 3"), Ok(GmCommand::Speed(3.0))); @@ -534,7 +452,7 @@ mod tests { } #[test] - #[allow(clippy::excessive_precision)] // The expected values pin the Spec's f32 coordinates. + #[allow(clippy::excessive_precision)] fn canonical_tele_destinations_have_the_specified_coordinates() { let destinations = [ ( @@ -603,8 +521,6 @@ mod tests { } } - // ---- Clamping ---- - #[test] fn speed_clamps_to_0_5_through_10() { assert_eq!(parse_gm_command(".speed 0.01"), Ok(GmCommand::Speed(0.5))); @@ -633,8 +549,6 @@ mod tests { assert_eq!(parse_gm_command(".level 255"), Ok(GmCommand::Level(60))); } - // ---- Bad args ---- - #[test] fn bad_or_missing_numeric_args_are_rejected() { assert!(parse_gm_command(".speed").is_err(), "missing arg"); @@ -663,8 +577,6 @@ mod tests { assert!(parse_gm_command(".tele").is_err()); } - // ---- Unknown command / malformed input ---- - #[test] fn unknown_command_is_rejected() { assert!(parse_gm_command(".nuke").is_err()); @@ -677,15 +589,11 @@ mod tests { assert!(parse_gm_command("hello world").is_err()); } - // ---- xprate bp math (pure, read side) ---- - #[test] fn xprate_default_bp_is_one_x() { assert_eq!(XPRATE_DEFAULT_BP, 10_000); } - // ---- Tele map paths ---- - #[test] fn eastern_kingdoms_destinations_use_the_same_map_path() { for spot in [ diff --git a/module/src/go_collider.rs b/module/src/go_collider.rs index da5f721bd..0febcdf65 100644 --- a/module/src/go_collider.rs +++ b/module/src/go_collider.rs @@ -1,5 +1,3 @@ -//! Closed DOOR and BUTTON collision, scoped to one map and instance. - use crate::gameobject::{game_gameobject, game_gameobject_template, GameObject}; use crate::go_model::game_go_model; use lyracore_shared::vmap::{decode, segment_tri_hit, supports_walking}; @@ -18,7 +16,6 @@ pub struct GoCollider { pub radius_world: f32, } -/// Replace derived registration from the current spawn and model. Open doors remain registered. pub(crate) fn register(ctx: &ReducerContext, go: &GameObject) { remove(ctx, go.guid); let Some(template) = ctx @@ -35,8 +32,7 @@ pub(crate) fn register(ctx: &ReducerContext, go: &GameObject) { let Some(model) = ctx.db.game_go_model().entry().find(go.template_entry) else { return; }; - // Older imports stored an AABB-centered radius without its center. Derive the origin - // bound from vertices so off-center meshes cannot miss the broad phase. + // Derive an origin bound from vertices; the imported AABB radius lacks its off-center origin. let Ok(tris) = decode(&model.blob) else { return; }; @@ -69,7 +65,6 @@ pub(crate) fn remove(ctx: &ReducerContext, guid: u64) { ctx.db.game_go_collider().go_guid().delete(guid); } -/// Operator reconciliation covers every partition hosted by this Shard. #[reducer] pub fn rebuild_go_colliders(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -102,7 +97,6 @@ fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { ] } -/// Conservative broad phase, including tangency and a point segment inside the sphere. fn overlaps_sphere(a: [f32; 3], b: [f32; 3], center: [f32; 3], radius: f32) -> bool { let dir = sub(b, a); let len_sq = dot(dir, dir); @@ -116,7 +110,6 @@ fn overlaps_sphere(a: [f32; 3], b: [f32; 3], center: [f32; 3], radius: f32) -> b dot(offset, offset) <= radius * radius + 0.0001 } -/// Inverse of scale, normalized spawn quaternion and translation. Zero quaternion means yaw. fn local_point( point: [f32; 3], position: [f32; 3], @@ -144,7 +137,6 @@ fn local_point( local.iter().all(|v| v.is_finite()).then_some(local) } -/// Nearest world hit. Triangle class is irrelevant for state-controlled geometry. pub(crate) fn ray( ctx: &ReducerContext, map_id: u32, @@ -155,7 +147,6 @@ pub(crate) fn ray( cast(ctx, map_id, instance_id, a, b, false) } -/// Nearest walkable support in a vertical world probe, including rotated closed doors. pub(crate) fn floor_ray( ctx: &ReducerContext, map_id: u32, @@ -288,7 +279,7 @@ mod tests { #[test] fn stored_quaternion_overrides_yaw_and_rotates_out_of_the_horizontal_plane() { - let q = [1.0, 0.0, 0.0, 1.0]; // Normalizes to a quarter turn about X. + let q = [1.0, 0.0, 0.0, 1.0]; near( local_point([2.0, -6.0, 4.0], [0.0; 3], q, 2.0, 2.0).unwrap(), [1.0, 2.0, 3.0], diff --git a/module/src/go_collider_fixture.rs b/module/src/go_collider_fixture.rs index 5f25e4b66..c989a6ddd 100644 --- a/module/src/go_collider_fixture.rs +++ b/module/src/go_collider_fixture.rs @@ -1,4 +1,4 @@ -//! Synthetic durable verification. Even success aborts the transaction to preserve existing rows. +//! Even successful verification aborts the transaction to preserve existing rows. use crate::game_world_entity; use crate::gameobject::game_gameobject; @@ -9,7 +9,6 @@ use spacetimedb::{reducer, ReducerContext, Table}; const MAP: u32 = 36; const ENTRY: u32 = 509_113; -// The ray misses the spawn origin, so an incorrectly centered tiny radius would discard it. const A: [f32; 3] = [-4.0, 0.5, 0.0]; const B: [f32; 3] = [4.0, 0.5, 0.0]; @@ -149,7 +148,6 @@ fn package_lifecycle(ctx: &ReducerContext) -> Result<(), String> { } fn packed_model(entry: u32, scale: f32) -> String { - // Off-center local geometry exposes an incorrect spawn-centered use of an AABB radius. let tris = [VmapTri { verts: [[1.0, -2.0, -2.0], [1.0, 2.0, -2.0], [1.0, 0.0, 2.0]], class: TriClass::M2, @@ -345,7 +343,6 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { ctx.db.game_gameobject().guid().update(closed); rays(ctx, first, Some(1.0))?; - // Static and dynamic hits compete by distance, independent of insertion order. { use crate::vmap::{ game_vmap_generation, game_vmap_generation_chunk, VmapGeneration, VmapGenerationChunk, @@ -397,7 +394,6 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { ctx.db.game_vmap_generation().id().delete(generation_id); } - // Model replacement refreshes every registered instance and its broad-phase bound. crate::go_model::import_go_models_append(ctx, packed_model(ENTRY, 2.0))?; rays(ctx, first, Some(2.0))?; rays(ctx, sibling, Some(2.0))?; @@ -421,7 +417,6 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { )?; crate::instance::teardown_instance(ctx, first); - // The durable ray uses the spawn's current yaw/quaternion, translation and model scale. let mut transformed = door(ctx, 0)?; transformed.x = 10.0; transformed.y = 20.0; @@ -440,7 +435,6 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { } walking_support(ctx)?; - // A stale registry row cannot resurrect a deleted GameObject. let source = door(ctx, 0)?.guid; ctx.db.game_gameobject().guid().delete(source); for hit in [ @@ -455,7 +449,6 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { "rebuild retained orphan", )?; - // Both supported types register; an unrelated prop with a model does not. for (entry, type_id, expected) in [(ENTRY + 1, 1, true), (ENTRY + 2, 3, false)] { crate::debug::debug_spawn_gameobject( ctx, entry, type_id, 0, 0, MAP, 0.0, 0.0, 0.0, 0, 0, 0, 0, diff --git a/module/src/go_model.rs b/module/src/go_model.rs index 334db41e7..ff7ef83be 100644 --- a/module/src/go_model.rs +++ b/module/src/go_model.rs @@ -1,27 +1,14 @@ -//! Imported DOOR and BUTTON meshes in model-local coordinates. -//! Registration derives conservative world bounds; rays read current spawn state and transform. - use spacetimedb::{reducer, table, ReducerContext, Table}; #[table(accessor = game_go_model)] pub struct GoModel { #[primary_key] pub entry: u32, - /// `gameobject_template.size` (render/collision scale), carried verbatim from the dump. pub scale: f32, - /// Importer's AABB-centered radius. Registration derives an origin bound from the vertices. pub radius: f32, - /// `lyracore_shared::vmap`-codec local-space triangles. The class tag the codec carries is - /// unused here — a door's dynamic-ray participation is decided per `game_gameobject` row - /// (state/liveness), not per triangle class. pub blob: Vec, } -// =========================================================================================== -// Import reducers — same packed-string convention as `vmap::import_vmap_chunks`/`_append`: -// rows `;`, fields `,`, the blob hex LAST so `splitn` keeps it intact. -// =========================================================================================== - fn hex_decode(s: &str) -> Result, String> { if !s.len().is_multiple_of(2) { return Err("odd-length hex blob".to_string()); @@ -40,7 +27,6 @@ fn hex_decode(s: &str) -> Result, String> { .collect() } -/// One packed row: `entry,scale,radius,blob_hex`. fn parse_row(row: &str) -> Result<(u32, f32, f32, Vec), String> { let f: Vec<&str> = row.splitn(4, ',').collect(); if f.len() != 4 { @@ -75,8 +61,6 @@ fn parse_row(row: &str) -> Result<(u32, f32, f32, Vec), String> { Ok((entry, scale, radius, blob)) } -/// Load one batch, REPLACING any row whose entry repeats (retry-safe: re-appending the same -/// entry with the same bytes is a no-op in effect, and a corrected re-extract simply overwrites). fn load_go_model_batch(ctx: &ReducerContext, packed: &str) -> Result { let models = ctx.db.game_go_model(); let mut loaded = 0u32; @@ -94,7 +78,6 @@ fn load_go_model_batch(ctx: &ReducerContext, packed: &str) -> Result Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -109,8 +92,6 @@ pub fn import_go_models(ctx: &ReducerContext, packed: String) -> Result<(), Stri crate::go_collider::rebuild_go_colliders(ctx) } -/// Append a go-model batch WITHOUT the reset — a full catalogue can span many `spacetime call` -/// args, same reason `import_vmap_chunks_append` exists. #[reducer] pub fn import_go_models_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 6fed081bb..1092ef266 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -1,15 +1,5 @@ -//! -//! Graveyard resolution and its imported tables. `tables.rs` exports the accessors and row types. - use spacetimedb::{table, ReducerContext, Table}; -/// One `WorldSafeLocs.dbc` row (a graveyard's fixed position) — imported by the importer's `--dbc` -/// mode (see `importer/src/dbc.rs::graveyard_sql`). Replaces the hardcoded -/// `{NORTHSHIRE, GOLDSHIRE, ...}` consts below as the primary data source once imported; those -/// consts remain as the no-import fallback (`nearest`), and `seed.rs` row-seeds the SAME five points -/// here too, mirroring the `game_start_position` seed/import precedent (init seeds, import -/// clear+reloads over it). No orientation column — `WorldSafeLocs.dbc` carries none (a graveyard -/// release always faces 0.0). No Timestamp → plain SQL. [static] #[table(accessor = game_graveyard, public)] pub struct GraveyardLoc { #[primary_key] @@ -23,12 +13,6 @@ pub struct GraveyardLoc { pub name: String, } -/// One cmangos `game_graveyard_zone` row — links a `game_graveyard.id` (`safe_loc_id`) to the zone -/// it serves, with an optional faction restriction (0 = both factions; else the cmangos team-faction -/// id, see `team_for_race`). Imported by the importer's `--dump` mode; see -/// `importer/src/main.rs::build_graveyard_zone_sql`. `resolve_graveyard` reads it via `by_zone` to -/// prefer a zone-linked graveyard over a merely-closer unlinked one (the cmangos release rule). No -/// Timestamp → plain SQL. [static] #[table( accessor = game_graveyard_zone, public, @@ -52,7 +36,6 @@ pub(crate) struct Graveyard { pub(crate) o: f32, } -// world_safe_locs id 105 — Northshire Abbey const NORTHSHIRE: Graveyard = Graveyard { map: 0, x: -8935.33, @@ -61,7 +44,6 @@ const NORTHSHIRE: Graveyard = Graveyard { o: 2.72271, }; -// world_safe_locs id 106 — Goldshire const GOLDSHIRE: Graveyard = Graveyard { map: 0, x: -9339.59, @@ -70,7 +52,6 @@ const GOLDSHIRE: Graveyard = Graveyard { o: 0.0, }; -// world_safe_locs id 854 — Eastvale Logging Camp const EASTVALE: Graveyard = Graveyard { map: 0, x: -9552.73, @@ -79,26 +60,21 @@ const EASTVALE: Graveyard = Graveyard { o: 0.0, }; -// world_safe_locs id ≈80 [V], Sentinel Hill (Westfall, zone 40). Hand-added for -// the Westfall 1-20 slice; coords are an UNVERIFIED estimate (this sandbox has no reference -// world-database to read the real row from); orientation defaults to 0.0 (not sourced). CONFIRM -// x/y/z against your own imported world_safe_locs before relying on this for a live release. +// These Westfall fallback coordinates are unsourced estimates. const SENTINEL_HILL: Graveyard = Graveyard { map: 0, - x: -10650.0, // [V] - y: 1180.0, // [V] - z: 34.0, // [V] - o: 0.0, // [V] — orientation not sourced + x: -10650.0, + y: 1180.0, + z: 34.0, + o: 0.0, }; -// world_safe_locs id ≈81 [V] — Westfall coast (the western shoreline graveyard, zone 40). Same -// provenance caveat as SENTINEL_HILL: coords UNVERIFIED. const WESTFALL_COAST: Graveyard = Graveyard { map: 0, - x: -11390.0, // [V] - y: 1590.0, // [V] - z: 6.0, // [V] - o: 0.0, // [V] — orientation not sourced + x: -11390.0, + y: 1590.0, + z: 6.0, + o: 0.0, }; const STATIC_CANDIDATES: [Graveyard; 5] = [ @@ -109,14 +85,10 @@ const STATIC_CANDIDATES: [Graveyard; 5] = [ WESTFALL_COAST, ]; -// ---- Instance-map release -------------------------------------------------- -// - pub(crate) fn instance_release_zone(map_id: u32) -> Option { crate::instance::dungeon(map_id).map(|d| d.release_zone) } -/// Bootstrap release location when the fixture has no imported graveyards. pub(crate) fn instance_static_fallback(map_id: u32) -> Option { crate::instance::dungeon(map_id).map(|d| { let (map, x, y, z, o) = d.release_fallback; @@ -133,9 +105,6 @@ pub(crate) fn pick_instance_graveyard( nearest_of(linked, px, py).or(static_fallback) } -/// The pure nearest-by-squared-2D-distance picker, shared by every candidate source (the static -/// fallback list AND the DB-resolved zone-linked/all-imported lists `resolve_graveyard` builds). -/// `None` for an empty slice — never panics; the caller chains to the next fallback. pub(crate) fn nearest_of(candidates: &[Graveyard], px: f32, py: f32) -> Option { candidates .iter() @@ -147,10 +116,6 @@ pub(crate) fn nearest_of(candidates: &[Graveyard], px: f32, py: f32) -> Option Graveyard { nearest_of(&STATIC_CANDIDATES, px, py).unwrap_or(NORTHSHIRE) } @@ -166,10 +131,6 @@ pub(crate) fn pick_graveyard( .unwrap_or_else(|| nearest(px, py)) } -/// The team-faction id `game_graveyard_zone.faction` carries, for a character's race. Moved to -/// `lyracore_shared::faction` when the mail faction gate needed it: that gate runs in the GATEWAY -/// (realm-core holds no characters), and a second copy of the race table over there would agree -/// with this one only until somebody added a race to one of them. pub(crate) use lyracore_shared::faction::team_for_race; fn to_gy(g: GraveyardLoc) -> Graveyard { @@ -182,10 +143,6 @@ fn to_gy(g: GraveyardLoc) -> Graveyard { } } -/// Zone-linked graveyards for `zone_id`, faction-filtered (`faction == 0 || faction == team`), -/// resolved to positions via `game_graveyard`. A `game_graveyard_zone` row whose `safe_loc_id` has -/// no matching `game_graveyard` row is silently dropped — an inconsistent import shouldn't panic a -/// release. fn zone_linked( ctx: &ReducerContext, zone_id: u32, @@ -203,9 +160,6 @@ fn zone_linked( .collect() } -/// Every imported graveyard on `map_id`, regardless of zone — the fallback once zone-scoping -/// isn't available or didn't resolve (see `resolve_graveyard`). Map-only — same RESOLVED note -/// as `zone_linked`: only non-instance-map releases ever reach this chain. fn all_on_map(ctx: &ReducerContext, map_id: u32) -> Vec { ctx.db .game_graveyard() @@ -215,10 +169,6 @@ fn all_on_map(ctx: &ReducerContext, map_id: u32) -> Vec { .collect() } -/// The real entry point for a release: resolve the nearest graveyard to `(x, y)` on `map_id` for -/// a player of `race`, per the fallback chain zone-linked → all-imported-on-map → static consts -/// (see `pick_graveyard`). Never panics on an empty DB — every step degrades gracefully; the -/// static consts are the final floor. pub(crate) fn resolve_graveyard( ctx: &ReducerContext, map_id: u32, @@ -285,9 +235,6 @@ mod tests { #[test] fn deadmines_release_resolves_cross_map_to_westfall_never_by_raw_distance() { - // Map 36 is an instance map: its release zone is The Deadmines (1581 [V]) and its no-import - // static floor is Sentinel Hill on MAP 0 — the release itself is cross-map by design - // (corpse stays on 36; the ghost walks back in through the portal). assert_eq!(instance_release_zone(36), Some(1581)); assert_eq!( instance_release_zone(0), @@ -306,9 +253,6 @@ mod tests { ); assert!(instance_static_fallback(0).is_none()); - // The pick order: an imported zone-linked external graveyard wins over the static floor; - // with nothing imported the floor applies; a non-instance map (no floor, no links) yields - // None so the caller falls through to the normal open-world chain. let linked = Graveyard { map: 0, x: -10600.0, @@ -334,7 +278,6 @@ mod tests { #[test] fn nearest_graveyard_picks_the_closest_of_the_three_by_squared_distance() { - // Dying at each graveyard's own coordinates picks that graveyard (distance 0 beats the others). let n = nearest(-8935.33, -188.646); assert_eq!( (n.x, n.y), @@ -353,23 +296,14 @@ mod tests { (-9552.73, -1374.84), "Eastvale death releases at Eastvale" ); - // A south-east Elwynn death picks Eastvale (~380yd) over Northshire (~990yd) and Goldshire - // (~1180yd) — the exact wrong-direction corpse run `nearest` exists to avoid. let s = nearest(-9500.0, -1000.0); assert_eq!((s.x, s.y), (-9552.73, -1374.84)); - // a Westfall-ish death point (near Sentinel Hill) picks Sentinel Hill over - // Eastvale — the nearest Elwynn graveyard is nowhere close once Westfall is in the candidate - // set (Sentinel Hill ~95yd away vs Eastvale's ~2700yd), proving the two new graveyards - // actually widen the release map rather than sitting unreachable in the candidate slice. let w = nearest(-10700.0, 1100.0); assert_eq!((w.x, w.y), (-10650.0, 1180.0)); } #[test] fn pick_graveyard_prefers_a_zone_linked_graveyard_over_a_closer_unlinked_one() { - // game_graveyard_zone's whole point is that a graveyard EXPLICITLY linked to - // the death position's resolved zone wins even when an unlinked (or wrong-zone) graveyard - // sits geometrically closer — the cmangos release rule. let close_unlinked = Graveyard { map: 0, x: 0.0, @@ -396,8 +330,6 @@ mod tests { #[test] fn pick_graveyard_falls_back_through_all_then_to_the_static_consts() { - // No zone-linked candidates (empty zone-link set, e.g. an unresolved zone) → falls to `all` - // (every imported graveyard on the map). let only_all = [Graveyard { map: 0, x: 42.0, @@ -407,8 +339,6 @@ mod tests { }]; let picked = pick_graveyard(&[], &only_all, 0.0, 0.0); assert_eq!((picked.x, picked.y), (42.0, 42.0)); - // BOTH empty (nothing imported at all) → falls to the static consts (never panics), matching - // `nearest` directly. let picked_static = pick_graveyard(&[], &[], -10700.0, 1100.0); let expected = nearest(-10700.0, 1100.0); assert_eq!((picked_static.x, picked_static.y), (expected.x, expected.y)); @@ -416,14 +346,14 @@ mod tests { #[test] fn team_for_race_maps_alliance_and_horde_and_defaults_alliance() { - assert_eq!(team_for_race(1), 469); // Human - assert_eq!(team_for_race(3), 469); // Dwarf - assert_eq!(team_for_race(4), 469); // Night Elf - assert_eq!(team_for_race(7), 469); // Gnome - assert_eq!(team_for_race(2), 67); // Orc - assert_eq!(team_for_race(5), 67); // Undead - assert_eq!(team_for_race(6), 67); // Tauren - assert_eq!(team_for_race(8), 67); // Troll + assert_eq!(team_for_race(1), 469); + assert_eq!(team_for_race(3), 469); + assert_eq!(team_for_race(4), 469); + assert_eq!(team_for_race(7), 469); + assert_eq!(team_for_race(2), 67); + assert_eq!(team_for_race(5), 67); + assert_eq!(team_for_race(6), 67); + assert_eq!(team_for_race(8), 67); assert_eq!( team_for_race(200), 469, diff --git a/module/src/group.rs b/module/src/group.rs index c96dce183..72310a2a3 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1,39 +1,3 @@ -//! Party/group system: invite → accept/decline handshake, membership, leave/kick/ -//! disband, and the group-scoped kill rewards (XP split + shared quest credit). -//! -//! Shape mirrors the friend/ignore system: the GATEWAY resolves typed names to guids -//! (`character_guid_by_name`, the `/who` lookup) and the reducers re-validate server-side off -//! `ctx.sender()`'s in-world character. Cross-player notifications ride a `game_group_event` row -//! (RLS-scoped to the recipient, the `game_whisper_event` pattern) that the gateway relays as -//! `SMSG_GROUP_INVITE` / `SMSG_GROUP_LIST` / `SMSG_GROUP_DECLINE` / `SMSG_GROUP_DESTROYED`; LIST -//! events carry the roster snapshot in their payload, built in the SAME transaction as the -//! membership change (a relay-time coordinator read would race the event across connections). -//! -//! **Where this state LIVES (group slice).** `game_group` / `game_group_member` / -//! `game_group_invite` are authoritative on **realm-core** — none of the three is coupled to space, -//! and all three broke the moment a second database existed (a player inside Deadmines could not -//! invite one in Elwynn: `invite_core` resolved the target inside the CALLING database and a -//! character on another shard has no row there). The gateway drives them there through the -//! operator-gated [`realm_group_op`], and each world shard's copy is a write-through cache it -//! refreshes with [`sync_group_mirror`] — the `game_account`/`game_session` relationship. -//! A single-database deployment has no realm-core to route to and keeps calling the player-facing -//! reducers below exactly as before; the whole split is a gateway routing decision, not a schema one. -//! -//! **Server-driven invites.** A session-less Character's invite has no client and no -//! `ctx.sender()` to resolve — it is a decision a Package makes inside the Module. It cannot write -//! group rows for the same reason a player's cross-shard invite cannot: only the gateway can reach -//! realm-core. [`BotInviteIntent`] is the module's half of that split — a DECISION, not a write — -//! picked up by `gateway/src/world/party.rs`'s `run_bot_invite`. The same row carries a bot's -//! decision to LEAVE its party, which meets the identical authority wall. -//! -//! Vanilla-parity notes: a Party caps at 5 members. Its leader may convert it to a Raid of up to 40 -//! in 8 Subgroups, and it never converts back. A Raid leader promotes Assistants, who may also -//! invite and remove members, and the first Assistant leads when the leader leaves. Kill XP splits -//! EVENLY among in-range living members (each member's grey-clamp applies to their OWN level, so -//! a too-high member naturally gets 0). Vanilla's sum-of-levels weighting, the 3/4/5-member bonus -//! multipliers and the raid XP rate are a documented follow-up. Kill quest-credit goes to every -//! in-range member (vanilla). - mod mirror; #[cfg_attr(not(feature = "debug_reducers"), allow(unused_imports))] pub use mirror::sync_group_mirror; @@ -48,8 +12,6 @@ use lyracore_shared::group::{ TARGET_ICON_COUNT, TARGET_ICON_LIST_REQUEST, }; -/// Group kill-reward radius² — members farther than this from the slain creature get neither XP -/// nor quest credit. Vanilla's `sWorld.getConfig(CONFIG_FLOAT_GROUP_XP_DISTANCE)` = 74.0 yd. pub const GROUP_XP_RANGE_SQ: f32 = 74.0 * 74.0; pub mod loot_method { @@ -60,12 +22,6 @@ pub mod loot_method { pub const NEED_BEFORE_GREED: u8 = 4; } -/// A Group: a Party, or a Raid its leader converted. `leader_guid` is a member's character guid; -/// leadership transfers on leader-leave and the group disbands below 2 members. Public + no RLS -/// (membership is world-visible state, like `game_world_entity`). `SMSG_GROUP_LIST` is driven by -/// the module's LIST event rows (`roster_payload`), not by a read of this table. The Gateway -/// coordinator caches it to read Realm-core's roster and push each World Shard's mirror. -/// [entity] #[table(accessor = game_group, public)] pub struct Group { #[primary_key] @@ -73,21 +29,18 @@ pub struct Group { pub group_id: u64, pub leader_guid: u64, - #[default(3)] // loot_method::GROUP + #[default(3)] pub loot_method: u8, - #[default(2)] // ItemQuality::Uncommon + #[default(2)] pub loot_threshold: u8, #[default(0)] pub rr_cursor: u32, #[default(0u64)] pub master_looter_guid: u64, - /// [`GroupKind`] as its wire byte. END-appended, so every earlier row reads as a Party. #[default(0u8)] pub group_type: u8, } -/// One member row per character in a group. `character_guid` is unique across the table — a -/// character is in at most one group. [entity] #[table( accessor = game_group_member, public, @@ -101,15 +54,11 @@ pub struct GroupMember { pub group_id: u64, pub character_guid: u64, pub owner_identity: Identity, - /// [`RaidSlot`] as its wire byte. END-appended, so every earlier member reads as Subgroup 0 - /// without the Assistant flag, which every Party member is. #[default(0u8)] pub raid_slot: u8, } -/// Realm-core's order for one complete party roster and each World Shard mirror's last accepted -/// value. The row survives disband so a delayed pre-disband snapshot cannot recreate the party. -/// Realm-core owns increments; World Shards only copy the supplied value in [`sync_group_mirror`]. +// Keep this revision after disband so a delayed roster cannot recreate the party. #[table(accessor = game_group_roster_revision, public)] pub struct GroupRosterRevision { #[primary_key] @@ -118,10 +67,6 @@ pub struct GroupRosterRevision { pub active: bool, } -/// Realm-core-certified partition state for one mirrored party member. This local projection -/// carries no position and names no Shard. The Realm membership row and locator revisions order -/// delayed Gateway fanout without relying on clocks from different databases. -/// [entity] #[table( accessor = game_group_member_partition, public, @@ -131,13 +76,11 @@ pub struct GroupMemberPartition { #[primary_key] pub character_guid: u64, pub group_id: u64, - /// The authoritative Realm-core `game_group_member.id`, never a World Shard mirror row id. pub membership_revision: u64, - /// False is a retained removal fence. A later join must carry a newer membership revision. + // A removed member remains fenced until a newer membership revision arrives. pub member_active: bool, pub map_id: u32, pub instance_id: u64, - /// Realm-core `game_character_shard.revision`. pub locator_revision: u64, pub state: PartyPartitionState, } @@ -157,9 +100,6 @@ crate::character_owned!(delete, fn sweep_delete_game_group_member_partition(ctx, }); crate::character_owned!(not_transported, fn sweep_transfer_game_group_member_partition()); -// Character-owned sweeps: a deleted character leaves its group through the same -// leader-transfer/disband logic a voluntary leave uses — never a bare row delete, which would -// orphan leadership or leave a 1-member group alive. crate::character_owned!(delete, fn sweep_delete_game_group_member(ctx, character_guid) { remove_member(ctx, character_guid, Departure::Left); }); @@ -173,34 +113,10 @@ crate::character_owned!(restamp, fn sweep_restamp_game_group_member(ctx, charact } }); -// CROSS-DATABASE transport (group slice): membership does NOT ride the export blob. -// -// Shipped an interim MIRROR here — the member row travelled in the manifest carrying its -// original `group_id`, and an `ensure_group` helper re-created the parent `game_group` row at the -// destination. Its own AC called for that mirror to be deleted once membership had one -// authoritative home, and the group slice gives it one: `game_group` / `game_group_member` / `game_group_invite` -// are authoritative on REALM-CORE, and each world shard's copy is a gateway-maintained write-through -// cache (`sync_group_mirror`) — the same relationship `game_account` / `game_session` have. -// -// So the blob must not carry membership: it would race the authority. The gateway re-pushes the -// realm-core roster onto the destination at world entry (`world::party::on_world_entry`), which is -// strictly better than the snapshot — a party SPLIT across the boundary re-syncs both sides, where -// the snapshot could only carry what the character had when it stepped into the portal. -// -// This also settles the three OPEN entries on the in-transit exception list (`transfer/mod.rs`): -// a third party's accept/kick/leave for an in-transit character is now a REALM-CORE write, so -// there is no source-copy write left to lose. +// Membership snapshots must not travel because they can overwrite a newer Realm-core roster. crate::character_owned!(not_transported, fn sweep_transfer_game_group_member()); -/// Drop a character's MIRROR row on the shard it is leaving, without the leave/disband semantics -/// (re-scoped). -/// -/// `transfer::do_finish` calls this immediately before `cascade_delete_character` tears the source -/// copy down. A shard hop is not a departure: running `remove_member` would fire DESTROYED at the -/// remaining members and DISBAND a two-person party the moment its first member stepped through the -/// portal. On realm-core that would be worse than wrong — it would be a MIRROR inventing a membership -/// change and notifying clients about it, when the authority (realm-core) recorded nothing at all. -/// Deleting the row and nothing else is exactly what a departing cache entry should do. +// Transfer removes the local mirror without leave or disband semantics. pub(crate) fn detach_for_transfer(ctx: &ReducerContext, character_guid: u64) { let members = ctx.db.game_group_member(); for m in members @@ -212,10 +128,6 @@ pub(crate) fn detach_for_transfer(ctx: &ReducerContext, character_guid: u64) { } } -/// A pending invite: at most one per target (a newer invite replaces it). Consumed by -/// accept/decline; a never-answered invite is reaped by the event GC on its own 2-minute TTL -/// (long enough to answer the dialog, short enough not to leak — see `gc.rs`). Private — -/// only the module reads it. [entity] #[table(accessor = game_group_invite, index(accessor = by_target, btree(columns = [target_guid])))] pub struct GroupInvite { #[primary_key] @@ -224,18 +136,10 @@ pub struct GroupInvite { pub target_guid: u64, pub inviter_guid: u64, pub created_at: Timestamp, - /// The Group the invite joins, or 0 when the inviter had none. As in cmangos, an accept joins - /// this Group whatever the inviter did since, and fails once the Group is gone. The first - /// accept of a 0 invite forms the inviter's Group and stamps it onto the inviter's other - /// pending invites. A row from before this column reads 0. #[default(0u64)] pub group_id: u64, } -// Character-owned sweep: a deleted character's pending invites go with it — rows where it -// is the TARGET (indexed) and rows where it is the INVITER (scan; the table only ever holds -// currently-pending invites, so it stays tiny). Delete-only: GroupInvite carries no -// owner_identity, so there is nothing to restamp. crate::character_owned!(delete, fn sweep_delete_game_group_invite(ctx, character_guid) { let invites = ctx.db.game_group_invite(); for inv in invites.by_target().filter(&character_guid).collect::>() { @@ -250,34 +154,8 @@ crate::character_owned!(delete, fn sweep_delete_game_group_invite(ctx, character invites.id().delete(id); } }); -// CROSS-DATABASE transport: a pending invite is a 2-minute dialog with a GC TTL, and -// the inviter is by definition NOT transferring with the target (they are still standing in the -// open world). Carrying it would pop an invite dialog for a party the arriving character cannot see. -// Not transported, by decision — the invite dies with the source copy, exactly as a decline would. crate::character_owned!(not_transported, fn sweep_transfer_game_group_invite()); -/// A bot-initiated invite the MODULE has DECIDED but cannot execute (closing the gap -/// the group slice opened): `game_group`/`game_group_member` are authoritative on realm-core, and -/// only the gateway can reach it — the module never has. Before this table existed, a Package's -/// server-driven invite called [`invite_core`] directly, writing this shard's LOCAL -/// `game_group`/`game_group_member` rows, which realm-core had never heard of; the next -/// `sync_group_mirror` push then wiped the party the mirror did not recognise. -/// -/// The fix splits the decision from the execution, the same way `world::party` already splits a -/// player's own invite: the module picks the fellow quester (it already owns the spatial + quest -/// data that decision needs) and writes it HERE instead of writing group rows; the gateway's -/// `world::party::run_bot_invite` picks the row up and runs the SAME `realm_group_op` a player's own -/// CMSG_GROUP_INVITE would, so a bot party is created through the authority a `sync_group_mirror` -/// push never contradicts. -/// -/// The row carries a LEAVE as well as an INVITE now (see [`op`](Self::op)) — a bot leaving a party -/// hits the same authority wall the invite did. The table keeps its name because renaming one is a -/// migration and this is not. -/// -/// Private — no client ever needs to see this, only the gateway's owner-token coordinator connection -/// (the `game_account`/`game_session` pattern). Short-lived: reaped by -/// the shared 1s event TTL (`gc.rs`), which is generous — the gateway's subscription callback fires -/// on the insert, not on a poll. [entity] #[table(accessor = game_bot_invite_intent, index(accessor = by_actor, btree(columns = [inviter_guid])))] pub struct BotInviteIntent { #[primary_key] @@ -286,42 +164,20 @@ pub struct BotInviteIntent { pub inviter_guid: u64, pub target_guid: u64, pub created_at: Timestamp, - /// Which group op the gateway should run: [`lyracore_shared::group::bot_op`]. END-appended with - /// a `0` default, so every row written before this column existed reads as the INVITE it was. - /// - /// A leave needs the same relay for the same reason an invite does — membership is - /// authoritative on realm-core and the module cannot reach it — so it rides this row rather - /// than a second table with an identical shape and an identical reaper. #[default(0u8)] pub op: u8, } -/// Record a bot's serendipity invite DECISION for the gateway to execute. No gating here -/// beyond existence-of-nothing: every real gate (leader or Assistant only, party cap, -/// already-grouped, pending-invite-replaces-older) lives in `invite_core_on`/`realm_group_op`, -/// which the gateway calls against the correct authority; this is a pure write, mirroring how a -/// player's own CMSG_GROUP_INVITE is a pure gateway-side resolve-then-call with no module-side -/// pre-check either. -/// -/// Only Packages call this operation. A Core checkout without an installed Rust Package has no caller. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn emit_bot_invite_intent(ctx: &ReducerContext, inviter_guid: u64, target_guid: u64) { emit_bot_group_intent(ctx, bot_op::INVITE, inviter_guid, target_guid); } -/// Record a session-less Character's decision to LEAVE its party, for the gateway to execute. -/// -/// The same split, and the same reason: a bot that ran `leave_group_for` itself would write this -/// shard's local member rows, and the next `sync_group_mirror` push would put the party back. -/// -/// Nothing here checks that the Character is in a party. `leave_group_for` refuses a non-member, so -/// a decision that has already been executed costs one refused op rather than a wrong one. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn emit_bot_leave_intent(ctx: &ReducerContext, leaver_guid: u64) { emit_bot_group_intent(ctx, bot_op::LEAVE, leaver_guid, 0); } -/// The one writer of [`BotInviteIntent`]. `target_guid` is unused by a LEAVE. #[cfg_attr(not(has_packages), allow(dead_code))] fn emit_bot_group_intent(ctx: &ReducerContext, op: u8, actor_guid: u64, target_guid: u64) { ctx.db.game_bot_invite_intent().insert(BotInviteIntent { @@ -344,9 +200,6 @@ pub(crate) fn clear_unclaimed_group_intents(ctx: &ReducerContext, character_guid } } -/// Admit and consume one Group Intent in a serialized World Shard transaction. -/// Consent and live Session ownership are checked before deletion. A selection after this claim -/// cannot recall the action that the winning Gateway will send to Realm-core. #[reducer] pub fn claim_bot_invite_intent(ctx: &ReducerContext, intent_id: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -362,7 +215,6 @@ pub fn claim_bot_invite_intent(ctx: &ReducerContext, intent_id: u64) -> Result<( Ok(()) } -/// Reducer edge: only the tag crosses to the gateway; the detail stays in module logs. pub(crate) fn refused(refusal: GroupRefusal, detail: &str) -> String { let tag = refusal.as_tag(); spacetimedb::log::info!("group refused {tag}: {detail}"); @@ -381,8 +233,6 @@ impl From for GroupOpError { } } -/// Serialize a gameplay Refusal as its stable tag, while leaving broken durable relationships -/// untagged so the Gateway treats them as session failures. fn group_op_error(error: GroupOpError, detail: &str) -> String { match error { GroupOpError::Refused(refusal) => refused(refusal, detail), @@ -393,55 +243,20 @@ fn group_op_error(error: GroupOpError, detail: &str) -> String { } } -// Event kinds, roster grammar, and classified error strings are the SHARED wire contract: -// lyracore_shared::group is the one definition both crates import — a renumber, -// reword, or delimiter change is a cross-crate compile-visible edit, never a runtime drift. use lyracore_shared::group::{bot_op, event_kind as group_event_kind, leave_cause, GroupRefusal}; -/// A per-recipient group notification (the `game_whisper_event` pattern): public + RLS-scoped so -/// only the recipient's connection sees it; reaped by the shared event GC. LIST events carry the -/// FULL roster snapshot in `payload` ([`RosterPayload`]), built in the SAME transaction as the -/// membership change, so the gateway relay never races a cross-connection coordinator read (the -/// module is the one place the roster is guaranteed consistent). [event] #[table(accessor = game_group_event, public, index(accessor = by_recipient, btree(columns = [recipient_identity])))] pub struct GroupEvent { #[primary_key] #[auto_inc] pub id: u64, pub recipient_identity: Identity, - pub kind: u8, // group_event_kind::* + pub kind: u8, pub other_guid: u64, - /// Unused by the gateway relay. Kept as an empty column rather than removed, because dropping - /// it is a schema change this fix does not need. - /// - /// A name written here would only be correct when [`push_event`] runs on the same database - /// that holds `other_guid`'s character row. That holds for `gw_group_invite`/`gw_group_decline` - /// on an unsharded Realm. It fails for `realm_group_op` on a sharded Realm, which is exactly - /// the case a cross-shard invite needs: `other_guid`'s Character lives on a different World - /// Shard than the write. This reducer cannot tell the two cases apart, so it leaves the column - /// blank and lets the gateway resolve the name from the World Shard caches at render time, the - /// same way it already resolves a blank LIST roster name - /// (`gateway/src/stdb/subscriptions.rs::other_character_name`). pub other_name: String, pub created_at: Timestamp, - // LIST roster snapshot (`RosterPayload::encode`); empty for the other kinds. A plain - // column (String cannot be #[default]-ed — the macro's typecheck is const): fine because this - // whole table has no pre-payload row to migrate anywhere real. pub payload: String, - /// The recipient's CHARACTER GUID (group slice). END-appended + `#[default(0)]`, so - /// this is an additive auto-migration and every earlier row reads as 0. - /// - /// `recipient_identity` cannot address a recipient on REALM-CORE: an identity is minted per - /// (account, database) by the node, so the identity a player holds on a world shard names - /// nobody on the directory database — and realm-core has no `game_character` rows to resolve one - /// from in the first place. A guid is the one realm-wide name a character has. The identity - /// column stays exactly as it was and still drives the per-player RLS on a world shard; this - /// column is what the gateway's realm-core relay filters on (it reads through the owner token, - /// which bypasses RLS, and self-filters per session — the coordinator-relay law). - // The u64 default MUST be typed: a bare `0` encodes as 4 bytes and `publish` rejects the - // migration with "data too short for u64: Expected 8, given 4" (world.rs:127 records the same - // rule). Nothing in `cargo test`/`cargo check` validates default-value encoding — only a real - // migration does — so this shipped green and blocked the first publish that tried to apply it. + // Identities are Shard-local; Realm-core addresses recipients by Character guid. #[default(0u64)] pub recipient_guid: u64, } @@ -453,10 +268,6 @@ pub(crate) fn push_event( other_guid: u64, payload: String, ) { - // A missing character row is not an early return. A world shard has the row. REALM-CORE has no - // character rows at all, so an early return there would stop the directory database from - // notifying anybody, and every invite popup and roster refresh for a cross-shard party would be - // dropped at the source. let bound = ctx .db .game_character() @@ -468,8 +279,6 @@ pub(crate) fn push_event( recipient_identity: crate::helpers::event_recipient_identity(bound), kind, other_guid, - // Left empty: see the field comment. This reducer cannot tell here whether `ctx.db` holds - // `other_guid`'s character row, so it does not guess. other_name: String::new(), created_at: ctx.timestamp, payload, @@ -477,15 +286,6 @@ pub(crate) fn push_event( }); } -/// The LIST payload, encoded by the SHARED grammar ([`RosterPayload`], delimiter defense -/// included). Carries the group's CURRENT loot rules, kind and every Raid Slot alongside the -/// roster, so any of those changing re-renders the party frame through this same relay. A Party's -/// payload also carries its Target Icons. Presence is the Gateway's to add: this database may be -/// Realm-core, which has no live entities. -/// -/// `None` means the `game_group` row is MISSING — every caller treats that as a hard invariant -/// violation (they've either just inserted the row or already `.ok_or("group row missing")?`'d -/// their own read of it), so there is no plausible roster to fabricate here. fn roster_payload(ctx: &ReducerContext, group_id: u64) -> Option { let group = ctx.db.game_group().group_id().find(group_id)?; let members = members_of(ctx, group_id) @@ -511,7 +311,7 @@ fn roster_payload(ctx: &ReducerContext, group_id: u64) -> Option { master_looter_guid: group.master_looter_guid, kind, members, - // The Party client clears its marks on every list, so the list carries them back. + // The Party client clears marks on each roster list, so send them back with it. target_icons: match kind { GroupKind::Party => target_icons_of(ctx, group_id), GroupKind::Raid => Vec::new(), @@ -529,21 +329,16 @@ pub(crate) fn raid_slot_of(member: &GroupMember) -> RaidSlot { RaidSlot::from_wire_or_default(member.raid_slot) } -/// Whether `character_guid` is in a Raid, read from this database's own rows: the authority on -/// Realm-core, the mirror on a World Shard. pub(crate) fn in_raid(ctx: &ReducerContext, character_guid: u64) -> bool { group_of(ctx, character_guid) .and_then(|member| ctx.db.game_group().group_id().find(member.group_id)) .is_some_and(|group| group_kind_of(&group) == GroupKind::Raid) } -/// Whether `member` may manage `group`'s Raid: its leader, or an Assistant. Only a Raid member -/// holds the Assistant flag, so in a Party this is the leader alone. Invites and kicks read it. pub(crate) fn manages_raid(group: &Group, member: &GroupMember) -> bool { group.leader_guid == member.character_guid || raid_slot_of(member).is_assistant() } -/// Who receives one Group Broadcast. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GroupAudience { Everyone, @@ -563,8 +358,6 @@ impl GroupAudience { } } -/// Push one `game_group_event` row per member of `group_id` in `audience`. Every Gateway relays -/// the rows addressed to its own sessions. A missing group reaches nobody. pub(crate) fn group_broadcast( ctx: &ReducerContext, group_id: u64, @@ -589,12 +382,10 @@ pub(crate) fn group_broadcast( } } -/// Whether a Group of `kind` with `member_count` members can take one more. fn has_room(kind: GroupKind, member_count: usize) -> bool { member_count < kind.member_cap() } -/// The group a character belongs to, if any. pub(crate) fn group_of(ctx: &ReducerContext, character_guid: u64) -> Option { ctx.db .game_group_member() @@ -613,8 +404,6 @@ pub struct PartyUnitFacts { pub health: u32, pub max_health: u32, pub dead: bool, - /// The unit's client selection. Selection identifies the designated target but does not start a - /// fight by itself. pub target_guid: u64, } @@ -637,7 +426,6 @@ pub struct PartyFacts { pub group_id: u64, pub leader_guid: u64, pub members: Vec, - /// Living hostile units with current evidence that this party is already fighting them. pub enemies: Vec, } @@ -672,8 +460,6 @@ pub struct CompanionTargetFacts { pub max_health: u32, } -/// Resolve only the named hostile creature. This exact read permits a designated pull without -/// widening the party fight scan or choosing a substitute target. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn companion_target_facts( ctx: &ReducerContext, @@ -719,10 +505,6 @@ pub(crate) fn companion_target_facts( }) } -/// Recheck the Gateway-certified authority projection and the bot Gate in the target transaction. -/// `None` means Package application may proceed; `Some` is a terminal typed Refusal. Companion -/// Orders cover the whole Raid: the bound is the member cap of the Group's kind, and a longer list -/// is a damaged mirror. pub(crate) fn admit_party_command( ctx: &ReducerContext, admitted: &crate::bridge::AdmittedClientCommand, @@ -786,10 +568,6 @@ pub struct PartyFactsUnavailable { pub reason: PartyFactsUnavailableReason, } -/// Whether another current party member is certified in `partition`. This portal Gate reads only -/// the bounded roster and exact member projections; combat facts cannot make location unavailable. -/// The bound stays at the Party cap on purpose: a Raid of more than five members reads as -/// unavailable here, as the 5-player dungeon cap in `instance.rs` also refuses it. pub(crate) fn has_known_party_member_in_partition( ctx: &ReducerContext, character_guid: u64, @@ -851,17 +629,6 @@ fn known_party_partition( }) } -/// Read one Character's local durable party mirror, member facts, and enemies with current party -/// melee, cast, threat, or control evidence. Membership remains useful when a member has no live -/// entity on this Shard, so those facts are nullable. -/// -/// Facts cover every member of the Group, Raid included, whatever the Subgroup. Reads stop with -/// `FightLimit` above the member cap of the Group's kind (only a damaged mirror holds more), 24 -/// incoming melee or threat-source rows for one member, one pending cast for one member, or 24 -/// aggregate enemy GUIDs. -/// Each retained enemy permits 80 threat sources (a full Raid and one pet each), 64 control auras, -/// and three effects on a pending spell. A missing parent Group stops with `MissingGroup`; neither -/// failure returns facts selected from an arbitrary prefix. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn party_facts( ctx: &ReducerContext, @@ -1047,8 +814,6 @@ pub(crate) fn party_facts( })) } -/// Resolve a membership and its required parent for mutation cores. `Ok(None)` means the -/// Character has no membership; a membership without its Group is a durable invariant failure. pub(crate) fn checked_group_membership( ctx: &ReducerContext, character_guid: u64, @@ -1070,7 +835,6 @@ pub(crate) fn checked_group_membership( Ok(Some((member, group))) } -/// `character_guid`'s Group and its members, through [`checked_group_membership`]. pub(crate) fn group_with_members( ctx: &ReducerContext, character_guid: u64, @@ -1083,10 +847,6 @@ pub(crate) fn group_with_members( ) } -/// The leader-authorization sequence of the ops only the leader may run (loot rules, convert, set -/// leader, set Assistant): resolve `guid`'s group membership, its `Group` row, and confirm `guid` -/// actually IS that group's leader. [`GroupRefusal::NotInGroup`] if `guid` has no group at all; -/// [`GroupRefusal::NotLeader`] if it does but isn't the leader. fn led_group_of(ctx: &ReducerContext, guid: u64) -> Result<(GroupMember, Group), GroupOpError> { let (m, group) = checked_group_membership(ctx, guid)?.ok_or(GroupRefusal::NotInGroup)?; if group.leader_guid != guid { @@ -1095,8 +855,6 @@ fn led_group_of(ctx: &ReducerContext, guid: u64) -> Result<(GroupMember, Group), Ok((m, group)) } -/// `pub(crate)` because the kill-time loot stamping (`combat/mod.rs` → -/// `loot::apply_group_loot_rules`) needs the CURRENT roster to pick a round-robin/master designee. pub(crate) fn members_of(ctx: &ReducerContext, group_id: u64) -> Vec { ctx.db .game_group_member() @@ -1105,9 +863,6 @@ pub(crate) fn members_of(ctx: &ReducerContext, group_id: u64) -> Vec { if !manages_raid(&group, &m) { @@ -1331,16 +1051,6 @@ fn invite_core_on( inviter_guid, String::new(), ); - // The hook is PLANE-LOCAL, and this is what that costs: package tables live on the world - // shards, so on a TRUE multi-database realm-core a Package's bot table is empty and its - // auto-accept handler returns immediately — a player's invite to a bot was created correctly and - // then nobody answered it (observed live 2026-07-26). Fired here regardless: a single-database - // gateway's realm-core IS the world shard, so the bot table is populated and the hook - // still answers in this transaction there — including every bot-to-bot serendipity invite - // which since that fix runs through `realm_group_op`/Plane::RealmCore like every - // other invite, never through `invite_core`/Plane::Shard directly. On a real multi-database - // deployment the gateway answers instead, where it can see both databases - // (`gateway/src/world/party.rs`, `answer_for_session_less`). crate::hooks::fire_on_group_invite( ctx, &crate::hooks::GroupInvitePayload { @@ -1351,12 +1061,6 @@ fn invite_core_on( Ok(()) } -/// `CMSG_GROUP_ACCEPT`: consume the caller's pending invite; create the inviter's group if this -/// is its first acceptance, join it otherwise, and push a roster refresh to every member. Reached -/// via `gw::gw_accept_group_invite`. -/// -/// The identity-free accept core: shared by `gw::gw_accept_group_invite` and any server-driven -/// acceptor (a Package auto-accept hook calls this with the bot's guid). pub(crate) fn accept_invite_for(ctx: &ReducerContext, acceptor_guid: u64) -> Result<(), String> { let acceptor = crate::helpers::character_by_guid(ctx, acceptor_guid); if acceptor.as_ref().is_some_and(|character| !character.online) { @@ -1372,9 +1076,6 @@ pub(crate) fn accept_invite_for(ctx: &ReducerContext, acceptor_guid: u64) -> Res }) } -/// Whether `character_guid` has invited someone while it has no Group, so the first accept will -/// form its Group. cmangos refuses to invite such a Character, which already holds the leader -/// invite of the Group it is forming (cm:GroupHandler.cpp:105-114). fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool { ctx.db .game_group_invite() @@ -1382,8 +1083,6 @@ fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool .any(|invite| invite.inviter_guid == character_guid && invite.group_id == 0) } -/// Consume `acceptor_guid`'s pending invite and join its Group through [`plan_join`]. `class` is -/// the acceptor's, for the Seeker row a queued Party gains; 0 when unknown. fn accept_invite_on( ctx: &ReducerContext, plane: Plane, @@ -1397,8 +1096,6 @@ fn accept_invite_on( .next() .ok_or(GroupRefusal::NoPendingInvite)?; invites.id().delete(invite.id); - // cmangos joins the invite's Group whatever the inviter did since: left, removed or demoted - // (cm:GroupHandler.cpp:185-227). An invite without a Group forms the inviter's. let target = match invite.group_id { 0 => JoinTarget::NewParty { leader_guid: invite.inviter_guid, @@ -1406,8 +1103,6 @@ fn accept_invite_on( group_id => JoinTarget::Group(group_id), }; let plan = plan_join(ctx, acceptor_guid, target)?; - // A World Shard holds every Character it groups, so a missing row is a Character that is gone - // or in transit. Realm-core holds none and takes the Gateway's word. if plane == Plane::Shard { if let JoinTarget::NewParty { leader_guid } = target { crate::helpers::character_by_guid(ctx, leader_guid) @@ -1424,26 +1119,19 @@ fn accept_invite_on( .map_err(GroupOpError::Invariant) } -/// Which Group [`plan_join`] adds a Character to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum JoinTarget { - /// An existing Group. Group(u64), - /// A Party this join forms. The leader joins it first. NewParty { leader_guid: u64 }, } -/// The Group [`JoinPlan::apply`] added a Character to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct JoinedGroup { pub(crate) group_id: u64, - /// The join formed this Party. pub(crate) formed: bool, } -/// A join that passed every check and has written nothing yet. [`JoinPlan::apply`] cannot fail, so -/// a caller may write its own rows between the two and never leave half a join behind. The plan -/// holds while nothing between the two changes group membership. +// A checked JoinPlan remains valid only while intervening writes leave membership unchanged. #[derive(Debug)] pub(crate) struct JoinPlan { joiner_guid: u64, @@ -1451,10 +1139,6 @@ pub(crate) struct JoinPlan { slot: RaidSlot, } -/// The one way a Character joins a Group: an accepted invite and a Stone Add both come here. -/// -/// The joiner must be in no Group. An existing Group must exist and have room for its kind; a Raid -/// joiner takes a free Raid Slot. A new Party's leader must be in no Group either. Writes nothing. pub(crate) fn plan_join( ctx: &ReducerContext, joiner_guid: u64, @@ -1467,8 +1151,6 @@ pub(crate) fn plan_join( JoinTarget::Group(group_id) => { let current = members_of(ctx, group_id); let Some(group) = ctx.db.game_group().group_id().find(group_id) else { - // A Group that is gone ends the invite. Member rows that still name a missing - // Group are broken state, not a lapsed invite. if current.is_empty() { return Err(GroupRefusal::NoPendingInvite.into()); } @@ -1487,7 +1169,6 @@ pub(crate) fn plan_join( } } JoinTarget::NewParty { leader_guid } => { - // A leader that has joined a Group since speaks for nobody. if checked_group_membership(ctx, leader_guid)?.is_some() { return Err(GroupRefusal::InviterUnavailable.into()); } @@ -1502,14 +1183,6 @@ pub(crate) fn plan_join( } impl JoinPlan { - /// Add the joiner. A new Party starts with Vanilla's defaults, group loot at Uncommon, and its - /// leader's other pending invites now join it. Every member gets the roster list. The joiner, - /// and a new Party's leader, get their Instance Removal reconciled, so joining the Group that - /// owns the instance cancels the countdown (cm:Group.cpp:880-885). - /// - /// A member row's `owner_identity` is the local Character row's identity when this database - /// holds one, else zero: Realm-core holds no Character rows, and each World Shard mirror - /// re-derives it. pub(crate) fn apply(self, ctx: &ReducerContext) -> JoinedGroup { let Self { joiner_guid, @@ -1572,8 +1245,6 @@ impl JoinPlan { } } -/// A member row's `owner_identity`: this database's binding for the Character, or zero where it -/// holds no Character row. fn member_identity(ctx: &ReducerContext, character_guid: u64) -> Identity { crate::helpers::event_recipient_identity( crate::helpers::character_by_guid(ctx, character_guid) @@ -1613,8 +1284,6 @@ pub(crate) fn leave_group_for(ctx: &ReducerContext, leaver_guid: u64) -> Result< .map_err(|error| group_op_error(error, &format!("{leaver_guid} could not leave its party"))) } -/// A deleted Character also loses every Target Icon on it. Its World Shard's delete sweep cannot -/// reach the icon rows when the party authority is Realm-core. fn leave_group_on(ctx: &ReducerContext, leaver_guid: u64, cause: u8) -> Result<(), GroupOpError> { if checked_group_membership(ctx, leaver_guid)?.is_none() { return Err(GroupRefusal::NotInGroup.into()); @@ -1636,9 +1305,6 @@ pub(crate) fn uninvite_from_group( }) } -/// The leader or an Assistant removes another member (cm:Player.cpp:19051-19063). Nobody removes -/// the leader: an Assistant who tries is refused with [`GroupRefusal::NotLeader`], the answer -/// cmangos sends (cm:GroupHandler.cpp:274-276). An Assistant may remove another Assistant. fn uninvite_on( ctx: &ReducerContext, actor_guid: u64, @@ -1699,7 +1365,7 @@ fn set_loot_method_on( } master_guid } else { - 0 // meaningless for a non-MASTER method — never leave a stale master guid set + 0 }; group.loot_method = loot_setting; group.loot_threshold = loot_threshold; @@ -1709,8 +1375,6 @@ fn set_loot_method_on( Ok(()) } -/// Is `method` one of the five known `loot_method::*` values? Pure. [`group_loot_method`]'s gate, -/// split out so it's unit-testable without a live `ReducerContext`. pub(crate) fn valid_loot_method(method: u8) -> bool { matches!( method, @@ -1722,15 +1386,10 @@ pub(crate) fn valid_loot_method(method: u8) -> bool { ) } -/// Is `threshold` a real vanilla `ItemQuality` (0 Poor..=6 Artifact)? Pure. pub(crate) fn valid_loot_threshold(threshold: u8) -> bool { threshold <= 6 } -/// `CMSG_GROUP_RAID_CONVERT`: the leader converts its Party to a Raid (cm:GroupHandler.cpp:473-490). -/// Every member keeps the Subgroup 0 slot a Party member holds (cm:Group.cpp:208-222), and every -/// member receives the raid list. Converting a Raid again succeeds and changes nothing, so it -/// writes no row and sends no list; cmangos does not check it either. fn raid_convert_on(ctx: &ReducerContext, actor_guid: u64) -> Result { let (member, mut group) = led_group_of(ctx, actor_guid)?; if group_kind_of(&group) == GroupKind::Raid { @@ -1738,8 +1397,6 @@ fn raid_convert_on(ctx: &ReducerContext, actor_guid: u64) -> Result Result= RAID_SUBGROUPS { return Err(GroupRefusal::InvalidSubgroup.into()); } @@ -1860,11 +1501,6 @@ fn change_subgroup_on( Ok(RosterChange::Changed) } -/// `CMSG_GROUP_SWAP_SUB_GROUP`: the leader or an Assistant exchanges the Subgroups of `first_guid` -/// and `second_guid` (cm:GroupHandler.cpp:901-944). Both members already in one Subgroup succeed -/// and change nothing (cm:GroupHandler.cpp:939). cmangos applies two moves and sends two lists; one -/// atomic swap can never overfill a Subgroup, so it needs no capacity Gate, and this writes both -/// slots in one transaction and pushes one list. fn swap_subgroup_on( ctx: &ReducerContext, actor_guid: u64, @@ -1896,30 +1532,18 @@ fn swap_subgroup_on( Ok(RosterChange::Changed) } -/// Whether a successful op changed what the Roster Revision orders: the member list, leader, loot -/// rules, Group kind or a Raid Slot. Only a change advances the revision. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RosterChange { Changed, Unchanged, } -/// How a member left its Group. The Meeting Stone Queue answers a kick differently from a leave. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Departure { - /// The member left, or its Character was deleted. Left, - /// The leader or an Assistant removed the member. Kicked, } -/// The single membership-removal core (voluntary leave, kick, character delete): drops the member -/// row, notifies the leaver, transfers leadership if the leader left, and DISBANDS below 2 members -/// (vanilla: a party of one is no party), which also deletes the Group's Target Icons. A new leader -/// is announced to the remaining members before their list (cm:Group.cpp:464-470). Every Character -/// that lost its Group here gets its Instance Removal reconciled (cm:Group.cpp:461). A queued -/// Party's Meeting Stone Queue state follows the departure before the list -/// ([`crate::meeting_stone::party_left`]). Idempotent for a guid not in any group. pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure: Departure) { let Some(m) = group_of(ctx, character_guid) else { return; @@ -1937,8 +1561,6 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure let remaining = members_of(ctx, group_id); let group = ctx.db.game_group().group_id().find(group_id); - // The incumbent-leader arg only matters for the survive branch; a missing group row (shouldn't - // happen while members exist) falls back to 0 — no update runs without a row to update anyway. let rematch = match leader_after_removal( group.as_ref().map_or(GroupKind::Party, group_kind_of), &remaining, @@ -1946,11 +1568,7 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure group.as_ref().map_or(0, |g| g.leader_guid), ) { None => { - // trap: a group disbanding mid-roll must resolve any of its live rolls - // to the sole remaining member (vanilla behavior) rather than leaving them to time out. - // The FULL former membership (the leaver, already removed above, PLUS whoever's left) is - // what `force_resolve_rolls_for_disband` needs to recognize "every voter on this roll - // belonged to the group that just disbanded" — `remaining` alone would miss the leaver. + // Include the leaver in the former roster when resolving rolls after disband. let survivors: Vec = remaining.iter().map(|r| r.character_guid).collect(); let mut all_guids = survivors.clone(); all_guids.push(character_guid); @@ -1973,8 +1591,6 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure crate::meeting_stone::party_disbanded(ctx, group_id, &all_guids); clear_target_icons(ctx, group_id); ctx.db.game_group().group_id().delete(group_id); - // A disband starts the countdown for every former member in the instance, the last - // one included (cm:Group.cpp:554). for guid in all_guids { crate::instance::reconcile_instance_removal(ctx, guid); } @@ -2003,8 +1619,6 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure }; push_list_to_all(ctx, group_id); crate::instance::reconcile_instance_removal(ctx, character_guid); - // `remove_member` has no failure path: a delete sweep runs it. A broken relationship stops - // the pass and is logged; the removal itself stands. if let Some(bucket) = rematch { if let Err(reason) = crate::meeting_stone::match_bucket(ctx, bucket) { spacetimedb::log::error!("meeting stone pass after {character_guid} left: {reason}"); @@ -2012,8 +1626,6 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure } } -/// Tell every member who leads now. `SMSG_GROUP_SET_LEADER` carries only a name, which the Gateway -/// resolves from `leader_guid`, because Realm-core holds no names. fn announce_leader(ctx: &ReducerContext, group_id: u64, leader_guid: u64) { group_broadcast( ctx, @@ -2025,13 +1637,6 @@ fn announce_leader(ctx: &ReducerContext, group_id: u64, leader_guid: u64) { ); } -/// The post-removal survival/leadership decision for [`remove_member`]: `None` → the group DISBANDS -/// (fewer than 2 members remain — vanilla: a party of one is no party); `Some(leader)` → the group -/// survives under `leader`, the incumbent unless the leaver WAS the leader. Then the lead passes in -/// join order (lowest member-row id): in a Raid to the first Assistant, else to the first member -/// (cm:Group.cpp:954-997). cmangos also prefers online members; Realm-core cannot see presence, so -/// that preference is not reproduced. `remaining` is the member rows left AFTER the leaver's row is -/// deleted. Pure and unit-tested. pub(crate) fn leader_after_removal( kind: GroupKind, remaining: &[GroupMember], @@ -2054,30 +1659,12 @@ pub(crate) fn leader_after_removal( .map(|heir| heir.character_guid) } -// =========================================================================================== -// REALM-CORE plane (group slice) -// =========================================================================================== - -/// Which DEPLOYMENT of this one module a group call is running on. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Plane { - /// A world shard: characters and live entities are local. Shard, - /// The realm-wide directory database: neither is. RealmCore, } -/// The realm-wide party ops, as ONE operator-gated reducer keyed by [`lyracore_shared::group::realm_op`]. -/// -/// **Operator-gated, and it has to be**: it takes the acting character's guid as an argument instead -/// of deriving it from `ctx.sender()`, because on realm-core there is no live entity to derive it -/// from. A client that could call it could accept invites, kick members and change loot rules as -/// anybody in the realm. The gateway is the only caller, it holds the coordinator (operator) token, -/// and it passes the guid it already authenticated for that socket (`InWorld::self_guid`) — the same -/// trust boundary `set_character_shard` and `establish_session` sit on. -/// -/// One reducer rather than one per op: see [`lyracore_shared::group::realm_op`] for that trade and -/// for the argument slots each op reads. #[reducer] pub fn realm_group_op( ctx: &ReducerContext, @@ -2091,22 +1678,18 @@ pub fn realm_group_op( crate::helpers::require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; use lyracore_shared::group::realm_op; - // An op byte this module does not know is a gateway newer than the module — a deployment fault, - // not a party outcome, so it stays an untagged error the gateway treats as a failure. let before_groups = realm_op_groups(ctx, op, actor_guid, target_guid); use RosterChange::{Changed, Unchanged}; let ran = match op { realm_op::INVITE => { invite_core_on(ctx, Plane::RealmCore, actor_guid, target_guid).map(|()| Unchanged) } - // Realm-core holds no Character rows, so the Gateway conveys the acceptor's class. realm_op::ACCEPT => { accept_invite_on(ctx, Plane::RealmCore, actor_guid, arg_a).map(|()| Changed) } realm_op::DECLINE => decline_invite_on(ctx, actor_guid).map(|()| Unchanged), realm_op::LEAVE => leave_group_on(ctx, actor_guid, arg_a).map(|()| Changed), realm_op::UNINVITE => uninvite_on(ctx, actor_guid, target_guid).map(|()| Changed), - // `CMSG_LOOT_METHOD`'s own field order: setting, master, threshold. realm_op::LOOT_METHOD => { set_loot_method_on(ctx, actor_guid, arg_a, target_guid, arg_b).map(|()| Changed) } @@ -2115,7 +1698,6 @@ pub fn realm_group_op( realm_op::SET_ASSISTANT => set_assistant_on(ctx, actor_guid, target_guid, arg_a != 0), realm_op::CHANGE_SUBGROUP => change_subgroup_on(ctx, actor_guid, target_guid, arg_a), realm_op::SWAP_SUBGROUP => swap_subgroup_on(ctx, actor_guid, target_guid, arg_c), - // Group Broadcasts change no roster. realm_op::READY_CHECK_START => ready_check_start_on(ctx, actor_guid).map(|()| Unchanged), realm_op::READY_CHECK_ANSWER => { ready_check_answer_on(ctx, actor_guid, arg_a).map(|()| Unchanged) @@ -2164,7 +1746,6 @@ fn realm_op_groups( guids.push(target_guid); } if op == realm_op::ACCEPT { - // An invite with a Group joins that Group, which the acceptor's own row names afterwards. if let Some(invite) = ctx .db .game_group_invite() @@ -2190,8 +1771,6 @@ fn realm_op_groups( .collect() } -/// Advance `group_id`'s Roster Revision. `was_active` says whether the Group existed before this -/// transaction, which sets the first revision of a Group that has no row yet. pub(crate) fn advance_group_revision( ctx: &ReducerContext, group_id: u64, @@ -2220,9 +1799,6 @@ fn next_group_revision(current: Option, was_active: bool) -> u64 { .expect("party roster revision exhausted") } -/// Acknowledged Realm-core party authority read for one companion command attempt. Companion -/// Orders cover the whole Raid: both lists are bounded at the member cap of the Group's kind, and a -/// longer list is a damaged mirror. #[reducer] pub fn admit_party_command_authority( ctx: &ReducerContext, @@ -2278,16 +1854,6 @@ pub fn admit_party_command_authority( Ok(()) } -// =========================================================================================== -// Kill rewards: XP split + shared quest credit -// =========================================================================================== - -/// Everyone a kill by `killer_guid` at `(x, y)` on `map_id`/`instance_id` (the kill site) rewards: -/// the killer alone when ungrouped, else every group member whose LIVE entity is alive, on the -/// same map + instance, and within [`GROUP_XP_RANGE_SQ`] of the corpse (the killer qualifies -/// through the same filter — it is always in range of its own kill). Each recipient gets `1/n` of -/// its OWN level-based kill XP ([`crate::xp::award_xp`]'s `share_count`) and full quest -/// kill-credit. pub(crate) fn kill_reward_recipients( ctx: &ReducerContext, killer_guid: u64, @@ -2325,16 +1891,6 @@ pub(crate) fn eligible_for_kill_reward( !dead && same_map && same_instance && dist_sq <= GROUP_XP_RANGE_SQ } -// =========================================================================================== -// Group Broadcasts: Ready Check, Target Icons, minimap ping, /roll -// =========================================================================================== -// -// Each op pushes one `game_group_event` row per recipient and changes no roster, so none of them -// advances the Roster Revision or needs a mirror push. - -/// One Target Icon a Group holds. A Group holds each icon once and a unit carries one icon at -/// most, so a Group has 8 rows at most. Private: LIST and TARGET_ICON_LIST events carry the icons -/// to clients. The rows go when the Group disbands. [entity] #[table( accessor = game_group_target_icon, index(accessor = by_group, btree(columns = [group_id])), @@ -2349,22 +1905,18 @@ pub struct GroupTargetIcon { pub target_guid: u64, } -// A deleted Character loses the icon it carries. No client needs an update: the unit is gone. crate::character_owned!(delete, fn sweep_delete_game_group_target_icon(ctx, character_guid) { delete_target_icons_on(ctx, character_guid); }); -/// Drop every Target Icon on `target_guid`, in every Group, without an update: the unit is gone. fn delete_target_icons_on(ctx: &ReducerContext, target_guid: u64) { let icons = ctx.db.game_group_target_icon(); for row in icons.by_target().filter(&target_guid).collect::>() { icons.id().delete(row.id); } } -// Group state on the party authority. The icon stays on its unit across a Transfer. crate::character_owned!(not_transported, fn sweep_transfer_game_group_target_icon()); -/// Every Target Icon `group_id` holds, in icon order. fn target_icons_of(ctx: &ReducerContext, group_id: u64) -> Vec { let mut icons: Vec = ctx .db @@ -2380,10 +1932,6 @@ fn target_icons_of(ctx: &ReducerContext, group_id: u64) -> Vec { icons } -/// The partial updates one Target Icon request makes, in send order (cm:Group.cpp:583-601). A -/// unit that already holds an icon loses it first, with its own clearing update. Then `icon` goes -/// to `target_guid`, and a unit that held `icon` before loses it through that same update. Target -/// 0 clears `icon`. Pure. fn target_icon_updates( held: &[TargetIcon], icon: u8, @@ -2404,7 +1952,6 @@ fn target_icon_updates( Ok(updates) } -/// Store one partial update. Target 0 deletes the icon's row. fn write_target_icon(ctx: &ReducerContext, group_id: u64, update: TargetIcon) { let icons = ctx.db.game_group_target_icon(); let row = icons @@ -2438,8 +1985,6 @@ fn clear_target_icons(ctx: &ReducerContext, group_id: u64) { } } -/// `actor_guid`'s membership and Group, when it leads the Group or is one of its Assistants -/// (cm:GroupHandler.cpp:463-467, 556-558). In a Party only the leader qualifies. fn managed_group_of( ctx: &ReducerContext, actor_guid: u64, @@ -2452,16 +1997,11 @@ fn managed_group_of( Ok((member, group)) } -/// A `realm_group_op` slot that carries a 32-bit value. A wider value means the Gateway broke the -/// argument contract, which is not a gameplay answer. fn wire_u32(value: u64, slot: &str) -> Result { u32::try_from(value) .map_err(|_| GroupOpError::Invariant(format!("{slot} {value} does not fit 32 bits"))) } -/// `MSG_RAID_READY_CHECK` without a body. The leader or an Assistant asks every member, the asker -/// included (cm:GroupHandler.cpp:549-564). A Party may run one too. The Module keeps no Ready -/// Check state and no timeout: the leader's client counts the answers. fn ready_check_start_on(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GroupOpError> { let (member, _group) = managed_group_of(ctx, actor_guid)?; group_broadcast( @@ -2475,8 +2015,6 @@ fn ready_check_start_on(ctx: &ReducerContext, actor_guid: u64) -> Result<(), Gro Ok(()) } -/// `MSG_RAID_READY_CHECK` with the answer's state byte. Only the leader receives it, with the -/// answerer's guid (cm:GroupHandler.cpp:566-581). fn ready_check_answer_on( ctx: &ReducerContext, actor_guid: u64, @@ -2495,9 +2033,6 @@ fn ready_check_answer_on( Ok(()) } -/// `MSG_RAID_TARGET_UPDATE` (cm:GroupHandler.cpp:444-471). [`TARGET_ICON_LIST_REQUEST`] sends -/// every icon to the actor alone, and any member may ask. Setting an icon needs the leader or an -/// Assistant, and every member receives each partial update. fn target_icon_on( ctx: &ReducerContext, actor_guid: u64, @@ -2532,8 +2067,6 @@ fn target_icon_on( Ok(()) } -/// `MSG_MINIMAP_PING`: every member but the sender sees the ping (cm:GroupHandler.cpp:395-415). -/// `x_bits` and `y_bits` are the client's `f32` bit patterns. fn minimap_ping_on( ctx: &ReducerContext, actor_guid: u64, @@ -2555,10 +2088,6 @@ fn minimap_ping_on( Ok(()) } -/// `MSG_RANDOM_ROLL`: every member sees a grouped roll, the roller included, wherever they are. -/// An ungrouped roll reaches the roller alone (cm:GroupHandler.cpp:417-442; patch 1.7.0, -/// vm:GroupHandler.cpp:419-431). The result comes from `ctx.random`, so two rolls in one -/// microsecond are unrelated. fn random_roll_on( ctx: &ReducerContext, actor_guid: u64, @@ -2624,8 +2153,6 @@ mod tests { ); } - /// The share filter's range gate is the vanilla 74yd group-XP distance, squared for the - /// comparison `kill_reward_recipients` runs — pin it so a tuning edit is a conscious act. #[test] fn group_xp_range_is_74_yards() { assert_eq!(GROUP_XP_RANGE_SQ, 5476.0); @@ -2634,16 +2161,13 @@ mod tests { #[test] fn kill_reward_needs_an_alive_member_on_the_same_map_within_range() { - // The all-pass baseline: alive, same map, same instance, at the corpse. assert!(eligible_for_kill_reward(false, true, true, 0.0)); - // Exactly at the 74yd² boundary is still IN (inclusive comparison). assert!(eligible_for_kill_reward( false, true, true, GROUP_XP_RANGE_SQ )); - // Each gate rejects on its own: a dead member, another map, another instance, out of range. assert!(!eligible_for_kill_reward(true, true, true, 0.0)); assert!(!eligible_for_kill_reward(false, false, true, 0.0)); assert!(!eligible_for_kill_reward(false, true, false, 0.0)); @@ -2655,7 +2179,6 @@ mod tests { )); } - /// A remaining member row: `id` is its join order. fn member_row(id: u64, character_guid: u64, assistant: bool) -> GroupMember { GroupMember { id, @@ -2670,7 +2193,6 @@ mod tests { fn leadership_passes_to_the_longest_standing_member_or_the_group_disbands() { let party = GroupKind::Party; let row = |id, guid| member_row(id, guid, false); - // Fewer than 2 remaining → disband (None), whoever led and whoever left. assert_eq!(leader_after_removal(party, &[], 7, 7), None); assert_eq!(leader_after_removal(party, &[row(3, 30)], 7, 7), None); assert_eq!( @@ -2683,8 +2205,6 @@ mod tests { Some(10), "a member who does not lead leaves the incumbent in place" ); - // The LEADER leaving passes to the lowest member-ROW id (longest-standing), not the lowest - // guid, whatever order the rows arrive in. assert_eq!( leader_after_removal(party, &[row(5, 90), row(9, 20)], 10, 10), Some(90) @@ -2695,8 +2215,6 @@ mod tests { ); } - /// cm:Group.cpp:972-977, 993: a Raid passes the lead to its first Assistant in join order, and - /// to its first member when it has no Assistant. #[test] fn a_raid_leader_leaving_passes_the_lead_to_the_first_assistant_in_join_order() { let raid = GroupKind::Raid; @@ -2726,7 +2244,6 @@ mod tests { ); } - /// Only a Raid prefers an Assistant (cm:Group.cpp:972). A Party passes the lead in join order. #[test] fn a_party_passes_the_lead_in_join_order_whatever_a_slot_says() { let remaining = [member_row(2, 20, false), member_row(5, 50, true)]; @@ -2736,8 +2253,6 @@ mod tests { ); } - // ---- Raids ---- - fn raid_member(character_guid: u64, subgroup: u8, assistant: bool) -> GroupMember { GroupMember { id: character_guid, @@ -2791,7 +2306,6 @@ mod tests { assert_eq!(selected(GroupAudience::Subgroup(1)), [3]); } - /// Invites and kicks read this over the actor's current Group row and Raid Slot. #[test] fn the_leader_and_assistants_manage_a_raid() { let group = raid_led_by(1); @@ -2810,9 +2324,6 @@ mod tests { ); } - /// The raid chat audience table (cm:ChatHandler.cpp:413-446, 461-490, 506-534), every row: a - /// Party refuses all three kinds regardless of privilege; a Raid answers each kind by its own - /// rule. #[test] fn the_raid_chat_gate_follows_the_audience_table() { use lyracore_shared::chat::ChatRefusal; @@ -2831,7 +2342,6 @@ mod tests { } } - // Raid: any Raid member, leader or not. assert_eq!( raid_chat_refusal(RaidChatKind::Raid, GroupKind::Raid, false, false), None @@ -2845,7 +2355,6 @@ mod tests { None ); - // RaidLeader: the leader alone. An Assistant who does not lead is still refused. assert_eq!( raid_chat_refusal(RaidChatKind::RaidLeader, GroupKind::Raid, true, true), None @@ -2860,7 +2369,6 @@ mod tests { Some(ChatRefusal::NotRaidLeader) ); - // RaidWarning: the leader or an Assistant. assert_eq!( raid_chat_refusal(RaidChatKind::RaidWarning, GroupKind::Raid, true, true), None @@ -2876,8 +2384,6 @@ mod tests { ); } - // ---- Target Icons (cm:Group.cpp:583-601) ---- - fn held(icon: u8, target_guid: u64) -> TargetIcon { TargetIcon { icon, target_guid } } @@ -2887,7 +2393,6 @@ mod tests { assert_eq!(target_icon_updates(&[], 7, 100), Ok(vec![held(7, 100)])); } - /// A unit carries one icon, so moving it sends the clear before the new icon. #[test] fn a_marked_unit_loses_its_old_icon_before_it_takes_the_new_one() { assert_eq!( @@ -2896,7 +2401,6 @@ mod tests { ); } - /// The icon's previous unit loses it through the one update that gives it to the new unit. #[test] fn an_icon_given_to_another_unit_replaces_the_old_holder_in_one_update() { assert_eq!( @@ -2905,7 +2409,6 @@ mod tests { ); } - /// cmangos clears and sets again when a unit takes the icon it already holds. #[test] fn marking_a_unit_with_its_own_icon_clears_it_and_sets_it_again() { assert_eq!( @@ -2922,7 +2425,6 @@ mod tests { ); } - /// cm:Group.cpp:585-586: an index of 8 or more is not an icon. #[test] fn an_icon_index_of_eight_or_more_is_refused() { for icon in [8, 9, 0xFE] { @@ -2933,12 +2435,8 @@ mod tests { } } - // ---- Group loot methods ---- - #[test] fn loot_method_encoding_matches_the_real_wire_grouplootsetting_order() { - // wow_world_base::GroupLootSetting: FreeForAll=0, RoundRobin=1, MasterLoot=2, GroupLoot=3, - // NeedBeforeGreed=4 — a mismatch here would silently misrender the party frame's loot icon. assert_eq!(loot_method::FFA, 0); assert_eq!(loot_method::ROUND_ROBIN, 1); assert_eq!(loot_method::MASTER, 2); diff --git a/module/src/group/mirror.rs b/module/src/group/mirror.rs index ac88b3b3f..3d60a8544 100644 --- a/module/src/group/mirror.rs +++ b/module/src/group/mirror.rs @@ -1,5 +1,3 @@ -//! Roster Revision and Party Partition rules for a World Shard's Group mirror. - use super::{ game_group, game_group_member, game_group_member_partition, game_group_roster_revision, group_kind_of, Group, GroupMember, GroupMemberPartition, GroupRosterRevision, @@ -8,12 +6,8 @@ use super::{ use lyracore_shared::group::{GroupKind, RaidSlot, RAID_MAX_MEMBERS}; use spacetimedb::{reducer, ReducerContext, Table}; -/// Apply one Realm-core roster to this World Shard's Group mirror atomically. -/// Older Roster Revisions are ignored; equal revisions must agree on the roster and its rules. -/// Party Partition ordering preserves newer location and membership facts. Membership changes -/// also revoke departed Loot Tags and reconcile Instance Removal in this transaction. #[reducer] -#[allow(clippy::too_many_arguments)] // The roster and initiating Actor are the reducer wire contract. +#[allow(clippy::too_many_arguments)] pub fn sync_group_mirror( ctx: &ReducerContext, group_id: u64, @@ -152,8 +146,6 @@ pub fn sync_group_mirror( .map(|m| (m.id, m.character_guid)) .collect(); let (stale_row_ids, arriving) = mirror_plan(¤t, &effective_members); - // Every guid whose membership this push changes. Only these can start or end an Instance - // Removal, so a push that moves Raid Slots or the lead evaluates nothing. let mut membership_changed = arriving.clone(); for id in stale_row_ids { if let Some((_, guid)) = current.iter().find(|(row_id, _)| *row_id == id) { @@ -170,9 +162,6 @@ pub fn sync_group_mirror( } return Ok(()); } - // A character is in at most one group (the `by_character` uniqueness the whole system assumes), - // so an arriving member's row in some OTHER group on this shard — a mirror that missed the push - // where they left it — is stale by construction and goes first. for guid in &arriving { for m in member_tbl.by_character().filter(guid).collect::>() { crate::loot::tag::revoke_group_member(ctx, m.group_id, *guid); @@ -201,8 +190,7 @@ pub fn sync_group_mirror( leader_guid: effective_leader, loot_method: loot_method_setting, loot_threshold, - // The round-robin cursor is per-SHARD kill state, not realm state: it advances on - // this shard's kills and means nothing on another. A fresh mirror starts it at 0. + // Round-robin loot follows this Shard's kills; keep its local cursor. rr_cursor: 0, master_looter_guid, group_type: kind.wire(), @@ -210,13 +198,7 @@ pub fn sync_group_mirror( } } write_mirror_slots(ctx, group_id, &slots); - // `slots` is keyed by `members`, which equals the effective roster here, so every arriving - // member has an entry and the default below is unreachable. for guid in arriving { - // The identity binding is this shard's, re-derived here the same way `player_login` restamps - // every other character-owned row. A member who has never logged in HERE — or who is mid-hop, - // which is why this reads through the in-transit chokepoint rather than raw — has no - // readable character row and mirrors as ZERO; their own login restamps it. let owner_identity = crate::helpers::event_recipient_identity( crate::helpers::character_by_guid(ctx, guid).map(|c| c.owner_identity), ); @@ -235,7 +217,6 @@ pub fn sync_group_mirror( Ok(()) } -/// Record the Roster Revision a mirror push was accepted at. fn store_group_revision(ctx: &ReducerContext, group_id: u64, revision: u64, active: bool) { let revisions = ctx.db.game_group_roster_revision(); let row = GroupRosterRevision { @@ -250,7 +231,6 @@ fn store_group_revision(ctx: &ReducerContext, group_id: u64, revision: u64, acti } } -/// Check the kind byte and one Raid Slot byte per member, and key the slots by member guid. Pure. fn mirror_kind_and_slots( members: &[u64], group_kind: u8, @@ -284,8 +264,6 @@ fn mirror_kind_and_slots( Ok((kind, slots)) } -/// Give every mirrored member of `group_id` the Raid Slot `slots` names. `sync_group_mirror` keys -/// `slots` by the effective roster, so every remaining row has an entry. fn write_mirror_slots( ctx: &ReducerContext, group_id: u64, @@ -301,7 +279,6 @@ fn write_mirror_slots( } } -/// Whether a mirrored member of `group_id` holds another Raid Slot than `slots` gives it. fn mirror_slots_differ( ctx: &ReducerContext, group_id: u64, @@ -402,7 +379,6 @@ fn partition_update( } } -/// Return departed member row ids and arriving Character guids for the accepted roster. fn mirror_plan(current: &[(u64, u64)], incoming: &[u64]) -> (Vec, Vec) { let stale = current .iter() @@ -439,21 +415,12 @@ mod tests { #[test] fn the_mirror_diff_deletes_departed_rows_and_inserts_arriving_members() { - // Steady state: the mirror already matches the authority — nothing to do at all. (A diff - // that re-inserted every member on every push would churn `game_group_member` row ids on - // every party op, on every shard.) let current = [(10u64, 100u64), (11, 200)]; assert_eq!(mirror_plan(¤t, &[100, 200]), (vec![], vec![])); - // A member joined: only the newcomer is inserted. assert_eq!(mirror_plan(¤t, &[100, 200, 300]), (vec![], vec![300])); - // A member left: only their ROW is deleted, addressed by row id (not guid — the delete is - // applied through the member table's PK). assert_eq!(mirror_plan(¤t, &[100]), (vec![11], vec![])); - // A swap does both, and the ROW ID of the departing member is what comes back. assert_eq!(mirror_plan(¤t, &[200, 300]), (vec![10], vec![300])); - // Disband: every row goes and nothing arrives. assert_eq!(mirror_plan(¤t, &[]), (vec![10, 11], vec![])); - // A shard with no mirror yet (a party crossing a boundary for the first time) inserts all. assert_eq!(mirror_plan(&[], &[100, 200]), (vec![], vec![100, 200])); } diff --git a/module/src/guild/chat.rs b/module/src/guild/chat.rs index d0b8be9ce..013069279 100644 --- a/module/src/guild/chat.rs +++ b/module/src/guild/chat.rs @@ -1,8 +1,3 @@ -//! Guild and officer chat on the Realm Chat path (`cm:Guild.cpp:554-594`, -//! `cm:ChatHandler.cpp:142-147,349-369`). Both Chat Kinds read the speaker's own Guild Rank and -//! every member's, from one indexed read of `game_guild_member` scoped to the speaker's Guild; no -//! call here reads a member of any other Guild. - use spacetimedb::ReducerContext; use lyracore_shared::chat::{chat_kind, ChatRefusal}; @@ -11,10 +6,6 @@ use lyracore_shared::guild::{has_right, rights}; use super::{game_guild_member, member, ranks, GuildRank}; use crate::realm_chat::{ChatAudience, RealmChatRequest}; -/// Who may speak and who hears one Guild or Officer line. Guild chat needs GCHATSPEAK to speak -/// and GCHATLISTEN to hear; officer chat needs OFFCHATSPEAK and OFFCHATLISTEN -/// (`cm:Guild.cpp:559-561,580-593`). Both are ignorable (`cm:Guild.cpp:570,591`): the Relay's -/// per-viewer ignore set is what filters a listener who ignores the speaker, not this rule. pub(crate) fn guild_chat_audience( ctx: &ReducerContext, speaker_guid: u64, @@ -26,8 +17,6 @@ pub(crate) fn guild_chat_audience( (rights::GCHATSPEAK, rights::GCHATLISTEN) }; let speaker = member(ctx, speaker_guid).ok_or(ChatRefusal::NotInGuild)?; - // One guild-scoped read of every Guild Rank, reused for every member below instead of a - // repeated lookup per member. let guild_ranks = ranks(ctx, speaker.guild_id); if !has_right(rights_of(&guild_ranks, speaker.rank_id), speak_right) { return Err(ChatRefusal::NoGuildChatRight); @@ -47,8 +36,6 @@ pub(crate) fn guild_chat_audience( }) } -/// One Guild Rank's rights out of an already-read list, 0 for an unknown rank id -/// (`cm:Guild.cpp:660-666`'s rule). fn rights_of(guild_ranks: &[GuildRank], rank_id: u32) -> u32 { guild_ranks .iter() diff --git a/module/src/guild/cleanup.rs b/module/src/guild/cleanup.rs index ec47f204c..c715b9bae 100644 --- a/module/src/guild/cleanup.rs +++ b/module/src/guild/cleanup.rs @@ -1,19 +1,9 @@ -//! Forget a deleted Character. Realm-core holds no Character rows, so it cannot see a World Shard -//! delete one. The Gateway's character-gone reconciliation proves the Character is absent from -//! every World Shard and then sends `ForgetDeletedCharacter` as that Character, with no ownership -//! token. Every step here finds nothing on a second call, so a replay is harmless. - use lyracore_shared::guild::{event_kind, GuildRefusal, LEADER_RANK}; use spacetimedb::ReducerContext; use super::membership::{disband_guild, game_guild_invite}; use super::{game_guild, game_guild_member, member, petition, push_event, GuildMember}; -/// Remove every guild trace of `character_guid`: the Guild Invite it holds, its membership -/// (passing leadership or disbanding as `cm:Guild.cpp:493-552` does), its own Petition and the -/// Signatures it made (`cm:Player.cpp:4061-4062`). A Guild Invite it sent stays: mangos keeps only -/// the invited Guild on the target (`cm:GuildHandler.cpp:124`), so the target can still accept -/// while that Guild exists. pub(super) fn forget_deleted_character( ctx: &ReducerContext, character_guid: u64, @@ -29,8 +19,6 @@ pub(super) fn forget_deleted_character( Ok(()) } -/// Take a deleted member out of its Guild. A Guild Leader passes leadership to its successor -/// first; a Guild with nobody left disbands. fn leave_guild(ctx: &ReducerContext, departed: GuildMember) { let guild_id = departed.guild_id; let Some(mut guild) = ctx.db.game_guild().guild_id().find(guild_id) else { @@ -81,9 +69,6 @@ fn leave_guild(ctx: &ReducerContext, departed: GuildMember) { ); } -/// The member who leads after the Guild Leader is gone: the highest Guild Rank (lowest rank id), -/// as mangos picks (`cm:Guild.cpp:499-516`). mangos breaks a tie by hash-map order; here the -/// earliest join wins, then the lowest guid, so the choice never depends on storage order. fn successor(members: Vec) -> Option { members .into_iter() diff --git a/module/src/guild/fee.rs b/module/src/guild/fee.rs index 10b26619f..aabb4a863 100644 --- a/module/src/guild/fee.rs +++ b/module/src/guild/fee.rs @@ -1,29 +1,4 @@ -//! The Fee Hold: how a guild operation that costs copper pays across two databases. -//! -//! The purse lives on the payer's Home Shard. Whether the operation is allowed is known only on -//! Realm-core. No transaction spans both, so one fee takes three Durable Requests, each idempotent -//! on the Gateway's operation id: -//! -//! 1. [`gw_guild_fee_hold`] on the Home Shard runs the NPC and purse Gates and moves the fee from -//! the purse into the payer's Fee Hold. A Guild Charter is created in the same step. -//! 2. [`realm_guild_fee_decide`] on Realm-core runs the guild Gates and commits one decision row -//! for the operation id, with the accepted effect in the same transaction. A refusal is data in -//! that row, so a retry gets the same answer. -//! 3. [`gw_guild_fee_finish`] on the Home Shard spends the Hold on acceptance or puts the copper -//! back on refusal, and deletes the Hold last. A refused Guild Charter is destroyed and refunded -//! only while the payer still holds it, so a Charter the payer destroyed is never paid back. A -//! refund that does not fit in the purse follows the auction rule: the purse takes what fits and -//! the rest goes out as Mail Escrow in the same transaction. -//! -//! What the fee pays for can vanish between the phases: the Guild disbands, leadership moves. The -//! decision then refuses and the finish refunds, so copper never strands. A Hold that a crash left -//! behind travels with its Character on Transfer, and the Gateway finishes it at the next world -//! entry. Character deletion is refused while one exists. -//! -//! The Gateway mints a fresh operation id inside each hold call and never replays that call, so a -//! Hold is taken at most once per operation id; a replay of an id that Realm-core already decided -//! would take the fee a second time. It always holds before it decides. A finish or decide that -//! arrives without its Hold spends nothing. +//! Hold before deciding; reusing a decided operation id can debit the fee twice. use lyracore_shared::constants::npc_flags; use lyracore_shared::guild::{ @@ -36,27 +11,20 @@ use super::{game_guild, member, petition, push_event}; use crate::game_world_entity; use crate::items::{game_item_instance, game_item_template}; -/// Copper a Character paid for a guild operation that is not finished yet. Keyed by the payer, so -/// a Character has at most one Fee Hold and the Gateway finds it by guid. The row carries every -/// input the decision needs, so a re-drive decides from the Hold alone. #[table(accessor = game_guild_fee_hold)] pub struct GuildFeeHold { #[primary_key] pub payer_guid: u64, pub operation_id: u64, - /// `lyracore_shared::guild::fee_kind`. pub kind: u8, pub copper: u32, - /// The Tabard Designer or Petitioner the payer stood at. pub npc_guid: u64, pub emblem_style: u32, pub emblem_color: u32, pub border_style: u32, pub border_color: u32, pub background_color: u32, - /// The Guild Charter the Hold created. 0 for an emblem. pub charter_item_guid: u64, - /// The guild name on the Guild Charter. Empty for an emblem. pub charter_name: String, pub created_micros: i64, } @@ -68,11 +36,8 @@ crate::character_owned!(transfer, fn sweep_transfer_game_guild_fee_hold(ctx, cha primary_key = payer_guid, }); -/// The text of the letter that carries the part of a guild fee refund the purse could not hold. const REFUND_LETTER_BODY: &str = "Your purse could not hold all of this guild fee refund."; -/// Realm-core's one decision for one operation id. Kept after the Hold is finished: a guild pays -/// a few fees in its lifetime, and the row is what makes a late retry harmless. #[table(accessor = game_guild_fee_decision)] pub struct GuildFeeDecision { #[primary_key] @@ -80,14 +45,11 @@ pub struct GuildFeeDecision { pub payer_guid: u64, pub kind: u8, pub accepted: bool, - /// The `GuildRefusal` tag when refused, empty when accepted. pub refusal: String, - /// The petition a Guild Charter opened. 0 for an emblem. pub petition_id: u32, pub decided_micros: i64, } -/// The five tabard design values of a Guild, in the order the client sends them. #[derive(SpacetimeType, Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct GuildEmblem { pub emblem_style: u32, @@ -97,30 +59,24 @@ pub struct GuildEmblem { pub background_color: u32, } -/// A Guild Emblem bought at the Tabard Designer `npc_guid`. #[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub struct GuildEmblemPurchase { pub npc_guid: u64, pub emblem: GuildEmblem, } -/// A Guild Charter bought at the Petitioner `npc_guid`, proposing a Guild named `name`. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildCharterPurchase { pub npc_guid: u64, pub name: String, } -/// What the payer asks its Home Shard to hold copper for. Later fee kinds append variants. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub enum GuildFeeRequest { Emblem(GuildEmblemPurchase), Charter(GuildCharterPurchase), } -/// A Guild Charter purchase as Realm-core decides it: the Charter the Hold created and its name, -/// read back from the Hold, plus the owner's name and team, which the Gateway conveys because -/// Realm-core holds no Character rows. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildCharterTerms { pub charter_item_guid: u64, @@ -129,7 +85,6 @@ pub struct GuildCharterTerms { pub payer_team: u32, } -/// What Realm-core decides on: the operation a Fee Hold pays for, read back from the Hold. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub enum GuildFeeTerms { Emblem(GuildEmblem), @@ -145,11 +100,9 @@ impl GuildFeeTerms { } } -/// What a Fee Hold pays for, as the Home Shard stores it. #[derive(Clone, Debug, PartialEq, Eq)] enum HeldTerms { Emblem(GuildEmblem), - /// `charter_item_guid` is 0 until the hold creates the Charter. Charter { name: String, charter_item_guid: u64, @@ -172,7 +125,6 @@ impl HeldTerms { } } -/// One Fee Hold as the protocol sees it. #[derive(Clone, Debug, PartialEq, Eq)] struct HeldFee { operation_id: u64, @@ -205,8 +157,6 @@ impl HeldFee { } } - /// Is this Hold what `request` asked for? The hold mints the Charter guid, so a replay of the - /// same request matches whatever guid the first hold minted. fn answers(&self, request: &HeldFee) -> bool { let mut minted = request.clone(); if let ( @@ -275,8 +225,6 @@ impl HeldFee { } } -/// Why a fee Durable Request did nothing. A Refusal answers the player; a conflict is a Gateway or -/// transport fault and leaves the Hold for the next re-drive. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FeeError { Refused(GuildRefusal), @@ -292,39 +240,23 @@ impl FeeError { } } -/// The Home Shard half: the payer's purse, its Guild Charters and its Fee Hold. trait FeePurse { fn held(&self, payer_guid: u64) -> Option; - /// The payer's purse, `None` when the payer is not in the world on this Shard. fn purse(&self, payer_guid: u64) -> Option; - /// Does `npc_guid` serve fee `kind` to the payer here: flag, alive, in reach, not refusing? fn npc_serves(&self, payer_guid: u64, npc_guid: u64, kind: u8) -> bool; - /// Does this Shard know the Guild Charter item? fn charter_known(&self) -> bool; - /// Can the payer carry one more Guild Charter? The item's unique count comes before a free - /// bag slot, as `Player::CanStoreNewItem` checks them (`cm:PetitionsHandler.cpp:128-134`). fn charter_room(&self, payer_guid: u64) -> Result<(), GuildRefusal>; - /// Take `hold.copper` from the purse and insert the Hold. A Charter Hold also creates the - /// Charter and records its guid. fn take(&mut self, hold: HeldFee) -> Result<(), FeeError>; - /// Destroy the Guild Charter `charter_item_guid` if the payer still holds it. Answers whether - /// it did. fn take_back_charter(&mut self, payer_guid: u64, charter_item_guid: u64) -> bool; - /// Put `credit` back in the purse, send `mailed` by mail, and delete the Hold. `credit` fits - /// in the purse. fn release(&mut self, hold: &HeldFee, credit: u32, mailed: u32) -> Result<(), FeeError>; } -/// The Realm-core half: guild facts, Petitions and the decision ledger. trait FeeLedger { - /// The payer and kind a decision for `operation_id` was made for. fn decided_for(&self, operation_id: u64) -> Option<(u64, u8)>; - /// The payer's Guild and its Guild Leader, when the payer is a member. fn guild_of(&self, payer_guid: u64) -> Option<(u32, u64)>; fn owns_petition(&self, payer_guid: u64) -> bool; fn guild_name_taken(&self, name: &str) -> bool; fn apply_emblem(&mut self, guild_id: u32, emblem: &GuildEmblem); - /// Open the Petition of an accepted Charter. Answers its id. fn open_petition(&mut self, payer_guid: u64, terms: &GuildCharterTerms) -> u32; fn record( &mut self, @@ -336,8 +268,6 @@ trait FeeLedger { ); } -/// Gates in mangos order: the NPC, the Charter template, the purse, then a bag slot for -/// the Charter (`cm:GuildHandler.cpp:726-755`, `cm:PetitionsHandler.cpp:76-134`). fn hold_fee(purse: &mut P, hold: HeldFee) -> Result<(), FeeError> { if hold.operation_id == 0 { return Err(FeeError::Conflict("operation id 0")); @@ -370,12 +300,10 @@ fn hold_fee(purse: &mut P, hold: HeldFee) -> Result<(), FeeError> { purse.take(hold) } -/// Does one more Guild Charter fit under the item's unique count? `max_count` 0 means no limit. fn charter_fits(max_count: u32, held: usize) -> bool { max_count == 0 || held < max_count as usize } -/// Exactly one decision per operation id; a retry changes nothing. fn decide_fee( ledger: &mut L, operation_id: u64, @@ -413,7 +341,6 @@ fn decide_fee( Ok(()) } -/// Only the Guild Leader saves an emblem (`cm:GuildHandler.cpp:735-748`). Answers the Guild. fn emblem_gate(ledger: &L, payer_guid: u64) -> Result { let (guild_id, leader_guid) = ledger .guild_of(payer_guid) @@ -424,9 +351,6 @@ fn emblem_gate(ledger: &L, payer_guid: u64) -> Result( ledger: &L, payer_guid: u64, @@ -445,9 +369,7 @@ fn charter_gate( Ok(()) } -/// A Hold with another operation id, or none, means this finish already ran. Both -/// outcomes need the payer in the world here, so a Character in transit keeps its Hold for the -/// destination. +// An absent Character keeps its Fee Hold for the Transfer destination. fn finish_fee( purse: &mut P, operation_id: u64, @@ -499,9 +421,6 @@ impl FeePurse for CtxPurse<'_> { } fn npc_serves(&self, payer_guid: u64, npc_guid: u64, kind: u8) -> bool { - // A Guild Charter needs a Petitioner that is also a Tabard Designer - // (`cm:PetitionsHandler.cpp:76-84`). The interaction Gate accepts any one of the flags, so - // every one of them is checked after it. let required_flags = match kind { fee_kind::EMBLEM => npc_flags::TABARDDESIGNER, fee_kind::CHARTER => npc_flags::PETITIONER | npc_flags::TABARDDESIGNER, @@ -616,9 +535,6 @@ impl FeePurse for CtxPurse<'_> { } impl CtxPurse<'_> { - /// File the part of a refund the purse cannot hold as Mail Escrow from the NPC that took the - /// fee. The Gateway delivers it at the payer's next world entry or mailbox visit, like a Reward - /// Letter. An NPC no longer in the world here signs the letter with entry 0. fn mail_refund(&self, hold: &HeldFee, mailed: u32) -> Result<(), FeeError> { let npc_entry = self .ctx @@ -644,7 +560,6 @@ impl CtxPurse<'_> { .map_err(|_| FeeError::Conflict("refund letter not filed")) } - /// Mint one Guild Charter into the payer's bags. Answers its guid. fn create_charter( &self, payer_guid: u64, @@ -765,10 +680,6 @@ impl FeeLedger for CtxLedger<'_> { } } -/// Fee on the payer's Home Shard: move the fee from the purse into a Fee Hold. While that -/// Hold exists, the same operation id with the same request is a replay and succeeds without a -/// second debit. After the finish deletes the Hold, a replay would take the fee again, so the -/// Gateway never replays a hold. #[reducer] pub fn gw_guild_fee_hold( ctx: &ReducerContext, @@ -785,8 +696,6 @@ pub fn gw_guild_fee_hold( .map_err(FeeError::into_text) } -/// Fee on Realm-core: commit the one decision for `operation_id`. Always Ok once a -/// decision exists for this payer and kind, accepted or refused. #[reducer] pub fn realm_guild_fee_decide( ctx: &ReducerContext, @@ -800,8 +709,6 @@ pub fn realm_guild_fee_decide( .map_err(FeeError::into_text) } -/// Fee on the payer's Home Shard: spend the Hold, or refund it when Realm-core refused, -/// then delete it. Without a Hold for `operation_id` it is a replay and changes nothing. #[reducer] pub fn gw_guild_fee_finish( ctx: &ReducerContext, @@ -815,7 +722,7 @@ pub fn gw_guild_fee_finish( .map_err(FeeError::into_text) } -/// Character deletion must not destroy copper held for a guild operation. +// Character deletion must not destroy copper held for a guild operation. pub(crate) fn character_has_fee_hold(ctx: &ReducerContext, character_guid: u64) -> bool { ctx.db .game_guild_fee_hold() @@ -849,19 +756,14 @@ mod tests { }) } - /// One Home Shard: purses of Characters in the world, the NPCs that serve them, the Guild - /// Charters each Character holds, and the Holds. #[derive(Default)] struct Shard { purses: BTreeMap, - /// Refund copper sent by mail because the purse could not hold it, per payer. mailed: BTreeMap, serving_npcs: Vec, holds: BTreeMap, - /// Guild Charter guid to its holder. charters: BTreeMap, charter_unknown: bool, - /// What the payer's bags answer one more Guild Charter. charter_room: Option, } @@ -927,14 +829,12 @@ mod tests { } } - /// Realm-core: membership, leaders, emblems, Petitions, decisions and TABARD_CHANGED events. #[derive(Default)] struct Realm { members: BTreeMap, leaders: BTreeMap, guild_names: Vec, emblems: BTreeMap, - /// Owner to its Petition id and the terms that opened it. petitions: BTreeMap, decisions: BTreeMap)>, decided_petitions: BTreeMap, @@ -1025,7 +925,6 @@ mod tests { matches!(realm.decisions.get(&operation_id), Some((_, _, None))) } - /// Hold, decide, finish with the decision the ledger recorded, as the Gateway drives it. fn pay(shard: &mut Shard, realm: &mut Realm, operation_id: u64, payer_guid: u64) { hold(shard, operation_id, payer_guid).unwrap(); decide(realm, operation_id, payer_guid).unwrap(); @@ -1060,7 +959,6 @@ mod tests { fn a_refund_the_purse_cannot_hold_sends_the_rest_by_mail() { let (mut shard, mut realm) = (shard(150_000), realm()); hold(&mut shard, 1, MEMBER).unwrap(); - // Loot fills the purse to 30,000 copper under the u32 cap while the Hold waits. shard.purses.insert(MEMBER, 4_294_937_295); decide(&mut realm, 1, MEMBER).unwrap(); finish_fee(&mut shard, 1, MEMBER, accepted(&realm, 1)).unwrap(); @@ -1229,8 +1127,6 @@ mod tests { ) } - /// Decide the Charter the shard holds for `payer_guid`, as the Gateway conveys it from the - /// Hold plus the payer's name and team. fn decide_charter( shard: &Shard, realm: &mut Realm, @@ -1336,8 +1232,6 @@ mod tests { } } - /// Item 5863 is unique (`max_count` 1): a Character who kept its first Charter through a - /// Guild it has since left holds one and cannot carry a second. #[test] fn a_unique_charter_fits_only_under_its_max_count() { assert!(charter_fits(1, 0)); diff --git a/module/src/guild/membership.rs b/module/src/guild/membership.rs index 225074029..942000381 100644 --- a/module/src/guild/membership.rs +++ b/module/src/guild/membership.rs @@ -1,7 +1,3 @@ -//! Membership lifecycle: invite, accept, decline, leave, remove, promote, demote, pass leadership -//! and disband. The Gates and their order are cmangos-classic's (`cm:GuildHandler.cpp`); the cores -//! here are what [`super::realm_guild_op`] dispatches to. - use lyracore_shared::guild::{event_kind, has_right, rights, GuildRefusal, LEADER_RANK}; use spacetimedb::{table, ReducerContext, Table, Timestamp}; @@ -10,12 +6,8 @@ use super::{ GuildAcceptRequest, GuildInviteRequest, }; -/// The Guild Rank id `SetLeader` gives the old Guild Leader: the default Officer rank (`cm:Guild.h:37`). const OFFICER_RANK: u32 = 1; -/// A pending offer for one Character to join one Guild. At most one per target: a repeated invite -/// to the same target is a Refusal, never a replacement. Reaped on `INVITE_TTL_MICROS` -/// (`module/src/gc.rs`), like `game_group_invite`. #[table(accessor = game_guild_invite, index(accessor = by_guild, btree(columns = [guild_id])))] pub struct GuildInvite { #[primary_key] @@ -25,9 +17,6 @@ pub struct GuildInvite { pub created_at: Timestamp, } -/// `Invite` (`cm:GuildHandler.cpp:66-131`): a member on `actor_guid`'s side offers `request.target_guid` -/// a place in its Guild. An ignored actor gets a silent success and no row, matching mangos' -/// "OK result but not send invite". pub(crate) fn invite( ctx: &ReducerContext, actor_guid: u64, @@ -80,8 +69,6 @@ pub(crate) fn invite( Ok(()) } -/// `Accept` (`cm:GuildHandler.cpp:192-211`): the actor joins the Guild that invited it, at the -/// lowest Guild Rank. A stale, missing or cross-team invite is silent, exactly like mangos. pub(crate) fn accept( ctx: &ReducerContext, actor_guid: u64, @@ -128,8 +115,6 @@ pub(crate) fn accept( Ok(()) } -/// `Decline` (`cm:GuildHandler.cpp:214-238`): the actor refuses its pending Guild Invite. The -/// inviter hears about it through SMSG_GUILD_DECLINE, addressed by this Guild Event. pub(crate) fn decline( ctx: &ReducerContext, actor_guid: u64, @@ -157,8 +142,6 @@ pub(crate) fn decline( Ok(()) } -/// Which of Leave's two outcomes a member gets, pure so the branch is unit-testable without a -/// `ReducerContext`. Mirrors `cm:GuildHandler.cpp:383-404`. #[derive(Debug, PartialEq, Eq)] enum LeaveOutcome { Disband, @@ -173,8 +156,6 @@ fn leave_outcome(is_leader: bool, member_count: usize) -> Result Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let guild = ctx @@ -211,8 +192,6 @@ pub(crate) fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRe Ok(()) } -/// Remove's rank Gate (`cm:GuildHandler.cpp:146-177`): the Guild Leader cannot be removed, and the -/// actor can only remove someone below its own Guild Rank. fn remove_gate(actor_rank: u32, target_rank: u32) -> Result<(), GuildRefusal> { if target_rank == LEADER_RANK { return Err(GuildRefusal::LeaderCannotLeave); @@ -223,7 +202,6 @@ fn remove_gate(actor_rank: u32, target_rank: u32) -> Result<(), GuildRefusal> { Ok(()) } -/// `Remove` (`cm:GuildHandler.cpp:136-190`): the actor expels `target_guid` from its own Guild. pub(crate) fn remove( ctx: &ReducerContext, actor_guid: u64, @@ -254,8 +232,6 @@ pub(crate) fn remove( Ok(()) } -/// Promote's rank Gate (`cm:GuildHandler.cpp:279-311`): the actor can only promote to a Guild Rank -/// below its own plus one. Answers the Guild Rank the promotion lands on. fn promote_target_rank( actor_rank: u32, target_rank: u32, @@ -270,8 +246,6 @@ fn promote_target_rank( Ok(target_rank - 1) } -/// `Promote` (`cm:GuildHandler.cpp:269-320`): `target_guid` moves one Guild Rank up (its rank id -/// decreases by one). pub(crate) fn promote( ctx: &ReducerContext, actor_guid: u64, @@ -301,8 +275,6 @@ pub(crate) fn promote( Ok(()) } -/// Demote's rank Gate (`cm:GuildHandler.cpp:332-372`): the actor can only demote someone below its -/// own Guild Rank, and never past the Guild's lowest. Answers the Guild Rank the demotion lands on. fn demote_target_rank( actor_rank: u32, target_rank: u32, @@ -321,8 +293,6 @@ fn demote_target_rank( Ok(target_rank + 1) } -/// `Demote` (`cm:GuildHandler.cpp:322-380`): `target_guid` moves one Guild Rank down (its rank id -/// increases by one). pub(crate) fn demote( ctx: &ReducerContext, actor_guid: u64, @@ -358,9 +328,6 @@ pub(crate) fn demote( Ok(()) } -/// `SetLeader` (`cm:GuildHandler.cpp:441-486`): the Guild Leader passes leadership to `target_guid`. -/// Naming itself is a no-op: mangos' own code demotes the leader to Officer here through a shared -/// path (`cm:GuildHandler.cpp:482-483`), which this Gate declines to reproduce. pub(crate) fn set_leader( ctx: &ReducerContext, actor_guid: u64, @@ -407,7 +374,6 @@ pub(crate) fn set_leader( Ok(()) } -/// `Disband` (`cm:GuildHandler.cpp:424-435`): the Guild Leader dissolves its own Guild. pub(crate) fn disband(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let guild = ctx @@ -423,9 +389,7 @@ pub(crate) fn disband(ctx: &ReducerContext, actor_guid: u64) -> Result<(), Guild Ok(()) } -/// The mechanics both `Disband` and a lone Guild Leader's `Leave` share (`cm:Guild.cpp:695-711`). -/// DISBANDED goes out addressed, one row per member, before any row is deleted: the generic -/// broadcast reads membership when its relay job runs, which is too late once the members are gone. +// Address DISBANDED before deletion; the later broadcast Relay cannot read deleted membership. pub(super) fn disband_guild(ctx: &ReducerContext, guild_id: u32) { let member_guids: Vec = ctx .db @@ -471,8 +435,6 @@ pub(super) fn disband_guild(ctx: &ReducerContext, guild_id: u32) { ctx.db.game_guild().guild_id().delete(guild_id); } -/// The name of one Guild Rank, empty for an unknown rank (`cm:Guild.cpp:660-666`'s rule, read for -/// names rather than rights). fn rank_name(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> String { ctx.db .game_guild_rank() diff --git a/module/src/guild/mod.rs b/module/src/guild/mod.rs index 1c47bf461..807f931cf 100644 --- a/module/src/guild/mod.rs +++ b/module/src/guild/mod.rs @@ -1,15 +1,3 @@ -//! Guilds on Realm-core. Every guild fact lives here: the Guild, its Guild Ranks, its members, the -//! Petitions that found new Guilds ([`petition`]) and the Guild Events that tell members what -//! happened. Every table is private. The copper a guild operation costs stays on the payer's Home -//! Shard in a Fee Hold until Realm-core decides ([`fee`]). -//! -//! Realm-core holds no Character rows, so the Gateway conveys the Character facts a Gate needs -//! (name, team, Realm Account, GM level) inside the Durable Request, and this Module applies the -//! rules. Every guild Durable Request enters through [`realm_guild_op`]. -//! -//! PLAYER_GUILDID and PLAYER_GUILDRANK are a Gateway projection of `game_guild_member` (the Guild -//! Projection). No World Shard stores them. - use lyracore_shared::guild::{ event_kind, founding_gate, GuildRefusal, DEFAULT_MOTD, DEFAULT_RANKS, LEADER_RANK, }; @@ -23,7 +11,6 @@ pub mod petition; pub(crate) use fee::{sweep_delete_game_guild_fee_hold, sweep_transfer_game_guild_fee_hold}; mod settings; -/// One Guild. `name_key` makes names unique without regard to case. #[table(accessor = game_guild, index(accessor = by_leader, btree(columns = [leader_guid])))] pub struct Guild { #[primary_key] @@ -33,7 +20,6 @@ pub struct Guild { pub name_key: String, pub name: String, pub leader_guid: u64, - /// `lyracore_shared::faction::TEAM_*`, fixed at founding. pub team: u32, pub motd: String, pub info: String, @@ -45,7 +31,6 @@ pub struct Guild { pub created_micros: i64, } -/// One Guild Rank. `rank_id` 0 is the highest; ids are dense from 0. #[table(accessor = game_guild_rank, index(accessor = by_guild, btree(columns = [guild_id])))] pub struct GuildRank { #[primary_key] @@ -57,27 +42,19 @@ pub struct GuildRank { pub rights: u32, } -/// One Character's membership. A Character is in at most one Guild. #[table(accessor = game_guild_member, index(accessor = by_guild, btree(columns = [guild_id])))] pub struct GuildMember { #[primary_key] pub character_guid: u64, pub guild_id: u32, pub rank_id: u32, - /// Name snapshot, like mangos `MemberSlot::Name` (`cm:Guild.h:173`). Guild Events and by-name - /// member ops read it, so they need no Character row. Refreshed at sign-on. pub name: String, pub public_note: String, pub officer_note: String, - /// The member's Realm Account. 0 when unknown; the guild info count treats it as its own - /// Account. pub realm_account_id: u64, pub joined_micros: i64, } -/// One Guild Event. A row with `recipient_guid == 0` goes to every online member of `guild_id`; -/// a nonzero `recipient_guid` addresses that one Character. The strings are final, so the Gateway -/// renders without reads. Reaped on the shared event TTL. #[table( accessor = game_guild_event, index(accessor = by_guild, btree(columns = [guild_id])), @@ -89,31 +66,23 @@ pub struct GuildEvent { pub id: u64, pub guild_id: u32, pub recipient_guid: u64, - /// `lyracore_shared::guild::event_kind`. pub kind: u8, - /// The Character the event is about. SMSG_GUILD_EVENT writes it after the strings when nonzero. pub subject_guid: u64, - /// A second object the event names, such as a Guild Charter item. pub other_guid: u64, pub strings: Vec, pub created_at: Timestamp, } -/// A GM founds a Guild led by `leader_guid`. The leader may be offline. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildGmCreate { pub leader_guid: u64, pub leader_name: String, pub leader_team: u32, pub leader_realm_account: u64, - /// The acting Character's GM level, read from its Home Shard. pub gm_level: u8, pub name: String, } -/// A member invites a live Character on another World Shard to join its Guild. The Gateway -/// resolves the target realm-wide and conveys the facts Realm-core cannot read itself: each side's -/// team (from race) and whether the target ignores the actor. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildInviteRequest { pub target_guid: u64, @@ -122,22 +91,18 @@ pub struct GuildInviteRequest { pub target_ignores_actor: bool, } -/// The actor accepts its pending Guild Invite. Realm-core holds no Character row for a non-member, -/// so the Gateway conveys the name and team `add_member` needs. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildAcceptRequest { pub actor_name: String, pub actor_team: u32, } -/// A Public or Officer Note edit: the same shape, gated by a different Rank Right and field. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildNoteEdit { pub target_guid: u64, pub text: String, } -/// A Guild Rank rename and rights change. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildRankEdit { pub rank_id: u32, @@ -145,71 +110,36 @@ pub struct GuildRankEdit { pub name: String, } -/// One guild Durable Request. Later ops append variants here. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub enum GuildOp { GmCreate(GuildGmCreate), - /// The actor entered the world. `actor_name` refreshes the member's name snapshot. - SignOn { - actor_name: String, - }, + SignOn { actor_name: String }, SignOff, Invite(GuildInviteRequest), Accept(GuildAcceptRequest), - /// The actor declines its pending Guild Invite. `actor_name` addresses the inviter's event, - /// since Realm-core holds no name snapshot for a non-member. - Decline { - actor_name: String, - }, + Decline { actor_name: String }, Leave, - Remove { - target_guid: u64, - }, - Promote { - target_guid: u64, - }, - Demote { - target_guid: u64, - }, - SetLeader { - target_guid: u64, - }, + Remove { target_guid: u64 }, + Promote { target_guid: u64 }, + Demote { target_guid: u64 }, + SetLeader { target_guid: u64 }, Disband, - SetMotd { - text: String, - }, - SetInfo { - text: String, - }, + SetMotd { text: String }, + SetInfo { text: String }, SetPublicNote(GuildNoteEdit), SetOfficerNote(GuildNoteEdit), EditRank(GuildRankEdit), - AddRank { - name: String, - }, + AddRank { name: String }, DeleteRank, SignPetition(petition::GuildPetitionSign), OfferPetition(petition::GuildPetitionOffer), - /// The actor declines to sign the Petition of `charter_item_guid`. - DeclinePetition { - charter_item_guid: u64, - }, + DeclinePetition { charter_item_guid: u64 }, RenamePetition(petition::GuildPetitionRename), - TurnInPetition { - charter_item_guid: u64, - }, - ClosePetition { - petition_id: u32, - }, - /// The actor, a Character that no World Shard holds any more, leaves every guild trace behind. - /// Only a tokenless actor may send it: the Gateway proves the deletion first. + TurnInPetition { charter_item_guid: u64 }, + ClosePetition { petition_id: u32 }, ForgetDeletedCharacter, } -/// Run one guild op for the acting Character. -/// -/// Operator-gated because the actor's guid is an argument: Realm-core has no live entity to derive -/// it from. A Refusal returns its [`GuildRefusal`] tag as the whole error text and commits nothing. #[reducer] pub fn realm_guild_op( ctx: &ReducerContext, @@ -250,7 +180,6 @@ pub fn realm_guild_op( petition::turn_in(ctx, actor_guid, actor_account, charter_item_guid) } GuildOp::ClosePetition { petition_id } => petition::close(ctx, actor_guid, petition_id), - // A World Session's token proves its Character is live, so it can never be forgotten. GuildOp::ForgetDeletedCharacter if !tokenless => { return Err(format!( "Character {actor_guid} has a World Session and is not forgotten" @@ -310,8 +239,6 @@ fn sign_off(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { Ok(()) } -/// Found a Guild with the five default Guild Ranks and `leader_guid` at rank 0 -/// (`cm:Guild.cpp:104-154`). The Gates and their order are `founding_gate`'s. pub(crate) fn create_guild( ctx: &ReducerContext, leader_guid: u64, @@ -366,9 +293,6 @@ pub(crate) fn create_guild( Ok(guild.guild_id) } -/// Insert one member row. This is the only place a member row is inserted. Refuses a Character -/// that is already a member of any Guild (`cm:Guild.cpp:167-179`). A joiner's own Petition closes -/// and its Signatures are struck. pub(crate) fn add_member( ctx: &ReducerContext, guild_id: u32, @@ -394,7 +318,6 @@ pub(crate) fn add_member( Ok(()) } -/// The membership row of `character_guid`, if it is in a Guild. pub(crate) fn member(ctx: &ReducerContext, character_guid: u64) -> Option { ctx.db .game_guild_member() @@ -402,7 +325,6 @@ pub(crate) fn member(ctx: &ReducerContext, character_guid: u64) -> Option Vec { let mut ranks: Vec = ctx .db @@ -414,7 +336,6 @@ pub(crate) fn ranks(ctx: &ReducerContext, guild_id: u32) -> Vec { ranks } -/// The lowest Guild Rank's id: where a new member starts. pub(crate) fn lowest_rank(ctx: &ReducerContext, guild_id: u32) -> u32 { ctx.db .game_guild_rank() @@ -425,7 +346,6 @@ pub(crate) fn lowest_rank(ctx: &ReducerContext, guild_id: u32) -> u32 { .unwrap_or(LEADER_RANK) } -/// The Rank Rights of one Guild Rank. An unknown rank reads 0 (`cm:Guild.cpp:660-666`). pub(crate) fn rank_rights(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> u32 { ctx.db .game_guild_rank() @@ -435,7 +355,6 @@ pub(crate) fn rank_rights(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> .map_or(0, |rank| rank.rights) } -/// Write one Guild Event. `recipient_guid == 0` broadcasts to the Guild's online members. pub(crate) fn push_event( ctx: &ReducerContext, guild_id: u32, @@ -461,10 +380,6 @@ pub(crate) fn push_event( mod tests { use super::*; - /// BSATN encodes a `GuildOp` variant as its ordinal position, so appending a variant in the - /// wrong place silently renames every variant after it on the wire. This pins each variant's - /// tag byte to a hand-written value: reordering `GuildOp` fails this test instead of shipping - /// a schema a live client silently decodes as the wrong op. #[test] fn guild_op_variant_bsatn_tags_are_pinned_by_position() { let cases: [(&str, GuildOp, u8); 26] = [ diff --git a/module/src/guild/petition.rs b/module/src/guild/petition.rs index 1848da737..b1ad60fb9 100644 --- a/module/src/guild/petition.rs +++ b/module/src/guild/petition.rs @@ -1,17 +1,3 @@ -//! Petitions: how players found a Guild. A Character buys a Guild Charter through the Fee Hold -//! ([`super::fee`]), which opens a Petition here on Realm-core. Nine Characters on nine Realm -//! Accounts sign it, and the owner turns it in to found the Guild. The Gates and their order are -//! cmangos-classic's (`cm:PetitionsHandler.cpp`); the cores here are what -//! [`super::realm_guild_op`] dispatches to. -//! -//! The Charter item lives on the owner's Home Shard and the Petition on Realm-core. They share only -//! the Charter's item guid. The Charter binds when picked up, so trade and mail refuse it, and a -//! Transfer keeps its guid. A Petition loses its Charter only when the owner destroys the Charter; -//! nobody can turn that Petition in, and the owner's next Charter purchase closes it first. A -//! Charter loses its Petition when the owner joins a Guild; the Charter then does nothing, and the -//! Gateway destroys it at the owner's next world entry. A deleted owner's Petition closes and a -//! deleted signer's Signature is struck when the Gateway forgets that Character. - use lyracore_shared::guild::{ event_kind, name_key, petition_signature_key, validate_guild_name, GuildRefusal, GUILD_CHARTER_ENTRY, MAX_PETITION_SIGNATURES, MIN_PETITION_SIGNATURES, @@ -22,8 +8,6 @@ use super::membership::game_guild_invite; use super::{add_member, create_guild, game_guild, lowest_rank, member, push_event}; use crate::items::game_item_instance; -/// One open Petition: a proposed Guild, its owner and the Guild Charter that stands for it. An -/// owner has at most one. #[table(accessor = game_guild_petition)] pub struct GuildPetition { #[primary_key] @@ -33,36 +17,27 @@ pub struct GuildPetition { pub charter_item_guid: u64, #[unique] pub owner_guid: u64, - /// The owner's name snapshot, for the Guild Leader's member row at founding. pub owner_name: String, - /// `lyracore_shared::faction::TEAM_*` of the owner. Only the same team signs. pub team: u32, pub name: String, pub created_micros: i64, } -/// One Signature on one Petition. At most one per Realm Account on a Petition. #[table( accessor = game_guild_petition_signature, index(accessor = by_petition, btree(columns = [petition_id])), index(accessor = by_signer, btree(columns = [signer_guid])) )] pub struct GuildPetitionSignature { - /// `lyracore_shared::guild::petition_signature_key(petition_id, slot)`, so the Gateway reads a - /// Petition's Signatures by key. #[primary_key] pub signature_key: u64, pub petition_id: u32, pub signer_guid: u64, - /// The signer's name snapshot, for its member row at founding. pub signer_name: String, - /// The signer's Realm Account, never 0: a signer without a known Account is refused. pub signer_realm_account: u64, pub signed_micros: i64, } -/// A Character signs the Petition of `charter_item_guid`. Realm-core holds no row for a -/// non-member, so the Gateway conveys the signer's name and team. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildPetitionSign { pub charter_item_guid: u64, @@ -70,8 +45,6 @@ pub struct GuildPetitionSign { pub actor_team: u32, } -/// The owner offers its Petition to a live Character, who then sees the signature window. The -/// Gateway resolves the target's team from its race. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildPetitionOffer { pub charter_item_guid: u64, @@ -79,30 +52,24 @@ pub struct GuildPetitionOffer { pub target_team: u32, } -/// The owner renames its Petition. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct GuildPetitionRename { pub charter_item_guid: u64, pub name: String, } -/// One Signature as the Gates read it. #[derive(Clone, Debug, PartialEq, Eq)] struct SignatureFacts { signer_guid: u64, signer_realm_account: u64, } -/// What a signing attempt that passed the Gates does. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SignVerdict { Sign, - /// The signer's Realm Account already signed. Nothing is written, but both sides hear it - /// (`cm:PetitionsHandler.cpp:380-396`). AlreadySigned, } -/// Where a would-be signer stands with the Guilds: a member, invited to one, or neither. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum GuildStanding { Member, @@ -126,8 +93,6 @@ fn guild_standing(ctx: &ReducerContext, character_guid: u64) -> GuildStanding { } } -/// A Guild member, or a Character with a pending Guild Invite, can neither sign nor be offered a -/// Petition (`cm:PetitionsHandler.cpp:359-369,474-484`). fn standing_gate(standing: GuildStanding) -> Result<(), GuildRefusal> { match standing { GuildStanding::Member => Err(GuildRefusal::AlreadyInGuild), @@ -136,10 +101,6 @@ fn standing_gate(standing: GuildStanding) -> Result<(), GuildRefusal> { } } -/// The signature Gates in mangos order (`cm:PetitionsHandler.cpp:338-396`). mangos stays silent -/// when the owner signs; here the owner hears CANT_SIGN_OWN, as vmangos answers -/// (`vm:src/game/Handlers/PetitionsHandler.cpp:232-240`). A signer whose Realm Account is unknown -/// is refused: without it, one Signature per Account cannot hold. fn sign_verdict( owner_guid: u64, petition_team: u32, @@ -170,7 +131,6 @@ fn sign_verdict( Ok(SignVerdict::Sign) } -/// The turn-in Gates in mangos order (`cm:PetitionsHandler.cpp:530-571`). fn turn_in_gate( owner_guid: u64, actor_guid: u64, @@ -193,8 +153,6 @@ fn turn_in_gate( Ok(()) } -/// The signers that join the new Guild, in slot order. A signer who joined another Guild -/// since it signed is skipped (`cm:Guild.cpp:167-179`). fn joining_signers( mut signatures: Vec, is_member: impl Fn(u64) -> bool, @@ -238,8 +196,6 @@ fn delete_petition(ctx: &ReducerContext, petition_id: u32) { .delete(petition_id); } -/// Does `owner_guid` own an open Petition? The Charter decision refuses a second one -/// (`vm:src/game/Handlers/PetitionsHandler.cpp:69-71`). pub(crate) fn owns_petition(ctx: &ReducerContext, owner_guid: u64) -> bool { ctx.db .game_guild_petition() @@ -248,7 +204,6 @@ pub(crate) fn owns_petition(ctx: &ReducerContext, owner_guid: u64) -> bool { .is_some() } -/// Open the Petition an accepted Charter purchase pays for. Answers its id. pub(crate) fn open( ctx: &ReducerContext, owner_guid: u64, @@ -271,9 +226,6 @@ pub(crate) fn open( .petition_id } -/// `SignPetition`: the actor signs. Both the owner and the signer hear the result through an -/// addressed Guild Event, so an already-signed Realm Account is an outcome, not a Refusal: the -/// owner's event must commit even though no Signature does. pub(crate) fn sign( ctx: &ReducerContext, actor_guid: u64, @@ -336,8 +288,6 @@ pub(crate) fn sign( Ok(()) } -/// `OfferPetition` (`cm:PetitionsHandler.cpp:452-512`): the owner shows its Petition to a live -/// Character of its team outside any Guild and without a pending Guild Invite. pub(crate) fn offer( ctx: &ReducerContext, actor_guid: u64, @@ -364,7 +314,6 @@ pub(crate) fn offer( Ok(()) } -/// `DeclinePetition` (`cm:PetitionsHandler.cpp:424-450`): the owner learns who declined. pub(crate) fn decline( ctx: &ReducerContext, actor_guid: u64, @@ -384,7 +333,6 @@ pub(crate) fn decline( Ok(()) } -/// `RenamePetition` (`cm:PetitionsHandler.cpp:284-319`). pub(crate) fn rename( ctx: &ReducerContext, actor_guid: u64, @@ -404,11 +352,6 @@ pub(crate) fn rename( Ok(()) } -/// `TurnInPetition` (`cm:PetitionsHandler.cpp:514-634`): found the Guild with the owner at rank 0 -/// and every signer still outside a Guild at the lowest Guild Rank. The Petition and its -/// Signatures go first, so the `add_member` hook finds none of them. The Charter item is the -/// Gateway's to destroy on the Home Shard afterwards; until then it is inert, because its Petition -/// is gone. pub(crate) fn turn_in( ctx: &ReducerContext, actor_guid: u64, @@ -457,8 +400,6 @@ pub(crate) fn turn_in( Ok(()) } -/// `ClosePetition`: the owner drops its Petition, as the Gateway does before a new Charter -/// purchase when the old Charter is gone. pub(crate) fn close( ctx: &ReducerContext, actor_guid: u64, @@ -477,9 +418,6 @@ pub(crate) fn close( Ok(()) } -/// A Character joined a Guild or was deleted: its own Petition closes and every Signature it made -/// is struck, and each owner who lost one gets a fresh petition query (`cm:Guild.cpp:181-183`, -/// `cm:Player.cpp:4061-4062,16980-17007`). pub(super) fn withdraw(ctx: &ReducerContext, character_guid: u64) { if let Some(own) = ctx .db @@ -519,10 +457,6 @@ pub(super) fn withdraw(ctx: &ReducerContext, character_guid: u64) { } } -/// Destroy a turned-in Guild Charter on the actor's Home Shard. A Charter that is already gone is -/// Ok, so a repeated call changes nothing. Any other item is left alone. A Charter a failed call -/// leaves behind has no Petition, founds nothing, and is destroyed through this reducer again at -/// its owner's next world entry. #[reducer] pub fn gw_destroy_guild_charter( ctx: &ReducerContext, diff --git a/module/src/guild/settings.rs b/module/src/guild/settings.rs index 3905e4c2d..2442f7a96 100644 --- a/module/src/guild/settings.rs +++ b/module/src/guild/settings.rs @@ -1,7 +1,3 @@ -//! MOTD, guild info text, public and officer notes, and Guild Rank edit, add and delete -//! (`cm:GuildHandler.cpp:488-680`). Every op first requires membership; a Refusal leaves every -//! guild row unchanged. - use super::{ game_guild, game_guild_member, game_guild_rank, member, push_event, rank_rights, GuildMember, GuildNoteEdit, GuildRank, GuildRankEdit, @@ -12,8 +8,6 @@ use lyracore_shared::guild::{ }; use spacetimedb::{ReducerContext, Table}; -/// CMSG_GUILD_MOTD: the actor's Guild Rank needs SETMOTD. An empty MOTD is allowed -/// (`cm:GuildHandler.cpp:494-497`). Broadcasts MOTD `[text]` (`:511-513`). pub(super) fn set_motd( ctx: &ReducerContext, actor_guid: u64, @@ -49,8 +43,6 @@ pub(super) fn set_motd( Ok(()) } -/// CMSG_GUILD_INFO_TEXT: the actor's Guild Rank needs MODIFY_GUILD_INFO. No event, no reply -/// (`cm:GuildHandler.cpp:693-713`). pub(super) fn set_info( ctx: &ReducerContext, actor_guid: u64, @@ -77,7 +69,6 @@ pub(super) fn set_info( Ok(()) } -/// Which note field an edit targets, and the Rank Right that gates it. enum NoteKind { Public, Officer, @@ -99,10 +90,6 @@ impl NoteKind { } } -/// CMSG_GUILD_SET_PUBLIC_NOTE / SET_OFFICER_NOTE, shared: check the editor's right, check the -/// target is a member of the same Guild, check the note length, write the note, then address a -/// fresh roster back to the editor (event `0x50`) so it renders after the commit -/// (`cm:GuildHandler.cpp:526-588`). fn set_note( ctx: &ReducerContext, actor_guid: u64, @@ -153,7 +140,6 @@ pub(super) fn set_officer_note( set_note(ctx, actor_guid, edit, NoteKind::Officer) } -/// The acting Character's Guild, when it is the Guild Leader. fn require_leader( ctx: &ReducerContext, actor_guid: u64, @@ -171,9 +157,6 @@ fn require_leader( Ok(()) } -/// CMSG_GUILD_RANK: the Guild Leader renames a Guild Rank and sets its rights. An unknown -/// `rank_id` changes nothing and answers success, matching `cm:Guild.cpp:668-688`; rank 0 keeps -/// every right whatever the client sends (`:621-622`). Broadcasts `ROSTER_REFRESH`. pub(super) fn edit_rank( ctx: &ReducerContext, actor_guid: u64, @@ -212,9 +195,6 @@ pub(super) fn edit_rank( Ok(()) } -/// CMSG_GUILD_ADD_RANK: the Guild Leader appends a new lowest Guild Rank with -/// GCHATLISTEN|GCHATSPEAK, up to [`MAX_RANKS`] (`cm:GuildHandler.cpp:650-657`). Broadcasts -/// `ROSTER_REFRESH`. pub(super) fn add_rank( ctx: &ReducerContext, actor_guid: u64, @@ -249,11 +229,6 @@ pub(super) fn add_rank( Ok(()) } -/// CMSG_GUILD_DEL_RANK: the Guild Leader deletes the lowest Guild Rank, down to [`MIN_RANKS`] -/// (`cm:Guild.cpp:639-650`). Its members move to the new lowest rank in the same transaction, so -/// the Guild Projection relay on `game_guild_member` picks up their new PLAYER_GUILDRANK as an -/// ordinary update; mangos instead leaves them out of range until the client reloads. Broadcasts -/// `ROSTER_REFRESH`. pub(super) fn delete_rank(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; require_leader(ctx, actor_guid, actor.guild_id)?; @@ -290,8 +265,3 @@ pub(super) fn delete_rank(ctx: &ReducerContext, actor_guid: u64) -> Result<(), G ); Ok(()) } - -// Behavior tests for these reducers (rank add and delete bounds, members moved on delete, rank 0 -// rights forced) live in `module/tests/guild_settings.rs`, which runs them as real reducer calls -// against real rows. A pure unit test here could only restate the same constants or expressions -// the reducer already uses, proving nothing about the reducer itself. diff --git a/module/src/gw.rs b/module/src/gw.rs index a8a32088a..7aa79afc0 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -1,7 +1,3 @@ -//! Player action reducers for the Gateway's shared Operator connection. -//! Each request requires the Operator and resolves its Session Actor before entering the owning -//! gameplay operation. Acting entity lookup applies the Transfer fence. - use lyracore_shared::loot::LootBoundaryFailure; use spacetimedb::{reducer, table, Identity, ReducerContext, ScheduleAt, Table, Timestamp}; @@ -9,24 +5,6 @@ use crate::game_account; use crate::game_world_entity; use crate::helpers::{acting_entity_by_guid, require_operator}; -// =========================================================================================== -// The gateway lease — bounded ghost lifetime for shared-connection sessions [server] -// =========================================================================================== -// -// THE sharp edge: under one shared connection, a gateway crash fires ONE -// `client_disconnected` instead of one per player, so nothing per-player tears world entities -// down and a crash would leave every seated player as a permanent world ghost. The fix is a -// LEASE: each gateway heartbeats its row; sessions opened through the shared connection bind -// their entity to the lease (`gw_player_login` writes the `GatewaySession` rows); a -// scheduled reaper removes the world entities of any lease that stops heartbeating. -// -// Deliberately TTL-ONLY — no sweep on the shared connection's own disconnect. The coordinator -// connection reconnects routinely (module republish closes every websocket), and a -// disconnect-triggered sweep would mass-despawn every player on a live gateway that is merely -// reconnecting. A dead gateway simply stops heartbeating and lapses; ghost lifetime is bounded -// by TTL + reap interval (~75 s worst case) no matter HOW the gateway died. - -/// One row per live gateway process, keyed by its operator identity. Private. [server] #[table(accessor = game_gateway_lease)] pub struct GatewayLease { #[primary_key] @@ -37,9 +15,6 @@ pub struct GatewayLease { pub last_heartbeat: Timestamp, } -/// entity_guid → lease binding for a session riding the shared connection. Written by -/// `gw_player_login`; the reaper below is inert while the table is empty. -/// Private. [server] #[table(accessor = game_gateway_session)] pub struct GatewaySession { #[primary_key] @@ -47,9 +22,6 @@ pub struct GatewaySession { pub lease_id: u64, } -/// The reaper's schedule row — armed by `seed::init` on a fresh database and ensured by -/// `debug_repair_after_publish` on every publish (the lesson: `init` never re-runs on an -/// auto-migrate republish). [server] #[table(accessor = game_gateway_lease_reaper_schedule, scheduled(reap_gateway_leases))] pub struct GatewayLeaseReaperSchedule { #[primary_key] @@ -58,20 +30,13 @@ pub struct GatewayLeaseReaperSchedule { pub scheduled_at: ScheduleAt, } -/// How often the reaper checks leases. pub(crate) const LEASE_REAP_MICROS: i64 = 15_000_000; -/// How long a lease survives without a heartbeat. Three missed 15 s heartbeats plus slack: a -/// gateway that cannot heartbeat for a full minute is not serving its players anyway. pub(crate) const LEASE_TTL_MICROS: i64 = 60_000_000; -/// The reap decision, pure: a lease is dead when its last heartbeat is older than the TTL. pub(crate) fn lease_expired(now_micros: i64, heartbeat_micros: i64, ttl_micros: i64) -> bool { now_micros - heartbeat_micros > ttl_micros } -/// Upsert the calling gateway's lease. The gateway calls this every ~15 s on its shared -/// connection; the FIRST call creates the lease (world entry before the first heartbeat is -/// impossible — `gw_player_login` will resolve the lease by `ctx.sender()`). #[reducer] pub fn gw_heartbeat(ctx: &ReducerContext) -> Result<(), String> { require_operator(ctx)?; @@ -92,10 +57,6 @@ pub fn gw_heartbeat(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Close expired Account Fences and Account Claims, then remove the world entities of every lapsed -/// lease and the lease itself. Closing a claim ends its Character's Channel Memberships. Scheduled, -/// so the sender guard is the standard "only the scheduler may fire this" fence every scheduled -/// reducer here carries. #[reducer] pub fn reap_gateway_leases( ctx: &ReducerContext, @@ -107,7 +68,6 @@ pub fn reap_gateway_leases( crate::account_ownership::reap_account_fences(ctx); crate::account_ownership::reap_account_claims(ctx); let now = ctx.timestamp.to_micros_since_unix_epoch(); - // Collect first: the loops delete from the tables they walk. let dead: Vec = ctx .db .game_gateway_lease() @@ -134,8 +94,6 @@ pub fn reap_gateway_leases( sessions.len() ); for session in sessions { - // Resolve the entity's owner identity so the removal takes the SAME logout path a - // per-player disconnect takes (hooks fire, pets despawn, corpse rules apply). if let Some(entity) = ctx.db.game_world_entity().guid().find(session.entity_guid) { crate::world::remove_from_world(ctx, entity.owner_identity); } @@ -149,9 +107,6 @@ pub fn reap_gateway_leases( Ok(()) } -/// Resolve the acting entity for a gateway verb, with the sender path's exact error string — -/// `is_desync_error` on the gateway classifies on "not in world", and the two entries must be -/// indistinguishable to that classifier. fn actor(ctx: &ReducerContext, actor_guid: u64) -> Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; @@ -159,8 +114,6 @@ fn actor(ctx: &ReducerContext, actor_guid: u64) -> Result, mut suppressed: impl FnMut(u64) -> bool, @@ -217,13 +167,6 @@ fn apply_movement_batch( } } -/// ONE transaction per gateway tick instead of one per player heartbeat. The measured wall -/// behind this: ~10k `gw_movement_update` transactions/s at ~43µs of per-transaction machinery -/// each put the writer at 92% while the actual row work (the batched `publish_motion` side) was -/// a fraction of that. The gateway collects a tick's worth of movement (25–50ms) and sends it -/// here; each entry runs the SAME `apply_movement_update` core with the same per-entry -/// validation. A failing entry (mover despawned mid-tick, NaN guard, in-transit fence) is LOGGED -/// and skipped — one bad move must never roll back the other few hundred players' tick. #[reducer] pub fn gw_movement_batch(ctx: &ReducerContext, moves: Vec) -> Result<(), String> { require_operator(ctx)?; @@ -246,18 +189,11 @@ pub fn gw_movement_batch(ctx: &ReducerContext, moves: Vec) -> Result<(), ) }, ); - // Drain the staged motion INSIDE this transaction — the subscription sweep is paid - // once per batch either way (the whole point), and inline draining removes the publish - // schedule's 50ms latency quantum plus the pending-row churn for this path. + // Drain motion inside the batch transaction so its subscription sweep runs once. crate::motion::publish_staged(ctx); Ok(()) } -// =========================================================================================== -// Action verbs — thin gated wrappers over the actor.rs cores -// =========================================================================================== - -/// [`crate::actor::attack`] behind the gateway gate — melee auto-attack engage. #[reducer] pub fn gw_attack( ctx: &ReducerContext, @@ -270,7 +206,6 @@ pub fn gw_attack( crate::actor::attack(ctx, actor_guid, target_guid) } -/// [`crate::actor::cast_at`] behind the gateway gate — the full cast core with every gate. #[reducer] pub fn gw_cast_at( ctx: &ReducerContext, @@ -284,9 +219,6 @@ pub fn gw_cast_at( crate::actor::cast_at(ctx, actor_guid, spell_id, target_guid) } -/// [`crate::spell::scheduler::apply_cast_spell_at`] behind the gateway gate. The GROUND-TARGET -/// cast form (`CMSG`-routed `cast_spell_at`): spellbook + NaN + self-target-default gates, target -/// coords carried. [`gw_cast_at`] is the unit-target Actor form and applies the same spellbook Gate. #[reducer] pub fn gw_cast_spell_at( ctx: &ReducerContext, @@ -303,8 +235,6 @@ pub fn gw_cast_spell_at( crate::spell::apply_cast_spell_at(ctx, &caster, spell_id, target_guid, x, y, z) } -/// Taxi flight is an expected loot Refusal. A missing Actor is a tagged boundary failure so legacy -/// untagged core compatibility cannot turn it into a gameplay outcome. fn loot_actor(ctx: &ReducerContext, actor_guid: u64) -> Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| { loot_boundary_failure(LootBoundaryFailure::MissingActor, "mover not in world") @@ -328,7 +258,6 @@ fn loot_operator_error(detail: String) -> String { loot_boundary_failure(LootBoundaryFailure::OperatorRejected, &detail) } -/// [`crate::actor::take_loot`] behind the gateway gate — one loot slot off a corpse/GO. #[reducer] pub fn gw_take_loot( ctx: &ReducerContext, @@ -342,8 +271,6 @@ pub fn gw_take_loot( crate::actor::take_loot(ctx, actor_guid, corpse_guid, loot_slot) } -/// [`crate::actor::open_creature_loot`] behind the gateway gate. A successful call authorizes -/// the Gateway's following corpse-loot read; it creates no durable loot-window state. #[reducer] pub fn gw_open_creature_loot( ctx: &ReducerContext, @@ -356,7 +283,6 @@ pub fn gw_open_creature_loot( crate::actor::open_creature_loot(ctx, actor_guid, corpse_guid) } -/// [`crate::actor::ranged_attack`] behind the gateway gate — Auto Shot / wand engage. #[reducer] pub fn gw_ranged_attack( ctx: &ReducerContext, @@ -370,7 +296,6 @@ pub fn gw_ranged_attack( crate::actor::ranged_attack(ctx, actor_guid, target_guid, spell_id) } -/// [`crate::actor::stop_attack`] behind the gateway gate — disarm the actor's outgoing swing. #[reducer] pub fn gw_stop_attack( ctx: &ReducerContext, @@ -382,8 +307,6 @@ pub fn gw_stop_attack( crate::actor::stop_attack(ctx, actor_guid) } -/// [`crate::world::apply_set_sheathed`] behind the gateway gate — the `CMSG_SETSHEATHED` a client -/// sends on `Z`. `state` is raw client input and is range-checked in the apply fn, not here. #[reducer] pub fn gw_set_sheathed( ctx: &ReducerContext, @@ -396,7 +319,6 @@ pub fn gw_set_sheathed( crate::world::apply_set_sheathed(ctx, actor, state) } -/// [`crate::actor::accept_quest`] behind the gateway gate. #[reducer] pub fn gw_accept_quest( ctx: &ReducerContext, @@ -410,7 +332,6 @@ pub fn gw_accept_quest( crate::actor::accept_quest(ctx, actor_guid, giver_guid, quest_entry) } -/// [`crate::actor::turn_in_quest`] behind the gateway gate. #[reducer] pub fn gw_turn_in_quest( ctx: &ReducerContext, @@ -423,13 +344,10 @@ pub fn gw_turn_in_quest( let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; actor(ctx, actor_guid)?; crate::actor::turn_in_quest(ctx, actor_guid, giver_guid, quest_entry, reward_index)?; - // Only the World Session files a Reward Letter: no Gateway drives a bot's mail, so the - // Package turn-in path would leave the letter held. + // Only World Sessions drive Reward Letters; a bot turn-in would leave its letter held. crate::mail_reward::file_reward_letter(ctx, actor_guid, giver_guid, quest_entry) } -/// Taxi flight is an expected item Refusal. Missing actors still carry the generic actor error, -/// which the Gateway treats as a failure with an unknown result. fn item_actor(ctx: &ReducerContext, actor_guid: u64) -> Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; @@ -442,7 +360,6 @@ fn item_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; @@ -552,7 +460,6 @@ fn trainer_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; @@ -792,8 +666,6 @@ fn contact_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result { let actor = acting_entity_by_guid(ctx, actor_guid).ok_or_else(|| "mover not in world".to_string())?; @@ -864,7 +727,6 @@ fn group_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result<(), String> { match ctx.db.game_operator().id().find(0) { Some(op) if op.identity == ctx.sender() => Ok(()), @@ -17,31 +9,6 @@ pub(crate) fn require_operator(ctx: &ReducerContext) -> Result<(), String> { } } -/// The live world entity owned by `owner`, if in world. Indexed probe through `by_owner` (perf -/// catalog 1.2) — this is the auth prologue of essentially every player-fired reducer, so the old -/// full `game_world_entity` scan was an O(E) cost on EVERY player transaction. -/// Note: creatures all share `Identity::ZERO` as `owner_identity`, so a `ZERO` probe would walk -/// every creature — but no caller ever passes `ZERO` (`ctx.sender()` is a real client identity), and -/// the old scan would have matched the first creature anyway. Same result, same shape, no new guard. -/// -/// THE actor chokepoint: every player-fired reducer resolves "who is acting" through here -/// (60+ call sites, all shaped `entity_by_owner(ctx, ctx.sender()).ok_or_else(|| "... not in -/// world")?`). That is why the escrowed-transfer in-transit gate lives here rather than -/// at each caller — an in-transit character reads as "not in world" everywhere, for free. -/// `begin_transfer` also DELETES the live entity row in the same transaction it writes the escrow, -/// so the target side (the ~50 `map_id`/`instance_id` gates, aggro candidate scans, threat lists, -/// the AOI relay) stops seeing the character by construction. This check is the belt to that -/// braces: it fences the actor side even if a live row somehow survives. -/// -/// SCOPE: this gates the ACTOR side only. Reducers that reach a character by `character_guid` or by -/// name go through [`character_by_guid`] / [`character_by_name`] instead — see the -/// verdict table in `transfer.rs`'s module doc for the paths that are deliberately NOT gated. -/// -/// The candidate is collapsed to AT MOST one row with `.next()` FIRST, then handed to -/// [`gate_by_guid`] — never `Iterator::filter` ahead of `.next()`, which would SKIP an in-transit -/// row and hand back the next matching entity instead of none, silently defeating the fence rather -/// than closing it. The sense of the check itself (in-transit ⇒ refuse) lives in -/// [`gate_in_transit`], not here — see its doc for why. pub(crate) fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option { gate_by_guid( ctx, @@ -50,45 +17,16 @@ pub(crate) fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option Option { gate_by_guid(ctx, ctx.db.game_world_entity().guid().find(guid), |e| { e.guid }) } -/// THE by-guid chokepoint — `entity_by_owner`'s twin for every reducer that reaches a -/// character by `character_guid` instead of through the acting entity. Returns the durable -/// `game_character` row unless the character is mid-transfer, in which case it reads as ABSENT and -/// the caller's existing "no such character" arm fires. -/// -/// Reading as absent is deliberate and is what keeps this a zero-cost seam: every call site already -/// has a not-found arm with an error string the gateway already handles, so fencing adds no new -/// player-facing error and no gateway edit (the constraint in the design notes). -/// -/// SCOPE — this gate is the REFUSE verdict, and refusal is NOT the right answer everywhere. Three -/// other verdicts exist and are deliberately not routed through here; the audited table lives in -/// `transfer.rs`'s module doc: -/// * DEFER, `loot::credit_purse` folds a post-begin `money` delta into the escrowed blob -/// (`transfer::defer_money_delta`); refusing would DROP a third party's copper. -/// * REGENERATE — `auth::establish_session` rewrites `Character.owner_identity`, which is -/// per-CONNECTION derived state the destination rebinds on arrival. -/// * OPEN, `group::group_accept`/`group_uninvite`/`group_leave`; spec puts group -/// membership on realm-core, settled by the group slice. -/// -/// Same `.find()`-then-check ordering as `entity_by_owner` above, and the same shared -/// [`gate_by_guid`] for the check itself. pub(crate) fn character_by_guid(ctx: &ReducerContext, guid: u64) -> Option { gate_by_guid(ctx, ctx.db.game_character().guid().find(guid), |c| c.guid) } -/// [`character_by_guid`], name-keyed. Case-insensitive ASCII fold — the operator/whisper convention -/// (`/w bob` reaches "Bob"), which the `#[unique]` exact-match name index cannot do, so this is a -/// scan by construction. Same fence, same read-as-absent semantics. pub(crate) fn character_by_name(ctx: &ReducerContext, name: &str) -> Option { gate_by_guid( ctx, @@ -100,7 +38,6 @@ pub(crate) fn character_by_name(ctx: &ReducerContext, name: &str) -> Option( ctx: &ReducerContext, candidate: Option, @@ -112,7 +49,7 @@ fn gate_by_guid( gate_in_transit(candidate, in_transit) } -/// A Character in transit is absent from normal gameplay reads. +// Transfer-fenced Characters are absent from ordinary gameplay reads. pub(crate) fn gate_in_transit(candidate: Option, in_transit: bool) -> Option { if in_transit { None @@ -126,32 +63,18 @@ pub(crate) fn dist_sq(a: &WorldEntity, b: &WorldEntity) -> f32 { dx * dx + dy * dy + dz * dz } -/// Max distance for a player-to-player interaction (a Trade Session): (10 yd)², the same -/// interaction-range convention as `VENDOR_RANGE_SQ` / `INSPECT_RANGE_SQ` et al. const PLAYER_INTERACTION_RANGE_SQ: f32 = 100.0; -/// Why [`player_interaction_gate`] refused — a typed decision enum in the house pure-gate style -/// (`trainer::BuyGrant`, `stacking::ApplyDecision`, `rolls::GroupLootDecision`): the Trade Session -/// opener maps each reason onto a distinct `SMSG_TRADE_STATUS` variant, a mapping that -/// substring-matching `Err(String)` text would leave fragile. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PlayerInteractionDenied { ActorDead, NoTarget, TargetNotPlayer, TargetDead, - /// The target is outside the actor's `(map_id, instance_id)` partition — either dimension. - /// "Partition", not "map": danger-zones §1.7 fixes the two-dimensional vocabulary, so the - /// Trade Session must not read this as map-only when it picks a wire status. DifferentPartition, OutOfRange, } -/// The pure player-vs-player interaction gate — `npc_interaction_gate`'s player twin, factored -/// pure like `world::can_inspect` (module-crate convention: no `ReducerContext` test harness). -/// The caller resolves the partner through a FENCED lookup ([`acting_entity_by_guid`]) and passes -/// the result: a mid-transfer or offline partner arrives as `None` and reads NoTarget, the same -/// absent-semantics the fence guarantees everywhere else. pub(crate) fn player_interaction_gate( actor: &WorldEntity, target: Option<&WorldEntity>, @@ -177,13 +100,6 @@ pub(crate) fn player_interaction_gate( Ok(()) } -/// Live entities in the grid cells covering `radius` yards around `(x, y)` on `map_id` + -/// `instance_id`, read through the `by_grid` btree index — the scale-safe replacement for a full -/// `game_world_entity` scan. Coverage is by WHOLE cells (`spatial::GRID_CELL_SIZE`), so the result -/// is a superset of the exact circle: callers keep their own precise distance check. Cost scales -/// with the neighborhood's population, not the world's. `instance_id` is a REQUIRED param (no -/// default) so the compiler finds every call site, pass the ACTING -/// entity's own `instance_id`; every caller is at instance 0, so behavior is unchanged. pub(crate) fn entities_near( ctx: &ReducerContext, map_id: u32, @@ -197,10 +113,6 @@ pub(crate) fn entities_near( let mut out = Vec::new(); for gx in gx0..=gx1 { for gy in gy0..=gy1 { - // `by_grid`'s own exact-tuple match already scopes this to (map_id, instance_id, gx, gy), - // so `in_same_partition` is a belt-and-suspenders re-check of the SAME predicate — kept as - // a real (non-mocked-ctx) call so the isolation key is unit-testable (see `tests` below; - // the module crate has no `ReducerContext` test harness by design). out.extend( entities .by_grid() @@ -212,17 +124,10 @@ pub(crate) fn entities_near( out } -/// Is `e` in the `(map_id, instance_id)` isolation partition `entities_near` queries for? The exact -/// predicate the `by_grid` filter tuple encodes, pulled out pure so the -/// instance dimension is unit-testable without a live `ReducerContext`. pub(crate) fn in_same_partition(e: &WorldEntity, map_id: u32, instance_id: u64) -> bool { e.map_id == map_id && e.instance_id == instance_id } -/// -/// Every one of those callers is a `debug_reducers` lever ("nearest X" is a test-harness affordance — -/// the real client always names its target), so the whole helper is gated with them: a production -/// build has no caller, and an ungated definition is dead code the cold-clone build gate rejects. #[cfg(feature = "debug_reducers")] pub(crate) fn nearest_entity( ctx: &ReducerContext, @@ -230,8 +135,6 @@ pub(crate) fn nearest_entity( mut pred: impl FnMut(&WorldEntity) -> bool, ) -> Option { let mut best: Option<(WorldEntity, f32)> = None; - // `by_map` (not a raw `.iter()`, partition-discipline tripwire) scopes candidates to the - // origin's own map before `in_same_partition` narrows to its instance too. for e in ctx.db.game_world_entity().by_map().filter(&origin.map_id) { if !in_same_partition(&e, origin.map_id, origin.instance_id) || !pred(&e) { continue; @@ -244,30 +147,10 @@ pub(crate) fn nearest_entity( best.map(|(e, _)| e) } -/// Address a `game_group_event` row: the recipient's bound identity when this -/// database HAS the character (every world shard), [`Identity::ZERO`] when it does not (realm-core, -/// whose only characters are guids in the directory tables). ZERO matches no client: the -/// per-player RLS filter on such a table is `recipient_identity = :sender` and a client's sender is -/// never ZERO, so a ZERO-addressed row is visible to the owner-token coordinator alone, which is -/// exactly who reads realm-core's events. Pure, so the fallback is pinned by a test rather than by -/// a live node. -/// -/// Moved here from `group.rs`: the roll and master-loot notifications (`loot.rs`) reuse this SAME -/// address rule, so it lives with the other cross-module lookups rather than under the module it -/// happened to be written for first. pub(crate) fn event_recipient_identity(bound: Option) -> Identity { bound.unwrap_or(Identity::ZERO) } -/// The live `game_world_entity` row for `guid`, the canonical "must be in world" fetch -/// that replaces the 38 hand-rolled `ctx.db.game_world_entity().guid().find(guid).ok_or_else(|| -/// format!(...))?` call sites scattered across the reducer modules (debug.rs alone had 14). Most of -/// those sites already spelled their error text exactly as this helper's default below; callers whose -/// wording differs and is wire/user-visible (the gateway's `is_desync_error` substring classifier -/// keys on "not in world" / "no live entity", and `combat::validate_attack_target`'s target-lookup -/// error is explicitly load-bearing — see its doc) keep their own string via `.map_err(...)` on top of -/// this helper instead of adopting the default, so the migration is a pure lookup consolidation with -/// zero change to any error string a caller relies on. pub(crate) fn live_entity(ctx: &ReducerContext, guid: u64) -> Result { ctx.db .game_world_entity() @@ -276,36 +159,11 @@ pub(crate) fn live_entity(ctx: &ReducerContext, guid: u64) -> Result Result { character_by_guid(ctx, guid).ok_or_else(|| format!("no character {guid}")) } -/// The grid address of `guid`'s live entity, for stamping a broadcast event row (perf catalog 2.3). -/// -/// The event tables — spell casts/impacts, combat swings, emotes, rolls — carried no spatial -/// columns, so each was subscribed `SELECT *` by EVERY per-player connection: the database -/// evaluated and shipped every swing and every spell visual anywhere in the world to all P -/// sessions, which then discarded the out-of-scope ones via the gateway's `created` set. Write -/// volume is small (~5/s at 500 players) but the FAN-OUT is P, so it was ~13% of all deliveries -/// and it is the term that grows with population rather than with local density. -/// -/// Stamping these four columns lets the AOI tracker subscribe them as grid-box ranges, exactly like -/// `game_world_entity`/`game_entity_motion`/`game_creature_spline`. -/// -/// Falls back to `(0, 0, 0, 0)` when the actor has no live entity — a caster who logged out -/// mid-flight, say. Such a row simply matches no box and is never delivered, which is the correct -/// outcome: nobody can see an actor who is not in the world. pub(crate) fn grid_of(ctx: &ReducerContext, guid: u64) -> (u32, u64, i32, i32) { ctx.db .game_world_entity() @@ -323,8 +181,6 @@ pub(crate) fn entity_addr(e: &WorldEntity) -> (u32, u64, i32, i32) { pub(crate) mod tests { use super::*; - /// A minimal `WorldEntity` for pure tests — every field but `map_id`/`instance_id`/ - /// `grid_x`/`grid_y` is a neutral zero/default, mirroring `combat/tables.rs`'s `entity_for_regen`. pub(crate) fn entity( guid: u64, map_id: u32, @@ -419,11 +275,11 @@ pub(crate) mod tests { let same_map = 1u32; let same_cell = (3i32, -4i32); let population = [ - entity(100, same_map, 0, same_cell.0, same_cell.1), // open world - entity(101, same_map, 0, same_cell.0, same_cell.1), // open world - entity(200, same_map, 7, same_cell.0, same_cell.1), // instance 7 - entity(201, same_map, 9, same_cell.0, same_cell.1), // instance 9 (a third, distinct instance) - entity(300, same_map + 1, 0, same_cell.0, same_cell.1), // different MAP entirely + entity(100, same_map, 0, same_cell.0, same_cell.1), + entity(101, same_map, 0, same_cell.0, same_cell.1), + entity(200, same_map, 7, same_cell.0, same_cell.1), + entity(201, same_map, 9, same_cell.0, same_cell.1), + entity(300, same_map + 1, 0, same_cell.0, same_cell.1), ]; let instance_0: Vec = population @@ -446,7 +302,6 @@ pub(crate) mod tests { assert_eq!(instance_7, vec![200]); assert_eq!(instance_9, vec![201]); - // Pairwise disjoint — the same cell, three different instances, zero overlap. for a in &instance_0 { assert!(!instance_7.contains(a) && !instance_9.contains(a)); } @@ -455,10 +310,6 @@ pub(crate) mod tests { } } - /// The event addressing realm-core rests on: a recipient this database knows gets their bound identity - /// (so the per-player RLS still scopes the row on a world shard), and one it does not gets ZERO - /// — which no client's `:sender` can ever equal, so a realm-core event row is visible to the - /// owner-token coordinator alone. Moved from `group.rs` with the function itself. #[test] fn a_recipient_without_a_character_row_addresses_the_event_to_nobody() { let bound = Identity::from_byte_array([7u8; 32]); @@ -483,26 +334,16 @@ pub(crate) mod tests { None, "in transit: the candidate must be refused (read as absent), never handed back" ); - // Nothing found in the first place — in-transit-ness is moot either way. assert_eq!(gate_in_transit(None::<&str>, false), None); assert_eq!(gate_in_transit(None::<&str>, true), None); } - /// `entity_addr` exists ONLY so the event-constructor `signal_at` variants (`SpellCastEvent`/ - /// `CombatEvent`) and the chat broadcast reducers can stamp a row's AOI address off an - /// already-fetched entity without a redundant `grid_of` lookup. Its whole contract is "same four - /// fields, same order, as `grid_of`'s `(map_id, instance_id, grid_x, grid_y)` tuple" — a swapped - /// `grid_x`/`grid_y` here would compile clean (both are `i32`) yet silently mis-place every - /// affected broadcast in the AOI grid. Four distinct field values (never a repeated 0/1/2/3) so a - /// transposition can't accidentally read back correct. #[test] fn entity_addr_matches_grid_of_field_order() { let e = entity(1, 7, 42, -3, 11); assert_eq!(entity_addr(&e), (7, 42, -3, 11)); } - /// A living player on map 0, instance 0, at `(x, 0, 0)` — the trade-gate tests' cast. Built on - /// [`entity`] (which is type_mask 0 = not a player) with the PLAYER bit set. fn player_at(guid: u64, x: f32) -> WorldEntity { let mut e = entity(guid, 0, 0, 0, 0); e.type_mask = lyracore_shared::constants::type_mask::PLAYER_BIT; @@ -510,10 +351,6 @@ pub(crate) mod tests { e } - /// The player-interaction gate's two load-bearing outcomes for opening a Trade Session - /// a living player standing next to another living player passes, and a partner - /// the fenced lookup could not produce (offline, or mid-transfer behind the fence) - /// reads as NoTarget — never a panic, never a pass. #[test] fn a_living_player_next_to_a_living_player_passes_and_an_absent_partner_is_no_target() { let actor = player_at(1, 0.0); @@ -525,10 +362,6 @@ pub(crate) mod tests { ); } - /// Every refusal the gate owes the trade handshake, each mapped 1:1 to a wire status — - /// plus the two edges that don't survive careless edits: the boundary (exactly 10 yd is IN - /// range, mirroring `can_inspect`'s `>` comparison) and precedence (a dead actor outranks an - /// absent partner, so the client hears YouDead, not NoTarget). #[test] fn the_gate_names_the_specific_refusal_and_holds_the_range_boundary_and_precedence() { let actor = player_at(1, 0.0); @@ -540,7 +373,6 @@ pub(crate) mod tests { player_interaction_gate(&dead_actor, Some(&partner)), Err(PlayerInteractionDenied::ActorDead) ); - // Precedence: dead actor with no partner is still ActorDead. assert_eq!( player_interaction_gate(&dead_actor, None), Err(PlayerInteractionDenied::ActorDead) @@ -566,7 +398,6 @@ pub(crate) mod tests { player_interaction_gate(&actor, Some(&other_map)), Err(PlayerInteractionDenied::DifferentPartition) ); - // Same map, different INSTANCE is just as unreachable — the partition has two dimensions. let mut other_instance = player_at(2, 3.0); other_instance.instance_id = 7; assert_eq!( @@ -574,7 +405,6 @@ pub(crate) mod tests { Err(PlayerInteractionDenied::DifferentPartition) ); - // Exactly (10 yd)² is IN range; a step past is not. The distance is 3D — z counts. let at_boundary = player_at(2, PLAYER_INTERACTION_RANGE_SQ.sqrt()); assert_eq!(player_interaction_gate(&actor, Some(&at_boundary)), Ok(())); let mut past_boundary = player_at(2, PLAYER_INTERACTION_RANGE_SQ.sqrt()); diff --git a/module/src/hooks.rs b/module/src/hooks.rs index 12ed061e4..1b3edd5f9 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -1,63 +1,16 @@ -//! Notify-hook event catalog + package tick-pass dispatch. -//! -//! This is the runtime half of the package substrate's extension points; the compile-time half is -//! `build.rs`'s marker scan (see the `game_hook` / `game_tick_pass` macro docs in `lib.rs`). Each -//! core chokepoint calls exactly ONE `fire_*` fn here, which iterates the generated per-event -//! registry. Handlers are NOTIFY-ONLY: they observe the payload and may act through the same -//! reducer-internal fns core code uses (grant, damage, spawn, ...), but there is no veto/fold. -//! Hooks cannot change a result. -//! -//! The catalog: -//! -//! | event | fires from | payload | -//! |---------------------|--------------------------------------------------------|---------| -//! | `on_damage_taken` | `spell::break_auras_on_damage` — the single helper every real-damage path (melee swing, direct spell, DoT tick) already calls | [`DamageTakenPayload`] | -//! | `on_death_prevented` | combat's shared damage path, once after a protected creature survives its first lethal hit | [`DeathPreventedPayload`] | -//! | `on_creature_spawn` | `creatures::insert_creature_entity` — the single wrapper every creature-entity insert routes through (seed, respawn, debug spawn, pet summon, tame) | [`CreatureSpawnPayload`] | -//! | `on_levelup` | `xp::grant_xp`'s ding loop, once per level gained | [`LevelupPayload`] | -//! | `on_group_invite` | `group::group_invite`, after the invite row + event are written | [`GroupInvitePayload`] | -//! | `on_death` | `combat::kill_creature` (creature corpse AND pet despawn), plus each player-death site (melee tick, spell damage, `debug_set_health(0)`) | [`DeathPayload`] | -//! | `on_kill` | `combat::kill_creature` when the death has a known source | [`KillPayload`] | -//! | `on_aggro` | `combat::arm_creature_engagement`, after a creature's first outgoing engagement is armed | [`AggroPayload`] | -//! | `on_cast_resolved` | `spell::resolve_cast_at` success exit — every cast path funnels through it | [`CastResolvedPayload`] | -//! | `on_loot` | `items::apply_take_loot` success — player + debug loot both route here | [`LootPayload`] | -//! | `on_quest_accept` | `quest::apply_accept_quest` AND `quest::grant_quest_unchecked` (the debug/harness grant) | [`QuestAcceptPayload`] | -//! | `on_quest_turnin` | `quest::apply_turn_in_quest` success (the rep/XP site) | [`QuestTurninPayload`] | -//! | `on_login` | `world::player_login` success exit | [`LoginPayload`] | -//! | `on_character_relocated` | `world::teleport_player`, after placement and motion cleanup | [`CharacterRelocatedPayload`] | -//! | `on_logout` | `world::remove_from_world` — covers explicit logout AND abrupt disconnect | [`LogoutPayload`] | -//! | `on_gossip_select` | `world::gossip_select` — the notify reducer the gateway calls on CMSG_GOSSIP_SELECT_OPTION | [`GossipSelectPayload`] | -//! | `on_creature_death` | `combat::kill_creature`, non-pet branch, the encounter-grade twin of `on_death` (entry + instance snapshot) | [`CreatureDeathPayload`] | -//! | `on_hp_threshold` | `encounter::encounter_hp_probe`, the kernel's own `on_damage_taken` handler; fires once per registered `(entry, pct)` crossing per instance | [`HpThresholdPayload`] | -//! | `on_go_used` | `gameobject::apply_use_gameobject` success exit, player use AND the debug drivers | [`GoUsedPayload`] | -//! -//! Add a hook payload here, its row in `datascripts/runtime-scripts/events.json`, and its core -//! dispatch call. `build.rs` derives payload aliases, dispatch and registries. Author declarations -//! use the same catalogue. - use spacetimedb::ReducerContext; -/// A unit took real damage (`dmg > 0`, target survived). Fired from `break_auras_on_damage`, so it -/// inherits that helper's exact coverage: every melee-swing, direct-spell, and DoT-tick damage path. -/// `attacker_guid` is the genuine MELEE assailant or `0` when the damage path has no such concept -/// (direct spell / DoT tick — same convention as the helper itself). `periodic` marks a DoT tick. pub struct DamageTakenPayload { pub target_guid: u64, pub attacker_guid: u64, pub periodic: bool, } -/// A creature's lethal floor prevented its first death since protection was enabled. Fired after -/// the surviving health value and the notification latch are durable. Later protected hits remain -/// quiet until the floor is enabled again. pub struct DeathPreventedPayload { pub creature_guid: u64, pub attacker_guid: u64, } -/// A creature `game_world_entity` row was just inserted (world seed, respawn pass, debug spawn, or -/// pet summon). The payload carries the row's identity/position snapshot; the row IS already -/// committed to the table when handlers run, so `ctx.db.game_world_entity()` lookups by `guid` see it. pub struct CreatureSpawnPayload { pub guid: u64, pub entry: u32, @@ -66,40 +19,22 @@ pub struct CreatureSpawnPayload { pub z: f32, } -/// A group invite was just recorded for `target_guid`. Fired AFTER the invite row + notification -/// event are written — a handler may accept immediately via `group::accept_invite_for` (a -/// Package's auto-accept path). pub struct GroupInvitePayload { pub target_guid: u64, pub inviter_guid: u64, } -/// A player entity crossed a level threshold (once per level in a multi-level award). NOTE: fired -/// from inside `grant_xp`'s ding loop, where the caller persists the mutated entity row AFTER the -/// loop — so within the firing transaction the `game_world_entity` row may still show the pre-ding -/// state. Read the payload, not the table, for the new level. pub struct LevelupPayload { pub character_guid: u64, pub new_level: u32, } -/// A unit DIED. Fires for every death path: a creature killed via `combat::kill_creature` (melee -/// killing blow, lethal spell/DoT, `debug_kill_creature`, `debug_set_health(0)`) INCLUDING the pet -/// clean-despawn branch, and a PLAYER death at each of its sites (melee tick, spell-damage path, -/// `debug_set_health(0)`). `killer_guid` identifies the unit that caused the death, or `0` when the -/// path has none. It is independent of reward ownership: Forced Death names the creature itself but -/// grants no kill reward. Fired after the death is committed, so consumers observe the post-death -/// world. pub struct DeathPayload { pub victim_guid: u64, pub killer_guid: u64, pub victim_is_player: bool, } -/// A creature death has a known source unit. Complements `on_death`: this is the source-centric -/// event, while `on_death` is victim-centric. The source may be the dying creature itself for Forced -/// Death and does not imply reward ownership. `victim_entry` and `victim_level` are captured before -/// the corpse fields are cleared. pub struct KillPayload { pub killer_guid: u64, pub victim_guid: u64, @@ -107,26 +42,18 @@ pub struct KillPayload { pub victim_level: u32, } -/// A creature entered its first outgoing engagement. Fired once after the melee row is armed; -/// `assist` distinguishes a neighbor or summon joining an existing fight. pub struct AggroPayload { pub creature_guid: u64, pub target_guid: u64, pub assist: bool, } -/// A cast fully RESOLVED (all effects dispatched, visual emitted, GCD/cooldown started) — the -/// success exit of `spell::resolve_cast_at`, the single core every cast path funnels through -/// (player cast, timed completion, channel tick start, creature cast, triggered cast, debug cast). -/// Rejected/failed casts never fire this. pub struct CastResolvedPayload { pub caster_guid: u64, pub spell_id: u32, pub target_guid: u64, } -/// One scheduled cast ended. The scheduled identity distinguishes successive casts of one spell. -/// The pending row has been removed before handlers run. pub struct CastFinishedPayload { pub caster_guid: u64, pub target_guid: u64, @@ -134,9 +61,6 @@ pub struct CastFinishedPayload { pub outcome: crate::spell::CastFinish, } -/// A unit LOOTED one item stack from a corpse/gameobject loot window (`items::apply_take_loot` -/// success — the player reducer and `debug_take_loot` both route here). Money pickup is a separate -/// path and does not fire this. pub struct LootPayload { pub looter_guid: u64, pub corpse_guid: u64, @@ -144,79 +68,42 @@ pub struct LootPayload { pub count: u32, } -/// A quest landed in a character's log: the real accept (`quest::apply_accept_quest`, all gates -/// passed) or the harness grant (`quest::grant_quest_unchecked` behind `debug_grant_quest`, gates -/// skipped). Both write the same CharacterQuest row shape, so consumers see one event either way. pub struct QuestAcceptPayload { pub character_guid: u64, pub quest_entry: u32, } -/// A quest was TURNED IN (`quest::apply_turn_in_quest` success — rewards granted, row marked -/// rewarded). This is the rep/XP site from the research catalog. pub struct QuestTurninPayload { pub character_guid: u64, pub quest_entry: u32, } -/// A player finished `world::player_login`: the live entity exists, owned rows are restamped, and -/// the character row is updated (online, rested XP accrued). Server-side bots do NOT fire this — -/// they enter the world via their own spawn path, not `player_login`. pub struct LoginPayload { pub character_guid: u64, } -/// A Character's teleport destination and motion cleanup are committed. Cross-map relocation -/// removes the live body before this notification. Retained Package movement must be discarded. pub struct CharacterRelocatedPayload { pub character_guid: u64, } -/// A player LEFT the world (`world::remove_from_world` — explicit CMSG_LOGOUT_REQUEST and abrupt -/// socket disconnect both land here). Fired BEFORE the live entity row is deleted, so handlers can -/// still read it; progression is already persisted back to the character row. pub struct LogoutPayload { pub character_guid: u64, } -/// A player clicked a gossip option (`world::gossip_select`, the notify reducer the gateway calls -/// on CMSG_GOSSIP_SELECT_OPTION before running its own vendor/innkeeper handling). Pure -/// notification — core gossip behavior (vendor window, inn bind, window close) is unaffected. pub struct GossipSelectPayload { pub character_guid: u64, pub npc_guid: u64, - /// The clicked option's POSITION in the per-player filtered menu — NOT stable across viewers - /// (a cond-gated row or a second option renumbers it). Kept for the vendor/inn routing; a hook - /// that needs to identify a SPECIFIC option must use `option_row_id` instead. + // option_id is a per-viewer menu position; use option_row_id for stable option identity. pub option_id: u32, - /// The clicked option's `game_gossip_option.row_id` — the STABLE identifier. A package - /// that mints its own gossip option at a known reserved row_id (≥50000) keys on this, immune to - /// menu position. `u32::MAX` for the trailing Farewell line and the flag-synthesized - /// vendor/inn/browse options (which have no imported row). pub option_row_id: u32, } -/// A CREATURE died for real (the encounter kernel's death event), fired from -/// `combat::kill_creature`'s non-pet branch right after `on_death`, with the encounter-grade -/// identity `on_death` lacks: the victim's `entry` and `instance_id`, SNAPSHOTTED before the corpse -/// row was mutated. Pet clean-despawns and player deaths do NOT fire this (encounters key on wild -/// creatures); `killer_guid` identifies the death source or is 0, using the same convention as -/// `on_death`. Reward ownership is separate. pub struct CreatureDeathPayload { pub creature_guid: u64, pub entry: u32, pub instance_id: u64, pub killer_guid: u64, - /// The victim's selected opponent before death cleared its engagement. pub current_target_guid: u64, - /// The victim's threat table `(source_guid, threat)` as it stood at the death — snapshotted - /// BEFORE `disengage` wipes it (hooks fire after the wipe, so handlers can't read the table - /// themselves). Threat is the engine's only per-player damage/heal ledger (every damage path - /// adds `threat_from_damage`, heals add `threat_from_heal_each`), so this is the engine's - /// ready-made contribution-share input for any "who earned this kill" split (its original - /// consumer, the dynamic-events package, was removed). Only player sources exist by - /// construction (creatures keep no threat on creatures). Empty for uncontested deaths - /// (debug kills, DoT deaths after disengage). pub threat_snapshot: Vec<(u64, i64)>, } @@ -227,12 +114,6 @@ pub struct HpThresholdPayload { pub pct: u8, } -/// A unit successfully USED a gameobject, fired at `apply_use_gameobject`'s -/// success exit (player `use_gameobject` and the debug drivers share that core), AFTER the -/// type-dispatch committed its effect (chest looted, door toggled, goober credited, inert no-op). -/// Rejected uses (out of range, already-looted chest...) never fire. `instance_id` is the GO -/// ROW's own (rows are instance-tagged: per-instance copies carry their instance, -/// static rows carry 0) — handlers key state checks AND primitives off it directly. pub struct GoUsedPayload { pub go_guid: u64, pub go_entry: u32, @@ -240,13 +121,8 @@ pub struct GoUsedPayload { pub instance_id: u64, } -// Generated aliases and dispatch use the Runtime Script event catalogue. Including them here -// preserves the `hooks::fire_*` and `hooks::payload_for::*` paths. include!(concat!(env!("OUT_DIR"), "/hook_dispatch.rs")); -/// Run every registered package tick pass, in the registry's deterministic (sorted-by-path) order. -/// Called at the END of `tick_creatures` every tick (0.5s), after all core passes — see the cadence -/// note in the `game_tick_pass` macro doc. pub(crate) fn run_package_tick_passes(ctx: &ReducerContext) { crate::package_teardown::forget_removed_packages(ctx); for (_name, f) in crate::GAME_TICK_PASSES { diff --git a/module/src/import_meta.rs b/module/src/import_meta.rs index efc128fbd..65d1bd7a7 100644 --- a/module/src/import_meta.rs +++ b/module/src/import_meta.rs @@ -1,55 +1,19 @@ -//! Import provenance stamps. `game_import_meta` records, per FAMILY, the source -//! SHA / file hash / row count / timestamp of the last successful (re)load, so a live DB can answer -//! "what data am I running?" via `spacetime sql` — a question neither `import-world.sh`'s floor -//! assertions nor the wire suite's data gates (docs/danger-zones.md) can answer on their own (they -//! prove data LANDED, not WHERE it came from). -//! -//! NOT public — the precedent for genuinely non-`public` module tables is `game_taunt_lock` -//! (threat.rs) / `game_event_reaper_schedule` (gc.rs). (`game_lock`/`game_creature_family` are a -//! DIFFERENT axis: `public` but gateway-UNSUBSCRIBED — visible to any SpacetimeDB client, just -//! never bound/read by our gateway.) Nothing subscribes this table, so it needs no gateway binding -//! regen — docs/danger-zones.md §1.2's "new table → regenerate the gateway bindings" rule applies -//! to tables the gateway actually reads. The importer and an operator via `spacetime sql` still -//! read/write it directly against the SpacetimeDB node. -//! -//! `game_world_import` records which maps a completed World Import loaded on this database. The CLI's -//! `import world` brackets each destination with `begin_world_import` and `complete_world_import`; -//! `dev check`, `import check` and `dev smoke` ask `check_world_content` before play. use spacetimedb::{reducer, table, ReducerContext, Table, Timestamp}; use crate::helpers::require_operator; -/// Maps that need imported WMO collision before play. The importer's `--vmap` mode has an -/// instance slice for Deadmines only; the Deeprun Tram (369) joins once it has one. const WMO_MAPS: [u32; 1] = [36]; -/// One row per FAMILY (a `--dump`/`--dbc` import family name — e.g. "creatures", "quests" — or a -/// `debug_seed_` reseed) recording the provenance of its last successful load. `family` is the -/// primary key: a re-import UPSERTS (delete+insert) rather than appending history, so a query always -/// shows the CURRENT state — "what data am I running right now", not a full audit log. #[table(accessor = game_import_meta)] pub struct ImportMeta { #[primary_key] pub family: String, - /// The resolved cmangos `classic-db` commit SHA this load ran against. Empty string when the - /// caller has no external dump provenance (e.g. a `debug_seed_*` reseed, or a `--dbc`-only - /// family that reads client DBCs rather than the cmangos dump). pub source_sha: String, - /// sha256 hex digest of the dump bytes actually parsed for this run. Empty string when not - /// applicable (same cases as `source_sha`). pub file_hash: String, - /// The row count this family's load actually wrote/touched — family-scoped, not a global table - /// size, so it stays meaningful across families that share an underlying table. pub row_count: u64, pub imported_at: Timestamp, } -/// Upsert `family`'s provenance row. The shared core both `stamp_import_meta` (below, the importer's -/// operator-gated entry point) and module-side callers (`debug.rs`'s `debug_seed_*` reseed reducers, -/// which run under the `debug_reducers` feature and don't go through the operator gate themselves) -/// use. Delete-then-insert — the same upsert convention `debug_compute_swing`'s -/// `readouts.key().delete(&key)` already uses for a primary-keyed "latest state" row — rather than an -/// append-only history, so a re-run overwrites the prior stamp for that family. pub(crate) fn stamp( ctx: &ReducerContext, family: &str, @@ -68,11 +32,6 @@ pub(crate) fn stamp( }); } -/// Operator-only entry point the importer (a privileged, non-gateway SpacetimeDB client — see -/// `require_operator`'s doc on why this check can't move to the gateway) calls after each family's -/// successful clear+reload to record its provenance. NOT feature-gated (unlike `debug.rs`'s harness -/// reducers behind `debug_reducers`): this is production tooling the importer calls on every real -/// run, not a test-only actuator. #[reducer] pub fn stamp_import_meta( ctx: &ReducerContext, @@ -86,7 +45,6 @@ pub fn stamp_import_meta( Ok(()) } -/// One row per map whose World Import completed on this database. #[table(accessor = game_world_import)] pub struct WorldImport { #[primary_key] @@ -94,7 +52,6 @@ pub struct WorldImport { pub completed_at: Timestamp, } -/// Clear every completion on this database before an import reloads it. #[reducer] pub fn begin_world_import(ctx: &ReducerContext) -> Result<(), String> { require_operator(ctx)?; @@ -106,7 +63,6 @@ pub fn begin_world_import(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Record `map_ids` as imported, after their content passes the same checks as play. #[reducer] pub fn complete_world_import(ctx: &ReducerContext, map_ids: Vec) -> Result<(), String> { require_operator(ctx)?; @@ -126,7 +82,6 @@ pub fn complete_world_import(ctx: &ReducerContext, map_ids: Vec) -> Result< Ok(()) } -/// Refuse when any of `map_ids` lacks a completed World Import or its required content. #[reducer] pub fn check_world_content(ctx: &ReducerContext, map_ids: Vec) -> Result<(), String> { require_operator(ctx)?; @@ -138,7 +93,6 @@ pub fn check_world_content(ctx: &ReducerContext, map_ids: Vec) -> Result<() ) } -/// The pure half of the content check, so the decision is testable without a database. fn content_problem( map_ids: &[u32], completed: impl Fn(u32) -> bool, diff --git a/module/src/instance.rs b/module/src/instance.rs index ff0458aae..baf30e834 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -1,4 +1,4 @@ -//! Instance lifecycle. See `docs/instance-lifecycle.md` for the contract and rationale. +//! Before changing Instance allocation or pool routing, read docs/instance-lifecycle.md. use std::collections::{HashMap, HashSet}; @@ -10,35 +10,21 @@ use crate::{ game_gameobject_template, game_group, game_group_member, game_world_entity, }; -// =========================================================================================== -// Constants / policy -// =========================================================================================== - pub(crate) use lyracore_shared::instance::is_dungeon_map; pub(crate) struct DungeonMap { pub(crate) map_id: u32, - /// The open-world position OUTSIDE the entrance — `(map, x, y, z, o)`, the stranding fallback - /// for a login whose `pending_instance_id` was reaped ("fall back to the entrance at instance - /// 0", design doc §3). - /// pub(crate) entrance: (u32, f32, f32, f32, f32), - /// The instance's own zone id for graveyard release (cmangos `game_graveyard_zone.ghost_zone`) - /// — the map→zone hop `world::graveyard::resolve_zone_id` can't make without terrain. pub(crate) release_zone: u32, - /// Static release floor when nothing is imported — `(map, x, y, z, o)`. NEVER - /// `world::graveyard::nearest(px, py)` for an instance map: cross-map 2-D distance against the - /// open-world consts is meaningless (see `world::graveyard`'s section doc). + // Never resolve dungeon graveyards by outside-map coordinates. pub(crate) release_fallback: (u32, f32, f32, f32, f32), } -/// Bootstrap fallback for a fixture without imported portal or graveyard metadata. -/// Full imports resolve exits and release locations from the Operator's source data. pub(crate) const BOOTSTRAP_DUNGEONS: &[DungeonMap] = &[DungeonMap { map_id: 36, - entrance: (0, -11080.0, 1520.0, 46.0, 0.0), // [V] Moonbrook, Westfall - release_zone: 1581, // [V] The Deadmines - release_fallback: (0, -10650.0, 1180.0, 34.0, 0.0), // [V] Sentinel Hill + entrance: (0, -11080.0, 1520.0, 46.0, 0.0), + release_zone: 1581, + release_fallback: (0, -10650.0, 1180.0, 34.0, 0.0), }]; pub(crate) fn dungeon(map_id: u32) -> Option<&'static DungeonMap> { @@ -49,8 +35,6 @@ pub(crate) fn entrance_fallback(map_id: u32) -> Option<(u32, f32, f32, f32, f32) dungeon(map_id).map(|d| d.entrance) } -/// Prefer a source-defined exit. An entrance volume also gives a valid outside-map fallback -/// when a dungeon uses a scripted exit instead of an Area Trigger teleport. pub(crate) fn imported_entrance_fallback( ctx: &ReducerContext, map_id: u32, @@ -81,7 +65,6 @@ pub(crate) fn imported_entrance_fallback( .or_else(|| entrance_fallback(map_id)) } -/// Copy a source patrol in source order. Each population operation reserves waypoint ids once. pub(crate) fn copy_spawn_waypoints( ctx: &ReducerContext, source_guid: u64, @@ -117,7 +100,6 @@ pub(crate) fn remove_spawn_waypoints(ctx: &ReducerContext, guid: u64) { } } -/// Updating or removing an authored source retires each of its runtime copies. pub(crate) fn remove_spawn_copies(ctx: &ReducerContext, source_guid: u64) { let spawns = ctx.db.game_creature_spawn(); for spawn in spawns @@ -131,49 +113,13 @@ pub(crate) fn remove_spawn_copies(ctx: &ReducerContext, source_guid: u64) { } } -/// Reap an instance after it has been EMPTY this long (30min const). Vanilla -/// keeps an untouched instance alive ~1h; 30min is the item's chosen constant (deviation noted). pub(crate) const INSTANCE_EMPTY_REAP_MICROS: i64 = 30 * 60 * 1_000_000; -/// `reap_instances` cadence — minutes-scale per the design (occupancy resolution; see the module -/// doc). 60s: fine-grained enough that `reset_requested` lands fast, coarse enough to cost nothing. pub(crate) const INSTANCE_REAPER_INTERVAL_MICROS: i64 = 60 * 1_000_000; -/// How long a **LEASE** must read empty before the reaper takes it. -/// -/// A lease is a `game_instance` row on a database that does not host instance populations -/// (`game_config.hosts_instances = false`). -/// It reads EMPTY within seconds of the last party member transferring to the instance shard, i.e. -/// seconds after the run *starts*, because occupancy is counted from live player entities and there -/// are none here any more. Reaping it on the ordinary 30-minute timer therefore deletes the world -/// shard's only record of "this party is in instance N" **while they are still fighting in it**, and -/// the next member through the portal — someone who died, released, and ran back — resolves -/// `InstanceRoute::Create` and is teleported into a fresh, empty dungeon while their party is in the -/// old one. That is the same split fixed from the other direction. -/// -/// **The countdown starts at the run's START, not at its end**, which is what sizes this number. -/// The lease reads empty seconds after the party leaves the world shard, so the timer has to exceed -/// the run's WALL-CLOCK LENGTH — it is not an idle grace period. Anything shorter than the longest -/// run the realm intends to support just moves the 30-minute fork further out: a BRD or Stratholme -/// clear runs 3–5 hours in vanilla and a 40-man raid night runs longer, so 3h (the first value this -/// constant held) re-created the same split for exactly the runs that hurt most to lose. -/// -/// The error is deliberately one-sided. Too SHORT forks a party mid-run — the bug. Too LONG costs -/// one stub row per run, and its only behavioural effect is that a re-entry within the window -/// resolves the OLD instance id, which the owning shard then re-mints with a fresh population -/// (`ensure_instance`) — i.e. indistinguishable from a new instance to the players. So the value is -/// pushed well past any plausible run rather than tuned: 12h, pinned by -/// `a_lease_only_database_holds_its_stub_rows_far_past_the_thirty_minute_run_timer`. -/// -/// Deliberate simplification: a constant, not a protocol — the simplest thing that closes the -/// hole. Upgrade path is the one [`teardown_instance_inner`] already names: realm-core owns the -/// instance→shard index and the lease stops existing. **Single-database realms never reach -/// this arm at all** — `hosts_instances` defaults to true, so their reap semantics are byte-for-byte -/// what they were. +// A lease reads empty at run start, so its expiry must exceed the whole dungeon run. pub(crate) const INSTANCE_LEASE_REAP_MICROS: i64 = 12 * 60 * 60 * 1_000_000; -/// The empty-timer this database reaps instances on: [`INSTANCE_EMPTY_REAP_MICROS`] where the -/// populations actually live, [`INSTANCE_LEASE_REAP_MICROS`] where only leases do. Pure. pub(crate) fn empty_reap_micros(hosts_populations: bool) -> i64 { if hosts_populations { INSTANCE_EMPTY_REAP_MICROS @@ -182,18 +128,12 @@ pub(crate) fn empty_reap_micros(hosts_populations: bool) -> i64 { } } -/// Cap on instances torn down in ONE reaper firing — bounds the transaction like the corpse -/// reaper's batching discipline (a mass-expiry event tears down over a few firings, not one -/// giant commit). const REAP_MAX_PER_FIRING: usize = 4; -/// Bit 23 separates Instance population copies from imported spawns and encounter summons. -/// Both live and respawning copies reserve their sequence until the Instance is reaped. +// Bit 23 separates Instance copies; respawning copies still reserve their sequence. pub(crate) const INSTANCE_POP_LOW_BAND: u64 = 0x80_0000; -/// Bit 46 of a gameobject guid low: the per-instance GO-COPY band. Below `gameobject::POOL_TAG` -/// (bit 47 — pool points), far above static imported db-guid lows and the debug spawner's -/// entry-derived lows (< 2^24). +// Bit 46 reserves gameobject Instance copies below pool bit 47. pub(crate) const GO_COPY_BAND: u64 = 1 << 46; const GO_COPY_TYPES: [u8; 4] = [ @@ -203,16 +143,6 @@ const GO_COPY_TYPES: [u8; 4] = [ crate::gameobject::go_type::GOOBER, ]; -// =========================================================================================== -// Private instance tables [server] -// =========================================================================================== - -/// One live dungeon instance. `instance_id` auto_inc from 1 (0 = open world, reserved by -/// construction). `party_id` is the owning `game_group.group_id`, or 0 for a solo-created -/// instance (resolution for those rides `game_instance_binding` only — party_id 0 is NEVER -/// queried by `by_party`, or every solo instance in the world would alias one "party"). -/// `last_empty_at_micros`: 0 = occupied/never-observed-empty; else when the reaper first saw it -/// empty. `reset_requested`: flagged by [`reset_instance`], honored by the reaper when empty. [server] #[table( accessor = game_instance, index(accessor = by_party, btree(columns = [party_id])), @@ -229,9 +159,6 @@ pub struct GameInstance { pub reset_requested: bool, } -/// One row per (character, map): the instance this character re-enters through that map's portal. -/// REPLACED on a new binding (resolve step 2/3 rebinds), survives party disband (vanilla), dropped -/// by the instance reap and by character deletion (the `character_owned` sweep below). [server] #[table( accessor = game_instance_binding, index(accessor = by_character, btree(columns = [character_guid])), @@ -246,18 +173,12 @@ pub struct GameInstanceBinding { pub map_id: u32, } -// A deleted character's instance bindings go with it (the tripwire-enforced sweep). Delete-only: -// bindings carry no owner_identity, so there is nothing to restamp (the GroupInvite precedent). crate::character_owned!(delete, fn sweep_delete_game_instance_binding(ctx, character_guid) { let t = ctx.db.game_instance_binding(); for b in t.by_character().filter(&character_guid).collect::>() { t.id().delete(b.id); } }); -// CROSS-DATABASE transport: the binding is what "you re-enter YOUR instance" means, and -// on the instance shard it is what `resolve_or_create_instance` reads when the character zones back -// in after a disconnect. `instance_id` is carried VERBATIM — it is the id the gateway mirrored to -// this shard via `ensure_instance`, so the two agree by construction. `id` is a surrogate PK. crate::character_owned!(transfer, fn sweep_transfer_game_instance_binding(ctx, character_guid, io) { table = game_instance_binding, by = by_character, @@ -272,14 +193,9 @@ pub struct InstanceReaperSchedule { pub scheduled_at: ScheduleAt, } -/// How long an Instance Removal counts down before it moves the Character home. pub(crate) const INSTANCE_REMOVAL_MICROS: i64 = lyracore_shared::instance::INSTANCE_REMOVAL_MS as i64 * 1_000; -/// One Character's running Instance Removal: the one-shot schedule that fires -/// [`INSTANCE_REMOVAL_MICROS`] after it is armed. `instance_id` is the instance the Character -/// stood in when it was armed and `group_id` is the Group that owns that instance. The Gateway -/// relays an insert as the client countdown and a delete as its end. [server] #[table(accessor = game_instance_removal, scheduled(expire_instance_removal))] pub struct InstanceRemoval { #[primary_key] @@ -295,17 +211,8 @@ pub struct InstanceRemoval { crate::character_owned!(delete, fn sweep_delete_game_instance_removal(ctx, character_guid) { ctx.db.game_instance_removal().character_guid().delete(character_guid); }); -// The countdown belongs to the instance. A Character that leaves the Instance Pool has left the -// instance, and leaving the instance cancels the countdown (cm:MovementHandler.cpp:133-135). crate::character_owned!(not_transported, fn sweep_transfer_game_instance_removal()); -// =========================================================================================== -// Pure decisions (unit-tested below) -// =========================================================================================== - -/// The resolve order, as data: the party's live instance → the character's own live binding → -/// create (swapped the first two; see [`route_instance`]). Pure — the DB reads live in -/// [`resolve_or_create_instance`]. #[derive(Debug, PartialEq)] pub(crate) enum InstanceRoute { Own(u64), @@ -324,7 +231,6 @@ pub(crate) fn route_instance( } } -/// Admission uses the dungeon or raid capacity. Occupancy and saved raid lockouts remain separate. fn instance_capacity(map_id: u32) -> usize { match map_id { 249 | 409 | 469 | 531 | 533 => lyracore_shared::group::RAID_MAX_MEMBERS, @@ -338,17 +244,11 @@ pub(crate) fn party_size_allows_entry(map_id: u32, member_count: usize) -> bool member_count <= instance_capacity(map_id) } -/// What the reaper does to one instance this firing, given its observed occupancy. Pure — -/// unit-tested; the entity scan and row writes live in [`reap_instances`]. #[derive(Debug, PartialEq)] pub(crate) enum OccupancyAction { - /// Players inside and a stale empty-stamp → clear it (re-entry cancels the countdown). ClearStamp, - /// Empty and unstamped → start the 30min countdown now. Stamp, - /// Nothing to do this firing. Wait, - /// Tear it down (30min empty elapsed, or reset requested while empty). Reap, } @@ -367,8 +267,6 @@ pub(crate) fn occupancy_action( }; } if reset_requested { - // Reset honors the CURRENT emptiness observation, not the stamp — a leader's reset lands - // on the next firing (≤60s), never waits out the 30min countdown. return OccupancyAction::Reap; } if last_empty_at_micros == 0 { @@ -381,17 +279,10 @@ pub(crate) fn occupancy_action( } } -/// Where a login whose `pending_instance_id` was reaped lands (NEVER strand, design trap). -/// Pure over the two facts the caller derives ([`entrance_fallback`], [`is_dungeon_map`]); -/// alive-or-ghost is orthogonal and preserved per the `pending_ghost` rules at the call site. #[derive(Debug, PartialEq)] pub(crate) enum StrandingFallback { - /// A dungeon entrance on its outside map, at instance zero. Entrance(u32, f32, f32, f32, f32), - /// A NON-dungeon map (the dev-map fixture case): the position is fine open-world terrain — - /// stay in place, drop to instance 0. InPlaceOpenWorld, - /// A dungeon with no imported entrance or bootstrap fallback returns to the Character's home. HearthstoneHome, } @@ -406,22 +297,11 @@ pub(crate) fn stranding_fallback( } } -// Live only under `debug_reducers`: `apply_reset_instances` is its sole caller. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] -/// May this caller flag this instance for reset? Vanilla "Reset all instances" semantics: never -/// while players are inside; a party instance takes its CURRENT leader, a solo instance -/// (`party_id == 0`) takes any character bound to it (the caller reaches it via their own binding, -/// which IS the bound-to proof). Pure. pub(crate) fn reset_eligible(party_id: u64, caller_is_leader: bool, occupied: bool) -> bool { !occupied && (party_id == 0 || caller_is_leader) } -/// The Instance Removal rule: a Character standing in a dungeon instance that a Group owns, while -/// it is not a member of that Group, gets the countdown. `instance_party` is 0 for a solo instance. -/// `own_group` is the Character's Group on this Shard's rows. A GM is exempt -/// (cm:Group.cpp:1470-1484). cmangos asks `IsGameMaster()`, which is GM mode switched on. LyraCore -/// has no GM mode toggle, so a nonzero `gm_level` stands in for it: every GM is exempt, all the -/// time. Pure. pub(crate) fn instance_removal_due( map_id: u32, instance_id: u64, @@ -436,13 +316,6 @@ pub(crate) fn instance_removal_due( && gm_level == 0 } -// =========================================================================================== -// Entry: resolve-or-create (the areatrigger hook's target) -// =========================================================================================== - -/// A binding/party instance is LIVE for resolution iff its row still exists, is for the right -/// map, and is not flagged for reset (a reset-flagged instance is already condemned — resolving -/// into it would race the reaper; vanilla's reset invalidates the old id immediately). fn live_instance_for(ctx: &ReducerContext, instance_id: u64, map_id: u32) -> bool { ctx.db .game_instance() @@ -452,8 +325,6 @@ fn live_instance_for(ctx: &ReducerContext, instance_id: u64, map_id: u32) -> boo .unwrap_or(false) } -/// Bind `character_guid` to `instance_id` for `map_id` — one row per (character, map), REPLACE on -/// a new binding (delete-then-insert). fn bind_character(ctx: &ReducerContext, character_guid: u64, instance_id: u64, map_id: u32) { let t = ctx.db.game_instance_binding(); let stale: Vec = t @@ -473,12 +344,6 @@ fn bind_character(ctx: &ReducerContext, character_guid: u64, instance_id: u64, m }); } -/// The dungeon-entry chokepoint: resolve which instance of `target_map` this -/// character enters — party's live instance → own live binding → create — enforcing the 5-player -/// cap at trigger time. Solo entry allowed (binds to the character, `party_id = 0`). A stale -/// binding (instance reaped or reset-flagged) self-heals: the row is dropped and resolution falls -/// through. Returns the instance id to teleport into, or a loud `Err` (cap breach — surfaced by -/// the caller as a warn; there is no "instance full" wire packet in 1.12's portal flow). pub(crate) fn resolve_or_create_instance( ctx: &ReducerContext, character_guid: u64, @@ -496,8 +361,6 @@ pub(crate) fn resolve_or_create_instance( )); } - // 1. The character's own binding for this map, if its instance is still live. A dead binding - // is dropped here (self-heal) so it never shadows the party's live instance below. let bindings = ctx.db.game_instance_binding(); let own_binding = bindings .by_character() @@ -512,8 +375,6 @@ pub(crate) fn resolve_or_create_instance( None => None, }; - // 2. The party's live instance for this map (party_id 0 — solo — is never queried: it is the - // "no party" sentinel, not a party; see the GameInstance doc). let party_live = group.as_ref().and_then(|g| { ctx.db .game_instance() @@ -526,14 +387,6 @@ pub(crate) fn resolve_or_create_instance( let party_id = group.as_ref().map(|g| g.group_id).unwrap_or(0); match route_instance(own_live, party_live) { InstanceRoute::Own(id) => { - // ADOPTION (defect 3, the other half of the split): the holder of a SOLO - // instance who has since joined a party is the first of that party through the portal, - // and `by_party` cannot see a `party_id = 0` instance — so without this the members - // behind them would mint a second dungeon and the party would play in two of them. - // Re-stamping the owner makes the instance they are walking into the party's, which is - // also the cheap outcome: nobody re-spawns a population that already exists. Only a - // solo instance or one whose Group disbanded is adopted. An instance a living Group - // owns is never stolen. adopt_instance_for_party(ctx, id, party_id); Ok(id) } @@ -549,10 +402,6 @@ pub(crate) fn resolve_or_create_instance( } } -/// Admit a session-less party member into one exact instance already selected by a remote party -/// member. Unlike [`resolve_or_create_instance`], this never creates an alternative: every Gate is -/// checked before the admitted binding is written, because Package callers record a Refusal instead -/// of aborting their outer reducer transaction. pub(crate) fn admit_existing_party_instance( ctx: &ReducerContext, character_guid: u64, @@ -592,18 +441,10 @@ pub(crate) fn admit_existing_party_instance( Ok(expected_instance) } -/// Whether an instance has no living owner on this Shard: it was created solo (`party_id == 0`), -/// or the Group that owned it disbanded here. A Group that enters or forms in it may adopt it. -/// cmangos gives a new Group its leader's binds (`Player::ConvertInstancesToGroup`), and a disband -/// leaves the binds with the players. The Group mirror deletes a Group's row only on disband, so a -/// missing row is a disbanded Group, never one this Shard has not heard of yet. fn instance_is_ownerless(ctx: &ReducerContext, party_id: u64) -> bool { party_id == 0 || ctx.db.game_group().group_id().find(party_id).is_none() } -/// Re-stamp an ownerless instance (see [`instance_is_ownerless`]) as `party_id`'s, so the rest of -/// that party resolves into it through `by_party` instead of creating a second one. No-op when -/// the caller has no party, when a living Group owns the instance, or when the row is gone. fn adopt_instance_for_party(ctx: &ReducerContext, instance_id: u64, party_id: u64) { if party_id == 0 { return; @@ -618,14 +459,6 @@ fn adopt_instance_for_party(ctx: &ReducerContext, instance_id: u64, party_id: u6 } } -// =========================================================================================== -// create_instance — row + population + GO copies + tick row -// =========================================================================================== - -/// Does THIS database host dungeon-instance POPULATIONS (`game_config.hosts_instances`)? -/// A missing config row reads `true`, so a database that predates the column — or a fresh one whose -/// seed has not run — behaves exactly as it did before the flag existed. The `nav::nav_enabled` -/// shape, deliberately: a world policy read at the one decision point, not a shard id. pub(crate) fn hosts_instance_populations(ctx: &ReducerContext) -> bool { ctx.db .game_config() @@ -635,10 +468,6 @@ pub(crate) fn hosts_instance_populations(ctx: &ReducerContext) -> bool { .unwrap_or(true) } -/// Create a live instance of `map_id` owned by `party_id` (0 = solo): insert the `game_instance` -/// row, spawn the per-instance creature population, and copy the map's interactive gameobjects. The -/// creature tick rides the global catch-all row. Copies retain independent death and respawn timers. -/// Returns the new instance id. pub(crate) fn create_instance( ctx: &ReducerContext, map_id: u32, @@ -647,11 +476,6 @@ pub(crate) fn create_instance( create_instance_with_id(ctx, 0, map_id, party_id) } -/// [`create_instance`] with the id supplied by the caller (`0` = let `auto_inc` allocate, the -/// normal path). The explicit form exists for ONE caller: [`ensure_instance`], the gateway's -/// cross-database mirror, which has to re-create the SAME instance id on the shard that -/// owns the map so every party member's `game_instance_binding` — which travels in their transfer -/// blob carrying that id verbatim — still resolves. pub(crate) fn create_instance_with_id( ctx: &ReducerContext, instance_id: u64, @@ -659,17 +483,15 @@ pub(crate) fn create_instance_with_id( party_id: u64, ) -> Result { let inst = ctx.db.game_instance().insert(GameInstance { - instance_id, // 0 → auto_inc allocates from 1, so 0 stays the open world + instance_id, map_id, party_id, created_at: ctx.timestamp, - last_empty_at_micros: 0, // occupied-until-observed-otherwise (the party is mid-teleport) + last_empty_at_micros: 0, reset_requested: false, }); let instance_id = inst.instance_id; - // A non-hosting database files the lease and binding without population. - // The owning Shard creates population when ensure_instance receives this lease. if !hosts_instance_populations(ctx) { log::info!( "create_instance: instance {instance_id} map {map_id} party {party_id} — LEASE only \ @@ -678,10 +500,6 @@ pub(crate) fn create_instance_with_id( return Ok(instance_id); } - // --- Population: every spawn template on the map through the NORMAL spawn path. ----------- - // Guid allocation: wave-guid layout with the INSTANCE_POP_LOW_BAND bit (see the module doc's - // namespace section). One prepass over live entities builds the per-entry max BAND-low so - // consecutive creates (and two live instances of the same map) never collide. let entities = ctx.db.game_world_entity(); let mut max_band_low: HashMap = HashMap::new(); for e in entities.iter() { @@ -698,7 +516,6 @@ pub(crate) fn create_instance_with_id( } } - // A dead copy awaiting respawn still owns its guid. for spawn in ctx.db.game_creature_spawn().by_instance().filter(1u64..) { let low = spawn.guid & 0xFF_FFFF; if low & INSTANCE_POP_LOW_BAND != 0 { @@ -754,9 +571,6 @@ pub(crate) fn create_instance_with_id( spawned += 1; } - // --- GO copies: the map's interactive props (type-gated), one copy per instance. ----------- - // Source rows are the STATIC (instance 0) spawns; copies carry the new instance id and a - // GO_COPY_BAND guid. `state` is copied (startOpen doors keep their initial state). let gos = ctx.db.game_gameobject(); let mut next_go_seq = gos .iter() @@ -802,8 +616,6 @@ pub(crate) fn create_instance_with_id( grid_x: lyracore_shared::spatial::grid_cell(src.x, src.y).0, grid_y: lyracore_shared::spatial::grid_cell(src.x, src.y).1, cell: lyracore_shared::spatial::cell_id_at(src.x, src.y), - // Copy the source door/chest/goober's real spawn quaternion — a dungeon copy is - // the same prop at the same orientation, not a reset to identity. rotation_0: src.rotation_0, rotation_1: src.rotation_1, rotation_2: src.rotation_2, @@ -820,26 +632,6 @@ pub(crate) fn create_instance_with_id( Ok(instance_id) } -// =========================================================================================== -// Reap — scheduled reducer + shared teardown -// =========================================================================================== - -// =========================================================================================== -// Cross-database instance placement — the two calls the gateway drives -// =========================================================================================== - -/// **Destination side.** Mirror instance `instance_id` of `map_id` onto THIS database, spawning its -/// population if it isn't here yet. Idempotent: the second party member through the portal finds -/// the instance already live and joins it. A solo lease, or one whose Group disbanded here, adopts -/// the admitted party; a living different party is refused instead of sharing the same instance id. -/// -/// The id is supplied, not allocated, because it was allocated on the SOURCE shard — the -/// areatrigger hook runs where the player was standing, and the module deliberately knows nothing -/// about shards (spec "no module game logic ever reads a shard id"). Every member's -/// `game_instance_binding` carries that id verbatim in their transfer blob, so mirroring it is what -/// makes the party land in one instance. -/// -/// Operator-gated: orchestration machinery, never a client action. #[reducer] pub fn ensure_instance( ctx: &ReducerContext, @@ -880,17 +672,7 @@ pub fn ensure_instance( Ok(()) } -/// **Source side.** Evict an instance's POPULATION from this database, keeping the `game_instance` -/// row and its bindings as a lease (see [`teardown_instance_inner`]). -/// -/// This is what makes AC true: the world shard spawned the dungeon when the first -/// player stepped through the portal, but the run happens on the instance shard, so the world -/// writer must stop ticking its creatures. Refuses while a live player is inside — the same guard -/// `teardown_instance` has, and here it is load-bearing: a member who has NOT transferred yet is a -/// live player in that instance on this database, and evicting around them would delete the -/// creatures they are standing next to. -/// -/// Operator-gated. +// Keep the lease for later party arrivals and refuse eviction while a Character remains inside. #[reducer] pub fn evict_instance_population( ctx: &ReducerContext, @@ -909,27 +691,19 @@ pub fn evict_instance_population( .find(instance_id) .is_none() { - return Ok(()); // nothing here to evict — replay-safe + return Ok(()); } teardown_instance_inner(ctx, instance_id, false); Ok(()) } -/// The instance reaper (scheduled, scheduler-only): one entity pass classifies every instance's -/// player-occupancy, then each instance stamps/clears/waits/reaps per [`occupancy_action`]. -/// Teardown is batched ([`REAP_MAX_PER_FIRING`]); leftovers reap next firing (60s later). -/// -/// Each database reaps only its hosted Instances. With no leases, it skips the entity scan. -/// Empty timers are database-local; see [`empty_reap_micros`]. #[reducer] pub fn reap_instances(ctx: &ReducerContext, _schedule: InstanceReaperSchedule) { if ctx.sender() != ctx.database_identity() { - return; // scheduler-only (the gc.rs guard) + return; } let instances: Vec = ctx.db.game_instance().iter().collect(); if instances.is_empty() { - // No instances on THIS database → nothing to classify. Skipping the entity scan is what - // makes "the reaper lives on the owning database" a cost statement and not just a comment. return; } let now = ctx.timestamp.to_micros_since_unix_epoch(); @@ -957,20 +731,12 @@ pub fn reap_instances(ctx: &ReducerContext, _schedule: InstanceReaperSchedule) { teardown_instance(ctx, inst.instance_id); reaped += 1; } - // Over-batch instances keep their stamp and reap next firing. } OccupancyAction::Wait => {} } } } -/// The set of instance ids with at least one live PLAYER entity — one pass classifies every -/// instance at once (bots count: a parked bot holds its instance open, correctly). -/// -/// Plus every instance claimed by a pending Transfer Intent or Escrow. A Transfer Intent removes -/// the live body before the Gateway can begin Escrow, so both durable phases must hold the source -/// lease and destination. An unexpected intent overflow keeps every instance rather than letting -/// an incomplete bounded read reap one that is still claimed. pub(crate) fn occupied_instances(ctx: &ReducerContext) -> HashSet { let mut occupied: HashSet = ctx .db @@ -993,34 +759,13 @@ pub(crate) fn occupied_instances(ctx: &ReducerContext) -> HashSet { occupied } -/// Tear one instance down, in the design's order: population (entities + their combat/threat/leg/ -/// loot state, player corpses, GO copies + chest loot) → the encounter-kernel sweep splice → -/// the tick row → bindings → the `game_instance` row itself. Refuses instance 0 (the open -/// world) and any instance with a live player inside (belt over the caller's occupancy check — -/// same-transaction, so no race). Shared by the reaper and `debug_reap_instance`. pub(crate) fn teardown_instance(ctx: &ReducerContext, instance_id: u64) { teardown_instance_inner(ctx, instance_id, true) } -/// [`teardown_instance`], optionally KEEPING the `game_instance` row and its bindings. -/// -/// `keep_lease = true` is the cross-database eviction: the world shard created this instance when -/// the first player stepped through the portal (the areatrigger hook runs there, and the module -/// deliberately knows nothing about shards), but the RUN happens on the instance shard. Deleting -/// the population here is what makes the world writer stop paying for it; keeping the row is what -/// makes the NEXT party member's `resolve_or_create_instance` resolve to the SAME id instead of -/// minting a second instance the rest of the party is not in. -/// -/// Note: the leased row is a stub the world shard never populates again, and it is left to the -/// reaper's empty timer — but NOT the 30-minute one. A lease reads empty seconds after the party -/// transfers out, so a lease-only database reaps on [`INSTANCE_LEASE_REAP_MICROS`] instead; -/// the 30-minute run timer would delete the world shard's record of "this party is in instance N" -/// while they were still in it. The cost is one row per run for the length of that timer. Upgrade -/// path: the instance→shard index moves to realm-core and neither the lease nor this arm -/// exists. pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, delete_row: bool) { if instance_id == 0 { - return; // never "reap" the open world + return; } let entities = ctx.db.game_world_entity(); if entities @@ -1031,10 +776,6 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de return; } - // 1. Population: every non-player entity in the instance (alive or corpse), through the ONE - // canonical creature-teardown checklist (this loop, `encounter::despawn_tracked` - // and `creatures::tick::pass_decay` had each grown their own copy, and they had diverged on - // the loot family, the taunt lock and the withheld gate). let doomed: Vec = entities .iter() .filter(|e| e.instance_id == instance_id && !e.is_player()) @@ -1052,9 +793,6 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de } crate::creatures::population::clear_instance(ctx, instance_id); - // 2. Player corpses left in the instance (a ghost who never corpse-ran back). The owner's - // stranding outcome is the vanilla one: nothing left to reclaim — spirit healer only - // (documented on `player_login`'s fallback and in the runbook). let corpses = ctx.db.game_corpse(); let dead_bodies: Vec = corpses .iter() @@ -1065,7 +803,6 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de corpses.guid().delete(guid); } - // 3. GO copies + any chest loot rows keyed on a copy's guid. let gos = ctx.db.game_gameobject(); let copies: Vec = gos .iter() @@ -1073,19 +810,14 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de .map(|g| g.guid) .collect(); for guid in &copies { - crate::loot::reap_corpse_loot_family(ctx, *guid); // a GO is not a creature — the loot family only + crate::loot::reap_corpse_loot_family(ctx, *guid); crate::go_collider::remove(ctx, *guid); gos.guid().delete(guid); } - // 4. Encounter-kernel state (splice documented on sweep_encounter_state): tracked - // waves (their untagged spawn rows MUST die here or they'd respawn into instance 0), - // encounter state + HP fired-marks, equip rows. crate::creatures::cancel_relay_runs_for_instance(ctx, instance_id); crate::encounter::sweep_encounter_state(ctx, instance_id); - // 5. The dedicated tick row (debug_disarm_instance_tick's body) — coverage of the (now - // empty) id falls back to the catch-all, which is a no-op for a population of zero. let sched = ctx.db.game_creature_move_schedule(); let ticks: Vec = sched .iter() @@ -1096,15 +828,7 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de sched.scheduled_id().delete(id); } - // 6/7. Bindings, then the instance row itself, last — the design's documented order ends at the - // row (within one reducer transaction the order is atomic anyway; keeping it makes the - // invariant readable: population never outlives its row). - // - // SKIPPED for a cross-database eviction (`delete_row == false`): the row is a - // LEASE the world shard keeps so the party's later arrivals resolve to the same instance, - // and the bindings are the manifest rows that just travelled to the instance shard in each - // member's transfer blob — deleting them here would delete the source copy of state the - // destination now owns, which is the one thing a transfer must never do. + // Remove bindings and the Instance last; population must never outlive its parent row. if !delete_row { log::info!( "teardown_instance: instance {instance_id} population EVICTED ({} entities, {} GO \ @@ -1131,15 +855,7 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de ); } -// =========================================================================================== -// reset_instance — the party-leader / solo reset verb -// =========================================================================================== - -// Live only under `debug_reducers`: `debug_reset_instance` is its sole caller. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] -/// The instance-reset core keyed off an explicit guid. Walks the caller's OWN bindings (being -/// bound is the reach — vanilla resets the instances you're bound to), gating each per -/// [`reset_eligible`]. Returns how many instances were flagged, `Err` if none were eligible. pub(crate) fn apply_reset_instances( ctx: &ReducerContext, character_guid: u64, @@ -1154,7 +870,7 @@ pub(crate) fn apply_reset_instances( let mut flagged = 0u32; for b in bindings { let Some(mut inst) = ctx.db.game_instance().instance_id().find(b.instance_id) else { - continue; // already reaped — the binding dies on the reap/next resolve + continue; }; let caller_is_leader = ctx .db @@ -1183,25 +899,13 @@ pub(crate) fn apply_reset_instances( } } -// =========================================================================================== -// Instance Removal -// =========================================================================================== - -/// Where the Instance Removal rule holds for one Character on this Shard. #[derive(Clone, Copy)] struct RemovalStanding { instance_id: u64, group_id: u64, - /// The Character has a live entity. Only then does a countdown start. in_world: bool, } -/// The instance `character_guid` stands in and the Group that owns it, when the Instance Removal -/// rule holds on this Shard. A Character with a live entity stands where the entity is. A -/// Character with no live entity stands where its durable row says, because its next login builds -/// it there. A session-less Character with a live entity reads as exempt: only a World Session -/// turns the cross-map teleport home into a Transfer, so a bot stays where it is. An in-transit -/// Character reads as absent, because it is leaving this Shard. fn instance_removal_standing(ctx: &ReducerContext, character_guid: u64) -> Option { let character = crate::helpers::character_by_guid(ctx, character_guid)?; let entity = ctx.db.game_world_entity().guid().find(character_guid); @@ -1231,10 +935,6 @@ fn instance_removal_standing(ctx: &ReducerContext, character_guid: u64) -> Optio }) } -/// A Group formed again inside the instance of a Group that disbanded takes the instance over, so -/// its members stand in their own Group's instance and no countdown runs. cmangos gets the same -/// outcome: the new Group takes its leader's binds, and `Group::AddMember` cancels the countdown -/// of a member whose bind matches its instance (cm:Group.cpp:880-885). Returns the adopting Group. fn adopt_for_regrouped_member(ctx: &ReducerContext, character_guid: u64) -> Option { let standing = instance_removal_standing(ctx, character_guid)?; let own_group = crate::group::group_of(ctx, character_guid)?.group_id; @@ -1245,12 +945,6 @@ fn adopt_for_regrouped_member(ctx: &ReducerContext, character_guid: u64) -> Opti Some(own_group) } -/// Make `character_guid`'s Instance Removal match the rule: start the countdown when the rule holds -/// for a Character in the world and no countdown runs for that instance, and cancel it when the -/// rule no longer holds. cmangos marks only a Character in the world (cm:Group.cpp:1470-1484), so a -/// Character removed while logged out starts its countdown at its next login. A countdown that is -/// already running survives a logout, and its expiry moves the logged-out Character home. -/// Idempotent. Realm-core holds no Characters, so there it does nothing. pub(crate) fn reconcile_instance_removal(ctx: &ReducerContext, character_guid: u64) { if let Some(group_id) = adopt_for_regrouped_member(ctx, character_guid) { reconcile_group_members(ctx, group_id); @@ -1279,14 +973,12 @@ pub(crate) fn reconcile_instance_removal(ctx: &ReducerContext, character_guid: u } } -/// Reconcile every member of `group_id` on this Shard, after the Group adopted an instance. fn reconcile_group_members(ctx: &ReducerContext, group_id: u64) { for member in crate::group::members_of(ctx, group_id) { reconcile_instance_removal(ctx, member.character_guid); } } -/// Scheduler-only: an Instance Removal countdown ran out. See [`fire_instance_removal`]. #[reducer] pub fn expire_instance_removal( ctx: &ReducerContext, @@ -1299,10 +991,6 @@ pub fn expire_instance_removal( Ok(()) } -/// End one countdown. A firing that is not the Character's running countdown changes nothing, so -/// a replay is a no-op. The rule is read again first: a Character that rejoined the Group, left -/// the instance or left this Shard stays where it is. Otherwise the Character goes to its -/// hearthstone home in the open world (cm:Player.cpp:17710-17716). pub(crate) fn fire_instance_removal(ctx: &ReducerContext, removal: &InstanceRemoval) { let removals = ctx.db.game_instance_removal(); if removals @@ -1325,10 +1013,6 @@ pub(crate) fn fire_instance_removal(ctx: &ReducerContext, removal: &InstanceRemo } } -// =========================================================================================== -// Tests — the pure decisions above (the module crate has no ReducerContext harness by design) -// =========================================================================================== - #[cfg(test)] mod tests { use super::*; @@ -1343,13 +1027,7 @@ mod tests { #[test] fn route_prefers_the_party_then_the_personal_binding_then_create() { - // Defect 3 — THE SPLIT. Ginger entered Deadmines solo (instance 4, `party_id 0`), - // then partied up; the party's own entry made instance 5. With the personal binding ranked - // first she re-entered 4 while her party was in 5 — two dungeons, one party. The party now - // wins, which is also vanilla's rule for a non-saved 5-man. assert_eq!(route_instance(Some(7), Some(9)), InstanceRoute::Party(9)); - // No party instance → the personal binding still carries you back into your own dungeon, - // which is what makes a binding survive a disband (a party-less character lands here). assert_eq!(route_instance(Some(7), None), InstanceRoute::Own(7)); assert_eq!(route_instance(None, Some(9)), InstanceRoute::Party(9)); assert_eq!(route_instance(None, None), InstanceRoute::Create); @@ -1382,10 +1060,8 @@ mod tests { #[test] fn occupancy_action_covers_stamp_clear_wait_and_both_reap_paths() { let now = 1_800_000_000_000_000i64; - // The single-database timer — the ONLY one a realm without a shard map ever uses. let t = empty_reap_micros(true); assert_eq!(t, INSTANCE_EMPTY_REAP_MICROS); - // Occupied: a stale stamp clears; no stamp → nothing. assert_eq!( occupancy_action(true, now - 1, false, now, t), OccupancyAction::ClearStamp @@ -1394,17 +1070,14 @@ mod tests { occupancy_action(true, 0, false, now, t), OccupancyAction::Wait ); - // Occupied + reset_requested: NEVER reaped out from under live players. assert_eq!( occupancy_action(true, 0, true, now, t), OccupancyAction::Wait ); - // Empty, unstamped → start the countdown. assert_eq!( occupancy_action(false, 0, false, now, t), OccupancyAction::Stamp ); - // Empty, stamped just short of 30min → wait; exactly 30min → reap (inclusive boundary). let stamped = now - INSTANCE_EMPTY_REAP_MICROS; assert_eq!( occupancy_action(false, stamped + 1, false, now, t), @@ -1414,7 +1087,6 @@ mod tests { occupancy_action(false, stamped, false, now, t), OccupancyAction::Reap ); - // Reset + empty reaps IMMEDIATELY — no 30min wait, stamp or not. assert_eq!( occupancy_action(false, 0, true, now, t), OccupancyAction::Reap @@ -1425,8 +1097,6 @@ mod tests { ); } - /// **AC**, reap semantics on a shard-pool deployment, stated against the single-database - /// ones they must not change. #[test] fn a_lease_only_database_holds_its_stub_rows_far_past_the_thirty_minute_run_timer() { let now = 1_800_000_000_000_000i64; @@ -1443,9 +1113,6 @@ mod tests { instance the party is in while they are still in it" ); - // The bug this closes, as data. A party enters Deadmines; every member transfers to the - // instance shard, so the world shard's lease reads EMPTY from minute ~0 of the run. Forty - // minutes in, a member dies and runs back through the portal. let entered = now - 40 * 60 * 1_000_000; assert_eq!( occupancy_action(false, entered, false, now, hosting), @@ -1473,14 +1140,11 @@ mod tests { "an 8-hour raid night must still find its lease on the world shard" ); - // It is a longer timer, not an immortal row: the stub is still collected. let ancient = now - INSTANCE_LEASE_REAP_MICROS; assert_eq!( occupancy_action(false, ancient, false, now, lease), OccupancyAction::Reap ); - // And an occupied instance is never reaped on either timer — the guard is orthogonal (an - // occupied instance with a stale stamp clears it, exactly as on the hosting timer). for t in [hosting, lease] { assert_eq!( occupancy_action(true, ancient, true, now, t), @@ -1495,18 +1159,14 @@ mod tests { #[test] fn stranding_fallback_prefers_entrance_then_in_place_then_home() { - // A known dungeon map: its entrance const (the map-36 arm). assert_eq!( stranding_fallback(entrance_fallback(36), true), StrandingFallback::Entrance(0, -11080.0, 1520.0, 46.0, 0.0) ); - // A non-dungeon map (the dev-map fixture instance): stay in place at instance 0 — the - // position is real open-world terrain. assert_eq!( stranding_fallback(None, false), StrandingFallback::InPlaceOpenWorld ); - // A dungeon map with no entrance arm (pinned-never above, but never strand): hearthstone. assert_eq!( stranding_fallback(None, true), StrandingFallback::HearthstoneHome @@ -1515,7 +1175,6 @@ mod tests { #[test] fn instance_removal_is_due_only_for_a_non_member_in_a_group_instance() { - // Deadmines is map 36. Instance 7 belongs to Group 42. assert!(instance_removal_due(36, 7, 42, None, 0), "left the Group"); assert!( instance_removal_due(36, 7, 42, Some(43), 0), @@ -1536,10 +1195,8 @@ mod tests { #[test] fn reset_eligibility_requires_unoccupied_and_leadership_for_party_instances() { - // Solo instance (party 0): any bound caller, but never while occupied. assert!(reset_eligible(0, false, false)); assert!(!reset_eligible(0, false, true)); - // Party instance: the current leader only, and never while occupied. assert!(reset_eligible(42, true, false)); assert!( !reset_eligible(42, false, false), @@ -1553,25 +1210,16 @@ mod tests { #[test] fn instance_population_low_band_is_disjoint_from_spawn_row_allocators() { - // spawn_wave's allocator: max over SPAWN-ROW lows + 1 — spawn lows are imported cmangos db - // guids (~10^5) or seed ordinals, all far below bit 23. The band bit keeps every - // instance-population low ABOVE anything that allocator can ever produce from them. let realistic_spawn_lows = [1u64, 3, 40_000, 150_000]; let wave_next = realistic_spawn_lows.iter().max().unwrap() + 1; assert!( wave_next & INSTANCE_POP_LOW_BAND == 0, "wave allocation stays below the band" ); - // The documented invariant's exact boundary: EVERY spawn low strictly below bit 23 yields - // a wave allocation (max+1) that is still band-free — only a low at 0x7F_FFFF itself (16× - // any real cmangos guid) could push max+1 into the band, and that is the loud-duplicate-PK - // `[V]` the band const documents. const { assert!(INSTANCE_POP_LOW_BAND == 0x80_0000) }; let pop_low = INSTANCE_POP_LOW_BAND | 1; assert!(pop_low & INSTANCE_POP_LOW_BAND != 0); assert_ne!(pop_low, wave_next); - // The band bit survives wave_guid's 24-bit low mask (it IS bit 23) — the full guid keeps - // the entry bits intact, so the client still classifies the copy as a Unit. let g = crate::encounter::wave_guid(636, pop_low); assert_eq!(g >> 48, 0xF130); assert_eq!((g >> 24) & 0xFF_FFFF, 636); @@ -1581,14 +1229,9 @@ mod tests { #[test] fn go_copy_band_is_disjoint_from_pool_static_and_debug_namespaces() { let copy_guid = (0xF110u64 << 48) | GO_COPY_BAND | 1; - // Below the pool tag: pool_point_id_of must NOT classify a copy as a pool point (else the - // GO respawn pass would try to reroll a foreign pool on it). const { assert!(GO_COPY_BAND < crate::gameobject::POOL_TAG) }; assert_eq!(crate::gameobject::pool_point_id_of(copy_guid), None); - // Above every static/debug low: imported db guids and the debug spawner's entry-derived - // lows are < 2^24, far under bit 46. const { assert!(GO_COPY_BAND > 0xFF_FFFF) }; - // And distinct from a pool point at the same sequence. assert_ne!(copy_guid, crate::gameobject::pool_point_guid(1)); } } diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index 6f1f3f8aa..25d6f07fc 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -1,12 +1,8 @@ -//! The vendor/armorer economy — sell / buyback / buy / repair, plus the shared NPC-interaction trust -//! gate all four reduce to (split off `ops.rs`, pure code-motion, no behavior change). Each -//! `apply_*` is the shared core behind a thin player reducer and its debug twin (see `reducers.rs`). - use spacetimedb::{ReducerContext, Table}; use crate::game_npc_vendor; use crate::game_world_entity; -use crate::WorldEntity; // npc_interaction_gate's return type +use crate::WorldEntity; use super::inventory::is_carried_slot; use super::ops::store_item; @@ -16,23 +12,10 @@ use super::tables::{ ItemInstance, }; -/// Max distance to interact with a vendor: (10 yd)². The client walks into range before sending -/// `CMSG_BUY_ITEM`, so this only rejects clearly-out-of-range abuse (mirrors `loot::LOOT_RANGE_SQ`). +// Squared yards. const VENDOR_RANGE_SQ: f32 = 100.0; -/// -/// The four call sites' WIRE-VISIBLE error text differs in wording, not in the checks themselves, so -/// three of the five messages stay parameterized rather than folded into one template: -/// * `dead_verb` — the dead-player message's verb ("sell" / "use buyback" / "buy" / "repair"), -/// formatted as `"dead players cannot {dead_verb}"`. -/// * `noun` — the NPC-kind word used in the not-found / map / range messages ("vendor" / "armorer"), -/// formatted as `"no such {noun}"` / `"{noun} on another map"` / `"{noun} out of range"`. -/// * `flag_err` — the flag-mismatch message verbatim, since its wording does NOT follow the `noun` -/// template (repair's is `"target cannot repair"`, not `"target is not a armorer"`). -/// -/// Every string this replaces is preserved byte-for-byte at each call site — `build_buy_failed` -/// (gateway/src/codec/item.rs) substring-matches `"out of range"` / `"another map"` / `"not enough -/// money"` on these paths, so the SHAPE of each message (not just its presence) must survive. +// Gateway buy-failure mapping matches substrings in these Refusal messages. pub(crate) fn npc_interaction_gate( ctx: &ReducerContext, player_guid: u64, @@ -66,8 +49,6 @@ pub(crate) fn npc_interaction_gate( Ok((player, npc)) } -/// The bank-access trust gate: a move or split touching a bank slot needs an OPEN bank, so the player -/// must be alive with a live BANKER npc in reach. pub(crate) fn bank_access_gate(ctx: &ReducerContext, player_guid: u64) -> Result<(), String> { use lyracore_shared::constants::npc_flags; flagged_npc_in_reach_gate( @@ -79,7 +60,6 @@ pub(crate) fn bank_access_gate(ctx: &ReducerContext, player_guid: u64) -> Result ) } -/// Binding a home requires the selected innkeeper to be alive and in reach. pub(crate) fn innkeeper_access_gate( ctx: &ReducerContext, character_guid: u64, @@ -100,9 +80,6 @@ pub(crate) fn innkeeper_access_gate( Ok(()) } -/// Is the player alive with a live NPC carrying `flag` inside the interaction radius? The gate for -/// the interactions the client requests WITHOUT naming an NPC, so it searches the player's own -/// partition — `npc_interaction_gate` covers the ones that do name a target. fn flagged_npc_in_reach_gate( ctx: &ReducerContext, player_guid: u64, @@ -119,7 +96,6 @@ fn flagged_npc_in_reach_gate( if player.dead { return Err(format!("dead players cannot {verb}")); } - // Grid-indexed and partition-scoped: a spatial table must never be scanned whole. let near = crate::helpers::entities_near( ctx, player.map_id, @@ -144,8 +120,6 @@ fn flagged_npc_in_reach_gate( Ok(()) } -/// Does one nearby entity satisfy such a gate? A live NPC (never another player) carrying `flag`, -/// inside the interaction radius. Pure so the refusal cases are unit-tested without a live module. pub(crate) fn flagged_npc_in_reach( flag: u32, is_player: bool, @@ -156,24 +130,14 @@ pub(crate) fn flagged_npc_in_reach( !is_player && !dead && npc_flags & flag != 0 && dist_sq <= VENDOR_RANGE_SQ } -/// The four distinguishable results of a bank bag slot purchase. Typed rather than a `Result<(), -/// String>` so a caller tells the refusals apart without matching error prose; the reducer boundary -/// then tags each one with its `SMSG_BUY_BANK_SLOT_RESULT` code (see `buy_bank_slot_result`). #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BuyBankSlotOutcome { - /// Charged `price` copper; the buyer now owns `owned` slots. Bought { price: u32, owned: u8 }, - /// All six rungs already bought. AlreadyMaxed, - /// Short of copper for the next rung. CannotAfford { price: u32 }, - /// The named target is no usable banker (wrong flag, wrong map, out of range, buyer dead). NotBanker(String), } -/// Map an outcome onto the reducer boundary, which can only carry `Result<(), String>`. A refusal -/// leads with its wire code in brackets so the gateway maps it by code, not by prose (the -/// `trainer_buy` precedent). Pure — unit-tested. pub(crate) fn buy_bank_slot_result(outcome: BuyBankSlotOutcome) -> Result<(), String> { use lyracore_shared::bank::result; match outcome { @@ -190,13 +154,6 @@ pub(crate) fn buy_bank_slot_result(outcome: BuyBankSlotOutcome) -> Result<(), St } } -/// Buy the next bank bag slot from `banker_guid`: charge the next unowned rung of the vanilla ladder -/// and increment the owned count on the live entity (mirrored back to the character row by -/// `persist_entity`, exactly as `money` is). -/// -/// The client NAMES the banker here, so this validates that target through the shared -/// `npc_interaction_gate` — unlike `bank_access_gate`, which searches the partition because a bank -/// move names no NPC at all. pub(crate) fn apply_buy_bank_slot( ctx: &ReducerContext, player_guid: u64, @@ -223,16 +180,12 @@ pub(crate) fn apply_buy_bank_slot( player.money -= price; player.bank_bag_slots += 1; let owned = player.bank_bag_slots; - // PLAYER_BYTES_2 byte 2 mirrors the owned count so the client sees the bought slot without a - // relog; byte 0 (facial hair) and byte 3 (rest state) ride along untouched. player.player_bytes_2 = lyracore_shared::packing::with_bank_bag_slots(player.player_bytes_2, owned); ctx.db.game_world_entity().guid().update(player); BuyBankSlotOutcome::Bought { price, owned } } -/// Does the vendor creature `vendor_entry` stock `item_entry`? Gates `apply_buy_item` so a player can -/// only buy what a vendor actually sells (not any item with a buy price). Reads `game_npc_vendor`. pub(crate) fn vendor_sells(ctx: &ReducerContext, vendor_entry: u32, item_entry: u32) -> bool { ctx.db .game_npc_vendor() @@ -241,41 +194,21 @@ pub(crate) fn vendor_sells(ctx: &ReducerContext, vendor_entry: u32, item_entry: .any(|v| v.item_entry == item_entry) } -/// FIFO eviction gate for the 12-slot buyback ring: once it already holds 12 entries, the OLDEST -/// (ascending by auto_inc id, so the caller's already-sorted first element) is evicted before the new -/// sale is pushed — so the ring never grows past 12. Extracted from `apply_item_sell` (pure code-motion) -/// so the `>= 12` boundary is unit-tested without a live module. pub(crate) fn buyback_ring_full(current_len: usize) -> bool { current_len >= 12 } -/// The buyback ring's re-purchase ORDER: given the ring's entry ids (any order), return them NEWEST -/// FIRST (descending by auto_inc id) — slot 0 is the most recently sold item. Extracted from -/// `apply_buyback_item` (pure code-motion): the live path sorts entries by this exact id order then -/// indexes by `slot_idx`. pub(crate) fn buyback_newest_first(mut ids: Vec) -> Vec { ids.sort_by(|a, b| b.cmp(a)); ids } -/// Shared sell-to-vendor logic for the player + debug paths: sell the whole stack in inventory `slot` -/// for copper. Looks up the item's template; a 0 `sell_price` means the item has no vendor value -/// (quest/soulbound-junk analogue) and is rejected. On success it credits the player's `money` -/// (saturating, so the copper counter never wraps) by `sell_value(sell_price, stack_count)` and then -/// DELETES the whole item row — vanilla sells the entire stack in one action. Errors if the user -/// isn't in world, the slot is empty, the template is missing, or the item is valueless. Additive — -/// touches only the user's money + the one item row. [entity] pub(crate) fn apply_item_sell( ctx: &ReducerContext, player_guid: u64, vendor_guid: u64, slot: u8, ) -> Result<(), String> { - // Vendor gating, mirroring apply_buy_item/apply_buyback_item/apply_player_repair (the - // shared npc_interaction_gate): a dead/ghost player can't vendor; the sale must be to a real - // VENDOR creature within range on the same map+instance. Unlike buy there is NO vendor_sells() - // check — vanilla lets you sell any sellable item to any vendor (junk goes to anyone), only the - // proximity + "is a vendor" gates apply. let (mut player, _vendor) = npc_interaction_gate( ctx, player_guid, @@ -294,12 +227,9 @@ pub(crate) fn apply_item_sell( lyracore_shared::item::ItemRefusal::NotRightNow, )); } - // Only BACKPACK/bag items are sellable. Refuse an EQUIPPED item (slots 0..=18): vanilla's vendor - // window can't target worn gear, so a client must not be able to sell items off the body. if inst.slot <= equip_slot::END { return Err("cannot sell an equipped item".to_string()); } - // Selling is a bag-only action: an item sitting in the bank is out of the vendor window's reach. if !is_carried_slot(inst.slot) { return Err("cannot sell a banked item".to_string()); } @@ -312,11 +242,9 @@ pub(crate) fn apply_item_sell( if tmpl.sell_price == 0 { return Err("item has no sell value".to_string()); } - // Credit the copper for the whole stack (saturating), then remove the sold item. let earned = sell_value(tmpl.sell_price, inst.stack_count); player.money = player.money.saturating_add(earned); entities.guid().update(player); - // Push to the 12-slot buyback ring (oldest evicted when full). let buyback_tab = ctx.db.game_character_buyback(); let mut old: Vec = buyback_tab.by_player_guid().filter(&player_guid).collect(); old.sort_by_key(|e| e.id); @@ -324,7 +252,7 @@ pub(crate) fn apply_item_sell( buyback_tab.id().delete(old[0].id); } buyback_tab.insert(BuybackEntry { - id: 0, // auto_inc + id: 0, player_guid, item_entry: inst.entry, stack_count: inst.stack_count, @@ -336,9 +264,6 @@ pub(crate) fn apply_item_sell( Ok(()) } -/// Re-purchase an item from the buyback ring (`CMSG_BUYBACK_ITEM`). Mirrors the vendor gating of -/// [`apply_item_sell`]; `slot_idx` is 0-based (the gateway maps `BuybackSlot.as_int() - 69`). The -/// buyback ring is ordered newest-first by id; slot 0 = most recently sold. [entity] pub(crate) fn apply_buyback_item( ctx: &ReducerContext, player_guid: u64, @@ -355,7 +280,6 @@ pub(crate) fn apply_buyback_item( "use buyback", )?; let entities = ctx.db.game_world_entity(); - // Find the buyback entry: sort by id desc (newest first = slot 0), take Nth. let buyback_tab = ctx.db.game_character_buyback(); let entries: Vec = buyback_tab.by_player_guid().filter(&player_guid).collect(); let ordered_ids = buyback_newest_first(entries.iter().map(|e| e.id).collect()); @@ -395,17 +319,6 @@ pub(crate) fn apply_buyback_item( Ok(()) } -/// Shared buy-from-vendor logic for the player + debug paths — the inverse of [`apply_item_sell`], -/// closing the money loop. Charges `buy_cost(buy_price, count)` copper for `count` units of `item_entry` -/// and grants them via `store_item`, which tops up an existing partial stack first and then spills the -/// remainder across as many stacks as it takes (each ≤ the template's `max_stack`), so buying more than -/// one stack's worth doesn't reject the purchase. A 0 `buy_price` means the item has no vendor value -/// (quest/junk analogue) and is unbuyable. MONEY IS DEBITED BEFORE THE ITEM IS GRANTED: deduct -/// (saturating, so the copper counter never wraps) and persist the player, THEN place the item — so a -/// mid-call "inventory full" still fails the whole reducer (SpacetimeDB rolls back the tx on `Err`), -/// never charging without delivering. Errors if the user isn't in world, is dead, `count == 0`, the -/// template is missing, the item is valueless, the player can't afford it, or there's no room left for -/// it all. Additive — touches only the player's money + the granted item row(s). [entity] pub(crate) fn apply_buy_item( ctx: &ReducerContext, player_guid: u64, @@ -413,16 +326,6 @@ pub(crate) fn apply_buy_item( item_entry: u32, count: u32, ) -> Result<(), String> { - // Death is server-authoritative everywhere — a dead/ghost player can't shop (mirrors the other - // paths). Vendor gating: the purchase must come from a real VENDOR creature the player is - // standing at, and that vendor must actually stock the item — you can't buy arbitrary items or - // from a non-vendor. - // - // ORDERING NOTE: before this extraction, `count == 0` was checked BETWEEN the dead - // check and the vendor-resolution gate; `npc_interaction_gate` folds dead+vendor into one atomic - // check, so `count == 0` now runs AFTER the vendor/range gate instead. This only changes which - // error string comes back for a MALFORMED packet that combines count=0 with a bogus/out-of-range - // vendor — the real client's Buy dialog never sends count=0, so no legitimate interaction changes. let (mut player, vendor) = npc_interaction_gate( ctx, player_guid, @@ -444,17 +347,13 @@ pub(crate) fn apply_buy_item( .entry() .find(item_entry) .ok_or_else(|| format!("no such item {item_entry}"))?; - // A 0 buy_price is the "vendor never sells this" sentinel (quest/junk analogue) — reject before charging. if tmpl.buy_price == 0 { return Err("item cannot be bought".to_string()); } - // Reputation vendor discount: Honored+ with the vendor's parent faction cuts the BUY price - // (vanilla 5% per rank above Neutral — Honored 10%). Sell is unchanged. Neutral / no-standing / a - // factionless vendor → 0% → full price. Applied at this single buy chokepoint, after the count math. let cost = { let base = buy_cost(tmpl.buy_price, count); let pct = crate::reputation::vendor_discount_pct(ctx, player_guid, vendor.faction_template); - base - base.saturating_mul(pct) / 100 // floor rounding (matches cmangos' truncating multiply) + base - base.saturating_mul(pct) / 100 }; if player.money < cost { return Err(format!( @@ -462,23 +361,13 @@ pub(crate) fn apply_buy_item( player.money, cost )); } - // DEBIT FIRST: deduct (saturating, never wraps) and persist the player BEFORE granting the item, so a - // subsequent failure (inventory full) rolls the whole tx back rather than charging for nothing. A buy - // of more than `max_stack` now SPILLS across stacks (parity) — and tops up an existing partial - // stack of the same item — instead of being rejected. + // Propagate storage failure so the reducer rolls back the debit. player.money = player.money.saturating_sub(cost); let owner_identity = player.owner_identity; ctx.db.game_world_entity().guid().update(player); store_item(ctx, player_guid, owner_identity, &tmpl, count, false, None) } -/// Repair the item in `slot` back to its template's `max_durability` — the FREE restore the harness -/// drives via `debug_repair_item` (deterministic durability resets without money bookkeeping). The -/// PLAYER path is `apply_player_repair` below (NPC gate + cost + batch), which deliberately does not -/// share this body: its restore is fused with the debit so the whole tx rolls back together. -// Sole consumer is the feature-gated debug reducer — the `debug_only!` rule (actor.rs): silence -// dead-code ONLY in a default build; a debug_reducers build still flags it if the harness stops -// consuming it, which is the cue to delete it. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] pub(crate) fn apply_repair_item( ctx: &ReducerContext, @@ -501,24 +390,14 @@ pub(crate) fn apply_repair_item( Ok(()) } -/// Sentinel `slot` for `apply_player_repair`: repair every equipped item (the whole body) rather than -/// one slot. The 1.12 client sends one `CMSG_REPAIR_ITEM` per damaged item (no repair-all bit), so the -/// live gateway path passes a specific slot; this is reachable via the reducer (harness / future convenience). pub(crate) const REPAIR_ALL: u8 = u8::MAX; -/// Player repair: restore durability at a REPAIR-flagged NPC, charging copper. Mirrors the vendor gate -/// (`apply_item_sell`): the player must be alive, the `npc_guid` a real REPAIR creature on the same map -/// within `VENDOR_RANGE_SQ`. `slot == REPAIR_ALL` repairs every equipped item; otherwise just `slot`. -/// Cost (`rules::repair_cost` summed) is debited BEFORE the restore and the whole tx rolls back on Err -/// (never charge without repairing). A no-cost call (nothing damaged) is a successful no-op. [entity] pub(crate) fn apply_player_repair( ctx: &ReducerContext, player_guid: u64, npc_guid: u64, slot: u8, ) -> Result<(), String> { - // REPAIR-NPC gate — same shape as apply_item_sell's vendor gate, keyed on the REPAIR flag - // (the shared npc_interaction_gate). let (mut player, _npc) = npc_interaction_gate( ctx, player_guid, @@ -532,7 +411,6 @@ pub(crate) fn apply_player_repair( let instances = ctx.db.game_item_instance(); let templates = ctx.db.game_item_template(); - // Collect the damaged item(s) to repair: one slot, or every equipped item (slot 0..=END). let targets: Vec = instances .by_owner_guid() .filter(&player_guid) @@ -548,7 +426,6 @@ pub(crate) fn apply_player_repair( return Err(format!("no item in slot {slot}")); } - // Sum the cost over items that actually have durability damage; skip no-durability items silently. let mut total_cost: u32 = 0; let mut to_repair: Vec<(ItemInstance, u32)> = Vec::new(); for inst in targets { @@ -561,12 +438,12 @@ pub(crate) fn apply_player_repair( } } if to_repair.is_empty() { - return Ok(()); // nothing damaged — successful no-op (vanilla: button greyed) + return Ok(()); } if player.money < total_cost { return Err("not enough money to repair".to_string()); } - // Debit first, persist, THEN restore — Err anywhere rolls the whole tx back (no charge-without-repair). + // Debit and repair must commit in the same transaction. player.money -= total_cost; entities.guid().update(player); for (mut inst, max_dur) in to_repair { @@ -584,9 +461,6 @@ mod tests { }; use lyracore_shared::constants::npc_flags::{BANKER, VENDOR}; - /// BANK ACCESS: only a live BANKER-flagged NPC inside the interaction radius opens the bank. A - /// player, a corpse, a non-banker NPC, and a banker one yard too far all leave it shut — so a move - /// into a bank slot with no banker in range is refused. #[test] fn banker_in_reach_accepts_only_a_live_banker_inside_the_radius() { let edge = VENDOR_RANGE_SQ; @@ -594,15 +468,13 @@ mod tests { |is_player, flags, dead, d| flagged_npc_in_reach(BANKER, is_player, flags, dead, d); assert!(banker_in_reach(false, BANKER, false, 0.0)); assert!(banker_in_reach(false, BANKER | VENDOR, false, edge)); - assert!(!banker_in_reach(true, BANKER, false, 0.0)); // another player, however flagged - assert!(!banker_in_reach(false, BANKER, true, 0.0)); // a banker's corpse - assert!(!banker_in_reach(false, VENDOR, false, 0.0)); // wrong npc kind - assert!(!banker_in_reach(false, 0, false, 0.0)); // no npc flags at all - assert!(!banker_in_reach(false, BANKER, false, edge + 1.0)); // out of range + assert!(!banker_in_reach(true, BANKER, false, 0.0)); + assert!(!banker_in_reach(false, BANKER, true, 0.0)); + assert!(!banker_in_reach(false, VENDOR, false, 0.0)); + assert!(!banker_in_reach(false, 0, false, 0.0)); + assert!(!banker_in_reach(false, BANKER, false, edge + 1.0)); } - /// Each purchase outcome must reach the reducer boundary as its own wire code, so the relay never - /// has to read the prose. `Bought` is the only `Ok`. #[test] fn every_purchase_outcome_carries_its_own_wire_code() { use lyracore_shared::bank::result; @@ -631,8 +503,6 @@ mod tests { ); } - /// BUYBACK RING FIFO EVICTION: the ring evicts its oldest entry once it already holds 12 (so the - /// 13th sale pushes one out and the ring never grows past 12); below that it never evicts. #[test] fn buyback_ring_full_evicts_at_twelve_not_before() { assert!(!buyback_ring_full(0)); @@ -641,8 +511,6 @@ mod tests { assert!(buyback_ring_full(13)); } - /// BUYBACK NEWEST-FIRST SLOT ORDER: re-purchase slot 0 is the most recently sold item (the highest - /// auto_inc id), descending from there — independent of the ids' original (insertion) order. #[test] fn buyback_newest_first_orders_ids_descending_by_auto_inc() { assert_eq!(buyback_newest_first(vec![5, 1, 9, 3]), vec![9, 5, 3, 1]); diff --git a/module/src/items/exchange.rs b/module/src/items/exchange.rs index 929d211a0..2255192a5 100644 --- a/module/src/items/exchange.rs +++ b/module/src/items/exchange.rs @@ -1,5 +1,3 @@ -//! Plan item consumption and grants together before changing the Character's inventory. - use crate::actor::{ActionRefusal, ActionRefusalKind}; use spacetimedb::{Identity, ReducerContext, Table, Timestamp}; @@ -9,8 +7,7 @@ use super::{ select_property, ItemInstance, ItemTemplate, }; -/// Quest collect items and all rewards form one exchange. Every Refusal precedes item changes; -/// GUID reservation may consume unused identities, but never returns them to the range. +// Refusals leave items unchanged; GUID reservations may still advance the range. pub(crate) fn exchange_items( ctx: &ReducerContext, character_guid: u64, @@ -35,7 +32,6 @@ pub(crate) fn exchange_items( plan.commit(ctx) } -/// Check the complete carried quantity before changing any stack. Bank copies stay untouched. pub(super) fn consume_item_stacks( items: &mut [ItemInstance], entry: u32, @@ -67,7 +63,7 @@ pub(super) fn consume_item_stacks( Ok(()) } -/// Existing rows retain their identities. New rows receive GUIDs only after the full plan passes. +// Reserve every new GUID before committing any inventory change. pub(super) struct ItemStoragePlan { character_guid: u64, owner_identity: Identity, @@ -232,7 +228,7 @@ impl ItemStoragePlan { enchant_id: 0, soulbound, random_property_id, - item_text_id: 0, // a plan-based grant is an ordinary item, never a Letter Copy + item_text_id: 0, }); count -= take; } @@ -269,7 +265,6 @@ impl ItemStoragePlan { } Some(_) => {} None => { - // The reservation above issued one GUID for every appended item. item.guid = guids.next().expect("item plan reserved every new GUID"); instances.insert(item); } diff --git a/module/src/items/guids.rs b/module/src/items/guids.rs index 0c1c0fc72..b86e1548e 100644 --- a/module/src/items/guids.rs +++ b/module/src/items/guids.rs @@ -1,10 +1,3 @@ -//! Item identities issued from the Shard's durable GUID Range. -//! -//! Characters and items share the range's high-water mark. An item GUID keeps `HIGHGUID_ITEM` in -//! bits 48–63, sets bit 47 to mark this format, and holds the issued value in bits 0–46. It does not -//! pack its owner's GUID. An item keeps its GUID across slot moves and Character Transfer; Trading, -//! Mail, Auctions and Buyback create new items for the recipient. - use spacetimedb::ReducerContext; use super::tables::game_item_instance; @@ -13,8 +6,7 @@ const ITEM_PREFIX: u64 = lyracore_shared::constants::starter_item::HIGHGUID_ITEM const FORMAT_BIT: u64 = 1 << 47; const MAX_LOW_ID: u64 = FORMAT_BIT - 1; -/// Reserve the complete batch before changing inventory. The shared high-water mark never -/// rewinds, so crafting and Trading cannot reuse identities deleted in the same transaction. +// The shared GUID Range never rewinds, even after items are deleted. pub(crate) fn allocate_item_guids(ctx: &ReducerContext, count: usize) -> Result, String> { if count == 0 { return Ok(Vec::new()); diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 0229da451..9623be715 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -1,8 +1,3 @@ -//! Move / split / equip / unequip + the slot-space vocabulary and free-slot search (split off -//! `ops.rs`, pure code-motion, no behavior change except the free-slot search's index-scan count — see -//! `first_free_backpack_slot`/`first_free_bag_slot`). Each `apply_*` is the shared core behind a thin -//! player reducer and its debug twin (see `reducers.rs`). - use spacetimedb::{ReducerContext, Table}; use lyracore_shared::constants::starter_item; @@ -32,7 +27,6 @@ fn bag_has_contents(ctx: &ReducerContext, character_guid: u64, slot: u8) -> bool .any(|item| (start..start + MAX_BAG_SIZE).contains(&item.slot)) } -/// Destroy only the addressed owned stack. Zero means the whole stack. pub(crate) fn apply_item_destroy( ctx: &ReducerContext, character_guid: u64, @@ -76,8 +70,6 @@ pub(crate) fn apply_item_destroy( Ok(()) } -/// Split a proper subset into empty storage, preserving ownership and binding state. -/// Equipment and bag equipment slots cannot receive a split stack. pub(crate) fn apply_item_split( ctx: &ReducerContext, player_guid: u64, @@ -85,7 +77,6 @@ pub(crate) fn apply_item_split( count: u32, to_slot: u8, ) -> Result<(), ItemRefusal> { - // The bank is a place, not a portable bag: either endpoint in bank space needs an open bank. if is_bank_slot(slot) || is_bank_slot(to_slot) { bank_access(ctx, player_guid)?; } @@ -95,8 +86,6 @@ pub(crate) fn apply_item_split( if crate::trade::item_is_offered(ctx, player_guid, inst.guid) { return Err(ItemRefusal::NotRightNow); } - // A split must leave at least one unit in BOTH slots — splitting off none or the whole stack isn't - // a split (the latter is a move). if !valid_split_count(count, inst.stack_count) { return Err(refuse(ItemRefusal::WrongSlot, "invalid split count")); } @@ -106,10 +95,7 @@ pub(crate) fn apply_item_split( format!("invalid destination slot {to_slot}"), )); } - // Bag-content destination: validate that the corresponding bag is equipped and the slot is - // within its capacity — same phantom-slot dupe vector as apply_item_move. validate_bag_dest_slot(ctx, player_guid, to_slot)?; - // The destination slot must be free; we never merge/swap on a split. if slot_occupied(ctx, player_guid, to_slot) { return Err(refuse(ItemRefusal::WrongSlot, "destination slot occupied")); } @@ -119,8 +105,8 @@ pub(crate) fn apply_item_split( let owner_identity = inst.owner_identity; let durability = inst.durability; let random_property_id = inst.random_property_id; - let soulbound = inst.soulbound; // the split half carries the SAME binding state as its source stack - let item_text_id = inst.item_text_id; // ditto — a split half stays as readable as its source + let soulbound = inst.soulbound; + let item_text_id = inst.item_text_id; instances.guid().update(inst); instances.insert(ItemInstance { guid: new_guid, @@ -131,7 +117,7 @@ pub(crate) fn apply_item_split( stack_count: count, durability, created_at: ctx.timestamp, - enchant_id: 0, // a split is only ever on a stackable (non-equippable) item → never enchanted + enchant_id: 0, soulbound, random_property_id, item_text_id, @@ -139,8 +125,6 @@ pub(crate) fn apply_item_split( Ok(()) } -/// Move, swap or merge two owned stacks after both destinations pass their Gates. -/// Nonempty equipped bags stay in place until contents can follow a bag-slot change. pub(crate) fn apply_item_move( ctx: &ReducerContext, player_guid: u64, @@ -150,7 +134,6 @@ pub(crate) fn apply_item_move( if from_slot == to_slot { return Ok(()); } - // Bank access applies to either endpoint. if is_bank_slot(from_slot) || is_bank_slot(to_slot) { bank_access(ctx, player_guid)?; } @@ -334,14 +317,6 @@ pub(crate) fn apply_item_move( Ok(()) } -/// Shared EQUIP logic for the player + debug paths: take the item in backpack/inventory `from_slot` and -/// equip it into the correct `EQUIPMENT_SLOT_*` for its `inventory_type` (auto-resolved, including the -/// first-free of a finger/trinket pair). Rejects an item whose type isn't equippable (`inventory_type` -/// has no slot — e.g. food/junk). Delegates the actual placement/swap (and the required-level gate) to -/// the shared `apply_item_move`, so equipping is exactly "a move into the resolved equip slot": an empty -/// target just receives the item; an occupied target SWAPS its resident back into `from_slot`. Additive -/// — touches only the item rows' `slot` (no new fields). Errors if `from_slot` is empty, the template is -/// missing, or the item isn't equippable. [entity] pub(crate) fn apply_equip_item( ctx: &ReducerContext, player_guid: u64, @@ -364,22 +339,16 @@ pub(crate) fn apply_equip_item( format!("no template for item entry {}", src.entry), ) })?; - // Bags (INVTYPE_BAG) equip into bag-equip slots 19..=22, not the equipment region 0..=18. - // Route them separately: find the first free bag-equip slot and move the bag there. A bag - // already in the bag slots (dragged manually) won't come through here, but autoequip does. if tmpl.inventory_type == invtype::BAG { let to_slot = first_free_bag_equip_slot(ctx, player_guid) .ok_or_else(|| refuse(ItemRefusal::InventoryFull, "all four bag slots are full"))?; return apply_item_move(ctx, player_guid, from_slot, to_slot); } - // Dual Wield: redirect a second one-hander to OFFHAND (instead of swapping MAINHAND) when the - // caster has learned spell 674 — see `resolve_equip_slot`'s doc comment. let can_dual_wield = crate::spell::knows_spell( ctx, player_guid, lyracore_shared::constants::dual_wield::SPELL_ID, ); - // Snapshot which equip slots are occupied so the pair-resolver can prefer a free finger/trinket. let to_slot = resolve_equip_slot(tmpl.inventory_type, can_dual_wield, |s| { slot_occupied(ctx, player_guid, s) }) @@ -389,13 +358,9 @@ pub(crate) fn apply_equip_item( format!("item {} is not equippable", src.entry), ) })?; - // Equip == a validated move into the resolved equip slot (reuses the equip-validation + swap there). apply_item_move(ctx, player_guid, from_slot, to_slot) } -/// Equip a profile item only when its destination is empty or holds lower item-level gear. The -/// existing equip operation still owns every slot, level, proficiency, skill, reputation, and bind -/// Gate. Full bag slots and missing equipped templates preserve what the Character already wears. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn apply_equip_profile_upgrade( ctx: &ReducerContext, @@ -461,10 +426,6 @@ pub(crate) fn apply_equip_profile_upgrade( Ok(true) } -/// Shared UNEQUIP logic for the player + debug paths: take the item in equipment `from_slot` (0..=18) -/// and move it to the first free backpack slot. Rejects a non-equipment source slot (it's not equipped) -/// or a full backpack. Delegates to `apply_item_move` so the placement is the exact same move primitive; -/// the destination is a backpack slot, so the equip-validation branch there is a no-op for it. Additive. pub(crate) fn apply_unequip_item( ctx: &ReducerContext, player_guid: u64, @@ -476,7 +437,6 @@ pub(crate) fn apply_unequip_item( format!("slot {from_slot} is not an equipment slot"), )); } - // Must actually hold an equipped item to unequip. if !slot_occupied(ctx, player_guid, from_slot) { return Err(refuse( ItemRefusal::ItemNotFound, @@ -488,82 +448,43 @@ pub(crate) fn apply_unequip_item( apply_item_move(ctx, player_guid, from_slot, free) } -/// The loose-inventory ("backpack") slot range in this minimal model: 16 slots, 23..=38, mirroring -/// `starter_item::BACKPACK_SLOT_0` (23). Equip slots (0..=18) and bag-container slots aren't loose -/// storage, so auto-store only ever lands an item in the backpack. Expressed as an exclusive 23..39. -const BACKPACK_SLOT_END: u8 = starter_item::BACKPACK_SLOT_0 + 16; // 39 +const BACKPACK_SLOT_END: u8 = starter_item::BACKPACK_SLOT_0 + 16; -/// First of the four bag-equip slots (the bags themselves live here; NOT their contents). const BAG_SLOT_START: u8 = 19; -/// Last bag-equip slot (inclusive). Vanilla has four: 19..=22. const BAG_SLOT_END_INCL: u8 = 22; -/// Number of bag-equip slots. const BAG_SLOT_COUNT: u8 = 4; -/// Base of the flat bag-content slot space. All values from here to `BAG_CONTENT_END - 1` are -/// used exclusively for items stored INSIDE equipped bags. The range starts above the largest -/// vanilla `ItemSlot` ordinal (Keyring32 = 112), so `ItemSlot::try_from(slot)` returns `Err` -/// for bag-content slots — the gateway correctly sends no `PLAYER_FIELD_INV_SLOT` pointer for -/// them (items in bags are tracked via the container object, not the player descriptor). +// Keep bag-content slots above vanilla ItemSlot ordinals, whose last value is 112. const BAG_CONTENT_OFFSET: u8 = 120; -/// Maximum container slots any bag can hold — vanilla's largest (Portable Hole) is 18 slots. -/// Each bag-equip position (19..=22) is assigned this many flat content slots. +// Each equipped bag reserves 18 flat slots, the vanilla maximum capacity. const MAX_BAG_SIZE: u8 = 18; -/// Exclusive upper bound of the bag-content region: 120 + 4 × 18 = 192. Slots 120..=191. -const BAG_CONTENT_END: u8 = BAG_CONTENT_OFFSET + BAG_SLOT_COUNT * MAX_BAG_SIZE; // 192 - -/// The anti-dupe destination-slot gate shared by `apply_item_move` and `apply_item_split`: a valid -/// destination is the flat equip+bag-equip+backpack range (0..=38), the base bank range (39..=62), or -/// a bag-content slot (120..=191). Anything else (the bank-bag/buyback/keyring ordinals 63..=119 we -/// don't model, or 192..255) is an inventory-overflow dupe vector from a modified client and is -/// rejected. Extracted (pure code-motion, deduplicating the two identical inline expressions) so the -/// slot-range boundaries are unit-tested without a live module. +const BAG_CONTENT_END: u8 = BAG_CONTENT_OFFSET + BAG_SLOT_COUNT * MAX_BAG_SIZE; + pub(crate) fn valid_dest_slot(to_slot: u8) -> bool { - to_slot < BACKPACK_SLOT_END // 0..=38 (equip + bag-equip + backpack) - || is_bank_slot(to_slot) // 39..=62 (the 24 base bank slots) - || (BAG_CONTENT_OFFSET..BAG_CONTENT_END).contains(&to_slot) // 120..=191 + to_slot < BACKPACK_SLOT_END + || is_bank_slot(to_slot) + || (BAG_CONTENT_OFFSET..BAG_CONTENT_END).contains(&to_slot) } -/// First base bank slot (vanilla `ItemSlot::Bank1`). const BANK_SLOT_START: u8 = 39; -/// Last base bank slot (`ItemSlot::Bank24`), 24 slots in total. const BANK_SLOT_END_INCL: u8 = 62; -/// Whether a slot is one of the 24 base bank slots. Bank bag slots (63..=68) are NOT bank storage -/// here — their contents have no slot addresses, so they stay refused everywhere. +// Bank-bag slots 63..=68 have no content addresses in this model. pub(crate) fn is_bank_slot(slot: u8) -> bool { (BANK_SLOT_START..=BANK_SLOT_END_INCL).contains(&slot) } -/// Whether a slot is CARRIED — equipment, bag-equip, backpack, or bag content — as opposed to merely -/// owned. Bank slots are owned but not carried: they don't count for collect quests, aren't consumed -/// on turn-in, aren't fired as ammo, and never receive auto-stored loot. Stated as the carried ranges -/// rather than "not the bank", so the bank-bag region stays uncarried when it opens. pub(crate) use lyracore_shared::item::is_carried_slot; -/// Splits target storage slots only. Equipment requires the move path's Gates. pub(crate) fn valid_split_dest_slot(to_slot: u8) -> bool { valid_dest_slot(to_slot) && to_slot > BAG_SLOT_END_INCL } -/// Decompose a bag-content slot (120..=191) into `(bag_idx, slot_in_bag)`: which of the four equipped -/// bags (0..=3) and which position within it. Extracted from `apply_item_move` / `apply_item_split` -/// (pure code-motion, deduplicating the two identical inline expressions) — the caller adds `bag_idx` to -/// `BAG_SLOT_START` to find the bag's equip slot. Only meaningful for `to_slot >= BAG_CONTENT_OFFSET` -/// (the caller guards that); this does not itself validate the range. pub(crate) fn bag_content_decompose(to_slot: u8) -> (u8, u8) { let offset = to_slot - BAG_CONTENT_OFFSET; (offset / MAX_BAG_SIZE, offset % MAX_BAG_SIZE) } -/// The bag-content CAPACITY gate shared by `apply_item_move` and `apply_item_split`: once -/// `to_slot` has already passed `valid_dest_slot`/`valid_split_dest_slot`, a bag-content destination -/// (120..=191) additionally needs the corresponding bag-equip slot to actually hold an equipped bag -/// whose template covers `slot_in_bag`. A no-op (`Ok(())`) for a non-bag-content destination — the -/// caller only reaches here after its own range gate already accepted `to_slot`. Anti-dupe: a modified -/// client can otherwise `CMSG_SPLIT_ITEM`/`CMSG_MOVE_ITEM` straight to slot 120 with no bag equipped in -/// slot 19, creating an orphaned item row that is invisible and can never be freed by normal play. -/// Extracted (pure code-motion, deduplicating the two byte-identical inline blocks) — every error -/// string below is preserved verbatim from both call sites. +// A valid flat slot still needs an equipped bag with enough capacity. pub(crate) fn validate_bag_dest_slot( ctx: &ReducerContext, player_guid: u64, @@ -598,22 +519,15 @@ pub(crate) fn validate_bag_dest_slot( Ok(()) } -/// The banker-proximity Gate as an item Refusal: its own prose stays in the Module log. fn bank_access(ctx: &ReducerContext, player_guid: u64) -> Result<(), ItemRefusal> { super::economy::bank_access_gate(ctx, player_guid) .map_err(|detail| refuse(ItemRefusal::BankUnavailable, detail)) } -/// A split must leave at least one unit in each stack. pub(crate) fn valid_split_count(count: u32, stack_count: u32) -> bool { count != 0 && count < stack_count } -/// First backpack slot (23..39) not occupied by any of the player's items, or `None` if the backpack -/// is full. Collects the owner's occupied slots into a set ONCE (smalls: was one `by_owner_guid` -/// index scan PER CANDIDATE slot — up to 16 full scans for a nearly-full backpack) then does a plain -/// membership check per candidate. Vanilla auto-store fills the first free bag slot. -/// The occupied-slot set behind the two backpack probes below — one spelling of the scan. fn occupied_slots(ctx: &ReducerContext, player_guid: u64) -> std::collections::HashSet { ctx.db .game_item_instance() @@ -623,7 +537,6 @@ fn occupied_slots(ctx: &ReducerContext, player_guid: u64) -> std::collections::H .collect() } -/// Number of free carry slots across the backpack and all equipped bags. pub(crate) fn count_free_inventory_slots(ctx: &ReducerContext, player_guid: u64) -> u32 { let owned: Vec = ctx .db @@ -641,8 +554,6 @@ pub(crate) fn count_free_inventory_slots(ctx: &ReducerContext, player_guid: u64) .len() as u32 } -/// Free storage slots after the planned item consumption. Empty stacks no longer occupy a slot -/// or provide bag capacity. Backpack slots precede equipped bags in their normal storage order. pub(super) fn free_inventory_slots( owned: &[ItemInstance], mut bag_capacity: impl FnMut(u32) -> u8, @@ -678,19 +589,10 @@ pub(crate) fn first_free_backpack_slot(ctx: &ReducerContext, player_guid: u64) - (starter_item::BACKPACK_SLOT_0..BACKPACK_SLOT_END).find(|slot| !occupied.contains(slot)) } -/// First free bag-equip slot (19..=22) not currently holding a bag, or `None` if all four are -/// occupied. Used by `apply_equip_item` to auto-resolve where to drop a bag. Pure scan. fn first_free_bag_equip_slot(ctx: &ReducerContext, player_guid: u64) -> Option { (BAG_SLOT_START..=BAG_SLOT_END_INCL).find(|&slot| !slot_occupied(ctx, player_guid, slot)) } -/// First free content slot across all equipped bags (19..=22), scanning in bag-equip order (bag 1 -/// first). Returns `None` if every equipped bag is full or no bags are equipped. Used by -/// `store_item` as the fallback after the 16-slot backpack is exhausted — bags thus act as -/// overflow storage. A bag equip slot with no item is skipped; a bag whose template is missing -/// or has `container_slots == 0` (not a real bag) is also skipped. Collects the owner's rows ONCE -/// (smalls, same fix as `first_free_backpack_slot`) instead of one index scan per candidate -/// content slot — up to 18 scans per bag before this. [entity] pub(crate) fn first_free_bag_slot(ctx: &ReducerContext, player_guid: u64) -> Option { let templates = ctx.db.game_item_template(); let owned: Vec = ctx @@ -723,9 +625,6 @@ pub(crate) fn first_free_bag_slot(ctx: &ReducerContext, player_guid: u64) -> Opt None } -/// First bank slot (39..=62) not occupied by any of the player's items, or `None` if the bank is -/// full. Same one-scan shape as `first_free_backpack_slot` — collect the owner's occupied slots -/// once, then a plain membership check per candidate. pub(crate) fn first_free_bank_slot(ctx: &ReducerContext, player_guid: u64) -> Option { let occupied: std::collections::HashSet = ctx .db @@ -737,11 +636,6 @@ pub(crate) fn first_free_bank_slot(ctx: &ReducerContext, player_guid: u64) -> Op (BANK_SLOT_START..=BANK_SLOT_END_INCL).find(|slot| !occupied.contains(slot)) } -/// Shared auto-bank/auto-store-bank core for the player + debug paths (right-click to bank, right- -/// click to withdraw): the direction is inferred from `slot` itself rather than taken as an argument -/// — a bank slot withdraws to the first free carry slot (backpack, then bag space, the same fallback -/// `store_item` uses for loot); anything else deposits to the first free bank slot. Delegates the -/// placement to `apply_item_move`, so both directions get the banker-proximity gate for free. pub(crate) fn apply_auto_bank_item( ctx: &ReducerContext, player_guid: u64, @@ -765,8 +659,6 @@ mod tests { valid_split_dest_slot, BANK_SLOT_END_INCL, BANK_SLOT_START, }; - /// BANK SLOT RANGE: exactly the 24 base bank slots (39..=62). The bank-bag ordinals just past them - /// (63..=68) are not bank storage in this model. #[test] fn is_bank_slot_covers_the_24_base_slots_only() { for slot in BANK_SLOT_START..=BANK_SLOT_END_INCL { @@ -777,9 +669,6 @@ mod tests { } } - /// CARRIED-SLOT PREDICATE: equipment, bag-equip, backpack, and bag-content slots are carried; the - /// base bank range is owned but not carried, and so is every slot past it (the bank-bag region and - /// the unaddressed tail). #[test] fn is_carried_slot_admits_the_two_carry_regions_only() { for slot in [0u8, 18, 19, 22, 23, 38, 120, 191] { @@ -796,9 +685,9 @@ mod tests { #[test] fn valid_split_count_rejects_zero_and_the_whole_stack_only() { const STACK: u32 = 5; - assert!(!valid_split_count(0, STACK)); // splitting off nothing isn't a split - assert!(!valid_split_count(STACK, STACK)); // splitting off the whole stack is a move, not a split - assert!(!valid_split_count(STACK + 1, STACK)); // over the stack is never valid either + assert!(!valid_split_count(0, STACK)); + assert!(!valid_split_count(STACK, STACK)); + assert!(!valid_split_count(STACK + 1, STACK)); for count in 1..STACK { assert!( valid_split_count(count, STACK), @@ -807,28 +696,19 @@ mod tests { } } - /// ANTI-DUPE DESTINATION-SLOT GATE (`apply_item_move` / `apply_item_split`): the equip+bag-equip+ - /// backpack range (0..=38), the base bank range (39..=62), and the bag-content range (120..=191) are - /// valid; the unmodeled bank-bag/buyback/keyring gap (63..=119) and anything past the bag-content - /// region (192+) are rejected. #[test] fn valid_dest_slot_admits_the_three_modeled_ranges_only() { - // Inside 0..=38 (equip 0..=18, bag-equip 19..=22, backpack 23..=38). for slot in [0u8, 18, 19, 22, 23, 38] { assert!(valid_dest_slot(slot), "slot {slot} is in the 0..=38 range"); } - // The 24 base bank slots. for slot in BANK_SLOT_START..=BANK_SLOT_END_INCL { assert!(valid_dest_slot(slot), "bank slot {slot} is a valid dest"); } - // Bank bag slots and the rest of the unmodeled gap stay refused. assert!(!valid_dest_slot(63)); assert!(!valid_dest_slot(68)); assert!(!valid_dest_slot(119)); - // Inside the bag-content region 120..=191. assert!(valid_dest_slot(120)); assert!(valid_dest_slot(191)); - // Just past the bag-content region. assert!(!valid_dest_slot(192)); assert!(!valid_dest_slot(255)); } @@ -847,13 +727,11 @@ mod tests { } } - /// BAG-CONTENT SLOT DECOMPOSITION: a flat bag-content slot (120..=191) decomposes into - /// `(bag_idx, slot_in_bag)` — 18 slots per bag, in bag-equip order. #[test] fn bag_content_decompose_maps_flat_slot_to_bag_index_and_position() { - assert_eq!(bag_content_decompose(120), (0, 0)); // first slot of the first bag - assert_eq!(bag_content_decompose(137), (0, 17)); // last slot of the first bag (18 slots: 120..=137) - assert_eq!(bag_content_decompose(138), (1, 0)); // first slot of the second bag - assert_eq!(bag_content_decompose(191), (3, 17)); // last slot of the fourth (last) bag + assert_eq!(bag_content_decompose(120), (0, 0)); + assert_eq!(bag_content_decompose(137), (0, 17)); + assert_eq!(bag_content_decompose(138), (1, 0)); + assert_eq!(bag_content_decompose(191), (3, 17)); } } diff --git a/module/src/items/mod.rs b/module/src/items/mod.rs index f577b5112..cfb041a9a 100644 --- a/module/src/items/mod.rs +++ b/module/src/items/mod.rs @@ -1,15 +1,3 @@ -//! The item pipeline: a hand-authored item definition (`game_item_template`) plus a per-player -//! owned-item row (`game_item_instance`), so a freshly-logged-in Warrior carries one starter weapon -//! ("Worn Shortsword") equipped in the main-hand slot (15, vanilla-authentic) so the client renders it -//! on the 3D character model via `PLAYER_VISIBLE_ITEM`. The wire format is `CREATE_OBJECT`, -//! `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, and the `PLAYER_FIELD_INV_SLOT` descriptor. [entity] -//! -//! The instance row is born at login (when `owner_identity` is known — it can't be SQL-seeded in -//! `init`, where the owner is still `Identity::ZERO`, and an RLS filter on `owner_identity = :sender` -//! would hide it). See [[gtker-descriptor-setter-wall]]: the inventory slot is reachable via gtker's -//! typed `set_player_field_inv(ItemSlot, Guid)` (NOT walled like the aura array), so this needs no raw -//! encoder. - mod economy; mod exchange; mod guids; @@ -21,14 +9,11 @@ mod tables; use lyracore_shared::item::ItemRefusal; -/// Refuse an item action. The human detail stays in the Module log and only the typed Refusal -/// travels, so neither tier reads prose. pub(crate) fn refuse(refusal: ItemRefusal, detail: impl std::fmt::Display) -> ItemRefusal { spacetimedb::log::info!("item refused {}: {detail}", refusal.as_tag()); refusal } -/// Reducer edge: a reducer answers with a bare tag, which is the whole error text the Gateway reads. pub(crate) fn refused(refusal: ItemRefusal) -> String { refusal.as_tag().to_string() } @@ -39,7 +24,6 @@ pub(crate) use economy::{ apply_buy_bank_slot, apply_buy_item, apply_buyback_item, apply_item_sell, apply_player_repair, buy_bank_slot_result, innkeeper_access_gate, npc_interaction_gate, }; -// the apply_* cores are pub(crate) (no pub items to re-export) pub(crate) use exchange::exchange_items; pub(crate) use guids::{allocate_item_guids, next_item_guid}; pub(crate) use inventory::{ @@ -47,7 +31,6 @@ pub(crate) use inventory::{ apply_item_move, apply_item_split, apply_unequip_item, count_free_inventory_slots, is_carried_slot, validate_bag_dest_slot, }; -// ditto pub(crate) use ops::{ apply_death_durability_loss, apply_item_use, apply_take_loot, carried_items, equipped_school_stat_bonus, equipped_stat_bonus, grant_item, grant_item_property, @@ -55,7 +38,6 @@ pub(crate) use ops::{ remove_items, request_grant_item, request_profile_item, request_take_loot, resistance_items, restore_carried_items, store_instance_state, ItemSnapshot, }; -// ditto pub(crate) use properties::{ enchant_stat, enchant_stat_for_school, is_known_enchant, property_stat, property_stat_for_school, seed_compatibility_enchantments, select_loot_property, diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index b749d20ce..10fb56ca4 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -1,9 +1,3 @@ -//! The `apply_*` mutation cores that don't belong to the vendor economy (`economy.rs`) or the -//! move/split/equip slot machinery (`inventory.rs`, split): grant / use items, take corpse loot, -//! the starter-loadout grant, and the equipped-stat sum. Each is the shared core behind a thin player -//! reducer and its debug twin (see `reducers.rs`). All effects are additive: they touch only the item -//! rows + the actor's health/money. - use crate::actor::{ActionRefusal, ActionRefusalKind}; use spacetimedb::{Identity, ReducerContext, Table}; @@ -11,12 +5,12 @@ use lyracore_shared::constants::starter_item; use lyracore_shared::item::ItemRefusal; use super::{next_item_guid, refuse, select_property}; -use crate::game_character; // the durable char holds `class` (the live WorldEntity does not) +use crate::game_character; use crate::game_corpse_loot; use crate::game_gameobject; -use crate::game_spell_effect; // Gate A reads the on-use spell's effect kinds (by_spell) to mana-gate energize consumables -use crate::game_start_item; // per-class starter loadout (CharStartOutfit via importer --dbc) -use crate::game_world_entity; // gameobject (chest) loot source — apply_take_loot resolves it too +use crate::game_spell_effect; +use crate::game_start_item; +use crate::game_world_entity; use super::inventory::{first_free_backpack_slot, first_free_bag_slot, is_carried_slot}; use super::rules::{ @@ -26,22 +20,12 @@ use super::tables::{ game_item_instance, game_item_template, item_in_slot, ItemInstance, ItemTemplate, }; -/// Grant the starter loadout to a character the first time it logs in, idempotently. Called from -/// `player_login` once the live entity exists (so `owner_identity` is bound). A no-op if the -/// character already owns any item (so a relog never duplicates). The single ownership guard at the -/// top covers every grant below, so a relog never duplicates any of them. Each individual grant is -/// skipped if its template isn't seeded (so login never depends on item data being loaded). The -/// loadout is the EQUIPPED main-hand weapon plus a couple of loose backpack items, proving the -/// inventory renders multiple distinct items at distinct slots. Additive: it only ever inserts into -/// the two new item tables. [entity] pub(crate) fn grant_starter_item( ctx: &ReducerContext, owner_guid: u64, owner_identity: Identity, ) -> Result<(), String> { let instances = ctx.db.game_item_instance(); - // Already has items → nothing to do (idempotent across relogs). This one guard gates every grant - // below, so the whole loadout is granted at most once per character. if instances .by_owner_guid() .filter(&owner_guid) @@ -50,8 +34,6 @@ pub(crate) fn grant_starter_item( { return Ok(()); } - // Grant one owned item at `slot`, only if its template is seeded (a missing template is skipped, - // never fatal to login). Allocation still refuses an exhausted GUID Range. let grant_one = |entry: u32, slot: u8, stack: u32| -> Result<(), String> { let Some(tmpl) = ctx.db.game_item_template().entry().find(entry) else { return Ok(()); @@ -68,18 +50,13 @@ pub(crate) fn grant_starter_item( stack_count: stack, durability: tmpl.max_durability, created_at: ctx.timestamp, - enchant_id: 0, // freshly minted loadout item — unenchanted - // BoP (bonding::BIND_ON_PICKUP) binds the instant it's granted — the starter kit is a - // grant source like any other. + enchant_id: 0, soulbound: binds_on_grant(tmpl.bonding), random_property_id, - item_text_id: 0, // no starter item carries readable text + item_text_id: 0, }); Ok(()) }; - // Every character starts with a Hearthstone in the backpack (use it to recall to the bound home). - // Granted BEFORE the outfit/fallback branches so both paths get it; HEARTHSTONE_SLOT (38, last - // backpack) sits clear of the outfit's stow slots (from BACKPACK_SLOT_0 up). Skipped if unseeded. grant_one( starter_item::HEARTHSTONE_ENTRY, starter_item::HEARTHSTONE_SLOT, @@ -104,23 +81,14 @@ pub(crate) fn grant_starter_item( let mut occupied: std::collections::HashSet = std::collections::HashSet::new(); let mut backpack = starter_item::BACKPACK_SLOT_0; for entry in outfit { - // Skip the Hearthstone — it was already granted explicitly into HEARTHSTONE_SLOT above. - // CharStartOutfit lists 6948 for every race/class, so without this guard every new - // character would receive two Hearthstone rows (one equipped slot + one backpack copy). + // CharStartOutfit includes the Hearthstone already granted above. if entry == starter_item::HEARTHSTONE_ENTRY { continue; } let Some(tmpl) = ctx.db.game_item_template().entry().find(entry) else { continue; }; - // Equippable (its inventory_type maps to a slot) → that slot; otherwise the next free backpack - // slot. `occupied` tracks slots filled this pass so a paired ring/trinket takes the free finger. - // `false` (Dual Wield) is correct here — a brand-new character has learned no spells yet, - // so the second-one-hander-to-offhand redirect never applies at grant time. - // Ammo (Projectile, class 6) starts as a FULL STACK. CharStartOutfit lists only the item id, - // not a count, so a flat grant of 1 gave hunters/rogues a single round — the first Auto Shot - // consumes it, then the ranged swing tick finds no ammo and cancels the auto-repeat (the shot - // never lands). Vanilla starts a hunter with 200. Non-ammo items grant 1 as before. + // CharStartOutfit omits counts; vanilla starting ammunition is 200 rounds. const CLASS_AMMO: u8 = 6; let count = if tmpl.class == CLASS_AMMO { 200 } else { 1 }; match resolve_equip_slot(tmpl.inventory_type, false, |s| occupied.contains(&s)) { @@ -137,20 +105,12 @@ pub(crate) fn grant_starter_item( return Ok(()); } - // FALLBACK (pre-import / unseeded race_class): the hand-authored Warrior loadout — a weapon EQUIPPED in - // the main hand (slot 15) so the client renders it on the model, plus a couple of backpack items. grant_one(starter_item::ENTRY, starter_item::MAINHAND_SLOT, 1)?; grant_one(51, starter_item::BACKPACK_SLOT_0, 1)?; grant_one(52, starter_item::BACKPACK_SLOT_0 + 1, 5)?; Ok(()) } -/// Grant `count`× `item_entry` to `player_guid` — the canonical "mint an owned item" core, used by -/// quest turn-in rewards ([`crate::quest`]) and available to any other give-an-item path. Looks up the -/// player (for the RLS `owner_identity`) and the item template (for durability), then delegates to -/// `store_item`, which tops up an existing partial stack first and spills the remainder across as many -/// backpack/bag slots as it takes. A Refusal leaves inventory unchanged. Quest turn-in uses -/// `exchange_items` to plan all consumption and rewards together. `count` is floored at 1. pub(crate) fn grant_item( ctx: &ReducerContext, player_guid: u64, @@ -205,9 +165,6 @@ pub(crate) fn request_grant_item( ) } -/// Top a profile-owned resource up to a fixed target. The target is capped at the largest -/// supported ammunition stack so a malformed profile cannot turn periodic repair into an unlimited -/// grant. Storage still owns capacity, uniqueness, and Random Property Gates. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn request_profile_item( ctx: &ReducerContext, @@ -246,9 +203,7 @@ fn profile_item_target_admitted(target_count: u32) -> Result<(), ActionRefusal> )) } -/// Add items to matching carried stacks, then free backpack or bag slots. -/// Capacity, uniqueness and every Random Property are resolved before changing any item, so callers may -/// handle an inventory-full Refusal without keeping a partial grant. +// Callers may catch a Refusal, so failed storage must leave inventory unchanged. pub(crate) fn store_item( ctx: &ReducerContext, player_guid: u64, @@ -287,20 +242,12 @@ fn store_item_typed( plan.commit(ctx) } -/// The next slot an incoming item can land in: backpack first (23..=38), then the content slots of -/// equipped bags in bag-equip order. `Err` is the full-bag refusal, and it is the ONE the whole -/// server answers with — [`store_item`] and [`store_instance_state`] both come here, so a mail take -/// and a vendor buy cannot disagree about whether there is room. pub(crate) fn free_slot(ctx: &ReducerContext, player_guid: u64) -> Result { first_free_backpack_slot(ctx, player_guid) .or_else(|| first_free_bag_slot(ctx, player_guid)) .ok_or_else(|| lyracore_shared::mail::INVENTORY_FULL.to_string()) } -/// Is there anywhere for one more item to land? [`free_slot`]'s boolean twin, for a caller that -/// wants the answer BEFORE it starts moving value it cannot put back — the sharded mail take probes -/// the recipient's shard with this so a full bag refuses the click instead of stranding the item in -/// an escrow. pub(crate) fn has_free_slot(ctx: &ReducerContext, player_guid: u64) -> bool { free_slot(ctx, player_guid).is_ok() } @@ -313,7 +260,7 @@ pub(crate) struct ItemSnapshot { pub enchant_id: u32, pub soulbound: bool, pub random_property_id: u32, - /// `ITEM_FIELD_ITEM_TEXT_ID` of a Plain Letter from a Letter Copy. 0 for every other item. + // ITEM_FIELD_ITEM_TEXT_ID; zero except on a Letter Copy. pub item_text_id: u32, } @@ -337,7 +284,6 @@ impl From<&ItemInstance> for ItemSnapshot { } } -/// Recreates an exact item snapshot, applying only template grant-time constraints. pub(crate) fn store_instance_state( ctx: &ReducerContext, player_guid: u64, @@ -347,9 +293,6 @@ pub(crate) fn store_instance_state( snapshot: &ItemSnapshot, ) -> Result<(), String> { let slot = free_slot(ctx, player_guid)?; - // Trade allocates before deleting either side's outgoing rows: otherwise an emptied inventory - // can re-mint a guid deleted earlier in the same transaction and the item relay sees UPDATE - // instead of CREATE. Mail can safely allocate at insertion time. let guid = match preallocated_guid { Some(guid) => guid, None => next_item_guid(ctx)?, @@ -360,14 +303,11 @@ pub(crate) fn store_instance_state( owner_identity, owner_guid: player_guid, slot, - // Clamped to the template's own stack cap: the snapshot is durable data that outlives a - // template edit, and a row above the cap would render as an impossible stack. + // Snapshots can outlive template edits, including a lower stack cap. stack_count: snapshot.stack_count.max(1).min(tmpl.max_stack.max(1)), durability: snapshot.durability, created_at: ctx.timestamp, enchant_id: snapshot.enchant_id, - // The recorded bind state, ORed with the template's grant-time rule — `store_item`'s - // expression exactly, so an arriving item cannot end up less bound than a granted one. soulbound: snapshot.soulbound || binds_on_grant(tmpl.bonding), random_property_id: snapshot.random_property_id, item_text_id: snapshot.item_text_id, @@ -375,15 +315,7 @@ pub(crate) fn store_instance_state( Ok(()) } -/// Store one letter granted by a Letter Copy: a fresh `item_entry` instance carrying -/// `item_text_id` in `ITEM_FIELD_ITEM_TEXT_ID`. `free_slot` is both the capacity Gate and the slot -/// choice — the same one every other item grant uses, so a letter and a mail take can never -/// disagree about whether there is room. `Err(INVENTORY_FULL)` on a full bag, unchanged. -/// -/// Idempotent by text id: a retry reaching this after an earlier call already granted the same -/// `item_text_id` (a resent packet, or a Gateway that never learned its own driven call landed) is -/// a no-op success, not a second letter. The owner-guid index keeps the check O(the player's own -/// items), never a whole-table scan. +// Retry identity is item_text_id, so a lost reply cannot duplicate a Letter Copy. pub(crate) fn grant_letter_item( ctx: &ReducerContext, player_guid: u64, @@ -418,7 +350,7 @@ pub(crate) fn grant_letter_item( stack_count: 1, durability: tmpl.max_durability, created_at: ctx.timestamp, - enchant_id: 0, // a freshly minted letter — unenchanted + enchant_id: 0, soulbound: binds_on_grant(tmpl.bonding), random_property_id: 0, item_text_id, @@ -426,7 +358,6 @@ pub(crate) fn grant_letter_item( Ok(()) } -/// Carried quantity of `item_entry` for collect-quest completion and turn-in. pub(crate) fn item_count(ctx: &ReducerContext, owner_guid: u64, item_entry: u32) -> u32 { lyracore_shared::item::carried_quantity( item_entry, @@ -438,8 +369,7 @@ pub(crate) fn item_count(ctx: &ReducerContext, owner_guid: u64, item_entry: u32) ) } -/// Remove exactly `count` carried units, lowest slot first. Missing quantity leaves every item -/// unchanged, even when the caller retains the Refusal and continues its reducer. +// A caught Refusal must leave every stack unchanged. pub(crate) fn remove_items( ctx: &ReducerContext, owner_guid: u64, @@ -450,7 +380,6 @@ pub(crate) fn remove_items( let mut stacks: Vec = instances .by_owner_guid() .filter(&owner_guid) - // A turn-in consumes carried stacks only; the bank is never silently emptied. .filter(|i| is_carried_slot(i.slot) && i.entry == item_entry) .collect(); let original_counts: Vec = stacks.iter().map(|item| item.stack_count).collect(); @@ -487,8 +416,7 @@ pub(crate) fn carried_items(ctx: &ReducerContext, owner_guid: u64) -> Vec Option { (tmpl.spellid_1 != 0 && tmpl.spelltrigger_1 == 0).then_some(tmpl.spellid_1) } -/// Recall items, mounts and hostile summon quest items remain in the bag after use. -/// The effect kind is the authority; the quest item is also needed for retries. fn spell_keeps_item(ctx: &ReducerContext, spell_id: u32) -> bool { let reusable_effect = ctx .db @@ -531,22 +453,10 @@ fn spell_keeps_item(ctx: &ReducerContext, spell_id: u32) -> bool { reusable_effect || crate::mount::spell_is_mount(ctx, spell_id) } -/// Is `entry` a BANDAGE AND is its cooldown debuff (`RECENTLY_BANDAGED_SPELL`) currently live on the -/// user? The pure half of the re-bandage gate (the `has_aura` presence is the ctx half) — split out so -/// the "a bandage is cooldown-gated, other consumables are not" decision is unit-testable without a ctx. -/// BOTH bandage entries (real items 1251 Linen + 2581 Heavy Linen) are gated — in vanilla ALL bandages -/// share the single "Recently Bandaged" lockout (Gate B), so a player can't alternate 1251/2581 to bypass -/// the window. Every other consumable returns false (no cooldown), so the gate is a no-op for the -/// potion/food/water (baseline-safe). fn bandage_cooldown_blocks(entry: u32, has_cooldown_debuff: bool) -> bool { matches!(entry, 1251 | 2581) && has_cooldown_debuff } -/// Is `player_guid`'s class a MANA class? (class lives on `game_character`, not the live entity). Gates -/// every POWER-restoring consumable — the legacy drink branch (water without an on-use spell) AND Gate A -/// for the new mapped energize consumables (mana potion / drink). A non-mana class (Warrior/Rogue) using a -/// power consumable would otherwise preload rage/energy out of combat (the reducer-validation-sweep -/// exploit). A missing character row fails closed (treated as non-mana). [entity] fn is_mana_class(ctx: &ReducerContext, player_guid: u64) -> bool { ctx.db .game_character() @@ -559,10 +469,6 @@ fn is_mana_class(ctx: &ReducerContext, player_guid: u64) -> bool { .unwrap_or(false) } -/// Does the on-use `spell_id` RESTORE POWER (an `E_ENERGIZE` instant or an `A_PERIODIC_ENERGIZE` drink)? -/// The pure-data half of Gate A: a power-restoring consumable is gated to mana classes (`is_mana_class`). -/// Reads `game_spell_effect` by the `by_spell` index — `true` if ANY effect is an energize kind. A heal / -/// HoT / food / buff has no energize effect → `false` → ungated (class-agnostic). [entity] fn spell_restores_power(ctx: &ReducerContext, spell_id: u32) -> bool { ctx.db .game_spell_effect() @@ -571,14 +477,6 @@ fn spell_restores_power(ctx: &ReducerContext, spell_id: u32) -> bool { .any(|e| e.kind == crate::spell::E_ENERGIZE || e.kind == crate::spell::A_PERIODIC_ENERGIZE) } -/// Shared use-an-item logic for the player + debug paths: resolve the item -> run its gates -> consume -/// one unit (unless it's a non-consuming recall item) -> `begin_cast` its `spellid_1`. `spellid_1` -/// (trigger 0) is the SINGLE on-use authority — this retired the old three-mechanism `apply_item_use` -/// (a hardcoded Hearthstone special case, the `USE_EFFECTS` shadow-map, and a legacy vital-restore -/// fallback with its own duplicated mana gate): every usable item, consumable or not (a potion, a -/// bandage, the Engineering bomb, the Hearthstone), now resolves through ONE lookup and ONE cast path. -/// Errors if the user is dead, the slot is empty, the template is missing, or the item carries no on-use -/// spell. Additive — touches only the item row(s) + whatever `begin_cast`'s effect list touches. [entity] pub(crate) fn apply_item_use( ctx: &ReducerContext, player_guid: u64, @@ -601,7 +499,6 @@ pub(crate) fn apply_item_use( if crate::trade::item_is_offered(ctx, player_guid, inst.guid) { return Err(ItemRefusal::NotRightNow); } - // A banked item must be taken out before it can be used — the bank is not a second action bar. if !is_carried_slot(inst.slot) { return Err(refuse( ItemRefusal::ItemNotUsable, @@ -619,48 +516,33 @@ pub(crate) fn apply_item_use( format!("no template for item entry {}", inst.entry), ) })?; - // spellid_1 (trigger 0) is the item's on-use spell — the single authority now. No mapped - // spell means nothing to "use" (a plain reagent, a piece of gear, an un-migrated item). let spell_id = use_spell_for(&tmpl).ok_or_else(|| { refuse( ItemRefusal::ItemNotUsable, format!("item {} is not consumable", inst.entry), ) })?; - // Required-level gate: a too-high item can sit in the bag, but can't be USED. Seeded items are - // required_level 1, so this never trips for the loadout. - // Vanilla's level result carries the required level in the packet, and a Refusal tag has no - // payload, so a level Gate reads as the generic "can never use that item". + // The level-specific packet needs a level payload, which ItemRefusal cannot carry. if !meets_required_level(player.level, tmpl.required_level) { return Err(refuse( ItemRefusal::ItemNotUsable, format!("requires level {}", tmpl.required_level), )); } - // RE-BANDAGE GATE (vanilla "Recently Bandaged"): refuse the BANDAGE while its cooldown debuff is - // live — BEFORE consuming the item or casting, so a blocked re-bandage costs nothing. `has_aura` - // keys on the existing 11196 marker (applied by the bandage's E_TRIGGER eff1). Only the bandage is - // gated; every other consumable skips it (pure `bandage_cooldown_blocks` decides). if bandage_cooldown_blocks( inst.entry, crate::spell::has_aura(ctx, player_guid, RECENTLY_BANDAGED_SPELL), ) { return Err(refuse(ItemRefusal::NotRightNow, "Recently Bandaged")); } - // GATE A (the one surviving mana-class gate, retired its legacy-branch duplicate): a mana potion - // / drink restores POWER through E_ENERGIZE / A_PERIODIC_ENERGIZE — for a non-mana class that is - // rage/energy, the out-of-combat preload exploit. `begin_cast` has no power-class gate of its own, so - // DENY a power-restoring on-use spell here, BEFORE consuming/casting, when the user isn't a mana - // class. Only energize spells are gated (`spell_restores_power`); a heal/HoT/food/buff/recall is - // class-agnostic (Warriors can quaff a healing potion / eat / hearth). + // Energize restores rage or energy on non-mana classes, allowing out-of-combat preloading. if spell_restores_power(ctx, spell_id) && !is_mana_class(ctx, player_guid) { return Err(refuse( ItemRefusal::ItemNotUsable, "only mana users can use that", )); } - // Reusable spells keep the item. Other casts consume one unit on admission; - // a Refusal from begin_cast rolls back this inventory change with the Durable Request. + // Propagate cast Refusals so the reducer rolls back item consumption. if !spell_keeps_item(ctx, spell_id) { if inst.stack_count > 1 { inst.stack_count -= 1; @@ -669,9 +551,6 @@ pub(crate) fn apply_item_use( instances.guid().delete(inst.guid); } } - // Cast at SELF. `begin_cast` runs the full effect list, emits the cast-START/GO events the gateway - // relays, and routes channeled, timed, and instant casts from the spell header. No - // per-item branching here. crate::spell::begin_cast( ctx, player_guid, @@ -684,7 +563,6 @@ pub(crate) fn apply_item_use( .map_err(|detail| refuse(ItemRefusal::NotRightNow, detail)) } -/// Project owned item rows for the shared resistance fold. pub(crate) fn resistance_items( ctx: &ReducerContext, owner_guid: u64, @@ -726,7 +604,6 @@ pub(crate) fn resistance_items( }) } -/// One item's template, applied enchantment, and Random Property contribution to a Stat Kind. fn item_stat_bonus_for_school( ctx: &ReducerContext, item: &ItemInstance, @@ -793,12 +670,10 @@ fn equipped_stat_bonus_for_school( ) } -/// Sum one Stat Kind across working items in the equipment region. pub(crate) fn equipped_stat_bonus(ctx: &ReducerContext, owner_guid: u64, which: EquipStat) -> i32 { equipped_stat_bonus_for_school(ctx, owner_guid, which, u32::MAX) } -/// The school-filtered form used by spell power. Effects with a zero school mask apply to every school. pub(crate) fn equipped_school_stat_bonus( ctx: &ReducerContext, owner_guid: u64, @@ -808,23 +683,14 @@ pub(crate) fn equipped_school_stat_bonus( equipped_stat_bonus_for_school(ctx, owner_guid, which, u32::from(school_mask)) } -/// The pure per-item durability-loss formula `apply_death_durability_loss` applies to each equipped -/// piece: 10% of MAX durability (rounded down, at least 1), saturating so it never wraps below 0. -/// Returns the item UNCHANGED (no loss) when it has no durability concept (`max_durability == 0`) or is -/// already broken (`durability == 0`) — matching the live gate that skips both cases. Extracted from -/// `apply_death_durability_loss` (pure code-motion) so the formula is unit-tested without a live module. pub(crate) fn death_durability_loss(max_durability: u32, durability: u32) -> u32 { if max_durability == 0 || durability == 0 { - return durability; // no durability concept, or already broken — untouched + return durability; } - let loss = (max_durability / 10).max(1); // 10% of max, at least 1 + let loss = (max_durability / 10).max(1); durability.saturating_sub(loss) } -/// On a player's DEATH, every equipped item with a durability concept loses 10% of its MAX durability -/// (rounded down, at least 1) — vanilla's dominant durability sink, on top of the slow per-swing wear -/// (`combat::wear_weapon`). Saturates at 0 (a broken item gives unarmed/no-stats until repaired). -/// Player-death only — creatures don't wear gear. Collect-then-mutate (don't write the table mid-scan). pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64) { let instances = ctx.db.game_item_instance(); let templates = ctx.db.game_item_template(); @@ -844,16 +710,6 @@ pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64 } } -/// Typed take-an-item-from-a-corpse request for Package callers and the legacy player adapter -/// (`CMSG_AUTOSTORE_LOOT_ITEM`): -/// move one `game_corpse_loot` item into the looter's backpack. Validates the looter is in world and -/// alive, the corpse exists / is dead / is on the same map; resolves the loot row at `loot_slot`, mints -/// a fresh owned `ItemInstance` in the first free backpack slot, then DELETES the consumed loot row. -/// Re-derives the corpse `LOOTABLE` flag after consuming (`loot::refresh_lootable` — cleared only -/// when no rows remain AND money is 0). Errors if the looter is dead, the corpse is -/// missing/alive/on another map, the Loot Tag excludes the looter, the slot has no loot, the item -/// template is missing, or the backpack is full. Creature corpses clone quest rows to their resolved -/// eligibility set. GameObjects retain their current-group clone behavior. [entity] pub(crate) fn request_take_loot( ctx: &ReducerContext, player_guid: u64, @@ -864,17 +720,12 @@ pub(crate) fn request_take_loot( let player = entities.guid().find(player_guid).ok_or_else(|| { ActionRefusal::new(ActionRefusalKind::MissingActor, "looter not in world") })?; - // Death is server-authoritative everywhere — a dead/ghost looter can't take loot (mirrors loot_money). if player.dead { return Err(ActionRefusal::new( ActionRefusalKind::DeadActor, "dead players cannot loot", )); } - // The loot source is EITHER a dead creature corpse (game_world_entity) OR a used gameobject - // (game_gameobject — a chest, whose `use_gameobject` rolled rows into game_corpse_loot keyed on its - // guid). Resolve the source position for the range gate; a missing/alive corpse or an unknown guid - // is rejected. The dead-creature branch is byte-identical to before. let (src_map, src_instance, sx, sy, sz, source_is_corpse) = if let Some(corpse) = entities.guid().find(corpse_guid) { if !corpse.dead { @@ -923,9 +774,6 @@ pub(crate) fn request_take_loot( "loot in another instance", )); } - // Range gate (anti-exploit), mirroring the money path `loot_money` — a client can't autostore a - // corpse's item from across the map. Shares the same `LOOT_RANGE_SQ` (10 yd)² so the loot paths - // never drift. let (dx, dy, dz) = (sx - player.x, sy - player.y, sz - player.z); if dx * dx + dy * dy + dz * dz > crate::loot::LOOT_RANGE_SQ { return Err(ActionRefusal::new( @@ -933,7 +781,6 @@ pub(crate) fn request_take_loot( "loot out of range", )); } - // The specific loot-window row the client asked for. `by_corpse` then match the slot index. let loot = ctx.db.game_corpse_loot(); let row = loot .by_corpse() @@ -942,11 +789,6 @@ pub(crate) fn request_take_loot( .ok_or_else(|| { ActionRefusal::new(ActionRefusalKind::MissingTarget, "no loot in that slot") })?; - // Quest-only rows: the TAKER's OWN need is re-validated server-side (the - // gateway's window is a display hint, not authoritative) — an unreserved row (`reserved_for == 0`, - // the shared row nobody has split yet) is claimable by anyone who currently needs it; an already - // per-member-reserved clone is claimable ONLY by its reserved owner. A non-quest row (the common - // case) skips this entirely — unconditional FFA, byte-identical to before. if row.quest_only { let needs = crate::loot::killer_needs_item(ctx, Some(player_guid), row.item_entry); if !crate::loot::quest_take_allowed(row.reserved_for, player_guid, needs) { @@ -966,10 +808,6 @@ pub(crate) fn request_take_loot( row.designated_looter_guid, player_guid, ) { - // Group loot methods: a NEED/GREED winner-locked row - // (`reserved_for`), a MASTER-only row, or a round-robin/below-threshold row designated to - // someone else all reject the plain autostore path here — server-authoritative, the gateway's - // per-viewer loot-window filter (`reads.rs`) is a display hint only. return Err(ActionRefusal::new( ActionRefusalKind::CannotAct, if row.master_only { @@ -990,9 +828,7 @@ pub(crate) fn request_take_loot( format!("no template for item entry {}", row.item_entry), ) })?; - // Auto-store with stacking (parity): a looted stackable tops up a matching partial stack first, - // then spills into free slots (visible only to its owner via owner_identity). `?` so an inventory-full - // loot rolls back and the loot row stays for a retry. + // Grant before deleting the loot row; failed storage leaves it available for retry. store_item_typed( ctx, player_guid, @@ -1002,13 +838,9 @@ pub(crate) fn request_take_loot( false, Some(row.random_property_id), )?; - // Consume the loot: remove the row so a second take can't dupe it. let (item_entry, count) = (row.item_entry, row.count.max(1)); let (was_quest_only, was_unreserved) = (row.quest_only, row.reserved_for == 0); loot.id().delete(row.id); - // The first take of a shared quest-only row grants the other still-needing recipients their own - // reserved clone. Creature corpses use their resolved eligibility set. GameObjects keep the - // existing current-group calculation. if was_quest_only && was_unreserved { if source_is_corpse { crate::loot::clone_quest_loot_for_eligible( @@ -1030,12 +862,9 @@ pub(crate) fn request_take_loot( ); } } - // The flag follows the rule (rows remain OR money > 0) — taking the LAST item of a no-money - // corpse must drop the loot cursor. Gameobject sources carry no dynamic flag; skip them. if source_is_corpse { crate::loot::refresh_lootable(ctx, corpse_guid); } - // Notify-hook: one item stack looted (player + debug loot both route through this core). crate::hooks::fire_on_loot( ctx, &crate::hooks::LootPayload { @@ -1076,35 +905,23 @@ mod tests { assert!(profile_item_target_admitted(200).is_ok()); } - /// `use_spell_for`: `spellid_1` is the on-use spell IFF it's nonzero AND `spelltrigger_1` names - /// the on-use trigger slot (0 — `ItemSpellTriggerType::OnUse`). A trigger-1 (on-equip) spell is NOT - /// an on-use cast even with a nonzero id, and a zero `spellid_1` (a plain reagent/gear piece/ - /// un-migrated item) resolves to `None` regardless of its trigger byte. Replaces the old hardcoded - /// `USE_EFFECTS` shadow-map drift guard — now there is nothing to drift, `spellid_1` IS the data. #[test] fn use_spell_for_reads_spellid_1_gated_on_the_on_use_trigger() { let mut tmpl = crate::seed::base_item(1, "x"); tmpl.spellid_1 = 50110; tmpl.spelltrigger_1 = 0; - assert_eq!(use_spell_for(&tmpl), Some(50110)); // nonzero + trigger 0 -> the on-use cast + assert_eq!(use_spell_for(&tmpl), Some(50110)); tmpl.spellid_1 = 0; - assert_eq!(use_spell_for(&tmpl), None); // no spell at all + assert_eq!(use_spell_for(&tmpl), None); tmpl.spellid_1 = 50110; - tmpl.spelltrigger_1 = 1; // trigger 1 = on-equip, NOT on-use + tmpl.spelltrigger_1 = 1; assert_eq!(use_spell_for(&tmpl), None); } - /// The re-bandage cooldown gate (the pure half — `has_aura` is the ctx half): a BANDAGE use is blocked - /// ONLY while the "Recently Bandaged" (11196) debuff is live; once it expires the bandage is usable - /// again. NO other consumable is cooldown-gated — a potion/food use is never blocked, even if a stray - /// 11196 aura were present (only the bandage entry gates). This is the load-bearing anti-spam gate. #[test] fn bandage_cooldown_gate_blocks_only_the_bandage_while_debuff_is_live() { - // BOTH bandages (real items 1251 Linen + 2581 Heavy Linen) are blocked iff the cooldown debuff is up - // and usable once it has expired — Gate B: vanilla shares ONE "Recently Bandaged" lockout across all - // bandages, so 1251/2581 can't be alternated to bypass the window. assert!( bandage_cooldown_blocks(1251, true), "re-bandage blocked while Recently Bandaged is live" @@ -1121,23 +938,18 @@ mod tests { !bandage_cooldown_blocks(2581, false), "rank-2 bandage usable once the cooldown expired" ); - // Potion (real 118) and food (real 2681) are NEVER cooldown-gated — the gate is bandage-only. assert!(!bandage_cooldown_blocks(118, true)); assert!(!bandage_cooldown_blocks(2681, true)); - // The gate keys on the real seeded debuff id (a drift guard if 11196 ever moves). assert_eq!(RECENTLY_BANDAGED_SPELL, 11196); } - /// DEATH DURABILITY LOSS: 10% of MAX durability, rounded down, floored at 1 — and saturating (never - /// wraps below 0). A 0-durability item (already broken) or a no-durability-concept template - /// (`max_durability == 0`) is left UNTOUCHED, matching the live gate that skips both. #[test] fn death_durability_loss_is_ten_percent_of_max_floored_at_one_and_saturating() { - assert_eq!(death_durability_loss(100, 100), 90); // 10% of 100 = 10 - assert_eq!(death_durability_loss(10, 10), 9); // 10% of 10 = 1 (exact) - assert_eq!(death_durability_loss(5, 5), 4); // 10% of 5 floors to 0 -> floored up to 1 - assert_eq!(death_durability_loss(100, 5), 0); // loss (10) saturates at 0, never wraps - assert_eq!(death_durability_loss(100, 0), 0); // already broken -> untouched - assert_eq!(death_durability_loss(0, 50), 50); // no durability concept -> untouched + assert_eq!(death_durability_loss(100, 100), 90); + assert_eq!(death_durability_loss(10, 10), 9); + assert_eq!(death_durability_loss(5, 5), 4); + assert_eq!(death_durability_loss(100, 5), 0); + assert_eq!(death_durability_loss(100, 0), 0); + assert_eq!(death_durability_loss(0, 50), 50); } } diff --git a/module/src/items/properties.rs b/module/src/items/properties.rs index 7bcf567cf..561f92287 100644 --- a/module/src/items/properties.rs +++ b/module/src/items/properties.rs @@ -1,5 +1,3 @@ -//! Random Property selection and the shared enchantment contribution to equipped stats. - use super::{game_item_template, EquipStat, ItemTemplate}; use lyracore_shared::item_property as kind; use spacetimedb::{table, ReducerContext, Table}; @@ -35,8 +33,6 @@ pub struct ItemPropertyWeight { pub weight: u32, } -/// Stable property-ID order, normalized to the total integer weight. Band endpoints round up. -/// Input is in 0..10000. Empty pools, zero IDs, duplicate IDs and zero weights are invalid. fn property_pick(mut members: Vec<(u32, u32)>, roll: u32) -> Option { if roll >= 10_000 || members.is_empty() { return None; @@ -104,8 +100,7 @@ pub(crate) fn enchant_stat(ctx: &ReducerContext, enchant_id: u32, stat_kind: u8) enchant_stat_for_school(ctx, enchant_id, stat_kind, u32::MAX) } -/// Sum one Stat Kind from an enchantment when its school applies to the requested spell. A zero -/// `school_mask` is universal. Imported school-specific spell power carries its exact school bits. +// A zero school_mask applies to every school. pub(crate) fn enchant_stat_for_school( ctx: &ReducerContext, enchant_id: u32, diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index 9fe0216b8..512ae31fe 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -1,24 +1,10 @@ -//! Pure equip/economy taxonomy + arithmetic — the ctx-free item rules. Equip-slot/invtype vocabulary, -//! the type→slot resolver and equip predicates, the level gate, the vendor/merge arithmetic, and the -//! `EquipStat` projection. All unit-testable on plain values without a live module; the pure -//! taxonomy/arithmetic tests live at the bottom of this file. -//! -//! `consumable_heal` — the flat-plus-item-level HP formula that stood in for a real on-use effect id — -//! is RETIRED: every on-use item now carries a real `spellid_1` cast through `begin_cast` -//! (`items::ops::apply_item_use`), so the magnitude lives in `game_spell_effect.base_points` like any -//! other spell, not in a second, item-only formula. - use super::tables::ItemTemplate; -/// Whether a Classic class/race eligibility mask names `id`. IDs are one-based in the source -/// format, so class/race 1 is bit 0. A zero durable mask is malformed/restrictive and fails -/// closed; only the importer may normalize the source unrestricted sentinel. +// Class/race IDs are one-based bit indices; a zero durable mask grants no eligibility. pub(crate) fn eligibility_mask_allows(mask: u32, id: u8) -> bool { id != 0 && (id as u32) <= u32::BITS && mask & (1u32 << (id - 1)) != 0 } -/// Whether a character's optional skill row meets an item's requirement. A zero skill id has no -/// gate; every non-zero id needs a durable row at or above the required rank. pub(crate) fn meets_required_skill( required_skill: u32, required_skill_rank: u32, @@ -28,8 +14,6 @@ pub(crate) fn meets_required_skill( || current_skill.is_some_and(|current| u32::from(current) >= required_skill_rank) } -/// Whether a character's standing meets an item's reputation requirement. Missing durable -/// reputation rows use the Module's established Neutral (zero-standing) semantics. pub(crate) fn meets_required_reputation( required_faction: u32, required_rank: u32, @@ -39,54 +23,31 @@ pub(crate) fn meets_required_reputation( || u32::from(crate::reputation::reputation_rank(standing.unwrap_or(0))) >= required_rank } -/// Gold a vendor pays for a stack: the per-unit `sell_price` times the stack count (cmangos sells a -/// whole stack at once). Pure — unit-tested. Saturating so a pathological count never wraps the copper -/// total (the credit itself is also saturating on the player's money). A 0 `sell_price` is the -/// "no vendor value" sentinel handled by the caller, not here. pub(crate) fn sell_value(unit_price: u32, count: u32) -> u32 { unit_price.saturating_mul(count) } -/// Copper a vendor charges for a buy: the per-unit `buy_price` times the count (cmangos charges -/// `buy_price * count`). Pure — unit-tested. Saturating so a pathological count never wraps the copper -/// total; the affordability check then compares against the player's money. A 0 `buy_price` is the -/// "no vendor value" sentinel rejected by the caller (an item the vendor never sells), not here. pub(crate) fn buy_cost(unit_price: u32, count: u32) -> u32 { unit_price.saturating_mul(count) } -/// Copper to repair ONE item from `durability` back to `max_durability`: 1 copper per restored point. -/// Pure — unit-tested. A simple, correct proxy for vanilla's DBC DurabilityCost table (which we don't -/// import): monotonic in damage, zero when undamaged, saturating. `repair_all` sums this over the body. +// Repair currently costs one copper per point; DBC DurabilityCost is not imported. pub(crate) fn repair_cost(max_durability: u32, durability: u32) -> u32 { max_durability.saturating_sub(durability) } -/// How many units move from a source stack onto a destination stack of the SAME item: as many as the -/// source has, but never more than the destination's remaining headroom (`max_stack - dst`). Pure — -/// unit-tested. `saturating_sub` keeps an already-full (or over-full) destination at 0 headroom rather -/// than wrapping. The merge caller uses this for the move-onto-same-stackable case (FEATURE B). [entity] pub(crate) fn merge_amount(src: u32, dst: u32, max_stack: u32) -> u32 { src.min(max_stack.saturating_sub(dst)) } -/// Whether an item may be equipped in the MAIN-HAND slot (15). Pure — unit-tested. Gates only the -/// main-hand: the item must be a Weapon (`class == 2`) whose `inventory_type` is a main-hand-capable -/// equip type — 13 = INVTYPE_WEAPON (one-hand), 17 = INVTYPE_2HWEAPON, 21 = INVTYPE_WEAPONMAINHAND. -/// Anything else (food, armor, an off-hand-only weapon type) is rejected, so you can't equip e.g. -/// Tough Jerky in the weapon slot. The other 18 equipment slots are validated by `can_equip_into` / -/// `inventory_type_to_slot`, which this function backs for main-hand specifically. [reference] pub(crate) fn can_equip_mainhand(class: u8, inventory_type: u8) -> bool { class == 2 && matches!(inventory_type, 13 | 17 | 21) } -/// The cmangos `item_template.bonding` values — item binding taxonomy. Only NONE, BIND_ON_PICKUP -/// (BoP) and BIND_ON_EQUIP (BoE) drive a binding trigger today; BIND_ON_USE and the two quest-item -/// codes are recognized but not yet wired to any binding trigger in the pipeline. [reference] pub mod bonding { pub const NONE: u8 = 0; - pub const BIND_ON_PICKUP: u8 = 1; // BoP — binds the instant it's granted (quest reward, loot, starter kit, vendor buy) - pub const BIND_ON_EQUIP: u8 = 2; // BoE — binds the first time it's equipped + pub const BIND_ON_PICKUP: u8 = 1; + pub const BIND_ON_EQUIP: u8 = 2; #[cfg(test)] pub const BIND_ON_USE: u8 = 3; #[cfg(test)] @@ -95,10 +56,6 @@ pub mod bonding { pub const QUEST_ITEM2: u8 = 5; } -/// Does a template with this `bonding` value bind THE INSTANT it's granted (any `store_item`/ -/// `grant_starter_item` insert — quest reward, loot, starter kit, vendor buy, buyback)? Pure — -/// unit-tested. Only BIND_ON_PICKUP (BoP) does; everything else (unbound, BoE, BoU, quest items) -/// does not bind at grant time. [reference] pub(crate) fn binds_on_grant(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_PICKUP } @@ -107,11 +64,6 @@ pub(crate) fn binds_on_equip(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_EQUIP } -/// The 19 vanilla `EQUIPMENT_SLOT_*` ordinals (0..=18) — the equipment region of the player inventory -/// that renders gear on the 3D model (`PLAYER_VISIBLE_ITEM[slot]`). The main-hand value matches -/// `starter_item::MAINHAND_SLOT` (15) — kept in lockstep with the constants crate. The "pair" slots -/// (fingers 10/11, trinkets 12/13) are the FIRST of their pair; the equip path lands in the first free -/// one of the pair (the second is `+1`). cmangos `EquipmentSlots`. [reference] pub mod equip_slot { pub const HEAD: u8 = 0; pub const NECK: u8 = 1; @@ -123,15 +75,13 @@ pub mod equip_slot { pub const FEET: u8 = 7; pub const WRISTS: u8 = 8; pub const HANDS: u8 = 9; - pub const FINGER1: u8 = 10; // FINGER2 == 11 (the pair's second slot) - pub const TRINKET1: u8 = 12; // TRINKET2 == 13 (the pair's second slot) + pub const FINGER1: u8 = 10; // FINGER2 is slot 11. + pub const TRINKET1: u8 = 12; // TRINKET2 is slot 13. pub const BACK: u8 = 14; pub const MAINHAND: u8 = 15; pub const OFFHAND: u8 = 16; pub const RANGED: u8 = 17; pub const TABARD: u8 = 18; - /// Inclusive upper bound of the equipment region (TABARD). Slots 0..=18 are equipment; 19..=22 are - /// equipped-bag slots; 23.. is the backpack — only 0..=18 are model-visible / validated as equip. pub const END: u8 = TABARD; } @@ -141,7 +91,7 @@ pub mod invtype { pub const HEAD: u8 = 1; pub const NECK: u8 = 2; pub const SHOULDERS: u8 = 3; - pub const BODY: u8 = 4; // shirt + pub const BODY: u8 = 4; pub const CHEST: u8 = 5; pub const WAIST: u8 = 6; pub const LEGS: u8 = 7; @@ -150,31 +100,21 @@ pub mod invtype { pub const HANDS: u8 = 10; pub const FINGER: u8 = 11; pub const TRINKET: u8 = 12; - pub const WEAPON: u8 = 13; // one-hand (either hand) + pub const WEAPON: u8 = 13; // One-handed weapon, either hand. pub const SHIELD: u8 = 14; - pub const RANGED: u8 = 15; // bow - pub const CLOAK: u8 = 16; // back + pub const RANGED: u8 = 15; + pub const CLOAK: u8 = 16; pub const TWO_HAND_WEAPON: u8 = 17; - /// `INVTYPE_BAG` (18) — a container/bag item. Equips into bag slots 19..=22 (NOT equipment 0..=18). - /// `inventory_type_to_slot` returns `None` for this type (bags are NOT equipment); the equip path - /// routes bag items to `first_free_bag_equip_slot` instead of `resolve_equip_slot`. [reference] pub const BAG: u8 = 18; pub const TABARD: u8 = 19; - pub const ROBE: u8 = 20; // chest (cloth robe) + pub const ROBE: u8 = 20; pub const WEAPON_MAINHAND: u8 = 21; pub const WEAPON_OFFHAND: u8 = 22; - pub const HOLDABLE: u8 = 23; // off-hand frill / tome - pub const THROWN: u8 = 25; // ranged - pub const RANGED_RIGHT: u8 = 26; // gun / wand → ranged + pub const HOLDABLE: u8 = 23; + pub const THROWN: u8 = 25; + pub const RANGED_RIGHT: u8 = 26; // Gun or wand. } -/// Map an item's `inventory_type` to the single canonical `EQUIPMENT_SLOT_*` ordinal it equips into, -/// or `None` if the type isn't equippable (e.g. `INVTYPE_NON_EQUIP` for food/junk). Pure — unit-tested. -/// Covers all 19 equipment slots. The "pair" types (finger/trinket) return the FIRST slot of their -/// pair (10/12); the equip path then picks the first -/// FREE slot of the pair. 2H + main-hand-only weapons and a plain one-hand all map to MAINHAND (15); -/// off-hand weapon / shield / holdable map to OFFHAND (16); bow/gun/thrown/wand map to RANGED (17). -/// The vanilla first-free-slot rule, as a pure type→slot lookup of our own. [reference] pub(crate) fn inventory_type_to_slot(inventory_type: u8) -> Option { use equip_slot as e; use invtype as t; @@ -183,29 +123,23 @@ pub(crate) fn inventory_type_to_slot(inventory_type: u8) -> Option { t::NECK => e::NECK, t::SHOULDERS => e::SHOULDERS, t::BODY => e::SHIRT, - t::CHEST | t::ROBE => e::CHEST, // a cloth robe wears in the chest slot + t::CHEST | t::ROBE => e::CHEST, t::WAIST => e::WAIST, t::LEGS => e::LEGS, t::FEET => e::FEET, t::WRISTS => e::WRISTS, t::HANDS => e::HANDS, - t::FINGER => e::FINGER1, // first of the finger pair (10/11) - t::TRINKET => e::TRINKET1, // first of the trinket pair (12/13) + t::FINGER => e::FINGER1, + t::TRINKET => e::TRINKET1, t::CLOAK => e::BACK, - // Main hand: a plain one-hand, a two-hander, or a main-hand-only weapon. t::WEAPON | t::TWO_HAND_WEAPON | t::WEAPON_MAINHAND => e::MAINHAND, - // Off hand: an off-hand-only weapon, a shield, or a holdable frill/tome. t::WEAPON_OFFHAND | t::SHIELD | t::HOLDABLE => e::OFFHAND, - // Ranged: a bow, a gun/wand (RANGED_RIGHT), or a thrown weapon. t::RANGED | t::RANGED_RIGHT | t::THROWN => e::RANGED, t::TABARD => e::TABARD, - _ => return None, // NON_EQUIP and anything unrecognized isn't equippable + _ => return None, }) } -/// The pair partner of a dual-slot equipment ordinal: FINGER1↔FINGER2 (10/11), TRINKET1↔TRINKET2 -/// (12/13). `None` for every single-occupancy slot. Pure — unit-tested. Lets the equip path place a -/// ring/trinket in the first FREE slot of its pair (vanilla equips into ring-2 when ring-1 is taken). pub(crate) fn paired_equip_slot(slot: u8) -> Option { use equip_slot as e; match slot { @@ -215,20 +149,6 @@ pub(crate) fn paired_equip_slot(slot: u8) -> Option { } } -/// Whether moving an item with `inventory_type` (of item `class`) INTO equipment `dest_slot` is a valid -/// equip. Pure — unit-tested. The main-hand slot (15) defers to `can_equip_mainhand` exactly: only a -/// class==2 weapon of invtype 13/17/21 equips there, so a class-0 food in invtype 21 is rejected. For -/// the dual-slot pairs the SECOND slot (finger-2 11, trinket-2 13) accepts the same type as its first. -/// Every other equipment slot accepts an item whose `inventory_type_to_slot` resolves to it. A -/// non-equipment `dest_slot` (>18) is not an equip move and returns `false` here (the caller only -/// consults this for equipment destinations). -/// -/// `can_dual_wield`: when `true` (the caster knows Dual Wield, spell 674), a plain one-hander -/// (`INVTYPE_WEAPON`, 13 — the ONLY type `can_equip_mainhand` accepts besides the main-hand-only/2H -/// types, which stay main-hand-only) is ALSO accepted at OFFHAND (16), on top of its existing MAINHAND -/// acceptance below. When `false`, a one-hander is only accepted at MAINHAND (matching -/// `inventory_type_to_slot`'s WEAPON→MAINHAND mapping) — baseline-safe for every caller that hasn't -/// threaded Dual Wield knowledge. [reference] pub(crate) fn can_equip_into( class: u8, inventory_type: u8, @@ -236,15 +156,13 @@ pub(crate) fn can_equip_into( can_dual_wield: bool, ) -> bool { use equip_slot as e; - // Main-hand defers to the stricter `can_equip_mainhand` gate (weapon class + specific invtypes). if dest_slot == e::MAINHAND { return can_equip_mainhand(class, inventory_type); } if dest_slot == e::OFFHAND && can_dual_wield && class == 2 && inventory_type == invtype::WEAPON { - return true; // a one-hander may ALSO land in the off-hand when Dual Wield is known + return true; } - // The second slot of a pair accepts whatever resolves to the pair's first slot. let resolved = match inventory_type_to_slot(inventory_type) { Some(s) => s, None => return false, @@ -261,16 +179,10 @@ pub(crate) fn can_equip_into( pub use lyracore_shared::item::armor_subclass; pub use lyracore_shared::item::{weapon_subclass, Proficiency}; -/// Whether a character of `player_level` meets an item's `required_level` to EQUIP or USE it. Pure — -/// unit-tested. A required_level of 1 (every seeded item today) is met by every character, so this gate -/// is a no-op for the existing loadout; only items SQL-raised above the character level are rejected. -/// Vanilla "requires level N" — you can carry a too-high item in the bag, you just can't equip/use it. pub(crate) fn meets_required_level(player_level: u32, required_level: u8) -> bool { player_level >= required_level as u32 } -/// A Stat Kind summed across a unit's working equipped items. The typed projection keeps callers from -/// passing raw imported codes and makes every supported family explicit. [reference] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum EquipStat { Strength, @@ -300,11 +212,6 @@ pub(crate) enum EquipStat { Block, } -/// This item template's contribution to one `EquipStat` — the single typed column the variant names. -/// Pure (no ctx): the per-template projection `equipped_stat_bonus` sums across a unit's worn pieces, so -/// the stat-sum arithmetic is unit-testable on a plain `ItemTemplate` without a live module. A column the -/// item doesn't carry is 0 (every starter/loadout template, e.g. entry 25, is all-zero → contributes 0, -/// baseline-safe). [reference] pub(crate) fn template_stat(tmpl: &ItemTemplate, which: EquipStat) -> i32 { match which { EquipStat::Strength => tmpl.stat_strength, @@ -335,21 +242,6 @@ pub(crate) fn template_stat(tmpl: &ItemTemplate, which: EquipStat) -> i32 { } } -/// The equipment slot an item in `from_slot` should EQUIP into, given its `inventory_type` and the -/// player's current equipped set — the auto-resolve vanilla performs on a right-click-equip. Pure over -/// the (resolved-slot, occupied-set) inputs so it's unit-testable. Returns `None` if the item isn't -/// equippable. For a dual-slot pair (finger/trinket): if the FIRST slot is free it goes there, else the -/// SECOND; if both are taken it falls back to the first (an equip into an occupied slot SWAPS the -/// resident back into `from_slot`, same as a manual move). Single-occupancy slots always return their -/// one slot. The `occupied` predicate reports whether a given equip slot currently holds an item. -/// -/// `can_dual_wield`: when `true` AND `inventory_type` is a plain one-hander (`INVTYPE_WEAPON`, 13, -/// which normally resolves ONLY to MAINHAND) AND main-hand is already occupied AND off-hand is free, -/// this redirects the second one-hander to OFFHAND instead of falling through to the MAINHAND swap — -/// so a Rogue who has learned Dual Wield and right-click-equips a second one-hander lands it in the -/// off-hand, not swapping out the first weapon. `false` (or a 2H/main-hand-only weapon, or main-hand -/// free, or off-hand occupied) resolves to MAINHAND as usual — baseline-safe for every caller that -/// hasn't threaded Dual Wield knowledge. pub(crate) fn resolve_equip_slot( inventory_type: u8, can_dual_wield: bool, @@ -364,7 +256,6 @@ pub(crate) fn resolve_equip_slot( { return Some(equip_slot::OFFHAND); } - // For a pair, prefer the first free of the two; otherwise the canonical (first) slot. if let Some(second) = paired_equip_slot(primary) { if !occupied(primary) { return Some(primary); @@ -381,79 +272,66 @@ pub(crate) mod tests { use super::*; use crate::items::tables::ItemTemplate; - /// The class CEILING: what a class can equip with both level-40 armor upgrades trained. The - /// equip Gate asks the stricter `Proficiency::from_spellbook` question; these cases pin the - /// class table itself, which training never changes. fn can_equip_proficiency(player_class: u8, item_class: u8, item_subclass: u8) -> bool { Proficiency::class_ceiling(player_class).can_equip(item_class, item_subclass) } #[test] fn sell_value_multiplies_by_stack_count() { - assert_eq!(sell_value(0, 5), 0); // a 0-priced (valueless) item is worth nothing per the caller's guard - assert_eq!(sell_value(2, 5), 10); // 5 units at 2 copper each = 10 copper - assert_eq!(sell_value(u32::MAX, 2), u32::MAX); // saturates instead of wrapping the copper total + assert_eq!(sell_value(0, 5), 0); + assert_eq!(sell_value(2, 5), 10); + assert_eq!(sell_value(u32::MAX, 2), u32::MAX); } #[test] fn buy_cost_multiplies_by_count() { - assert_eq!(buy_cost(0, 5), 0); // a 0-priced (unbuyable) item is rejected by the caller, costs nothing here - assert_eq!(buy_cost(35, 2), 70); // 2 units at 35 copper each = 70 copper - assert_eq!(buy_cost(u32::MAX, 2), u32::MAX); // saturates instead of wrapping the copper total + assert_eq!(buy_cost(0, 5), 0); + assert_eq!(buy_cost(35, 2), 70); + assert_eq!(buy_cost(u32::MAX, 2), u32::MAX); } #[test] fn repair_cost_is_one_copper_per_restored_point() { - assert_eq!(repair_cost(100, 100), 0); // undamaged → free (button greyed in vanilla) - assert_eq!(repair_cost(100, 0), 100); // fully broken → full max_durability in copper - assert_eq!(repair_cost(100, 60), 40); // 40 points of damage → 40 copper - assert_eq!(repair_cost(0, 0), 0); // a no-durability item never costs anything - assert_eq!(repair_cost(50, 80), 0); // saturating: durability above max never wraps + assert_eq!(repair_cost(100, 100), 0); + assert_eq!(repair_cost(100, 0), 100); + assert_eq!(repair_cost(100, 60), 40); + assert_eq!(repair_cost(0, 0), 0); + assert_eq!(repair_cost(50, 80), 0); } #[test] fn merge_amount_caps_at_destination_headroom() { - // The whole source fits under max_stack → all 3 move (src empties, then deletes). assert_eq!(merge_amount(3, 2, 20), 3); - // Source larger than headroom (20 - 10 = 10) → capped at 10, leaving a remainder in src. assert_eq!(merge_amount(15, 10, 20), 10); - // Destination already full → zero headroom → nothing moves (no-op, no swap). assert_eq!(merge_amount(5, 20, 20), 0); - // An over-full destination still yields 0 (saturating_sub never wraps). assert_eq!(merge_amount(5, 25, 20), 0); } #[test] fn can_equip_mainhand_only_accepts_mainhand_weapon_types() { - // A Weapon (class 2) in a main-hand-capable inventory_type equips. - assert!(can_equip_mainhand(2, 21)); // INVTYPE_WEAPONMAINHAND - assert!(can_equip_mainhand(2, 13)); // INVTYPE_WEAPON (one-hand) - assert!(can_equip_mainhand(2, 17)); // INVTYPE_2HWEAPON - // Non-weapon classes are rejected even with a weapon-shaped inventory_type. - assert!(!can_equip_mainhand(0, 21)); // class 0 = Consumable (food) - assert!(!can_equip_mainhand(4, 21)); // class 4 = Armor - // A weapon with a non-main-hand inventory_type (5 = INVTYPE_CHEST) is rejected. + assert!(can_equip_mainhand(2, 21)); + assert!(can_equip_mainhand(2, 13)); + assert!(can_equip_mainhand(2, 17)); + assert!(!can_equip_mainhand(0, 21)); + assert!(!can_equip_mainhand(4, 21)); assert!(!can_equip_mainhand(2, 5)); } #[test] fn meets_required_level_gates_below_requirement_only() { - assert!(meets_required_level(1, 1)); // level 1 meets a required_level 1 item (every seeded item) - assert!(meets_required_level(5, 1)); // a higher-level character meets a low requirement - assert!(!meets_required_level(1, 5)); // a level-1 character can't equip/use a level-5 item - assert!(meets_required_level(5, 5)); // exactly meeting the requirement is allowed + assert!(meets_required_level(1, 1)); + assert!(meets_required_level(5, 1)); + assert!(!meets_required_level(1, 5)); + assert!(meets_required_level(5, 5)); } #[test] fn explicit_equip_requirements_fail_closed_and_admit_qualified_characters() { - // Class/race ids are one-based bits. A malformed durable zero mask never becomes universal. assert!(eligibility_mask_allows(1 << 7, 8)); assert!(!eligibility_mask_allows(1 << 7, 9)); assert!(!eligibility_mask_allows(0, 8)); assert!(!eligibility_mask_allows(u32::MAX, 0)); - // Vanilla class ids skip 6 and 10; the unrestricted sentinel must admit all nine playable - // classes, Druid (11) included, and set no bit for the unused ids. for class_id in [1u8, 2, 3, 4, 5, 7, 8, 9, 11] { assert!(eligibility_mask_allows( crate::items::tables::ALL_PLAYABLE_CLASS_MASK, @@ -469,13 +347,11 @@ pub(crate) mod tests { 10 )); - // A required skill needs both a durable row and enough current rank. assert!(!meets_required_skill(171, 50, None)); assert!(!meets_required_skill(171, 50, Some(49))); assert!(meets_required_skill(171, 50, Some(50))); assert!(meets_required_skill(0, 50, None)); - // Missing reputation uses Neutral (standing 0, rank 3), not a faction base standing. assert!(!meets_required_reputation(72, 4, None)); assert!(!meets_required_reputation(72, 4, Some(2_999))); assert!(meets_required_reputation(72, 4, Some(3_000))); @@ -486,56 +362,49 @@ pub(crate) mod tests { fn inventory_type_to_slot_maps_each_invtype_to_its_equipment_slot() { use equip_slot as e; use invtype as t; - // Armor / apparel. - assert_eq!(inventory_type_to_slot(t::HEAD), Some(e::HEAD)); // 1 -> 0 - assert_eq!(inventory_type_to_slot(t::NECK), Some(e::NECK)); // 2 -> 1 - assert_eq!(inventory_type_to_slot(t::SHOULDERS), Some(e::SHOULDERS)); // 3 -> 2 - assert_eq!(inventory_type_to_slot(t::BODY), Some(e::SHIRT)); // 4 -> 3 (shirt) - assert_eq!(inventory_type_to_slot(t::CHEST), Some(e::CHEST)); // 5 -> 4 - assert_eq!(inventory_type_to_slot(t::ROBE), Some(e::CHEST)); // 20 (robe) -> 4 (chest) - assert_eq!(inventory_type_to_slot(t::WAIST), Some(e::WAIST)); // 6 -> 5 - assert_eq!(inventory_type_to_slot(t::LEGS), Some(e::LEGS)); // 7 -> 6 - assert_eq!(inventory_type_to_slot(t::FEET), Some(e::FEET)); // 8 -> 7 - assert_eq!(inventory_type_to_slot(t::WRISTS), Some(e::WRISTS)); // 9 -> 8 - assert_eq!(inventory_type_to_slot(t::HANDS), Some(e::HANDS)); // 10 -> 9 - assert_eq!(inventory_type_to_slot(t::FINGER), Some(e::FINGER1)); // 11 -> 10 (first of pair) - assert_eq!(inventory_type_to_slot(t::TRINKET), Some(e::TRINKET1)); // 12 -> 12 (first of pair) - assert_eq!(inventory_type_to_slot(t::CLOAK), Some(e::BACK)); // 16 -> 14 - assert_eq!(inventory_type_to_slot(t::TABARD), Some(e::TABARD)); // 19 -> 18 - // Weapons → main hand (1H, 2H, main-hand-only all land in 15). - assert_eq!(inventory_type_to_slot(t::WEAPON), Some(e::MAINHAND)); // 13 -> 15 + assert_eq!(inventory_type_to_slot(t::HEAD), Some(e::HEAD)); + assert_eq!(inventory_type_to_slot(t::NECK), Some(e::NECK)); + assert_eq!(inventory_type_to_slot(t::SHOULDERS), Some(e::SHOULDERS)); + assert_eq!(inventory_type_to_slot(t::BODY), Some(e::SHIRT)); + assert_eq!(inventory_type_to_slot(t::CHEST), Some(e::CHEST)); + assert_eq!(inventory_type_to_slot(t::ROBE), Some(e::CHEST)); + assert_eq!(inventory_type_to_slot(t::WAIST), Some(e::WAIST)); + assert_eq!(inventory_type_to_slot(t::LEGS), Some(e::LEGS)); + assert_eq!(inventory_type_to_slot(t::FEET), Some(e::FEET)); + assert_eq!(inventory_type_to_slot(t::WRISTS), Some(e::WRISTS)); + assert_eq!(inventory_type_to_slot(t::HANDS), Some(e::HANDS)); + assert_eq!(inventory_type_to_slot(t::FINGER), Some(e::FINGER1)); + assert_eq!(inventory_type_to_slot(t::TRINKET), Some(e::TRINKET1)); + assert_eq!(inventory_type_to_slot(t::CLOAK), Some(e::BACK)); + assert_eq!(inventory_type_to_slot(t::TABARD), Some(e::TABARD)); + assert_eq!(inventory_type_to_slot(t::WEAPON), Some(e::MAINHAND)); assert_eq!( inventory_type_to_slot(t::TWO_HAND_WEAPON), Some(e::MAINHAND) - ); // 17 -> 15 + ); assert_eq!( inventory_type_to_slot(t::WEAPON_MAINHAND), Some(e::MAINHAND) - ); // 21 -> 15 - // Off hand → 16 (off-hand weapon, shield, holdable). - assert_eq!(inventory_type_to_slot(t::WEAPON_OFFHAND), Some(e::OFFHAND)); // 22 -> 16 - assert_eq!(inventory_type_to_slot(t::SHIELD), Some(e::OFFHAND)); // 14 -> 16 - assert_eq!(inventory_type_to_slot(t::HOLDABLE), Some(e::OFFHAND)); // 23 -> 16 - // Ranged → 17 (bow, gun/wand, thrown). - assert_eq!(inventory_type_to_slot(t::RANGED), Some(e::RANGED)); // 15 -> 17 - assert_eq!(inventory_type_to_slot(t::RANGED_RIGHT), Some(e::RANGED)); // 26 -> 17 - assert_eq!(inventory_type_to_slot(t::THROWN), Some(e::RANGED)); // 25 -> 17 - // Non-equippable types map to no slot. - assert_eq!(inventory_type_to_slot(t::NON_EQUIP), None); // 0 (food/junk) - assert_eq!(inventory_type_to_slot(99), None); // unrecognized + ); + assert_eq!(inventory_type_to_slot(t::WEAPON_OFFHAND), Some(e::OFFHAND)); + assert_eq!(inventory_type_to_slot(t::SHIELD), Some(e::OFFHAND)); + assert_eq!(inventory_type_to_slot(t::HOLDABLE), Some(e::OFFHAND)); + assert_eq!(inventory_type_to_slot(t::RANGED), Some(e::RANGED)); + assert_eq!(inventory_type_to_slot(t::RANGED_RIGHT), Some(e::RANGED)); + assert_eq!(inventory_type_to_slot(t::THROWN), Some(e::RANGED)); + assert_eq!(inventory_type_to_slot(t::NON_EQUIP), None); + assert_eq!(inventory_type_to_slot(99), None); } #[test] fn can_equip_into_mainhand_is_byte_identical_to_can_equip_mainhand() { use equip_slot::MAINHAND; - // Every case `can_equip_mainhand` accepts/rejects must be unchanged at slot 15. - assert!(can_equip_into(2, 21, MAINHAND, false)); // weapon main-hand - assert!(can_equip_into(2, 13, MAINHAND, false)); // one-hand - assert!(can_equip_into(2, 17, MAINHAND, false)); // two-hand - assert!(!can_equip_into(0, 21, MAINHAND, false)); // food in invtype 21 still rejected (class check) - assert!(!can_equip_into(4, 21, MAINHAND, false)); // armor still rejected at main-hand - assert!(!can_equip_into(2, 5, MAINHAND, false)); // a chest-invtype weapon rejected at main-hand - // Cross-check: identical to the original predicate across the same inputs. + assert!(can_equip_into(2, 21, MAINHAND, false)); + assert!(can_equip_into(2, 13, MAINHAND, false)); + assert!(can_equip_into(2, 17, MAINHAND, false)); + assert!(!can_equip_into(0, 21, MAINHAND, false)); + assert!(!can_equip_into(4, 21, MAINHAND, false)); + assert!(!can_equip_into(2, 5, MAINHAND, false)); for &(c, it) in &[(2u8, 21u8), (2, 13), (2, 17), (0, 21), (4, 21), (2, 5)] { assert_eq!( can_equip_into(c, it, MAINHAND, false), @@ -548,42 +417,28 @@ pub(crate) mod tests { fn can_equip_into_validates_armor_and_offhand_and_rejects_mismatch() { use equip_slot as e; use invtype as t; - // A helm (class 4 Armor, INVTYPE_HEAD) equips into HEAD but NOT into CHEST. assert!(can_equip_into(4, t::HEAD, e::HEAD, false)); assert!(!can_equip_into(4, t::HEAD, e::CHEST, false)); - // A shield lands in OFFHAND (16), not main-hand or chest. assert!(can_equip_into(4, t::SHIELD, e::OFFHAND, false)); assert!(!can_equip_into(4, t::SHIELD, e::MAINHAND, false)); - // A robe (cloth chest) equips into CHEST. assert!(can_equip_into(4, t::ROBE, e::CHEST, false)); - // The SECOND ring slot (11) accepts a finger item (first-of-pair resolves to 10). - assert!(can_equip_into(4, t::FINGER, e::FINGER1, false)); // 10 - assert!(can_equip_into(4, t::FINGER, e::FINGER1 + 1, false)); // 11 (pair partner) - assert!(can_equip_into(4, t::TRINKET, e::TRINKET1 + 1, false)); // 13 (trinket-2) - // A ring does NOT fit a trinket slot or vice-versa. + assert!(can_equip_into(4, t::FINGER, e::FINGER1, false)); + assert!(can_equip_into(4, t::FINGER, e::FINGER1 + 1, false)); + assert!(can_equip_into(4, t::TRINKET, e::TRINKET1 + 1, false)); assert!(!can_equip_into(4, t::FINGER, e::TRINKET1, false)); - // A non-equippable (food) fits nowhere. assert!(!can_equip_into(0, t::NON_EQUIP, e::CHEST, false)); - // A non-equipment destination slot (>18) is never an equip. - assert!(!can_equip_into(4, t::HEAD, 23, false)); // backpack slot 0 + assert!(!can_equip_into(4, t::HEAD, 23, false)); } #[test] fn can_equip_into_offhand_only_accepts_one_hander_with_dual_wield() { use equip_slot as e; use invtype as t; - // A plain one-hander (class 2, INVTYPE_WEAPON) is REJECTED at OFFHAND without Dual Wield — it - // only resolves to MAINHAND. assert!(!can_equip_into(2, t::WEAPON, e::OFFHAND, false)); - // WITH Dual Wield known, the same one-hander IS accepted at OFFHAND. assert!(can_equip_into(2, t::WEAPON, e::OFFHAND, true)); - // A 2H weapon or main-hand-only weapon is NEVER accepted at OFFHAND, even with Dual Wield — - // only the plain one-hander (INVTYPE_WEAPON, 13) gets the off-hand carve-out. assert!(!can_equip_into(2, t::TWO_HAND_WEAPON, e::OFFHAND, true)); assert!(!can_equip_into(2, t::WEAPON_MAINHAND, e::OFFHAND, true)); - // A non-weapon class is never accepted at OFFHAND via the dual-wield carve-out either. assert!(!can_equip_into(4, t::WEAPON, e::OFFHAND, true)); - // MAINHAND acceptance is unaffected by the flag either way. assert!(can_equip_into(2, t::WEAPON, e::MAINHAND, true)); assert!(can_equip_into(2, t::WEAPON, e::MAINHAND, false)); } @@ -591,16 +446,13 @@ pub(crate) mod tests { #[test] fn paired_equip_slot_only_for_finger_and_trinket() { use equip_slot as e; - assert_eq!(paired_equip_slot(e::FINGER1), Some(e::FINGER1 + 1)); // 10 -> 11 - assert_eq!(paired_equip_slot(e::TRINKET1), Some(e::TRINKET1 + 1)); // 12 -> 13 - assert_eq!(paired_equip_slot(e::HEAD), None); // single-occupancy slots have no pair + assert_eq!(paired_equip_slot(e::FINGER1), Some(e::FINGER1 + 1)); + assert_eq!(paired_equip_slot(e::TRINKET1), Some(e::TRINKET1 + 1)); + assert_eq!(paired_equip_slot(e::HEAD), None); assert_eq!(paired_equip_slot(e::MAINHAND), None); - assert_eq!(paired_equip_slot(e::FINGER1 + 1), None); // the second slot isn't itself a pair head + assert_eq!(paired_equip_slot(e::FINGER1 + 1), None); } - /// A bare `ItemTemplate` for the stat-sum tests: zero everything except the stat columns the test - /// sets via the returned mutable handle. Keeps the pure stat arithmetic testable without a module. - // pub(crate): reused by `trade::tests` — the one 100-field template fixture. pub(crate) fn blank_template(entry: u32) -> ItemTemplate { ItemTemplate { entry, @@ -690,7 +542,6 @@ pub(crate) mod tests { assert_eq!(template_stat(&t, EquipStat::FrostResistance), 4); assert_eq!(template_stat(&t, EquipStat::ShadowResistance), 5); assert_eq!(template_stat(&t, EquipStat::ArcaneResistance), 6); - // An untouched column is 0 — the starter/loadout templates carry no stats, so they contribute 0. assert_eq!(template_stat(&t, EquipStat::Stamina), 0); assert_eq!(template_stat(&blank_template(25), EquipStat::Strength), 0); } @@ -700,10 +551,8 @@ pub(crate) mod tests { let sum = |pieces: &[ItemTemplate], which: EquipStat| -> i32 { pieces.iter().map(|t| template_stat(t, which)).sum() }; - // No gear (a creature or an unequipped player) → 0 for every stat (byte-identical readout). assert_eq!(sum(&[], EquipStat::Strength), 0); assert_eq!(sum(&[], EquipStat::Armor), 0); - // Two pieces: a +10 STR chest and a +5 STR / +20 AGI / +50 armor item sum per-stat independently. let mut chest = blank_template(2001); chest.stat_strength = 10; let mut ring = blank_template(2002); @@ -711,11 +560,10 @@ pub(crate) mod tests { ring.stat_agility = 20; ring.stat_armor = 50; let worn = [chest, ring]; - assert_eq!(sum(&worn, EquipStat::Strength), 15); // 10 + 5 - assert_eq!(sum(&worn, EquipStat::Agility), 20); // 0 + 20 - assert_eq!(sum(&worn, EquipStat::Armor), 50); // 0 + 50 - assert_eq!(sum(&worn, EquipStat::Crit), 0); // neither piece carries crit - // All-zero templates (the existing loadout, e.g. entry 25) contribute nothing → baseline-safe. + assert_eq!(sum(&worn, EquipStat::Strength), 15); + assert_eq!(sum(&worn, EquipStat::Agility), 20); + assert_eq!(sum(&worn, EquipStat::Armor), 50); + assert_eq!(sum(&worn, EquipStat::Crit), 0); let plain = [blank_template(25), blank_template(51)]; assert_eq!(sum(&plain, EquipStat::Strength), 0); assert_eq!(sum(&plain, EquipStat::Hit), 0); @@ -725,33 +573,25 @@ pub(crate) mod tests { fn resolve_equip_slot_picks_first_free_of_a_pair() { use equip_slot as e; use invtype as t; - // Single slot: always its one slot, regardless of occupancy. assert_eq!(resolve_equip_slot(t::HEAD, false, |_| false), Some(e::HEAD)); assert_eq!(resolve_equip_slot(t::HEAD, false, |_| true), Some(e::HEAD)); - // Ring, both free → first of pair (10). assert_eq!( resolve_equip_slot(t::FINGER, false, |_| false), Some(e::FINGER1) ); - // Ring, first taken → second (11). assert_eq!( resolve_equip_slot(t::FINGER, false, |s| s == e::FINGER1), Some(e::FINGER1 + 1) ); - // Ring, both taken → falls back to the first (a swap into ring-1). assert_eq!( resolve_equip_slot(t::FINGER, false, |_| true), Some(e::FINGER1) ); - // Trinket pair behaves the same. assert_eq!( resolve_equip_slot(t::TRINKET, false, |s| s == e::TRINKET1), Some(e::TRINKET1 + 1) ); - // A non-equippable resolves to no slot. assert_eq!(resolve_equip_slot(t::NON_EQUIP, false, |_| false), None); - // Weapon (one-hander), main-hand free → resolves to MAINHAND regardless of the dual-wield flag - // (nothing to redirect from yet). assert_eq!( resolve_equip_slot(t::WEAPON, true, |_| false), Some(e::MAINHAND) @@ -766,25 +606,18 @@ pub(crate) mod tests { fn resolve_equip_slot_routes_second_one_hander_to_offhand_with_dual_wield() { use equip_slot as e; use invtype as t; - // Main-hand occupied, off-hand free, Dual Wield known → the second one-hander goes to OFFHAND. assert_eq!( resolve_equip_slot(t::WEAPON, true, |s| s == e::MAINHAND), Some(e::OFFHAND) ); - // Same occupancy WITHOUT Dual Wield → falls through to a MAINHAND swap (baseline-safe). assert_eq!( resolve_equip_slot(t::WEAPON, false, |s| s == e::MAINHAND), Some(e::MAINHAND) ); - // Main-hand occupied AND off-hand occupied, even with Dual Wield → no free off-hand to redirect - // into, falls back to MAINHAND (a swap). assert_eq!( resolve_equip_slot(t::WEAPON, true, |_| true), Some(e::MAINHAND) ); - // A 2H weapon is NEVER redirected to OFFHAND even with Dual Wield known (primary != MAINHAND - // guard doesn't apply here since 2H DOES resolve to MAINHAND, but it should still just swap, - // never split into the off-hand slot). assert_eq!( resolve_equip_slot(t::TWO_HAND_WEAPON, true, |s| s == e::MAINHAND), Some(e::MAINHAND) @@ -793,27 +626,22 @@ pub(crate) mod tests { #[test] fn proficiency_mage_cloth_only() { - // Mage (class 8) may equip cloth (subclass 1) and misc accessories (subclass 0). - assert!(can_equip_proficiency(8, 4, 0)); // misc — ring/neck/trinket/cloak - assert!(can_equip_proficiency(8, 4, 1)); // cloth - // Mage cannot equip leather, mail, or plate. - assert!(!can_equip_proficiency(8, 4, 2)); // leather - assert!(!can_equip_proficiency(8, 4, 3)); // mail - assert!(!can_equip_proficiency(8, 4, 4)); // plate — THE MAIN REGRESSION - assert!(!can_equip_proficiency(8, 4, 6)); // shield + assert!(can_equip_proficiency(8, 4, 0)); + assert!(can_equip_proficiency(8, 4, 1)); + assert!(!can_equip_proficiency(8, 4, 2)); + assert!(!can_equip_proficiency(8, 4, 3)); + assert!(!can_equip_proficiency(8, 4, 4)); + assert!(!can_equip_proficiency(8, 4, 6)); } #[test] fn proficiency_warlock_cloth_only() { - assert!(can_equip_proficiency(9, 4, 0)); // misc - assert!(can_equip_proficiency(9, 4, 1)); // cloth - assert!(!can_equip_proficiency(9, 4, 2)); // leather - assert!(!can_equip_proficiency(9, 4, 4)); // plate + assert!(can_equip_proficiency(9, 4, 0)); + assert!(can_equip_proficiency(9, 4, 1)); + assert!(!can_equip_proficiency(9, 4, 2)); + assert!(!can_equip_proficiency(9, 4, 4)); } - /// EQUIP GATE, PLATE: plate is a level-40 trainer purchase, not a class birthright. The Gate - /// reads no level at all here — a Warrior at 1 and at 60 are refused alike until spell 750 is in - /// the spellbook, and mail is wearable from the first level either way. #[test] fn plate_needs_the_trained_passive_whatever_the_level() { use armor_subclass as a; @@ -832,7 +660,6 @@ pub(crate) mod tests { } } - /// EQUIP GATE, MAIL: Hunter and Shaman start in leather and buy the mail upgrade (8737) at 40. #[test] fn hunters_and_shamans_need_the_trained_passive_for_mail() { use armor_subclass as a; @@ -840,14 +667,10 @@ pub(crate) mod tests { for class in [3u8, 7] { assert!(!Proficiency::derive(class, false, false).can_equip(ARMOR, a::MAIL)); assert!(Proficiency::derive(class, false, true).can_equip(ARMOR, a::MAIL)); - // The plate flag is not theirs to use. assert!(!Proficiency::derive(class, true, true).can_equip(ARMOR, a::PLATE)); } } - /// Proficiency gates the BODY only. Every non-equip destination — bags, bank, trade, mail, - /// vendor purchases — reaches its mutation without this rule, which is why the Gate lives in - /// `apply_item_move`'s equipment branch alone. #[test] fn proficiency_leaves_non_equippable_item_classes_alone() { let untrained_warrior = Proficiency::derive(1, false, false); @@ -861,77 +684,68 @@ pub(crate) mod tests { #[test] fn proficiency_warrior_can_wear_plate() { - // The class CEILING (the auction "usable" filter's question): a Warrior's reachable tiers - // include plate once trained. The equip Gate asks the stricter question above. - assert!(can_equip_proficiency(1, 4, 1)); // cloth - assert!(can_equip_proficiency(1, 4, 2)); // leather - assert!(can_equip_proficiency(1, 4, 3)); // mail - assert!(can_equip_proficiency(1, 4, 4)); // plate — Warrior can equip this - assert!(can_equip_proficiency(1, 4, 6)); // shield + assert!(can_equip_proficiency(1, 4, 1)); + assert!(can_equip_proficiency(1, 4, 2)); + assert!(can_equip_proficiency(1, 4, 3)); + assert!(can_equip_proficiency(1, 4, 4)); + assert!(can_equip_proficiency(1, 4, 6)); } #[test] fn proficiency_paladin_can_wear_plate() { - assert!(can_equip_proficiency(2, 4, 4)); // plate - assert!(can_equip_proficiency(2, 4, 6)); // shield + assert!(can_equip_proficiency(2, 4, 4)); + assert!(can_equip_proficiency(2, 4, 6)); } #[test] fn proficiency_rogue_leather_only() { - assert!(can_equip_proficiency(4, 4, 1)); // cloth (universally allowed) - assert!(can_equip_proficiency(4, 4, 2)); // leather - assert!(!can_equip_proficiency(4, 4, 3)); // mail - assert!(!can_equip_proficiency(4, 4, 4)); // plate - assert!(!can_equip_proficiency(4, 4, 6)); // shield + assert!(can_equip_proficiency(4, 4, 1)); + assert!(can_equip_proficiency(4, 4, 2)); + assert!(!can_equip_proficiency(4, 4, 3)); + assert!(!can_equip_proficiency(4, 4, 4)); + assert!(!can_equip_proficiency(4, 4, 6)); } #[test] fn proficiency_druid_leather_only() { - assert!(can_equip_proficiency(11, 4, 2)); // leather - assert!(!can_equip_proficiency(11, 4, 3)); // mail - assert!(!can_equip_proficiency(11, 4, 6)); // shield + assert!(can_equip_proficiency(11, 4, 2)); + assert!(!can_equip_proficiency(11, 4, 3)); + assert!(!can_equip_proficiency(11, 4, 6)); } #[test] fn proficiency_hunter_leather_and_mail() { - assert!(can_equip_proficiency(3, 4, 2)); // leather - assert!(can_equip_proficiency(3, 4, 3)); // mail — reachable, trained at 40 - assert!(!can_equip_proficiency(3, 4, 4)); // plate - assert!(!can_equip_proficiency(3, 4, 6)); // shield + assert!(can_equip_proficiency(3, 4, 2)); + assert!(can_equip_proficiency(3, 4, 3)); + assert!(!can_equip_proficiency(3, 4, 4)); + assert!(!can_equip_proficiency(3, 4, 6)); } #[test] fn proficiency_shaman_leather_mail_shield() { - assert!(can_equip_proficiency(7, 4, 2)); // leather - assert!(can_equip_proficiency(7, 4, 3)); // mail - assert!(!can_equip_proficiency(7, 4, 4)); // plate - assert!(can_equip_proficiency(7, 4, 6)); // shield + assert!(can_equip_proficiency(7, 4, 2)); + assert!(can_equip_proficiency(7, 4, 3)); + assert!(!can_equip_proficiency(7, 4, 4)); + assert!(can_equip_proficiency(7, 4, 6)); } #[test] fn proficiency_non_armor_always_allowed() { - // Non-armor item classes (consumables, quest items, etc.) have no class restriction. - assert!(can_equip_proficiency(8, 0, 0)); // consumable - assert!(can_equip_proficiency(8, 7, 0)); // trade goods - assert!(can_equip_proficiency(9, 12, 0)); // quest item + assert!(can_equip_proficiency(8, 0, 0)); + assert!(can_equip_proficiency(8, 7, 0)); + assert!(can_equip_proficiency(9, 12, 0)); } #[test] fn proficiency_wand_only_for_caster_classes() { use weapon_subclass as w; - // Only Mage (8), Warlock (9), Priest (5) may use wands. assert!(can_equip_proficiency(8, 2, w::WAND)); assert!(can_equip_proficiency(9, 2, w::WAND)); assert!(can_equip_proficiency(5, 2, w::WAND)); - // Warrior and Rogue cannot use wands. assert!(!can_equip_proficiency(1, 2, w::WAND)); assert!(!can_equip_proficiency(4, 2, w::WAND)); } - /// Item binding: `binds_on_grant` is true ONLY for BoP; `binds_on_equip` is true ONLY for BoE. - /// Every other bonding value (unbound, BoU, both quest-item codes) binds at neither trigger today — - /// the pure taxonomy `ops::store_item`/`grant_starter_item` (grant) and `apply_item_move`'s equip - /// branch (equip) key off. #[test] fn binding_taxonomy_grant_vs_equip_triggers() { use bonding as b; diff --git a/module/src/items/tables.rs b/module/src/items/tables.rs index 3f50c58e3..bff99f4f4 100644 --- a/module/src/items/tables.rs +++ b/module/src/items/tables.rs @@ -1,40 +1,28 @@ -//! Item table definitions + RLS filter + the guid/slot primitives. The `game_item_template` static -//! definition, the per-player `game_item_instance` owned-item row, the owner-scoped RLS filter, and the -//! small pure-ish primitives every op builds on: guid composition/minting and the per-slot scans. - use spacetimedb::{table, Identity, ReducerContext, Table, Timestamp}; -/// The nine class bits playable by the 1.12 client. Class ids skip 6 and 10, so Druid (11) is bit -/// 0x400; the client hides the tooltip "Classes:" line only when every playable bit is set. -/// Item-template masks retain unknown bits from imported content; this value exists only to make -/// the source dump's unrestricted sentinel durable. +// Playable class IDs skip 6 and 10; Druid is bit 10. pub const ALL_PLAYABLE_CLASS_MASK: u32 = 0x5df; -/// The eight race bits playable by the 1.12 client. As with class masks, imported restrictions are -/// otherwise opaque `u32` values and are never reconstructed from local variants. pub const ALL_PLAYABLE_RACE_MASK: u32 = 0xff; -/// Static item definition (the cmangos `item_template` analogue) — hand-authored, public + read-only -/// to clients (answered via `CMSG_ITEM_QUERY_SINGLE`). A small subset of the ~130 vanilla -/// `item_template` columns: only the fields the slice's query response + bag render need. [reference] #[table(accessor = game_item_template, public)] pub struct ItemTemplate { #[primary_key] pub entry: u32, - pub class: u8, // item class — 2 = Weapon - pub subclass: u8, // weapon subclass — 7 = Sword (one-hand) + pub class: u8, + pub subclass: u8, pub name: String, - pub display_id: u32, // inventory-icon / model display id - pub quality: u8, // 0 = Poor (grey) - pub inventory_type: u8, // equip slot type — 21 = INVTYPE_WEAPONMAINHAND + pub display_id: u32, + pub quality: u8, + pub inventory_type: u8, pub item_level: u8, pub required_level: u8, pub max_durability: u32, - pub buy_price: u32, // copper - pub sell_price: u32, // copper - pub max_stack: u32, // 1 for a non-stackable weapon - pub damage_min: f32, // physical melee damage range (tooltip) + pub buy_price: u32, // Copper. + pub sell_price: u32, // Copper. + pub max_stack: u32, + pub damage_min: f32, pub damage_max: f32, - pub delay_ms: u32, // weapon swing speed (ms) + pub delay_ms: u32, #[default(0)] pub stat_strength: i32, @@ -47,30 +35,17 @@ pub struct ItemTemplate { #[default(0)] pub stat_spirit: i32, #[default(0)] - pub stat_crit: i32, // crit RATING in attack-table basis points (CRIT_BP += this) + pub stat_crit: i32, // Attack-table basis points. #[default(0)] - pub stat_hit: i32, // hit RATING in attack-table basis points (miss band -= this) + pub stat_hit: i32, // Attack-table basis points. #[default(0)] - pub stat_armor: i32, // bonus armor, added ON TOP of the agility*2 base (not double-counted) - /// SHIELD base block value: the flat physical damage a blocked melee swing absorbs (item-only — the - /// Strength term is added live in `combat::effective_block_value`). 0 for every non-shield template, - /// so a unit without a shield blocks nothing. END-appended + `#[default(0)]` → additive auto-migration. + pub stat_armor: i32, + // Item contribution only; combat::effective_block_value adds Strength. #[default(0)] pub block_value: i32, - /// A DRINK: this consumable restores POWER (mana) instead of HEALTH when used. Vanilla splits - /// food (E_HEAL) from drink (E_ENERGIZE) by the on-use spell, which we don't import — so the - /// importer derives this from the item's class/subclass + name. `apply_item_use` reads it to pick - /// health vs power; the restore AMOUNT reuses the same `consumable_heal(item_level)` formula. - /// END-appended + `#[default(false)]` → additive auto-migration (existing food rows stay food). [reference] #[default(false)] pub restores_power: bool, - /// On-use / on-equip spell slots 1-2 (vanilla items carry up to 5; slots 1-2 cover every 1-10 - /// consumable — food/drink/potion/scroll on-use + the common on-equip proc). Sent in - /// SMSG_ITEM_QUERY_SINGLE_RESPONSE so the CLIENT renders the green "Use:/Equip:/Chance on hit:" text - /// from its OWN Spell.dbc — we don't need the spell server-side (`restores_power` still drives the - /// actual food/drink effect). `spelltrigger` is the vanilla ItemSpellTriggerType (0=on-use, 1=on-equip, - /// 2=chance-on-hit). END-appended + `#[default(0)]` → additive auto-migration. Deliberate - /// simplification: add slots 3-5 only if an on-equip trinket at 1-10 needs them. [reference] + // Spell triggers: 0 = on use, 1 = on equip, 2 = chance on hit. #[default(0)] pub spellid_1: u32, #[default(0)] @@ -79,36 +54,13 @@ pub struct ItemTemplate { pub spellid_2: u32, #[default(0)] pub spelltrigger_2: u8, - /// Number of container slots this item provides when equipped in a bag slot (19..=22). - /// 0 = not a bag (every existing template → additive auto-migration baseline-safe). A bag with - /// N slots expands the player's inventory by N: `store_item` searches these after the 16-slot - /// backpack is full (see `first_free_bag_slot` in ops.rs). Vanilla max is 18 (Portable Hole). - /// END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] pub container_slots: u8, - /// Vanilla item_template.sheath — where the item visually stows when sheathed. Read by the - /// gateway item view and passed VERBATIM to `SMSG_ITEM_QUERY_SINGLE_RESPONSE.sheathe_type` - /// (cmangos sends `pProto->Sheath` the same way). The byte is an opaque client posture index — - /// gtker's `SheatheType` variant names do NOT describe the rendered posture, so trust dump - /// values, not names: Worn Shortsword (25) = 3, Dented Buckler (1166, shield) = 4, 0 = no stow. - /// END-appended + `#[default(0)]` → additive auto-migration. [reference] + // Preserve raw sheath values; gtker variant names do not describe the rendered posture. #[default(0)] pub sheath: u8, - /// Item binding: the cmangos `item_template.bonding` value — 0 = NoBind, 1 = Bind on Pickup (BoP), - /// 2 = Bind on Equip (BoE), 3 = Bind on Use, 4/5 = Quest Item (see [`super::rules::bonding`]). - /// Drives the client's "Binds when picked up/equipped" tooltip line via - /// `SMSG_ITEM_QUERY_SINGLE_RESPONSE.bonding`. Trade and mail enforcement are - /// out of scope here — this is the data model only. - /// END-appended + `#[default(0)]` → additive auto-migration (every existing template reads - /// NoBind, baseline-safe). [reference] #[default(0)] pub bonding: u8, - /// The 6 vanilla resistance schools (cmangos `item_template.holyres/fireres/natureres/frostres/ - /// shadowres/arcaneres`) — resist gear is dead weight until these land; the effective-resistance - /// combat math already exists and can fold these in once a consumer reads them. - /// DATA PLUMBING ONLY — no consumer reads these yet, so every existing template stays at 0 - /// (baseline-safe). i32 matching `stat_armor`'s sibling convention (a future negative - /// suffix-enchant fits). END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] pub holy_res: i32, #[default(0)] @@ -121,11 +73,6 @@ pub struct ItemTemplate { pub shadow_res: i32, #[default(0)] pub arcane_res: i32, - /// On-use/on-equip spell slots 3-5 (vanilla items carry up to 5; slots 1-2 already cover every - /// 1-10 consumable). Completes the proc engine's item half — no consumer reads these yet - /// (data plumbing only). Same shape as `spellid_1`/`spelltrigger_1`: `u32` id, - /// `u8` `ItemSpellTriggerType`. END-appended + `#[default(0)]` → additive auto-migration. - /// [reference] #[default(0)] pub spellid_3: u32, #[default(0)] @@ -138,24 +85,15 @@ pub struct ItemTemplate { pub spellid_5: u32, #[default(0)] pub spelltrigger_5: u8, - /// Weapon-skill proficiency gate (cmangos `RequiredSkill`/`RequiredSkillRank`) — mail/plate - /// proficiency + weapon-skill requirements beyond the class tables. Data - /// plumbing only, no consumer reads these yet (0 = no skill gate, baseline-safe). - /// END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] pub required_skill: u32, #[default(0)] pub required_skill_rank: u32, - /// Reputation gate (cmangos `RequiredReputationFaction`/`RequiredReputationRank`). - /// Data plumbing only, no consumer reads these yet (0 = no rep gate, - /// baseline-safe). END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] pub required_reputation_faction: u32, #[default(0)] pub required_reputation_rank: u32, - /// Unique-count cap (`maxcount`), item flags, readable-item page, quest starter, and bag family. - /// Storage enforces `max_count` across carried and banked copies. Zero means no limit. - /// END-appended defaults keep existing items unrestricted. [reference] + // Zero means unlimited; the cap includes carried and banked copies. #[default(0)] pub max_count: u32, #[default(0)] @@ -166,18 +104,12 @@ pub struct ItemTemplate { pub start_quest: u32, #[default(0)] pub bag_family: u32, - /// cmangos `BuyCount`: the stack a vendor hands over per purchase (water/food ×5, ammo - /// ×200). Defaulted 1 so un-reimported rows keep single-unit sales. END-appended + defaulted → - /// additive auto-migration. #[default(1u32)] pub buy_count: u32, - /// Vanilla item_template.FoodType. Zero is not pet food; 1..=8 select the matching bit in a - /// creature family's pet_food_mask. Kept at the end for additive schema migration. + // Zero means no pet food; 1..=8 select a bit in pet_food_mask. #[default(0)] pub food_type: u8, - /// Explicit class/race eligibility masks. The importer turns ClassicDB's `-1` unrestricted - /// sentinel into the complete playable masks before rows become durable; zero remains a - /// restrictive/malformed durable value. END-appended defaults make existing rows unrestricted. + // Import maps the source -1 sentinel to playable masks; durable zero remains restrictive. #[default(ALL_PLAYABLE_CLASS_MASK)] pub allowed_class: u32, #[default(ALL_PLAYABLE_RACE_MASK)] @@ -186,55 +118,35 @@ pub struct ItemTemplate { pub random_property: u32, } -/// A per-player owned item. Public but RLS-restricted to the owner (like `game_character`), so a -/// connection only ever sees its own items. The `guid` carries `HIGHGUID_ITEM` (0x4000) in its high -/// bits so the client routes it as an item. Timestamp-bearing → born only in [`grant_starter_item`] -/// at login (no SQL import path — see the no-Timestamp-literal rule). [entity] #[table(accessor = game_item_instance, public, index(accessor = by_owner_guid, btree(columns = [owner_guid])))] pub struct ItemInstance { #[primary_key] pub guid: u64, - pub entry: u32, // -> ItemTemplate.entry - pub owner_identity: Identity, // RLS scope: the connection that owns this item - pub owner_guid: u64, // the owning character's guid (ITEM_FIELD_OWNER / _CONTAINED) - pub slot: u8, // the player inventory slot it occupies (ItemSlot ordinal) + pub entry: u32, + pub owner_identity: Identity, + pub owner_guid: u64, + pub slot: u8, pub stack_count: u32, pub durability: u32, pub created_at: Timestamp, - /// Applied enchantment, resolved through the item enchantment catalogue. Zero means none. #[default(0)] pub enchant_id: u32, - /// Item binding state: true once this SPECIFIC instance has bound to its owner and cannot be - /// traded or mailed away (trade and mail enforcement gate on this - /// column). Set `true` at grant time for a BoP-templated item (quest reward, starter - /// kit, loot, vendor buy — any `store_item`/`grant_starter_item` insert) and on first EQUIP for a - /// BoE-templated item (`apply_item_move`'s equip-validation branch). A BoU/unbound-type template - /// never flips this today (binds-on-use is a future add). - /// END-appended + `#[default(false)]` → additive auto-migration (every existing item reads - /// unbound, baseline-safe). [entity] #[default(false)] pub soulbound: bool, #[default(0)] pub random_property_id: u32, - /// `ITEM_FIELD_ITEM_TEXT_ID` — nonzero on a Letter Copy's Plain Letter (the mail id narrowed - /// to u32, `lyracore_shared::mail::item_text_id_for`). Zero means the item carries no readable - /// text, true of every item that is not a copied letter. - /// END-appended + `#[default(0u32)]` → additive auto-migration (every existing item reads no - /// text, baseline-safe). + // ITEM_FIELD_ITEM_TEXT_ID; zero except on a Letter Copy. #[default(0u32)] pub item_text_id: u32, } -// Character-owned sweeps: items are deleted on character delete, re-owned (identity re-stamp) on a -// relog under a changed gateway identity. crate::character_owned!(delete, fn sweep_delete_game_item_instance(ctx, character_guid) { let items = ctx.db.game_item_instance(); for r in items.by_owner_guid().filter(&character_guid).collect::>() { items.guid().delete(r.guid); } }); -// Transfer preserves the item identity the client already knows. New items consume the Shard's -// GUID Range; import also checks collisions because legacy GUIDs can overlap. +// Transfer preserves item GUIDs; legacy formats still need collision checks. crate::character_owned!(transfer, fn sweep_transfer_game_item_instance(ctx, character_guid, io) { table = game_item_instance, by = by_owner_guid, @@ -250,9 +162,6 @@ crate::character_owned!(restamp, fn sweep_restamp_game_item_instance(ctx, charac } }); -/// The owner's item instance occupying inventory `slot`, or `None` if that slot is empty. A point scan -/// over the owner's rows (the inventory model has no per-slot index, so this matches each call site's -/// existing `by_owner_guid().filter(&owner).find(|i| i.slot == slot)` exactly). pub(crate) fn item_in_slot( ctx: &ReducerContext, owner_guid: u64, @@ -265,8 +174,6 @@ pub(crate) fn item_in_slot( .find(|i| i.slot == slot) } -/// Whether any of the owner's items occupies inventory `slot` — the boolean twin of [`item_in_slot`] -/// for the call sites that only need occupancy (an `.any(|i| i.slot == slot)` over the owner's rows). pub(crate) fn slot_occupied(ctx: &ReducerContext, owner_guid: u64, slot: u8) -> bool { ctx.db .game_item_instance() @@ -275,19 +182,10 @@ pub(crate) fn slot_occupied(ctx: &ReducerContext, owner_guid: u64, slot: u8) -> .any(|i| i.slot == slot) } -/// Whether an equipped item is BROKEN — it HAS a durability concept (`max_durability > 0`) AND has worn -/// to 0. A broken item grants none of its damage/stats until repaired (`apply_repair_item`). Items with -/// no durability concept (`max_durability == 0` — trinkets/consumables) are never broken. The single -/// source of the rule, shared by [`equipped_stat_bonus`] (stats) and `combat::equipped_weapon_damage` -/// (weapon damage) so the two readouts can never desync. pub(crate) fn item_is_broken(tmpl: &ItemTemplate, inst: &ItemInstance) -> bool { tmpl.max_durability > 0 && inst.durability == 0 } -/// One row of a creature's vendor inventory: vendor `creature_entry` sells `item_entry`. Loaded from -/// cmangos `npc_vendor` by the importer; read by the gateway to build `SMSG_LIST_INVENTORY` and by -/// `apply_buy_item` to gate purchases (you can only buy what a vendor *in range* actually stocks). -/// `slot` orders the window; `max_count` is the stock limit (0 = unlimited — the common case). [static] #[table(accessor = game_npc_vendor, public, index(accessor = by_vendor, btree(columns = [creature_entry])))] pub struct NpcVendor { #[primary_key] @@ -295,14 +193,10 @@ pub struct NpcVendor { pub id: u64, pub creature_entry: u32, pub item_entry: u32, - pub slot: u32, // display order in the vendor window - pub max_count: u32, // stock limit (0 = unlimited) + pub slot: u32, + pub max_count: u32, // Zero means unlimited stock. } -/// Per-player buyback ring — the last ≤12 items sold to any vendor. Newest = highest id; the client -/// numbers slots 69–81 (SLOT1 = 0-index 0 = most recent). On sell: oldest is evicted if at capacity, -/// new row inserted. On buyback: the player pays `price` copper and the item is granted back via -/// `store_item`. The gateway subscribes (coordinator) to build the vendor-window buyback tab. #[table(accessor = game_character_buyback, index(accessor = by_player_guid, btree(columns = [player_guid])))] pub struct BuybackEntry { #[primary_key] @@ -311,28 +205,21 @@ pub struct BuybackEntry { pub player_guid: u64, pub item_entry: u32, pub stack_count: u32, - pub price: u32, // copper paid at sell time = buy-back cost - // Per-instance bind state at sell time: without this, buyback would re-derive soulbound from the - // template alone (BoP-only), silently un-binding an already-bound BoE item on a sell→buyback round - // trip. END-appended + `#[default(false)]` → auto-migrates. + pub price: u32, // Copper paid at sell time. + // Buyback must preserve an already-bound BoE item independently of template binding. #[default(false)] pub soulbound: bool, #[default(0)] pub random_property_id: u32, } -// Character-owned sweep: the buyback ring is deleted (not re-owned) on character delete — there's no -// restamp counterpart for this one. crate::character_owned!(delete, fn sweep_delete_game_character_buyback(ctx, character_guid) { let buybacks = ctx.db.game_character_buyback(); for r in buybacks.by_player_guid().filter(&character_guid).collect::>() { buybacks.id().delete(r.id); } }); -// CROSS-DATABASE transport: the buyback ring is real value the player can still reclaim -// (they paid for the item once). Dropping it at a shard boundary is a silent refund window closing. -// `id` orders the ring (newest = highest), so re-minting preserves the order: `move_rows` inserts in -// export order, and export order is index order. +// Re-minted buyback IDs must preserve export order because IDs order the ring. crate::character_owned!(transfer, fn sweep_transfer_game_character_buyback(ctx, character_guid, io) { table = game_character_buyback, by = by_player_guid, diff --git a/module/src/lib.rs b/module/src/lib.rs index 3504cee5a..8ba7c8637 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -1,99 +1,3 @@ -//! SpacetimeDB module — **all** WoW 1.12 game state and logic. -//! -//! This is the thin index. The actual tables and reducers live in per-domain submodules — the -//! `mod` declarations below are the authoritative list of what exists (47 of them at last count, -//! from `action_bar` to `xp`); this doc comment doesn't duplicate that list because a hand-kept -//! copy of it only goes stale as the game grows. A few load-bearing ones to start from: `world` -//! is the live entity/movement relay, `combat`/`spell`/`items`/`quest`/`instance` are the bulk of -//! the gameplay logic, `transfer` is the escrowed cross-database character-transport subsystem, -//! and `seed` is the `init` lifecycle reducer that populates a fresh database. -//! -//! Named table accessors and row types are re-exported below for `crate::game_*` reads and -//! schema parity checks. Other items are reached through their owning Module path. -//! The `#[table(accessor = X)]` macro generates the trait that provides `ctx.db.X()`. -//! Scheduled tables are co-located with the -//! reducer they name (`scheduled(...)` resolves the reducer in its own module's scope). -//! -//! Built against SpacetimeDB 2.7.1 (`accessor =` table syntax; `ctx.sender()` / -//! `ctx.db.()`; RLS via the `unstable` `client_visibility_filter`). Categories, -//! visibility, and field-index annotations follow `docs/schema.md`. - -/// Marks a table's rows as keyed by `character_guid` and defines the sweep fn invoked when a -/// character is deleted (`world::cascade_delete_character`) or re-owned across an identity change -/// (`world::restamp_owned_data`). Write the invocation right next to the table it sweeps — `build.rs` -/// text-scans every file under `src/` for this marker and generates the two registries `world.rs` -/// iterates, `CHARACTER_OWNED_DELETE_SWEEPS` and `CHARACTER_OWNED_RESTAMP_SWEEPS` (see -/// `$OUT_DIR/character_sweeps.rs`, `include!`d below). A new character-keyed table therefore needs -/// exactly ONE addition — this marker, colocated with the table, in the table's own file — and never -/// an edit to `world.rs` (the modularity goal: an out-of-tree custom-content table gets the same -/// one-line hookup). -/// -/// Because the scan is a text match, not a real parser, the invocation MUST be written in exactly -/// one of these two shapes or `build.rs` panics (a malformed marker fails the build loudly; it -/// never skips silently). Comments and strings are stripped before scanning, which is why this doc -/// can show the real syntax: -/// -/// `character_owned!(delete, fn (, ) { })` — two params (ctx, the -/// character guid); expands to `pub(crate) fn (ctx: &ReducerContext, guid: u64)`. -/// -/// `character_owned!(restamp, fn (, , ) { })` — three -/// params (ctx, guid, the new owning identity); expands to `pub(crate) fn (ctx: -/// &ReducerContext, guid: u64, identity: Identity)`. -/// -/// `character_owned!(transfer, fn (, , ) { table = .., by = .., .. })` and -/// `character_owned!(not_transported, fn ())` — the CROSS-DATABASE row transport, -/// which is what lets a table's rows actually leave one SpacetimeDB database and arrive in -/// another. Both expand to `pub(crate) fn (ctx: &ReducerContext, guid: u64, io: &mut -/// crate::transfer::RowIo)`. Required for every `game_*` manifest table — -/// `transfer::every_manifest_table_can_cross_a_database_boundary` fails the test suite if one is -/// missing, because a table with no transport arm is silent data LOSS the first time a character -/// crosses a real shard boundary. See the GRAMMAR section below: these two are the ONLY shapes -/// that parse, deliberately. -/// -/// See `crate::reputation::sweep_delete_game_player_reputation` for a worked example. -/// -/// # The transport GRAMMAR, and why it is a grammar -/// -/// The transport arm declares its table, Character index, and surrogate key. The macro -/// generates export and import operations from those declarations. -/// -/// ```text -/// // Transports. Exports the rows `` yields for the transferring guid, and re-inserts each -/// // arriving row at the destination with the surrogate primary key `` re-minted (an -/// // `#[auto_inc]` id is local to ONE database, so carrying it verbatim either collides with a row -/// // the destination already minted or means nothing there). -/// crate::character_owned!(transfer, fn sweep_transfer_game_player_skill(ctx, character_guid, io) { -/// table = game_player_skill, -/// by = by_character, -/// remint = id, -/// }); -/// -/// // Transports, KEEPING the row's primary key — for the handful of tables whose key is derived -/// // from the OWNER guid (so it is already namespaced per character and cannot collide) and is the -/// // id the CLIENT knows the row by. Re-minting one of those makes the row look brand new. -/// crate::character_owned!(transfer, fn sweep_transfer_game_item_instance(ctx, character_guid, io) { -/// table = game_item_instance, -/// by = by_owner_guid, -/// keep_key, -/// }); -/// -/// // Transports one row whose primary key is the Character guid, preserving that key. -/// crate::character_owned!(transfer, fn sweep_transfer_game_sessionless_action_consent(ctx, character_guid, io) { -/// table = game_sessionless_action_consent, -/// primary_key = character_guid, -/// }); -/// -/// // Declines. A DECISION written at the table (one-shot relay rows whose durable half lives on the -/// // character row, or state another database is authoritative for). A CORE table must ALSO be -/// // listed on `transfer::NOT_TRANSPORTED` with its reason, which build.rs's generated -/// // `CHARACTER_OWNED_NOT_TRANSPORTED` cross-checks. A `packages/` table is not on that list — it -/// // writes its reason at its own table and nowhere else; see NOT_TRANSPORTED's own doc for why. -/// crate::character_owned!(not_transported, fn sweep_transfer_game_rest_state_event()); -/// ``` -/// -/// `ctx`/`character_guid`/`io` stay in the `transfer` head because they are the expansion's own -/// parameter names, and writing them keeps the marker's shape identical to the `delete`/`restamp` -/// forms build.rs scans for. #[macro_export] macro_rules! character_owned { (delete, fn $name:ident($ctx:ident, $guid:ident) $body:block) => { @@ -121,8 +25,7 @@ macro_rules! character_owned { $io, || $ctx.db.$table().$index().filter(&$guid).collect::>(), |ctx: &spacetimedb::ReducerContext, mut row| { - // Surrogate `#[auto_inc]` PK: local to ONE database, so the destination mints - // its own rather than inheriting a number that means something else there. + // Surrogate keys are Shard-local; mint new keys at the destination. row.$pk = 0; ctx.db.$table().insert(row); }, @@ -179,21 +82,6 @@ macro_rules! character_owned { }; } -/// Registers a periodic pass with the core scheduler tick pipeline. Same build.rs -/// text-scan mechanism as the `character_owned` marker: write the invocation next to the code it -/// drives, in core src/ or a drop-in `packages//src/` file. The generated `GAME_TICK_PASSES` -/// registry picks it up with zero core-file edits. -/// -/// The ONE accepted shape (anything else fails the build loudly; commented-out/quoted marker -/// syntax is inert — build.rs strips comments and strings before scanning): -/// -/// `game_tick_pass!(fn () { })` — expands to -/// `pub(crate) fn (ctx: &ReducerContext)`. -/// -/// CADENCE: every registered pass runs at the END of every `tick_creatures` tick (`MOVE_TICK_SECS`, -/// 0.5s), AFTER all core passes — a package pass can never wedge itself between load-bearing core -/// pass orderings. A pass needing a slower cadence must self-quantize on `ctx.timestamp` (the -/// `is_sense_tick` pattern in `creatures/tick/mod.rs`). #[macro_export] macro_rules! game_tick_pass { (fn $name:ident($ctx:ident) $body:block) => { @@ -201,23 +89,6 @@ macro_rules! game_tick_pass { }; } -/// Registers a NOTIFY-ONLY handler on a core gameplay event. Same build.rs -/// text-scan mechanism as the `character_owned` marker; the generated per-event `GAME_HOOKS_*` -/// arrays are dispatched at the core chokepoints via `hooks::fire_*` (one dispatch line each — see -/// `src/hooks.rs` for the event catalog, payload shapes, and firing semantics). -/// -/// The ONE accepted shape (anything else — including an unknown event name — fails the build -/// loudly; commented-out/quoted marker syntax is inert): -/// -/// `game_hook!(, fn (, ) { })` — expands to -/// `pub(crate) fn (ctx: &ReducerContext, payload: &)` where the payload type -/// is resolved from the event name via `hooks::payload_for` (generated from build.rs's -/// `HOOK_EVENTS` catalog). -/// -/// Handlers OBSERVE and may act through the same reducer-internal fns core code uses; they must not -/// assume any ordering relative to other handlers of the same event beyond the registry's -/// deterministic (sorted-by-path) order. Mutating or decorating semantics (veto, fold) are not -/// supported by this mechanism. #[macro_export] macro_rules! game_hook { ($event:ident, fn $name:ident($ctx:ident, $payload:ident) $body:block) => { @@ -228,13 +99,6 @@ macro_rules! game_hook { }; } -/// Names the Characters a Package controls on this Shard, so Package Teardown can make each one a -/// Dormant Character. Core already finds Characters on Accounts the Package owns here; this adds -/// the ones it cannot attribute, such as a Character that crossed from another Shard. Package -/// files only, at most once per Package. -/// -/// `game_package_characters!(fn () { })` — expands to -/// `pub(crate) fn (ctx: &ReducerContext) -> Vec`. #[macro_export] macro_rules! game_package_characters { (fn $name:ident($ctx:ident) $body:block) => { @@ -242,16 +106,11 @@ macro_rules! game_package_characters { }; } -/// Installs the one Package-owned parser and admitted apply operation for addon client commands. -/// `REPLY` names a `&'static str` const: the addon command that carries each outcome to the issuer. -/// Core authenticates the issuer and Gateway certifies realm-wide party authority before apply. #[macro_export] macro_rules! game_client_command { ($parse:ident, $apply:ident, $reply:ident) => {}; } -/// Installs one package as the sole authority for a map-scoped encounter binding. `build.rs` -/// rejects duplicate bindings, and the generated registry keeps routing out of core code. #[macro_export] macro_rules! encounter_package { ($binding:ident, fn $name:ident($ctx:ident, $instance_id:ident, $signal:ident) $body:block) => { @@ -263,23 +122,10 @@ macro_rules! encounter_package { }; } -// GENERATED by module/build.rs from the `character_owned` marker invocations scattered across src/ -// and packages/*/src/. Defines -// `CHARACTER_OWNED_DELETE_SWEEPS: &[fn(&ReducerContext, u64)]` and -// `CHARACTER_OWNED_RESTAMP_SWEEPS: &[fn(&ReducerContext, u64, Identity)]` at crate scope -// (`crate::CHARACTER_OWNED_DELETE_SWEEPS`). `build.rs` panics at compile time if a marker is -// malformed, so an empty/short slice here means "no markers found", not "scan failed silently". include!(concat!(env!("OUT_DIR"), "/character_sweeps.rs")); -// GENERATED by module/build.rs from packages/*/src/mod.rs discovery: one -// `#[path = ...] pub mod pkg_;` per drop-in package, compiling the package's Rust into this -// crate. Empty when packages/ holds no Rust packages. include!(concat!(env!("OUT_DIR"), "/package_mods.rs")); -// GENERATED by module/build.rs from package marker invocations. -// Defines `GAME_TICK_PASSES: &[(&str, fn(&ReducerContext))]` (name string kept for logging) -// and one `GAME_HOOKS_: &[fn(&ReducerContext, &Payload)]` per event in build.rs's -// HOOK_EVENTS catalog, plus the map-scoped encounter authority registry and `GAME_PACKAGES`. include!(concat!(env!("OUT_DIR"), "/package_registries.rs")); mod account_ownership; @@ -291,7 +137,7 @@ mod away; mod breath; mod breath_relay; mod bridge; -/// Chat Channels: team-scoped channels on Realm-core and their Channel Notices. + mod channel; mod character; mod chat; @@ -300,26 +146,22 @@ mod config; mod corpse; mod creatures; mod exploration; -// Client-automation test reducers, behind the `debug_reducers` feature. Every publish enables the -// feature, so they exist on production databases too; see `debug/mod.rs`. + #[cfg(feature = "debug_reducers")] mod debug; -// Encounter kernel: instance-scoped encounter state, HP-threshold/death/GO-use -// hooks, and the shared choreography primitives Packages consume (`packages/dungeons/` owns it). + mod duel; mod encounter; mod faction; mod gameobject; mod gc; mod gm; -// Module-private DOOR/BUTTON collision-mesh store: the import reducers `--go-models` calls. -// Consumption (the state-gated ray merge) is the `game_go_collider` registry. + mod go_collider; #[cfg(feature = "debug_reducers")] mod go_collider_fixture; mod go_model; -// Graveyard resolution: the death-release subsystem `world::do_repop` calls to -// pick where a ghost teleports. Extracted from `world.rs`. + mod graveyard; mod group; pub mod guild; @@ -327,84 +169,63 @@ mod gw; mod helpers; pub mod hooks; mod import_meta; -// Dungeon-instancing lifecycle: game_instance/game_instance_binding, -// the areatrigger resolve-or-create entry, per-instance population spawn, and the reset/reap loop. + mod combo; mod ghost; mod instance; mod items; mod load; mod loot; -/// Mail: the durable `game_mail` row, its sweeps, and the shared insert core. Realm-core is -/// authoritative; a single-database gateway reads and writes its own copy through the same rules. + mod mail; -/// Imported mail catalogue: letter bodies, the item a letter may carry, and which quests send one at -/// turn-in. The importer and `mail_reward` read these tables. + mod mail_catalogue; -/// Mail attachment escrow: the source-side fence, the mail-plane commit keyed by the same -/// caller-chosen id, and the reaper. The mechanism for moving value into a mail row across a -/// database boundary no transaction spans. A single-database send by a Character bypasses it; a Reward -/// Letter uses it on every plane. + mod mail_escrow; -/// Reward Letters: the letter a quest giver sends at turn-in, filed as Mail Escrow in the turn-in's -/// transaction and driven to the mail plane by the Gateway. + mod mail_reward; -/// Letter Copy (`CMSG_MAIL_CREATE_TEXT_ITEM`): the durable `game_item_text` row a made-permanent -/// mail's body becomes, and the reducers that set COPIED on the mail plane and grant the Plain -/// Letter on the Home Shard. + mod mail_text; -/// The Mail Timer, Mail Expiry and the Mail Arrival event. + mod mail_timer; -/// The Meeting Stone Queue on Realm-core, and the Home Shard Gate for using a stone. + mod meeting_stone; -/// Batched movement republish: the PRIVATE `game_entity_motion_pending` staging table that -/// `movement_update` writes, and the 20 Hz `publish_motion` tick that drains it into the public -/// `game_entity_motion` relay in one transaction. + mod motion; -/// Land mounts: the `A_MOUNTED` state model, the `mount_display_id` projection, and the one shared -/// idempotent dismount every removal path converges on. + mod mount; mod nav; -/// Operator reducers every build carries: the post-publish repair pass. + mod operations; -/// Named Package operations and gated fixture and test operations. + mod package; -/// Package-owned Accounts and the operation that creates their session-less Characters. + mod package_account; -/// The Operator key-value config surface any installed Package reads: `game_package_config`, -/// `set_package_config`, and the `ensure_package_config_default` seeding helper Packages call from -/// their own ensure/init path. + mod package_config; -/// Debug operations exported through `package::fixture`. #[cfg(feature = "debug_reducers")] pub(crate) mod package_fixture; -/// Package Deltas applied to the spell tables as the last stage of their base import, and the -/// per-Package provenance that records what landed. + mod package_import; -/// Package Teardown: the Operator step that stops a Package before it leaves the build. + mod package_teardown; -/// Unit-test operations exported through `package::test`. + #[cfg(test)] pub(crate) mod package_test; mod professions; -/// Publish-safety Architecture Tests scan `scripts/**` + `tools/**` for a destructive -/// `spacetime publish -c`, and pins the sanctioned deploy script's own argv guard and required -/// flags. Test-only; reads files, never runs the CLI. + #[cfg(test)] mod publish_safety; mod quest; -/// Realm Chat: party chat and every later non-proximity Chat Kind, one row per line with its whole -/// audience, written on Realm-core. + mod realm_chat; mod realm_core; mod region; mod reputation; mod rest; -// The Runtime Script Host. Called from `script_binding`'s dispatch on every hook event in a -// default build. + mod runtime_script; -/// The `game_script` table and the Event Binding dispatch: which Runtime Scripts run for which -/// event. + mod script_binding; mod seed; mod sessionless; @@ -422,15 +243,12 @@ mod threat; mod trade; mod trainer; mod transfer; -/// Exact per-cell vmap collision-triangle store + LoS/collision ray queries (part of -/// the full-vmap epic). Builds on `nav`'s obstruction-grid approximation with an exact -/// triangle store; see the module doc comment for the split. + mod vmap; -// Architecture Tests for structural and publish invariants. + #[cfg(test)] mod tripwires; -/// Per-zone weather: the seasonal climate weights, the current-weather row the gateway relays, the -/// ten-minute roll that advances it, and the operator's forced-weather lever. + mod weather; mod world; mod xp; @@ -671,10 +489,6 @@ pub use tables::{ }; pub use tables::{game_world_config, GmWorldConfig}; -/// How long a movement / creature-move event lives before the reaper GCs it. Events are delivered -/// to subscribers in real time on insert, so a short TTL is safe (see docs). -pub(crate) const EVENT_TTL_MICROS: i64 = 1_000_000; // 1s +pub(crate) const EVENT_TTL_MICROS: i64 = 1_000_000; -/// How long a never-answered `game_group_invite` row lives. NOT the event TTL — a human is -/// looking at the invite dialog, so give them two minutes before the reaper treats it as dead. -pub(crate) const INVITE_TTL_MICROS: i64 = 120 * 1_000_000; // 2min +pub(crate) const INVITE_TTL_MICROS: i64 = 120 * 1_000_000; diff --git a/module/src/load.rs b/module/src/load.rs index 5f774c66f..4f59a515f 100644 --- a/module/src/load.rs +++ b/module/src/load.rs @@ -1,43 +1,10 @@ -//! Per-shard writer occupancy and per-region population, queryable as plain SQL. -//! -//! The GATEWAY is the one component that can see the whole realm — every shard's `/v1/metrics` -//! endpoint, every session, every live position — so it is the one that SAMPLES (a periodic -//! background task, `gateway/src/load_sample.rs`). This module only RECORDS what it is told, the -//! same split `region.rs` uses for the assignment table: an operator-gated write, authoritative on -//! realm-core, inert everywhere else (nothing calls these reducers on a world shard's own -//! connection in production, but nothing would break if something did — the module never reads a -//! shard id, so it has no idea which database it is running on). -//! -//! Two tables, RING-BUFFERED rather than TTL-reaped — each keeps the last [`SHARD_LOAD_RING`] / -//! [`REGION_LOAD_RING`] samples per key, oldest evicted on insert. The reason this is -//! spelled out: a `game_*` table nothing ever reaps grows forever, and a scheduled reaper is not the -//! only way to avoid that — bounding the table AT WRITE TIME needs no schedule, no GC pass, and no -//! extra reducer. At the gateway's default 30s sample cadence, 20 samples is ~10 minutes of -//! history: enough to eyeball a sustained trend against a momentary spike without a single `ORDER -//! BY` (`spacetime sql` has none — `docs/danger-zones.md` §2). -//! -//! `docs/danger-zones.md` describes the Gateway binding generation rules. - use spacetimedb::{reducer, table, ReducerContext, Table}; -/// Keep at most this many samples per shard (~10 minutes at the gateway's default 30s cadence). pub const SHARD_LOAD_RING: usize = 20; -/// Same, per `(map_id, region_id)`. pub const REGION_LOAD_RING: usize = 20; -/// Occupancy readings above this are almost certainly a unit error (a fraction instead of a -/// percentage, or raw seconds instead of a percentage) rather than a real writer — refuse rather -/// than record garbage an operator would otherwise have to notice by eye. const MAX_SANE_OCCUPANCY_PCT: f32 = 1000.0; -/// One periodic sample of a world shard's writer: `spacetime_txn_cpu_time_sec_sum` delta ÷ -/// wall-clock delta (the same computation the internal capacity-benchmark tool reads from the -/// node's `/v1/metrics`, sampled continuously here instead of only during a deliberate ramp) plus -/// the session count the gateway already tracks per shard. -/// -/// PRIVATE (no `public`): ops telemetry, not game state — same rationale as -/// `game_region_assignment`. The gateway's coordinator connection (owner token, RLS bypass) reads -/// it exactly the way it reads that table, and an operator reads it with `spacetime sql`. #[table(accessor = game_shard_load)] pub struct ShardLoad { #[primary_key] @@ -47,50 +14,18 @@ pub struct ShardLoad { pub sampled_at_micros: i64, pub writer_occupancy_pct: f32, pub sessions: u32, - /// Which GATEWAY PROCESS wrote this sample. With N gateway processes sampling the - /// same shard, `sessions` is only ever that ONE process's own player-connection cache size - /// (`Coordinator::session_count`) — never the shard's realm-wide total. Keying the ring by - /// `(shard, gateway_key)` instead of `shard` alone means every gateway's samples survive - /// side-by-side rather than the last writer clobbering the rest, so an operator (or a future - /// realm-list balancer) can sum the latest row per `gateway_key` to get the real total. - /// - /// A deterministic (FNV-1a) hash of the gateway's own `LYRACORE_GATEWAY_ID` (config-supplied, - /// default hostname:port — `load_sample::gateway_key`), not the raw string: END-appending a - /// `String` column here cannot be defaulted (`#[default(...)]` cannot default a `String` — - /// `docs/danger-zones.md` §1.2), so the identity crosses the wire as the number instead. `0` is - /// what every pre-migration row reads as — an "unknown gateway" bucket, not a collision with a - /// real one. A hash rather than a registry-assigned id trades a (negligible, at realm-gateway - /// counts) collision risk for needing no extra table or find-or-insert reducer; it is an - /// internal telemetry key, not game state. #[default(0u64)] pub gateway_key: u64, } -/// The realm-wide total for one shard, MATERIALIZED at write time (the actual -/// Done-when: `game_shard_load` alone is per-`(shard, gateway_key)` rows, and `spacetime sql` has -/// no `GROUP BY`/`SUM` (`docs/danger-zones.md` §2) to fold those into a total on read — so -/// [`record_shard_load`] recomputes and upserts this row on every sample instead, and an operator -/// queries `game_shard_load_total` directly for the number the issue asks for. -/// -/// PRIVATE: same rationale as [`ShardLoad`]. #[table(accessor = game_shard_load_total)] pub struct ShardLoadTotal { #[primary_key] pub shard: String, - /// Sum, across every `gateway_key` that has ever sampled this shard, of that gateway's - /// LATEST `sessions` reading — see [`realm_wide_sessions`]. pub sessions: u32, pub updated_at_micros: i64, } -/// One periodic sample of a region's open-world player population, bucketed by the gateway from -/// live session positions against the region definitions (`game_map_region` — see `crate::region` -/// for the hierarchy). Approximate by construction: a player mid-move between two samples is -/// counted wherever they were AT the sample instant, and only assignable regions are ever recorded -/// — the catch-all "rest of the map" bucket (`lyracore_shared::region::DEFAULT_REGION`) answers nothing -/// an operator can act on, so the gateway never sends it here. -/// -/// PRIVATE: same rationale as [`ShardLoad`]. #[table(accessor = game_region_load)] pub struct RegionLoad { #[primary_key] @@ -102,22 +37,12 @@ pub struct RegionLoad { pub players: u32, } -/// Which existing ids (already ASCENDING = oldest first) must be evicted before inserting one more -/// row, so the ring never holds more than `ring_size` rows for one key — including self-healing a -/// ring that is already over cap (e.g. after `ring_size` shrinks in a future edit). Pure so it is -/// testable without a `ReducerContext` (this crate's convention: never mock one, extract a pure -/// function instead). pub(crate) fn ring_evict(existing_ids_ascending: &[u64], ring_size: usize) -> &[u64] { - let keep = ring_size.saturating_sub(1); // room for the row about to be inserted + let keep = ring_size.saturating_sub(1); let over = existing_ids_ascending.len().saturating_sub(keep); &existing_ids_ascending[..over] } -/// Fold one shard's `(gateway_key, id, sessions)` samples into the realm-wide total: for each -/// `gateway_key`, only its HIGHEST `id` (auto-inc, so highest = most recent) contributes — a -/// gateway that has sampled 20 times must count once, not 20 — then those latest-per-gateway -/// values are summed. Pure so [`record_shard_load`]'s upsert and the test below share one rule, -/// same convention as [`ring_evict`]. pub(crate) fn realm_wide_sessions(samples: impl Iterator) -> u32 { let mut latest_per_gateway: std::collections::HashMap = Default::default(); for (gateway_key, id, sessions) in samples { @@ -137,11 +62,7 @@ pub(crate) fn realm_wide_sessions(samples: impl Iterator .sum() } -/// The occupancy sanity gate, decided purely so both reducer and test share the exact rule. See -/// [`MAX_SANE_OCCUPANCY_PCT`]'s doc for why the upper bound exists. pub(crate) fn validate_occupancy_pct(pct: f32) -> Result<(), String> { - // The `is_finite` arm runs FIRST and is what rejects NaN; `contains` alone would too (it is - // false for NaN, so the negation is true), but spelling it out keeps the error message honest. if !pct.is_finite() || !(0.0..=MAX_SANE_OCCUPANCY_PCT).contains(&pct) { return Err(format!( "writer_occupancy_pct must be finite and within [0, {MAX_SANE_OCCUPANCY_PCT}], got {pct}" @@ -150,11 +71,6 @@ pub(crate) fn validate_occupancy_pct(pct: f32) -> Result<(), String> { Ok(()) } -/// Record one shard's writer-occupancy + session sample. Operator-gated like -/// [`crate::region::set_region_assignment`] — in production this is called by the gateway's own -/// periodic sampling task over its privileged coordinator connection -/// (that connection's identity IS the claimed operator, the same one -/// that fires every other privileged reducer), not by a player. #[reducer] pub fn record_shard_load( ctx: &ReducerContext, @@ -170,11 +86,7 @@ pub fn record_shard_load( } validate_occupancy_pct(writer_occupancy_pct)?; let table = ctx.db.game_shard_load(); - // Ring-scoped per (shard, gateway_key), NOT per shard alone — with N gateways - // sampling the same shard, keying eviction (and therefore the ring) by shard alone let each - // gateway's insert evict every OTHER gateway's rows, so only the last writer's share ever - // survived. Scoping the ring per gateway too means every gateway keeps its own ~10 minutes of - // history, and summing the latest row per gateway_key for a shard gives the realm-wide total. + // Keep one ring per Gateway process so another Gateway's samples cannot evict its history. let mut existing: Vec = table .iter() .filter(|r| r.shard == shard && r.gateway_key == gateway_key) @@ -192,8 +104,6 @@ pub fn record_shard_load( sessions, gateway_key, }); - // Recompute and upsert the realm-wide total (the actual Done-when) rather than - // leaving that fold as a manual eyeball step — see `ShardLoadTotal`'s doc. let total_sessions = realm_wide_sessions( table .iter() @@ -214,8 +124,6 @@ pub fn record_shard_load( Ok(()) } -/// Record one region's open-world player-count sample. Same gating and ring-buffer -/// discipline as [`record_shard_load`]; see that reducer's doc. #[reducer] pub fn record_region_load( ctx: &ReducerContext, @@ -276,19 +184,16 @@ mod tests { #[test] fn realm_wide_sessions_sums_only_the_latest_row_per_gateway() { assert_eq!(realm_wide_sessions(std::iter::empty()), 0); - // One gateway, three samples over time (rising id): only the newest counts. assert_eq!( realm_wide_sessions(vec![(1, 10, 5), (1, 11, 7), (1, 12, 9)].into_iter()), 9, "a single gateway's older samples must not be double-counted" ); - // Two gateways sampling the same shard: their latest rows sum to the realm-wide total. assert_eq!( realm_wide_sessions(vec![(1, 10, 5), (2, 11, 7)].into_iter()), 12, "distinct gateways' latest samples sum to the realm-wide total" ); - // Ids need not arrive in order. assert_eq!( realm_wide_sessions(vec![(1, 12, 9), (1, 10, 5), (2, 11, 7)].into_iter()), 16, diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index ac87860ac..b19783704 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -1,28 +1,12 @@ -//! Money + item loot from a creature corpse. The lootable copper is rolled onto the corpse's -//! `WorldEntity` (with `UNIT_DYNFLAG_LOOTABLE`) on the killing blow in `combat/`; `loot_money` moves -//! it into the looter's purse and clears the flag. Item drops are rolled into `game_corpse_loot` rows -//! on the same killing blow; the gateway reads them to populate the loot window, and taking one -//! (`CMSG_AUTOSTORE_LOOT_ITEM`) is `crate::items::take_loot` → `apply_take_loot`. [server]/[entity] -//! -//! Split into three files that stay one Rust module (`crate::loot::*` paths are -//! unaffected — everything below re-exports through): this file (the roll core, the money split, -//! and the loot-family DATA tables — creature/pickpocket/gameobject/skinning/fishing), `rolls.rs` -//! (the NEED/GREED/round-robin/master group-loot machinery), and the sibling top-level -//! `crate::professions` (skinning/fishing/enchanting — feature reducers over that data, not loot-table -//! machinery themselves). -//! -//! Loot-family completeness, two design decisions recorded here because they shape -//! every table/reducer below: - use spacetimedb::{table, ReducerContext, Table}; use crate::actor::{ActionRefusal, ActionRefusalKind}; use crate::character::game_character; -use crate::game_group_member; // clone_quest_loot_for_group's GameObject roster read +use crate::game_group_member; use crate::game_world_entity; -use crate::{game_character_quest, game_quest_objective}; // killer_needs_item (fishing's zone resolve now lives in terrain::zone_id_at) +use crate::{game_character_quest, game_quest_objective}; use lyracore_shared::loot::LootRefusal; -use lyracore_shared::loot_roll::event_kind as roll_event_kind; // apply_loot_money's MONEY_SHARE push +use lyracore_shared::loot_roll::event_kind as roll_event_kind; mod rolls; pub use rolls::{ @@ -45,80 +29,37 @@ pub use tag::{ CreatureLootTagGroup, CreatureQuestTap, CreatureQuestTapMember, }; -// The profession reducers (skinning/fishing/enchanting) live in the sibling top-level -// `crate::professions` module — re-exported here so every existing -// `crate::loot::skin_corpse`/`apply_fish`/`apply_disenchant`/`apply_enchant_item`/`entry_is_beast` -// call site (debug.rs's twins) keeps compiling unchanged even though nothing in THIS file uses -// them directly. #[allow(unused_imports)] pub(crate) use crate::professions::{ apply_disenchant, apply_enchant_item, apply_fish, entry_is_beast, skin_corpse, }; -/// One item available to loot on a creature corpse. Born on the killing blow (combat/), -/// read by the gateway for `SMSG_LOOT_RESPONSE`, reaped when the corpse decays (creatures/). Public -/// + no RLS: loot is shown to whoever opens the corpse (the take is gated by the loot reducer, like -/// money). `item_entry` → `game_item_template` (the gateway joins it for the display id). [entity] #[table(accessor = game_corpse_loot, public, index(accessor = by_corpse, btree(columns = [corpse_guid])))] pub struct CorpseLoot { #[primary_key] #[auto_inc] pub id: u64, pub corpse_guid: u64, - pub slot: u8, // loot-window slot index (0-based) - pub item_entry: u32, // -> game_item_template.entry + pub slot: u8, + pub item_entry: u32, pub count: u32, - // END-APPENDED (see the module doc): - // does this row carry a QUEST-only item (rolled from a `quest_only` creature/pickpocket/ - // gameobject-loot row)? Quest rows now roll UNCONDITIONALLY (the drop-chance still applies; only - // the pre-roll killer gate is gone) — visibility (gateway) and takability (`apply_take_loot`) are - // decided per-viewer/per-taker instead. `#[default(false)]` so every existing row (and every - // non-quest drop) keeps behaving exactly as before — additive auto-migration. #[default(false)] pub quest_only: bool, - // END-APPENDED: `0` = the SHARED, not-yet-split row, any currently-needing - // character may claim it, which triggers the per-member clone (`clone_quest_loot_for_group`); - // nonzero = a character guid, this specific row is a per-member CLONE reserved for exactly that - // character (minted the moment the shared row is first taken). Meaningless when `quest_only` is - // false UNLESS it's a group-loot WINNER-LOCKED row (below): nonzero then also marks - // "this row belongs EXCLUSIVELY to this guid" for a NEED/GREED roll winner whose bag was full at grant time (`resolve_roll`'s inventory-full fallback — the item - // sits here until they free space and re-loot). Same predicate on both the gateway's visibility - // read and `apply_take_loot`'s gate either way: nonzero `reserved_for` always means "only this - // guid, unconditionally". `#[default(0)]` — additive. + // Nonzero reserves the row for one Character, including a Loot Roll winner with full bags. #[default(0u64)] pub reserved_for: u64, - // END-APPENDED (group loot methods): three columns the module stamps - // at KILL TIME (`apply_group_loot_rules`, called from `combat::kill_creature` — never at - // loot-open; see the module doc below for why). All three are meaningless (stay at their - // defaults) for an ungrouped kill, an FFA-method group, or any `quest_only` row (the two systems - // are kept ORTHOGONAL — `apply_group_loot_rules` never touches a `quest_only` row). `#[default]` - // on all three — additive, byte-identical for every existing row. - /// `0` = no round-robin/master restriction (FFA, or a solo/ungrouped kill); nonzero = ONLY this - /// character guid may see/take the row via the plain loot path (ROUND_ROBIN/GROUP-below- - /// threshold: the corpse's picked-once designee; MASTER-above-threshold: the master looter). #[default(0u64)] pub designated_looter_guid: u64, - /// Above-threshold under the MASTER method: takable ONLY via `loot_master_give` (never the plain - /// autostore path, not even by the master themselves — matches vanilla's explicit-assign model). + // Even the master must use the explicit assignment path. #[default(false)] pub master_only: bool, - /// A live NEED/GREED/NBG roll is in progress on this row — WITHHELD from EVERY viewer's loot - /// window (including the eventual winner) until `resolve_roll` clears it (by deleting the row on - /// a successful grant, or flipping `reserved_for` to the winner on an inventory-full fallback). + // Withheld rows may belong to a Loot Roll promoted to Realm-core. #[default(false)] pub withheld: bool, #[default(0)] pub random_property_id: u32, } -/// A creature's loot-table entry: when an `creature_entry` creature dies, its rows roll for drops. -/// `chance_bp` is BASIS POINTS (0..=10000; 10000 = always, 0 = never) so sub-1% odds are representable -/// (real loot tables are full of fractional chances). `group_id` gives vanilla "pick-one" pools: -/// `0` = an independent roll (this row drops on its own chance), `>0` = a group where AT MOST ONE -/// member drops per kill (the members' `chance_bp` are weights walked as a cumulative band — so a -/// shared loot pool of N items drops one of them, not N independent items). Multiple rows per -/// `creature_entry` form the full table. Loaded by the importer (cmangos `*_loot_template`) or seeded; -/// public + read-only, no Timestamp → SQL-loadable. A creature with NO rows drops no items. [static] #[table(accessor = game_creature_loot, public, index(accessor = by_creature, btree(columns = [creature_entry])))] pub struct CreatureLoot { #[primary_key] @@ -126,28 +67,17 @@ pub struct CreatureLoot { pub id: u64, pub creature_entry: u32, pub item_entry: u32, - pub chance_bp: u32, // drop chance in basis points (0..=10000); a group member's weight if group_id>0 + pub chance_bp: u32, // Basis points; group_id > 0 uses this as a weight. pub count: u32, - pub group_id: u32, // 0 = independent roll; >0 = pick-one group (≤1 member drops per kill) - // END-APPENDED: cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only - // ever visible to a player who currently needs it (an active matching COLLECT_ITEM objective — see - // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (see the module doc), - // never re-evaluated later. `#[default(false)]` so existing rows keep - // rolling exactly as before (never quest-gated) — additive auto-migration. + pub group_id: u32, // Zero is independent; nonzero selects a pool with at most one drop. #[default(false)] pub quest_only: bool, } -/// Independent drop test (`group_id == 0`): does an item with `chance_bp` (0..=10000) drop on a -/// `roll` in 0..10000? Half-open (`roll < chance`) so 10000 always drops and 0 never does. Pure/testable. pub(crate) fn loot_drops(chance_bp: u32, roll_0_9999: u32) -> bool { roll_0_9999 < chance_bp } -/// Pick-one group roll: given member weights in basis points and a `roll` in 0..10000, return the index -/// of the member that drops, or `None` if the roll lands past the members' cumulative weight (the -/// group's "nothing" band — only reachable when the weights sum below 10000). This is the vanilla -/// group-loot semantics: at most one item from a shared pool, weighted by chance. Pure/testable. pub(crate) fn group_pick(member_bp: &[u32], roll_0_9999: u32) -> Option { let mut cum = 0u32; for (i, &bp) in member_bp.iter().enumerate() { @@ -159,27 +89,17 @@ pub(crate) fn group_pick(member_bp: &[u32], roll_0_9999: u32) -> Option { None } -/// Per-1000 copper-loot multiplier for a creature's classification `rank` — an elite/rare/boss is -/// worth more money than a normal mob of the same level. cmangos `creature_template.rank`: `0`=normal, -/// `1`=elite, `2`=rare-elite, `3`=boss, `4`=rare. Bumps: normal 1.0×, elite 1.5×, rare-elite 2.0×, -/// boss 3.0×, rare 2.0×. PERMILLE (×1000) for exact integer math; rank 0 (`1000`) is the identity so a -/// normal kill's purse is BYTE-IDENTICAL to before. Unknown rank → normal (never amplify). Pure — -/// unit-tested. (HP/damage rank scaling live in `creatures/`/`combat/`; this is the loot twin.) pub(crate) fn rank_money_multiplier_permille(rank: u8) -> u32 { match rank { - 0 => 1000, // normal — identity (baseline) - 1 => 1500, // elite - 2 => 2000, // rare-elite - 3 => 3000, // boss - 4 => 2000, // rare - _ => 1000, // unknown rank → treat as normal + 0 => 1000, // Normal. + 1 => 1500, // Elite. + 2 => 2000, // Rare elite. + 3 => 3000, // Boss. + 4 => 2000, // Rare. + _ => 1000, } } -/// Apply the rank money multiplier to a rolled copper amount (integer permille math, saturating so a -/// large roll × a high multiplier can't wrap a `u32`). Shared by the killing-blow money roll -/// (combat/) and the unit test so the live scaling and the asserted numbers never drift. Rank 0 → -/// the amount unchanged. Pure. pub(crate) fn scale_money_for_rank(money: u32, rank: u8) -> u32 { let permille = rank_money_multiplier_permille(rank); ((money as u64 * permille as u64) / 1000) as u32 @@ -215,17 +135,6 @@ pub(crate) fn roll_loot_rows_quest_aware( winners } -/// Roll ONE generic loot table (the shape shared by every family — creature/pickpocket/gameobject/ -/// skinning/fishing): independent rows (`group_id == 0`) each test their own `chance_bp`; grouped rows -/// (`group_id > 0`) are collected and rolled ONCE per group (pick-one, weighted by `chance_bp`). -/// Returns the winners as `(item_entry, count)`, in table order for independents then group order — -/// callers decide what a winner MEANS (a `game_corpse_loot` row, a direct `grant_item`, …). -/// -/// A thin adapter over [`roll_loot_rows_quest_aware`] (the "one roll core"): tag every row -/// `quest_only = false`, delegate, then drop the flag off each winner. The RNG draw sequence is -/// IDENTICAL to the old standalone body this replaces (the same `ctx.random` calls in the same -/// order), so `professions::skin_corpse`/`professions::apply_fish` — the two callers with no -/// `quest_only` column to carry — stay byte-identical. pub(crate) fn roll_loot_rows( ctx: &ReducerContext, rows: Vec<(u32, u32, u32, u32)>, @@ -242,7 +151,6 @@ pub(crate) fn roll_loot_rows( .collect() } -/// A quest reservation grants its owner the item even when current need has ended. pub(crate) fn quest_take_allowed( reserved_for: u64, taker_guid: u64, @@ -267,11 +175,6 @@ pub(crate) fn group_loot_take_allowed( ) } -/// Pure: given `(character_guid, currently_needs_item)` pairs for every OTHER -/// group member (the taker already excluded by the caller), which should receive a fresh per-member -/// clone when the shared `quest_only` row is first taken? Only the still-needing ones — a member who's -/// already capped on the item (or no longer holds the quest) gets nothing, matching vanilla (the item -/// simply isn't offered to them). Pure. pub(crate) fn clone_targets(other_members: &[(u64, bool)]) -> Vec { other_members .iter() @@ -280,11 +183,6 @@ pub(crate) fn clone_targets(other_members: &[(u64, bool)]) -> Vec { .collect() } -/// Pure: `count` fresh, ascending loot-window slot indices past whatever is -/// already used on a corpse (`used_slots`), so per-member quest clones never collide with the -/// just-deleted shared row's slot or any sibling drop. Saturating (a corpse loot window is -/// realistically far under 255 rows — the gateway's RAW `SMSG_LOOT_RESPONSE` builder already caps the -/// wire count at `u8::MAX`). Pure. pub(crate) fn next_free_slots(used_slots: &[u8], count: usize) -> Vec { let mut next = used_slots .iter() @@ -300,7 +198,6 @@ pub(crate) fn next_free_slots(used_slots: &[u8], count: usize) -> Vec { .collect() } -/// Read quest and inventory facts for the shared quest-item need rule. pub(crate) fn killer_needs_item(ctx: &ReducerContext, killer: Option, item: u32) -> bool { let Some(killer_guid) = killer else { return false; @@ -337,19 +234,7 @@ pub(crate) fn killer_needs_item(ctx: &ReducerContext, killer: Option, item: ) } -/// Purge every `game_corpse_loot` / `game_corpse_loot_eligible` row still parked on `guid`, BEFORE a -/// fresh roll ever writes to it. Two residue sources land here: (a) corpse-guid REUSE -/// (a harness SQL teardown or `debug_spawn_at_feet` skipping the decay reaper leaves a departed kill's -/// `game_corpse_loot_eligible` snapshot behind for the next creature minted at that guid), and (b) -/// the pickpocket/kill collision — `roll_pickpocket_loot` inserts `game_corpse_loot` rows at -/// slots 0.. keyed on the LIVE creature's guid (vanilla lets a rogue crack a pocket before the kill); -/// if the mob dies before every row is taken, `roll_creature_loot` re-inserts kill drops starting at -/// slot 0 on the SAME guid, producing duplicate `(corpse_guid, slot)` pairs that every first-match -/// consumer (`items::apply_take_loot`, `unlock_row_ffa`, `settle_roll_grant`, `loot_master_give`) -/// addresses ambiguously. Vanilla-correct either way: the pickpocket window dies with the mob, so any -/// leftover pickpocket rows are stale the instant it does. Called from the ONE spot every creature -/// death routes through (`combat::kill_creature`), before the fresh loot roll / group snapshot — a -/// no-op for a guid with no residue (the common path). [server] +// Purge pickpocket residue before death loot reuses the same guid and slot numbers. pub(crate) fn purge_corpse_residue(ctx: &ReducerContext, guid: u64) { let loot = ctx.db.game_corpse_loot(); let stale_loot: Vec = loot.by_corpse().filter(&guid).map(|l| l.id).collect(); @@ -363,24 +248,7 @@ pub(crate) fn purge_corpse_residue(ctx: &ReducerContext, guid: u64) { } } -/// Reap every loot-family row keyed on `corpse_guid` (creature corpse OR chest-GO guid): item rows, -/// the group-loot eligibility snapshot, rolls + votes. This is the CANONICAL teardown sweep — the one -/// every despawn path routes through ([`crate::creatures::despawn_creature_entity`] for creatures, the -/// GO-copy loop in `instance::teardown_instance_inner` for chests) — so a new loot-family table is -/// added HERE, once, and every teardown picks it up. -/// -/// Distinct from [`purge_corpse_residue`], which sits next door and looks similar: that one runs -/// BEFORE a fresh kill roll and deliberately purges unconditionally (nothing on a live mob's guid can -/// be owned by a roll that matters). This one runs when the corpse itself goes away, and must respect -/// the withheld gate below. -/// -/// Fix: a `game_corpse_loot` row still `withheld` is locked by a live NEED/GREED roll — -/// which, in a sharded deployment, may be authoritative on realm-core and invisible to this -/// database's own `game_loot_roll` table (promoted away within ~200ms of kill-time; see -/// `creatures::tick::pass_decay`'s matching fix). Reaping it here would delete the item out from -/// under a roll that has not resolved yet, so `settle_loot_roll`'s eventual grant finds nothing and -/// silently no-ops. Leave a withheld row behind — orphaned-but-safe, the same posture the -/// inventory-full fallback already uses for a row exclusively reserved for a winner. [server] +// Preserve withheld rows: their live Loot Roll may exist only on Realm-core. pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) { let loot = ctx.db.game_corpse_loot(); let stale: Vec = loot @@ -417,8 +285,6 @@ pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) { } } -/// Insert valid drops at consecutive slots. Invalid property catalogues omit the affected item. -/// Each row starts unclaimed and FFA; creature group rules are applied by the caller. pub(crate) fn insert_corpse_rows( ctx: &ReducerContext, corpse_guid: u64, @@ -441,8 +307,6 @@ pub(crate) fn insert_corpse_rows( count, quest_only, reserved_for: 0, - // Group-loot stamping happens AFTER this fn returns, in - // `apply_group_loot_rules` (combat::kill_creature) — this insert always starts FFA. designated_looter_guid: 0, master_only: false, withheld: false, @@ -453,15 +317,6 @@ pub(crate) fn insert_corpse_rows( inserted != 0 } -/// Roll a creature's loot table into `game_corpse_loot` rows on its corpse; returns whether anything -/// dropped (so the caller sets `UNIT_DYNFLAG_LOOTABLE`). Data-driven: reads `game_creature_loot` for -/// `creature_entry`, then rolls independent rows + one weighted pick per group with the -/// `roll_loot_rows_quest_aware` core (`quest_only` rows roll UNCONDITIONALLY, with -/// no killer gate; the flag just rides along onto the inserted row for the gateway/take-path to -/// gate per-viewer/per-taker instead), inserting the winners at sequential loot slots with a fresh -/// `reserved_for = 0` (unclaimed). No table rows → nothing drops (no universal placeholder). Shared by -/// the real killing blow (combat/) and the debug kill (debug.rs) so both produce identical loot. -/// [server] pub(crate) fn roll_creature_loot( ctx: &ReducerContext, creature_entry: u32, @@ -478,20 +333,7 @@ pub(crate) fn roll_creature_loot( insert_corpse_rows(ctx, corpse_guid, winners) } -// =========================================================================================== -// LOOT-FAMILY COMPLETENESS, the four families `game_creature_loot` never covered: -// pickpocket / gameobject-chest / skinning / fishing. All four share the SAME row shape as -// `CreatureLoot` (item/chance_bp/count/group_id, some also carrying `quest_only`); the importer maps -// cmangos's `*_loot_template` family onto them (see importer's `parse_loot_family`). Public + no -// Timestamp → SQL-loadable, exactly like `CreatureLoot`. [static] -// =========================================================================================== - -/// `pickpocketing_loot_template` — a creature's PICKPOCKET table, keyed directly on -/// `creature_entry` (the importer collapses cmangos's `PickpocketLootId` indirection the same way it -/// already does for `CreatureLoot`'s `LootId`). Rolled by `roll_pickpocket_loot` (E_PICKPOCKET, -/// spell/cast.rs) into `game_corpse_loot` keyed on the LIVE creature's guid — pickpocketing doesn't -/// kill it, so the shared corpse-loot table just serves as "this guid has a loot window" (like the -/// GO-chest reuse below). [static] +// The importer resolves PickpocketLootId to creature_entry before storage. #[table(accessor = game_pickpocket_loot, public, index(accessor = by_creature, btree(columns = [creature_entry])))] pub struct GamePickpocketLoot { #[primary_key] @@ -505,12 +347,6 @@ pub struct GamePickpocketLoot { pub quest_only: bool, } -/// `gameobject_loot_template` — a lootable gameobject's (CHEST) table, keyed on `loot_id` -/// (`GameObjectTemplate.data1` — the REAL cmangos `gameobject_template.Data1`, read verbatim by the -/// importer). Rolled by `apply_use_gameobject`'s CHEST arm (gameobject.rs) into `game_corpse_loot` -/// keyed on the GO's guid, multi-slot (unlike the old single-`data0`-item shortcut it replaces). A -/// `loot_id` of 0 (unimported / the seed/demo chest) has no rows here — the module falls back to the -/// legacy single-item `data0` drop, so existing seed/demo data + tests stay byte-identical. [static] #[table(accessor = game_gameobject_loot, public, index(accessor = by_loot, btree(columns = [loot_id])))] pub struct GameObjectLoot { #[primary_key] @@ -524,12 +360,6 @@ pub struct GameObjectLoot { pub quest_only: bool, } -/// `skinning_loot_template` — a level-banded leather table, keyed on `skin_loot_id` -/// (`CreatureTemplate.skin_loot_id` — cmangos's `SkinLootId`; several creatures of the same level band -/// share ONE `skin_loot_id`, so this is NOT collapsed onto `creature_entry` the way pickpocket is). -/// Rolled by `professions::skin_corpse` in place of the flat `LEATHER_ENTRY` shortcut; a `skin_loot_id` -/// of 0 (unimported, or a seeded/test beast) falls back to the flat leather, byte-identical to before. -/// No `quest_only` — no cmangos skinning row is ever quest-gated. [static] #[table(accessor = game_skinning_loot, public, index(accessor = by_skin, btree(columns = [skin_loot_id])))] pub struct GameSkinningLoot { #[primary_key] @@ -554,14 +384,6 @@ pub struct GameFishingLoot { pub group_id: u32, } -/// Roll a creature's PICKPOCKET table into `game_corpse_loot` rows keyed on the LIVE target's guid, then -/// refresh `UNIT_DYNFLAG_LOOTABLE` (`refresh_lootable`) so a still-alive-but-pickpocketed -/// creature shows the loot cursor if anything rolled. Quest-only rows roll UNCONDITIONALLY -/// (no rogue gate; the flag rides onto the row for per-viewer/per-taker gating -/// downstream). A no-row table (or an all-miss roll) inserts nothing and never touches the flag — the -/// existing money-only path (spell/cast.rs) is unaffected either way. Called from `E_PICKPOCKET` -/// (spell/cast.rs), inside the SAME `!target.pickpocketed` once-gate the copper roll already uses, so -/// items can never be drained twice per life either. [server] pub(crate) fn roll_pickpocket_loot(ctx: &ReducerContext, creature_entry: u32, target_guid: u64) { let raw: Vec<(u32, u32, u32, u32, bool)> = ctx .db @@ -578,14 +400,6 @@ pub(crate) fn roll_pickpocket_loot(ctx: &ReducerContext, creature_entry: u32, ta refresh_lootable(ctx, target_guid); } -/// On the FIRST take of a shared `quest_only` row (`apply_take_loot`'s caller): -/// mint one independent, per-member `reserved_for` clone for every OTHER member of `taker_guid`'s group -/// who currently still needs `item_entry` — so a grouped quest-holder's copy doesn't vanish the instant -/// a fellow member loots theirs. A solo `taker_guid` (`group_of` returns `None`) touches nothing — -/// solo behavior is unchanged (design requirement). Clones land at FRESH slots past whatever is -/// already on the corpse (`next_free_slots`), never colliding with the just-deleted original's slot or -/// any sibling drop. Reads `by_group`, an indexed group-scoped lookup, so it costs one clone per -/// OTHER member whatever the group's size, up to 39 in a full Raid. [server] pub(crate) fn clone_quest_loot_for_group( ctx: &ReducerContext, taker_guid: u64, @@ -639,9 +453,7 @@ pub(crate) fn clone_quest_loot_for_group( } } -/// Clone a quest-only creature-loot row for the other corpse-eligible Characters who still need -/// it. The resolved eligibility set is the whole recipient ceiling: a current group lookup would -/// let a later joiner receive a clone. +// Use death-time eligibility; a current roster would admit later joiners. pub(crate) fn clone_quest_loot_for_eligible( ctx: &ReducerContext, taker_guid: u64, @@ -689,11 +501,9 @@ pub(crate) fn clone_quest_loot_for_eligible( } } -/// Max distance to loot a corpse: (10 yd)². Generous — the vanilla client walks into interaction -/// range itself before sending `CMSG_LOOT_MONEY`, so this only rejects clearly-out-of-range abuse. +// Squared yards. pub(crate) const LOOT_RANGE_SQ: f32 = 100.0; -/// A corpse is ready for skinning once no item row or money remains. pub(crate) fn corpse_is_looted(ctx: &ReducerContext, corpse_guid: u64, money: u32) -> bool { money == 0 && ctx @@ -705,16 +515,12 @@ pub(crate) fn corpse_is_looted(ctx: &ReducerContext, corpse_guid: u64, money: u3 .is_none() } -/// The reducer edge for every loot Refusal. Only the tag crosses the tier boundary, so the Gateway -/// never reads Module prose; the operator detail stays in the Module log. pub(crate) fn refused(refusal: LootRefusal, detail: &str) -> String { let tag = refusal.as_tag(); spacetimedb::log::info!("loot refused {tag}: {detail}"); tag.to_string() } -/// Open a creature corpse for the read that follows. This reducer core only authorizes the read; -/// it does not create durable loot-window state. pub(crate) fn request_open_creature_corpse( ctx: &ReducerContext, actor_guid: u64, @@ -795,8 +601,6 @@ pub(crate) fn open_creature_corpse( request_open_creature_corpse(ctx, actor_guid, corpse_guid).map_err(Into::into) } -/// Shared core: take corpse money by explicit looter guid, behind the `loot_money` reducer and -/// `actor::loot_money`. The Loot Tag Gate runs before the corpse purse changes. pub(crate) fn apply_loot_money( ctx: &ReducerContext, looter_guid: u64, @@ -807,8 +611,6 @@ pub(crate) fn apply_loot_money( .guid() .find(looter_guid) .ok_or_else(|| refused(LootRefusal::LooterUnavailable, "looter not in world"))?; - // A dead looter (killed with a loot window still open) can't loot — gate on the same `dead` flag - // every other action checks, so death stays server-authoritative. if looter.dead { return Err(refused( LootRefusal::LooterUnavailable, @@ -820,9 +622,6 @@ pub(crate) fn apply_loot_money( .find(target_guid) .ok_or_else(|| refused(LootRefusal::NoLootSource, "no such corpse"))?; - // Only CREATURE corpses are lootable. A dead/ghost PLAYER is also `dead` with a non-zero purse - // (loaded from character.money), so without this guard a second player in range could drain the - // victim's coinage (PvP purse theft). Money loot is creature-only until real player-loot rules. if corpse.is_player() { return Err(refused(LootRefusal::NoLootSource, "cannot loot a player")); } @@ -832,8 +631,6 @@ pub(crate) fn apply_loot_money( "Loot Source is not a corpse", )); } - // Map + instance gated: a creature corpse is a `game_world_entity` row, so its - // `instance_id` is already on it, so a looter can never reach across an instance wall. if corpse.map_id != looter.map_id { return Err(refused(LootRefusal::OutOfRange, "corpse on another map")); } @@ -861,19 +658,11 @@ pub(crate) fn apply_loot_money( corpse.money = 0; entities.guid().update(corpse); - // Split across the resolved corpse-eligibility set, never a current party lookup. The access - // Gate above makes an empty set a Refusal. A one-recipient set is solo and sends no - // `MONEY_SHARE` event. let recipients = corpse_eligible_recipients(ctx, target_guid); let grouped = money_is_grouped(&recipients); let shares = split_money(amount, &recipients, looter_guid); - // Credit the looter's own share in-place (avoid a stale-copy clobber: `looter` is updated ONCE, - // after the loop, never re-fetched mid-function) and every other recipient's purse via - // `credit_purse` (live entity if online, else the durable Character row — TRAP: a member who was - // online at KILL time (the snapshot only ever contains callers of `kill_reward_recipients` who - // were online then) may have logged off by LOOT time; vanilla still pays their share [V], so this - // never silently drops their copper). + // Credit the looter in this local copy to avoid overwriting a separately updated purse. for &(recipient_guid, share) in &shares { if share == 0 { continue; @@ -886,9 +675,6 @@ pub(crate) fn apply_loot_money( } entities.guid().update(looter); - // Only a real GROUPED split pushes `MONEY_SHARE` — a solo credit above never reaches here with - // `grouped == true`. Every recipient gets its own notify, including the looter (vanilla shows the - // looter the same "Your share of the loot is X" line as the rest of the party when grouped). if grouped { for &(recipient_guid, share) in &shares { if share == 0 { @@ -904,25 +690,11 @@ pub(crate) fn apply_loot_money( } } - // Money may not be the last thing on the corpse — the flag follows the RULE, not this path. refresh_lootable(ctx, target_guid); Ok(()) } -/// Credit `share` copper to `recipient_guid`'s purse: the live `WorldEntity.money` if currently -/// online (relayed to their own connection as `PLAYER_FIELD_COINAGE`), else the durable -/// `Character.money` row directly (an OFFLINE grouped recipient still gets paid; -/// `build_player_entity` loads `character.money` back into the entity at their next login). Saturating, -/// mirroring the looter's own transfer in [`apply_loot_money`]. No-op if the guid resolves to neither -/// (a deleted character mid-flight) — never panics on a stale snapshot row. -/// -/// DEFER verdict for the in-transit case. This is the one audited by-guid path a REFUSAL -/// gets wrong: the recipient is a PARTY MEMBER collecting their share of someone else's kill, so -/// dropping the write would silently short a third party who is not transferring and cannot know -/// why. `transfer::defer_money_delta` folds the copper into the escrowed export blob instead, so it -/// travels with the character. `begin_transfer` deleted the live entity, so the entity branch above -/// has already missed — the fold happens before the durable-row write, which stays as the -/// same-database payer of record (both partitions share the one `game_character` row). +// During Transfer, defer shares into Escrow so an in-flight recipient still gets paid. fn credit_purse(ctx: &ReducerContext, recipient_guid: u64, share: u32) { let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(recipient_guid) { @@ -938,9 +710,6 @@ fn credit_purse(ctx: &ReducerContext, recipient_guid: u64, share: u32) { } } -/// Split `total` copper evenly across the resolved corpse-eligibility set, with the remainder to -/// `looter_guid`. The access Gate ensures a real caller is in a non-empty set. The empty case stays -/// defined for this pure helper's callers and tests, but never authorizes money loot. Pure. pub(crate) fn split_money(total: u32, recipients: &[u64], looter_guid: u64) -> Vec<(u64, u32)> { if recipients.is_empty() { return vec![(looter_guid, total)]; @@ -966,16 +735,10 @@ pub(crate) fn split_money(total: u32, recipients: &[u64], looter_guid: u64) -> V shares } -/// Money shares are a party action only when the corpse has more than one eligible Character. pub(crate) fn money_is_grouped(recipients: &[u64]) -> bool { recipients.len() > 1 } -/// Re-derive `UNIT_DYNFLAG_LOOTABLE` for a corpse from what is actually left on it: -/// lootable ⇔ any `game_corpse_loot` row remains OR `money > 0`. The ONE clear/set point both -/// loot paths call after consuming — item-only corpses go dark when the last row is taken, and -/// taking the money early no longer strips the cursor while items remain. Writes only on a real -/// change (no spurious on_update relay); a despawned/non-corpse guid is a no-op. pub(crate) fn refresh_lootable(ctx: &ReducerContext, corpse_guid: u64) { let entities = ctx.db.game_world_entity(); let Some(mut corpse) = entities.guid().find(corpse_guid) else { @@ -1006,33 +769,28 @@ mod tests { #[test] fn loot_chance_is_half_open() { - // 10000 bp (100%) always drops (every 0..9999 roll is < 10000); 0 never drops. assert!(loot_drops(10000, 0)); assert!(loot_drops(10000, 9999)); assert!(!loot_drops(0, 0)); - // A 50% item (5000 bp) drops on the low half, not the high half. assert!(loot_drops(5000, 0)); assert!(loot_drops(5000, 4999)); assert!(!loot_drops(5000, 5000)); assert!(!loot_drops(5000, 9999)); - // Basis points represent sub-1% odds the old integer percent couldn't: 39 bp = 0.39%. assert!(loot_drops(39, 38)); assert!(!loot_drops(39, 39)); } #[test] fn group_pick_is_one_weighted_band() { - // A 3-item pool: bands [0,2000) [2000,5000) [5000,6000), total 6000 bp (60% to drop something). let w = [2000u32, 3000, 1000]; - assert_eq!(group_pick(&w, 0), Some(0)); // first band + assert_eq!(group_pick(&w, 0), Some(0)); assert_eq!(group_pick(&w, 1999), Some(0)); - assert_eq!(group_pick(&w, 2000), Some(1)); // second band + assert_eq!(group_pick(&w, 2000), Some(1)); assert_eq!(group_pick(&w, 4999), Some(1)); - assert_eq!(group_pick(&w, 5000), Some(2)); // third band + assert_eq!(group_pick(&w, 5000), Some(2)); assert_eq!(group_pick(&w, 5999), Some(2)); - assert_eq!(group_pick(&w, 6000), None); // past the cumulative weight → nothing drops + assert_eq!(group_pick(&w, 6000), None); assert_eq!(group_pick(&w, 9999), None); - // An equal-chance pool that sums to 10000 always yields exactly one member. let eq = [2500u32, 2500, 2500, 2500]; assert!(group_pick(&eq, 9999).is_some()); assert_eq!(group_pick(&eq, 2500), Some(1)); @@ -1040,7 +798,6 @@ mod tests { #[test] fn rank_money_scales_and_rank0_is_identity() { - // Rank 0 (normal) is the IDENTITY — a normal kill's purse is unchanged for any rolled amount. for money in [0u32, 1, 17, 1000, 1_000_000, u32::MAX] { assert_eq!( scale_money_for_rank(money, 0), @@ -1048,35 +805,25 @@ mod tests { "rank-0 money must be identity for {money}" ); } - // Elite/rare/boss money bumps (permille): a 100-copper roll scales by the documented factors. - assert_eq!(scale_money_for_rank(100, 1), 150); // elite ×1.5 - assert_eq!(scale_money_for_rank(100, 2), 200); // rare-elite ×2.0 - assert_eq!(scale_money_for_rank(100, 3), 300); // boss ×3.0 - assert_eq!(scale_money_for_rank(100, 4), 200); // rare ×2.0 - // Unknown rank → normal (never amplify an unrecognized value). + assert_eq!(scale_money_for_rank(100, 1), 150); + assert_eq!(scale_money_for_rank(100, 2), 200); + assert_eq!(scale_money_for_rank(100, 3), 300); + assert_eq!(scale_money_for_rank(100, 4), 200); assert_eq!(scale_money_for_rank(100, 9), 100); - // An empty roll (0 copper) stays 0 at any rank — a no-money creature drops nothing regardless. assert_eq!(scale_money_for_rank(0, 3), 0); - // The permille table: rank 0 is the identity divisor; elite/boss exceed normal. assert_eq!(rank_money_multiplier_permille(0), 1000); assert!(rank_money_multiplier_permille(1) > rank_money_multiplier_permille(0)); assert!(rank_money_multiplier_permille(3) > rank_money_multiplier_permille(1)); - // Saturating: a near-max roll × a >1 multiplier must not wrap. assert!(scale_money_for_rank(u32::MAX, 3) >= u32::MAX / 2); } - // ---- LOOT-FAMILY COMPLETENESS ---- - #[test] fn quest_take_allowed_admits_the_reservee_unconditionally_and_needing_takers_on_shared_rows() { - // Unreserved (shared) row: any needing taker is admitted; a non-needing one is rejected. assert!(quest_take_allowed(0, 7, true)); assert!( !quest_take_allowed(0, 7, false), "no need → no take on the unreserved shared row" ); - // Reserved for guid 7: guid 7 admitted regardless of current need; guid 8 rejected even if - // THEY also need it — the reservation is exclusive. assert!(quest_take_allowed(7, 7, true)); assert!( !quest_take_allowed(7, 8, true), @@ -1090,10 +837,6 @@ mod tests { ); } - /// `group_loot_take_allowed` — the take-time gate for NON-quest rows: a nonzero `reserved_for` - /// (winner-locked fallback) is exclusive and unconditional; a `master_only` row is NEVER takable - /// via the plain path (even by the stamped master — they must use `loot_master_give`); a - /// `designated_looter_guid` restricts to that guid; the zero/false baseline is plain FFA. #[test] fn group_loot_take_allowed_gates_withheld_reserved_master_and_designated_rows() { assert!( @@ -1104,9 +847,7 @@ mod tests { !group_loot_take_allowed(true, 7, false, 0, 7), "even the future reservee waits for resolution" ); - // Baseline: no restriction at all → anyone may take. assert!(group_loot_take_allowed(false, 0, false, 0, 7)); - // Reserved (winner-locked): only the reservee, regardless of the other flags. assert!(group_loot_take_allowed(false, 7, false, 0, 7)); assert!( !group_loot_take_allowed(false, 7, false, 0, 8), @@ -1116,13 +857,11 @@ mod tests { !group_loot_take_allowed(false, 7, true, 99, 8), "reserved wins over master_only/designated too" ); - // Master-only: nobody takes it via the plain path, not even the stamped master. assert!( !group_loot_take_allowed(false, 0, true, 42, 42), "master_only blocks even the master's OWN take" ); assert!(!group_loot_take_allowed(false, 0, true, 42, 8)); - // Designated (round-robin/below-threshold): only that guid. assert!(group_loot_take_allowed(false, 0, false, 42, 42)); assert!( !group_loot_take_allowed(false, 0, false, 42, 8), @@ -1130,9 +869,6 @@ mod tests { ); } - /// `clone_targets` — only the STILL-NEEDING other members get a clone; a member who capped out or - /// dropped the quest between roll and take gets nothing (mirrors vanilla: the item simply isn't - /// offered). Order-preserving (deterministic slot assignment downstream). #[test] fn clone_targets_admits_only_the_still_needing_members() { let others = [(10u64, true), (20u64, false), (30u64, true)]; @@ -1151,9 +887,6 @@ mod tests { ); } - /// `next_free_slots` — fresh ascending slots strictly past the corpse's current max, so a clone - /// never collides with the just-deleted original's slot or a sibling drop. An empty corpse (no - /// rows left) starts at slot 0, never panics. #[test] fn next_free_slots_never_collides_with_whats_already_on_the_corpse() { assert_eq!(next_free_slots(&[0, 1, 2], 2), vec![3, 4]); @@ -1163,15 +896,9 @@ mod tests { "an emptied corpse starts back at slot 0" ); assert_eq!(next_free_slots(&[5], 1), vec![6]); - // Saturating at the u8 ceiling — never panics/wraps even from an already-maxed corpse. assert_eq!(next_free_slots(&[255], 2), vec![255, 255]); } - // ---- Money-loot split ---- - - /// SOLO passthrough: an empty `recipients` slice (no `game_corpse_loot_eligible` snapshot) always - /// credits the WHOLE amount to the looter in a single entry — byte-identical to the - /// unconditional-credit path, regardless of amount. #[test] fn split_money_solo_passthrough_credits_the_whole_amount_to_the_looter() { assert_eq!(split_money(137, &[], 7), vec![(7, 137)]); @@ -1179,8 +906,6 @@ mod tests { assert_eq!(split_money(u32::MAX, &[], 7), vec![(7, u32::MAX)]); } - /// GROUPED even split: divides evenly with NO remainder when it divides cleanly, in the exact - /// recipient order given. #[test] fn split_money_even_split_with_no_remainder() { assert_eq!( @@ -1193,49 +918,35 @@ mod tests { ); } - /// GROUPED split with a remainder: the remainder (cmangos convention [V]) goes to whichever - /// recipient IS the looter, wherever it sits in the list — never to the first/last member - /// positionally. #[test] fn split_money_remainder_goes_to_the_looter() { - // 100 / 3 = 33 each, remainder 1 → the looter (guid 2, the middle entry) gets 34. assert_eq!( split_money(100, &[1, 2, 3], 2), vec![(1, 33), (2, 34), (3, 33)] ); - // Looter is the FIRST entry. assert_eq!( split_money(100, &[1, 2, 3], 1), vec![(1, 34), (2, 33), (3, 33)] ); - // Looter is the LAST entry. assert_eq!( split_money(100, &[1, 2, 3], 3), vec![(1, 33), (2, 33), (3, 34)] ); - // 7 copper across 2 recipients: 3 each, remainder 1 to the looter. assert_eq!(split_money(7, &[5, 9], 9), vec![(5, 3), (9, 4)]); } - /// If the looter is not itself in the snapshot (edge case — e.g. joined the group after the kill - /// was snapshotted), the remainder is folded into an EXTRA entry for the looter instead of being - /// silently dropped — every recipient still gets its even share. #[test] fn split_money_looter_outside_recipients_gets_an_extra_remainder_entry() { assert_eq!( split_money(100, &[10, 20, 30], 99), vec![(10, 33), (20, 33), (30, 33), (99, 1)] ); - // No remainder → no extra entry needed (nothing to fold in). assert_eq!( split_money(90, &[10, 20, 30], 99), vec![(10, 30), (20, 30), (30, 30)] ); } - /// A single-recipient "group" (shouldn't happen — `apply_group_loot_rules` never snapshots below - /// 2 recipients — but the pure fn degrades sanely if it ever did): the whole amount to that one - /// recipient, remainder folded in if they're the looter. #[test] fn split_money_single_recipient_degrades_to_whole_amount() { assert_eq!(split_money(50, &[7], 7), vec![(7, 50)]); diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index d2ac4a146..8c7576300 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -1,7 +1,7 @@ use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; use crate::game_corpse_loot; -use crate::game_group; // Group row: loot_method/loot_threshold/rr_cursor/master_looter_guid +use crate::game_group; use crate::game_item_template; use crate::game_world_entity; use lyracore_shared::loot::LootRefusal; @@ -9,14 +9,7 @@ use lyracore_shared::loot_roll::{event_kind as roll_event_kind, vote_kind}; use super::{refused, CorpseLoot}; -/// A member of a kill's loot-eligible set, resolved from the Loot Tag at death. One row per -/// `(corpse_guid, recipient)`. Private, no -/// RLS — only the module reads it (roll-vote snapshotting, `loot_master_give`'s recipient gate). -/// `eligible_guid` (not `character_guid`) deliberately dodges the crate's `character_owned` sweep- -/// marker tripwire (`tripwires.rs`): these rows are transient, corpse-lifetime data reaped alongside -/// `game_corpse_loot` on decay (`creatures::tick::pass_decay`) — never durable per-character state, so -/// they need no delete/restamp sweep on character delete/relog (a stale row just never matches any -/// live roll again). [entity] +// Corpse lifetime owns these rows; they are not Character-owned state. #[table(accessor = game_corpse_loot_eligible, index(accessor = by_corpse, btree(columns = [corpse_guid])))] pub struct CorpseLootEligible { #[primary_key] @@ -26,11 +19,6 @@ pub struct CorpseLootEligible { pub eligible_guid: u64, } -/// A live NEED/GREED/NBG roll on one `game_corpse_loot` row. Private — the gateway never reads this -/// (relayed via `game_group_event` instead, see the module doc above). `resolved` is the exactly-once -/// resolution guard: set the moment `resolve_roll` decides an outcome, checked -/// FIRST on every entry point (the deadline sweep AND a landing vote can both reach the same roll). -/// [entity] #[derive(Clone)] #[table(accessor = game_loot_roll, index(accessor = by_corpse, btree(columns = [corpse_guid])))] pub struct LootRoll { @@ -44,18 +32,11 @@ pub struct LootRoll { pub resolved: bool, #[default(0)] pub random_property_id: u32, - /// Source Module identity carried unchanged by promotion retries. #[default(Identity::ZERO)] pub promotion_source: Identity, } -/// One Loot Roll Promotion Receipt per source Module identity, corpse and slot. Resolution keeps -/// this high-water mark; replacing it with a later source id also covers every older replay. -/// -/// The source `game_loot_roll` auto-increment sequence orders generations, so content imports must -/// not replace that table or insert explicit roll ids. Restoring an older copy under the same -/// identity can reuse ids and needs its own recovery procedure. Receipts never expire: a retired -/// source can still have delayed requests in flight, and nothing refuses them yet. +// Keep promotion receipts after resolution; delayed retries must not recreate rolls. #[table(accessor = game_loot_roll_promotion_receipt, index(accessor = by_source_slot, btree(columns = [promotion_source, corpse_guid, slot])))] pub struct LootRollPromotionReceipt { #[primary_key] @@ -67,14 +48,6 @@ pub struct LootRollPromotionReceipt { pub source_roll_id: u64, } -/// One eligible member's vote on a [`LootRoll`] — snapshotted (one row per eligible guid) the moment -/// the roll starts, so a member joining the group later never joins a live roll. `voted=false` rows -/// auto-pass at resolution (deadline OR all-voted). `rolled` is assigned THE MOMENT a NEED/GREED vote -/// lands (mirrors real vanilla: `SMSG_LOOT_ROLL`'s roll number is generated at vote time, not -/// deferred to resolution) — `0` for a pass (real or auto-) and never read for one. Private, no RLS -/// (the module resolves rolls server-side; the gateway never reads vote state directly). `voter_guid` -/// (not `character_guid`) dodges the `character_owned` tripwire — same transient-data rationale as -/// [`CorpseLootEligible`]. [entity] #[derive(Clone)] #[table(accessor = game_loot_roll_vote, index(accessor = by_roll, btree(columns = [roll_id])))] pub struct LootRollVote { @@ -84,27 +57,17 @@ pub struct LootRollVote { pub roll_id: u64, pub voter_guid: u64, pub voted: bool, - pub vote: u8, // vote_kind::* — meaningful only if `voted` - pub rolled: u8, // 1-100 once a NEED/GREED vote lands; 0 otherwise + pub vote: u8, // Meaningful only when voted is true. + pub rolled: u8, // 1..=100 for Need or Greed; zero otherwise. } -/// Roll-window countdown (vanilla: 60s). pub(crate) const ROLL_WINDOW_MICROS: i64 = 60_000_000; -/// Which handling applies to ONE `game_corpse_loot` row under the group's current `method`/ -/// `threshold`, given the row's item `quality`? Pure — the single decision table every group-loot -/// stamping choice below reduces to; unit-tested as the "threshold split" matrix. An unrecognized -/// `method` (shouldn't happen — `group_loot_method` validates it) degrades to `Ffa` (never invents a -/// restriction from a bad value). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GroupLootDecision { - /// No restriction — behaves exactly as before this feature (FFA method, or a bad/unknown value). Ffa, - /// Round-robin/below-threshold-GROUP: stamp the corpse's one pre-picked designee. Designate, - /// Above-threshold under GROUP or any row under NEED_BEFORE_GREED: spawn a need/greed/pass roll. Roll, - /// Above-threshold under MASTER: stamp the master + `master_only`. Master, } @@ -136,10 +99,6 @@ pub(crate) fn group_loot_decision_for_row( } } -/// Round-robin cursor advance (trap: "skipping offline members"): given a STABLE -/// member ordering's online bitmap and the group's current `cursor`, return the chosen member's -/// INDEX into that same ordering plus the cursor value to persist for NEXT time (wrapping). `None` -/// if nobody is online (a fully-offline group — degrades to FFA, never panics/blocks looting). Pure. pub(crate) fn advance_rr_cursor(online: &[bool], cursor: u32) -> Option<(usize, u32)> { let n = online.len(); if n == 0 { @@ -155,9 +114,6 @@ pub(crate) fn advance_rr_cursor(online: &[bool], cursor: u32) -> Option<(usize, None } -/// Given every ELIGIBLE (voted-or-auto-passed) member's vote kind, which tier contends for the item — -/// NEED beats GREED, an empty result means everyone passed (the row unlocks FFA-in-group)? Pure. -/// Returns the CONTENDING members' indices into `votes` (order-preserving). pub(crate) fn contending_tier(votes: &[u8]) -> Vec { let need: Vec = votes .iter() @@ -176,9 +132,6 @@ pub(crate) fn contending_tier(votes: &[u8]) -> Vec { .collect() } -/// Pick the winner among a contending tier's already-rolled 1-100 values: the unique max, or `None` -/// on a TIE (multiple share the max — cmangos re-rolls internally and re-decides; the caller loops -/// this with fresh rolls for the tied members until it resolves, then announces ONCE). Pure. pub(crate) fn pick_roll_winner(rolls: &[u8]) -> Option { if rolls.is_empty() { return None; @@ -191,20 +144,12 @@ pub(crate) fn pick_roll_winner(rolls: &[u8]) -> Option { .map(|(i, _)| i); let first = winners.next()?; if winners.next().is_some() { - None // a second index shares the max — a tie + None } else { Some(first) } } -/// KILL-TIME group-loot stamping (module doc above explains the timing choice): called once from -/// `combat::kill_creature` right after `roll_creature_loot`, for a grouped Loot Tag only. The -/// corpse-eligibility rows are the one recipient set for designation, master loot, and rolls. -/// -/// A solo player with method GROUP set must not roll threshold rows: a solo -/// `recipients` (`len() < 2`, e.g. every other member out of XP range or dead) skips ALL group-loot -/// handling too — vanilla's "party size 1 -> direct loot" applies to loot exactly like it does to -/// the XP split, so the same recipient-count gate covers both. pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, group_id: u64) { let recipients = super::tag::corpse_eligible_recipients(ctx, corpse_guid); if recipients.len() < 2 { @@ -218,8 +163,7 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro return; } - // One STABLE member ordering (sorted by character_guid — deterministic regardless of join - // order) so the rr_cursor index means the same thing kill after kill. + // Stable GUID order keeps the round-robin cursor meaningful across kills. let mut members = recipients.clone(); members.sort_unstable(); let online: Vec = members @@ -250,11 +194,11 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro .by_corpse() .filter(&corpse_guid) .collect(); - let mut master_rows: Vec = Vec::new(); // slots stamped master_only, for the MASTER_LIST push + let mut master_rows: Vec = Vec::new(); let now = ctx.timestamp.to_micros_since_unix_epoch(); for mut row in rows { if row.quest_only { - continue; // orthogonal systems — never group-gate a quest drop (module doc invariant) + continue; } let quality = templates .entry() @@ -304,9 +248,6 @@ pub(crate) fn apply_group_loot_rules(ctx: &ReducerContext, corpse_guid: u64, gro } } -/// Create one Loot Roll and its votes, or return the existing roll for this corpse and slot. -/// Repeated starts preserve votes, recipients and deadline. No event is emitted here. -/// Promotion checks its durable receipt before reaching this local row creation path. #[allow(clippy::too_many_arguments)] fn insert_roll_rows( ctx: &ReducerContext, @@ -350,14 +291,6 @@ fn insert_roll_rows( roll.id } -/// Spawn a NEED/GREED/NBG roll at KILL TIME: [`insert_roll_rows`] + `ROLL_START` to every recipient. -/// -/// This ALWAYS runs on the corpse's own WORLD SHARD — kill-time combat resolution has no -/// path to another database — so in a SHARDED deployment the row it creates is a TRANSIENT staging -/// copy: the gateway's loot-roll relay (`gateway/src/world/loot.rs::relay_tick`) promotes it onto -/// realm-core and clears this copy (`clear_promoted_loot_roll`) so voting/resolution never runs -/// twice. Unsharded, nothing promotes or clears it — it just IS the roll, byte-identical to before -/// this issue. fn start_roll( ctx: &ReducerContext, corpse_guid: u64, @@ -389,7 +322,6 @@ fn start_roll( } } -/// Stage a reserved Loot Roll through the same creation path as a killing blow. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_stage_loot_roll_fixture(ctx: &ReducerContext) -> Result<(), String> { @@ -406,13 +338,6 @@ pub fn debug_stage_loot_roll_fixture(ctx: &ReducerContext) -> Result<(), String> Ok(()) } -/// The identity-free vote core (mirrors `group.rs`'s `*_on` cores): shared by the player -/// reducer above (the world shard the voter is physically standing on) and `realm_loot_op`'s VOTE arm -/// (realm-core, once the roll has been promoted there). Same gates, same resolution, in the same -/// order — `loot_roll` is byte-identical for clients. Unlike a group op, nothing about a vote's RULES -/// differs by which database it runs on, only which one `game_loot_roll`'s `ctx.db` resolves to — the -/// caller already picked that by calling this on the right connection, so (smalls) there is -/// no `plane` parameter to thread through. pub(crate) fn cast_vote_on( ctx: &ReducerContext, corpse_guid: u64, @@ -446,7 +371,7 @@ pub(crate) fn cast_vote_on( } else { (ctx.random::() % 100 + 1) as u8 }; - let my_rolled = my_vote.rolled; // captured before `my_vote` moves into `update` below + let my_rolled = my_vote.rolled; votes.id().update(my_vote); let all: Vec = votes.by_roll().filter(&roll.id).collect(); @@ -475,22 +400,6 @@ pub(crate) fn cast_vote_on( Ok(()) } -/// Grant a resolved roll's item to its winner, on WHICHEVER database holds the corpse. -/// -/// Called inline by [`resolve_roll`] / [`force_resolve_rolls_for_disband`], wherever they execute — -/// a single database, or REALM-CORE in a sharded one — and by the [`settle_loot_roll`] operator -/// reducer, which the gateway's loot-roll relay calls on the corpse's OWN world shard after observing -/// the `ROLL_WON` event realm-core just pushed. All three call sites are safe to call -/// unconditionally: there is nothing to settle where no row matches at all (REALM-CORE never has -/// `game_corpse_loot` rows — no kills happen there — so the two inline callers no-op for free there), -/// and nothing to settle a second time (the row is gone after the first successful grant). -/// -/// `withheld` is checked FIRST — the roll's own lock on the row, the same gate -/// [`super::group_loot_take_allowed`] uses — so a `(corpse_guid, slot)` pair that merely COINCIDES with -/// an unrelated row on another shard (`corpse_guid` is a per-shard `game_world_entity` sequence, not -/// realm-unique — the tail risk this issue's cross-shard-routing note accepts, same class as -/// character-name collisions elsewhere in this codebase) can never be mistaken for the row that just -/// resolved: a row that is not mid-roll is never touched by this function, however its numbers land. pub(crate) fn settle_roll_grant( ctx: &ReducerContext, corpse_guid: u64, @@ -503,11 +412,7 @@ pub(crate) fn settle_roll_grant( .filter(&corpse_guid) .find(|l| l.slot == slot) else { - // - // It cannot be an error, because the gateway fans `settle_loot_roll` out to every shard and - // "no row here" is the normal answer on all but one. So it is logged at INFO and worded so - // the two cases are TELLABLE APART BY CONTEXT — exactly one shard should have held this - // corpse, so if NO shard logs a grant for a settled roll, this line is where the item went. + // Settlement fans out to every World Shard, so a missing corpse row is expected. spacetimedb::log::info!( "settle_roll_grant: corpse {corpse_guid} slot {slot} — no loot row on this database \ (expected on every shard but the corpse's owner; if NO shard granted, the corpse was \ @@ -516,13 +421,11 @@ pub(crate) fn settle_roll_grant( return; }; if !row.withheld { - // Not mid-roll: this row belongs to some other corpse/slot pairing on this shard, or the - // roll already settled here. Distinct from the branch above — the row EXISTS. spacetimedb::log::info!( "settle_roll_grant: corpse {corpse_guid} slot {slot} — row is not withheld, so no roll \ is open on it here; nothing granted" ); - return; // not a live roll's row on THIS database — see the doc above + return; } match crate::items::grant_item_property( ctx, @@ -536,7 +439,6 @@ pub(crate) fn settle_roll_grant( super::refresh_lootable(ctx, corpse_guid); } Err(_) => { - // Inventory-full fallback: leave the row, exclusively reserved for the winner. row.withheld = false; row.reserved_for = winner_guid; row.designated_looter_guid = 0; @@ -546,16 +448,9 @@ pub(crate) fn settle_roll_grant( } } -/// Resolve exactly once (the `resolved` flag guard, trap): any NEED beats any GREED; -/// a TIE within the winning tier re-rolls JUST the tied members internally (bounded — 1-100 rolls -/// collide vanishingly rarely, `MAX_TIE_REROLLS` is generous headroom) and announces once; all-pass -/// unlocks the row FFA-in-group (no `ROLL_WON` — vanilla shows no "won" line either). The winner is -/// granted via `items::grant_item`; on `Err` (inventory full) the row is LEFT, `reserved_for` stamped -/// to the winner (documented winner-locked fallback — mail delivery is the eventual fix). -/// `votes`/`recipients` are the FULL snapshot (already auto-passed by the caller where needed). fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], recipients: &[u64]) { if roll.resolved { - return; // exactly-once guard + return; } ctx.db.game_loot_roll().id().update(LootRoll { resolved: true, @@ -581,21 +476,15 @@ fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], r attempts += 1; } let Some(w) = winner_in_tier else { - // Astronomically unlikely (20 straight all-tied re-rolls); fail safe to the first tied - // member rather than looping forever or panicking. unlock_row_ffa(ctx, roll.corpse_guid, roll.slot); cleanup_roll(ctx, roll.id); return; }; let winning_roll = rolls[w]; let winner_guid = votes[tier[w]].voter_guid; - let winning_vote = votes[tier[w]].vote; // The winning tier determines whether the vote was Need or Greed. + let winning_vote = votes[tier[w]].vote; - // Grant, then finalize the CorpseLoot row per the outcome (module doc: 068 mail is deferred). - // On a single database this runs inline, right here, exactly as before. On REALM-CORE - // (sharded) `game_corpse_loot` is always empty — no kills happen there — so this no-ops for free, - // and the ACTUAL grant happens on the corpse's own world shard via `settle_loot_roll`, which the - // gateway's loot-roll relay calls after observing the `ROLL_WON` event pushed below. + // Realm-core has no corpse loot; its ROLL_WON Relay settles on the owning World Shard. settle_roll_grant(ctx, roll.corpse_guid, roll.slot, winner_guid); let payload = lyracore_shared::loot_roll::encode_won( roll.corpse_guid, @@ -617,9 +506,6 @@ fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], r cleanup_roll(ctx, roll.id); } -/// All-pass unlock: clear `withheld` so the row becomes an ordinary FFA-in-group row (any current -/// group member may loot it via the plain path — `designated_looter_guid`/`master_only` stay at -/// their defaults, so there is no further restriction). fn unlock_row_ffa(ctx: &ReducerContext, corpse_guid: u64, slot: u8) { let loot = ctx.db.game_corpse_loot(); if let Some(mut row) = loot @@ -632,7 +518,6 @@ fn unlock_row_ffa(ctx: &ReducerContext, corpse_guid: u64, slot: u8) { } } -/// Delete a resolved roll's vote rows + the roll row itself — nothing left to sweep/orphan. fn cleanup_roll(ctx: &ReducerContext, roll_id: u64) { let votes = ctx.db.game_loot_roll_vote(); let stale: Vec = votes.by_roll().filter(&roll_id).map(|v| v.id).collect(); @@ -642,12 +527,6 @@ fn cleanup_roll(ctx: &ReducerContext, roll_id: u64) { ctx.db.game_loot_roll().id().delete(roll_id); } -/// Deadline sweep ("disconnected member mid-roll: their vote auto-passes at -/// deadline"): scans every UNRESOLVED roll whose deadline has elapsed, auto-passes any still-`!voted` -/// member (in place — never blocks), then resolves. `pub(crate)` — exposed for the orchestrator's -/// scheduled-GC tick (`gc.rs`, NOT edited here per instructions): wire one call -/// `crate::loot::sweep_loot_rolls(ctx);` into `reap_movement_events` (it already runs every -/// `EVENT_TTL_MICROS` = 1s, ample granularity for a 60s roll window). pub(crate) fn sweep_loot_rolls(ctx: &ReducerContext) { let now = ctx.timestamp.to_micros_since_unix_epoch(); let due: Vec = ctx @@ -672,13 +551,6 @@ pub(crate) fn sweep_loot_rolls(ctx: &ReducerContext) { } } -/// Disband mid-roll ("resolve to sole member"): called from -/// `group::remove_member`'s full-disband branch with the FULL former membership (`member_guids`, -/// leaver included) and the sole survivor if exactly one remains. Force-resolves every UNRESOLVED -/// roll whose ENTIRE voter set belonged to the disbanding group: with a sole survivor, grants them -/// the item directly (bypassing need/greed entirely — vanilla's behavior); with none (or more than -/// one — shouldn't happen, disband implies <2 remain), just unlocks FFA (nothing left to award to, -/// or the caller mis-invoked with a still-live group). pub(crate) fn force_resolve_rolls_for_disband( ctx: &ReducerContext, member_guids: &[u64], @@ -694,7 +566,7 @@ pub(crate) fn force_resolve_rolls_for_disband( let votes_tab = ctx.db.game_loot_roll_vote(); let all: Vec = votes_tab.by_roll().filter(&roll.id).collect(); if all.is_empty() || !all.iter().all(|v| member_guids.contains(&v.voter_guid)) { - continue; // not this group's roll + continue; } ctx.db.game_loot_roll().id().update(LootRoll { resolved: true, @@ -702,13 +574,8 @@ pub(crate) fn force_resolve_rolls_for_disband( }); match survivor.filter(|s| all.iter().any(|v| v.voter_guid == *s)) { Some(winner_guid) => { - // Inline on a single database (unchanged), a free no-op on REALM-CORE - // (no local `game_corpse_loot` row there) — the real grant then settles onto the - // corpse's own world shard via `settle_loot_roll`, driven by the gateway's loot-roll - // relay off the `ROLL_WON` event pushed below. See `settle_roll_grant`'s own doc. settle_roll_grant(ctx, roll.corpse_guid, roll.slot, winner_guid); - // Forced disband resolution: no contest happened — label it NEED (the sole - // survivor takes by default; there is no honest greed/need distinction here). + // Forced disband has no contest; Need is the label sent for the sole survivor. let payload = lyracore_shared::loot_roll::encode_won( roll.corpse_guid, roll.slot, @@ -731,21 +598,8 @@ pub(crate) fn force_resolve_rolls_for_disband( } } -// =========================================================================================== -// REALM-CORE plane — mirrors `group.rs`'s realm-core section for the roll itself. -// =========================================================================================== - -// `#[reducer]`: SpacetimeDB reducers take their arguments FLAT off the wire, so a parameter struct is not available. #[allow(clippy::too_many_arguments)] -/// The realm-wide loot-roll ops, as ONE operator-gated reducer keyed by -/// [`lyracore_shared::loot_roll::loot_op`] — the same one-reducer-not-several trade `realm_group_op` -/// made (`lyracore_shared::group::realm_op`'s doc), for the same reason: every gateway-callable reducer -/// needs a hand-maintained SDK binding (`docs/danger-zones.md` §1.2). -/// -/// **Operator-gated, and it has to be**: both ops act on realm-core, which has no live entity to -/// derive an actor from. START's `recipients` are a snapshot the WORLD SHARD already computed -/// spatially at kill time (`kill_reward_recipients`); VOTE's `actor_guid` is the guid the gateway -/// authenticated for the calling socket (`InWorld::self_guid`), never a client-supplied literal. +// Realm-core has no live Actor; the Operator must authenticate the supplied guid. #[reducer] pub fn realm_loot_op( ctx: &ReducerContext, @@ -781,8 +635,6 @@ pub fn realm_loot_op( } } -/// A replay is an acknowledged no-op, even after resolution. A newer generation waits for the -/// current roll to resolve. Both outcomes leave that current roll and its votes unchanged. #[allow(clippy::too_many_arguments)] fn accept_promotion( ctx: &ReducerContext, @@ -849,10 +701,6 @@ fn accept_promotion( Ok(()) } -/// Grant a resolved roll's item on the WORLD SHARD that actually holds the corpse. The -/// gateway's loot-roll relay calls this on every connected world shard after observing realm-core's -/// `ROLL_WON` event — [`settle_roll_grant`]'s own guards make every wrong-shard call a harmless -/// no-op, so the relay never needs to know in advance WHICH shard holds the corpse. #[reducer] pub fn settle_loot_roll( ctx: &ReducerContext, @@ -867,10 +715,7 @@ pub fn settle_loot_roll( Ok(()) } -/// Delete a STAGING roll's rows on the WORLD SHARD that created them, once the gateway's loot-roll -/// relay has promoted the roll onto realm-core — [`cleanup_roll`], the same delete a -/// resolved roll already used, called here for an UNRESOLVED one that just changed which database is -/// authoritative for it. +// Clear staging only after promotion succeeds on Realm-core. #[reducer] pub fn clear_promoted_loot_roll( ctx: &ReducerContext, @@ -883,9 +728,6 @@ pub fn clear_promoted_loot_roll( Ok(()) } -/// The identity-free master-give core (the `cast_vote_on` shape): everything -/// [`loot_master_give`] does after resolving WHO the master looter is. `gw::gw_loot_master_give` is -/// the other entry. pub(crate) fn apply_master_give( ctx: &ReducerContext, master_guid: u64, @@ -936,7 +778,6 @@ pub(crate) fn apply_master_give( Ok(()) } -/// Convert the stable capacity refusal. Missing templates and other grant failures remain errors. fn master_delivery_error(error: String) -> String { if error == lyracore_shared::mail::INVENTORY_FULL { refused(LootRefusal::RecipientInventoryFull, &error) @@ -961,24 +802,17 @@ mod tests { ); } - /// `group_loot_decision_for_row` — the threshold-split matrix: FFA never restricts; ROUND_ROBIN - /// always designates regardless of quality; GROUP splits on the threshold (below → designate, - /// at/above → roll); NEED_BEFORE_GREED always rolls regardless of quality; MASTER splits on the - /// threshold (below → designate, at/above → master-only). An unrecognized method degrades to Ffa. #[test] fn group_loot_decision_matches_the_threshold_split_matrix() { use crate::group::loot_method::*; use GroupLootDecision::*; - // FFA: never restricts, at any quality. assert_eq!(group_loot_decision_for_row(FFA, 0, 2), Ffa); assert_eq!(group_loot_decision_for_row(FFA, 6, 2), Ffa); - // ROUND_ROBIN: always designate, below AND at/above the threshold. assert_eq!(group_loot_decision_for_row(ROUND_ROBIN, 0, 2), Designate); assert_eq!(group_loot_decision_for_row(ROUND_ROBIN, 6, 2), Designate); - // GROUP: the threshold split — below designates, AT (inclusive) and above rolls. assert_eq!( group_loot_decision_for_row(GROUP, 1, 2), Designate, @@ -995,42 +829,31 @@ mod tests { "well above the threshold rolls" ); - // NEED_BEFORE_GREED: always rolls, ignoring the threshold entirely. assert_eq!(group_loot_decision_for_row(NEED_BEFORE_GREED, 0, 2), Roll); assert_eq!(group_loot_decision_for_row(NEED_BEFORE_GREED, 6, 6), Roll); - // MASTER: the same threshold split as GROUP, but Master instead of Roll. assert_eq!(group_loot_decision_for_row(MASTER, 1, 2), Designate); assert_eq!(group_loot_decision_for_row(MASTER, 2, 2), Master); assert_eq!(group_loot_decision_for_row(MASTER, 6, 2), Master); - // An unrecognized method (shouldn't happen post-validation) never invents a restriction. assert_eq!(group_loot_decision_for_row(255, 6, 2), Ffa); } - /// `advance_rr_cursor` — round-robin trap: "skipping offline members". Wraps around a STABLE - /// ordering, picks the next ONLINE member from the current cursor (inclusive), and returns - /// `None` only when the whole group is offline (never panics/blocks looting). #[test] fn advance_rr_cursor_skips_offline_members_and_wraps() { - // All online: cursor 0 picks index 0, advances to 1. assert_eq!(advance_rr_cursor(&[true, true, true], 0), Some((0, 1))); assert_eq!(advance_rr_cursor(&[true, true, true], 1), Some((1, 2))); - // Wraps past the end back to 0. assert_eq!(advance_rr_cursor(&[true, true, true], 2), Some((2, 0))); - // Member at the cursor is OFFLINE — skip forward to the next online member. assert_eq!( advance_rr_cursor(&[true, false, true], 1), Some((2, 0)), "index 1 is offline, skip to 2" ); - // The skip WRAPS if needed (cursor near the end, only an early member online). assert_eq!( advance_rr_cursor(&[true, false, false], 2), Some((0, 1)), "wraps around to the only online member" ); - // Nobody online at all → None (degrades to FFA at the call site, never panics). assert_eq!(advance_rr_cursor(&[false, false], 0), None); assert_eq!( advance_rr_cursor(&[], 0), @@ -1039,25 +862,16 @@ mod tests { ); } - /// `contending_tier` — NEED beats GREED unconditionally; an all-pass vote set contends nothing - /// (the caller unlocks FFA-in-group). Order-preserving indices into the input slice. #[test] fn contending_tier_need_beats_greed_and_empty_means_all_passed() { use vote_kind::*; - // A mix: NEED wins even though GREED/PASS are also present. assert_eq!(contending_tier(&[PASS, GREED, NEED, GREED]), vec![2]); - // Multiple NEEDs all contend (the tie-break is a separate step, `pick_roll_winner`). assert_eq!(contending_tier(&[NEED, PASS, NEED]), vec![0, 2]); - // No NEED at all → GREED contends. assert_eq!(contending_tier(&[PASS, GREED, GREED]), vec![1, 2]); - // Everyone passed → nobody contends (empty). assert!(contending_tier(&[PASS, PASS, PASS]).is_empty()); assert!(contending_tier(&[]).is_empty()); } - /// `pick_roll_winner` — the vote-resolution matrix's final step: the unique max roll wins; a - /// TIE for the max (two+ share it) returns `None` so the caller re-rolls just the tied members - /// (cmangos's internal re-roll) rather than picking arbitrarily. #[test] fn pick_roll_winner_picks_the_unique_max_or_none_on_a_tie() { assert_eq!(pick_roll_winner(&[50, 99, 12]), Some(1)); diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 0fdf4274e..0b9552fe0 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -1,10 +1,3 @@ -//! Durable Loot Tag ownership for live creatures. -//! -//! The first positive player-controlled threat fixes the tagger and party roster. Death resolves -//! that ceiling against the same party's current membership and the existing kill-reward range, -//! then snapshots the result into corpse eligibility. The two `game_creature_quest_tap*` table -//! names stay unchanged because generated bindings and deployed schemas already pin them. - use std::collections::BTreeSet; use spacetimedb::{table, ReducerContext, Table}; @@ -22,7 +15,6 @@ use crate::{ game_melee_attack, game_player_skill, }; -/// The Character whose controlled unit first generated positive threat on this creature. #[table(accessor = game_creature_quest_tap)] pub struct CreatureQuestTap { #[primary_key] @@ -30,7 +22,6 @@ pub struct CreatureQuestTap { pub character_guid: u64, } -/// One Character in the Loot Tag's tag-time party roster. #[table( accessor = game_creature_quest_tap_member, index(accessor = by_creature, btree(columns = [creature_guid])) @@ -43,11 +34,7 @@ pub struct CreatureQuestTapMember { pub character_guid: u64, } -/// The party identity captured with a grouped Loot Tag. -/// -/// The retained tag tables cannot distinguish a two-person party's leaver after that party -/// disbands because both Characters then have no current group row. This additive row records the -/// original party without changing either pinned table shape. Its absence means the tag was solo. +// Preserve the original party identity even if both members leave and current rows disappear. #[table(accessor = game_creature_loot_tag_group)] pub struct CreatureLootTagGroup { #[primary_key] @@ -55,13 +42,11 @@ pub struct CreatureLootTagGroup { pub group_id: u64, } -/// A resolved Loot Tag at the death site. pub(crate) struct DeathEntitlement { pub group_id: Option, pub recipients: Vec, } -/// Whether a live creature's Loot Tag can still reward one Character. #[cfg_attr(not(has_packages), allow(dead_code))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum LiveLootTagEligibility { @@ -70,8 +55,6 @@ pub(crate) enum LiveLootTagEligibility { ReadLimit, } -/// Resolve a threat source to its controlling Character. A Character controls itself and its -/// owned creature. Dead or missing Characters cannot create a Loot Tag. pub(crate) fn controlling_character(ctx: &ReducerContext, source_guid: u64) -> Option { let entities = ctx.db.game_world_entity(); let source = entities.guid().find(source_guid)?; @@ -85,8 +68,7 @@ pub(crate) fn controlling_character(ctx: &ReducerContext, source_guid: u64) -> O .map(|owner| owner.guid) } -/// Record the first positive player-controlled threat on a live wild creature. Later calls are -/// no-ops. The stored entity receives `TAPPED`, while `TAPPED_BY_PLAYER` remains viewer-relative. +// TAPPED is stored; TAPPED_BY_PLAYER remains viewer-relative. pub(crate) fn record_first_threat( ctx: &ReducerContext, creature_guid: u64, @@ -150,9 +132,6 @@ pub(crate) fn record_first_threat( true } -/// Resolve the tag-time ceiling at a kill or EventAI credit site. A grouped member must still -/// belong to the captured party. Every recipient must also be alive, present on the same map and -/// instance, and within the 74-yard reward range. pub(crate) fn death_entitlement( ctx: &ReducerContext, creature_guid: u64, @@ -216,10 +195,6 @@ fn membership_is_current( } } -/// Read one live Loot Tag: tap membership alone, with no reward-distance check. A Package -/// calls it so a bot skips a creature another Character has tagged, and keeps fighting one -/// it still has a claim on. Loot and quest credit read the same membership rule once, at death, -/// through `death_entitlement`. The read is bounded at the Raid cap. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn live_loot_tag_eligibility( ctx: &ReducerContext, @@ -261,8 +236,7 @@ pub(crate) fn live_loot_tag_eligibility( } } -/// Remove a former party member from every live Loot Tag owned by that party. Rejoining the same -/// party later must not restore rights from an earlier tag-time snapshot. +// Rejoining must not restore rights from an earlier Loot Tag snapshot. pub(crate) fn revoke_group_member(ctx: &ReducerContext, group_id: u64, character_guid: u64) { let tagged_creatures: Vec = ctx .db @@ -284,8 +258,6 @@ pub(crate) fn revoke_group_member(ctx: &ReducerContext, group_id: u64, character } } -/// Replace a corpse's eligibility rows with the resolved Loot Tag recipients. The input is -/// deduplicated so every eligible Character receives exactly one row, including a solo tagger. pub(crate) fn record_corpse_eligibility( ctx: &ReducerContext, corpse_guid: u64, @@ -309,7 +281,6 @@ pub(crate) fn record_corpse_eligibility( } } -/// Read the canonical corpse-eligibility set in stable guid order. pub(crate) fn corpse_eligible_recipients(ctx: &ReducerContext, corpse_guid: u64) -> Vec { ctx.db .game_corpse_loot_eligible() @@ -325,9 +296,7 @@ pub(crate) fn corpse_eligible_for_access(recipients: &[u64], actor_guid: u64) -> recipients.binary_search(&actor_guid).is_ok() } -/// Require an Actor to appear in the corpse's resolved eligibility set. An empty set is -/// authoritative, so an unentitled corpse is not solo loot. Call this only after resolving a -/// creature corpse; GameObject loot has no Loot Tag and keeps its existing rules. +// Empty corpse eligibility refuses everyone; GameObjects do not use Loot Tags. pub(crate) fn corpse_access_gate( ctx: &ReducerContext, actor_guid: u64, @@ -341,8 +310,6 @@ pub(crate) fn corpse_access_gate( }) } -/// Typed Loot Tag Gate for in-module Actor requests. The reducer adapter above keeps the stable -/// wire tag for legacy callers; Package callers retain the refusal without parsing that tag. pub(crate) fn corpse_access( ctx: &ReducerContext, actor_guid: u64, @@ -355,8 +322,6 @@ pub(crate) fn corpse_access( Err(LootRefusal::LootTagIneligible) } -/// Clear a creature's live Loot Tag at combat end or despawn. Corpse and other dynamic flags stay -/// intact; only the live tag bits are removed. pub(crate) fn clear(ctx: &ReducerContext, creature_guid: u64) { ctx.db .game_creature_quest_tap() @@ -403,11 +368,9 @@ const LOOT_TAG_FIXTURE_CHARACTER_E: u64 = 5_093_855; #[cfg(feature = "debug_reducers")] const LOOT_TAG_FIXTURE_CHARACTER_F: u64 = 5_093_856; -/// Exercise Loot Tag creation, lifetime, recipient resolution, and tag-owned death against live -/// Module rows in one isolated standalone transaction. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] -#[allow(clippy::too_many_lines)] // One durable step per staged fixture row. +#[allow(clippy::too_many_lines)] pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> { let origin = ctx .db diff --git a/module/src/mail.rs b/module/src/mail.rs index 4e22faad9..914fe58f3 100644 --- a/module/src/mail.rs +++ b/module/src/mail.rs @@ -1,6 +1,3 @@ -//! Durable mail rows and single-database mailbox operations. -//! Attachments are item snapshots; cross-database moves live in `mail_escrow`. - use lyracore_shared::mail::{MailSender, CHECK_MASK_RETURNED}; use spacetimedb::{reducer, table, ReducerContext, Table, Timestamp}; @@ -28,28 +25,20 @@ pub struct Mail { pub created_at: Timestamp, #[default(0)] pub random_property_id: u32, - /// The vanilla `MailMessageType` code of the [`MailSender`]. 0 is a Character. #[default(0u8)] pub sender_kind: u8, - /// The auction house id, or the creature or gameobject entry. 0 for a Character. #[default(0u32)] pub sender_entry: u32, - /// `CHECK_MASK_*` bits other than READ, which is `was_read`. #[default(0u32)] pub check_flags: u32, #[default(0u32)] pub mail_template_id: u32, - /// When the recipient can first see the mail. 0 means from creation. #[default(0i64)] pub deliver_micros: i64, - /// The attached Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`. 0 when the attachment is not a - /// Letter Copy's letter. #[default(0u32)] pub item_text_id: u32, } -// A Transfer runs this sweep on the source after the rows travel, so it destroys no letter. The -// Character deletion that does destroy them drops their text first -// (`mail_text::drop_character_letters`). +// Transfer deletes transported source rows without destroying their letter text. crate::character_owned!(delete, fn sweep_delete_game_mail(ctx, character_guid) { let mails = ctx.db.game_mail(); for r in mails.by_recipient().filter(&character_guid).collect::>() { @@ -87,7 +76,6 @@ pub(crate) enum Attach { Soulbound, Detach, } -/// `owned` is `(owner_guid, soulbound)` off the live row, when one exists. pub(crate) fn plan_attach(item_guid: u64, owned: Option<(u64, bool)>, sender_guid: u64) -> Attach { if item_guid == 0 { return Attach::Nothing; @@ -139,7 +127,6 @@ pub(crate) fn grant_snapshot( .ok_or_else(|| format!("mail: no template for attached item {}", snapshot.entry))?; crate::items::store_instance_state(ctx, payee_guid, payee.owner_identity, &tmpl, None, snapshot) } -/// Everything a mail row is created from. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct Letter { pub recipient_guid: u64, @@ -150,15 +137,11 @@ pub(crate) struct Letter { pub cod: u32, pub item: ItemSnapshot, pub mail_template_id: u32, - /// `CHECK_MASK_*` bits other than READ. pub check_flags: u32, - /// When the recipient can first see the mail. 0 means from creation. pub deliver_micros: i64, } impl Letter { - /// A letter from a character. It has HAS_BODY when it has a body and COPIED when it has none, - /// so the client offers no copy of an empty letter (cmangos `MailHandler.cpp:264`). pub(crate) fn from_character( sender_guid: u64, recipient_guid: u64, @@ -186,9 +169,6 @@ impl Letter { deliver_micros: 0, } } - /// A Reward Letter from a quest giver. cmangos sends it with an empty subject and names the - /// Mail Template (`Player.cpp:12672-12673`). vmangos also stores the template text as the body - /// (`Mail/Mail.cpp:113-116`), so the letter has HAS_BODY. pub(crate) fn reward( header: lyracore_shared::mail::RewardHeader, recipient_guid: u64, @@ -209,16 +189,12 @@ impl Letter { deliver_micros: 0, } } - /// The same letter as the payment for a cash on delivery take. It keeps the priced letter's - /// subject, and the client shows "COD Payment: " (cmangos `MailHandler.cpp:475-477`). pub(crate) fn into_cod_payment(self) -> Self { Self { check_flags: lyracore_shared::mail::CHECK_MASK_COD_PAYMENT, ..self } } - /// The same Character letter sent back to its writer before its recipient got it, as a - /// Returned Mail. A COD payment whose price nobody owes any more goes back this way. pub(crate) fn into_returned(self) -> Self { let (_, writer_guid, _) = self.sender.columns(); Self { @@ -229,8 +205,6 @@ impl Letter { ..self } } - /// The same letter, delivered `delay_secs` after `now_micros`. A delay of 0 leaves it visible - /// from creation. pub(crate) fn delayed(self, now_micros: i64, delay_secs: u32) -> Self { if delay_secs == 0 { return self; @@ -241,13 +215,7 @@ impl Letter { } } } -/// vanilla's `MailDeliveryDelay` default of one hour (cmangos `World.cpp:614`). pub(crate) const MAIL_DELIVERY_DELAY_SECS: u32 = 3_600; -/// The Delivery Delay of a Character's letter: an item sent to a Character on another Account -/// waits one hour, and everything else arrives at once (mangoszero `MailHandler.cpp:306-318` for a -/// send, cmangos `Mail.cpp:241-261` for a return). A cash on delivery price needs an item, so a -/// priced letter waits too. The caller decides `same_account` from Realm Accounts, and an Account -/// it cannot name counts as another Account. pub(crate) fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { if has_item && !same_account { MAIL_DELIVERY_DELAY_SECS @@ -258,8 +226,6 @@ pub(crate) fn delivery_delay_secs(has_item: bool, same_account: bool) -> u32 { fn after_delay(now_micros: i64, delay_secs: u32) -> i64 { now_micros.saturating_add(i64::from(delay_secs) * 1_000_000) } -/// The one way a mail row is created. It starts the Mail's timer, and sends a Mail Arrival when -/// the recipient can see the Mail now. pub(crate) fn insert_letter(ctx: &ReducerContext, letter: Letter) -> u64 { let (sender_kind, sender_guid, sender_entry) = letter.sender.columns(); let mail = ctx.db.game_mail().insert(Mail { @@ -288,8 +254,6 @@ pub(crate) fn insert_letter(ctx: &ReducerContext, letter: Letter) -> u64 { crate::mail_timer::start(ctx, &mail); mail.id } -/// The mail as its recipient can act on it. Before its delivery instant it does not exist for -/// them yet (cmangos `MailHandler.cpp:359,417,516`). pub(crate) fn delivered_mail(ctx: &ReducerContext, mail_id: u64) -> Option { ctx.db .game_mail() @@ -442,8 +406,6 @@ pub(crate) fn clear_mail_money(ctx: &ReducerContext, mail_id: u64) { pub(crate) fn credited(purse: u32, amount: u32) -> u32 { purse.saturating_add(amount) } -/// Split a refund into the new purse and the remainder that does not fit in it. The caller sends -/// the remainder by mail, so no copper is lost to a full purse. pub(crate) fn split_refund(purse: u32, refund: u32) -> (u32, u32) { let purse_credit = refund.min(u32::MAX - purse); (purse + purse_credit, refund - purse_credit) @@ -514,9 +476,6 @@ pub(crate) fn apply_take_item( } Ok(()) } -/// Mark-read and delete refuse a Mail before its delivery instant, as every take and the return do. -/// cmangos needs no such Gate because its client never lists the Mail, but a Gateway whose clock -/// runs ahead of this one can. pub(crate) fn apply_mark_read( ctx: &ReducerContext, recipient_guid: u64, @@ -539,8 +498,6 @@ pub(crate) enum DeletePlan { CodPriced, Delete, } -/// The client offers no Delete on a priced mail, so the Gate refuses a crafted one -/// (cmangos `MailHandler.cpp:327-331`). pub(crate) fn plan_delete(row: Option<&Mail>, caller_guid: u64) -> DeletePlan { match row { Some(m) if m.recipient_guid != caller_guid => DeletePlan::NotYours, @@ -570,10 +527,7 @@ pub(crate) enum ReturnPlan { AlreadyReturned, Return, } -/// Only a Character's mail goes back, and only once. These are LyraCore Gates, not cmangos server -/// behavior: cmangos accepts every return and silently drops a mail with no Character sender -/// (`MailHandler.cpp:375`), and it relies on the client hiding Return on a returned mail. The -/// client hides Return on each mail these Gates refuse, so a refusal answers a crafted packet. +// The client hides Return on non-Character and returned Mail; refuse crafted packets too. pub(crate) fn plan_return(row: Option<&Mail>, caller_guid: u64) -> ReturnPlan { let Some(row) = row.filter(|m| m.recipient_guid == caller_guid) else { return ReturnPlan::NotYours; @@ -608,8 +562,6 @@ pub(crate) fn apply_return( send_back(ctx, row, delay_secs); Ok(()) } -/// Send `row` back to its sender, to arrive `delay_secs` from now. The Mail starts a new life with -/// that sender. pub(crate) fn send_back(ctx: &ReducerContext, row: Mail, delay_secs: u32) { let arrives = Timestamp::from_micros_since_unix_epoch(after_delay( ctx.timestamp.to_micros_since_unix_epoch(), @@ -618,21 +570,16 @@ pub(crate) fn send_back(ctx: &ReducerContext, row: Mail, delay_secs: u32) { let back = ctx.db.game_mail().id().update(returned(row, arrives)); crate::mail_timer::start(ctx, &back); } -/// Delete a Mail with its item snapshot, its copper and its timer. An attached Plain Letter is -/// destroyed with it. pub(crate) fn delete_mail(ctx: &ReducerContext, mail_id: u64) { if let Some(row) = ctx.db.game_mail().id().find(mail_id) { crate::mail_text::drop_letter_text(ctx, row.item_text_id); } remove_mail(ctx, mail_id); } -/// Remove a Mail row and its timer, and leave any attached letter's text alone. fn remove_mail(ctx: &ReducerContext, mail_id: u64) { ctx.db.game_mail().id().delete(mail_id); crate::mail_timer::stop(ctx, mail_id); } -/// `row` sent back to its sender, arriving at `arrives`. It carries only RETURNED, loses its price -/// and read state, and its expiry clock restarts at the arrival (cmangos `Mail.cpp:264,299-313`). pub(crate) fn returned(row: Mail, arrives: Timestamp) -> Mail { Mail { recipient_guid: row.sender_guid, @@ -654,10 +601,8 @@ pub fn realm_mail_mark_read( let recipient_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_mark_read(ctx, recipient_guid, mail_id) } -/// `same_account` says whether the sender and the recipient belong to one Realm Account. The -/// Gateway reads it, because the recipient's Account may live on another Shard. #[reducer] -#[allow(clippy::too_many_arguments)] // a reducer's arguments are the wire +#[allow(clippy::too_many_arguments)] pub fn realm_mail_send( ctx: &ReducerContext, request_actor: crate::account_ownership::SessionActor, @@ -728,8 +673,6 @@ pub fn realm_mail_delete( let recipient_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_delete(ctx, recipient_guid, mail_id) } -/// `same_account` says whether the returning recipient and the Mail's sender belong to one Realm -/// Account, as in [`realm_mail_send`]. #[reducer] pub fn realm_mail_return( ctx: &ReducerContext, diff --git a/module/src/mail_catalogue.rs b/module/src/mail_catalogue.rs index 99c6b984c..e6ab60b5c 100644 --- a/module/src/mail_catalogue.rs +++ b/module/src/mail_catalogue.rs @@ -1,17 +1,5 @@ -//! Imported mail catalogue: letter bodies, the single item a letter may carry, and which quests send -//! one at turn-in. Sourced by the importer from `MailTemplate.dbc`, cmangos `mail_loot_template`, and -//! two `quest_template` columns plus the `SCRIPT_COMMAND_SEND_MAIL` `dbscripts_on_quest_end` rows. -//! -//! NOT public, the `game_import_meta` precedent (`import_meta.rs`): only the Module reads these -//! tables (`mail_reward` files a Reward Letter from them at turn-in), so the Gateway subscribes to -//! none. Replicated to every shard like other imported catalogues, because a quest can turn in on -//! any Home Shard. - use spacetimedb::table; -/// One imported letter body (`MailTemplate.dbc`). The client shows this text for a mail that names -/// `id` as its template; vanilla sends the id with an empty subject and lets the client render the -/// stored body, which carries `$B` (line break) and `$n` (recipient name) placeholders verbatim. #[table(accessor = game_mail_template)] pub struct MailTemplate { #[primary_key] @@ -19,9 +7,6 @@ pub struct MailTemplate { pub body: String, } -/// The single item one mail template's letter attaches (cmangos `mail_loot_template`: chance 100, -/// group 0, count 1, no condition — a mail carries at most one item). A template with no row here is -/// a text- or money-only letter. #[table(accessor = game_mail_loot)] pub struct MailLoot { #[primary_key] @@ -30,10 +15,6 @@ pub struct MailLoot { pub count: u32, } -/// One quest's reward mail, sent at turn-in. Sourced from either `quest_template.RewMailTemplateId` -/// (the giver sends it, no money) or a `dbscripts_on_quest_end` `SCRIPT_COMMAND_SEND_MAIL` row (an -/// alternative sender and/or money); the importer refuses a quest that carries both. -/// `sender_creature_entry == 0` means the turn-in giver sends the letter. #[table(accessor = game_quest_reward_mail)] pub struct QuestRewardMail { #[primary_key] diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index 3271d2160..a36d9f00c 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -1,5 +1,3 @@ -//! Delete-last transfer of mail value across databases, keyed by a caller-chosen escrow ID. - use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, TimeDuration}; use lyracore_shared::mail::RewardHeader; @@ -10,8 +8,8 @@ use crate::mail::game_mail; #[cfg(test)] mod harness; -pub(crate) const MAIL_ESCROW_STALE_MICROS: i64 = 30_000_000; // 30s -const MAIL_ESCROW_REAP_INTERVAL_MICROS: u64 = 5_000_000; // 5s +pub(crate) const MAIL_ESCROW_STALE_MICROS: i64 = 30_000_000; +const MAIL_ESCROW_REAP_INTERVAL_MICROS: u64 = 5_000_000; #[table( accessor = game_mail_escrow, index(accessor = by_sender, btree(columns = [sender_guid])) @@ -45,19 +43,14 @@ pub struct MailEscrow { pub cod: u32, #[default(0)] pub random_property_id: u32, - /// The Delivery Delay the fence resolved for its letter, so a re-driven commit keeps it. 0 for - /// a take, a COD payment and every fence filed before the column existed. #[default(0u32)] pub delivery_delay_secs: u32, - /// A Reward Letter's quest giver, as `game_mail.sender_kind` and `sender_entry` store it, and - /// the Mail Template it names. 0 for every other fence, which is a Character's letter. #[default(0u8)] pub sender_kind: u8, #[default(0u32)] pub sender_entry: u32, #[default(0u32)] pub mail_template_id: u32, - /// The attached Plain Letter's `ITEM_FIELD_ITEM_TEXT_ID`, as `game_mail.item_text_id`. #[default(0u32)] pub item_text_id: u32, } @@ -100,12 +93,7 @@ crate::character_owned!(delete, fn sweep_delete_game_mail_escrow(ctx, character_ escrows.escrow_id().delete(r.escrow_id); } }); -// A Home Shard escrow row is a letter its Character still owes the mail plane: a send whose purse -// was debited, or a Reward Letter. It travels with the Character, because the Gateway drives only -// the escrows the Character's current Home Shard holds, and a row left on the old Shard would be -// deleted with the Character there. The escrow id stays the receipt key on Realm-core, so a drive -// that races the hop still writes one letter. A take fence lives on Realm-core, which no Character -// leaves. +// Home Shard Escrow travels with its Character so recovery can still find and deliver its value. crate::character_owned!(transfer, fn sweep_transfer_game_mail_escrow(ctx, character_guid, io) { table = game_mail_escrow, by = by_sender, @@ -118,8 +106,6 @@ crate::character_owned!(delete, fn sweep_delete_game_mail_delivery(ctx, characte } }); crate::character_owned!(not_transported, fn sweep_transfer_game_mail_delivery()); -/// A letter as its sender wrote it, with the postage paid for it. The commit turns it into a -/// [`crate::mail::Letter`], which adds the header. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct Draft { pub recipient_guid: u64, @@ -176,24 +162,17 @@ pub(crate) enum CommitPlan { } const LEGACY_COD_PAYMENT_PREFIX: &str = "COD Payment: "; -/// The Mail a COD payment is for, as the mail plane sees it now. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct PricedMail { pub recipient_guid: u64, pub cod: u32, pub delivered: bool, } -/// What a commit does with a COD payment. The payer's copper is already in the fence, so every -/// outcome except `Hold` must land it in a Mail. +// COD copper already left the purse; every outcome except Hold must land it in Mail. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum CodPayment { - /// The payer owes a delivered price: pay the seller and settle the price. Pay, - /// The payer owes the price, but the Mail is not delivered on this plane's clock yet, which - /// runs behind the Gateway's. The payment waits in its fence for a later commit. Hold, - /// Nobody owes the price any more: Mail Expiry or a return sent the Mail back, it was deleted, - /// or the price was paid. The payment goes back to the payer as a Returned Mail. Refund, } pub(crate) fn plan_cod_payment(priced: Option, payer_guid: u64) -> CodPayment { @@ -529,9 +508,6 @@ pub(crate) fn apply_fence( ); Ok(()) } -/// File a Reward Letter as Escrow for its recipient, in the turn-in's transaction. The recipient's -/// session drives it to the mail plane as it drives a stalled send of its own. No purse pays: the -/// quest creates the letter's copper and item, as it creates quest money and items. pub(crate) fn apply_file_reward( sink: &mut S, escrow_id: u64, @@ -580,7 +556,6 @@ pub(crate) fn apply_file_reward( ); Ok(()) } -/// [`apply_file_reward`] on this database. pub(crate) fn file_reward( ctx: &ReducerContext, escrow_id: u64, @@ -589,7 +564,7 @@ pub(crate) fn file_reward( ) -> Result<(), String> { apply_file_reward(&mut CtxDb { ctx }, escrow_id, recipient_guid, letter) } -#[allow(clippy::too_many_arguments)] // it takes the reducer's wire arguments +#[allow(clippy::too_many_arguments)] pub(crate) fn apply_commit( sink: &mut S, escrow_id: u64, @@ -616,8 +591,7 @@ pub(crate) fn apply_commit( } CommitPlan::Deliver => {} } - // A commit that settles another mail's price is that price's payment. The take fence that - // follows refuses a Mail the payment did not pay for, so the payment cannot buy an item. + // Bind the take fence to the exact Mail paid by the preceding COD commit. let pays_cod = cod_mail_id != 0; if pays_cod && reward.is_some() { return Err(format!( @@ -658,12 +632,9 @@ pub(crate) fn apply_commit( *item, ), }; - // The Delivery Delay counts from this commit. A COD payment and its refund carry copper only - // and arrive at once (cmangos `MailHandler.cpp:475-477`). let letter = match payment { None => letter.delayed(sink.now_micros(), delivery_delay_secs), Some(CodPayment::Pay) => letter.into_cod_payment(), - // A refund. `Hold` returned above. Some(_) => letter.into_returned(), }; let delivered_to = letter.recipient_guid; @@ -672,7 +643,7 @@ pub(crate) fn apply_commit( sink.settle_cod(cod_mail_id); } let created_micros = sink.now_micros(); - // The receipt names the Draft's recipient even for a refund, so a replayed commit finds it. + // Keep the Draft recipient on refund receipts so replay finds the original commit. sink.file_receipt(MailDelivery { escrow_id, mail_id, @@ -917,7 +888,7 @@ pub(crate) fn apply_confirm(sink: &mut S, escrow_id: u64) -> Re )); }; if row.delivered { - return Ok(()); // already attested — replay-safe like every other step + return Ok(()); } sink.attest_delivery(escrow_id); log::info!("mail escrow {escrow_id}: delivery attested; the fence may now be settled"); @@ -967,8 +938,6 @@ pub(crate) fn apply_reap(sink: &mut S) { } } } -/// `same_account` says whether the sender and the recipient belong to one Realm Account. The fence -/// knows whether it detached an item, so it resolves the Delivery Delay and stores it. #[reducer] #[allow(clippy::too_many_arguments)] pub fn realm_mail_fence( @@ -1004,10 +973,6 @@ pub fn realm_mail_fence( same_account, ) } -/// `delivery_delay_secs` is the Delivery Delay the fence stored. The letter arrives that long after -/// this commit. A Reward Letter's escrow row also stores `sender_kind`, `sender_entry` and -/// `mail_template_id`, and the commit writes the letter from its quest giver. They are 0 for a -/// Character's letter. #[reducer] #[allow(clippy::too_many_arguments)] pub fn realm_mail_commit( diff --git a/module/src/mail_escrow/harness.rs b/module/src/mail_escrow/harness.rs index 4c7e693c4..e585ed563 100644 --- a/module/src/mail_escrow/harness.rs +++ b/module/src/mail_escrow/harness.rs @@ -1,5 +1,3 @@ -//! In-memory interruption harness for the cross-database mail protocol. - use super::*; use crate::items::ItemSnapshot; use lyracore_shared::mail::MailSender; @@ -362,8 +360,6 @@ impl ReapSink for FakeMailPlane { } impl FakeMailPlane { - /// `crate::mail::delivered_mail`: a Mail before its delivery instant does not exist for its - /// recipient. fn delivered(&self, mail_id: u64) -> Option { let now = self.ledger.now.get(); self.mails @@ -460,7 +456,6 @@ const COST: u32 = MONEY + POSTAGE; const ITEM_GUID: u64 = 0x4000_0000_0000_0011; const NO_ITEM: u64 = 0; const NO_COD_MAIL: u64 = 0; -/// The sender and the recipient belong to different Realm Accounts, so an item waits an hour. const OTHER_ACCOUNT: bool = false; const SAME_ACCOUNT: bool = true; const NO_DELAY: u32 = 0; @@ -525,7 +520,6 @@ fn drive( if killed == Killed::AfterFence { return Ok(()); } - // The Gateway re-derives the attachment and the Delivery Delay from the fence row. let (item, delay_secs) = shard .escrow(escrow_id) .map(|e| (e.item(), e.delivery_delay_secs)) @@ -756,7 +750,7 @@ fn a_driver_killed_before_the_fence_costs_the_sender_nothing() { assert_eq!(shard.purse_of(SENDER), PURSE); assert!(plane.mailbox_of(RECIPIENT).is_empty()); shard.advance(MAIL_ESCROW_STALE_MICROS * 10); - apply_reap(&mut shard); // an empty ledger reaps nothing + apply_reap(&mut shard); assert_eq!(shard.purse_of(SENDER), PURSE); } #[test] @@ -1488,7 +1482,6 @@ enum ItemTakeKilled { AfterConfirm, Never, } -/// An item letter to another Account, an hour later, when it has arrived. fn delivered_item_fixture() -> (FakeShard, FakeMailPlane, u64) { let (mut shard, mut plane) = item_fixture(); drive(&mut shard, &mut plane, ESCROW, ITEM_GUID, Killed::Never).expect("committed"); @@ -1548,7 +1541,6 @@ fn the_take_moves_a_mails_item_into_the_bags_on_another_database() { ); assert!(!plane.has_fence(TAKE)); } -/// A Letter Copy's Plain Letter (item 8383) whose text is Item Text 41. fn plain_letter() -> ItemSnapshot { ItemSnapshot { entry: 8383, @@ -1872,8 +1864,6 @@ fn a_cod_payment_for_a_mail_not_yet_delivered_is_held_and_pays_nobody() { "the buyer's copper stays in the fence" ); } -/// The payer's copper is already out of their purse and in the fence. A commit that can pay nobody -/// must still land it somewhere, or the fence is held forever. fn assert_the_payment_came_back(plane: &FakeMailPlane, payer: u64, what: &str) { let refund = plane .mailbox_of(payer) @@ -2103,7 +2093,6 @@ fn an_item_send_re_driven_after_a_restart_keeps_its_delivery_delay() { let restarted_at = 10 * 60 * 1_000_000; plane.advance(restarted_at); - // The Gateway's re-drive reads the whole letter back from the fence row. let held = shard.escrow(ESCROW).expect("the fence is held"); apply_commit( &mut plane, @@ -2190,8 +2179,6 @@ fn a_cod_payment_arrives_at_once_whatever_delay_its_commit_carries() { "cmangos MailHandler.cpp:475-477 sends the payment with no delay" ); } -/// Filed like `mail_reward::file_reward_letter` files quest 3645, Membership Card Renewal: template -/// 99 attaches item 11423, count 1, after 86,400 s (`cdb:` quest_template, mail_loot_template). const REWARD: u64 = 0x5EED_0004; const DAY_MICROS: i64 = 86_400 * 1_000_000; const CARD_HEADER: RewardHeader = RewardHeader { @@ -2211,8 +2198,6 @@ fn card_renewal() -> crate::mail_reward::RewardLetter { delay_secs: 86_400, } } -/// The Gateway's drive of a filed Reward Letter: every commit argument comes from the escrow row, -/// as `world::mail::redrive` reads it. fn drive_reward( shard: &mut FakeShard, plane: &mut FakeMailPlane, diff --git a/module/src/mail_reward.rs b/module/src/mail_reward.rs index 2ebd97d5a..07b10facd 100644 --- a/module/src/mail_reward.rs +++ b/module/src/mail_reward.rs @@ -1,10 +1,3 @@ -//! Reward Letters: the letter a quest giver sends when a Character turns a quest in. -//! -//! The turn-in runs on the Home Shard and the mail plane is Realm-core, so no transaction spans -//! both. The turn-in files the letter as Mail Escrow in its own transaction, and the Gateway drives -//! that escrow to the mail plane as it drives a stalled send. The letter then commits with the -//! turn-in or not at all, and the escrow id makes the commit happen once. - use lyracore_shared::mail::{QuestGiver, RewardHeader}; use spacetimedb::{log, ReducerContext}; @@ -13,23 +6,15 @@ use crate::items::ItemSnapshot; use crate::mail_catalogue::{game_mail_loot, game_mail_template, game_quest_reward_mail}; use crate::mail_catalogue::{MailLoot, QuestRewardMail}; -/// A Reward Letter as the catalogue describes it, ready to file. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct RewardLetter { pub header: RewardHeader, - /// The Mail Template's text. pub body: String, pub money: u32, pub item: ItemSnapshot, - /// How long after the commit the letter arrives (`quest_template.RewMailDelaySecs`). pub delay_secs: u32, } -/// The Reward Letter `reward` describes. The sender is the alternative creature a -/// `SCRIPT_COMMAND_SEND_MAIL` row names, else the quest ender (cmangos `ScriptMgr.cpp:2850-2866`, -/// `Mail.cpp:48-75`). `body` is the Mail Template's text. `loot` is the template's one item and -/// `max_durability` that item's template value; the letter carries it unworn. `Err` names the -/// catalogue row that is missing. pub(crate) fn plan_reward_letter( reward: &QuestRewardMail, quest_ender: Option, @@ -64,12 +49,6 @@ pub(crate) fn plan_reward_letter( }) } -/// File the Reward Letter `quest_entry` sends, if it sends one, for `character_guid`, who just -/// turned the quest in at `giver_guid`. Call it in the turn-in's transaction. A quest with no -/// reward mail files nothing. A letter whose catalogue rows are missing logs an error and files -/// nothing, and the turn-in still succeeds: the importer refuses that data, so the Character is -/// not at fault. -/// `Err` when the Shard can mint no escrow id; the turn-in then fails too, so no letter is lost. pub(crate) fn file_reward_letter( ctx: &ReducerContext, character_guid: u64, @@ -114,8 +93,6 @@ pub(crate) fn file_reward_letter( return Ok(()); } }; - // The Shard's GUID Range is its own slot in the Realm-core registry, and a Gateway mints its - // escrow ids from a slot of its own there, so no other writer can issue this id. let escrow_id = *crate::auth::reserve_guids(ctx, 1, u64::MAX)?.start(); crate::mail_escrow::file_reward(ctx, escrow_id, character_guid, &letter) } @@ -133,25 +110,16 @@ mod fixture { }; const CHARACTER: u64 = 1; - /// The seed chicken. The fixture clones it into a giver next to the Character. const GIVER_SOURCE: u64 = (0xF130_u64 << 48) | ((620_u64) << 24) | 1; const GIVER: u64 = (0xF130_u64 << 48) | ((620_u64) << 24) | 0x00FF_FF01; const GIVER_ENTRY: u32 = 620; - /// Shaped like 3645, Membership Card Renewal: one item after 86,400 s (`cdb:` quest_template, - /// mail_loot_template). The item is the seed's Tempered Blade. const CARD_QUEST: u32 = 509_091; const CARD_TEMPLATE: u32 = 509_091; const CARD_ITEM: u32 = crate::seed::FIXTURE_BLADE; - /// Shaped like 8728: creature 11811 sends 1,000,000 copper after 129,600 s - /// (`cdb:` dbscripts_on_quest_end). const ELDER_QUEST: u32 = 509_092; const ELDER_TEMPLATE: u32 = 509_092; - /// A quest with no reward mail. const PLAIN_QUEST: u32 = 509_093; - /// Stage three completed quests for the seeded Character 1 at a giver creature next to it: a - /// card-renewal-shaped letter with an item, an 8728-shaped letter with copper from another - /// creature, and a quest that sends nothing. #[spacetimedb::reducer] pub fn debug_stage_reward_letter_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -301,8 +269,6 @@ mod tests { const CARD_ENDER: Option = Some(QuestGiver::Creature(7_802)); - /// Quest 3645, Membership Card Renewal: template 99 attaches item 11423, count 1, and the quest - /// ender sends it after 86,400 s (`cdb:` quest_template, mail_loot_template). #[test] fn the_quest_ender_sends_the_template_text_and_its_one_item() { let letter = plan_reward_letter( @@ -332,8 +298,6 @@ mod tests { ); } - /// Quest 8728: `dbscripts_on_quest_end` names creature 11811 and 1,000,000 copper, and the - /// template carries no item (`cdb:`). #[test] fn a_script_sender_replaces_the_quest_ender_and_brings_its_copper() { let letter = plan_reward_letter( @@ -396,9 +360,6 @@ mod tests { .is_err()); } - /// A Shard in slot n mints from `[n * 1e9, (n + 1) * 1e9)`, so a Shard in slot 9,999 or lower - /// mints no id above 9,999,999,999,999. A Gateway's escrow range starts at slot 10,000 or - /// higher. A Shard in a higher slot relies on the registry giving each slot one owner. #[test] fn a_shard_below_slot_10000_mints_no_id_in_a_gateway_escrow_range() { assert_eq!(crate::realm_core::GUID_RANGE_SIZE, 1_000_000_000); diff --git a/module/src/mail_text.rs b/module/src/mail_text.rs index 3aeb2dadd..555203f82 100644 --- a/module/src/mail_text.rs +++ b/module/src/mail_text.rs @@ -1,54 +1,30 @@ -//! Letter Copy (`CMSG_MAIL_CREATE_TEXT_ITEM`): the durable text behind a mail's Plain Letter, and -//! the two reducers that create one. The mail and its text stay on the mail plane (Realm-core when -//! sharded, the single shard's own database otherwise); the Plain Letter itself is granted on the -//! Home Shard. The item sells for 0, so this is not a value flow: it needs neither Escrow nor a -//! Hold. Realm-core records the copy first, the Home Shard grants second, and a grant lost to a -//! race with the Gateway's own bag-room check costs nothing. - use spacetimedb::{reducer, table, ReducerContext, Table}; use crate::game_item_instance; use crate::mail::{game_mail, Mail}; -/// cmangos `MAIL_BODY_ITEM_TEMPLATE` (`Mails/Mail.h:47`) — the "Plain Letter" a copied mail -/// becomes. const MAIL_BODY_ITEM_TEMPLATE: u32 = 8383; const NOTHING_TO_COPY: &str = "mail: this mail has no text to copy"; -/// `item_text_id_for` narrows a mail id to `u32` and answers `0` — its empty-body sentinel — for -/// one it cannot narrow. A mail id this large will not occur under today's GUID ranges, but the -/// Gate must still refuse outright rather than let a `0`-keyed `ItemText` masquerade as a real -/// copy: that would mark the mail COPIED and (via a later grant) GRANTED over an unreadable -/// letter, with no bit left to retry from. const ID_TOO_LARGE_TO_COPY: &str = "mail: this letter's id is too large to become a Plain Letter"; -/// A copied letter's text, keyed by the mail id narrowed to u32 -/// (`lyracore_shared::mail::item_text_id_for`). Private: the Gateway reads it through the owner -/// token. Outlives the mail row that created it, because vanilla lets a made-permanent letter keep -/// its text after the mail itself is gone. It is deleted with the last Plain Letter that carries it. #[table(accessor = game_item_text)] pub struct ItemText { #[primary_key] pub id: u32, pub text: String, - /// How many Plain Letters can still carry this text. Each copy adds one before the Home Shard - /// grants, so the count is never below the letters that exist; a grant that never lands only - /// keeps the row longer. Each destroyed letter takes one away, and the row goes at 0. A row - /// filed before the count existed reads [`UNCOUNTED_LETTERS`]. + // Count before granting so missed grants retain text without undercounting letters. #[default(UNCOUNTED_LETTERS)] pub letters: u32, } -/// `ItemText.letters` of a row whose letters nobody counted. It is never deleted, because a letter -/// that still carries it would lose its text. +// Unknown legacy counts are never deleted because a surviving letter may still need this text. pub(crate) const UNCOUNTED_LETTERS: u32 = u32::MAX; -/// The count after one more copy of the text. fn letters_after_copy(letters: u32) -> u32 { letters.saturating_add(1) } -/// The count after one letter is destroyed, or `None` when the row goes. fn letters_after_release(letters: u32) -> Option { match letters { UNCOUNTED_LETTERS => Some(UNCOUNTED_LETTERS), @@ -57,9 +33,6 @@ fn letters_after_release(letters: u32) -> Option { } } -/// One Plain Letter carrying `item_text_id` is destroyed. Its text goes with the last such letter. -/// A database that does not hold the text row changes nothing: on a sharded realm a Home Shard -/// holds letters, but their text is on Realm-core. pub(crate) fn drop_letter_text(ctx: &ReducerContext, item_text_id: u32) { if item_text_id == 0 { return; @@ -90,17 +63,6 @@ pub(crate) enum CopyTextPlan { Copy, } -/// `row` is the delivered mail at `mail_id`, already scoped to whether it exists at all. Refuses a -/// mail that is not the caller's the same way a missing one does (a mail id is client-supplied), an -/// empty body the same way vmangos refuses an already-COPIED mail — the two read identically to a -/// crafted click, since `Letter::from_character` already marks an empty-body letter COPIED at -/// creation and a legacy row predates that convention. -/// -/// The Gate checks GRANTED, not COPIED: COPIED alone only tells the vanilla client to hide the -/// letter button, and `apply_copy_text` treats it as a replay so a redundant click or a retry after -/// an interrupted grant can still reach the Home Shard. GRANTED is the durable record that the -/// Plain Letter actually landed — it survives the player destroying, mailing away, or trading that -/// item, which is what stops a copy-grant-destroy loop from minting the letter over and over. pub(crate) fn plan_copy_text(row: Option<&Mail>, caller_guid: u64) -> CopyTextPlan { let Some(row) = row.filter(|m| m.recipient_guid == caller_guid) else { return CopyTextPlan::NotYours; @@ -108,8 +70,6 @@ pub(crate) fn plan_copy_text(row: Option<&Mail>, caller_guid: u64) -> CopyTextPl if row.body.is_empty() { return CopyTextPlan::NoText; } - // Structural, ahead of GRANTED: whether this id can ever become a text id does not depend on - // whether a grant already landed. if u32::try_from(row.id).is_err() { return CopyTextPlan::IdTooLarge; } @@ -119,14 +79,6 @@ pub(crate) fn plan_copy_text(row: Option<&Mail>, caller_guid: u64) -> CopyTextPl CopyTextPlan::Copy } -/// Sets COPIED on the mail and files its body as durable item text, keyed by the mail's own id. -/// Replay-safe: a retry that reaches here again before GRANTED is ever set (bags filled, logout, -/// timeout, a crash between here and the Home Shard grant) re-runs harmlessly. The text row is -/// reused when it already exists: `mail::returned` keeps a returned mail's id, so a letter a second -/// Character copies after the first sender gets it back reuses the same text id, and the body never -/// changes for a given mail id. Every call counts one more letter, because the Gateway grants only -/// after this call succeeds; a replay that grants nothing keeps the text row longer and loses -/// nothing. Setting COPIED again is a no-op OR. pub(crate) fn apply_copy_text( ctx: &ReducerContext, recipient_guid: u64, @@ -165,9 +117,7 @@ pub(crate) fn apply_copy_text( Ok(()) } -/// A deleted Character's letters are destroyed: the ones in its bags and the ones attached to its -/// Mail. Only the Character deletion reducers call this. A Transfer runs the same delete sweeps on -/// its source, but there the letters travel, so their text must stay. +// Transfer source sweeps move letters rather than destroying them, so their text must remain. pub(crate) fn drop_character_letters(ctx: &ReducerContext, character_guid: u64) { let in_bags = ctx .db @@ -198,12 +148,6 @@ pub fn realm_mail_copy_text( apply_copy_text(ctx, recipient_guid, mail_id) } -/// The Home Shard half: grant one Plain Letter carrying `item_text_id`. Refuses on full bags with -/// the existing `INVENTORY_FULL` text, which the Gateway's `mail_item_room` pre-check already tries -/// to avoid — this Gate is the real one, since bags can still fill between that check and this call. -/// `grant_letter_item`'s own "the owner already holds this text id" check is a second, narrower -/// guard: it only covers the window between this call landing and `realm_mail_mark_letter_granted` -/// recording GRANTED, not the whole lifetime of the item. #[reducer] pub fn gw_mail_grant_letter( ctx: &ReducerContext, @@ -215,10 +159,7 @@ pub fn gw_mail_grant_letter( crate::items::grant_letter_item(ctx, payee_guid, MAIL_BODY_ITEM_TEMPLATE, item_text_id) } -/// Sets GRANTED on the mail plane once the Gateway has confirmed `gw_mail_grant_letter` returned -/// `Ok`. The durable record a Letter Copy landed: unlike the granted item itself, this bit cannot -/// be destroyed, mailed away, or traded, so it is what `plan_copy_text` checks to refuse a second -/// grant for good — not whether the player still happens to hold the item. +// GRANTED survives item destruction and trade; possession cannot prove a prior Letter Copy grant. pub(crate) fn apply_mark_letter_granted( ctx: &ReducerContext, recipient_guid: u64, @@ -290,9 +231,6 @@ mod tests { #[test] fn a_mail_with_no_body_has_nothing_to_copy() { - // A character letter with an empty body is already COPIED at creation - // (`Letter::from_character`), so this also covers that path — but a legacy row predating - // the convention (empty body, no flags) must still be refused on the body alone. assert_eq!( plan_copy_text(Some(&row(7, "", 0)), 7), CopyTextPlan::NoText @@ -306,10 +244,6 @@ mod tests { #[test] fn a_mail_id_too_large_for_a_text_id_is_refused_rather_than_copied_unreadable() { - // `item_text_id_for` narrows a mail id to `u32` and answers `0` — its empty-body - // sentinel — for one that does not fit. Without this Gate, `apply_copy_text` would file a - // `0`-keyed `ItemText`, mark the mail COPIED, and leave the player with an unreadable - // letter and no bit left to retry from. let mut too_large = row(7, "meet me at the gate", 0); too_large.id = u64::from(u32::MAX) + 1; assert_eq!( @@ -320,9 +254,6 @@ mod tests { #[test] fn a_copy_that_only_set_copied_replays_rather_than_refusing() { - // COPIED alone means an earlier attempt reached the mail plane but the Gateway never - // confirmed the Home Shard grant landed — the shape a crash or a lost reply leaves behind. - // The Gate must let a retry through so it can reach the grant. let copied_only = row(7, "meet me at the gate", 0x04); assert_eq!(plan_copy_text(Some(&copied_only), 7), CopyTextPlan::Copy); let with_has_body = row(7, "meet me at the gate", 0x10 | 0x04); diff --git a/module/src/mail_timer.rs b/module/src/mail_timer.rs index 4cc0e7610..b24e5540f 100644 --- a/module/src/mail_timer.rs +++ b/module/src/mail_timer.rs @@ -1,7 +1,3 @@ -//! The Mail Timer: one one-shot schedule per Mail. It fires at the delivery instant of a Mail that -//! is not delivered yet, and at the end of every Mail's life to run Mail Expiry. Mail rows live on -//! Realm-core in a sharded realm, so the timers and the Mail Arrival events live there too. - use lyracore_shared::mail::{MailSender, CHECK_MASK_COD_PAYMENT, CHECK_MASK_RETURNED}; use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; @@ -9,7 +5,6 @@ use crate::mail::{game_mail, Mail}; const MICROS_PER_SEC: i64 = 1_000_000; -/// The next firing of one Mail. `mail_id` is unique, so a Mail has at most one. #[table(accessor = game_mail_timer, scheduled(fire_mail_timer))] pub struct MailTimer { #[primary_key] @@ -20,8 +15,6 @@ pub struct MailTimer { pub mail_id: u64, } -/// A Mail became visible to `recipient_guid`. The Gateway relays it as `SMSG_RECEIVED_MAIL` to that -/// recipient's World Session on any Shard, and the event GC reaps it. #[table( accessor = game_mail_arrival, index(accessor = by_recipient, btree(columns = [recipient_guid])) @@ -34,9 +27,6 @@ pub struct MailArrival { pub created_at: Timestamp, } -/// The end of a Mail's life: 3 days after it arrives with a cash on delivery price, else 30 days -/// (cmangos `Mail.cpp:301-313`). Whole seconds, rounded as the Gateway rounds the list countdown, -/// so the countdown reaches zero when the Mail expires. pub(crate) fn expires_at(mail: &Mail) -> Timestamp { let secs = lyracore_shared::mail::expires_at_secs( mail.created_at @@ -50,18 +40,12 @@ pub(crate) fn expires_at(mail: &Mail) -> Timestamp { Timestamp::from_micros_since_unix_epoch(secs.saturating_mul(MICROS_PER_SEC)) } -/// What Mail Expiry does with a Mail (cmangos `ObjectMgr.cpp:6188-6232`). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Expiry { - /// Back to its Character sender in place, as a Returned Mail. Return, - /// Deleted with its item and copper. Delete, } -/// Only a Character's Mail that still carries an item goes back. A Returned Mail, a COD payment, -/// any other Mail Sender and a Mail from guid 0, which has nobody to take it back, are deleted. -/// So is a Mail without an item: its copper goes with it, as in cmangos and vmangos. pub(crate) fn expiry_of(mail: &Mail) -> Expiry { let from_character = matches!(mail.sender(), MailSender::Character(guid) if guid != 0); let sent_back_before = mail.check_flags & (CHECK_MASK_RETURNED | CHECK_MASK_COD_PAYMENT) != 0; @@ -72,21 +56,14 @@ pub(crate) fn expiry_of(mail: &Mail) -> Expiry { } } -/// What one firing does with its Mail. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Firing { - /// Too early: the Mail is not delivered yet, or its life grew after the timer was armed - /// because its cash on delivery price was paid. Fire again at this instant. Rearm(Timestamp), - /// The Mail's delivery instant: send its Mail Arrival, then fire again at its expiry. - Deliver { - expires: Timestamp, - }, + Deliver { expires: Timestamp }, Expire(Expiry), } -/// `due` is the instant the firing was armed for. The scheduler never runs it earlier, so -/// `now >= due`. Only a firing armed at the delivery instant delivers. +// Only a firing armed at the delivery instant may emit the delivery notification. pub(crate) fn firing(mail: &Mail, due: Timestamp, now: Timestamp) -> Firing { if !mail.is_delivered(now) { return Firing::Rearm(delivery(mail)); @@ -105,7 +82,6 @@ fn delivery(mail: &Mail) -> Timestamp { Timestamp::from_micros_since_unix_epoch(mail.deliver_micros) } -/// The instant a Mail's timer fires next: its delivery while it is not delivered, else its expiry. fn next_due(mail: &Mail, now: Timestamp) -> Timestamp { if mail.is_delivered(now) { expires_at(mail) @@ -114,8 +90,6 @@ fn next_due(mail: &Mail, now: Timestamp) -> Timestamp { } } -/// Start `mail`'s life with its current recipient: send a Mail Arrival when that recipient can see -/// it now, and arm its timer. A new Mail and a Returned Mail both come here. pub(crate) fn start(ctx: &ReducerContext, mail: &Mail) { if mail.is_delivered(ctx.timestamp) { arrive(ctx, mail.recipient_guid); @@ -123,18 +97,15 @@ pub(crate) fn start(ctx: &ReducerContext, mail: &Mail) { arm_next(ctx, mail); } -/// Arm `mail`'s timer for its next firing, replacing any timer it has. pub(crate) fn arm_next(ctx: &ReducerContext, mail: &Mail) { arm(ctx, mail.id, next_due(mail, ctx.timestamp)); } -/// Cancel a Mail's timer. Every path that deletes a Mail row calls this. pub(crate) fn stop(ctx: &ReducerContext, mail_id: u64) { ctx.db.game_mail_timer().mail_id().delete(mail_id); } -/// A fired row stays until its reducer returns, so delete by `mail_id` before the insert, or the -/// unique index refuses the next row. +// Fired rows remain until reducer return; delete before inserting the next unique mail_id. fn arm(ctx: &ReducerContext, mail_id: u64, at: Timestamp) { let timers = ctx.db.game_mail_timer(); timers.mail_id().delete(mail_id); @@ -153,16 +124,9 @@ fn arrive(ctx: &ReducerContext, recipient_guid: u64) { }); } -/// The gap between two past-due timers that one repair pass arms. Each firing is its own -/// transaction and may write one Mail Arrival. Arming a backlog at the same past instant would -/// run every firing back to back after the pass commits. At 10 ms the backlog fires at 100 -/// transactions a second, and 100,000 legacy Mail drain in under 17 minutes. Staggering keeps the -/// work in the one pass the Operator already runs, where a cap would leave Mail untimed until the -/// next publish. +// Stagger overdue timers so a repair cannot unleash the whole backlog in consecutive transactions. const BACKLOG_SPACING_MICROS: i64 = 10_000; -/// When the timers a repair pass arms fire, in the order of `dues`. A future instant stays. The -/// `n`th past-due instant, counted from 1, moves to `now + n × 10 ms`. fn spaced(dues: &[Timestamp], now: Timestamp) -> Vec { let mut past_due = 0; dues.iter() @@ -178,9 +142,6 @@ fn spaced(dues: &[Timestamp], now: Timestamp) -> Vec { .collect() } -/// Arm a timer for every Mail that has none: Mail written before the timer existed, or Mail a -/// Transfer imported. Mail past its life expires shortly after this transaction commits, spaced -/// by [`BACKLOG_SPACING_MICROS`]. Returns how many timers it armed. pub(crate) fn arm_missing(ctx: &ReducerContext) -> u64 { let timers = ctx.db.game_mail_timer(); let untimed: Vec = ctx @@ -199,7 +160,6 @@ pub(crate) fn arm_missing(ctx: &ReducerContext) -> u64 { untimed.len() as u64 } -/// Scheduler-only. #[reducer] pub fn fire_mail_timer(ctx: &ReducerContext, timer: MailTimer) -> Result<(), String> { if ctx.sender() != ctx.database_identity() { @@ -209,8 +169,6 @@ pub fn fire_mail_timer(ctx: &ReducerContext, timer: MailTimer) -> Result<(), Str Ok(()) } -/// One firing of `timer`. A firing that is no longer its Mail's armed timer, or whose Mail is gone, -/// changes nothing, so a replay is a no-op. pub(crate) fn fire(ctx: &ReducerContext, timer: &MailTimer) { let armed = ctx.db.game_mail_timer().mail_id().find(timer.mail_id); if armed.is_some_and(|armed| armed.scheduled_id != timer.scheduled_id) { @@ -221,7 +179,6 @@ pub(crate) fn fire(ctx: &ReducerContext, timer: &MailTimer) { }; let due = match timer.scheduled_at { ScheduleAt::Time(at) => at, - // `arm` writes only instants, so no interval row exists. Read one as due now. ScheduleAt::Interval(_) => ctx.timestamp, }; match firing(&mail, due, ctx.timestamp) { @@ -230,7 +187,6 @@ pub(crate) fn fire(ctx: &ReducerContext, timer: &MailTimer) { arrive(ctx, mail.recipient_guid); arm(ctx, mail.id, expires); } - // Auto-return arrives at once (cmangos `ObjectMgr.cpp:6214-6215`). Firing::Expire(Expiry::Return) => crate::mail::send_back(ctx, mail, 0), Firing::Expire(Expiry::Delete) => crate::mail::delete_mail(ctx, mail.id), } diff --git a/module/src/meeting_stone/matching.rs b/module/src/meeting_stone/matching.rs index 5de433a8f..646be26d3 100644 --- a/module/src/meeting_stone/matching.rs +++ b/module/src/meeting_stone/matching.rs @@ -1,13 +1,9 @@ -//! Pure Meeting Stone matching: class roles, a Party's Open Roles, the next Stone Add and Party -//! formation. The role table and fill order are cmangos's (cm:LFG/LFGMgr.cpp:91-158). - use spacetimedb::Timestamp; use lyracore_shared::group::GROUP_MAX_MEMBERS; use super::MeetingStoneSeeker; -/// 1.12 class ids (ChrClasses.dbc). mod class { pub const WARRIOR: u8 = 1; pub const PALADIN: u8 = 2; @@ -20,7 +16,6 @@ mod class { pub const DRUID: u8 = 11; } -/// A Party's dungeon roles, in the order a Party fills them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Role { Tank, @@ -32,8 +27,6 @@ const ROLE_ORDER: [Role; 3] = [Role::Tank, Role::Healer, Role::Damage]; const DAMAGE_ROLES: u8 = 3; -/// How well `class` fills `role`: 0 not at all, then 1 low, 2 normal, 3 high. An unknown class -/// fills nothing. fn role_priority(class: u8, role: Role) -> u8 { use class::*; const LOW: u8 = 1; @@ -54,7 +47,6 @@ fn fills(class: u8, role: Role) -> bool { role_priority(class, role) > 0 } -/// Which of a Party's one tank, one healer and three damage roles no member fills yet. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct OpenRoles { tank: bool, @@ -86,9 +78,6 @@ impl OpenRoles { } } -/// The Open Roles of a Party whose members have `classes`, in join order. Each member takes the -/// first open role its class fills, unless a member not yet placed has a higher priority for it -/// (cm:Groups/Group.cpp:1589-1685). A member left without a role holds a seat. pub(super) fn open_roles(classes: &[u8]) -> OpenRoles { let mut open = OpenRoles::ALL; let mut placed = vec![false; classes.len()]; @@ -111,14 +100,11 @@ pub(super) fn open_roles(classes: &[u8]) -> OpenRoles { open } -/// The longest wait first, ties by guid. fn wait_order(seeker: &MeetingStoneSeeker) -> (Timestamp, u64) { (seeker.queued_at, seeker.character_guid) } -/// The index in `solos` of the next Stone Add: for the first open role in fill order, the -/// longest-waiting Seeker whose class fills it. cmangos compares class priority as a `bool` -/// (cm:LFG/LFGQueue.cpp:300,316), so the wait decides. +// cmangos treats class priority as a bool, so waiting time decides the next Stone Add. pub(super) fn next_stone_add(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { ROLE_ORDER .into_iter() @@ -133,8 +119,6 @@ pub(super) fn next_stone_add(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> O }) } -/// With five solo Seekers in one bucket, the longest wait leads a new Party and the next joins it. -/// Both cores count Seekers across every area and block on the first one's; one bucket does not. pub(super) fn formation( solos: &[MeetingStoneSeeker], ) -> Option<(&MeetingStoneSeeker, &MeetingStoneSeeker)> { @@ -150,7 +134,6 @@ pub(super) fn formation( mod tests { use super::*; - /// The cmangos class table, row by row: the roles each class fills and its priority in each. #[test] fn the_role_table_is_the_cmangos_class_table() { use class::*; @@ -176,14 +159,12 @@ mod tests { } } - /// The paladin outranks nobody for tank while the warrior waits, so it heals. #[test] fn a_paladin_then_a_warrior_leave_three_damage_roles_open() { use class::*; assert_eq!(open_roles(&[PALADIN, WARRIOR]), open(false, false, 3)); } - /// Equal priority does not outrank, so the first warrior tanks and the second deals damage. #[test] fn two_warriors_leave_the_healer_and_two_damage_roles_open() { use class::*; @@ -206,7 +187,6 @@ mod tests { assert_eq!(open_roles(&[0, MAGE]), open(true, true, 2)); } - /// A fourth damage dealer finds every damage role taken and fills nothing. #[test] fn a_member_whose_roles_are_taken_fills_nothing() { use class::*; diff --git a/module/src/meeting_stone/mod.rs b/module/src/meeting_stone/mod.rs index 0f1d49eea..da422c735 100644 --- a/module/src/meeting_stone/mod.rs +++ b/module/src/meeting_stone/mod.rs @@ -1,12 +1,3 @@ -//! The Meeting Stone Queue. [`gw_admit_meeting_stone`] admits a Character at the stone on its Home -//! Shard and writes nothing. [`realm_meeting_stone_op`] queues it on Realm-core, where party -//! membership is authoritative, so Seekers on every World Shard share one queue. Realm-core holds -//! no Character rows, so the Gateway conveys race and class as [`SeekerFacts`]. -//! -//! Matching runs in the transaction that changes a bucket ([`match_bucket`]): the cores' queue -//! thread reduces to longest wait per Open Role, so a tick would only add latency. A solo Seeker -//! lasts as long as its Account Claim ([`claim_ended`]); a party Seeker as long as its membership. - use std::collections::BTreeSet; use spacetimedb::{ @@ -27,24 +18,19 @@ use matching::{formation, next_stone_add, open_roles}; use crate::group::{Departure, JoinPlan, JoinTarget}; use crate::{game_account_claim, game_group}; -/// A queued Party hears IN_PROGRESS this often (cm:LFG/LFGMgr.cpp:55). const REMINDER_INTERVAL_MICROS: i64 = 5 * 60 * 1_000_000; const REMINDER_TICK_MICROS: i64 = 5_000_000; -/// One Meeting Stone template's level range and dungeon area, imported from `data0` to `data2`. -/// Packages do not author it. [static] #[table(accessor = game_meeting_stone)] pub struct MeetingStone { #[primary_key] pub entry: u32, pub min_level: u32, - /// 0 means no upper bound. pub max_level: u32, pub area_id: u32, } -/// One Seeker. The Gateway reads it to answer `CMSG_MEETINGSTONE_INFO`. [entity] #[table( accessor = game_meeting_stone_seeker, index(accessor = by_bucket, btree(columns = [area_id, team])), @@ -55,15 +41,11 @@ pub struct MeetingStoneSeeker { pub character_guid: u64, pub area_id: u32, pub team: u32, - /// 0 when the Gateway could not read it. pub class: u8, - /// 0 for a solo Seeker. pub group_id: u64, pub queued_at: Timestamp, } -/// One queued Party. It exists exactly while the Party is queued, and its Seeker rows are its -/// current members. [entity] #[table(accessor = game_meeting_stone_party)] pub struct MeetingStoneParty { #[primary_key] @@ -82,7 +64,6 @@ pub struct MeetingStoneReminderSchedule { pub scheduled_at: ScheduleAt, } -/// The Seekers that can meet: one dungeon area and one team. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Bucket { pub(crate) area_id: u32, @@ -102,8 +83,6 @@ fn refused(refusal: MeetingStoneRefusal, detail: &str) -> String { tag.to_string() } -/// May `actor_guid` use the Meeting Stone `go_guid`? Writes nothing. The level check has no 1.12 -/// failure code, so the Gateway drops that Refusal silently, as the client checks it first. #[reducer] pub fn gw_admit_meeting_stone( ctx: &ReducerContext, @@ -126,7 +105,6 @@ fn admit( actor_guid: u64, go_guid: u64, ) -> Result<(), MeetingStoneRefusal> { - // Humans only: no World Session, no queue. if request_actor.ownership.is_none() || crate::taxi::is_in_flight(ctx, actor_guid) { return Err(MeetingStoneRefusal::ActorUnavailable); } @@ -160,7 +138,6 @@ fn gate_refusal(kind: crate::actor::ActionRefusalKind) -> MeetingStoneRefusal { } } -/// A `max_level` of 0 has no upper bound. fn level_in_range(level: u32, min_level: u32, max_level: u32) -> bool { level >= min_level && (max_level == 0 || level <= max_level) } @@ -172,8 +149,6 @@ struct Membership { members: usize, } -/// A grouped actor queues only as the leader of a Party with room. The order of the checks picks -/// the failure code (cm:LFG/LFGHandler.cpp:58-77). fn party_join_gate(membership: Membership) -> Result<(), MeetingStoneRefusal> { if !membership.leads { return Err(MeetingStoneRefusal::NotLeader); @@ -189,7 +164,6 @@ fn party_join_gate(membership: Membership) -> Result<(), MeetingStoneRefusal> { enum StoneOpError { Refused(MeetingStoneRefusal), - /// Untagged, so the Gateway treats the outcome as unknown. Invalid(String), } @@ -205,9 +179,6 @@ impl From for StoneOpError { } } -/// One Meeting Stone Queue op on the party authority. Operator-gated because the actor is an -/// argument. JOIN reads `area_id` and the `seekers` of the actor or its whole Party; LEAVE reads -/// neither. Too many facts, facts without the actor, or an unknown op is an untagged error. #[reducer] pub fn realm_meeting_stone_op( ctx: &ReducerContext, @@ -295,8 +266,6 @@ fn first_reminder_at(ctx: &ReducerContext) -> Result { .ok_or_else(|| "reminder time overflow".to_string()) } -/// Queue `group_id` with `members`, `(guid, class)` each, and tell each JOINED. Re-queueing resets -/// the area and wait time and drops Seeker rows of former members. fn queue_party( ctx: &ReducerContext, group_id: u64, @@ -383,8 +352,6 @@ fn member_guids(ctx: &ReducerContext, group_id: u64) -> Vec { .collect() } -/// A solo Seeker leaves with LEAVE_QUEUE, and a queued Party's leader takes the whole Party out. -/// Any other member only hears NONE. fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { let Some((group, _)) = crate::group::group_with_members(ctx, actor_guid)? else { let seekers = ctx.db.game_meeting_stone_seeker(); @@ -408,7 +375,6 @@ fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { Ok(()) } -/// Take a queued Party out of the queue. Every member hears `SETQUEUE(0, status)`. pub(crate) fn dequeue_party(ctx: &ReducerContext, group_id: u64, status: u8) { if ctx .db @@ -441,7 +407,6 @@ fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { } } -/// Every member hears COMPLETE, then `SETQUEUE(0, NONE)`, and the Party leaves the queue. fn complete_party(ctx: &ReducerContext, group_id: u64) { let members = member_guids(ctx, group_id); push_to_each(ctx, &members, event_kind::COMPLETE, 0); @@ -451,12 +416,6 @@ fn complete_party(ctx: &ReducerContext, group_id: u64) { dequeue_party_rows(ctx, group_id); } -// ================================================================================================= -// The bucket pass -// ================================================================================================= - -/// Match `bucket` until nothing changes: queued Parties fill oldest first, then five solo Seekers -/// form a Party and the Parties fill again. An `Err` is a broken durable relationship. pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), String> { loop { let (mut solos, parties) = read_bucket(ctx, bucket); @@ -470,9 +429,6 @@ pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), S } } -/// The bucket's solo Seekers, and its queued Parties oldest first. Drops what could make a Stone -/// Add fail: a solo Seeker without a live claim (the lease reaper may take 15 s) or in a Group, and -/// a party row whose Group is gone or is a Raid. `remove_member` cannot roll a pass back. fn read_bucket( ctx: &ReducerContext, bucket: Bucket, @@ -529,8 +485,6 @@ fn has_live_claim(ctx: &ReducerContext, character_guid: u64) -> bool { .any(|claim| !claim.closed && claim.expires_micros > now) } -/// Give `party` Stone Adds while a Seeker fits an Open Role. The current members hear MEMBER_ADDED -/// before each add. A full Party is completed, never joined. fn fill_party( ctx: &ReducerContext, party: &MeetingStoneParty, @@ -581,8 +535,6 @@ fn party_class(ctx: &ReducerContext, guid: u64, group_id: u64) -> u8 { .map_or(0, |seeker| seeker.class) } -/// Form a Party of `leader` and `member` and queue it. The leader hears MEMBER_ADDED before the -/// Party exists, then both hear JOINED. fn form_party( ctx: &ReducerContext, bucket: Bucket, @@ -635,7 +587,6 @@ fn plan_stone_add( }) } -/// Add `joiner` through `plan` and advance the Group's Roster Revision. Returns the Group id. fn stone_add(ctx: &ReducerContext, joiner: &MeetingStoneSeeker, plan: JoinPlan) -> u64 { ctx.db .game_meeting_stone_seeker() @@ -646,13 +597,6 @@ fn stone_add(ctx: &ReducerContext, joiner: &MeetingStoneSeeker, plan: JoinPlan) joined.group_id } -// ================================================================================================= -// Group core hooks -// ================================================================================================= - -/// `character_guid` joined `group_id` through an accepted invite. A solo Seeker leaves the queue, -/// with LEAVE_QUEUE unless the Party is queued for the same area. A queued Party gains a Seeker, -/// completes at five, and otherwise rematches, because the new member moves the Open Roles. pub(crate) fn party_joined( ctx: &ReducerContext, group_id: u64, @@ -694,14 +638,6 @@ pub(crate) struct PartyDeparture { pub(crate) leader_changed: bool, } -/// A member left a queued Party that survives it, after the member row is gone and before the -/// roster list (cm:Groups/Group.cpp:413-476). Returns the bucket to rematch, if any. -/// -/// - Left, same leader: the leaver hears NONE, the rest PARTY_MEMBER_LEFT_LFG, the Party stays. -/// - Left, new leader: the leaver hears NONE and the Party leaves with LEAVE_QUEUE. -/// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the Party leaves with -/// LEAVE_QUEUE. The kicked Character, if it has a World Session, hears -/// LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone. pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Option { let party = ctx .db @@ -725,7 +661,6 @@ pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Opti ); } dequeue_party(ctx, departed.group_id, queue_status::LEAVE_QUEUE); - // A kicked bot or offline member only leaves, as in cmangos (`if (player)`). if !has_live_claim(ctx, guid) { return None; } @@ -762,7 +697,6 @@ pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Opti } } -/// A queued Party disbanded: every former member hears NONE, and nobody waits on alone. pub(crate) fn party_disbanded(ctx: &ReducerContext, group_id: u64, former_members: &[u64]) { if ctx .db @@ -779,12 +713,6 @@ pub(crate) fn party_disbanded(ctx: &ReducerContext, group_id: u64, former_member dequeue_party_rows(ctx, group_id); } -// ================================================================================================= -// Reminders -// ================================================================================================= - -/// Every member of a queued Party whose reminder is due hears IN_PROGRESS. Scheduler-only. The -/// party table holds only queued Parties, so this reads it whole. #[reducer] pub fn remind_queued_parties(ctx: &ReducerContext, _schedule: MeetingStoneReminderSchedule) { if ctx.sender() != ctx.database_identity() { @@ -812,8 +740,6 @@ pub fn remind_queued_parties(ctx: &ReducerContext, _schedule: MeetingStoneRemind } } -/// Arm the reminder tick with exactly one schedule row. `init` and `debug_repair_after_publish` -/// both call this. pub(crate) fn rearm_reminder_schedule(ctx: &ReducerContext) { let schedule = ctx.db.game_meeting_stone_reminder_schedule(); let stale: Vec = schedule.iter().map(|row| row.scheduled_id).collect(); @@ -826,8 +752,6 @@ pub(crate) fn rearm_reminder_schedule(ctx: &ReducerContext) { }); } -/// `character_guid`'s Account Claim ended: drop its solo Seeker row without a packet, since no -/// session is left to hear one. A party Seeker row ends with the membership instead. pub(crate) fn claim_ended(ctx: &ReducerContext, character_guid: u64) { let seekers = ctx.db.game_meeting_stone_seeker(); if seekers @@ -871,7 +795,6 @@ mod tests { } } - /// A member of a full raid hears only that it does not lead. #[test] fn the_join_gate_checks_leader_then_raid_then_size() { use MeetingStoneRefusal::*; diff --git a/module/src/motion.rs b/module/src/motion.rs index d5a9a77da..788880dd8 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -1,76 +1,13 @@ -//! Batched movement republish — the private staging table + the scheduled reducer that -//! drains it into the public `game_entity_motion` relay. -//! -//! # Why -//! -//! SpacetimeDB pays its subscription sweep — re-evaluating every distinct query plan that touches a -//! changed table — **per transaction, not per row**. `movement_update` runs one transaction per -//! movement packet, so at 500 players the sweep ran ~1000×/s. Measured 2026-08-08 on an 8-core box: -//! `movement_update` cost 7.8 ms/call of which 1.5% (56.8 µs) was our own wasm and 98.5% was -//! SpacetimeDB's subscription work — ~80% of one writer core on the hot shard. -//! -//! A PRIVATE table has no subscribers, so writing one costs a row update and **no subscription -//! evaluation at all**. So `movement_update` now writes here, and one scheduled reducer republishes -//! every queued mover into the public table in ONE transaction (Clockwork's own -//! `spacetimedb-cookbook/multiple-position-updates` shape, and Blackholio's 50 ms -//! `move_all_players`). That takes the sweep from ~1000/s to 20/s and — the part that actually -//! matters — **decouples it from player count**: 20 sweeps/s at 500 players and at 2000. -//! -//! # The queue is the dirty set -//! -//! There is no `dirty` column and no "republish everything every tick" pass. A row exists in -//! `game_entity_motion_pending` **iff** that mover has motion the tick has not published yet; the -//! tick drains (deletes) every row it publishes. An entity that did not move therefore has no row -//! and is not touched — the tick's cost is O(movers since the last firing), never O(world). - -//! -//! # Discrete transitions (the part that is easy to get subtly wrong) -//! -//! The relay carries `(opcode, movement_info)`, not just a position. `MSG_MOVE_JUMP`, -//! `MSG_MOVE_STOP`, `MSG_MOVE_START_FORWARD` … are **edge events**: a peer plays an animation or -//! changes extrapolation state off them. Naive "keep the latest packet" loses any transition that -//! starts and ends inside one 50 ms window. [`coalesce`] is the rule that stops that — see its doc -//! for the three cases and why each is lossless. -//! -//! # Lifecycle -//! -//! The pending row is deleted everywhere the public motion row is (logout/disconnect, cascade -//! delete, creature despawn, pet despawn, teleport), and the tick independently refuses to publish a -//! guid with no live `game_world_entity` row — so a queued packet can never resurrect a motion row -//! for an entity that left between the write and the firing. - use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::{game_entity_motion, game_world_entity, EntityMotion}; -// =========================================================================================== -// Cadence -// =========================================================================================== - -/// Default republish cadence — 50 000 µs = **20 Hz** (the default). Peer movement gains at most -/// one tick (50 ms) of latency; vanilla clients dead-reckon between updates, and BitCraft ships far -/// coarser (one row per path segment, lerped on read). pub const MOTION_TICK_MICROS: i64 = 50_000; -/// Cadence floor for [`set_motion_tick_ms`]. Every firing is a transaction on the one serialized -/// commit stream, so a very tight interval trades the win this whole item exists to buy back for -/// scheduler pressure (`debug_arm_instance_tick`'s floor, same reasoning). pub const MOTION_TICK_MS_FLOOR: u64 = 10; -/// Cadence ceiling for [`set_motion_tick_ms`] — a second of staged movement is already well past -/// what peer dead-reckoning covers; anything larger is a misconfiguration, not a tuning choice. pub const MOTION_TICK_MS_CEIL: u64 = 1_000; -// =========================================================================================== -// The private staging table [server] — NO `public`, deliberately -// =========================================================================================== - -/// One row per mover whose latest motion has not been republished yet — the whole point of the -/// item, so **do not make this table `public`**: a subscriber here reinstates exactly the -/// per-transaction subscription sweep the batching removes. -/// -/// Carries the same payload `game_entity_motion` does, minus `seq` (minted at publish time from the -/// public row, so the two can never disagree) and minus any TTL/`created_at` shape (this is a work -/// queue that the tick drains, not an event table `gc.rs` reaps). +// Keep staging private; subscribing here restores a subscription sweep per heartbeat. #[table(accessor = game_entity_motion_pending)] pub struct PendingMotion { #[primary_key] @@ -79,23 +16,11 @@ pub struct PendingMotion { pub instance_id: u64, pub grid_x: i32, pub grid_y: i32, - /// `(grid_x, grid_y)` packed — the AOI cell key, carried through to the public row verbatim. - /// Written in the SAME statement as `grid_x`/`grid_y` from - /// `lyracore_shared::spatial::grid_cell_id` (see `tripwires::grid_cell_tripwire`). pub cell: i64, pub opcode: u16, pub movement_info: Vec, } -/// Drives [`publish_motion`]. ONE row; its `scheduled_at` **is** the cadence knob (the -/// `CreatureMoveSchedule` precedent: no separate `tick_ms` column, which would only duplicate -/// `scheduled_at` and then drift from it). [`set_motion_tick_ms`] is the operator lever. -/// -/// LOAD-BEARING: with this row absent nothing drains `game_entity_motion_pending`, so **peer -/// movement stops relaying entirely**. `seed::init` arms it on a fresh database and -/// `debug_repair_after_publish` ensures it after every publish — but `init` does NOT re-run on an -/// auto-migrate republish and the repair pass needs a claimed operator, so [`ensure_schedule_armed`] -/// is a third, unconditional net that arms it from the movement path itself. [server] #[table(accessor = game_motion_publish_schedule, scheduled(publish_motion))] pub struct MotionPublishSchedule { #[primary_key] @@ -104,23 +29,6 @@ pub struct MotionPublishSchedule { pub scheduled_at: ScheduleAt, } -// =========================================================================================== -// The coalescing rule — pure, unit-tested below -// =========================================================================================== - -/// Is `opcode` a DISCRETE state transition, i.e. an edge event a peer cannot reconstruct from a -/// later packet? -/// -/// Only two movement opcodes are not: `MSG_MOVE_HEARTBEAT` and `MSG_MOVE_SET_FACING`. Both carry -/// nothing but position + orientation + flags, and **every** movement packet carries all three — so -/// a superseded one is losslessly droppable. Everything else (start/stop/strafe/turn/jump/fall-land/ -/// swim/run-walk-mode) is an edge the client animates off; dropping one shows a peer sliding into a -/// run with no start, or landing with no jump. -/// -/// This mirrors the gateway's own rule 1 (`world/coalesce.rs`): "any non-heartbeat -/// opcode is ALWAYS a state change", one layer down — it is deliberately the SAME classification, -/// only stricter about `SET_FACING`, which the gateway forwards eagerly because the module's stored -/// heading feeds server-side facing checks. pub(crate) fn is_discrete(opcode: u16) -> bool { use lyracore_shared::opcodes::movement as m; !matches!( @@ -129,33 +37,14 @@ pub(crate) fn is_discrete(opcode: u16) -> bool { ) } -/// What to do with a newly-arrived payload given the one already queued for the same mover. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Coalesce { - /// Take the new payload and drop the queued one. Lossless: the queued one was a pure state - /// refresh (heartbeat / facing) whose entire content — position, orientation, flags — the new - /// payload also carries, in fresher form. Replace, - /// KEEP the queued payload and drop the new one. The queued one is a discrete transition and - /// the new one is only a state refresh, so the transition is the strictly more informative of - /// the two. Cost: the published position is up to one extra tick stale (~50 ms, a couple of - /// yards) — invisible against dead-reckoning, and the very next packet or tick corrects it. - /// The caller still re-addresses the row (map/grid/cell) so it is delivered to the cell the - /// mover is ACTUALLY in. KeepQueued, - /// Two discrete transitions inside one window (e.g. START_FORWARD then JUMP). Neither may be - /// dropped and one public row cannot express both — two writes to the same row in one - /// transaction coalesce into one diff for subscribers, so staging both would still lose one. - /// Publish the QUEUED one immediately (in this movement transaction, paying one sweep) and - /// stage the new one. This is the only path that still costs a per-packet sweep, and it is - /// reachable only when a player produces two input-state changes inside one tick window — with - /// the gateway's own 150 ms heartbeat coalescing upstream, that is rare and - /// self-limiting, never proportional to player count. + // Public updates collapse within a transaction; distinct transitions need separate firings. FlushThenReplace, } -/// The rule. `queued` is the opcode already staged for this mover, `incoming` the one that just -/// arrived. See [`Coalesce`] for why each case is the lossless choice. pub(crate) fn coalesce(queued: u16, incoming: u16) -> Coalesce { match (is_discrete(queued), is_discrete(incoming)) { (true, true) => Coalesce::FlushThenReplace, @@ -164,21 +53,12 @@ pub(crate) fn coalesce(queued: u16, incoming: u16) -> Coalesce { } } -/// What the tick does with one queued row. The row is deleted from the queue either way — a stale -/// one must not sit there forever, and a published one is by definition no longer pending. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TickAction { - /// The mover still has a live `game_world_entity` row: republish. Publish, - /// The mover left (logout, despawn, cross-map hop, cascade delete) between staging this packet - /// and this firing. Dropping it is what stops the tick from RESURRECTING a `game_entity_motion` - /// row for an entity that no longer exists — a resurrected row relays a ghost to every - /// subscriber in its cell and nothing ever deletes it again. DropStale, } -/// Pure per-row tick decision (this crate has no `ReducerContext` test harness by design, so the -/// decision is a function and the reducer only executes it). pub(crate) fn tick_action(entity_alive: bool) -> TickAction { if entity_alive { TickAction::Publish @@ -187,12 +67,6 @@ pub(crate) fn tick_action(entity_alive: bool) -> TickAction { } } -// =========================================================================================== -// Write path -// =========================================================================================== - -/// Publish ONE staged payload into the public `game_entity_motion` relay, minting `seq` off the -/// public row (`+1`, or 0 for a mover's first ever motion) exactly as the earlier inline relay did. fn publish(ctx: &ReducerContext, p: &PendingMotion) { let motions = ctx.db.game_entity_motion(); match motions.guid().find(p.guid) { @@ -225,11 +99,6 @@ fn publish(ctx: &ReducerContext, p: &PendingMotion) { } } -/// Stage one mover's motion for the next [`publish_motion`] firing — what `movement_update`'s -/// `plan.relay_motion` block calls instead of writing `game_entity_motion` directly. -/// -/// `map_id`/`instance_id`/grid come from the entity the caller just moved, so the staged row and the -/// entity row can never disagree about which AOI box they belong to. #[allow(clippy::too_many_arguments)] pub(crate) fn queue_motion( ctx: &ReducerContext, @@ -258,8 +127,7 @@ pub(crate) fn queue_motion( return; }; match coalesce(prev.opcode, opcode) { - // Keep the staged TRANSITION's payload; re-address the row so it lands in the cell the - // mover is actually in now (`..prev` keeps `opcode`/`movement_info`). + // Keep the queued transition but update its AOI address to the current cell. Coalesce::KeepQueued => { pending.guid().update(PendingMotion { map_id, @@ -270,7 +138,6 @@ pub(crate) fn queue_motion( ..prev }); } - // Two transitions in one window: neither may be lost, so the older one goes out now. Coalesce::FlushThenReplace => { publish(ctx, &prev); pending.guid().update(PendingMotion { @@ -299,29 +166,10 @@ pub(crate) fn queue_motion( } } -/// Drop any motion staged for `guid` — called from every path that deletes the mover's -/// `game_entity_motion` row (and from the teleport primitive, so a pre-teleport packet cannot be -/// republished at the old position after the entity has been moved). Idempotent. pub(crate) fn drop_pending(ctx: &ReducerContext, guid: u64) { ctx.db.game_entity_motion_pending().guid().delete(guid); } -// =========================================================================================== -// The tick -// =========================================================================================== - -/// **The 20 Hz republish.** Drains `game_entity_motion_pending` into the public -/// `game_entity_motion` in ONE transaction, so SpacetimeDB's subscription sweep runs once per -/// firing instead of once per movement packet. -/// -/// Scheduler-only (the `ctx.sender()` gate every scheduled reducer in this module carries). -/// -/// The `.iter()` here is deliberate and is NOT the whole-world scan `danger-zones.md` §1.7 forbids: -/// this table is a WORK QUEUE holding only the movers staged since the previous firing (typically a -/// few dozen), it is written exclusively by movers already resident on this shard, and the whole -/// point of the pass is to visit every one of them. There is no narrower read to be had — a -/// `dirty`-flag column plus an index would visit the identical rows and additionally pay for idle -/// ones. #[reducer] pub fn publish_motion(ctx: &ReducerContext, _schedule: MotionPublishSchedule) { if ctx.sender() != ctx.database_identity() { @@ -330,16 +178,9 @@ pub fn publish_motion(ctx: &ReducerContext, _schedule: MotionPublishSchedule) { publish_staged(ctx); } -/// The drain behind [`publish_motion`] — republish every staged mover into the public relay. -/// Also runs this at the END of `gw_movement_batch`, INSIDE the batch transaction: the -/// subscription sweep (the whole reason the pending stage exists) is paid once per batch -/// either way, and draining inline removes the schedule's 50ms latency quantum plus the -/// pending-row churn for the gateway path. The scheduled tick keeps running for the legacy -/// per-player path and is a cheap no-op when the batch already drained. pub(crate) fn publish_staged(ctx: &ReducerContext) { let pending = ctx.db.game_entity_motion_pending(); let entities = ctx.db.game_world_entity(); - // Collect first: the loop deletes from the table it is walking. let queued: Vec = pending.iter().collect(); for row in queued { pending.guid().delete(row.guid); @@ -350,10 +191,6 @@ pub(crate) fn publish_staged(ctx: &ReducerContext) { } } -/// Third net under the schedule row (see [`MotionPublishSchedule`]): checked ONCE per wasm instance -/// (the atomic), so the steady-state cost is nothing, and it self-heals the one failure mode that -/// would otherwise be silent and total — a republished database where `init` did not re-run and -/// `debug_repair_after_publish` was never called would stage movement forever and relay none of it. static SCHEDULE_CHECKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn ensure_schedule_armed(ctx: &ReducerContext) { @@ -374,21 +211,6 @@ fn ensure_schedule_armed(ctx: &ReducerContext) { } } -// =========================================================================================== -// The knob -// =========================================================================================== - -/// **THE CADENCE KNOB.** Retune the republish rate live, without a republish: -/// `spacetime call set_motion_tick_ms 50` (20 Hz, the default), `100` (10 Hz), `25` (40 Hz). -/// Takes effect on the next firing — the row is replaced, so the scheduler re-arms at the new -/// interval; no re-publish, no re-seed, nothing else to do. Run it on EVERY shard (each database -/// has its own schedule row). -/// -/// Operator-gated rather than `debug_reducers`-gated, so the lever exists in a build compiled -/// without the debug feature. It is deliberately NOT a `game_config` column: the cadence IS -/// `scheduled_at` (the `CreatureMoveSchedule` precedent), `spacetime sql` cannot write a -/// `ScheduleAt` anyway, and `game_config` is gateway-SUBSCRIBED — an END-appended column there needs -/// a hand-patched binding (`danger-zones.md` §1.2) for a value the gateway never reads. #[reducer] pub fn set_motion_tick_ms(ctx: &ReducerContext, tick_ms: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -417,10 +239,6 @@ pub fn set_motion_tick_ms(ctx: &ReducerContext, tick_ms: u64) -> Result<(), Stri Ok(()) } -// =========================================================================================== -// Tests for the movement coalescing decisions -// =========================================================================================== - #[cfg(test)] mod tests { use super::*; @@ -434,7 +252,6 @@ mod tests { #[test] fn only_heartbeat_and_facing_are_coalescible() { - // Every one of the 17 relayed movement opcodes is classified, not a hand-picked subset. for &op in m::SLICE_MOVE_OPCODES { let discrete = is_discrete(op as u16); let expected = op != m::MSG_MOVE_HEARTBEAT && op != m::MSG_MOVE_SET_FACING; @@ -448,15 +265,12 @@ mod tests { #[test] fn coalesce_never_drops_a_discrete_transition() { - // A staged refresh is always superseded — nothing in it survives that the new packet lacks. assert_eq!(coalesce(HEARTBEAT, HEARTBEAT), Coalesce::Replace); assert_eq!(coalesce(HEARTBEAT, JUMP), Coalesce::Replace); assert_eq!(coalesce(FACING, HEARTBEAT), Coalesce::Replace); assert_eq!(coalesce(FACING, STOP), Coalesce::Replace); - // A staged TRANSITION outranks a mere refresh — the refresh is dropped, not the jump. assert_eq!(coalesce(JUMP, HEARTBEAT), Coalesce::KeepQueued); assert_eq!(coalesce(START, FACING), Coalesce::KeepQueued); - // Two transitions in one window: the older one is flushed rather than overwritten. assert_eq!(coalesce(START, JUMP), Coalesce::FlushThenReplace); assert_eq!(coalesce(JUMP, STOP), Coalesce::FlushThenReplace); } diff --git a/module/src/mount.rs b/module/src/mount.rs index 4785cfca7..fb6b1ea59 100644 --- a/module/src/mount.rs +++ b/module/src/mount.rs @@ -1,19 +1,3 @@ -//! Land mounts — the ground-mount state model. A mount is an ordinary cancelable self aura: the -//! `A_MOUNTED` aura row IS the mounted state, and `WorldEntity.mount_display_id` is a PROJECTION of -//! that aura set for the client's `UNIT_FIELD_MOUNTDISPLAYID`, never a second state machine. -//! -//! The module has no single aura-deletion boundary — auras are removed by `do_cancel_aura`, the -//! expiry reap, dispel, the spellbook unlearn path and the death shed. Rather than teach each of -//! those to undo a mount, every one of them converges on [`recompute_mount`], which RE-DERIVES the -//! projection from whatever aura rows remain. That is the same collect-a-predicate-then-recompute -//! shape the crate -//! already uses for `aura_moves_vitals` + `recompute_vitals`, and it is what makes every dismount -//! trigger idempotent by construction: running it twice, or on an unmounted player, converges on the -//! same state and writes nothing the second time. -//! -//! TAXI BOUNDARY: a taxi flight owns `mount_display_id` for its whole duration (`taxi.rs`), and the -//! two mechanics share one field. Both entry points here refuse to touch a player in flight. [entity] - use spacetimedb::ReducerContext; use crate::game_aura; @@ -24,19 +8,10 @@ use crate::game_world_entity; use crate::spell::{SpellEffect, A_MOD_SPEED, A_MOUNTED, SPEED_MOUNTED}; use crate::WorldEntity; -/// Can applying or removing this aura effect move the mount projection? True for the mount state -/// itself, and for the mounted-speed effect that rides alongside it (the speed fold reads that aura -/// only while an `A_MOUNTED` aura is present, so gaining or losing it is also a mount-projection -/// event). Every aura-removal site collects this per deleted row and calls [`recompute_mount`] once -/// afterwards. Pure — the two-argument shape mirrors `aura_moves_vitals(kind, p0)`. pub(crate) fn mount_aura_moves_mount(eff_kind: u8, eff_p0: i32) -> bool { eff_kind == A_MOUNTED || (eff_kind == A_MOD_SPEED && eff_p0 == SPEED_MOUNTED as i32) } -/// The mount display an aura set projects: the display frozen on an `A_MOUNTED` aura's `p0`, or 0 -/// when the set holds none. A negative or absent value reads as 0 (unmounted) so unauthored data can -/// never write a nonsense display. Pure over `(eff_kind, eff_p0)` pairs, so the projection rule is -/// unit-testable without a reducer context. pub(crate) fn projected_mount_display(effects: impl IntoIterator) -> u32 { effects .into_iter() @@ -45,8 +20,7 @@ pub(crate) fn projected_mount_display(effects: impl IntoIterator Option { ctx.db .game_aura() @@ -57,18 +31,6 @@ pub(crate) fn active_mount_spell(ctx: &ReducerContext, guid: u64) -> Option .map(|a| a.spell_id) } -/// Re-derive `guid`'s `mount_display_id` AND relayed run speed from its current aura rows. Zero -/// `A_MOUNTED` auras clears the display; one restores the value frozen on it. The speed side reuses -/// `combat::effective_move_speed` at base 1.0 with the current movement effects, so -/// `run_speed_mult_bp` carries the exact multiplier — 16 000 for a 60% mount, 10 000 for an unmounted -/// rider with no other speed aura — and the existing `run_speed_mult_bp` subscription diff (shared with -/// GM `.speed`) turns that into `SMSG_FORCE_RUN_SPEED_CHANGE` with no gateway change needed. NEVER a -/// delta — that is what makes every caller idempotent. Both fields are read-modify-written TOGETHER and -/// the row is only written when at least one actually changed, because the gateway's subscription diff -/// fires on every write and a redundant write would relay a spurious VALUES / force-run-speed update. -/// -/// A player in a taxi flight is left completely alone: `taxi.rs` owns the shared display field for the -/// duration of the flight and clears it itself on landing. pub(crate) fn recompute_mount(ctx: &ReducerContext, guid: u64) { if crate::taxi::is_in_flight(ctx, guid) { return; @@ -93,14 +55,6 @@ pub(crate) fn recompute_mount(ctx: &ReducerContext, guid: u64) { entities.guid().update(e); } -/// The one shared land-dismount operation. Removes EVERY aura row belonging to the active mount spell -/// — so the paired mounted-speed effect goes with the mount itself — then recomputes the projection. -/// A no-op (and never an error) when `guid` is not mounted, so an action chokepoint can call it -/// unconditionally, and calling it twice changes nothing the second time. -/// -/// Every dismount trigger routes here: `E_DISMOUNT` resolution, mount replacement, and the accepted -/// action / indoor-transition hooks. Manual `CMSG_CANCEL_AURA`, natural expiry and DEATH reach the same -/// end state through ordinary aura removal plus [`recompute_mount`]. pub(crate) fn dismount(ctx: &ReducerContext, guid: u64) { if crate::taxi::is_in_flight(ctx, guid) { return; @@ -121,32 +75,12 @@ pub(crate) fn dismount(ctx: &ReducerContext, guid: u64) { recompute_mount(ctx, guid); } -/// Restore the land-mount projection onto a FRESHLY REBUILT player entity. A mount is an ordinary -/// aura row, so it outlives the despawn/rebuild that a relog, a WORLDPORT_ACK map change or a shard -/// handoff performs (`sweep_transfer_game_aura` carries the rows across a database boundary), while -/// `build_player_entity` always constructs at display 0 and 1x. Without this the rider comes back on -/// foot with an active mount buff. -/// -/// Guarded on actually being mounted: an ordinary login must leave the GM `.speed` carry -/// (`Character::pending_run_speed_mult_bp`) that `build_player_entity` just read alone. Idempotent — -/// `recompute_mount` writes nothing when the rebuilt row already agrees. pub(crate) fn restore_mount_on_rebuild(ctx: &ReducerContext, guid: u64) { if active_mount_spell(ctx, guid).is_some() { recompute_mount(ctx, guid); } } -// =========================================================================================== -// Mount eligibility — the cast gate. [entity] -// -// Every check below is READ-ONLY, and the whole sweep runs inside `check_cast_gates`, which sits -// strictly before the cast core spends anything. So a refused mount leaves the item, the aura set, -// the display, run speed, cooldowns and combat state byte-identical. -// =========================================================================================== - -/// Does casting `spell_id` mount its target? Keyed on the presence of an `A_MOUNTED` effect, never a -/// spell id or name — the same data-driven classification `items::ops::apply_item_use` reads to keep a -/// mount item in the bag, so onboarding another mount is data alone. [entity] pub(crate) fn spell_is_mount(ctx: &ReducerContext, spell_id: u32) -> bool { ctx.db .game_spell_effect() @@ -155,27 +89,14 @@ pub(crate) fn spell_is_mount(ctx: &ReducerContext, spell_id: u32) -> bool { .any(|e| e.kind == A_MOUNTED) } -/// Does a `SkillAbility` race/class mask admit `id`? A 0 mask is the DBC's "everyone" row; otherwise -/// `id`'s bit must be set. Races and classes are 1-based, so id 1 is bit 0 and anything outside 1..=32 -/// can never match. Pure — this is what makes a mount tied to another race's riding tradition refuse. fn mask_admits(mask: u32, id: u8) -> bool { mask == 0 || ((1..=32).contains(&id) && mask & (1 << (id - 1)) != 0) } -/// Does a learned rank meet a `SkillAbility` row's `min_skill`? Where vanilla's 75 (Apprentice) and 150 -/// (Journeyman) riding tiers live: the threshold is inclusive, so exactly 75 rides a rank-75 mount and 74 -/// does not. A negative/unauthored `min_skill` reads as 0 (no requirement). Pure. fn rank_satisfies(rank: u16, min_skill: i32) -> bool { i32::from(rank) >= min_skill.max(0) } -/// Is `guid` TRAINED for `spell_id`? True when one of the character's learned skill lines carries a -/// `game_skill_ability` row naming the spell, whose race/class masks admit the character, and whose -/// `min_skill` the learned rank meets. Walking the player's own lines (a handful) and probing the -/// `by_skill_line` index keeps this off a full scan of vanilla's ~10k ability rows. -/// -/// Fails CLOSED: a mount whose skill-line data was never imported is uncastable rather than free, which -/// is the whole point of the gate — owning the item must not bypass riding training. fn trained_for_spell(ctx: &ReducerContext, guid: u64, spell_id: u32, race: u8, class: u8) -> bool { let abilities = ctx.db.game_skill_ability(); ctx.db @@ -199,13 +120,6 @@ pub(crate) fn indoor_check_is_due(old_move_ms: u32, move_time_ms: u32) -> bool { move_time_ms / 100 != old_move_ms / 100 } -/// May a land mount be used at `(x, y, z)`? The ONE shared indoor predicate — the cast gate, the movement -/// heartbeat and the teleport path all read it rather than interpreting MOGP flags themselves, so there is -/// a single place for the WMO rule to be right. -/// -/// FAILS OPEN by construction: `vmap::is_indoor` answers `false` when vmap is disabled, when no generation -/// is active, when the cell carries no indoor-presence marker, and when the probe finds no WMO group. All -/// four read as outdoors, so the mount feature is fully shippable and correct with vmap off. pub(crate) fn mount_cast_allowed_here( ctx: &ReducerContext, map_id: u32, @@ -216,9 +130,6 @@ pub(crate) fn mount_cast_allowed_here( !crate::vmap::is_indoor(ctx, map_id, x, y, z) } -/// Is `entity`'s head under a liquid surface? The SAME predicate player breath uses -/// (`terrain::liquid_level_at` + `env::is_submerged`), not a second water-position interpretation: -/// wading through shallow water, or floating at the surface, is not submerged and never refuses a mount. fn is_submerged_here(ctx: &ReducerContext, entity: &WorldEntity) -> bool { let level = crate::terrain::liquid_level_at(ctx, entity.map_id, entity.x, entity.y); lyracore_shared::env::is_submerged( @@ -229,13 +140,6 @@ fn is_submerged_here(ctx: &ReducerContext, entity: &WorldEntity) -> bool { ) } -/// The land-mount cast gate: refuse a mount spell unless the caster is a trained rider of the right line -/// and rank, alive, out of combat, outdoors and not submerged. `Ok(())` for every spell that carries no -/// `A_MOUNTED` effect, so the cast core calls this unconditionally. -/// -/// Only PLAYERS are gated. A creature has no `game_player_skill` rows and no riding concept, so gating it -/// would refuse every scripted mount a mob is given, exactly like the level and cost gates' `is_player()` -/// carve-outs beside this one. pub(crate) fn check_mount_cast( ctx: &ReducerContext, caster: &WorldEntity, @@ -248,13 +152,9 @@ pub(crate) fn check_mount_cast( if !trained_for_spell(ctx, caster.guid, spell_id, caster.race(), caster.class()) { return Err(format!("spell {spell_id} requires riding training")); } - // A corpse and a ghost are both refused. `caster.dead` is already checked by the shared cast gate; - // the GHOST player flag is the released-spirit state that outlives it. if caster.dead || caster.player_flags & lyracore_shared::constants::player_flags::GHOST != 0 { return Err("you cannot mount while dead".to_string()); } - // The module's authoritative engagement state, never a gateway session flag — a mount is not a - // combat escape. if crate::combat::is_engaged(ctx, caster.guid) { return Err("you cannot mount while in combat".to_string()); } @@ -275,9 +175,6 @@ mod tests { SPEED_SWING, }; - /// The removal-convergence predicate every aura-deletion site collects. Only the mount state and - /// its paired mounted-speed effect move the projection; an ordinary buff, a DoT, or a NON-mounted - /// speed aura (a snare, a haste) must not drag a recompute onto every unrelated aura removal. #[test] fn mount_aura_moves_mount_covers_the_mount_pair_only() { assert!(mount_aura_moves_mount(A_MOUNTED, 0)); @@ -290,9 +187,6 @@ mod tests { assert!(!mount_aura_moves_mount(A_PERIODIC_DAMAGE, 0)); } - /// The projection rule: the display comes off the `A_MOUNTED` aura's frozen `p0`, and an aura set - /// without one projects 0. Unauthored (negative) data reads as unmounted rather than as a garbage - /// display id. #[test] fn projected_mount_display_reads_the_mount_aura_only() { assert_eq!(projected_mount_display([]), 0); @@ -307,9 +201,6 @@ mod tests { assert_eq!(projected_mount_display([(A_MOUNTED, -3)]), 0); } - /// Mount replacement (story 28): applying a second mount dismounts the first, so the aura set is - /// never allowed to hold two `A_MOUNTED` rows. Should one appear anyway, the projection stays - /// deterministic rather than flickering between displays. #[test] fn projected_mount_display_is_deterministic_with_a_stray_second_mount() { assert_eq!( @@ -318,10 +209,6 @@ mod tests { ); } - /// The riding rank tiers (stories 13/14), asserted on the boundary the gate actually compares: - /// `min_skill` is inclusive, so 74 fails a rank-75 mount and 75 rides it, 149 fails a rank-150 - /// mount and 150 rides it. Unauthored (negative) data imposes no requirement rather than becoming - /// an unreachable threshold. #[test] fn rank_satisfies_is_inclusive_at_the_vanilla_riding_tiers() { assert!(!rank_satisfies(74, 75)); @@ -335,10 +222,6 @@ mod tests { assert!(rank_satisfies(0, -1)); } - /// The race/class gate on a `SkillAbility` row (story 15): a 0 mask is the DBC's universal row, a - /// populated one admits only its own bits, and an out-of-range id can never match. Races and classes - /// are 1-based, so Human (1) is bit 0 — an off-by-one here would let every race ride every - /// race-specific line. #[test] fn mask_admits_treats_race_and_class_ids_as_one_based_bits() { assert!(mask_admits(0, 1)); @@ -361,7 +244,6 @@ mod tests { assert!(indoor_check_is_due(1_099, 1_100)); assert!(!indoor_check_is_due(1_100, 1_199)); assert!(indoor_check_is_due(1_100, 5_000)); - // A client clock that rolls backwards still reads as a crossing rather than wedging the check. assert!(indoor_check_is_due(5_000, 1_100)); } } diff --git a/module/src/nav.rs b/module/src/nav.rs index d5c746df8..4b0ea51f1 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -1,39 +1,21 @@ -//! Nav grid: walkability and line-of-sight obstruction data. -//! -//! One row per terrain MCNK cell that has ANY obstruction, steep slope, or hole — fully-clear -//! cells emit no row, so a missing chunk means "no obstacles known here" and every reader keeps -//! today's straight-line behavior (the same off-slice fallback contract as `game_terrain_chunk`). -//! Imported from the operator's own client geometry by `importer --nav` (licensing firewall). -//! Blob layout + cell math live in `lyracore_shared::nav` (one copy, unit-tested there; the importer -//! rasterizes through the SAME functions). - #[cfg(feature = "debug_reducers")] use lyracore_shared::nav::OBS_DIM; use lyracore_shared::nav::{self, OBS_BYTES, WALK_BYTES, WALK_DIM}; use lyracore_shared::terrain::{cell_index, cell_key}; use spacetimedb::{reducer, table, ReducerContext, Table}; -/// One terrain cell's nav data. `walk` is the 64×64 walkability bitmask; `obs` is the 32×32 -/// obstruction-height grid (may be EMPTY when the cell only has slope/hole unwalkability and -/// nothing tall enough to block sight). [static] #[table(accessor = game_nav_chunk, public)] pub struct NavChunk { - /// (map << 32) | (cell_x << 16) | cell_y — same `cell_key` as `game_terrain_chunk`. #[primary_key] pub key: u64, pub map_id: u32, pub cell_x: u16, pub cell_y: u16, - /// Obs heights quantize against this (the cell's min terrain corner height). pub base_z: f32, - /// 512-byte walkability bitmask (`lyracore_shared::nav::walk_get`). pub walk: Vec, - /// 1024-byte obstruction grid (`lyracore_shared::nav::obs_top`), or empty = all-clear. pub obs: Vec, } -/// Terrain and navigation imports, plus effective coverage changes, advance this singleton in -/// their transaction. An absent row means the installed inputs predate revision tracking. #[table(accessor = game_navigation_revision, public)] pub struct NavigationRevision { #[primary_key] @@ -51,8 +33,6 @@ pub struct NavigationInputs { pub coverage_generation: Option, } -/// Identifies the inputs to movement on one map. It does not certify coverage at a position; -/// the retained RouteStep carries that evidence. Dynamic obstacles still require bounded expiry. pub(crate) fn inputs(ctx: &ReducerContext, map_id: u32) -> NavigationInputs { NavigationInputs { imported_revision: ctx @@ -83,8 +63,7 @@ pub(crate) fn record_change(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } -/// Is (x, y) standable? `None` = no nav chunk here (un-imported or fully clear) — callers keep -/// their current behavior, exactly like `terrain::ground_z`'s off-slice contract. One PK find. +// Missing chunks mean unimported or fully clear terrain, so they do not prove coverage. pub(crate) fn walkable(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); let chunk = ctx @@ -99,7 +78,6 @@ pub(crate) fn walkable(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Opt Some(nav::walk_get(&chunk.walk, nx, ny)) } -/// Obstruction top-Z over (x, y), or `None` when the column (or the whole cell) is clear. #[cfg(feature = "debug_reducers")] pub(crate) fn obstruction_top(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); @@ -115,12 +93,6 @@ pub(crate) fn obstruction_top(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) nav::obs_top(&chunk.obs, chunk.base_z, ox, oy) } -// =========================================================================================== -// Import reducers — blobs ride the packed-string path like terrain (`Vec` has no -// spacetime-SQL literal): rows `;`, fields `,`, blobs hex. An EMPTY walk field means -// "all 4096 bits set" (the common some-obs-but-fully-walkable cell, e.g. a low fence). -// =========================================================================================== - fn hex_decode(s: &str) -> Result, String> { if !s.len().is_multiple_of(2) { return Err("odd-length hex blob".to_string()); @@ -179,7 +151,6 @@ fn load_nav_batch(ctx: &ReducerContext, packed: &str) -> Result { Ok(loaded) } -/// Clear + load the first nav batch (operator-only), mirroring `import_terrain_chunks`. #[reducer] pub fn import_nav_chunks(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -194,7 +165,6 @@ pub fn import_nav_chunks(ctx: &ReducerContext, packed: String) -> Result<(), Str record_change(ctx) } -/// Append a nav batch WITHOUT the reset — a zone spans many `spacetime call` args. #[reducer] pub fn import_nav_chunks_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -204,18 +174,10 @@ pub fn import_nav_chunks_append(ctx: &ReducerContext, packed: String) -> Result< Ok(()) } -// =========================================================================================== -// Runtime consumption, the module-side wrappers over `lyracore_shared::nav`'s -// pure queries, gated on `game_config.nav_enabled` (default OFF; the benchmark + live -// verify flips it). Every wrapper degrades to straight-line behavior when the -// flag is off or a chunk is missing. `route_step` holds position when no path exists. -// =========================================================================================== - use crate::game_config; use crate::vmap::game_vmap_nav_coverage; use lyracore_shared::nav::NavCellData; -/// The 243 consumption gate. Missing config row = false (fresh DB stays baseline). pub(crate) fn nav_enabled(ctx: &ReducerContext) -> bool { ctx.db .game_config() @@ -225,7 +187,6 @@ pub(crate) fn nav_enabled(ctx: &ReducerContext) -> bool { .unwrap_or(false) } -/// The vmap-derived coverage gate. Missing config row = false, like `nav_enabled`. pub(crate) fn nav_coverage_enabled(ctx: &ReducerContext) -> bool { ctx.db .game_config() @@ -235,9 +196,6 @@ pub(crate) fn nav_coverage_enabled(ctx: &ReducerContext) -> bool { .unwrap_or(false) } -/// The generation whose derived coverage may join planning on this map, or `None` for the -/// terrain-only grid. Needs the gate on, an ACTIVE generation, and a COMPLETE manifest — partial -/// coverage never routes, and a non-active generation's rows are invisible here. pub(crate) fn coverage_generation(ctx: &ReducerContext, map_id: u32) -> Option { if !nav_coverage_enabled(ctx) { return None; @@ -245,8 +203,6 @@ pub(crate) fn coverage_generation(ctx: &ReducerContext, map_id: u32) -> Option, coverage: Option) -> Option { match (terrain, coverage) { (Some(terrain), Some(coverage)) => Some(nav::merge_cells(&terrain, &coverage)), @@ -255,10 +211,6 @@ fn merged_cell(terrain: Option, coverage: Option) -> O } } -/// Chunk fetch closure for the shared queries — one PK find per crossed cell, blobs cloned -/// (a few KB; the shared layer memoizes per query). With coverage consuming, each cell also costs -/// one indexed coverage lookup and a blob merge; the generation itself resolves ONCE per query -/// (the same hoist `vmap::fetcher` does with its active-generation lookup), never per cell. fn fetcher(ctx: &ReducerContext, map_id: u32) -> impl FnMut(u16, u16) -> Option + '_ { let generation = coverage_generation(ctx, map_id); move |cx, cy| fetch_cell(ctx, map_id, generation, cx, cy).0 @@ -304,7 +256,6 @@ fn fetch_cell( (merged_cell(terrain, coverage), matching) } -/// Closed doors apply even where static geometry falls back to the coarse navigation grid. pub(crate) fn has_los( ctx: &ReducerContext, map_id: u32, @@ -321,16 +272,13 @@ pub(crate) fn has_los( lyracore_shared::nav::has_los(&mut fetcher(ctx, map_id), a, b) } -/// Maximum expansions consumed by one route step. Packages reserve this budget before movement. pub(crate) const LEG_MAX_EXPANSIONS: u32 = 16_384; -/// Complete and Partial describe the planned route, including when collision stops this step. #[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] pub enum RouteStatus { Complete, Partial, Blocked, - /// Navigation is disabled. The collision Gate still applies. Direct, } @@ -341,8 +289,6 @@ pub enum RouteStatus { )] pub enum CoverageEvidence { Unknown, - /// Every consulted navigation cell has a matching derived row from the active generation's - /// finalized manifest. This does not certify terrain coverage or the world import scope. VerifiedCells(VerifiedRouteCells), } @@ -374,8 +320,6 @@ pub struct RouteClip { pub hit: RoutePoint, } -/// `endpoint == from` means no movement. Complete means a route was planned, not arrival. Static -/// collision and exact walk-mask validation may shorten the committed endpoint. #[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] #[cfg_attr( not(has_packages), @@ -400,7 +344,6 @@ pub struct RoutePath { pub points: Vec<(f32, f32, f32)>, } -/// Retain a bounded part of one route, including its turns, after checking every segment. #[cfg_attr( not(has_packages), allow(dead_code, reason = "retained Package API operation") @@ -424,7 +367,6 @@ pub(crate) fn route_path( ) } -/// Use a caller's smaller search allowance while retaining the normal movement limits. #[cfg_attr( not(has_packages), allow(dead_code, reason = "retained Package API operation") @@ -606,8 +548,7 @@ struct WalkingStep { clipping: Option, } -/// Resample a clipped floor segment down to half a navigation cell. A clear shorter step must -/// pass the original collision point; stopping before it would erode the wall clearance margin. +// A shortened probe must clear the original collision point to avoid eroding walls. #[cfg_attr( not(has_packages), allow(dead_code, reason = "retained Package API operation") @@ -647,8 +588,6 @@ fn sample_walking_step( original } -/// Fit vanilla's quarter-yard points without cutting corners. A small bounded search permits -/// neighboring points to move together; every accepted segment still passes the normal Gates. #[cfg_attr( not(has_packages), allow(dead_code, reason = "retained Package API operation") @@ -695,7 +634,6 @@ fn fit_wire_path( { continue; } - // Routes are ordered by total displacement, so the first valid predecessor is best. if let Some((route, cost)) = routes.iter().find(|(route, _)| { gate( *route.last().expect("a fitted route includes its start"), @@ -741,7 +679,6 @@ fn segment_clear_with_fetch( && (!enabled || nav::walkable_prefix(fetch, start, end) == end) } -/// Recheck a retained segment against current collision and walk coverage without another search. pub(crate) fn route_segment_clear( ctx: &ReducerContext, map_id: u32, @@ -760,9 +697,7 @@ pub(crate) fn route_segment_clear( ) } -/// Plan one bot movement step. A failed search holds position; sparse missing rows retain -/// unknown coverage. Collision can shorten complete, partial, and direct steps independently. -#[allow(clippy::too_many_arguments)] // A movement step carries its partition, endpoints and distances. +#[allow(clippy::too_many_arguments)] #[cfg_attr( not(has_packages), allow(dead_code, reason = "retained Package API operation") @@ -808,7 +743,6 @@ pub(crate) fn route_step( cell }; let attempted = if enabled { - // The search exempts its starting sub-cell and may finish without fetching it. if let (Some(cx), Some(cy)) = (cell_index(cur.0), cell_index(cur.1)) { fetch(cx, cy); } @@ -860,16 +794,7 @@ pub(crate) fn route_step( result } -/// Steps toward a walkable path's first waypoint, then gates every result against geometry. -/// A missing path may aim straight at the goal, but the commit gate stops it at obstructions. -/// -/// PERF NOTE: the gate fires on every committed movement step for chase/return/wander/flee/ -/// pet-follow — the ~500ms movement tick, 8x more often than the ~4s sense tick the benchmark -/// (`debug_bench_los`) measured `vmap::los_ray` against. `debug_bench_collision_gate` -/// (`debug/instance.rs`) is the companion harness for THIS path — run it against the same box -/// before flipping `vmap_enabled` on a populated map, and compare its wall time to -/// `debug_bench_los(exact=true)`'s on that box. -#[allow(clippy::too_many_arguments)] // A movement step carries its partition, endpoints and distances. +#[allow(clippy::too_many_arguments)] pub(crate) fn nav_step( ctx: &ReducerContext, map_id: u32, @@ -894,20 +819,14 @@ pub(crate) fn nav_step( let wp = path[0]; crate::creatures::chase_step(cur.0, cur.1, wp.0, wp.1, max_step, 0.0) } - // Keep aiming at an unreachable goal; the commit gate truncates the move at geometry. None => crate::creatures::chase_step(cur.0, cur.1, dest.0, dest.1, max_step, stop_dist), } }; step_gate(ctx, map_id, instance_id, cur, stepped, z) } -/// The margin `nav_step`'s step gate stops short of a ray hit by — same value -/// `blink_forward`'s clamp uses (`BLINK_CLEARANCE_YD`), so a walked stop and a Blink stop land at -/// the same distance from a wall. const GATE_CLEARANCE_YD: f32 = 1.0; -/// The single commit-point gate for every `nav_step` result. -/// Exact vmap collision takes precedence; the obstruction grid is the fallback when nav is enabled. fn step_gate( ctx: &ReducerContext, map_id: u32, @@ -939,7 +858,7 @@ fn step_gate_with_fetch( gate_step(cur, stepped, |from, to| { let exact = crate::vmap::collision_ray(ctx, map_id, instance_id, from, to); let grid = if !crate::vmap::vmap_enabled(ctx, map_id) && nav_enabled(ctx) { - // The grid query adds eye height itself, so it starts at foot height. + // The grid query adds eye height itself; pass foot height here. lyracore_shared::nav::step_hit(fetch, cur, stepped) .map(|p| [p.0, p.1, height_on_segment(from.into(), to.into(), p)]) } else { @@ -949,7 +868,6 @@ fn step_gate_with_fetch( }) } -/// Retain the checked segment's slope when a horizontal Gate shortens movement. fn height_on_segment(from: (f32, f32, f32), to: (f32, f32, f32), at: (f32, f32)) -> f32 { let length = (to.0 - from.0).hypot(to.1 - from.1); let fraction = ((at.0 - from.0).hypot(at.1 - from.1) / length.max(0.001)).min(1.0); @@ -964,7 +882,6 @@ fn gate_step( if stepped == cur { return ((stepped.0, stepped.1), None); } - // Probe above walkable steps. A ray at foot height stops on every stair riser. let probe_height = lyracore_shared::nav::WALK_STEP_UP; let hit = collision( [cur.0, cur.1, cur.2 + probe_height], @@ -976,7 +893,7 @@ fn gate_step( let hit_dist = (dx * dx + dy * dy).sqrt(); let land_dist = hit_dist - GATE_CLEARANCE_YD; let endpoint = if land_dist <= 0.0 { - (cur.0, cur.1) // hit inside the clearance margin — hold in place + (cur.0, cur.1) } else { ( cur.0 + dx / hit_dist * land_dist, @@ -995,11 +912,6 @@ fn gate_step( } } -/// Probe-only: the raw `find_leg_ex` outcome as a printable summary (waypoints + expansions), -/// tagged with the coverage generation that planned it. That tag is what makes a before/after -/// pair readable: the same leg run with the gate off and on shows how many expansions routing -/// around real geometry actually costs against `LEG_MAX_EXPANSIONS`, and `complete=false` means -/// the budget ran out and the leg is best-effort. #[cfg(feature = "debug_reducers")] pub(crate) fn debug_find_leg( ctx: &ReducerContext, @@ -1296,7 +1208,6 @@ mod tests { }, ]; for (from, to, expected_x) in [ - // The body ray reaches the surface at x=1204; retain one yard of clearance. ((1200.0, 1200.0, 49.0), (1205.0, 1200.0, 51.0), 1203.0), ((1201.0, 1200.0, 49.0), (1201.0, 1200.0, 51.0), 1201.0), ] { @@ -1315,8 +1226,6 @@ mod tests { } } - /// The whole point of the read seam: an imported cell that knows nothing about a wall picks up - /// the coverage's blockers, and coverage alone still routes where no nav row was imported. #[test] fn either_side_can_block_and_a_lone_side_passes_through_unchanged() { let mut terrain = open(80.0); @@ -1333,7 +1242,6 @@ mod tests { let coverage_only = merged_cell(None, Some(coverage)).unwrap(); assert!(walk_get(&coverage_only.walk, 5, 5) && !walk_get(&coverage_only.walk, 9, 9)); - // Neither source covering the cell keeps the missing-chunk contract: no obstacles known. assert!(merged_cell(None, None).is_none()); } } diff --git a/module/src/operations.rs b/module/src/operations.rs index 6254169db..21504047f 100644 --- a/module/src/operations.rs +++ b/module/src/operations.rs @@ -1,5 +1,3 @@ -//! Operator reducers that every build carries, whatever its Cargo features. - use spacetimedb::{log, reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::spell::stacking::game_spell_group; @@ -12,22 +10,11 @@ use crate::{ CreatureMoveSchedule, DuelSchedule, GroundAreaSchedule, PetCareSchedule, }; -/// The post-publish repair pass. SpacetimeDB runs `init` only on a database's first publish, so -/// every seed, fixture and schedule row `seed::init` inserts stays absent on an auto-migrated -/// database until this pass restores it. Every step is idempotent, so it is safe to run after every -/// publish, on every shard. -/// -/// Gated by `require_operator`. On a database's first publish the operator is not yet claimed, so -/// the call fails with "operator not claimed"; `init` has already seeded everything there. -/// -/// Stamps one `game_import_meta` row (family `"debug_repair_after_publish"`) with the total row -/// count. The name keeps its `debug_` prefix because the CLI, scripts and runbooks call it by name. #[reducer] -#[allow(clippy::too_many_lines)] // One idempotent repair pass per row family. +#[allow(clippy::too_many_lines)] pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; - // --- seeders (each idempotent: inserts only rows that are absent) --- crate::talent::seed_talents(ctx); let talents = ctx.db.game_talent().count(); @@ -45,19 +32,15 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { crate::items::seed_compatibility_enchantments(ctx); let spell_groups = ctx.db.game_spell_group().count(); - // The live stacking-family probe needs its four family members present on an already-migrated - // database, where `init` never re-runs. crate::seed::seed_stacking_probe_fixture(ctx); crate::seed::seed_pw_shield_fixture(ctx); - // Weakened Soul 6788, Test PW:Shield 50072 let pw_shield = [6788u32, 50072u32] .iter() .filter(|id| ctx.db.game_spell().spell_id().find(**id).is_some()) .count() as u64; crate::seed::seed_fixture_catalogue(ctx); - // Tempered Blade 5090050 + Tough Jerky 5090052 + fixture faction 50900 let fixture_catalogue = [crate::seed::FIXTURE_BLADE, crate::seed::FIXTURE_JERKY] .iter() .filter(|e| ctx.db.game_item_template().entry().find(**e).is_some()) @@ -93,7 +76,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { crate::seed::seed_frost_armor_fixture(ctx); crate::seed::seed_test_proc_fixtures(ctx); - // Chilled 6136, Frost Armor 168, and the Proc engine's six test fixtures let frost_armor = [ 6136u32, 168u32, @@ -112,13 +94,8 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { let demon_skin = ctx.db.game_spell().spell_id().find(696u32).is_some() as u64; crate::seed::seed_regen_fixture(ctx); - // Test Regeneration 50137 (the combat-regen probe's kind-169 A_COMBAT_HEALTH_REGEN_PCT source) let regen = ctx.db.game_spell().spell_id().find(50137u32).is_some() as u64; - // The land-mount fixture: Riding skill line 762, Test Riding Horse 50310 + its two effects, the - // skill-ability row that makes it trainable, and Test Dazed 50311. `seed_fixture_catalogue` above - // restores the reins ITEM, so without this the item would survive a republish pointing at a spell - // that no longer exists. crate::seed::seed_mount_fixture(ctx); let mount_fixture = [ crate::seed::FIXTURE_MOUNT_SPELL, @@ -128,9 +105,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { .filter(|id| ctx.db.game_spell().spell_id().find(**id).is_some()) .count() as u64; - // The temporary Elwynn Forest / Westfall weather climate. Only-if-empty, so an operator edit and - // the eventual world-data import both survive this pass. A zone with no climate row never rolls, - // and its sky stays fine. let weather_climate = crate::weather::seed_weather_weights(ctx); let aura_schedule = if ctx.db.game_aura_schedule().iter().next().is_some() { @@ -177,10 +151,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { 1 }; - // Ensure — never REARM — the 20 Hz movement-republish schedule. Ensure, because - // `scheduled_at` IS the cadence knob (`set_motion_tick_ms`), and a publish must not silently - // undo an operator's retune. Absent, movement stages into the private table and never relays, - // so this is the row whose absence is loudest. + // Ensure rather than rearm because scheduled_at preserves the Operator's chosen motion cadence. let motion_schedule = if ctx .db .game_motion_publish_schedule() @@ -201,8 +172,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { 1 }; - // Hunter care is an authoritative module timer. Ensure it after an auto-migrate publish, where - // `init` does not run, without disturbing an already-armed row. let pet_care_schedule = if ctx.db.game_pet_care_schedule().iter().next().is_some() { 0 } else { @@ -215,7 +184,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { 1 }; - // Restore the global tick without changing dedicated Instance tick rows. let move_sched = ctx.db.game_creature_move_schedule(); let stale_move: Vec = move_sched .iter() @@ -243,9 +211,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { )), }); - // Re-arm the gateway lease reaper (bounded ghost lifetime for sessions riding - // the shared connection). Rearm-not-ensure is safe here — unlike the motion tick there is no - // operator tuning knob to preserve; the canonical interval is the only interval. let lease_sched = ctx.db.game_gateway_lease_reaper_schedule(); let stale_lease: Vec = lease_sched.iter().map(|r| r.scheduled_id).collect(); for id in stale_lease { @@ -256,27 +221,14 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { scheduled_at: ScheduleAt::Interval(TimeDuration::from_micros(crate::gw::LEASE_REAP_MICROS)), }); - // Re-arm the 10-minute weather roll, leaving exactly one schedule row. Rearm-not-ensure for the - // lease reaper's reason: there is no operator cadence knob here, so the canonical interval is the - // only interval. Absent, weather silently stops advancing. crate::weather::rearm_weather_schedule(ctx); - // Re-arm the 5 s Meeting Stone reminder tick, for the weather roll's reason. Absent, a queued - // Party never hears IN_PROGRESS. crate::meeting_stone::rearm_reminder_schedule(ctx); - // Re-tag legacy auction mail (plain English subject, Character sender) to the vanilla - // AuctionHouse sender and machine subject — see `auction::repair_legacy_auction_mail`, which - // runs at most once per database on its own marker. MUST stay ordered before any Mail Timer - // expiry backfill added to this reducer: a legacy "Auction won" still reads as returnable - // Character mail until this re-tag runs, and a backfill that reaches it first would send an - // already-paid seller their own item back. let (legacy_auction_mail, _unmapped_auction_mail) = crate::auction::repair_legacy_auction_mail(ctx); - // One-shot Auction expiries are created with their Auctions, not seeded globally. A publish - // must preserve them; if an older/broken publication left one absent, restore only that missing - // row at the Auction's original deadline. Never re-arm an existing row or move its deadline. + // Preserve one-shot Auction expiries; restore only missing schedule rows. let mut auction_expiries = 0u64; for auction in ctx.db.game_auction().iter() { if ctx @@ -295,20 +247,8 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { } } - // Mail Timers are created with their Mail. Arm one for each Mail that has none: Mail written - // before the timer existed, or Mail a Transfer imported. Mail older than its life expires after - // this pass commits, 100 a second. A Mail that has a timer keeps it, so a second pass arms - // nothing. let mail_timers = crate::mail_timer::arm_missing(ctx); - // PROC-PROFILE BACKFILL. The proc columns are END-appended and default to 0, and 0 in `proc_flags` - // reads as "never procs" — so an aura row that was already on a unit when the proc columns landed - // is a Proc that silently stopped firing. A permanent self-buff (Frost Armor) never refreshes on - // its own, so nothing would ever re-freeze it. Re-freeze each such row from its spell header and - // the `spell_proc_event` overlay, the same profile `aura_apply` freezes — which also picks up an - // overlay that landed after the aura did. Charges are refilled to full and the cooldown starts ready, - // which is what a fresh apply would have given it. Idempotent: a row already carrying its profile - // is left alone, so a second repair pass touches nothing. let auras = ctx.db.game_aura(); let stale_procs: Vec = auras .iter() @@ -317,11 +257,11 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { let mut proc_profiles = 0u64; for mut a in stale_procs { let Some(hdr) = ctx.db.game_spell().spell_id().find(a.spell_id) else { - continue; // no header to read a profile off — leave the row as it is + continue; }; let profile = crate::spell::proc::freeze_profile(ctx, a.eff_kind, &hdr); if profile.flags == 0 { - continue; // the header carries no proc data either; nothing to backfill + continue; } a.proc_flags = profile.flags; a.proc_chance = profile.chance; @@ -337,10 +277,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { proc_profiles += 1; } - // row_count: total rows/rearms across every family this pass touched. One provenance - // stamp records the whole repair pass. The trailing `+ 3` is the three schedule rows this always (re)arms - // unconditionally (creature tick + instance reaper + weather roll), which have no "was it - // already present" signal worth stamping separately. let total = talents + createinfo + lesser_heal_target diff --git a/module/src/package.rs b/module/src/package.rs index 7aace3439..4b4f7189a 100644 --- a/module/src/package.rs +++ b/module/src/package.rs @@ -3,10 +3,6 @@ reason = "Packages select the named API operations they need" )] -//! The Package API root for Package-level operations: Operator authorization, Package-owned -//! Characters, Package Config and encounter choreography. `fixture` exists only with -//! `debug_reducers` and `test` only in a test build. - #[cfg(feature = "debug_reducers")] pub(crate) use crate::package_fixture as fixture; #[cfg(test)] @@ -16,14 +12,12 @@ pub(crate) use crate::helpers::require_operator; pub(crate) use crate::package_account::create_package_character as create_character; pub(crate) use crate::package_config::ensure_package_config_default as ensure_config_default; -/// The core steps a Package runs to build, level and remove its own Characters. pub(crate) mod character { pub(crate) use crate::creatures::build_player_entity; pub(crate) use crate::stats::set_character_level; pub(crate) use crate::world::cascade_delete_character; } -/// The encounter kernel a Package drives through `encounter_package!`. pub(crate) mod encounter { pub(crate) use crate::encounter::{ encounter_reset, encounter_reset_full, equip_swap, get_encounter_data, get_encounter_state, diff --git a/module/src/package_account.rs b/module/src/package_account.rs index 9116899cd..7beaa06b8 100644 --- a/module/src/package_account.rs +++ b/module/src/package_account.rs @@ -1,12 +1,7 @@ -//! Package-owned Accounts: the Accounts a Package creates for its own session-less Characters, and -//! the record of which Package made each one. - use spacetimedb::{table, ReducerContext, Table}; use crate::auth::{game_account, Account, Appearance}; -/// The Package that created an Account. A Core table, so it outlives the Package's own tables when -/// the Package is disabled. Private. [session] #[table(accessor = game_package_account)] pub struct PackageAccount { #[primary_key] @@ -15,11 +10,7 @@ pub struct PackageAccount { pub package_name: String, } -/// Create a Character with no Session on an Account `package_name` owns, and return its guid. The -/// Character meets the same name, race and class Refusals as a client-created one, and those -/// Refusals write nothing. The Character goes on the first owned Account with room. When every one -/// is full, a new Account without credentials is created and recorded as owned by `package_name`. -#[cfg_attr(not(has_packages), allow(dead_code))] // package-only caller — see `build.rs` +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn create_package_character( ctx: &ReducerContext, package_name: &str, @@ -53,8 +44,7 @@ fn owned_account_with_room(ctx: &ReducerContext, package_name: &str) -> Result Option Vec { let mut keys: Vec = ctx .db @@ -62,10 +36,6 @@ fn known_keys(ctx: &ReducerContext, package_name: &str) -> Vec { keys } -/// How [`set_package_config`] should react to a write, given only whether a row already exists for -/// `(package_name, key)` and whether the caller passed `allow_new` (module — pure, ctx-free, so this -/// is unit-tested without a `ReducerContext`). An existing row always updates, regardless of -/// `allow_new`; an absent one inserts only when the caller opted in, and is refused otherwise. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ConfigWrite { Update, @@ -81,8 +51,6 @@ pub(crate) fn decide_config_write(row_exists: bool, allow_new: bool) -> ConfigWr } } -/// The refusal text for an unknown `(package_name, key)` pair (module — pure, ctx-free): names the -/// package's existing keys so a typo reads as a loud error instead of a value nobody ever reads. pub(crate) fn unknown_key_message(package_name: &str, key: &str, known_keys: &[String]) -> String { if known_keys.is_empty() { format!("package '{package_name}' has no config keys yet; pass allow_new to set '{key}'") @@ -94,10 +62,6 @@ pub(crate) fn unknown_key_message(package_name: &str, key: &str, known_keys: &[S } } -/// Operator-gated write to one `(package_name, key)` row: updates an existing row, or inserts -/// a new one only when `allow_new` is set. Refuses an absent key with its package's existing keys -/// named (sorted) — see [`decide_config_write`] and [`unknown_key_message`] for the pure decision -/// and message this reducer carries out. #[reducer] pub fn set_package_config( ctx: &ReducerContext, @@ -132,18 +96,11 @@ pub fn set_package_config( } } -/// Whether [`ensure_package_config_default`] should insert its default (module — pure, ctx-free): -/// only when no row exists yet, so a Package can call the seeding entry point from its ensure/init -/// path on every startup and never clobber an Operator's edit sitting in the row already. pub(crate) fn should_seed_default(row_exists: bool) -> bool { !row_exists } -/// A Package's idempotent default-seeding entry point: inserts `(package, key) = value` only -/// when no row exists yet — see [`should_seed_default`] for the decision this carries out. `pub(crate)` -/// — a Package's own defaults are seeded from inside the module build, not through a reducer an -/// external caller could hit. -#[cfg_attr(not(has_packages), allow(dead_code))] // package-only caller — see `build.rs` +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn ensure_package_config_default( ctx: &ReducerContext, package: &str, diff --git a/module/src/package_fixture.rs b/module/src/package_fixture.rs index 7b7f1eb22..170728065 100644 --- a/module/src/package_fixture.rs +++ b/module/src/package_fixture.rs @@ -1,15 +1,10 @@ #![cfg_attr(not(has_packages), allow(dead_code, unused_imports))] -//! Setup and observation steps for a Package's own debug fixtures. The root exists only with -//! `debug_reducers`, so a release build has none of it, and the Package API lint accepts it only in -//! a Package file gated on that feature. - use spacetimedb::{ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::creatures::{game_creature_move_schedule, GLOBAL_TICK_INSTANCE}; use crate::import_meta::game_import_meta; -/// The highest-threat living source on the creature's map and instance. Ties go to the lower guid. pub(crate) use crate::threat::top_threat_target; pub(crate) use crate::account_ownership::SessionActor; @@ -30,9 +25,6 @@ pub(crate) use crate::spell::{ }; pub(crate) use crate::xp::grant_xp; -/// Damage a live, living target through the real Core damage pipeline as a main-hand hit from -/// `attacker_guid` (0 means anonymous). Damage is capped one below the target's health, so the -/// call never kills. pub fn apply_damage( ctx: &ReducerContext, target_guid: u64, @@ -42,8 +34,6 @@ pub fn apply_damage( crate::debug::debug_apply_damage(ctx, target_guid, amount, attacker_guid) } -/// Remove a live Character from the world as a logout does: `on_logout` fires, the Character keeps -/// its position and progress, and its combat, duel, trade and corpse end. The Character row stays. pub fn remove_live_character(ctx: &ReducerContext, character_guid: u64) -> Result<(), String> { let entity = crate::helpers::live_entity(ctx, character_guid)?; if !entity.is_player() { @@ -53,8 +43,6 @@ pub fn remove_live_character(ctx: &ReducerContext, character_guid: u64) -> Resul Ok(()) } -/// Refuse when the Shard holds imported content. The temporary weather seed a fresh Module stamps -/// is Core's own data, so it does not count. pub fn require_no_imported_content(ctx: &ReducerContext) -> Result<(), String> { match ctx .db @@ -70,7 +58,6 @@ pub fn require_no_imported_content(ctx: &ReducerContext) -> Result<(), String> { } } -/// Cast through the Gates a client cast passes, as the Session Actor `caster_guid`. pub fn client_cast( ctx: &ReducerContext, caster_guid: u64, @@ -88,9 +75,6 @@ pub fn client_cast( ) } -/// Stage instance `instance_id` of `map_id` for `party_id` as a dungeon entry leaves it, and bind -/// `character_guid` to it. `request_actor` passes the instance placement Gates. Refuses when entry -/// resolves to another instance. pub fn admit_to_instance( ctx: &ReducerContext, character_guid: u64, @@ -109,8 +93,6 @@ pub fn admit_to_instance( Ok(()) } -/// Record a finished Transfer of `character_guid` to `map_id` and `instance_id` in Realm-core's -/// character-to-shard index. The index revision advances only when the map or instance changes. pub fn record_completed_transfer( ctx: &ReducerContext, character_guid: u64, @@ -120,9 +102,6 @@ pub fn record_completed_transfer( crate::realm_core::record_shard(ctx, character_guid, map_id, instance_id); } -/// Declare that the next creature movement tick fires once, `delay` from now. Refuses unless the -/// catch-all row is the only movement schedule, which holds on a fresh Shard. The row then fires -/// once, not on an interval. pub fn declare_next_movement_tick(ctx: &ReducerContext, delay: TimeDuration) -> Result<(), String> { let schedules = ctx.db.game_creature_move_schedule(); let mut rows = schedules.iter().take(2); diff --git a/module/src/package_import.rs b/module/src/package_import.rs index f0424fc48..a9a4863ad 100644 --- a/module/src/package_import.rs +++ b/module/src/package_import.rs @@ -1,48 +1,3 @@ -//! Applying a Package's artifacts to one Import Family's tables, and the provenance that records -//! what was applied. -//! -//! A base import replaces a whole Import Family, so a Package's row edits cannot be a one-shot edit -//! — the next reload would silently revert them. They are an artifact that replays as the last -//! stage of the family's import. This module is that stage's durable half: one operator-gated -//! reducer that takes the WHOLE enabled plan for one family and applies it in one transaction. -//! -//! Whole-plan, not per-Package, on purpose. Two Packages may claim different columns of one row, so -//! the merged picture is the only correct unit of work — and a plan that fails halfway would leave -//! the shard running a set of Packages nobody chose. The tracer for the family's artifact kind -//! produces the merged picture and every disagreement; this module refuses on a disagreement before -//! it writes. -//! -//! # Family dispatch -//! -//! Two artifact SHAPES meet here, and [`Family`] is where they are told apart. -//! -//! A CLAIM family's Packages state columns of rows a base import owns, so its apply traces and -//! merges them, asks the shard what a row currently holds, and refuses an update whose target will -//! not be there. That shell lives in this module and each such family owns only the setters for its -//! tables, where its Package-invented rows live, and how to find one row of it — [`ClaimFamily`] is -//! the only place the two meet. -//! -//! The SCRIPT family has no base import behind it and no shared rows: a Package ships whole -//! `game_script` rows it owns outright. Nothing to merge and nothing to look up, so -//! [`script`] owns its whole apply rather than filling in a claim shell it would have to -//! answer questions it cannot be asked. What the two still share is the envelope: the operator gate, -//! the payload separator, and the provenance rewrite. -//! -//! No match here carries a wildcard, so a family that arrives without an implementation does not -//! compile. -//! -//! # Reconciliation -//! -//! A base reimport clears only real identifiers, so rows a Package INVENTED (its family's Package -//! identifier band) survive it untouched. Nothing else would ever remove them, which is why this -//! reducer clears the whole band before it applies: a Package that left the enabled set takes its -//! invented rows with it, with no bookkeeping to disagree with the shard. For the script family the -//! band is the whole table, which makes its apply a total reconciliation. -//! -//! Rows a Package only TUNED are not restored here. Disabling a Package means replaying the base -//! import and then applying the remaining Packages — there is no pre-image to roll back to, which -//! is also why a Claim can never delete a row. - mod casts; mod creature_ai; mod creatures; @@ -71,70 +26,32 @@ use lyracore_package_delta::{ use crate::helpers::require_operator; use crate::import_meta::game_import_meta; -/// Separates the artifacts inside one `apply_package_deltas` payload. Every artifact kind's -/// canonical form escapes every control character, so no artifact can contain this byte and no -/// artifact needs quoting to travel next to another. +// Canonical artifacts escape control characters, so this byte cannot occur inside one. const ARTIFACT_SEPARATOR: char = '\n'; -/// An Import Family this build can apply a Package's artifacts for. -/// -/// Split by ARTIFACT SHAPE rather than listed flat, because the two shapes have almost nothing in -/// common below the envelope. A claim family's Packages state columns of rows a base import owns, -/// so its apply traces and merges them and has to ask the shard what a row currently holds. The -/// script family has no base import and no shared rows: a Package ships whole rows it owns -/// outright, and there is nothing to merge or to look up. -/// -/// Keeping them one flat enum would mean the script family carrying arms for `update_target` and -/// `write_row`, which take a traced CLAIM — questions it can never be asked. This way each shape's -/// matches stay exhaustive over exactly the families that answer them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Family { - /// A family whose Packages claim columns of rows a base import owns. Claims(ClaimFamily), - /// Runtime Scripts. Whole rows, no base import, no merge. Script, } -/// An Import Family whose artifact is a Package Delta. -/// -/// One variant per family the Package Delta schema names tables for, so a family with no claim -/// shape to read cannot be asked for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ClaimFamily { - /// `game_spell` and `game_spell_effect`. Spell, - /// `game_item_template`. Item, - /// The six quest tables (`game_quest_template` and its children). Quest, - /// The four non-creature loot tables (pickpocket, gameobject/chest, skinning, fishing). Loot, - /// `game_creature_cast` and `game_creature_spell`. Cast, - /// `game_trainer_spell`. Trainer, - /// The six gossip tables (menus, menu profiles, options and the greeting text). Gossip, - /// The seven world-wide reference tables (stat curves, start position, graveyard zones, - /// areatrigger destinations, and the two createinfo tables). Globals, - /// `game_spell_chain`, `game_spell_learn` and `game_spell_proc_event`. Spellmeta, - /// `game_creature_template` and `game_creature_spawn`. The first family with a SPATIAL table: - /// the importer routes a spawn claim by the map in its key, so a Shard only ever sees the - /// spawns its World Import Scope owns. Creatures, - /// `game_gameobject_template`, `game_gameobject_trap` and `game_gameobject`. Spatial in the - /// same way as [`ClaimFamily::Creatures`]. Gameobjects, - /// The EventAI catalogue: `game_creature_ai_broadcast_text`, `game_creature_ai_summon` and - /// `game_quest_event_requirement`. Global, and deliberately no scripted definitions — see - /// [`creature_ai`]. CreatureAi, } impl Family { - /// Every family this build applies, in the order a refusal lists them. const ALL: &'static [Self] = &[ Self::Claims(ClaimFamily::Spell), Self::Claims(ClaimFamily::Item), @@ -151,7 +68,6 @@ impl Family { Self::Script, ]; - /// The family name the importer stamps and the reducer takes. const fn as_str(self) -> &'static str { match self { Self::Claims(family) => family.as_str(), @@ -159,11 +75,6 @@ impl Family { } } - /// Resolves the reducer's `family` argument. - /// - /// # Errors - /// Names the family that was asked for and the ones this build carries, because the caller is - /// an operator running an import stage for a family whose schema has not landed yet. fn parse(name: &str) -> Result { Self::ALL .iter() @@ -180,7 +91,6 @@ impl Family { } impl ClaimFamily { - /// The family name the importer stamps and the reducer takes. const fn as_str(self) -> &'static str { match self { Self::Spell => SPELL_FAMILY, @@ -198,7 +108,6 @@ impl ClaimFamily { } } - /// What this shard holds for the row an `update` claim names. fn update_target(self, ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { match self { Self::Spell => spell::update_target(ctx, row), @@ -216,8 +125,6 @@ impl ClaimFamily { } } - /// Removes every row this family's Packages invented, so a Package that left the enabled set - /// takes its rows with it. fn clear_package_range(self, ctx: &ReducerContext) { match self { Self::Spell => spell::clear_package_range(ctx), @@ -235,7 +142,6 @@ impl ClaimFamily { } } - /// Writes one merged row into this family's tables. fn write_row(self, ctx: &ReducerContext, row: &TracedRow) -> Result<(), String> { match self { Self::Spell => spell::write_row(ctx, row), @@ -253,7 +159,6 @@ impl ClaimFamily { } } - /// Refuses references that would point at no row after this family lands. fn check_references(self, ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { match self { Self::Spell | Self::Item => Ok(()), @@ -271,9 +176,6 @@ impl ClaimFamily { } } -/// The whole family catalogue as a prose list — "`a`", "`a` or `b`", "`a`, `b` or `c`" — so a -/// refusal reads naturally whatever `Family::ALL`'s length is. Mirrors -/// `lyracore_package_delta::error`'s `known_tables`. fn known_families() -> String { let quoted: Vec = Family::ALL .iter() @@ -286,75 +188,30 @@ fn known_families() -> String { } } -/// What a shard holds for the row an `update` claim names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UpdateTarget { - /// The row is there, so the update lands on it. Present, - /// The row is not in this shard at all. Absent, - /// The row sits in this family's Package identifier band and no enabled Package inserts it. - /// The band is cleared on every apply, so the row is gone by the time the write pass runs. Uninvented, } -/// What one Package contributed to the last apply of one Import Family. -/// -/// One row per (family, Package), keyed by both joined — SpacetimeDB keys on one column, and a -/// Package appears once per family it claims in. Rewritten wholesale on every apply, like -/// `game_import_meta`: it answers "what Packages is this shard running right now", not "what did it -/// ever run". -/// -/// NOT public, for the same reason `game_import_meta` is not: nothing subscribes it, so it needs no -/// gateway binding. The importer writes it through the reducer below and an operator reads it with -/// `spacetime sql`. #[table(accessor = game_package_import)] pub struct PackageImport { - /// `/`. #[primary_key] pub id: String, - /// The Import Family this Package claimed in — the same name `game_import_meta.family` uses. pub family: String, - /// The Package identity the artifact carried. pub package: String, - /// BLAKE3 digest of the artifact's CANONICAL bytes, so two artifacts that say the same thing - /// hash the same however they were written. Distinct from `source_hash`. pub artifact_hash: String, - /// The digest of the Datascript source the artifact was generated from, carried verbatim from - /// the artifact. Distinct from `artifact_hash`: this one identifies the SOURCE, that one - /// identifies the generated artifact. pub source_hash: String, - /// Rows this Package changed but does not own. pub updated_rows: u64, - /// `game_spell` rows this Package invented. pub inserted_spells: u64, - /// `game_spell_effect` rows this Package invented. pub inserted_effects: u64, - /// The base import generation these claims sit on: `game_import_meta.source_sha` for this - /// family, read at apply. Empty when the family has never been stamped on this shard (a seeded - /// development shard), which is a fact worth recording rather than a reason to refuse. pub base_source_sha: String, pub applied_at: Timestamp, - /// Rows this Package invented, across every table its family owns — the family-generic - /// successor to `inserted_spells`/`inserted_effects` above. Those two keep their names (a - /// durable column is not renamed for a later family); a non-spell family reports through this - /// column alone and leaves the legacy pair at zero, and the spell writer fills this one too, as - /// the sum of its own two. END-appended + `#[default(0u64)]` → additive auto-migration. #[default(0u64)] pub inserted_rows: u64, } -/// Apply the complete set of enabled Package Deltas for one Import Family. -/// -/// One reducer call is one transaction, so the whole plan lands or none of it does. An unknown -/// family, an invalid artifact, a Claim Conflict, or an update whose target row is absent fails the -/// call before any row is touched, leaving neither claimed rows nor provenance behind. -/// -/// `packed`: the artifacts' canonical JSON, one per line. The caller sends the WHOLE enabled set, -/// including none at all — an empty payload is the honest statement "no Package claims this family -/// any more" and clears the family's Package identifier band accordingly. The importer never sends -/// an empty payload by accident; it refuses to run this stage at all unless the operator named the -/// enabled Package root. #[reducer] pub fn apply_package_deltas( ctx: &ReducerContext, @@ -373,7 +230,6 @@ pub fn apply_package_deltas( Ok(()) } -/// Apply one claim family's whole plan: trace it, refuse it, then clear and rewrite. fn apply_claims( ctx: &ReducerContext, family: ClaimFamily, @@ -389,20 +245,12 @@ fn apply_claims( family.write_row(ctx, row)?; } if family == ClaimFamily::Gameobjects { - // Operator apply and replay finish every spawn and template edit before reconciling. + // Reconcile only after every spawn and template edit has finished. crate::go_collider::rebuild_go_colliders(ctx)?; } Ok(plan.packages) } -// =========================================================================================== -// The plan — pure. Everything that can refuse the payload refuses here, before any write. -// =========================================================================================== - -/// One Package's contribution to a plan, in the shape its provenance row records. -/// -/// Family-neutral on purpose: it is what [`stamp_provenance`] writes, and every family produces one -/// per Package however its artifact was shaped. #[derive(Debug, Clone, PartialEq, Eq)] struct PlannedPackage { package: String, @@ -411,11 +259,6 @@ struct PlannedPackage { counts: RowCounts, } -/// The row counts one Package's provenance row records. -/// -/// The family-generic `updated_rows`/`inserted_rows` pair is what every family fills; the spell -/// breakdown below them is filled by the family that has one. A family with no breakdown leaves -/// them zero rather than growing a column nobody reads. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct RowCounts { updated_rows: u64, @@ -425,8 +268,6 @@ struct RowCounts { } impl From for RowCounts { - /// A claim family already tallies both halves, so this only widens the shape: the same numbers, - /// in the family-neutral type the provenance row is written from. fn from(counts: ClaimCounts) -> Self { Self { updated_rows: counts.updated_rows, @@ -437,7 +278,6 @@ impl From for RowCounts { } } -/// The merged picture of every enabled Package's claims, and who contributed what. #[derive(Debug, Clone, PartialEq)] struct ApplyPlan { packages: Vec, @@ -445,14 +285,6 @@ struct ApplyPlan { } impl ApplyPlan { - /// Reads a payload into a plan, refusing anything that must not reach the tables. - /// - /// The refusals, in order: an artifact that does not parse, two artifacts naming the same - /// Package, and any Claim Conflict between Packages. A conflict reports EVERY disagreement, not - /// just the first — the operator fixing them wants the whole list in one pass. - /// - /// The payload says nothing about which family it is for; that is the caller's argument, and - /// [`check_claims_belong_to`] holds the two together. fn read(packed: &str) -> Result { let mut deltas: Vec = Vec::new(); for (index, artifact) in packed @@ -504,11 +336,6 @@ impl ApplyPlan { } } -/// Refuses a plan that claims a table this call's family does not own. -/// -/// One apply reloads one family, so a claim on another family's table would be applied out of turn -/// and then reverted by that family's own import. Every table names its owner, so this is the whole -/// check. fn check_claims_belong_to(family: ClaimFamily, rows: &[TracedRow]) -> Result<(), String> { for row in rows { if row.table().family() != family.as_str() { @@ -524,12 +351,6 @@ fn check_claims_belong_to(family: ClaimFamily, rows: &[TracedRow]) -> Result<(), Ok(()) } -// =========================================================================================== -// Row building — pure, and shared by every family's setters below. -// =========================================================================================== - -/// An insert must carry every claimable column of its table. The artifact parser already refuses a -/// partial insert, so this catches only a schema that moved under a stored artifact. fn check_insert_is_whole(row: &TracedRow) -> Result<(), String> { let missing: Vec<&str> = row .table() @@ -613,13 +434,7 @@ fn wrong_type(field: &str, value: &FieldValue) -> String { ) } -// =========================================================================================== -// The durable shell — the only part that touches a ReducerContext. -// =========================================================================================== - -/// Refuses a plan whose updates name rows that will not be there. -/// -/// Runs before the first write, so a plan that names a missing row changes nothing at all. +// Resolve every missing-row Refusal before the first write. fn check_update_targets( ctx: &ReducerContext, family: ClaimFamily, @@ -651,8 +466,6 @@ fn check_update_targets( Ok(()) } -/// Rewrites this family's provenance wholesale, so the table always describes the Packages the -/// shard is running now rather than every Package it ever ran. fn stamp_provenance(ctx: &ReducerContext, family: Family, packages: &[PlannedPackage]) { let family = family.as_str(); let base_source_sha = ctx @@ -689,10 +502,6 @@ fn stamp_provenance(ctx: &ReducerContext, family: Family, packages: &[PlannedPac } } -// =========================================================================================== -// Tests — the shared half. Row WRITING needs a live ReducerContext, which a native test has no -// way to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::fixtures::{ @@ -707,8 +516,6 @@ mod tests { use super::{check_claims_belong_to, ApplyPlan, ClaimFamily, Family, ARTIFACT_SEPARATOR}; use lyracore_package_delta::PackageDelta; - /// The payload format rests on this: a canonical artifact escapes every control character, so - /// one line is one artifact and no quoting is needed to put two of them next to each other. #[test] fn a_canonical_artifact_never_contains_the_artifact_separator() { let awkward = spell_claim( @@ -759,7 +566,6 @@ mod tests { ); } - /// The digest is over the CANONICAL bytes, so how an artifact was spelled cannot change it. #[test] fn the_artifact_hash_ignores_how_the_artifact_was_written() { let claims = spell_claim( @@ -837,8 +643,6 @@ mod tests { assert!(refusal.contains("appears twice"), "{refusal}"); } - /// Two Packages tuning different columns of one spell are the reason the plan is traced as a - /// whole rather than applied Package by Package. #[test] fn two_packages_tuning_different_columns_merge_into_one_row() { let first = artifact( @@ -866,9 +670,6 @@ mod tests { #[test] fn an_import_family_with_no_artifact_schema_is_refused_by_name() { - // `terrain` is an importer mode with no Import Family behind it, and so no claim schema. - // Every `--dump` family this build loads now has one, so a real family no longer reaches - // this refusal. let refusal = Family::parse("terrain").expect_err("an unsupported family is refused"); assert!(refusal.contains("`terrain`"), "{refusal}"); @@ -928,9 +729,6 @@ mod tests { } } - /// The two shapes are separate enums so neither carries the other's questions. The reducer's - /// `family` argument is one namespace all the same, and two families answering to one name - /// would make an apply ambiguous. #[test] fn no_two_families_answer_to_one_name() { let mut names: Vec<&str> = Family::ALL.iter().map(|f| f.as_str()).collect(); @@ -940,7 +738,6 @@ mod tests { assert_eq!(names.len(), unique); } - /// Every claimable table belongs to the spell family today, so a spell plan is in scope whole. #[test] fn a_spell_plan_claims_only_spell_family_tables() { let claims = [ @@ -956,8 +753,6 @@ mod tests { ); } - /// An items plan is checked against the items family, not the spell family it happens to sit - /// beside in this build's catalogue. #[test] fn an_item_plan_claims_only_item_family_tables() { let plan = plan(&[artifact( @@ -973,8 +768,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Spell, &plan.rows).is_err()); } - /// A quest plan is checked against the quest family, not a sibling family it happens to sit - /// beside in this build's catalogue. #[test] fn a_quest_plan_claims_only_quest_family_tables() { let plan = plan(&[artifact( @@ -990,8 +783,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Item, &plan.rows).is_err()); } - /// A loot plan is checked against the loot family, not a sibling family it happens to sit - /// beside in this build's catalogue. #[test] fn a_loot_plan_claims_only_loot_family_tables() { let plan = plan(&[artifact( @@ -1007,8 +798,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Item, &plan.rows).is_err()); } - /// A cast plan is checked against the casts family, not a sibling family it happens to sit - /// beside in this build's catalogue. #[test] fn a_cast_plan_claims_only_cast_family_tables() { let plan = plan(&[artifact( @@ -1024,8 +813,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Loot, &plan.rows).is_err()); } - /// A trainer plan is checked against the trainers family, not a sibling family it happens to - /// sit beside in this build's catalogue. #[test] fn a_trainer_plan_claims_only_trainer_family_tables() { let plan = plan(&[artifact( @@ -1041,8 +828,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Cast, &plan.rows).is_err()); } - /// A gossip plan is checked against the gossip family, not a sibling family it happens to sit - /// beside in this build's catalogue. #[test] fn a_gossip_plan_claims_only_gossip_family_tables() { let plan = plan(&[artifact( @@ -1058,8 +843,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Globals, &plan.rows).is_err()); } - /// A globals plan is checked against the globals family, not a sibling family it happens to sit - /// beside in this build's catalogue. #[test] fn a_globals_plan_claims_only_globals_family_tables() { let plan = plan(&[artifact( @@ -1075,8 +858,6 @@ mod tests { assert!(check_claims_belong_to(ClaimFamily::Gossip, &plan.rows).is_err()); } - /// A spell metadata plan runs under `spellmeta`, NOT under `spell`, even though two of its - /// three tables key on a spell identifier. #[test] fn a_spell_metadata_plan_claims_only_spellmeta_family_tables() { let plan = plan(&[artifact( diff --git a/module/src/package_import/casts.rs b/module/src/package_import/casts.rs index 206e1effd..bf15f6013 100644 --- a/module/src/package_import/casts.rs +++ b/module/src/package_import/casts.rs @@ -1,20 +1,3 @@ -//! The casts Import Family's half of a Package Delta apply: the setters for -//! `game_creature_cast`/`game_creature_spell`, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of casts. -//! -//! `game_creature_cast` is update-only ([`lyracore_package_delta::DeltaError::InsertNotSupported`]): -//! its key names a creature template and the parser refuses the fixture cluster. It carries no -//! Package band, so [`update_target`] and `write_row` never treat it as `Uninvented`, and -//! [`clear_package_range`] never touches it. `game_creature_spell` follows the loot shape instead. -//! Its own surrogate `id` is the band, checked by `is_package_cast_id`, while this family's -//! reference Gate checks both the existing and final creature target. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -27,8 +10,6 @@ use crate::{game_creature_template, game_spell}; use super::{as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a cast claim whose final row would point at data this Shard does not hold or whose -/// existing or final target belongs to the fixture creature cluster. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { if row.table() == ClaimTable::CreatureSpell { @@ -43,8 +24,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// Refuses both a final fixture target and an existing fixture target a claim tries to hide by -/// omitting or replacing `creature_entry`. fn check_creature_spell_target(ctx: &ReducerContext, row: &TracedRow) -> Result<(), String> { let current = ctx .db @@ -73,9 +52,6 @@ fn check_creature_spell_target(ctx: &ReducerContext, row: &TracedRow) -> Result< Ok(()) } -/// The value `field` will hold once this row's claim lands: the claimed value if the claim sets -/// it, otherwise what the Shard already holds. An update that changes only one column is judged on -/// what the row will hold after the apply, not on the column alone. fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result { if let Some(FieldValue::U32(value)) = row.fields().get(field).map(|claimed| &claimed.value) { return Ok(*value); @@ -131,11 +107,7 @@ fn fixture_creature_reference(row: &TracedRow, entry: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { - // Only `game_creature_spell` has a Package band to reconcile against — `game_creature_cast` - // permits no insert at all, so no row of it is ever "Package-invented, but no enabled Package - // inserts it". if row.table() == ClaimTable::CreatureSpell && updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; } @@ -164,18 +136,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range `game_creature_spell` row that no -/// enabled Package invents. -/// -/// The Package cast range is cleared on every apply, so such a row is gone by the time the write -/// pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_cast_id(row.row_id()) } -/// Removes every `game_creature_spell` row a Package invented. `game_creature_cast` has no band — -/// nothing to clear there. Once per import, right after the base import rewrote -/// `game_creature_spell` anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let spells = ctx.db.game_creature_spell(); let stale: Vec = spells @@ -236,10 +200,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn apply_creature_cast_field( cast: &mut CreatureCast, field: &str, @@ -256,9 +216,6 @@ fn apply_creature_cast_field( Ok(()) } -/// The `creature_entry` a `game_creature_cast` row names. Total, not a `Result`: -/// `check_claims_belong_to` has already refused a foreign-family row before this module's -/// dispatch runs, so `row.key()` is always `PrimaryKey::CreatureCast` here. fn creature_entry_of(row: &TracedRow) -> u32 { let PrimaryKey::CreatureCast { creature_entry } = row.key() else { unreachable!( @@ -268,9 +225,6 @@ fn creature_entry_of(row: &TracedRow) -> u32 { creature_entry } -/// The `id` a `game_creature_spell` row names. Total, not a `Result`: -/// `check_claims_belong_to` has already refused a foreign-family row before this module's -/// dispatch runs. fn id_of(row: &TracedRow) -> u64 { match row.key() { PrimaryKey::CreatureSpell { id } => id, @@ -320,10 +274,6 @@ fn built_creature_spell(row: &TracedRow) -> Result { Ok(spell) } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -332,8 +282,6 @@ mod tests { }; use super::*; - /// The Package cast range is cleared on every apply, so tuning a Package `game_creature_spell` - /// row nobody enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_creature_spell_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -380,9 +328,6 @@ mod tests { assert_eq!(spell.priority, 10); } - /// `game_creature_cast` is update-only: the row a claim names always already exists, so there - /// is no `built_creature_cast` counterpart to `built_creature_spell` above. The setter is - /// still exercised directly, on a real creature's row. #[test] fn an_update_on_creature_cast_carries_the_claimed_spell_onto_the_row() { let plan = plan(&[artifact( @@ -406,8 +351,6 @@ mod tests { assert_eq!(cast.spell_id, 200); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_creature_cast_column_has_a_setter() { let mut cast = CreatureCast { diff --git a/module/src/package_import/creature_ai.rs b/module/src/package_import/creature_ai.rs index ca5fd650f..866f35d43 100644 --- a/module/src/package_import/creature_ai.rs +++ b/module/src/package_import/creature_ai.rs @@ -1,27 +1,3 @@ -//! The creature-ai Import Family's half of a Package Delta apply: the setters for the EventAI -//! catalogue, and how to find one row of it. -//! -//! The shared shell in the parent module decides everything a family does not. What is here is only -//! what is true of EventAI. -//! -//! Three tables, all the loot shape: `game_creature_ai_broadcast_text`, `game_creature_ai_summon` -//! and `game_quest_event_requirement` each key on a plain surrogate identifier with its own -//! Package band (`is_package_creature_ai_id`). None of them names a map, so every claim here is -//! global. -//! -//! The family's scripted definitions are not claimable, for the reasons -//! `lyracore_package_delta`'s `CREATURE_AI_FAMILY` gives. That is what keeps this module small: a -//! Package tunes what a creature SAYS, where its summons land, and which quests need their source -//! event, but never the rules themselves. -//! -//! # Encounter ownership -//! -//! An Encounter Binding is the map-scoped link from an imported EventAI action to the Package that -//! owns the encounter, and a loaded definition carrying a `NotifyEncounter` instruction is where -//! that link lives durably. A claim that retunes a line such a definition speaks, or a placement it -//! summons at, would change an encounter's fight without its owning Package's say. -//! [`check_references`] refuses that and names both sides. - use std::collections::BTreeMap; use spacetimedb::{ReducerContext, Table}; @@ -40,16 +16,11 @@ use crate::{ use super::{as_f32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a creature-ai claim that names data this Shard will not hold, or that reaches into an -/// encounter another Package owns. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { check_quests_exist(ctx, rows)?; check_encounter_ownership(ctx, rows) } -/// A quest event requirement names a quest, and `game_quest_template` belongs to another family. -/// The quest has to be on this Shard already, the same rule `trainers::check_references` applies -/// to the creature template and spell a trainer offering names. fn check_quests_exist(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { if row.table() != ClaimTable::QuestEventRequirement { @@ -74,8 +45,6 @@ fn check_quests_exist(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), St Ok(()) } -/// The value `quest_entry` will hold once this row's claim lands: the claimed value if the claim -/// sets it, otherwise what the Shard already holds. fn final_quest_entry(ctx: &ReducerContext, row: &TracedRow) -> Result { if let Some(FieldValue::U32(value)) = row .fields() @@ -99,8 +68,6 @@ fn final_quest_entry(ctx: &ReducerContext, row: &TracedRow) -> Result Result<(), String> { let claims_catalogue_row = rows.iter().any(|row| { matches!( @@ -137,24 +104,18 @@ fn check_encounter_ownership(ctx: &ReducerContext, rows: &[TracedRow]) -> Result Ok(()) } -/// One catalogue row a loaded definition names. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum OwnedRow { BroadcastText(u32), Summon(u32), } -/// Which encounter owns one catalogue row, and the creature whose definition names it. #[derive(Debug, Clone)] struct EncounterOwner { creature: String, binding: EncounterBinding, } -/// Every broadcast text and summon placement an encounter-owned definition depends on. -/// -/// A definition is encounter-owned when one of its rules notifies an Encounter Binding. Walked -/// once per apply, and only when the plan claims a catalogue row at all. fn encounter_owned_rows(ctx: &ReducerContext) -> BTreeMap { let mut owned = BTreeMap::new(); for definition in ctx.db.game_creature_ai_definition().iter() { @@ -182,7 +143,6 @@ fn encounter_owned_rows(ctx: &ReducerContext) -> BTreeMap Option { definition .rules @@ -194,8 +154,6 @@ fn notified_binding(definition: &CreatureAiDefinition) -> Option String { if definition.creature_entry == 0 { format!("creature guid {}", definition.creature_guid) @@ -204,7 +162,6 @@ fn subject_of(definition: &CreatureAiDefinition) -> String { } } -/// What this Shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if row.operation() == Operation::Update && is_package_creature_ai_id(row.row_id()) { return UpdateTarget::Uninvented; @@ -243,8 +200,6 @@ fn row_is_present(ctx: &ReducerContext, row: &TracedRow) -> bool { } } -/// Removes every row a Package invented, so a Package that left the enabled set takes its EventAI -/// rows with it. pub(super) fn clear_package_range(ctx: &ReducerContext) { let texts = ctx.db.game_creature_ai_broadcast_text(); let stale: Vec = texts @@ -361,7 +316,6 @@ fn gone(row: &TracedRow) -> String { format!("`{}` row {} vanished mid-apply", row.table(), row.key()) } -/// Runs one table's setter over every claimed column. fn apply_fields( row: &TracedRow, mut set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -372,10 +326,6 @@ fn apply_fields( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn blank_broadcast_text(id: u32) -> CreatureAiBroadcastText { CreatureAiBroadcastText { id, @@ -468,10 +418,6 @@ fn apply_quest_event_requirement_field( Ok(()) } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way to -// build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -483,8 +429,6 @@ mod tests { use super::*; use lyracore_package_delta::Table as ClaimTable; - /// The Package EventAI range is cleared on every apply, so tuning a Package row nobody enables - /// is a plan that names a row which will not exist. #[test] fn tuning_a_package_broadcast_text_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -572,8 +516,6 @@ mod tests { assert_eq!(requirement.quest_entry, 8_000_001); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_creature_ai_column_has_a_setter() { let mut text = blank_broadcast_text(PACKAGE_BROADCAST_TEXT); diff --git a/module/src/package_import/creatures.rs b/module/src/package_import/creatures.rs index 595097619..ffe1f459c 100644 --- a/module/src/package_import/creatures.rs +++ b/module/src/package_import/creatures.rs @@ -1,19 +1,3 @@ -//! The creatures Import Family's half of a Package Delta apply: the setters for the creature -//! catalogue and its spawns, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not. What is here is only -//! what is true of creatures. -//! -//! `game_creature_template` is a global catalogue, the item shape: one band, checked against the -//! row's own entry. `game_creature_spawn` is SPATIAL — its claim key names the map that routed it -//! here — and its durable guid is derived from the template and the spawn identifier, so a spawn -//! write is a guid the artifact never spelled. -//! -//! A spawn row is not the live creature. The respawn pass builds the entity from the spawn row and -//! its template, so every write here drops the live entity and arms the timer, exactly as -//! `import_creature_spawns` does for the base import. That is why an apply may only run as an -//! import stage: it rebuilds the creatures it touches. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -26,14 +10,6 @@ use crate::{game_creature_spawn, game_creature_template, CreatureSpawn, Creature use super::{as_f32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a creatures claim whose final row would point at no row after this family lands. -/// -/// Two checks, both of them things only the whole plan can answer. A spawn names a creature -/// template: the row it places has to exist once the plan lands, or the respawn pass would find a -/// spawn point it cannot build. And two spawn claims may derive ONE durable guid — the map routes a -/// claim but never reaches the guid, so the same template and spawn identifier stated on two maps -/// is one row claimed twice. That is an invalid statement about map ownership, and it is the one -/// this Module can see: the Shard itself is shard-agnostic and never decides which maps it owns. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { check_one_guid_per_spawn(rows)?; for row in rows { @@ -54,7 +30,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// Refuses a plan whose spawn claims collide on one derived guid. fn check_one_guid_per_spawn(rows: &[TracedRow]) -> Result<(), String> { let claimed: Vec<&TracedRow> = rows .iter() @@ -76,10 +51,6 @@ fn check_one_guid_per_spawn(rows: &[TracedRow]) -> Result<(), String> { Ok(()) } -/// True when `entry` will name a `game_creature_template` row once this plan lands. -/// -/// A Package-band template is satisfied by an insert in the same plan; anything else has to be on -/// the Shard already. The `npc_text_exists_after_apply` shape. fn template_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], entry: u32) -> bool { if entry == 0 { return false; @@ -98,7 +69,6 @@ fn template_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], entry: .is_some() } -/// What this Shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if row.operation() == Operation::Update && updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -131,10 +101,6 @@ fn row_is_present(ctx: &ReducerContext, row: &TracedRow) -> bool { } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The band is checked against the identifier the family owns: a template's own entry, a spawn's -/// own spawn identifier — never the template a spawn merely names. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { match row.key() { PrimaryKey::CreatureTemplate { entry } => is_package_creature_id(entry), @@ -143,12 +109,6 @@ fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { } } -/// Removes every row a Package invented, spawns before the templates they name. -/// -/// A Package spawn takes its live creature with it, through the canonical despawn checklist: the -/// spawn row is the source of truth the respawn pass rebuilds from, so leaving the entity would -/// leave a creature nothing can respawn or explain, and leaving its engagement, threat, motion and -/// loot rows behind would leave them keyed on a guid nothing holds. pub(super) fn clear_package_range(ctx: &ReducerContext) { let spawns = ctx.db.game_creature_spawn(); let package_guids: Vec = spawns @@ -173,7 +133,6 @@ pub(super) fn clear_package_range(ctx: &ReducerContext) { } } -/// True when a durable creature guid carries a Package-invented spawn identifier in its low field. fn is_package_spawn_guid(guid: u64) -> bool { is_package_creature_id((guid & u64::from(MAX_CREATURE_GUID_COMPONENT)) as u32) } @@ -227,10 +186,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str dispatch runs, found {other}" ), } - // A written spawn describes a creature that is standing somewhere else, or nowhere. Handing the - // live entity back to the respawn pass rebuilds it from this row on the next tick — the - // `import_creature_spawns` reset, one creature wide, through the canonical despawn checklist so - // no engagement, threat or motion row outlives the creature it named. if row.table() == ClaimTable::CreatureSpawn { crate::instance::remove_spawn_copies(ctx, row.row_id()); crate::creatures::despawn_creature_entity(ctx, row.row_id()); @@ -246,7 +201,6 @@ fn gone(row: &TracedRow) -> String { format!("`{}` row {} vanished mid-apply", row.table(), row.key()) } -/// Runs one table's setter over every claimed column. fn apply_fields( row: &TracedRow, mut set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -257,10 +211,6 @@ fn apply_fields( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn blank_template(entry: u32) -> CreatureTemplate { CreatureTemplate { entry, @@ -330,8 +280,6 @@ fn apply_template_field( Ok(()) } -/// A spawn row with everything the key already decided, and the live state a fresh import stamps: -/// the respawn timer armed at now, so the next tick builds the creature, and no corpse to decay. fn blank_spawn(ctx: &ReducerContext, key: PrimaryKey) -> CreatureSpawn { let (map_id, entry) = match key { PrimaryKey::CreatureSpawn { map_id, entry, .. } => (map_id, entry), @@ -376,10 +324,6 @@ fn no_such_column(table: &str, column: &str) -> String { format!("`{table}` has no claimable column `{column}`") } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -393,8 +337,6 @@ mod tests { const A_LEVEL: &str = r#"{"level":{"type":"u32","value":12}}"#; const A_POSITION: &str = r#"{"x":{"type":"f32","value":1.5}}"#; - /// The Package creature range is cleared on every apply, so tuning a Package creature nobody - /// enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_template_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -406,8 +348,6 @@ mod tests { assert!(updates_an_uninvented_package_row(&plan.rows[0])); } - /// A Package spawn of a REAL creature is the ordinary case: the spawn identifier is banded, the - /// template it names is not. #[test] fn a_package_spawn_of_a_real_creature_is_judged_on_its_spawn_identifier() { let package_spawn = plan(&[artifact( @@ -437,8 +377,6 @@ mod tests { assert!(!updates_an_uninvented_package_row(&real_spawn.rows[0])); } - /// The map routes a claim but never reaches the durable guid, so one spawn stated on two maps - /// is one row claimed twice. #[test] fn one_spawn_claimed_on_two_maps_is_refused() { let plan = plan(&[ @@ -498,8 +436,6 @@ mod tests { assert_eq!(template.armor, 120); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live Shard and nowhere else. #[test] fn every_claimable_creature_column_has_a_setter() { let mut template = blank_template(PACKAGE_CREATURE); @@ -528,7 +464,6 @@ mod tests { } } - /// A durable guid tells a Package spawn from an imported one by its low field alone. #[test] fn a_package_spawn_guid_is_recognised_by_its_spawn_identifier() { let package = diff --git a/module/src/package_import/fixtures.rs b/module/src/package_import/fixtures.rs index c258b289d..ac770ea8e 100644 --- a/module/src/package_import/fixtures.rs +++ b/module/src/package_import/fixtures.rs @@ -1,9 +1,3 @@ -//! Artifact fragments the tests in this module tree share. -//! -//! The JSON here is written by hand, the way a Datascript generator would emit it, so the tests -//! exercise the parser rather than a builder that could drift with it. A family that lands adds its -//! own fragments beside these. - use lyracore_package_delta::{Column, FieldType, FieldValue}; use super::{ApplyPlan, ARTIFACT_SEPARATOR}; @@ -57,8 +51,6 @@ pub(super) const WHOLE_EFFECT_ROW: &str = r#"{ "enters_combat": { "type": "bool", "value": false } }"#; -/// Wraps claims in an artifact envelope, on ONE line. The fixtures above are indented so a reader -/// can follow them; an artifact in a payload never is. pub(super) fn artifact(package: &str, claims: &str) -> String { let claims: String = claims.lines().map(str::trim).collect(); format!(r#"{{"version":1,"package":"{package}","source_hash":"{HASH_A}","claims":[{claims}]}}"#) @@ -82,7 +74,7 @@ pub(super) fn effect_claim( } pub(super) const PACKAGE_ITEM: u32 = 7_000_001; -pub(super) const REAL_ITEM: u32 = 25; // Worn Shortsword — a real imported item template. +pub(super) const REAL_ITEM: u32 = 25; pub(super) const WHOLE_ITEM_ROW: &str = r#"{ "class": { "type": "u8", "value": 2 }, @@ -151,7 +143,7 @@ pub(super) fn item_claim(entry: u32, operation: &str, fields: &str) -> String { } pub(super) const PACKAGE_QUEST: u32 = 8_000_001; -pub(super) const REAL_QUEST: u32 = 1; // "Undertaking", the first Elwynn quest, always imported in-box. +pub(super) const REAL_QUEST: u32 = 1; pub(super) const WHOLE_QUEST_ROW: &str = r#"{ "min_level": { "type": "u32", "value": 1 }, @@ -223,7 +215,7 @@ pub(super) fn pickpocket_loot_claim(id: u64, operation: &str, fields: &str) -> S } pub(super) const PACKAGE_CREATURE_SPELL: u64 = 10_000_001; -pub(super) const REAL_CREATURE_CAST: u32 = 6; // Kobold Vermin — a real imported creature template with a cast row. +pub(super) const REAL_CREATURE_CAST: u32 = 6; pub(super) const REAL_CREATURE_SPELL: u64 = 1; pub(super) const WHOLE_CREATURE_SPELL_ROW: &str = r#"{ @@ -264,13 +256,8 @@ pub(super) fn trainer_spell_claim(id: u64, operation: &str, fields: &str) -> Str ) } -// ---- gossip ---- - -/// A gossip row inside the Package gossip range, safe to insert. One band covers all five -/// insertable gossip tables. pub(super) const PACKAGE_GOSSIP: u64 = 12_000_001; -/// A real imported `game_npc_text` row, safe to update and never safe to insert. pub(super) const REAL_NPC_TEXT: u32 = 1; pub(super) const WHOLE_NPC_TEXT_ROW: &str = r#"{ @@ -301,12 +288,8 @@ pub(super) fn gossip_option_claim(row_id: u32, operation: &str, fields: &str) -> ) } -// ---- globals ---- - -/// A globals row inside the Package globals range, safe to insert. pub(super) const PACKAGE_GLOBALS: u64 = 13_000_001; -/// A real imported `game_graveyard_zone` row, safe to update and never safe to insert. pub(super) const REAL_GRAVEYARD_ZONE: u64 = 1; pub(super) const WHOLE_GRAVEYARD_ZONE_ROW: &str = r#"{ @@ -332,12 +315,8 @@ pub(super) fn class_level_stats_claim( ) } -// ---- spellmeta ---- - -/// A `game_spell_learn` row inside the Package spell metadata range, safe to insert. pub(super) const PACKAGE_SPELL_LEARN: u64 = 14_000_001; -/// A real imported `game_spell_learn` row, safe to update and never safe to insert. pub(super) const REAL_SPELL_LEARN: u64 = 1; pub(super) const WHOLE_SPELL_CHAIN_ROW: &str = r#"{ @@ -364,19 +343,12 @@ pub(super) fn spell_learn_claim(id: u64, operation: &str, fields: &str) -> Strin ) } -// ---- creatures ---- - -/// A creature identifier inside the Package creature range, safe to insert. One band covers the -/// template and the spawn. pub(super) const PACKAGE_CREATURE: u32 = 15_000_001; -/// A real imported creature template, safe to update and never safe to insert. -pub(super) const REAL_CREATURE: u32 = 6; // Kobold Vermin. +pub(super) const REAL_CREATURE: u32 = 6; -/// A real imported spawn identifier. pub(super) const REAL_CREATURE_SPAWN: u32 = 4_242; -/// The map a spatial fixture claim sits on: Eastern Kingdoms. pub(super) const REAL_MAP: u32 = 0; pub(super) const WHOLE_CREATURE_TEMPLATE_ROW: &str = r#"{ @@ -426,13 +398,8 @@ pub(super) fn creature_spawn_claim( ) } -// ---- gameobjects ---- - -/// A gameobject identifier inside the Package gameobject range, safe to insert. One band covers -/// the template, the trap and the spawn. pub(super) const PACKAGE_GAMEOBJECT: u32 = 16_000_001; -/// A real imported gameobject spawn identifier. pub(super) const REAL_GAMEOBJECT_SPAWN: u32 = 7_777; pub(super) const WHOLE_GAMEOBJECT_TEMPLATE_ROW: &str = r#"{ @@ -465,19 +432,12 @@ pub(super) fn gameobject_spawn_claim( ) } -// ---- creature-ai ---- - -/// A broadcast text inside the Package EventAI range, safe to insert. One band covers the text, -/// the summon placement and the quest event requirement. pub(super) const PACKAGE_BROADCAST_TEXT: u32 = 17_000_001; -/// A summon placement inside the Package EventAI range. pub(super) const PACKAGE_SUMMON: u32 = 17_000_002; -/// A quest event requirement inside the Package EventAI range. pub(super) const PACKAGE_QUEST_EVENT_REQUIREMENT: u64 = 17_000_003; -/// A real imported broadcast text identifier. pub(super) const REAL_BROADCAST_TEXT: u32 = 900; pub(super) const WHOLE_BROADCAST_TEXT_ROW: &str = r#"{ @@ -523,8 +483,6 @@ pub(super) fn quest_event_requirement_claim(id: u64, operation: &str, fields: &s ) } -/// A value of the column's declared type, so a test can claim any column without spelling out what -/// it holds. pub(super) fn some_value(column: Column) -> FieldValue { match column.ty { FieldType::U8 => FieldValue::U8(7), @@ -538,7 +496,6 @@ pub(super) fn some_value(column: Column) -> FieldValue { } } -/// Reads a set of artifacts as one payload, the way the reducer does. pub(super) fn plan(artifacts: &[String]) -> Result { ApplyPlan::read(&artifacts.join(&ARTIFACT_SEPARATOR.to_string())) } diff --git a/module/src/package_import/gameobjects.rs b/module/src/package_import/gameobjects.rs index c488f8c37..e3f259a94 100644 --- a/module/src/package_import/gameobjects.rs +++ b/module/src/package_import/gameobjects.rs @@ -1,15 +1,3 @@ -//! The gameobjects Import Family's half of a Package Delta apply: the setters for the gameobject -//! catalogue, its trap metadata and its spawns, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not. What is here is only -//! what is true of gameobjects. -//! -//! Three tables, one band. `game_gameobject_template` and `game_gameobject_trap` share one -//! identifier space on purpose: a trap row describes the template of the same entry, so a Package -//! trap is exactly as Package-owned as its template. `game_gameobject` is SPATIAL, and unlike a -//! creature its row IS the live prop — nothing rebuilds it from a spawn record, so a write here -//! also derives the AOI grid cell the row is addressed through. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -23,14 +11,8 @@ use crate::{ use super::{as_f32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Bits 0..47 of a gameobject guid: the spawn identifier the importer packs there. const GAMEOBJECT_GUID_SPAWN_MASK: u64 = 0xFFFF_FFFF_FFFF; -/// Refuses a gameobjects claim whose final row would point at no row after this family lands. -/// -/// A spawn and a trap both name a template: the row each describes has to exist once the plan -/// lands. And two spawn claims may derive ONE durable guid, because the map routes a claim but -/// never reaches the guid — the same invalid map-ownership statement the creatures family refuses. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { check_one_guid_per_spawn(rows)?; for row in rows { @@ -51,7 +33,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// Refuses a plan whose spawn claims collide on one derived guid. fn check_one_guid_per_spawn(rows: &[TracedRow]) -> Result<(), String> { let claimed: Vec<&TracedRow> = rows .iter() @@ -73,8 +54,6 @@ fn check_one_guid_per_spawn(rows: &[TracedRow]) -> Result<(), String> { Ok(()) } -/// The template a spawn will name once its claim lands: the claimed value if the claim sets it, -/// otherwise what the Shard already holds. fn final_template_entry(ctx: &ReducerContext, row: &TracedRow) -> Result { if let Some(FieldValue::U32(entry)) = row .fields() @@ -97,7 +76,6 @@ fn final_template_entry(ctx: &ReducerContext, row: &TracedRow) -> Result bool { if entry == 0 { return false; @@ -116,7 +94,6 @@ fn template_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], entry: .is_some() } -/// What this Shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if row.operation() == Operation::Update && updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -150,7 +127,6 @@ fn row_is_present(ctx: &ReducerContext, row: &TracedRow) -> bool { } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { match row.key() { PrimaryKey::GameobjectTemplate { entry } | PrimaryKey::GameobjectTrap { entry } => { @@ -161,8 +137,6 @@ fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { } } -/// Removes every row a Package invented: the spawns and the trap metadata first, then the -/// templates they name. pub(super) fn clear_package_range(ctx: &ReducerContext) { let spawns = ctx.db.game_gameobject(); let package_guids: Vec = spawns @@ -195,7 +169,6 @@ pub(super) fn clear_package_range(ctx: &ReducerContext) { } } -/// True when a durable gameobject guid carries a Package-invented spawn identifier. fn is_package_spawn_guid(guid: u64) -> bool { u32::try_from(guid & GAMEOBJECT_GUID_SPAWN_MASK).is_ok_and(is_package_gameobject_id) } @@ -275,9 +248,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -/// Re-derives the AOI grid columns from the coordinates in the same write that set them. They are -/// not claimable, and a stale value here does not slow a query down — it shows players the wrong -/// world (`module/src/tripwires.rs`'s grid-cell tripwire). fn stamp_cell(spawn: &mut GameObject) { let (grid_x, grid_y) = lyracore_shared::spatial::grid_cell(spawn.x, spawn.y); spawn.grid_x = grid_x; @@ -293,7 +263,6 @@ fn gone(row: &TracedRow) -> String { format!("`{}` row {} vanished mid-apply", row.table(), row.key()) } -/// Runs one table's setter over every claimed column. fn apply_fields( row: &TracedRow, mut set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -304,10 +273,6 @@ fn apply_fields( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn blank_template(entry: u32) -> GameObjectTemplate { GameObjectTemplate { entry, @@ -366,9 +331,6 @@ fn apply_trap_field( Ok(()) } -/// A spawn row with everything the key already decided, and the live state a fresh import stamps: a -/// ready prop, no pending respawn, open world. The grid columns are re-derived by [`stamp_cell`] -/// once the claimed coordinates are on the row. fn blank_spawn(ctx: &ReducerContext, key: PrimaryKey) -> GameObject { let map_id = match key { PrimaryKey::GameobjectSpawn { map_id, .. } => map_id, @@ -423,10 +385,6 @@ fn no_such_column(table: &str, column: &str) -> String { format!("`{table}` has no claimable column `{column}`") } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -493,8 +451,6 @@ mod tests { assert_eq!(template.data0, 25); } - /// The grid columns address the row for every viewer's AOI box, and they are derived rather - /// than claimed, so a written position has to re-derive them. #[test] fn a_written_position_restamps_the_grid_cell() { let mut spawn = blank_spawn_for_test(); @@ -512,7 +468,6 @@ mod tests { assert_ne!(spawn.cell, 0, "a real position is not cell (0, 0)"); } - /// The Claim schema and the setters above are one contract. #[test] fn every_claimable_gameobject_column_has_a_setter() { let mut template = blank_template(PACKAGE_GAMEOBJECT); @@ -540,8 +495,6 @@ mod tests { assert!(!is_package_spawn_guid(imported)); } - /// `blank_spawn` needs a live `ReducerContext` for its timestamp, which a native test cannot - /// build, so the pure cases construct the row directly. fn blank_spawn_for_test() -> GameObject { GameObject { guid: lyracore_package_delta::packed_gameobject_spawn_guid(PACKAGE_GAMEOBJECT), diff --git a/module/src/package_import/globals.rs b/module/src/package_import/globals.rs index 24ded8958..6021b146b 100644 --- a/module/src/package_import/globals.rs +++ b/module/src/package_import/globals.rs @@ -1,29 +1,3 @@ -//! The globals Import Family's half of a Package Delta apply: the setters for the seven world-wide -//! reference tables, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of globals. -//! -//! Three tables follow the loot shape: `game_graveyard_zone`, `game_createinfo_spell` and -//! `game_createinfo_action` key on a free surrogate, so one Package band -//! (`is_package_globals_id`) covers them. The other four are update-only -//! ([`lyracore_package_delta::DeltaError::InsertNotSupported`]): the two stat curves and -//! `game_start_position` key on a race, class and level the client fixes, and -//! `game_areatrigger_teleport` keys on an `AreaTrigger.dbc` trigger id. None of the four carries a -//! band, so [`clear_package_range`] never touches them. -//! -//! Two references are checked, and only two. `game_graveyard_zone.safe_loc_id` names a -//! `game_graveyard` row and `game_createinfo_spell.spell_id` names a `game_spell` row, so both -//! resolve against a catalogue this shard holds. `game_areatrigger_teleport.trigger_id` is NOT -//! checked against `game_area_trigger`: that table comes from the importer's `--dbc` pass, which a -//! `--dump`-only shard has never run, so the check would refuse every legitimate claim there. -//! `game_createinfo_action.action` is not checked either — it is polymorphic on `action_type` -//! (spell, item, macro), so no single catalogue answers it. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -39,7 +13,6 @@ use crate::{ use super::{as_f32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a globals claim whose final row would point at data this Shard does not hold. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { match row.table() { @@ -66,8 +39,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// The value `field` will hold once this row's claim lands: the claimed value if the claim sets it, -/// otherwise what the Shard already holds. fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result { if let Some(FieldValue::U32(value)) = row.fields().get(field).map(|claimed| &claimed.value) { return Ok(*value); @@ -107,7 +78,6 @@ fn missing_reference(row: &TracedRow, field: &str, value: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if has_package_band(row.table()) && updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -119,8 +89,6 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True for the three tables whose key is a free surrogate. The other four permit no insert, so no -/// row of them is ever "Package-invented, but no enabled Package inserts it". const fn has_package_band(table: ClaimTable) -> bool { matches!( table, @@ -165,15 +133,10 @@ fn row_is_present(ctx: &ReducerContext, row: &TracedRow) -> bool { } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package globals range is cleared on every apply, so such a row is gone by the time the write -/// pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_globals_id(row.row_id()) } -/// Removes every row a Package invented, from each of the three banded tables. pub(super) fn clear_package_range(ctx: &ReducerContext) { let zones = ctx.db.game_graveyard_zone(); let stale: Vec = zones @@ -326,7 +289,6 @@ fn update_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), String> { Ok(()) } -/// Runs one table's setter over every claimed column. Seven tables share the loop. fn apply_fields( row: &TracedRow, mut set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -337,10 +299,6 @@ fn apply_fields( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn apply_class_level_stats_field( stats: &mut ClassLevelStats, field: &str, @@ -482,10 +440,6 @@ fn no_such_column(table: &str, column: &str) -> String { format!("`{table}` has no claimable column `{column}`") } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -495,8 +449,6 @@ mod tests { use super::*; use lyracore_package_delta::Table as ClaimTable; - /// The Package globals range is cleared on every apply, so tuning a Package graveyard link - /// nobody enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_graveyard_zone_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -527,8 +479,6 @@ mod tests { assert!(!updates_an_uninvented_package_row(&plan.rows[0])); } - /// A stat curve carries no band, so a Package-range identifier on it is not "uninvented" — it - /// is simply a class and a level no client has. #[test] fn a_stat_curve_row_is_never_an_uninvented_package_row() { let plan = plan(&[artifact( @@ -564,8 +514,6 @@ mod tests { assert_eq!(zone.zone_id, 12); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_globals_column_has_a_setter() { let mut class_stats = ClassLevelStats { diff --git a/module/src/package_import/gossip.rs b/module/src/package_import/gossip.rs index 6a8fee258..86f16cb33 100644 --- a/module/src/package_import/gossip.rs +++ b/module/src/package_import/gossip.rs @@ -1,19 +1,3 @@ -//! The gossip Import Family's half of a Package Delta apply: the setters for the six gossip -//! tables, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of gossip. -//! -//! Five of the six tables follow the loot shape: their key is a free identifier space, so one -//! Package band (`is_package_gossip_id`) covers them all. `game_gossip_menu` is the sixth and is -//! update-only ([`lyracore_package_delta::DeltaError::InsertNotSupported`]): its key is a creature -//! template entry, which no Package may invent. It carries no band, so [`clear_package_range`] -//! never touches it. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -29,10 +13,6 @@ use crate::{ use super::{as_f32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a gossip claim whose final row would point at no row after this family lands. -/// -/// Every reference stays inside the family except `game_gossip_option.entry`, which names a -/// creature template the creatures family owns. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { match row.table() { @@ -68,10 +48,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// True when `text_id` will name a `game_npc_text` row once this plan lands. -/// -/// A Package-band greeting is satisfied by an insert in the same plan; anything else has to be on -/// the shard already. The `quest_exists_after_apply` shape. fn npc_text_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], text_id: u32) -> bool { if text_id == 0 { return false; @@ -86,7 +62,6 @@ fn npc_text_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], text_id ctx.db.game_npc_text().text_id().find(text_id).is_some() } -/// True when `menu_id` will name a `game_gossip_menu_profile` row once this plan lands. fn menu_profile_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], menu_id: u32) -> bool { if menu_id == 0 { return false; @@ -105,9 +80,6 @@ fn menu_profile_exists_after_apply(ctx: &ReducerContext, rows: &[TracedRow], men .is_some() } -/// The value `field` will hold once this row's claim lands: the claimed value if the claim sets it, -/// otherwise what the Shard already holds. An update that changes only one column is judged on what -/// the row will hold after the apply, not on the column alone. fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result { if let Some(FieldValue::U32(value)) = row.fields().get(field).map(|claimed| &claimed.value) { return Ok(*value); @@ -165,10 +137,7 @@ fn missing_reference(row: &TracedRow, field: &str, value: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { - // `game_gossip_menu` permits no insert at all, so no row of it is ever "Package-invented, but - // no enabled Package inserts it". if row.table() != ClaimTable::GossipMenu && updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; } @@ -210,16 +179,10 @@ fn row_is_present(ctx: &ReducerContext, row: &TracedRow) -> bool { } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package gossip range is cleared on every apply, so such a row is gone by the time the write -/// pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_gossip_id(row.row_id()) } -/// Removes every row a Package invented, from each of the five banded tables. `game_gossip_menu` -/// has no band — nothing to clear there. pub(super) fn clear_package_range(ctx: &ReducerContext) { let profiles = ctx.db.game_gossip_menu_profile(); for menu_id in package_keys(profiles.iter().map(|row| u64::from(row.menu_id))) { @@ -241,16 +204,13 @@ pub(super) fn clear_package_range(ctx: &ReducerContext) { slots.id().delete(id); } - // The greeting bodies go LAST: the slots above point at them, so clearing a body first would - // leave a slot naming a row that is already gone for the rest of this transaction. + // Clear greeting slots before the bodies they reference. let texts = ctx.db.game_npc_text(); for text_id in package_keys(texts.iter().map(|row| u64::from(row.text_id))) { texts.text_id().delete(text_id as u32); } } -/// The Package-band keys of one table, collected before any delete so the iteration does not run -/// against a table it is mutating. fn package_keys(keys: impl Iterator) -> Vec { keys.filter(|key| is_package_gossip_id(*key)).collect() } @@ -391,8 +351,6 @@ fn update_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), String> { Ok(()) } -/// Runs one table's setter over every claimed column. Six tables share the loop, so it lives here -/// rather than six times over. fn apply_fields( row: &TracedRow, mut set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -403,10 +361,6 @@ fn apply_fields( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn apply_gossip_menu_field( menu: &mut crate::GossipMenu, field: &str, @@ -554,10 +508,6 @@ fn no_such_column(table: &str, column: &str) -> String { format!("`{table}` has no claimable column `{column}`") } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -567,8 +517,6 @@ mod tests { use super::*; use lyracore_package_delta::Table as ClaimTable; - /// The Package gossip range is cleared on every apply, so tuning a Package greeting nobody - /// enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_npc_text_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -636,8 +584,6 @@ mod tests { assert_eq!(text.text, "The forge is cold, friend."); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_gossip_column_has_a_setter() { let mut menu = crate::GossipMenu { diff --git a/module/src/package_import/items.rs b/module/src/package_import/items.rs index fa8b6acfc..3f70f8bfe 100644 --- a/module/src/package_import/items.rs +++ b/module/src/package_import/items.rs @@ -1,17 +1,3 @@ -//! The items Import Family's half of a Package Delta apply: the setters for `game_item_template`, -//! where a Package item lives, and how to find one row. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of items. -//! -//! The Claim schema and the setter below are one contract; the test at the bottom fails if a -//! claimable column has no setter. -//! -//! Unlike the spell family, items own exactly one table, so `row.key()` reaching this module is -//! always `PrimaryKey::Item` — `check_claims_belong_to` refuses anything else before the family -//! dispatch in the parent module ever calls here. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{is_package_item_id, FieldValue, Operation, PrimaryKey, TracedRow}; @@ -20,7 +6,6 @@ use crate::items::{game_item_template, ItemTemplate}; use super::{as_bool, as_f32, as_i32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -38,17 +23,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package item range is cleared on every apply, so such a row is gone by the time the write -/// pass runs: the Package that owns it is not enabled, and the one tuning it is claiming a row that -/// does not exist. A base item is different — the base import puts it there. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_item_id(entry_of(row)) } -/// Removes every row a Package invented. A whole-table pass over `game_item_template`, once per -/// import, right after the base import rewrote it anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let items = ctx.db.game_item_template(); let stale: Vec = items @@ -85,13 +63,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Row building — pure. -// =========================================================================================== - -/// The `entry` an items-family row names. Total, not a `Result`: `check_claims_belong_to` has -/// already refused a foreign-family row before this module's dispatch runs, so `row.key()` is -/// always `PrimaryKey::Item` here. fn entry_of(row: &TracedRow) -> u32 { let PrimaryKey::Item { entry } = row.key() else { unreachable!("`check_claims_belong_to` refuses a non-item row before the items family's dispatch runs") @@ -99,9 +70,6 @@ fn entry_of(row: &TracedRow) -> u32 { entry } -/// A Package item before any claim has been applied to it. Every claimable column is overwritten by -/// the insert that follows, so these values never survive; they exist because the row's shape has no -/// `Default`. fn blank_item(entry: u32) -> ItemTemplate { ItemTemplate { entry, @@ -247,10 +215,6 @@ fn built_item(row: &TracedRow) -> Result { Ok(item) } -// =========================================================================================== -// Tests — the pure half. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -259,8 +223,6 @@ mod tests { use super::*; use lyracore_package_delta::Table as ClaimTable; - /// The Package item range is cleared on every apply, so tuning a Package item nobody enables is - /// a plan that names a row which will not exist. #[test] fn tuning_a_package_item_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -308,8 +270,6 @@ mod tests { assert!((item.damage_max - 9.0).abs() < f32::EPSILON); } - /// The Claim schema and the setter above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_item_column_has_a_setter() { let mut item = blank_item(PACKAGE_ITEM); diff --git a/module/src/package_import/loot.rs b/module/src/package_import/loot.rs index 9987cbbcf..5c0885b68 100644 --- a/module/src/package_import/loot.rs +++ b/module/src/package_import/loot.rs @@ -1,21 +1,3 @@ -//! The loot Import Family's half of a Package Delta apply: the setters for the four non-creature -//! loot tables a Package may claim, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of loot. -//! -//! Unlike items, spells and quests, a loot row's owning entity (a creature, a gameobject/chest, or -//! a zone) is never Package-invented. Creature and gameobject templates stay out of this issue's -//! scope. So the Package identifier band applies to a loot row's OWN `id`, exactly the way -//! `PACKAGE_ITEM_ID_FLOOR` applies to `game_item_template.entry`, rather than to an owning -//! identifier the way `is_package_quest_id` is checked against `quest_entry`. One band covers all -//! four tables: they are independent `SpacetimeDB` tables with independent primary-key spaces, so -//! sharing one numeric range across them cannot collide. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -30,7 +12,6 @@ use crate::{game_creature_template, game_gameobject_template, game_item_template use super::{as_bool, as_u32, check_insert_is_whole, UpdateTarget}; -/// Refuses a loot claim whose final row would point at data this Shard does not hold. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { let item_entry = final_u32(ctx, row, "item_entry")?; @@ -164,7 +145,6 @@ fn missing_reference(row: &TracedRow, field: &str, value: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -201,16 +181,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package loot range is cleared on every apply, so such a row is gone by the time the write -/// pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_loot_id(row.row_id()) } -/// Removes every row a Package invented, across all four tables. Once per import, right after the -/// base import rewrote them anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let pickpocket = ctx.db.game_pickpocket_loot(); let stale_pickpocket: Vec = pickpocket @@ -347,12 +321,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - -/// The `id` a loot-family row names. Total, not a `Result`: `check_claims_belong_to` has already -/// refused a foreign-family row before this module's dispatch runs. fn id_of(row: &TracedRow) -> u64 { match row.key() { PrimaryKey::PickpocketLoot { id } @@ -529,10 +497,6 @@ fn built_fishing_loot(row: &TracedRow) -> Result { Ok(loot) } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -541,8 +505,6 @@ mod tests { }; use super::*; - /// The Package loot range is cleared on every apply, so tuning a Package loot row nobody - /// enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_loot_row_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -589,8 +551,6 @@ mod tests { assert_eq!(loot.chance_bp, 5000); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_pickpocket_loot_column_has_a_setter() { let mut loot = blank_pickpocket_loot(PACKAGE_LOOT); diff --git a/module/src/package_import/quest.rs b/module/src/package_import/quest.rs index 8354aaa9b..8ed513a27 100644 --- a/module/src/package_import/quest.rs +++ b/module/src/package_import/quest.rs @@ -1,21 +1,3 @@ -//! The quest Import Family's half of a Package Delta apply: the setters for the six quest tables a -//! Package may claim, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of quests. -//! -//! One Package identifier band covers the whole family (`is_package_quest_id`, checked against -//! `quest_entry`), the same way the Package spell range covers both `game_spell` and -//! `game_spell_effect`. `game_quest_text` is 1:1 with its header by `quest_entry`; the other four -//! tables carry a derived packed key (`quest_entry` plus a natural per-row index), the same shape -//! `packed_spell_effect_id` documents. `game_creature_quest`/`game_gameobject_quest` (which -//! creature/gameobject starts or ends a quest) are out of this family's claimable catalogue. See -//! `lyracore_package_delta::Table::columns`'s doc for why. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -32,7 +14,6 @@ use crate::{game_creature_template, game_gameobject_template, game_item_template use super::{as_bool, as_i32, as_str, as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a quest claim whose final row would point at data this Shard does not hold. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { let quest_entry = row.key().quest_entry(); @@ -172,7 +153,6 @@ fn missing_reference(row: &TracedRow, field: &str, value: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -226,19 +206,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package quest range is cleared on every apply, so such a row is gone by the time the write -/// pass runs: the Package that owns the quest is not enabled, and the one tuning it is claiming a -/// row that does not exist. A base quest is different because the base import puts it there. Every quest -/// table checks the same `quest_entry`, because a child row is only ever as Package-owned as the -/// quest it belongs to. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_quest_id(row.key().quest_entry()) } -/// Removes every row a Package invented, across all six tables. Once per import, right after the -/// base import rewrote them anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let templates = ctx.db.game_quest_template(); let stale_templates: Vec = templates @@ -448,10 +419,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn blank_quest(entry: u32) -> QuestTemplate { QuestTemplate { entry, @@ -742,10 +709,6 @@ fn built_quest_reward_choice(row: &TracedRow) -> Result Result, String> { let plan = ScriptPlan::read(packed)?; @@ -50,20 +17,11 @@ pub(super) fn apply(ctx: &ReducerContext, packed: &str) -> Result = scripts @@ -85,8 +43,6 @@ fn write_script(ctx: &ReducerContext, traced: &TracedScript) -> Result<(), Strin name: script.name().to_string(), package: traced.package.to_string(), source_hash: traced.source_hash.to_string(), - // Derived here rather than read from the artifact, so it cannot disagree with the Lua - // it describes. It is also the Host's compiler-cache key. content_hash: blake3::hash(script.source().as_bytes()) .to_hex() .to_string(), @@ -105,11 +61,6 @@ fn write_script(ctx: &ReducerContext, traced: &TracedScript) -> Result<(), Strin Ok(()) } -// =========================================================================================== -// The plan — pure. Everything that can refuse the payload refuses here, before any write. -// =========================================================================================== - -/// Every enabled Package's scripts, merged, and who contributed what. #[derive(Debug)] struct ScriptPlan { packages: Vec, @@ -117,11 +68,6 @@ struct ScriptPlan { } impl ScriptPlan { - /// Reads a payload into a plan, refusing anything that must not reach the table. - /// - /// The refusals, in order: an artifact that does not parse, two artifacts naming the same - /// Package, and any collision between Packages. The shared tracer reports EVERY disagreement, - /// not just the first, because the operator fixing them wants the whole list in one pass. fn read(packed: &str) -> Result { let mut artifacts: Vec = Vec::new(); for (index, artifact) in packed @@ -157,9 +103,6 @@ impl ScriptPlan { .to_string(), source_hash: artifact.source_hash().to_string(), counts: RowCounts { - // A Package never tunes another Package's script, so nothing here is an update. - // Every script it ships is a row it owns, disabled ones included: the row is on - // the shard either way. inserted_rows: artifact.scripts().len() as u64, ..RowCounts::default() }, @@ -170,11 +113,6 @@ impl ScriptPlan { } } -// =========================================================================================== -// Tests — the pure half. Row WRITING needs a live ReducerContext, which a native test has no -// way to build (same limit `import_meta`'s tests note); `tests/package_runtime_scripts.rs` -// covers that rung against a published module. -// =========================================================================================== #[cfg(test)] mod tests { use super::ScriptPlan; @@ -199,8 +137,6 @@ mod tests { ScriptPlan::read(&artifacts.join(&ARTIFACT_SEPARATOR.to_string())) } - /// The payload format rests on this: a canonical artifact escapes every control character, so - /// one line is one artifact. Lua source is the member most likely to hold a newline. #[test] fn a_canonical_script_artifact_never_contains_the_artifact_separator() { let awkward = format!( @@ -213,8 +149,6 @@ mod tests { assert!(!parsed.to_canonical_json().contains(ARTIFACT_SEPARATOR)); } - /// An empty payload is the honest statement "no Package ships a Runtime Script any more", and - /// it clears the range. It is never sent by accident. #[test] fn an_empty_payload_is_a_plan_that_ships_nothing() { let plan = ScriptPlan::read("").expect("an empty plan is valid"); @@ -249,7 +183,6 @@ mod tests { ); } - /// The digest is over the CANONICAL bytes, so how an artifact was spelled cannot change it. #[test] fn the_artifact_hash_ignores_how_the_artifact_was_written() { let compact = artifact( @@ -280,8 +213,6 @@ mod tests { assert!(refusal.contains("Script Artifact 1"), "{refusal}"); } - /// A Package Delta packed into a script plan must be named for what it is, not complained - /// about member by member — both artifacts live in one directory. #[test] fn a_package_delta_in_a_script_plan_is_refused_by_name() { let delta = concat!( @@ -345,8 +276,6 @@ mod tests { assert!(refusal.contains("shared.greet"), "{refusal}"); } - /// Several Packages binding to one event is the normal case, not a conflict — that is what - /// `priority` is for. #[test] fn two_packages_binding_to_one_event_merge_into_one_plan() { let first = artifact( @@ -364,8 +293,6 @@ mod tests { assert_eq!(plan.packages.len(), 2); } - /// The written order is the plan order, whatever order the Packages arrived in — two Shards - /// given one plan write the same rows. #[test] fn the_plan_is_ordered_by_identifier_however_the_packages_arrived() { let alpha = artifact( @@ -391,8 +318,6 @@ mod tests { assert_eq!(ids(&forwards), ids(&backwards)); } - /// Every traced script carries the Package that ships it onto the row, so a Shard records who - /// put each script there. #[test] fn every_planned_script_carries_its_package_and_source_revision() { let plan = plan(&[artifact( diff --git a/module/src/package_import/spell.rs b/module/src/package_import/spell.rs index cf4e12edb..fba2ed005 100644 --- a/module/src/package_import/spell.rs +++ b/module/src/package_import/spell.rs @@ -1,13 +1,3 @@ -//! The spell Import Family's half of a Package Delta apply: the setters for `game_spell` and -//! `game_spell_effect`, where a Package spell lives, and how to find one row. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of spells. -//! -//! The Claim schema and these setters are one contract; the tests below fail if a claimable column -//! has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{ @@ -22,7 +12,6 @@ use super::{ UpdateTarget, }; -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -47,17 +36,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package spell range is cleared on every apply, so such a row is gone by the time the write -/// pass runs: the Package that owns it is not enabled, and the one tuning it is claiming a row that -/// does not exist. A base spell is different — the base import puts it there. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_spell_id(row.key().spell_id()) } -/// Removes every row a Package invented. A whole-table pass over both spell tables, once per -/// import, right after the base import rewrote them anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let effects = ctx.db.game_spell_effect(); let stale_effects: Vec = effects @@ -125,13 +107,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Row building — pure. -// =========================================================================================== - -/// A Package spell before any claim has been applied to it. Every claimable column is overwritten -/// by the insert that follows, so these values never survive; they exist because the row's shape -/// has no `Default`. fn blank_spell(spell_id: u32) -> Spell { Spell { spell_id, @@ -162,8 +137,6 @@ fn blank_spell(spell_id: u32) -> Spell { } } -/// A Package spell effect before any claim has been applied to it. `id` is the derived packed key, -/// never authored. fn blank_spell_effect(spell_id: u32, effect_index: u8) -> SpellEffect { SpellEffect { id: packed_spell_effect_id(spell_id, effect_index), @@ -276,10 +249,6 @@ fn built_spell_effect(row: &TracedRow) -> Result { Ok(effect) } -// =========================================================================================== -// Tests — the pure half. Row WRITING needs a live ReducerContext, which a native test has no -// way to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -288,8 +257,6 @@ mod tests { }; use super::*; - /// The Package spell range is cleared on every apply, so tuning a Package spell nobody enables - /// is a plan that names a row which will not exist. #[test] fn tuning_a_package_spell_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -338,8 +305,6 @@ mod tests { assert!(spell.is_negative); } - /// The packed key is derived from the spell and the effect index, never authored, so the built - /// row must carry the same value `game_aura.effect_id` is reproducible from. #[test] fn an_inserted_effect_derives_its_packed_key_from_the_spell_and_the_index() { let plan = plan(&[artifact( @@ -358,8 +323,6 @@ mod tests { assert_eq!(effect.p0_kind, 255); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_spell_column_has_a_setter() { let mut spell = blank_spell(PACKAGE_SPELL); diff --git a/module/src/package_import/spellmeta.rs b/module/src/package_import/spellmeta.rs index 001eb348e..3d6941d37 100644 --- a/module/src/package_import/spellmeta.rs +++ b/module/src/package_import/spellmeta.rs @@ -1,22 +1,4 @@ -//! The spellmeta Import Family's half of a Package Delta apply: the setters for `game_spell_chain`, -//! `game_spell_learn` and `game_spell_proc_event`, and how to find one row of each. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of spell metadata. -//! -//! `game_spell_learn` follows the loot shape: its `id` is a free surrogate with its own Package -//! band (`is_package_spellmeta_id`). The other two key on a spell identifier, so the band that -//! decides what a Package may invent there is the Package SPELL band (`is_package_spell_id`) — a -//! metadata row cannot outlive the `game_spell` row it describes, and giving it a band of its own -//! would let a Package write metadata for a spell that cannot exist. -//! -//! That also fixes an order: every spell a claim here names has to be on the shard already, which -//! for a Package spell means the SPELL family's own apply ran first. A `--dump` realm runs the -//! spell pass before the dump pass, so this is the order an import already has. -//! -//! The Claim schema and the setters below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. +//! Package spells must be applied before their metadata references are checked. use spacetimedb::{ReducerContext, Table}; @@ -32,17 +14,11 @@ use crate::{ use super::{as_f32, as_u32, as_u64, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a spell metadata claim whose final row would describe a spell this Shard does not hold. -/// -/// The key itself is checked as well as the columns: a rank link or a proc overlay for a missing -/// spell is a row nothing will ever read. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { match row.table() { ClaimTable::SpellChain => { check_spell(ctx, row, "spell_id", row.row_id() as u32)?; - // `prev_spell` is 0 on a family's first rank and `req_spell` is 0 on most rows, so - // both are checked only when set. for field in ["prev_spell", "req_spell"] { let spell_id = final_u32(ctx, row, field)?; if spell_id != 0 { @@ -81,8 +57,6 @@ fn check_spell( Ok(()) } -/// The value `field` will hold once this row's claim lands: the claimed value if the claim sets it, -/// otherwise what the Shard already holds. fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result { if let Some(FieldValue::U32(value)) = row.fields().get(field).map(|claimed| &claimed.value) { return Ok(*value); @@ -125,7 +99,6 @@ fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -157,15 +130,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The band differs per table, because two of the three key on a spell identifier. Both ranges are -/// cleared on every apply, so such a row is gone by the time the write pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_row(row.table(), row.row_id()) } -/// True when this identifier is one a Package invented on this table. fn is_package_row(table: ClaimTable, id: u64) -> bool { match table { ClaimTable::SpellLearn => is_package_spellmeta_id(id), @@ -173,8 +141,6 @@ fn is_package_row(table: ClaimTable, id: u64) -> bool { } } -/// Removes every row a Package invented. `game_spell_learn` clears its own band; the two -/// spell-keyed tables clear the Package spell band. pub(super) fn clear_package_range(ctx: &ReducerContext) { let chains = ctx.db.game_spell_chain(); let stale: Vec = chains @@ -283,7 +249,6 @@ fn gone(row: &TracedRow) -> String { format!("`{}` row {} vanished mid-apply", row.table(), row.key()) } -/// An insert has to carry every column, so it is checked before the setters run. fn build( row: &TracedRow, set: impl FnMut(&str, &FieldValue) -> Result<(), String>, @@ -302,10 +267,6 @@ fn apply_claimed( Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - fn blank_spell_chain(spell_id: u32) -> SpellChain { SpellChain { spell_id, @@ -389,10 +350,6 @@ fn no_such_column(table: &str, column: &str) -> String { format!("`{table}` has no claimable column `{column}`") } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -405,8 +362,6 @@ mod tests { const A_RANK: &str = r#"{"rank":{"type":"u8","value":3}}"#; const A_LEARN: &str = r#"{"learn_spell":{"type":"u32","value":168}}"#; - /// The Package spell range is cleared on every apply, so tuning a rank link for a Package spell - /// nobody enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_spells_rank_link_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -429,8 +384,6 @@ mod tests { assert!(!updates_an_uninvented_package_row(&plan.rows[0])); } - /// `game_spell_learn` has a band of its own, so a Package spell identifier on it is a real - /// imported row rather than a Package one. #[test] fn the_dependent_table_reconciles_against_its_own_band_not_the_spell_band() { assert!(is_package_row(ClaimTable::SpellLearn, PACKAGE_SPELL_LEARN)); @@ -474,8 +427,6 @@ mod tests { assert_eq!(chain.rank, 2); } - /// The Claim schema and the setters above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_spell_metadata_column_has_a_setter() { let mut chain = blank_spell_chain(PACKAGE_SPELL); diff --git a/module/src/package_import/trainers.rs b/module/src/package_import/trainers.rs index ba156febb..383a77973 100644 --- a/module/src/package_import/trainers.rs +++ b/module/src/package_import/trainers.rs @@ -1,21 +1,3 @@ -//! The trainers Import Family's half of a Package Delta apply: the setters for -//! `game_trainer_spell`, and how to find one row of it. -//! -//! The shared shell in the parent module decides everything a family does not: what the plan is, -//! what refuses it, the order of the durable pass, and the provenance. What is here is only what is -//! true of trainers. -//! -//! Trainers own exactly one table, the loot shape: `game_trainer_spell.id` is a plain surrogate, -//! its own Package band (`is_package_trainer_id`). Unlike every other reference this crate checks, -//! `spell_id` is NOT always a `game_spell` row: a profession offering (`learn_skill_line > 0`) -//! carries a synthetic marker `spell_id` (`module/src/skill.rs`'s `LEARN_COOKING_SPELL_ID` and -//! siblings) that the buy reducer routes around `game_spell` entirely -//! (`crate::skill::learn_profession`). [`check_references`] gates the `game_spell` check on -//! `learn_skill_line == 0` for exactly that reason. -//! -//! The Claim schema and the setter below are one contract; the tests at the bottom fail if a -//! claimable column has no setter. - use spacetimedb::{ReducerContext, Table}; use lyracore_package_delta::{is_package_trainer_id, FieldValue, Operation, TracedRow}; @@ -25,10 +7,7 @@ use crate::{game_creature_template, game_spell}; use super::{as_u32, as_u8, check_insert_is_whole, UpdateTarget}; -/// Refuses a trainer claim whose final row would point at data this Shard does not hold. -/// -/// `spell_id` is checked against `game_spell` only when the row's final `learn_skill_line` is 0: a -/// profession offering's `spell_id` is a marker, never a real spell. +// Profession offerings carry synthetic spell ids, so only class offerings join game_spell. pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Result<(), String> { for row in rows { let trainer_entry = final_u32(ctx, row, "trainer_entry")?; @@ -54,9 +33,6 @@ pub(super) fn check_references(ctx: &ReducerContext, rows: &[TracedRow]) -> Resu Ok(()) } -/// The value `field` will hold once this row's claim lands: the claimed value if the claim sets -/// it, otherwise what the Shard already holds. An update that changes only one column is judged on -/// what the row will hold after the apply, not on the column alone. fn final_u32(ctx: &ReducerContext, row: &TracedRow, field: &str) -> Result { if let Some(FieldValue::U32(value)) = row.fields().get(field).map(|claimed| &claimed.value) { return Ok(*value); @@ -89,7 +65,6 @@ fn missing_reference(row: &TracedRow, field: &str, value: u32) -> String { ) } -/// What this shard holds for the row an `update` claim names. pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarget { if updates_an_uninvented_package_row(row) { return UpdateTarget::Uninvented; @@ -107,16 +82,10 @@ pub(super) fn update_target(ctx: &ReducerContext, row: &TracedRow) -> UpdateTarg } } -/// True when a traced update would land on a Package-range row that no enabled Package invents. -/// -/// The Package trainer range is cleared on every apply, so such a row is gone by the time the -/// write pass runs. fn updates_an_uninvented_package_row(row: &TracedRow) -> bool { row.operation() == Operation::Update && is_package_trainer_id(row.row_id()) } -/// Removes every row a Package invented. A whole-table pass over `game_trainer_spell`, once per -/// import, right after the base import rewrote it anyway. pub(super) fn clear_package_range(ctx: &ReducerContext) { let rows = ctx.db.game_trainer_spell(); let stale: Vec = rows @@ -153,13 +122,6 @@ pub(super) fn write_row(ctx: &ReducerContext, row: &TracedRow) -> Result<(), Str Ok(()) } -// =========================================================================================== -// Pure row building. -// =========================================================================================== - -/// A Package trainer offering before any claim has been applied to it, defaulted the same way the -/// Module's own auto-migration defaults a class-spell row: `learn_skill_line` 0 (not a profession), -/// `learn_skill_cap` 75 (Apprentice, only meaningful once `learn_skill_line` is set). fn blank_trainer_spell(id: u64) -> TrainerSpell { TrainerSpell { id, @@ -202,10 +164,6 @@ fn built_trainer_spell(row: &TracedRow) -> Result { Ok(trainer) } -// =========================================================================================== -// Pure tests. Row WRITING needs a live ReducerContext, which a native test has no way -// to build (same limit `import_meta`'s tests note); the wire suite covers that rung. -// =========================================================================================== #[cfg(test)] mod tests { use super::super::fixtures::{ @@ -215,8 +173,6 @@ mod tests { use super::*; use lyracore_package_delta::Table as ClaimTable; - /// The Package trainer range is cleared on every apply, so tuning a Package trainer row nobody - /// enables is a plan that names a row which will not exist. #[test] fn tuning_a_package_trainer_spell_no_enabled_package_inserts_is_refused() { let plan = plan(&[artifact( @@ -265,8 +221,6 @@ mod tests { assert_eq!(trainer.learn_skill_cap, 75); } - /// The Claim schema and the setter above are one contract. A column the schema declares but no - /// setter names would fail an apply against a live shard and nowhere else. #[test] fn every_claimable_trainer_spell_column_has_a_setter() { let mut trainer = blank_trainer_spell(PACKAGE_TRAINER_SPELL); diff --git a/module/src/package_teardown.rs b/module/src/package_teardown.rs index 3916a0d45..9e2baa9c8 100644 --- a/module/src/package_teardown.rs +++ b/module/src/package_teardown.rs @@ -1,14 +1,3 @@ -//! Package Teardown: the Operator step that stops one Package on one Shard before the Package -//! leaves the build. -//! -//! A publish that removes a table refuses while the table holds rows. So before a Package's folder -//! moves, its tables must be empty, and its code must not write them again before the publish. -//! Teardown empties every table the Package declares, deletes its Package Config, and makes its -//! Characters Dormant Characters. It then records the Package as torn down, which stops every -//! registration the Package made (`build.rs` wraps each one in [`runs`]). The record goes away by -//! itself on the first tick of a build that no longer compiles the Package, so enabling the Package -//! again starts it fresh. - use spacetimedb::{reducer, table, ReducerContext, Table}; use crate::package_account::game_package_account; @@ -17,31 +6,24 @@ use crate::sessionless::game_sessionless_action_consent; use crate::transfer::{game_bot_transfer_intent, game_transfer_in, game_transfer_out}; use crate::{game_character, game_world_entity}; -/// One row per Package torn down on this Shard. Private. [server] #[table(accessor = game_package_teardown)] pub struct PackageTeardown { #[primary_key] pub package_name: String, } -/// One compiled Package, as `build.rs` generates it into `GAME_PACKAGES`. pub struct InstalledPackage { - /// The Package's Rust identifier: its folder name with `-` folded to `_`. pub name: &'static str, - /// Every table the Package declares. Debug-only tables are absent from a release build. pub tables: &'static [&'static str], - /// The Package's `game_package_characters!` read, when it registers one. pub characters: Option Vec>, } -/// Whether a Package's registered code may run on this Shard. -#[cfg_attr(not(has_packages), allow(dead_code))] // package-only caller — see `build.rs` +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn runs(ctx: &ReducerContext, package: &str) -> bool { let torn_down = ctx.db.game_package_teardown(); torn_down.count() == 0 || torn_down.package_name().find(package.to_string()).is_none() } -/// Forget the teardown of a Package this build no longer compiles. Runs every scheduler tick. pub(crate) fn forget_removed_packages(ctx: &ReducerContext) { let torn_down = ctx.db.game_package_teardown(); if torn_down.count() == 0 { @@ -57,9 +39,6 @@ pub(crate) fn forget_removed_packages(ctx: &ReducerContext) { } } -/// Tear down `package_name` on this Shard. Idempotent; run it on every Shard of the Realm, then -/// once more on each, so a Character that crossed into an already torn-down Shard is caught. A -/// Refusal writes nothing. #[reducer] pub fn teardown_package(ctx: &ReducerContext, package_name: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -75,7 +54,7 @@ pub fn teardown_package(ctx: &ReducerContext, package_name: String) -> Result<() )); } - // First, so the Package's own hooks stay silent while its Characters leave the world. + // Silence Package hooks before its Characters leave the world. if ctx .db .game_package_teardown() @@ -105,7 +84,6 @@ pub fn teardown_package(ctx: &ReducerContext, package_name: String) -> Result<() Ok(()) } -/// The Characters on Accounts the Package owns here, plus the ones it names itself. fn package_characters( ctx: &ReducerContext, package: &InstalledPackage, @@ -131,8 +109,7 @@ fn package_characters( guids } -/// An escrow row or a claimed Transfer Intent. The escrow blob names the Package's tables, so it -/// must settle while those tables still exist. +// Settle Escrow before removing the Package tables named in its blob. fn crossing(ctx: &ReducerContext, guid: u64) -> bool { ctx.db .game_transfer_out() @@ -155,8 +132,6 @@ fn crossing(ctx: &ReducerContext, guid: u64) -> bool { .any(|intent| intent.claim_token != 0) } -/// Offline, with no live entity, no consent and no pending Intent. The Account and Character rows -/// stay. A Character with a World Session is a player's, so it is left alone. fn make_dormant(ctx: &ReducerContext, guid: u64) { if crate::helpers::character_by_guid(ctx, guid).is_none_or(|character| character.online) { return; @@ -175,7 +150,6 @@ fn make_dormant(ctx: &ReducerContext, guid: u64) { } } -/// Empty one table by name. A table this build does not compile has nothing to empty. fn clear_table(name: &str) -> Result<(), String> { use spacetimedb::sys::{datastore_clear, table_id_from_name, Errno}; match table_id_from_name(name) { diff --git a/module/src/package_test.rs b/module/src/package_test.rs index f71b228b6..736bb9e1b 100644 --- a/module/src/package_test.rs +++ b/module/src/package_test.rs @@ -1,17 +1,11 @@ #![cfg_attr(not(has_packages), allow(dead_code, unused_imports))] -//! Steps a Package's own unit tests need. The root exists only under `cfg(test)`, and the Package -//! API lint accepts it only in a Package file gated on `#![cfg(test)]`. - use crate::runtime_script::{ask_event, EffectSink, RuntimeScriptHost, ScriptEvent}; pub(crate) use crate::runtime_script::{EntityView, RuntimeScript}; pub(crate) use crate::test_scan::read_scanned; pub(crate) use lyracore_test_support::source_scan::{code_of, shape_of}; -/// Run `scripts` in order for one event on a fresh Runtime Script Host and return the Script -/// Answer, read as `script_binding::ask` reads it. Staged Effects are discarded. Any Script -/// Diagnostic is an error, one diagnostic per line. pub(crate) fn ask_offline( event: &str, actor: Option, @@ -41,8 +35,6 @@ pub(crate) fn ask_offline( } } -/// Parse a Script Artifact and ask its enabled Event Bindings in priority and identifier order. -/// Staged Effects are discarded. Parse failures and Script Diagnostics return their text. pub(crate) fn ask_artifact_offline( event: &str, actor: Option, diff --git a/module/src/professions.rs b/module/src/professions.rs index 86c301871..dbbaf6ee0 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -1,17 +1,3 @@ -//! Profession reducers that are feature logic layered OVER a corpse or gear slot, not loot-table -//! machinery themselves: SKINNING (a skill-gated corpse interaction modeled on -//! `loot::loot_money`), FISHING (an immediate-catch cast), and ENCHANTING (the per-instance -//! `enchant_id` overlay — which never touches a corpse at all, the reason this split exists). -//! -//! All three still call back into `crate::loot` as a normal cross-module dependency: the shared -//! roll core (`loot::roll_loot_rows`) and the `game_skinning_loot`/`game_fishing_loot` tables stay -//! there as DATA, alongside the other loot-family tables (`game_creature_loot`, -//! `game_pickpocket_loot`, `game_gameobject_loot`) — this module is the feature/reducer layer on -//! top, split out so it stops inflating `loot.rs`. Pure code motion from `loot.rs`; every gate and -//! grant is byte-identical. Old `crate::loot::skin_corpse`/`apply_fish`/`apply_disenchant`/ -//! `apply_enchant_item`/`entry_is_beast` call sites (debug.rs's twins) keep compiling unchanged — -//! `loot.rs` re-exports this module's public surface. - use spacetimedb::ReducerContext; use crate::game_creature_template; @@ -23,30 +9,10 @@ use crate::game_skinning_loot; use crate::game_world_entity; use crate::loot::LOOT_RANGE_SQ; -// =========================================================================================== -// SKINNING — a skill-gated corpse interaction, modeled on `loot_money` -// =========================================================================================== - -/// The leather a skin yields — the REAL vanilla "Light Leather" `game_item_template` TRADE_GOODS entry -/// (2318); the slice mints a flat 1× per skin (a `game_skinning_loot` -/// table keyed on skill→tier is the DEFERRED real model — noted like the `RECIPES` map's -/// single-reagent-per-recipe shortcut). -pub(crate) const LEATHER_ENTRY: u32 = 2318; // "Light Leather" (real imported item) -/// Flat leather count per skin for the slice (DEFERRED: a skill→count table). +pub(crate) const LEATHER_ENTRY: u32 = 2318; pub(crate) const LEATHER_COUNT: u32 = 1; -// Pure predicate over every gate a skinning attempt must pass, argument-shaped so it is unit-testable without a `ReducerContext`. #[allow(clippy::too_many_arguments)] -/// The pure SKIN gate: may a `corpse` be skinned by a looter on `same_map`, in range? Decides on the -/// same primitives `loot_money` checks plus the beast/already-skinned markers — factored out (like -/// `loot::loot_drops`/`loot::group_pick`) so the gate is unit-testable without a live `ReducerContext`. -/// Returns the reject reason, or `Ok(())` to proceed. (Distance is the already-computed squared value -/// vs `LOOT_RANGE_SQ`.) -/// -/// `learned_skinning` is the character's current Skinning skill value (0 = not learned — no skill row), -/// mirroring `gameobject::can_gather`. `creature_level` is the corpse's level. Vanilla requires -/// Skinning trained AND skill >= (creature_level - 1) * 10, so level-1 beasts are always skinnable -/// with Apprentice Skinning (any skill ≥ 1). pub(crate) fn can_skin( looter_dead: bool, corpse_is_player: bool, @@ -79,10 +45,6 @@ pub(crate) fn can_skin( if already_skinned { return Err("already skinned".to_string()); } - // Skill gate: must have trained Skinning AND skill >= (creature_level - 1) * 10. - // Level-1 beasts require 0 skill so a freshly-trained skinner (skill=1) can immediately skin - // them, matching vanilla Apprentice Skinning. Level-2 beasts require 10, level-3 require 20, - // etc. saturating_sub guards against a hypothetical level-0 creature (maps to 0 required). if learned_skinning == 0 { return Err("you have not learned Skinning".to_string()); } @@ -95,9 +57,6 @@ pub(crate) fn can_skin( Ok(()) } -/// Is `entry`'s creature a BEAST (creature_type == 1)? The skinnable gate — reads the imported -/// `game_creature_template.creature_type` (loaded verbatim by the importer). A missing template (no row) -/// is not a beast (false) — a corpse with no template can't be skinned, never panics. pub(crate) fn entry_is_beast(ctx: &ReducerContext, entry: u32) -> bool { ctx.db .game_creature_template() @@ -106,16 +65,6 @@ pub(crate) fn entry_is_beast(ctx: &ReducerContext, entry: u32) -> bool { .is_some_and(|t| t.creature_type == crate::spell::BEAST_TYPE) } -/// SKIN a beast corpse: the core (resolved guids), modeled on `loot_money`. Requires the player to have -/// TRAINED Skinning at a trainer AND have skill >= corpse_level * 10 (vanilla floor), mirroring the -/// `can_gather` gate. Grants leather, climbs the Skinning line +1, and marks the corpse skinned so it -/// can't be re-skinned. Shared by the debug lever (`debug_skin_nearest`) and a future `CMSG`-routed -/// skin over the open corpse. The skinner must be Loot Tag eligible, and all corpse money and item rows -/// must already be gone. Returns the Refusal reason on any Gate failure, or `Ok` after the grant. -/// -/// ROLLBACK: the leather grant (`grant_item`) is `?`-propagated — a full bag returns `Err` BEFORE the -/// `skinned` marker is set, so the reducer tx rolls back and the corpse stays skinnable (no leather is -/// minted on a full bag, and the player can retry), exactly like the COOKING reagent gate. pub(crate) fn skin_corpse( ctx: &ReducerContext, looter_guid: u64, @@ -132,12 +81,9 @@ pub(crate) fn skin_corpse( .ok_or_else(|| "no such corpse".to_string())?; crate::loot::corpse_access_gate(ctx, looter_guid, corpse_guid)?; - // All gates (the pure decision shared with the unit test): looter alive, a dead non-player BEAST - // corpse, same map, in range, not already skinned, trained Skinning + skill >= level*10. let dist_sq = (corpse.x - looter.x).powi(2) + (corpse.y - looter.y).powi(2) + (corpse.z - looter.z).powi(2); - // Learned Skinning skill (0 = not trained — no skill row), mirroring the `can_gather` gather gate. let learned_skinning = ctx .db .game_player_skill() @@ -150,8 +96,6 @@ pub(crate) fn skin_corpse( looter.dead, corpse.is_player(), corpse.dead, - // Map + instance: `can_skin`'s `same_map` conjunct now carries the - // instance-equality clause too — the corpse is a `game_world_entity` row, instance-tagged. corpse.map_id == looter.map_id && corpse.instance_id == looter.instance_id, dist_sq, entry_is_beast(ctx, corpse.entry), @@ -163,14 +107,6 @@ pub(crate) fn skin_corpse( return Err("corpse still has loot".to_string()); } - // Grant leather, DATA-DRIVEN: the corpse's creature template names a - // `skin_loot_id` (cmangos `skinning_loot_template`, level-banded across many creatures sharing a - // band); roll it and grant every winner. `skin_loot_id == 0` (unimported / a seeded/test beast) OR - // an empty/no-win roll falls back to the flat `LEATHER_ENTRY`/`LEATHER_COUNT` — byte-identical to - // a plain flat drop, so a skin never comes up empty-handed. `?`-rollback: ANY winner's `grant_item` - // failing (full bag) rolls back the WHOLE skin BEFORE the corpse is marked skinned (the reducer tx - // rolls the whole call back), so no partial skin — retry after freeing space, exactly the COOKING - // reagent-gate rollback shape. let skin_loot_id = ctx .db .game_creature_template() @@ -197,62 +133,25 @@ pub(crate) fn skin_corpse( } } - // Skill-up — the COOKING skill-up hook reused verbatim (no-op if not learned / at the cap; - // the gate above already confirmed learned_skinning > 0, so the row exists). The DEFAULT sentinel band - // (orange=1, gray=0 ⇒ always +1) keeps skinning byte-identical to the pre-difficulty slice — a real - // Skinning difficulty table (gray>0) is DEFERRED. crate::skill::gain_profession_skill(ctx, looter_guid, crate::skill::skill_line::SKINNING, 1, 0); - // Mark skinned so a second skin is rejected (the gate above). Reset for free on respawn - // (`build_creature_entity`). The corpse decays normally. corpse.skinned = true; entities.guid().update(corpse); Ok(()) } -// =========================================================================================== -// FISHING (completing the 13) — a bounded new gather mechanic: an immediate-catch reducer modeled on -// `skin_corpse` (auto-learn → grant a fish → climb the line). No bobber/cast/timer (DEFERRED), and a -// LENIENT water gate (alpha: just alive) — the real near-water LiquidType check is a follow-up. [entity] -// =========================================================================================== - -/// The small-fish FALLBACK pool — REAL low-level cmangos fish `game_item_template` entries (standard -/// trade-goods present in the imported box). Used when a cast's zone has NO `game_fishing_loot` rows -/// (unimported, or off-slice), since the real per-zone table (`game_fishing_loot`) is -/// tried FIRST; this is the "never comes up empty-handed" floor beneath it (mirrors the skinning -/// `LEATHER_ENTRY` fallback). If a fish entry isn't in the imported `item_template`, `grant_item` -/// returns Err and the whole `fish` rolls back (no half-catch) — so verify these three are in-box. -pub(crate) const FISH_POOL: &[u32] = &[ - 6291, // Raw Brilliant Smallfish - 6303, // Raw Slitherskin Mackerel - 6289, // Raw Longjaw Mud Snapper -]; +pub(crate) const FISH_POOL: &[u32] = &[6291, 6303, 6289]; -/// Pure choice of the CAUGHT fish: the zone table's `winner` if the roll produced one, else a -/// deterministic `FISH_POOL` pick via `pool_idx` (the caller passes an already-reduced -/// `ctx.random::()`, so this stays a pure index lookup). Covers BOTH fallback triggers — no zone -/// rows at all, or a zone table that rolled nothing this cast — with the SAME floor, so a cast never -/// comes up empty-handed. Split out so the fallback decision is unit-testable. pub(crate) fn pick_caught_fish(winner: Option, pool_idx: usize) -> u32 { winner.unwrap_or_else(|| FISH_POOL[pool_idx % FISH_POOL.len()]) } -/// FISH at the player's current spot: the core (resolved guid), modeled on `skin_corpse`. LENIENT alpha -/// gate (alive only — the near-water LiquidType check is DEFERRED). Auto-learns Fishing on the first cast -/// (idempotent, born 1/75 like skinning), resolves the caster's ZONE (`terrain::zone_id_at`) and rolls -/// `game_fishing_loot` for it — falling back to the flat `FISH_POOL` when the zone is unresolved, has no -/// rows, or the roll lands on nothing (`pick_caught_fish`) — grants the caught fish, climbs the Fishing -/// line +1 (the default sentinel band ⇒ deterministic +1). Shared by the `fish` player reducer + the -/// `debug_fish` twin. ROLLBACK: the `grant_item` is `?`-propagated — a full bag returns Err BEFORE the -/// skill-up commits (the tx rolls back), so no fish is minted on a full bag and the skill doesn't climb. pub(crate) fn apply_fish(ctx: &ReducerContext, guid: u64) -> Result<(), String> { let player = crate::helpers::live_entity(ctx, guid).map_err(|_| "fisher not in world".to_string())?; if player.dead { return Err("dead players cannot fish".to_string()); } - // AUTO-LEARN Fishing on the first cast (idempotent — a re-cast never resets a climbed value), so the - // skill-up hook has a row to climb. The very first cast is born 1/75; this cast's +1 takes it to 2/75. crate::skill::learn_profession( ctx, guid, @@ -260,11 +159,6 @@ pub(crate) fn apply_fish(ctx: &ReducerContext, guid: u64) -> Result<(), String> crate::skill::skill_line::FISHING, crate::skill::APPRENTICE_CAP, ); - // Zone-keyed catch (resolver hoisted to `terrain::zone_id_at`): resolve the - // caster's zone and roll its real table; an unresolved zone (unimported terrain/AreaTable, or - // off-slice) rolls an empty Vec, which `roll_loot_rows` correctly turns into zero winners — - // `pick_caught_fish` then floors to the flat pool either way. `?`-rollback: a full bag fails here - // BEFORE the skill-up, so nothing is minted and the skill doesn't climb (retry after freeing space). let rows: Vec<(u32, u32, u32, u32)> = crate::terrain::zone_id_at(ctx, player.map_id, player.x, player.y) .map(|zone| { @@ -282,30 +176,13 @@ pub(crate) fn apply_fish(ctx: &ReducerContext, guid: u64) -> Result<(), String> .map(|(item, _)| item); let fish = pick_caught_fish(winner, ctx.random::()); crate::items::grant_item(ctx, guid, fish, 1)?; - // Skill-up — the same hook skinning/cooking reuse. DEFAULT sentinel band (orange=1, gray=0 ⇒ always +1) - // keeps fishing on the byte-identical deterministic path; a real Fishing difficulty table is DEFERRED. crate::skill::gain_profession_skill(ctx, guid, crate::skill::skill_line::FISHING, 1, 0); Ok(()) } -// =========================================================================================== -// ENCHANTING (completing the 13) — two reducers over the per-instance enchant overlay (`enchant_id` on -// ItemInstance + the `game_item_enchantment` catalogue). `disenchant` consumes an equipped item → mats + -// skill; `enchant_item` stamps an enchant id onto an equipped instance (server-real via the -// effective-* pipeline). The client glow/green-text is DEFERRED (by-entry item-stat cache). [entity] -// =========================================================================================== - -/// The mats a disenchant yields — REAL "Strange Dust" `game_item_template` entry (10940, the low-level -/// enchanting reagent, present in the imported box). The alpha mints a flat 1× per disenchant; a real -/// quality/ilvl→mats table is DEFERRED (the skinning `LEATHER_ENTRY` single-yield shortcut, again). -pub(crate) const DISENCHANT_MATS_ENTRY: u32 = 10940; // "Strange Dust" +pub(crate) const DISENCHANT_MATS_ENTRY: u32 = 10940; pub(crate) const DISENCHANT_MATS_COUNT: u32 = 1; -/// Disenchant/enchant apply only to GEAR (weapon or armor class) — not trade goods, reagents, or stacks. -/// Gates the "disenchant a stack of 20 ore for 1 dust" foot-gun AND the "enchant the dust slot" stale-write -/// (with the target forced to gear, the mats-consume can never touch it). Deliberate -/// simplification: class gate only; the quality≥uncommon gate stays deferred (any weapon/armor -/// disenchants in the alpha). fn require_gear(ctx: &ReducerContext, entry: u32) -> Result<(), String> { const ITEM_CLASS_WEAPON: u8 = 2; const ITEM_CLASS_ARMOR: u8 = 4; @@ -323,11 +200,6 @@ fn require_gear(ctx: &ReducerContext, entry: u32) -> Result<(), String> { Ok(()) } -/// DISENCHANT the item in `slot`: consume it (delete the instance), grant enchanting mats, climb -/// Enchanting +1. The core (resolved guid), shared by the `disenchant` reducer + `debug_disenchant` twin. -/// GATE: alive + a weapon/armor item present in `slot` (see `require_gear`). Auto-learns Enchanting on the -/// first disenchant (idempotent). ROLLBACK: `grant_item` is `?`-propagated BEFORE the instance is deleted, -/// so a full bag fails the whole tx and the item is NOT consumed (no item destroyed without delivering mats). pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Result<(), String> { let player = crate::helpers::live_entity(ctx, guid).map_err(|_| "enchanter not in world".to_string())?; @@ -341,8 +213,7 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res lyracore_shared::item::ItemRefusal::NotRightNow, )); } - require_gear(ctx, inst.entry)?; // weapon/armor only — never destroy a trade-good stack for 1 dust - // AUTO-LEARN Enchanting on the first disenchant (idempotent), giving the skill-up hook a row to climb. + require_gear(ctx, inst.entry)?; crate::skill::learn_profession( ctx, guid, @@ -350,15 +221,10 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res crate::skill::skill_line::ENCHANTING, crate::skill::APPRENTICE_CAP, ); - // Grant mats FIRST (the `?`-rollback point): a full bag fails here BEFORE the item is consumed, so a - // failed disenchant destroys nothing and grants nothing (the tx rolls back) — retry after freeing space. + // Grant materials before consuming the item so a full bag destroys nothing. crate::items::grant_item(ctx, guid, DISENCHANT_MATS_ENTRY, DISENCHANT_MATS_COUNT)?; - // Consume the disenchanted item (delete the instance) and climb the line. After the grant succeeded, so - // the delete + skill-up only run on a delivered disenchant. ctx.db.game_item_instance().guid().delete(inst.guid); crate::skill::gain_profession_skill(ctx, guid, crate::skill::skill_line::ENCHANTING, 1, 0); - // If the disenchanted item was EQUIPPED gear (slot 0..=18), its stats just left the body — re-derive - // max HP/mana (a disenchanted +Sta piece shrinks the health bar). A bag item (>18) touches no equip slot. if slot <= crate::items::equip_slot::END { crate::spell::recompute_vitals(ctx, guid); crate::spell::recompute_sheet(ctx, guid); @@ -366,14 +232,6 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res Ok(()) } -/// ENCHANT the item in `target_slot` with `enchant_id`: validate the id (it must be a known enchant in -/// `game_item_enchantment`), consume the enchanting mats, stamp `enchant_id` onto the instance, climb Enchanting. -/// The core (resolved guid), shared by the `enchant_item` reducer + `debug_enchant_item` twin. The enchant -/// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id, ..)`, so an equipped enchanted -/// piece moves the effective-* readouts (swing/dodge/armor/crit/hit + max HP/mana via recompute_vitals). -/// The client glow/green-text is DEFERRED (the 5875 client caches item stats by ENTRY). ROLLBACK: -/// `remove_items` (the mats consume) is `?`-propagated BEFORE the instance is updated, so a missing-mats -/// enchant changes nothing. pub(crate) fn apply_enchant_item( ctx: &ReducerContext, guid: u64, @@ -385,24 +243,18 @@ pub(crate) fn apply_enchant_item( if player.dead { return Err("dead players cannot enchant".to_string()); } - // The enchant id must be a known, applyable enchant — a client can't stamp an arbitrary id. if !crate::items::is_known_enchant(ctx, enchant_id) { return Err(format!("unknown enchant {enchant_id}")); } let mut inst = crate::items::item_in_slot(ctx, guid, target_slot) .ok_or_else(|| format!("no item in slot {target_slot}"))?; - require_gear(ctx, inst.entry)?; // weapon/armor only — also keeps the target ≠ the mats (no stale write) - // Consume the enchanting mats FIRST (the `?`-rollback point): not enough Strange Dust → Err here, - // BEFORE the instance is stamped, so the item is unchanged and no skill is gained. + require_gear(ctx, inst.entry)?; crate::items::remove_items(ctx, guid, DISENCHANT_MATS_ENTRY, DISENCHANT_MATS_COUNT)?; let instances = ctx.db.game_item_instance(); inst.enchant_id = enchant_id; instances.guid().update(inst); crate::skill::gain_profession_skill(ctx, guid, crate::skill::skill_line::ENCHANTING, 1, 0); - // If the enchanted item is EQUIPPED gear (slot 0..=18) and the enchant moves a pool stat (Stamina/ - // Intellect), re-derive max HP/mana so the bar grows. `recompute_vitals` is a no-op when the derived - // max is unchanged (e.g. a +Strength weapon enchant), so this is safe to call unconditionally for gear. if target_slot <= crate::items::equip_slot::END { crate::spell::recompute_vitals(ctx, guid); crate::spell::recompute_sheet(ctx, guid); @@ -414,22 +266,12 @@ pub(crate) fn apply_enchant_item( mod tests { use super::*; - // ---- SKINNING ---- - - /// The SKIN gate: every reject condition fails with its reason, and ONLY an alive looter at a dead, - /// in-range, same-map BEAST corpse that isn't already skinned proceeds (with sufficient Skinning skill). - /// This is the pure decision the live `skin_corpse` keys all its rejects off (mirrors `loot_money`'s - /// gate, plus beast + skinned + skill checks). #[test] fn skin_gate_admits_only_a_fresh_in_range_beast_corpse() { - // Helpers: learned=1 (freshly trained), creature_level=1 (requires (1-1)*10=0) — the baseline pass. let learned: u32 = 1; - let lvl: u32 = 1; // required = (1-1)*10 = 0 <= learned → passes + let lvl: u32 = 1; - // The all-pass baseline: alive looter, dead non-player beast, same map, in range, not skinned, - // trained Skinning at sufficient skill. assert!(can_skin(false, false, true, true, 0.0, true, false, learned, lvl).is_ok()); - // In range at exactly the cap (10 yd)² is still OK (inclusive, matching loot_money's `>`). assert!(can_skin( false, false, @@ -443,7 +285,6 @@ mod tests { ) .is_ok()); - // Each reject, in the order the gate checks them: assert_eq!( can_skin(true, false, true, true, 0.0, true, false, learned, lvl).unwrap_err(), "dead players cannot skin" @@ -475,29 +316,23 @@ mod tests { .unwrap_err(), "corpse out of range" ); - // NON-BEAST rejection (e.g. the demo Chicken, creature_type 8 → is_beast=false). assert_eq!( can_skin(false, false, true, true, 0.0, false, false, learned, lvl).unwrap_err(), "not a beast — cannot be skinned" ); - // RE-SKIN rejection (already_skinned=true) — the marker that stops a second skin of the same corpse. assert_eq!( can_skin(false, false, true, true, 0.0, true, true, learned, lvl).unwrap_err(), "already skinned" ); - // NOT TRAINED: learned=0 → "you have not learned Skinning". assert_eq!( can_skin(false, false, true, true, 0.0, true, false, 0, lvl).unwrap_err(), "you have not learned Skinning" ); - // SKILL TOO LOW: learned=5, creature_level=2 (requires (2-1)*10=10) → rejected. assert_eq!( can_skin(false, false, true, true, 0.0, true, false, 5, 2).unwrap_err(), "your Skinning skill (5) is too low — need 10" ); - // BOUNDARY: learned exactly equals the requirement → admitted (lvl=2 → required=10). assert!(can_skin(false, false, true, true, 0.0, true, false, 10, 2).is_ok()); - // Higher-level creature: creature_level=3 requires (3-1)*10=20; learned=19 rejected, 20 admitted. assert_eq!( can_skin(false, false, true, true, 0.0, true, false, 19, 3).unwrap_err(), "your Skinning skill (19) is too low — need 20" @@ -505,9 +340,6 @@ mod tests { assert!(can_skin(false, false, true, true, 0.0, true, false, 20, 3).is_ok()); } - /// Renamed (was `skinning_yields_one_light_leather_2318` — "yields" oversold a behavioral check when - /// this only guards the constants): the leather product is the REAL "Light Leather" TRADE_GOODS entry - /// (2318), minted a flat 1 per skin (the skill→count table is DEFERRED). A wire-facing drift guard. #[test] fn skin_leather_constants_are_the_real_light_leather_2318_flat_one() { assert_eq!(LEATHER_ENTRY, 2318); @@ -527,9 +359,6 @@ mod tests { ); } - /// `pick_caught_fish` — the fallback floor: a real winner always wins; `None` (no zone rows, or a - /// zone table that rolled nothing) floors to `FISH_POOL` at the given index, wrapping via `%` so an - /// out-of-range `pool_idx` (a raw `ctx.random::()`) never panics. #[test] fn pick_caught_fish_prefers_the_winner_else_floors_to_the_pool() { assert_eq!( diff --git a/module/src/publish_safety.rs b/module/src/publish_safety.rs index fafecf5af..fed137bb6 100644 --- a/module/src/publish_safety.rs +++ b/module/src/publish_safety.rs @@ -1,53 +1,21 @@ -//! Deploy-safety scans for destructive publish flags and schema field-name collisions. -//! Each scan includes a sanity floor so matching nothing cannot silently pass. - use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; use crate::test_scan::repo_root; -/// The literal we treat as "this line names a publish invocation". -/// -/// Ceiling: a line that reaches the CLI some other way (`$SPACETIME publish`, `spacetime` with two -/// spaces before `publish`, an `xargs`-assembled command) is invisible to a text scan. The tripwire's -/// job is to make the LAZY path — copy the command from a neighbouring script, add a flag — fail, -/// not to be a sound analysis of shell. const PUBLISH_NEEDLE: &str = "spacetime publish"; -/// Flags that make `spacetime publish` data-destructive. -/// -/// `spacetime publish --help` is not reachable from an offline test (and this module must never -/// shell out to the CLI), so the list is literal and deliberately over-broad rather than derived: -/// - `-c` is the short form documented all over this repo (`docs/danger-zones.md` and the -/// sanctioned deploy script's own header) as THE destructive wipe. -/// - `--clear-database` is its long form. -/// - `--delete-data` is the adjacent spelling the CLI has used across versions; scanned so a -/// version bump that renames the flag cannot quietly re-open the hole. -/// -/// Short flags are matched as a CLUSTER (`-cy` is `-c -y` to any clap-based CLI), so a bundle -/// cannot smuggle the wipe past a scan looking for a bare `-c`. const DESTRUCTIVE_LONG_FLAGS: &[&str] = &["--clear-database", "--delete-data"]; -/// Strip a `#`-comment from one line of shell (or Python), respecting single- and double-quoted -/// strings so a `#` inside one survives. -/// -/// Two shell rules this must get right, both load-bearing on the real files: -/// - `#` only opens a comment at the start of a WORD. `[ ${#dbs[@]} -eq 0 ]` in -/// the sanctioned deploy script is code, not a comment, and a naive scanner truncates that script -/// at line 30 — losing the publish line entirely and passing vacuously. -/// - inside `'...'` or `"..."` a `#` is literal, so a documented command in an `echo` keeps its text. -/// -/// Backslash escapes the next byte everywhere except inside single quotes, matching sh. Python's -/// triple-quoted strings are not modelled; the worst case there is truncating a docstring early, -/// which can only remove text from the scan's view on lines that are documentation anyway. +// Shell # opens a comment only at a word start; parameter-length expressions remain code. fn strip_shell_comment(line: &str) -> &str { let bytes = line.as_bytes(); let mut quote: Option = None; let mut i = 0; while i < bytes.len() { match bytes[i] { - b'\\' if quote != Some(b'\'') => i += 1, // skip whatever the backslash escapes + b'\\' if quote != Some(b'\'') => i += 1, c @ (b'"' | b'\'') => match quote { None => quote = Some(c), Some(q) if q == c => quote = None, @@ -65,9 +33,6 @@ fn strip_shell_comment(line: &str) -> &str { line } -/// One whitespace-token off a command line is a destructive publish flag. -/// -/// Surrounding quote/punctuation is trimmed first so `"-c"` and `-c;` are not a way through. fn is_destructive_flag(raw: &str) -> bool { let token = raw.trim_matches(|c: char| c == '"' || c == '\'' || c == ';' || c == ','); if let Some(long) = token.strip_prefix("--") { @@ -77,9 +42,7 @@ fn is_destructive_flag(raw: &str) -> bool { .any(|f| f.trim_start_matches('-') == name); } match token.strip_prefix('-') { - // A short-flag cluster containing `c`. Over-broad ON PURPOSE: a future flag spelled with a - // `c` would fail this test rather than slip past it, and a false RED on a deploy-safety - // tripwire costs one comment, while a false GREEN costs the database. + // Check clusters for c so combined flags cannot bypass destructive-publish checks. Some(short) => { !short.is_empty() && short.chars().all(|c| c.is_ascii_alphabetic()) @@ -89,7 +52,6 @@ fn is_destructive_flag(raw: &str) -> bool { } } -/// Every 1-based line number in `src` whose code (comments stripped) names a publish invocation. fn publish_lines(src: &str) -> Vec { src.lines() .enumerate() @@ -98,14 +60,6 @@ fn publish_lines(src: &str) -> Vec { .collect() } -/// Every `(line number, code)` in `src` where a publish invocation carries a destructive flag. -/// -/// Deliberately does NOT try to tell an executed command from one quoted inside an `echo` or a -/// Python string — several files legitimately DOCUMENT the publish command that way -/// (`importer/scripts/import-world.sh` and other internal tooling). Telling -/// the two apart needs a shell parser and would be the fragile part of this test; instead the rule -/// is simply "no destructive flag next to a publish, anywhere, in any form". Documented commands -/// must not carry `-c` either — somebody will paste them. fn destructive_publish_lines(src: &str) -> Vec<(usize, String)> { let mut out = Vec::new(); for (i, raw) in src.lines().enumerate() { @@ -121,10 +75,6 @@ fn destructive_publish_lines(src: &str) -> Vec<(usize, String)> { out } -/// `src` with comments gone and every whitespace run collapsed to one space, so a block of shell -/// can be compared for EQUALITY (or exact containment) instead of by loose `.contains()` on raw -/// text. Equality is what distinguishes "this guard IS exactly this" from "these words appear -/// somewhere". fn shell_shape(src: &str) -> String { src.lines() .map(strip_shell_comment) @@ -135,26 +85,6 @@ fn shell_shape(src: &str) -> String { .join(" ") } -/// The scan roots, each with its own file FLOOR: `(dir, minimum *.sh/*.py files)`. -/// -/// `adapters/` joined the list, when the wire harness moved to its own repository: the -/// live-test entrypoint that survived here (`adapters/lyracore/run-suite.sh`) shells out against a -/// running stack, which is exactly the shape of script this scan exists to police. `importer/` -/// joined it, when the world-import ETL moved out of `scripts/` and into -/// `importer/scripts/` — it is the same seven scripts, and one of them DOCUMENTS a publish command -/// in its abort message. A root that is not scanned is a root where a `spacetime publish -c` can -/// sit unnoticed. -/// -/// The floors are PER ROOT (they used to be one number, 25, across all four) so that one root -/// going blind can no longer be masked by the other three growing. They still sum to 25. -/// -/// `tools/` and `adapters/` are PRIVATE-ONLY. `scripts/` may contain public acceptance checks -/// without the private deploy wrapper, so it is always scanned when present but has only a -/// one-file floor. `importer/` is a workspace member and always there, which is what keeps this -/// scan from ever looking at nothing. -/// -/// Ceiling: renaming a private-only root wholesale now goes quiet instead of red. Direct safety -/// pins still fail when an installed target is missing. const SCAN_ROOTS: &[(&str, usize)] = &[ ("scripts", 1), ("tools", 9), @@ -162,8 +92,6 @@ const SCAN_ROOTS: &[(&str, usize)] = &[ ("importer", 5), ]; -/// The scan roots this checkout actually has, with their `*.sh` / `*.py` files, sorted for a -/// deterministic report. fn scanned_roots() -> Vec<(&'static str, Vec)> { let root = repo_root(); let mut out = Vec::new(); @@ -191,7 +119,6 @@ fn scanned_roots() -> Vec<(&'static str, Vec)> { out } -/// Every scanned script across every present root, flattened. fn scanned_scripts() -> Vec { scanned_roots().into_iter().flat_map(|(_, f)| f).collect() } @@ -219,7 +146,6 @@ fn collect_scripts(dir: &Path, out: &mut Vec) { } } -/// One pair of Rust fields which SpacetimeDB would publish under the same column name. #[derive(Debug, PartialEq, Eq)] struct SnakeCaseCollision { table: String, @@ -227,9 +153,6 @@ struct SnakeCaseCollision { fields: Vec, } -/// Approximate SpacetimeDB's field-name normalization for the publish preflight. Rust field names -/// are already identifiers, so the dangerous transformations are word boundaries before capitals -/// and digits (`rotation0` -> `rotation_0`) plus repeated underscores. fn publish_column_name(field: &str) -> String { let mut out = String::new(); let mut previous_was_word = false; @@ -253,8 +176,6 @@ fn publish_column_name(field: &str) -> String { out.trim_end_matches('_').to_string() } -/// Blunt source preflight for public `#[table]` structs. This intentionally accepts the table -/// declarations' ordinary one-field-per-line style; it is a deploy tripwire, not a Rust parser. fn table_field_collisions(src: &str) -> Vec { let mut awaiting_table = false; let mut table_attribute_depth = 0i32; @@ -328,10 +249,6 @@ fn table_declaration_count(src: &str) -> usize { .count() } -/// The publish-module.sh flag-rejection loop, verbatim, whitespace-collapsed. Pinned by exact shape -/// rather than by `.contains("-*)")` so that rewording, re-scoping or gutting the guard fails here -/// instead of silently narrowing what it refuses. If you changed this block on purpose, update the -/// constant AND satisfy yourself that a `-c` argument still cannot reach `spacetime publish`. const FLAG_REJECTION_LOOP: &str = "for db in \"${dbs[@]}\"; do \ case \"$db\" in \ -*) echo \"publish-module.sh takes database NAMES, not flags (got '$db'). Refusing.\" >&2; \ @@ -345,8 +262,6 @@ mod tests { #[test] fn snake_case_preflight_names_both_fields_in_a_collision() { - // Assemble the marker so the repo-wide scan below does not mistake this fixture for a real - // module table declaration while reading this source file. let fixture = [ "#[ta", "ble]\npub struct BrokenRotation {\n", @@ -445,8 +360,6 @@ mod tests { } } - // ---- the shell comment stripper's own self-tests ------------------------------------------- - #[test] fn strip_shell_comment_drops_a_whole_line_comment() { assert_eq!( @@ -483,9 +396,6 @@ mod tests { assert_eq!(strip_shell_comment(line), line); } - /// The sanctioned deploy script's line 30 is `[ ${#dbs[@]} -eq 0 ] && dbs=(lyracore)`. A stripper - /// that treats any `#` as a comment truncates the file there and never sees the publish command - /// four lines later — the exact way this scan could pass while enforcing nothing. #[test] fn strip_shell_comment_keeps_a_parameter_expansion_hash() { let line = "[ ${#dbs[@]} -eq 0 ] && dbs=(lyracore)"; @@ -506,11 +416,6 @@ mod tests { ); } - // ---- the extractor, proven against a synthetic fixture ------------------------------------- - - /// The guard against a scanner that matches nothing: run the real line-scanner over an inline - /// fixture holding BOTH a safe publish and a destructive one, and require it to separate them. - /// Without this, every assertion below could go green because the extractor was broken. #[test] fn the_line_scanner_flags_a_destructive_publish_and_spares_the_safe_one() { let fixture = "\ @@ -539,25 +444,6 @@ spacetime publish -cy lyracore ); } - // ---- the real scan ------------------------------------------------------------------------ - - /// Sanity FLOOR. A scan rooted at the wrong directory finds zero files, flags zero violations - /// and reports success — so the floor is what makes a green result mean something. - /// - /// The floor was 40 once. It is 25 now, and the drop is a REAL loss of surface, not a - /// relaxed standard: 47 of the files this used to scan were the wire harness's live-stack - /// orchestrators under `tools/wire-client/`, and they are in another repository now. What is - /// left (26 in `scripts/` + `tools/` + `importer/scripts/`, plus `adapters/lyracore/run-suite.sh`) - /// is every script this repo still ships. A later change moved seven of those files to - /// `importer/scripts/` and added that root to the scan in the same change, so the count did not - /// move — a relocation must never be allowed to shrink this number by leaving a root behind. - /// Lower it again only alongside a comparable structural move, and never to make a red run - /// green — the whole point of a floor is that it notices the scan going blind. - /// - /// The 25 is spread over `SCAN_ROOTS` per root now, so it is checked root by root: this is - /// STRICTER than the old single sum (one root emptying itself can no longer be masked by - /// another growing) and it is what lets a checkout without the private-only roots — the public - /// mirror — still hold the roots it does have to their own floors. #[test] fn the_publish_scan_actually_reaches_the_repo_scripts() { let roots = scanned_roots(); @@ -576,8 +462,6 @@ spacetime publish -cy lyracore ); } - // `publish-module.sh` is a private deploy wrapper. Public acceptance scripts may live in - // `scripts/` without it, so its absence does not make this scan incomplete. let publish_module = repo_root().join("scripts/publish-module.sh"); if !publish_module.is_file() { let _ = writeln!( @@ -621,7 +505,6 @@ spacetime publish -cy lyracore ); } - /// The tripwire proper. #[test] fn no_script_names_a_destructive_spacetime_publish() { let root = repo_root(); @@ -652,11 +535,6 @@ spacetime publish -cy lyracore ); } - // ---- publish-module.sh's own guards -------------------------------------------------------- - - /// The argv guard: this script's arguments are database NAMES, and forwarding a `-c` straight - /// through to `spacetime publish` is the one unrecoverable mistake it exists to prevent. - /// Deleting the loop must turn this red. #[test] fn publish_module_sh_still_refuses_flag_shaped_arguments() { let path = repo_root().join("scripts/publish-module.sh"); @@ -679,13 +557,6 @@ spacetime publish -cy lyracore ); } - /// The two flags the wrapper exists to guarantee. Both fail QUIETLY when missing: - /// `--features=debug_reducers` omitted makes publish report a FALSE "Removed table" breaking - /// change and abort (a bare publish omits `--features=debug_reducers` and false-aborts); - /// `--yes` omitted makes the disconnect prompt EOF-abort under a `/dev/null` - /// stdin, even for a safe additive migration. Both present as "the publish just didn't work", - /// and the historical consequence was a shard silently left behind on an old schema — which - /// surfaces as the gateway refusing logons on that shard, not as a publish error. #[test] fn publish_module_sh_publish_line_keeps_both_required_flags() { let path = repo_root().join("scripts/publish-module.sh"); diff --git a/module/src/quest.rs b/module/src/quest.rs index 4aabdf2ba..42dffef0d 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -1,37 +1,14 @@ -//! Quests (server core) — accept a quest from a giver NPC, track kill objectives as creatures die, -//! and turn the completed quest in for XP / money / item rewards. This is the SERVER-AUTHORITATIVE -//! engine; the gateway's quest-giver dialog codec (the `!`/`?` overhead icons, the `CMSG_QUESTGIVER_*` -//! opcodes) reads these tables to drive the client UI, but the quest-log tracking window itself is a -//! later gateway slice — every reducer here is driven + verified server-side (debug twins in `debug.rs`). -//! -//! Data model — a clean header + list shape (NOT cmangos's one fat row), so the runtime joins are -//! direct and the importer maps cmangos's flattened columns onto normalized rows: -//! -//! - [`QuestTemplate`], the quest header (level gate, title, money/XP reward). [static] -//! - [`QuestObjective`], a quest's objective list (today: kill N of a creature). [static] -//! - [`QuestRewardItem`] — a quest's guaranteed reward items. [static] -//! - [`CreatureQuest`], which creature STARTS (`!`) / ENDS (`?`) which quest (many-to-many). [static] -//! - [`CharacterQuest`], a character's live quest log: one row per accepted quest with per-objective -//! progress counts, RLS-scoped to the owner. Born in `accept_quest`. [entity] -//! -//! Flow: `accept_quest(giver, quest)` validates the giver offers it + the player qualifies, then opens -//! a [`CharacterQuest`] row. Each creature kill calls [`on_creature_killed`] (from the combat -//! killing-blow path), which bumps the matching objective counts. `turn_in_quest(giver, quest)` -//! validates the giver ends it + every objective is met, grants the rewards (reusing -//! [`crate::xp::grant_xp`] + [`crate::items::grant_item`]), and marks the row rewarded (kept, to block -//! a repeat). Purely additive: brand-new tables + one hook call in `kill_creature`. - use crate::actor::{ActionRefusal, ActionRefusalKind}; use std::collections::BTreeSet; use spacetimedb::{table, Identity, ReducerContext, Table}; use crate::game_area_trigger; -use crate::game_gameobject; // GAMEOBJECT quest givers (e.g. Wanted Poster, Lost Guards corpses) +use crate::game_gameobject; use crate::game_item_instance; use crate::game_item_template; use crate::game_world_entity; -use crate::game_xp_event; // quest XP "+N experience" relay (non-kill SMSG_LOG_XPGAIN) +use crate::game_xp_event; #[cfg(feature = "debug_reducers")] use crate::group::{game_group, game_group_member}; #[cfg(feature = "debug_reducers")] @@ -41,17 +18,10 @@ use crate::{game_creature_spawn, game_creature_template}; pub use lyracore_shared::quest::objective_kind; -/// The raid quest-credit rule. Shared with the Gateway (`gateway/src/stdb/reads/items.rs`'s loot -/// window must apply the identical rule, or it promises a Raid member an item their `take_loot` -/// would then refuse). pub(crate) use lyracore_shared::quest::allowed_in_raid; -/// Named separately from [`allowed_in_raid`]: every non-debug caller reaches the Raid Quest type -/// only through `allowed_in_raid` itself, so only the debug fixture below names this constant. #[cfg(feature = "debug_reducers")] pub(crate) use lyracore_shared::quest::QUEST_TYPE_RAID; -/// `quest_entry`'s stored type, read from durable rows. A quest entry with no template row reads -/// as type 0, an ordinary quest. pub(crate) fn quest_type_of(ctx: &ReducerContext, quest_entry: u32) -> u32 { ctx.db .game_quest_template() @@ -60,7 +30,6 @@ pub(crate) fn quest_type_of(ctx: &ReducerContext, quest_entry: u32) -> u32 { .map_or(0, |quest| quest.quest_type) } -/// The intended Character set for one EventAI quest-credit instruction. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub enum QuestCreditRecipientPolicy { SelectedCharacter, @@ -69,14 +38,12 @@ pub enum QuestCreditRecipientPolicy { EligibleGroup, } -/// A source quest completion event. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub struct QuestEvent { pub quest_entry: u32, pub recipient_policy: QuestCreditRecipientPolicy, } -/// A source cast-credit event. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub struct CastCredit { pub creature_entry: u32, @@ -84,14 +51,12 @@ pub struct CastCredit { pub recipient_policy: QuestCreditRecipientPolicy, } -/// A source kill-credit event. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub struct KillCredit { pub creature_entry: u32, pub recipient_policy: QuestCreditRecipientPolicy, } -/// One typed EventAI request to quest authority. #[derive(spacetimedb::SpacetimeType, Clone, Copy, Debug, Eq, PartialEq)] pub enum EventAiQuestCredit { QuestEvent(QuestEvent), @@ -99,7 +64,6 @@ pub enum EventAiQuestCredit { KillCredit(KillCredit), } -/// The dynamic facts one EventAI instruction supplies to quest authority. #[derive(Clone, Debug)] pub(crate) struct EventAiQuestCreditContext { pub source_creature_guid: u64, @@ -111,81 +75,42 @@ pub(crate) struct EventAiQuestCreditContext { pub invoker_beneficiary: Option, } -/// The outcome quest authority returns to the EventAI instruction. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum QuestCreditOutcome { Applied, Refused, } -/// `CreatureQuest.role` — whether a creature is where you GET the quest or where you HAND IT IN. A -/// quest is offered by its START giver(s) (`!`) and completed at its END giver(s) (`?`); the two are -/// often the same NPC but need not be (cmangos `creature_questrelation` vs `creature_involvedrelation`). pub mod quest_role { - /// The creature OFFERS this quest (`!` overhead) — gates [`accept_quest`]. pub const START: u8 = 0; - /// The creature COMPLETES this quest (`?` overhead) — gates [`turn_in_quest`]. pub const END: u8 = 1; } -/// Max distance to talk to a quest giver: (10 yd)². The client walks into range before sending the -/// `CMSG_QUESTGIVER_*` op, so this only rejects clearly-out-of-range abuse (mirrors `loot`/vendor range). const QUEST_GIVER_RANGE_SQ: f32 = 100.0; -/// Party quest-SHARE range: reuses the group system's own kill-reward range -/// (`crate::group::GROUP_XP_RANGE_SQ`, vanilla's 74yd `CONFIG_FLOAT_GROUP_XP_DISTANCE`) rather than -/// the tight NPC-interaction [`QUEST_GIVER_RANGE_SQ`] (10yd) — sharing a quest is a party-visibility -/// action (no walking up to a person), the same range the game already treats as "close enough to -/// act together" for kill-XP splitting. const PARTY_SHARE_RANGE_SQ: f32 = crate::group::GROUP_XP_RANGE_SQ; -/// Vanilla quest-log cap (`push_quest_to_party`'s LOG_FULL gate — a share target already holding 20 -/// active quests can't accept a 21st). Matches the gateway's `player_quest_log` slot cap -/// (`gateway/src/stdb/reads.rs`) — both sides model the same 20-slot vanilla quest log. pub(crate) const MAX_QUEST_LOG_SIZE: usize = 20; -/// Vanilla level cap (quest XP→money conversion gate), matches `xp::xp_to_next_level`'s -/// own hardcoded cap check (`level >= 60` there too; not re-exported as a shared const, so this is a -/// second literal by necessity — pinned by a test alongside `xp::xp_to_next_level`'s own cap tests). const QUEST_MAX_LEVEL_PAYOUT: u32 = 60; -// =========================================================================================== -// Static quest definition [static] — public, read-only, SQL-loadable (no Timestamp) -// =========================================================================================== - -/// A quest definition (the cmangos `quest_template` analogue, deduped to what the engine needs). -/// Public + read-only, no Timestamp → loadable by the importer via plain SQL. `reward_xp == 0` means -/// "compute from `quest_level`" (`lyracore_shared::quest::xp_reward`); a non-zero value overrides it. [static] #[table(accessor = game_quest_template, public)] pub struct QuestTemplate { #[primary_key] pub entry: u32, - pub min_level: u32, // the character level required to ACCEPT (0 = no gate) - pub quest_level: u32, // the quest's level — drives the computed XP reward + pub min_level: u32, + pub quest_level: u32, pub title: String, - pub reward_money: u32, // copper granted on turn-in (cmangos RewOrReqMoney, clamped ≥ 0) - pub reward_xp: u32, // explicit XP reward; 0 = compute (lyracore_shared::quest::xp_reward) - // --- availability gates (END-appended, #[default(0)] → additive auto-migration) --- - // Without these, EVERY quest a giver lists shows as available (the `!`-spam bug): a chain quest, - // a wrong-race/class quest, all appear. `prev_quest_id` is the cmangos PrevQuestId — the quest that - // must be turned in (rewarded) before this one unlocks (0 = no prerequisite). `required_races` / - // `required_classes` are cmangos bitmasks (0 = all) checked via lyracore_shared::quest::{race,class}_allowed. + pub reward_money: u32, + pub reward_xp: u32, #[default(0)] pub prev_quest_id: u32, #[default(0)] pub required_races: u32, #[default(0)] pub required_classes: u32, - // cmangos quest_template.ZoneOrSort — drives the quest-log CATEGORY header (positive = AreaTable - // zone id, negative = a QuestSort id). 0 → the client shows "Missing header! (quest designers)". - // i32 to carry the sign. [default(0)] + end-appended → additive auto-migration. #[default(0)] pub zone_or_sort: i32, - // --- reputation rewards (END-appended, #[default(0)] → additive auto-migration). cmangos - // RewRepFaction1/2 + RewRepValue1/2: turn-in grants `value_n` rep with `faction_n` (0 faction = none). - // INERT until a re-import populates them (the importer parses these; existing rows default 0, so no - // re-import is forced). Granted via reputation::grant_reputation, which itself no-ops on a no-rep-bar - // faction. [static] #[default(0)] pub rew_rep_faction_1: u32, #[default(0)] @@ -194,64 +119,22 @@ pub struct QuestTemplate { pub rew_rep_faction_2: u32, #[default(0)] pub rew_rep_value_2: i32, - // --- provided "source" item (cmangos SrcItemId/SrcItemCount): the item HANDED to the player on - // accept (apply_accept_quest grants it via grant_item), satisfying a COLLECT objective whose - // ReqItemId == SrcItemId — delivery quests (McBride class letters 3100-3105, Report to Goldshire, - // Return to Kristoff, …). END-appended + #[default(0)] → additive auto-migration. A dump - // src_item_count of 0 means "1" (cmangos treats 0 as 1; grant_item floors at 1). INERT until a - // re-import populates it (existing rows default to 0 = no provided item). [static] #[default(0)] pub src_item: u32, #[default(0)] pub src_item_count: u32, - // Repeatable (cmangos SpecialFlags bit0, QUEST_SPECIAL_FLAGS_REPEATABLE): a repeatable quest can be - // accepted again immediately after turn-in (Elwynn's 16/7677/7678/3861 are all repeatable-flagged). - // END-appended + #[default(false)] → additive auto-migration; existing rows default to non-repeatable - // (unchanged behavior) until a re-import populates this from SpecialFlags&1. apply_turn_in_quest marks - // the CharacterQuest row rewarded=true like any other quest (NEVER deletes it — that would erase the - // only record the quest was ever completed, permanently breaking any OTHER quest whose prev_quest_id - // points at this one). apply_accept_quest instead special-cases the duplicate guard: a rewarded row - // for a repeatable quest is reset in place (counts zeroed, rewarded=false) rather than rejected. #[default(false)] pub repeatable: bool, - // --- forward chains + timed quests, END-appended, #[default(0)] → additive - // auto-migration --- - // cmangos NextQuestId/NextQuestInChain: the successor quest auto-offered on this quest's turn-in - // (0 = no successor). `prev_quest_id`/auto_finish already do the ACCEPT-side gating + the - // "arm the accept button" wire bit (module/src/quest.rs's prereq check, gateway/src/codec/quest.rs's - // hardcoded `auto_finish: true`) — this column is the missing TURN-IN-side piece: threaded into - // `QuestDetailView.next_quest_id` and `SMSG_QUEST_QUERY_RESPONSE`'s `next_quest_in_chain` field - // (previously a hardcoded 0 stub) so the client's own "Accept this quest?" chain-continuation - // popup fires on complete. INERT until a re-import populates it (existing rows default 0). #[default(0)] pub next_quest_id: u32, - // cmangos LimitTime (seconds from ACCEPT, 0 = untimed). `apply_accept_quest` stamps - // `CharacterQuest.deadline_micros` from this; the `quest_timer_pass` tick fails the quest at - // expiry. INERT until a re-import populates it (existing rows default 0 = untimed, unchanged - // behavior). #[default(0)] pub limit_time: u32, - // cmangos RewMoneyMaxLevel — the per-quest field vanilla's `Quest::XPValue` divides by 0.6 to get - // the authentic quest XP (see `lyracore_shared::quest::quest_xp`). The old `xp_reward` placeholder gave - // ~half this. INERT until a re-import populates it (existing rows default 0 → the award falls back - // to `xp_reward(quest_level)`, i.e. today's behavior, so a publish-before-reimport never zeroes XP). #[default(0)] pub reward_money_max_level: u32, - // cmangos quest_template.Type (cm:ObjectMgr.cpp:4239, cm:QuestDef.cpp:31). 62 is - // QUEST_TYPE_RAID (cm:QuestDef.h:121); every other value is an ordinary quest. END-appended + - // #[default(0)] → additive auto-migration; an existing row reads as an ordinary quest, unchanged - // until a re-import populates the real type. See [`QUEST_TYPE_RAID`] and [`allowed_in_raid`]. #[default(0)] pub quest_type: u32, } -/// The free-text body of a quest (cmangos Details / Objectives / OfferRewardText / RequestItemsText), -/// kept in a SIDE table (not on [`QuestTemplate`]) for two reasons: the long text stays out of the hot -/// template row, and a NEW table always auto-migrates (sidestepping the String-column default-migration -/// question). 1:1 with `QuestTemplate` by entry. Public + read-only, no Timestamp → SQL-loadable. The -/// gateway reads it to fill the quest detail / offer-reward / request-items dialogs. Vanilla quest -/// text embeds `$B` (line break) and `$N`/`$C`/`$R`/`$G` -/// (name/class/race/gender) tokens — stored verbatim for the client to interpret. [static] #[table(accessor = game_quest_text, public)] pub struct QuestText { #[primary_key] @@ -262,23 +145,18 @@ pub struct QuestText { pub request_items_text: String, } -/// One objective of a quest (today: kill `required_count` of creature `target_entry`). A quest's rows -/// are ordered by `obj_index` (0-based, ≤ [`MAX_OBJECTIVES`]-1), which positionally indexes the -/// progress `counts` on the player's [`CharacterQuest`]. Public + read-only, SQL-loadable. [static] #[table(accessor = game_quest_objective, public, index(accessor = by_quest, btree(columns = [quest_entry])))] pub struct QuestObjective { #[primary_key] #[auto_inc] pub id: u64, - pub quest_entry: u32, // -> QuestTemplate.entry - pub obj_index: u8, // 0-based slot within the quest (indexes CharacterQuest.counts) - pub kind: u8, // objective_kind::* (KILL_CREATURE today) - pub target_entry: u32, // the creature entry to kill (kind == KILL_CREATURE) - pub required_count: u32, // how many + pub quest_entry: u32, + pub obj_index: u8, + pub kind: u8, + pub target_entry: u32, + pub required_count: u32, } -/// A quest that needs its source event before ordinary objectives can complete it. Kept separately -/// from [`QuestTemplate`] so completion requirements do not change the template schema. #[table( accessor = game_quest_event_requirement, index(accessor = by_quest, btree(columns = [quest_entry])) @@ -289,8 +167,6 @@ pub struct QuestEventRequirement { pub quest_entry: u32, } -/// The spell requirement on a quest objective. Kept separately from the public objective row because -/// it is Module-only quest-credit metadata. A missing row means ordinary (non-spell) credit. #[table( accessor = game_quest_cast_objective, index(accessor = by_quest, btree(columns = [quest_entry])) @@ -303,20 +179,16 @@ pub struct QuestCastObjective { pub spell_id: u32, } -/// A guaranteed reward item granted on turn-in (cmangos RewItemId/RewItemCount), granted alongside -/// any [`QuestRewardChoice`] pick. Public + read-only, SQL-loadable. [static] #[table(accessor = game_quest_reward_item, public, index(accessor = by_quest, btree(columns = [quest_entry])))] pub struct QuestRewardItem { #[primary_key] #[auto_inc] pub id: u64, - pub quest_entry: u32, // -> QuestTemplate.entry - pub item_entry: u32, // -> game_item_template.entry + pub quest_entry: u32, + pub item_entry: u32, pub count: u32, } -/// A quest reward whose source cast teaches one spell. The importer stores the learned spell, -/// never its teaching wrapper. Non-teaching reward casts are outside this catalogue. #[table(accessor = game_quest_reward_spell)] pub struct QuestRewardSpell { #[primary_key] @@ -324,64 +196,37 @@ pub struct QuestRewardSpell { pub spell_id: u32, } -/// A CHOICE reward item (pick-1-of-N) granted on turn-in (cmangos RewChoiceItemId/Count1-6). The player -/// picks ONE row by `choice_index`; that single item is granted IN ADDITION to every [`QuestRewardItem`]. -/// `choice_index` is 0-based and matches the wire order of the offer/details `choice_item_rewards` array -/// (the client sends the picked index back as `CMSG_QUESTGIVER_CHOOSE_REWARD.reward`). Pinning the index -/// in the row means a re-import that reorders rows can't desync the index the client sends. Public, -/// SQL-loadable. [static] #[table(accessor = game_quest_reward_choice, public, index(accessor = by_quest, btree(columns = [quest_entry])))] pub struct QuestRewardChoice { #[primary_key] #[auto_inc] pub id: u64, - pub quest_entry: u32, // -> QuestTemplate.entry - pub choice_index: u8, // 0..=5, matches packet order / the CMSG `reward` index - pub item_entry: u32, // -> game_item_template.entry + pub quest_entry: u32, + pub choice_index: u8, + pub item_entry: u32, pub count: u32, } -/// Which creature STARTS / ENDS which quest (a many-to-many: an NPC offers several quests; a quest can -/// be offered by several NPCs). `role` is [`quest_role::START`] or [`quest_role::END`]. Gates accept / -/// turn-in to a giver in range, and drives the gateway's `!`/`?` overhead icons. Loaded from cmangos -/// `creature_questrelation` (START) + `creature_involvedrelation` (END). SQL-loadable. [static] #[table(accessor = game_creature_quest, public, index(accessor = by_creature, btree(columns = [creature_entry])))] pub struct CreatureQuest { #[primary_key] #[auto_inc] pub id: u64, - pub creature_entry: u32, // -> game_creature_template.entry - pub quest_entry: u32, // -> QuestTemplate.entry - pub role: u8, // quest_role::START | quest_role::END -} - -/// Which GAMEOBJECT STARTS / ENDS which quest — the GO twin of [`CreatureQuest`], needed because some -/// quests have no creature giver at all: GO 68 "Wanted Poster" starts q176 Wanted: Hogger with NO -/// creature start giver, and GO 55/56 "Lost Guards" corpses drive the q37/q45/q71 chain. Same -/// many-to-many / `role` shape, keyed on `go_entry` (-> [`crate::gameobject::GameObjectTemplate`].entry) -/// instead of a creature entry. -/// Loaded from cmangos `gameobject_questrelation` (START) + `gameobject_involvedrelation` (END). -/// A brand-new table — additive auto-migration, zero risk to the existing creature-giver path. -/// SQL-loadable. [static] + pub creature_entry: u32, + pub quest_entry: u32, + pub role: u8, +} + #[table(accessor = game_gameobject_quest, public, index(accessor = by_gameobject, btree(columns = [go_entry])))] pub struct GameObjectQuest { #[primary_key] #[auto_inc] pub id: u64, - pub go_entry: u32, // -> crate::gameobject::GameObjectTemplate.entry - pub quest_entry: u32, // -> QuestTemplate.entry - pub role: u8, // quest_role::START | quest_role::END + pub go_entry: u32, + pub quest_entry: u32, + pub role: u8, } -// =========================================================================================== -// Per-character quest log [entity] — public, RLS-restricted to the owner -// =========================================================================================== - -/// A character's accepted quest — one row per quest in the log, carrying per-objective progress in -/// `counts` (parallel to the quest's [`QuestObjective`] rows by `obj_index`). Born in [`accept_quest`]; -/// kept after turn-in with `rewarded = true` so a non-repeatable quest can't be re-done. RLS-scoped to -/// the owner like `game_character`/`game_item_instance`. `character_guid` is the player's world-entity -/// guid (== the character guid), so [`on_creature_killed`] can find a killer's quests directly. [entity] #[table( accessor = game_character_quest, public, @@ -400,27 +245,16 @@ pub struct CharacterQuest { #[auto_inc] pub id: u64, pub character_guid: u64, - pub owner_identity: Identity, // RLS scope: the connection that owns this quest log entry - pub quest_entry: u32, // -> QuestTemplate.entry - pub counts: Vec, // per-objective kill progress, indexed by QuestObjective.obj_index - pub rewarded: bool, // true once turned in — the row stays to block a repeat - // --- timed quests, END-appended, #[default(...)] → additive auto-migration --- - // Micros-since-epoch deadline stamped by `apply_accept_quest` from `QuestTemplate.limit_time` - // (`ctx.timestamp` micros + `limit_time * 1_000_000`); 0 = untimed (the vast majority of quests — - // existing rows default to this, unchanged behavior). Cleared back to 0 the instant the quest - // fails (`quest_timer_pass`) — a failed row's deadline is meaningless past that point. + pub owner_identity: Identity, + pub quest_entry: u32, + pub counts: Vec, + pub rewarded: bool, #[default(0i64)] pub deadline_micros: i64, - // Set by `quest_timer_pass` when `deadline_micros` passes un-rewarded. A failed quest cannot be - // turned in (`apply_turn_in_quest` rejects it) but CAN be re-accepted — `apply_accept_quest`'s - // duplicate guard treats an existing `failed` row like a repeatable-rewarded one: reset in place - // (counts zeroed, `failed`/`rewarded` cleared, a fresh deadline stamped), never a second row. #[default(false)] pub failed: bool, } -/// A quest completed through an EventAI quest event. The row is private because it only changes the -/// Module's turn-in Gate; the client already receives quest-log rows through its existing read path. #[table( accessor = game_character_quest_event_credit, index(accessor = by_character, btree(columns = [character_guid])) @@ -433,16 +267,12 @@ pub struct CharacterQuestEventCredit { pub quest_entry: u32, } -// Character-owned sweeps: the quest log is deleted on character delete, re-owned (identity -// re-stamp) on a relog under a changed gateway identity. crate::character_owned!(delete, fn sweep_delete_game_character_quest(ctx, character_guid) { let quests = ctx.db.game_character_quest(); for r in quests.by_character().filter(&character_guid).collect::>() { quests.id().delete(r.id); } }); -// CROSS-DATABASE transport: the quest log IS the run — a Deadmines party arriving with -// an empty log could not turn anything in. `id` is a surrogate PK, re-minted. crate::character_owned!(transfer, fn sweep_transfer_game_character_quest(ctx, character_guid, io) { table = game_character_quest, by = by_character, @@ -470,17 +300,8 @@ crate::character_owned!(transfer, fn sweep_transfer_game_character_quest_event_c remint = id, }); -// =========================================================================================== -// Pure helpers -// =========================================================================================== - -/// Hard cap on a quest's objectives — vanilla's quest log shows 4, and the progress `counts` vector is -/// sized to the quest's objective count (≤ this). Objectives past this are ignored at accept time. pub const MAX_OBJECTIVES: usize = 4; -/// Completion requires ordinary objectives and any event requirement. Collect objectives read -/// carried items; other objectives read their progress count. This does not check the deadline; -/// turn-in remains the authority for expired quests. pub(crate) fn quest_is_complete(ctx: &ReducerContext, cq: &CharacterQuest) -> bool { let requires_event_credit = ctx .db @@ -513,8 +334,6 @@ pub(crate) fn quest_is_complete(ctx: &ReducerContext, cq: &CharacterQuest) -> bo ) } -/// Does creature `creature_entry` have the `role` relation to `quest_entry` (i.e. is it a valid giver -/// for this accept / turn-in)? Scans `game_creature_quest` by creature. fn creature_has_quest_role( ctx: &ReducerContext, creature_entry: u32, @@ -528,8 +347,6 @@ fn creature_has_quest_role( .any(|r| r.quest_entry == quest_entry && r.role == role) } -/// Does GAMEOBJECT template `go_entry` have the `role` relation to `quest_entry`? The [`GameObjectQuest`] -/// twin of [`creature_has_quest_role`] — scans `game_gameobject_quest` by GO template entry. fn gameobject_has_quest_role( ctx: &ReducerContext, go_entry: u32, @@ -543,8 +360,6 @@ fn gameobject_has_quest_role( .any(|r| r.quest_entry == quest_entry && r.role == role) } -/// The Character's exact quest-log row for `quest_entry`, if any. -/// `Some(rewarded == false)` means active; `Some(true)` means already completed. pub(crate) fn character_quest_row( ctx: &ReducerContext, character_guid: u64, @@ -562,9 +377,6 @@ pub(crate) fn quest_is_taken(ctx: &ReducerContext, character_guid: u64, quest_en .is_some_and(|quest| !quest.rewarded && !quest.failed) } -/// Has the character turned in (been rewarded for) `quest_entry`? The prerequisite-chain test for -/// [`apply_accept_quest`] — a `prev_quest_id` is satisfied only by a `rewarded` row, not merely an -/// accepted one. pub(crate) fn quest_is_rewarded( ctx: &ReducerContext, character_guid: u64, @@ -573,34 +385,13 @@ pub(crate) fn quest_is_rewarded( character_quest_row(ctx, character_guid, quest_entry).is_some_and(|q| q.rewarded) } -/// A validated quest giver's identity: which `game_*_quest` relation table + template entry -/// [`creature_has_quest_role`] / [`gameobject_has_quest_role`] should check. Only the entry + kind -/// survive validation — accept/turn-in never touch the giver's live position again. enum GiverKind { - /// A live creature entity (`game_world_entity`) — checked against `game_creature_quest`. Creature(u32), - /// A spawned gameobject (`game_gameobject`) — checked against `game_gameobject_quest` - /// (some quests, like GO 68 "Wanted Poster", have no creature giver at all). GameObject(u32), - /// (item-starts-quest): a USED item whose template names `start_quest` - /// (cmangos `item_template.startquest`, the "dropped tabard starts a quest" pattern) — carries the - /// item's TEMPLATE entry (checked against `ItemTemplate.start_quest`, never a `game_*_quest` - /// relation table). The gateway sends the item's OWN instance guid as the "giver" guid - /// (`CMSG_USE_ITEM` → `SMSG_QUESTGIVER_QUEST_DETAILS`); `validate_giver` resolves it back against - /// the PLAYER'S OWN `game_item_instance` row (never someone else's bag). Satisfies START only. Item(u32), - /// (sharing): a live PLAYER entity that shared this quest via `push_quest_to_party` - /// — carries the SHARER's character guid. Re-validated FRESH here every time (grouped + in range, - /// via [`validate_party_giver`]) — a spoofed/stale giver_guid can never self-authorize, and - /// [`giver_has_quest_role`] additionally requires the sharer to be CURRENTLY, ACTIVELY on the - /// quest. Satisfies START only, never END. Party(u64), } -/// A live quest giver `giver_guid` validated for `player`: it must exist (a creature OR a -/// gameobject, never a player), be on the same map, and be in range. Returns the giver's -/// [`GiverKind`] (its template entry, tagged by kind) or an `Err` string. Shared by accept + -/// turn-in so the giver gate is identical (mirrors the vendor gate in `items::ops`). fn validate_giver( ctx: &ReducerContext, player: &crate::WorldEntity, @@ -608,8 +399,6 @@ fn validate_giver( ) -> Result { if let Some(giver) = ctx.db.game_world_entity().guid().find(giver_guid) { if giver.is_player() { - // (sharing): the ONLY case a live PLAYER entity is a valid giver, a - // party-share re-validated fresh (never trusts the earlier push_quest_to_party call). return validate_party_giver(ctx, player, giver_guid, &giver); } if giver.map_id != player.map_id || giver.instance_id != player.instance_id { @@ -627,13 +416,7 @@ fn validate_giver( } return Ok(GiverKind::Creature(giver.entry)); } - // Not a live creature/player entity — fall back to a GAMEOBJECT quest giver: GO 68 "Wanted - // Poster" starts q176 Wanted: Hogger (no creature start giver at all), and the GO 55/56 "Lost - // Guards" corpses drive the q37/q45/q71 chain. Same map + range gate. if let Some(go) = ctx.db.game_gameobject().guid().find(giver_guid) { - // GameObject giver: map + instance gated (GO rows carry `instance_id`; - // dungeon GOs are per-instance copies, so a giver in another party's Deadmines can never - // serve this player). if go.map_id != player.map_id || go.instance_id != player.instance_id { return Err(ActionRefusal::new( ActionRefusalKind::OtherPartition, @@ -649,10 +432,6 @@ fn validate_giver( } return Ok(GiverKind::GameObject(go.template_entry)); } - // (item-starts-quest): not a live creature/GO/player entity, try the PLAYER'S - // OWN item instance (never someone else's bag; a used item's instance guid is what the gateway - // sends as "giver" for CMSG_USE_ITEM's start_quest path). Tried LAST — the common paths above are - // cheap and never collide with an item's HIGHGUID_ITEM-tagged guid space anyway. ctx.db .game_item_instance() .guid() @@ -662,9 +441,6 @@ fn validate_giver( .ok_or_else(|| ActionRefusal::new(ActionRefusalKind::MissingTarget, "no such quest giver")) } -/// The quest giver a turn-in ended at. Only a creature or a gameobject ends a quest (an item or a -/// party member only starts one), so after a turn-in this finds the giver [`validate_giver`] found, -/// in the same order. `None` for a Character or an unknown guid. pub(crate) fn quest_ender( ctx: &ReducerContext, giver_guid: u64, @@ -680,12 +456,6 @@ pub(crate) fn quest_ender( .map(|go| QuestGiver::Gameobject(go.template_entry)) } -/// (sharing): validates a live PLAYER `giver_guid` as a party-share giver for `player` -/// — grouped with the accepting player (the SAME group id), on the same map/instance, within -/// [`PARTY_SHARE_RANGE_SQ`]. Every field is re-read LIVE here — never trusts that -/// `push_quest_to_party` ran, so a spoofed/stale giver_guid that no longer names a real grouped, -/// in-range peer is rejected exactly like a fabricated creature guid would be. -/// [`giver_has_quest_role`] (not this fn) separately checks the sharer is actually ON the quest now. fn validate_party_giver( ctx: &ReducerContext, player: &crate::WorldEntity, @@ -718,9 +488,6 @@ fn validate_party_giver( Ok(GiverKind::Party(giver_guid)) } -/// Does `giver` (a validated [`GiverKind`]) have the `role` relation to `quest_entry`? Dispatches to -/// [`creature_has_quest_role`] / [`gameobject_has_quest_role`] by kind — the single call site -/// `apply_accept_quest` / `apply_turn_in_quest` use so neither has to match on `GiverKind` itself. fn giver_has_quest_role( ctx: &ReducerContext, giver: &GiverKind, @@ -730,8 +497,6 @@ fn giver_has_quest_role( match *giver { GiverKind::Creature(entry) => creature_has_quest_role(ctx, entry, quest_entry, role), GiverKind::GameObject(entry) => gameobject_has_quest_role(ctx, entry, quest_entry, role), - // Item-starts-quest: satisfies START iff the item's OWN template names this - // quest — never END (an item never completes a quest). GiverKind::Item(entry) => { role == quest_role::START && ctx @@ -741,10 +506,6 @@ fn giver_has_quest_role( .find(entry) .is_some_and(|t| t.start_quest == quest_entry) } - // Party share: satisfies START iff the sharer is CURRENTLY, ACTIVELY on the - // quest (an un-rewarded, un-failed row) — never END. This is the hard gate against a spoofed - // giver_guid self-authorizing: even a genuinely grouped, in-range peer can't hand out a quest - // they aren't actually holding right now. GiverKind::Party(sharer_guid) => { role == quest_role::START && character_quest_row(ctx, sharer_guid, quest_entry) @@ -753,20 +514,6 @@ fn giver_has_quest_role( } } -/// The accept Gates of `tmpl` for `player`, in the order the accept applies them: level, race, -/// class, prerequisite chain, already held. Every `Err` is the Refusal [`apply_accept_quest`] -/// returns, verbatim — the text is part of this seam, so rewording one is an observable change. -/// -/// `Ok` carries the character's existing quest-log row when there is one the accept must RESET in -/// place instead of inserting a second (a repeatable quest already turned in, or any quest that ran -/// out of time). Read once here, so a caller that goes on to accept does not scan the log again. -/// -/// The giver Gates (in range, offers this quest) are not here: they are about the request, not about -/// this Character and this quest, and a caller asking "could this Character take it at all" has no -/// giver to name. Bag room is not here either — the accept EFFECTS grant a `src_item` after every -/// Gate has passed, and that Refusal is `crate::items`'. -/// -/// The Module and Gateway apply the shared acceptance rules to their own row facts. pub(crate) fn accept_gates( ctx: &ReducerContext, player: &crate::WorldEntity, @@ -800,16 +547,6 @@ pub(crate) fn accept_gates( Ok(existing) } -// =========================================================================================== -// Mutation cores (shared by the player reducers + their debug twins) -// =========================================================================================== - -/// Accept quest `quest_entry` from giver `giver_guid` for `player_guid` — the shared core behind the -/// player `accept_quest` reducer and its debug twin. Gates: the player is in world + alive; the -/// giver is a real creature in range that OFFERS the quest; the quest exists; and every -/// [`accept_gates`] Gate passes. -/// On success opens a [`CharacterQuest`] row with zeroed `counts` sized to the quest's objective count. -/// Additive — inserts one quest-log row. [entity] pub(crate) fn apply_accept_quest( ctx: &ReducerContext, player_guid: u64, @@ -845,12 +582,7 @@ pub(crate) fn request_accept_quest( .entry() .find(quest_entry) .ok_or_else(|| format!("no such quest {quest_entry}"))?; - // The Gates and their Refusals live in `accept_gates`, which the party-share preview and a - // Package ask as well. It hands back the row `apply_accept_effects` resets in place, so the - // reset-in-place-vs-insert decision costs no second point-scan of the same row. let existing = accept_gates(ctx, &player, &tmpl)?; - // Timed quests: a `limit_time > 0` template stamps a deadline (accept-time - // ctx.timestamp micros + limit_time seconds, as micros); 0 = untimed (the vast majority). let deadline_micros = if tmpl.limit_time > 0 { ctx.timestamp.to_micros_since_unix_epoch() + (tmpl.limit_time as i64) * 1_000_000 } else { @@ -867,15 +599,6 @@ pub(crate) fn request_accept_quest( ) } -/// The accept EFFECTS shared by [`apply_accept_quest`] and [`grant_quest_unchecked`] (the debug twin) — -/// once every accept GATE has been checked (by the real accept) or deliberately skipped (by the harness -/// grant), both paths do the identical work: grant the quest's provided "source" item (cmangos -/// SrcItemId — the item HANDED to the player on accept, satisfying a COLLECT objective whose ReqItemId -/// == SrcItemId; an item Refusal leaves the quest unchanged), size a zeroed progress vector to -/// the quest's objective count (capped at [`MAX_OBJECTIVES`]), write the [`CharacterQuest`] row — -/// UPDATED in place if `existing` names one (the repeatable/failed reset path, id kept — see -/// `apply_accept_quest`'s duplicate guard), else a fresh insert — and fire the accept hook. The debug -/// twin must never reimplement these effects itself; this is the one place they live. fn apply_accept_effects( ctx: &ReducerContext, character_guid: u64, @@ -912,9 +635,6 @@ fn apply_accept_effects( event_credits.id().delete(credit.id); } match existing { - // Re-accepting a repeatable-rewarded OR failed quest: reset the SAME row (id kept) rather than - // inserting a second one, and keeping one row per (character, quest) is what - // `character_quest_row`'s point-scan-by-quest_entry assumes everywhere else. Some(mut row) => { row.counts = counts; row.rewarded = false; @@ -935,8 +655,6 @@ fn apply_accept_effects( }); } } - // Notify-hook: the quest landed in the log — the real accept (all gates passed) or the harness - // grant (gates skipped); a consumer sees one "quest in the log" signal regardless of which path. crate::hooks::fire_on_quest_accept( ctx, &crate::hooks::QuestAcceptPayload { @@ -947,16 +665,6 @@ fn apply_accept_effects( Ok(()) } -/// Grant `quest_entry` to `character_guid` WITHOUT a giver — the test path behind [`debug_grant_quest`]. -/// SKIPS every accept GATE (giver range/role, level, race/class, prereq) so a harness can stage a quest -/// by guid alone — the real accept needs the giver's creature guid, which the CLI `spacetime call` -/// mangles (u64 > 2^53) — but shares [`apply_accept_effects`] with [`apply_accept_quest`] for the -/// EFFECTS (row shape, source item, hook), so the two can never drift. Keeps the duplicate guard (no -/// reset exception here — a harness re-grant is always a fresh accept, never a repeatable/failed -/// reset). owner_identity comes from the live entity (the player is in-world when staging). [entity] -// Sole consumer today is the feature-gated harness (via `actor::stage_quest`); a default build -// compiles debug.rs out, so the lint is silenced ONLY there — a debug_reducers build still flags -// this dead if the harness stops staging quests. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] pub(crate) fn grant_quest_unchecked( ctx: &ReducerContext, @@ -979,8 +687,6 @@ pub(crate) fn grant_quest_unchecked( .find(character_guid) .map(|e| e.owner_identity) .ok_or_else(|| format!("character {character_guid} not in world"))?; - // No existing row (the duplicate guard above already rejected one) and no deadline (harness - // staging skips the timer — same as an untimed accept). apply_accept_effects( ctx, character_guid, @@ -993,12 +699,6 @@ pub(crate) fn grant_quest_unchecked( .map_err(Into::into) } -/// Pure choice-reward pick (testable without a live `ReducerContext`): given a quest's choice rows as -/// `(choice_index, item_entry, count)` and the player's picked `reward_index`, decide what to grant. -/// `Ok(None)` when the quest has NO choice rows (the index is ignored — a guaranteed-only quest); -/// `Ok(Some((item, count)))` for the row whose `choice_index == reward_index`; `Err` when choices exist -/// but none match the index — REJECT (the client must pick a valid slot), never silently grant nothing -/// or the wrong item. fn pick_choice_reward( choices: &[(u8, u32, u32)], reward_index: u32, @@ -1014,9 +714,6 @@ fn pick_choice_reward( .ok_or_else(|| format!("invalid reward choice {reward_index} for quest {quest_entry}")) } -/// Turn in a completed quest through the giver and objective Gates. Resolve the reward choice, -/// spell, and complete item exchange before changing items, money, XP, or the quest row. -/// A caller may retain a Refusal without relying on reducer rollback. pub(crate) fn apply_turn_in_quest( ctx: &ReducerContext, player_guid: u64, @@ -1056,8 +753,6 @@ pub(crate) fn request_turn_in_quest( if cq.rewarded { return Err("quest already turned in".to_string().into()); } - // (timed quests): a FAILED (expired) quest can't be turned in, it must be - // re-accepted first (the duplicate guard in `apply_accept_quest` lets that through). if cq.failed { return Err("quest has expired".to_string().into()); } @@ -1116,32 +811,20 @@ pub(crate) fn request_turn_in_quest( rewards.push(reward); } player.money = player.money.saturating_add(tmpl.reward_money); - // Quest XP is scaled by the realm xp_rate at the source (like kill XP), so a custom-rate realm - // speeds up quest leveling too. Quest XP has no rested bonus, so this is a plain scale. let base_xp = lyracore_shared::quest::base_reward_xp( tmpl.reward_xp, tmpl.reward_money_max_level, tmpl.quest_level, ); let xp = crate::xp::rated_xp(ctx, base_xp); - // Level-cap payout: at the vanilla level cap, quest XP is USELESS (no next - // level to fill) — real vanilla instead converts it to copper. Money-converted, never granted as - // XP (vanilla behaviour: a capped character never dings off a quest). Below the cap, unchanged: XP - // granted normally, no conversion. `max_level_money_reward` (the gateway's quest-log-preview stub, - // `codec/quest.rs`) uses the SAME `lyracore_shared::quest::max_level_money_reward` conversion so the - // preview number always matches what a level-60 turn-in actually pays. if player.level >= QUEST_MAX_LEVEL_PAYOUT { player.money = player .money .saturating_add(lyracore_shared::quest::max_level_money_reward(xp)); } - // Packages may retain a Refusal and continue their reducer. The exchange resolves all item - // Gates before committing; every quest effect below this point is infallible. + // Packages can catch a Refusal; resolve every item Gate before the infallible quest effects. crate::items::exchange_items(ctx, player_guid, &collect, &rewards)?; if player.level < QUEST_MAX_LEVEL_PAYOUT { - // "+N experience" feedback: a non-kill game_xp_event → SMSG_LOG_XPGAIN, mirroring the kill - // (award_xp) and exploration paths. grant_xp itself deliberately never emits one, so quest XP - // showed no floating text without this. killed_guid 0 = no source unit (the NonKill form). ctx.db.game_xp_event().insert(crate::xp::XpEvent { id: 0, recipient_identity: player.owner_identity, @@ -1155,10 +838,6 @@ pub(crate) fn request_turn_in_quest( } entities.guid().update(player); - // Reputation rewards: grant each non-zero (faction, value) pair. grant_reputation no-ops on a - // 0 faction id (no such faction) and on no-rep-bar factions, so the unconditional calls are safe; the - // gateway relays SMSG_SET_FACTION_STANDING off the resulting row write. INERT until a re-import fills - // these columns (default 0 today). crate::reputation::grant_reputation( ctx, player_guid, @@ -1176,14 +855,9 @@ pub(crate) fn request_turn_in_quest( crate::spell::learn_spell(ctx, player_guid, owner_identity, reward.spell_id); } - // Mark the row rewarded and KEEP it — for every quest, repeatable or not. Deleting it here for a - // repeatable quest would erase the only record it was ever completed, which would permanently break - // any OTHER quest whose `prev_quest_id` points at this one (quest_is_rewarded reads this row). A - // repeatable quest is instead re-taken by `apply_accept_quest`'s duplicate-guard exception, which - // resets this SAME row (rewarded -> false) rather than requiring it gone. + // Keep rewarded repeatable rows because later quests read them as prerequisite evidence. cq.rewarded = true; ctx.db.game_character_quest().id().update(cq); - // Notify-hook: the quest was turned in — rewards granted, row marked rewarded. crate::hooks::fire_on_quest_turnin( ctx, &crate::hooks::QuestTurninPayload { @@ -1215,7 +889,6 @@ const CHOICE_FIXTURE_GUARANTEED: u32 = 5_090_333; #[cfg(feature = "debug_reducers")] const CHOICE_FIXTURE_FILLER: u32 = 5_090_334; -/// Stage a quest-33-shaped durable turn-in fixture for the standalone reducer test. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_stage_choice_reward_fixture( @@ -1229,9 +902,6 @@ pub fn debug_stage_choice_reward_fixture( .find(CHOICE_FIXTURE_PLAYER) .ok_or_else(|| "choice fixture player is not live".to_string())?; - // A dedicated live giver, cloned from the seed chicken only to avoid reproducing the large - // WorldEntity constructor in test code. Its reserved guid and fixed position make the real - // giver validation deterministic across transactions. let mut giver = entities .guid() .find(CHOICE_FIXTURE_GIVER_SOURCE) @@ -1381,9 +1051,6 @@ pub fn debug_stage_choice_reward_fixture( entities.guid().update(player); crate::items::grant_item(ctx, CHOICE_FIXTURE_PLAYER, CHOICE_FIXTURE_OBJECTIVE_ITEM, 8)?; if fill_inventory { - // The objective occupies one backpack slot. Fifteen non-stackable fillers occupy the rest. - // Turn-in consumption frees one slot, the guaranteed reward takes it, then the selected - // non-stackable reward fails to store. Returning Err must roll that whole transaction back. crate::items::grant_item(ctx, CHOICE_FIXTURE_PLAYER, CHOICE_FIXTURE_FILLER, 15)?; } ctx.db.game_character_quest().insert(CharacterQuest { @@ -1399,7 +1066,6 @@ pub fn debug_stage_choice_reward_fixture( Ok(()) } -/// Drive the same durable turn-in core as `gw_turn_in_quest`, with the fixture guids resolved server-side. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_turn_in_choice_reward_fixture( @@ -1416,7 +1082,6 @@ pub fn debug_turn_in_choice_reward_fixture( ) } -/// Verify a committed turn-in from a later standalone transaction. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_verify_choice_reward_fixture( @@ -1476,7 +1141,6 @@ pub fn debug_verify_choice_reward_fixture( Ok(()) } -/// Verify that a refused turn-in rolled back every durable effect. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_verify_choice_reward_refusal_fixture( @@ -1564,10 +1228,9 @@ fn eventai_credit_fixture_count( }) } -/// Exercise EventAI quest authority against durable live rows in one standalone transaction. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] -#[allow(clippy::too_many_lines)] // One durable step per staged fixture row. +#[allow(clippy::too_many_lines)] pub fn debug_verify_eventai_quest_credit_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -2002,8 +1665,6 @@ const RQC_RAID_OTHER: u64 = 509_297_001; const RQC_PARTY_KILLER: u64 = 509_297_002; #[cfg(feature = "debug_reducers")] const RQC_RAID_FILLER_START: u64 = 509_297_010; -/// Fillers past [`RQC_PLAYER`] and [`RQC_RAID_OTHER`], so the Raid holds 12 members total: the -/// Loot Tag read bound the old cap of 5 would have refused. #[cfg(feature = "debug_reducers")] const RQC_RAID_FILLER_COUNT: u64 = 10; #[cfg(feature = "debug_reducers")] @@ -2014,9 +1675,6 @@ const RQC_PARTY_GROUP: u64 = 509_298; const RQC_NORMAL_QUEST: u32 = 5_092_970; #[cfg(feature = "debug_reducers")] const RQC_RAID_QUEST: u32 = 5_092_971; -/// A normal quest with a single spell-gated kill objective, isolated from -/// [`RQC_NORMAL_QUEST`]'s own ordinary (unspelled) kill objective so testing one credit path -/// never disturbs the other's spell requirement. #[cfg(feature = "debug_reducers")] const RQC_CAST_QUEST: u32 = 5_092_972; #[cfg(feature = "debug_reducers")] @@ -2029,17 +1687,10 @@ const RQC_TRIGGER_ID: u32 = 509_299; const RQC_ITEM_NORMAL: u32 = 509_300; #[cfg(feature = "debug_reducers")] const RQC_ITEM_RAID: u32 = 509_301; -/// The spell an EventAI cast-credit request names on `RQC_CAST_QUEST`'s kill objective, proving -/// cast credit (`spell_id != 0`) stays ungated even for a Raid member's non-Raid quest. #[cfg(feature = "debug_reducers")] const RQC_CAST_SPELL: u32 = 509_302; -/// The seeded "Test Wolf" creature template (`seed::fixtures::TEST_WOLF_ENTRY`), reused so the -/// live Loot Tag scale check spawns a real wild creature without a template fixture of its own. #[cfg(feature = "debug_reducers")] const RQC_WOLF_TEMPLATE: u32 = 51_000; -/// A reserved, self-chosen creature guid (HIGHGUID_UNIT high bits, `RQC_WOLF_TEMPLATE` in bits -/// 24..47, a low part far past any seeded spawn of that entry). Deterministic, so this fixture -/// never has to scan a spatial table to discover what it just inserted. #[cfg(feature = "debug_reducers")] const RQC_WOLF_GUID: u64 = (0xF130_u64 << 48) | ((RQC_WOLF_TEMPLATE as u64) << 24) | 509_297; @@ -2057,13 +1708,9 @@ fn rqc_progress( }) } -/// Exercise the vanilla raid quest-credit rule against live durable rows: a Raid member takes no -/// kill credit, quest-item need or quest-object need for a quest that is not a Raid Quest, the -/// killer is gated the same as any other recipient, a Raid Quest and exploration credit both keep -/// progressing, a Party is unaffected, and the Loot Tag read covers a full 12-member Raid. #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] -#[allow(clippy::too_many_lines)] // One durable step per staged fixture row. +#[allow(clippy::too_many_lines)] pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -2078,7 +1725,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() .collect(); let fixture_quests = [RQC_NORMAL_QUEST, RQC_RAID_QUEST, RQC_CAST_QUEST]; - // --- Reset: reruns against the same database must not accumulate stale rows. --- let logs = ctx.db.game_character_quest(); for &character_guid in &fixture_characters { for quest in logs @@ -2118,7 +1764,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() entities.guid().delete(RQC_WOLF_GUID); ctx.db.game_creature_spawn().guid().delete(RQC_WOLF_GUID); - // --- Characters: clone the seeded RQC_PLAYER entity to every other fixture guid. --- let player = entities .guid() .find(RQC_PLAYER) @@ -2133,7 +1778,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() entities.insert(clone); } - // --- Groups: a 12-member Raid, and a 2-member Party for the regression check. --- groups.insert(crate::Group { group_id: RQC_RAID_GROUP, leader_guid: RQC_PLAYER, @@ -2141,11 +1785,9 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() loot_threshold: 0, rr_cursor: 0, master_looter_guid: 0, - group_type: 1, // GroupKind::Raid wire value + group_type: 1, }); for (offset, &character_guid) in raid_members.iter().enumerate() { - // Real Subgroups of at most `SUBGROUP_SIZE`, the shape a live Raid actually has, not one - // oversized Subgroup 0. let subgroup = (offset / lyracore_shared::group::SUBGROUP_SIZE) as u8; let raid_slot = lyracore_shared::group::RaidSlot::new(subgroup, false) .expect("RQC_RAID_FILLER_COUNT keeps every Subgroup index below RAID_SUBGROUPS") @@ -2165,7 +1807,7 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() loot_threshold: 0, rr_cursor: 0, master_looter_guid: 0, - group_type: 0, // GroupKind::Party wire value + group_type: 0, }); group_members.insert(crate::GroupMember { id: 509_297_220, @@ -2181,7 +1823,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() return Err("the fixture Party killer must not read as in_raid".to_string()); } - // --- Quests: a normal quest and a Raid Quest, each with one objective of every gated kind. --- let quest_template = |entry: u32, title: &str, quest_type: u32| QuestTemplate { entry, min_level: 0, @@ -2294,8 +1935,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() required_count: 1, }); } - // A spell requirement on RQC_CAST_QUEST's kill objective, so an EventAI cast-credit request - // naming RQC_CAST_SPELL actually matches it, and an ordinary (unspelled) kill does not. ctx.db .game_quest_cast_objective() .insert(QuestCastObjective { @@ -2323,7 +1962,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() }); } - // --- Kill credit is decided per recipient, and gates the killer too. --- on_creature_killed(ctx, RQC_PLAYER, RQC_CREATURE_ENTRY); if rqc_progress(ctx, RQC_PLAYER, RQC_NORMAL_QUEST, 0)? != 0 { return Err("a Raid member's kill credited a non-Raid quest".to_string()); @@ -2337,8 +1975,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() return Err("a Party member's kill credit changed behavior".to_string()); } - // --- Cast credit (spell_id != 0) stays ungated, even for the same Raid member and the same - // objective ordinary kill credit just refused above. --- let cast_context = EventAiQuestCreditContext { source_creature_guid: 0, source_x: 0.0, @@ -2359,16 +1995,12 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() if rqc_progress(ctx, RQC_PLAYER, RQC_CAST_QUEST, 0)? != 1 { return Err("a Raid member's spell-gated cast credit was wrongly raid-gated".to_string()); } - // The ordinary (unspelled) kill earlier never touched RQC_CAST_QUEST's own spell-gated - // objective: `objective_matches_spell` only matches a spell_id of 0 against an UNREGISTERED - // cast requirement, and this objective's registered requirement is RQC_CAST_SPELL. if rqc_progress(ctx, RQC_PLAYER, RQC_NORMAL_QUEST, 0)? != 0 { return Err( "cast credit's setup leaked into the unrelated normal-quest kill objective".to_string(), ); } - // --- Quest-object use follows the same rule. --- on_gameobject_used(ctx, RQC_PLAYER, RQC_GAMEOBJECT_ENTRY); if rqc_progress(ctx, RQC_PLAYER, RQC_NORMAL_QUEST, 1)? != 0 { return Err("a Raid member's gameobject use credited a non-Raid quest".to_string()); @@ -2378,13 +2010,11 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() return Err("a Raid member's gameobject use did not credit its own Raid Quest".to_string()); } - // --- Exploration credit stays ungated even for a Raid member's non-Raid quest. --- on_areatrigger_entered(ctx, RQC_PLAYER, RQC_TRIGGER_ID); if rqc_progress(ctx, RQC_PLAYER, RQC_NORMAL_QUEST, 2)? != 1 { return Err("a Raid member's area-trigger credit was gated".to_string()); } - // --- Quest-item need, and a Party member keeps its unchanged behavior. --- if crate::loot::killer_needs_item(ctx, Some(RQC_PLAYER), RQC_ITEM_NORMAL) { return Err("a Raid member still needed a non-Raid quest's item".to_string()); } @@ -2395,9 +2025,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() return Err("a Party member's quest-item need changed behavior".to_string()); } - // --- A full 12-member Raid loots its own kill, through the real access path a player's - // take actually goes through: tap, `death_entitlement`, `record_corpse_eligibility`, then - // `corpse_access_gate` for a filler member. --- let wolf_template = ctx .db .game_creature_template() @@ -2445,7 +2072,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() } crate::loot::tag::record_corpse_eligibility(ctx, RQC_WOLF_GUID, &entitlement.recipients); crate::loot::tag::corpse_access_gate(ctx, RQC_RAID_FILLER_START, RQC_WOLF_GUID)?; - // The live Loot Tag read is bounded at the Raid cap too. if crate::loot::tag::live_loot_tag_eligibility(ctx, RQC_WOLF_GUID, RQC_RAID_FILLER_START) != crate::loot::tag::LiveLootTagEligibility::Available { @@ -2461,19 +2087,6 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() Ok(()) } -/// Quest objective-credit engine — the ONE loop shared by [`on_creature_killed`], -/// [`on_gameobject_used`], and [`on_areatrigger_entered`] (previously copy-pasted three times). -/// Snapshots `character_guid`'s active (un-rewarded) quests, then for each one bumps every incomplete -/// objective whose `(kind, target_entry, spell_id)` matches the given credit, capping each count -/// at its `required_count`. No matching quest (or a non-quest-holding actor) → no-op. Only writes back a -/// quest row whose counts actually changed. Additive — touches only quest-log rows. A fourth objective -/// kind (SPEAK_TO, CAST_ON, …) costs a new call site into this fn, not a fourth pasted loop. -/// -/// An ordinary KILL_CREATURE or USE_GAMEOBJECT credit (`spell_id == 0`) also carries the raid -/// quest-credit rule ([`quest_progresses_for_raid`]): a Raid member's non-Raid quest is skipped -/// entirely, covering the killer and every EventAI-selected recipient the same way, since they all -/// land here. EXPLORE_AREATRIGGER, and a spell-gated credit (`spell_id != 0`, EventAI cast credit, -/// cmangos `CastedCreatureOrGO`), stay ungated, matching cmangos. fn credit_objective( ctx: &ReducerContext, character_guid: u64, @@ -2486,11 +2099,8 @@ fn credit_objective( kind, objective_kind::KILL_CREATURE | objective_kind::USE_GAMEOBJECT ); - // Computed once per call, not once per active quest: `group::in_raid` is an indexed lookup on - // `character_guid` alone, so its answer cannot change across this character's own quest log. let in_raid = kind_is_raid_gated && crate::group::in_raid(ctx, character_guid); let log = ctx.db.game_character_quest(); - // Snapshot the actor's active quests first (we mutate rows in the loop). let active: Vec = log .by_character() .filter(&character_guid) @@ -2515,7 +2125,7 @@ fn credit_objective( } let idx = obj.obj_index as usize; let Some(have) = cq.counts.get(idx).copied() else { - continue; // objective beyond the sized progress vector (>MAX_OBJECTIVES) — skip + continue; }; if have < obj.required_count { cq.counts[idx] = have + 1; @@ -2542,8 +2152,6 @@ fn objective_matches_spell( .map_or(spell_id == 0, |objective| objective.spell_id == spell_id) } -/// Apply one EventAI quest-credit request through quest authority. Missing runtime recipient facts -/// are Refusals. A duplicate or irrelevant objective credit is applied idempotently. pub(crate) fn apply_eventai_credit( ctx: &ReducerContext, request: EventAiQuestCredit, @@ -2710,9 +2318,6 @@ fn live_character(ctx: &ReducerContext, character_guid: u64) -> bool { .is_some_and(|character| character.is_player() && !character.dead) } -/// Quest progress hook — called from the combat killing-blow path ([`crate::combat::kill_creature`]) -/// when a PLAYER `killer_guid` kills a creature of `creature_entry`. [`credit_objective`] for -/// [`objective_kind::KILL_CREATURE`]. pub(crate) fn on_creature_killed(ctx: &ReducerContext, killer_guid: u64, creature_entry: u32) { credit_objective( ctx, @@ -2723,8 +2328,6 @@ pub(crate) fn on_creature_killed(ctx: &ReducerContext, killer_guid: u64, creatur ); } -/// Quest credit for using a gameobject (the GOOBER path from CMSG_GAMEOBJ_USE). [`credit_objective`] -/// for [`objective_kind::USE_GAMEOBJECT`]. pub(crate) fn on_gameobject_used(ctx: &ReducerContext, player_guid: u64, go_entry: u32) { credit_objective( ctx, @@ -2735,8 +2338,6 @@ pub(crate) fn on_gameobject_used(ctx: &ReducerContext, player_guid: u64, go_entr ); } -/// Quest credit for entering an area trigger (the CMSG_AREATRIGGER path). [`credit_objective`] for -/// [`objective_kind::EXPLORE_AREATRIGGER`]. pub(crate) fn on_areatrigger_entered(ctx: &ReducerContext, player_guid: u64, trigger_id: u32) { credit_objective( ctx, @@ -2747,15 +2348,6 @@ pub(crate) fn on_areatrigger_entered(ctx: &ReducerContext, player_guid: u64, tri ); } -/// One cmangos `areatrigger_teleport` row, a dungeon entrance/exit portal. -/// Loaded by the importer's `--dump` "globals" family -/// (`importer/src/main.rs::build_areatrigger_teleport_sql`). Module-only: deliberately NOT `public` — -/// no client or gateway ever reads a teleport TARGET directly, only its EFFECT (the cross-map teleport -/// handshake, which already carries its own `game_teleport_event` wire path), so this -/// table needs no gateway binding at all (danger-zones.md §1's "new table → regenerate bindings" rule -/// only applies to tables a gateway subscription reads). [`apply_enter_areatrigger`] looks this up by -/// `trigger_id`; a hit routes the player through [`crate::world::teleport_player`], a miss leaves the -/// quest-credit-only behavior byte-identical. No Timestamp → plain SQL, clear+reload. [static] #[table(accessor = game_areatrigger_teleport)] pub struct AreatriggerTeleport { #[primary_key] @@ -2768,9 +2360,6 @@ pub struct AreatriggerTeleport { pub name: String, } -/// The imported source and destination maps for one portal. Coordinates for the landing stay -/// private; a Package may choose a source volume, but only [`enter_sessionless_areatrigger`] -/// resolves and applies its destination. #[derive(Clone, Copy)] pub(crate) struct AreaTriggerRoute { pub source_map: u32, @@ -2845,7 +2434,6 @@ struct AreaTriggerVolume { yaw: f32, } -/// Read one named imported portal through exact primary-key lookups. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn area_trigger_route( ctx: &ReducerContext, @@ -2878,10 +2466,6 @@ pub(crate) fn area_trigger_route( route.valid().then_some(route) } -/// Enter an imported portal for a session-less Character. Dungeon entry requires a party member's -/// certified destination instance. An open-world exit needs no remote member because the imported -/// route itself fixes instance zero. The operation does not grant explore credit; it applies only -/// the portal effect. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn enter_sessionless_areatrigger( ctx: &ReducerContext, @@ -3000,20 +2584,7 @@ pub(crate) fn enter_sessionless_areatrigger( ) } -/// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger`. Quest -/// credit fires FIRST and UNCONDITIONALLY (unchanged), then — iff `trigger_id` -/// has an imported `game_areatrigger_teleport` row — the player is routed through the cross-map -/// teleport. A trigger can be BOTH a quest-explore objective AND a teleport (Deadmines' Moonbrook -/// entrance/exit triggers, `[V]` ids ~1447/~1448 per the design doc) — this is deliberate, not a bug: -/// the two arms are independent and both fire on the same `CMSG_AREATRIGGER`. -/// -/// Dungeon entry: a teleport whose `target_map` is a DUNGEON -/// (`instance::is_dungeon_map` — Deadmines = map 36 today) resolves-or-creates the caller's -/// instance (`instance::resolve_or_create_instance`: own live binding → party's live instance → -/// create; solo allowed; 5-player cap enforced here at trigger time) and teleports into it. Every -/// NON-dungeon target keeps the byte-identical instance-0 path (`is_dungeon_map` false → the same -/// `teleport_player(..., 0, ...)` call as before), the Deadmines EXIT trigger (map 36 → map 0) -/// rides that arm, correctly landing the leaver back in the open world. +// A trigger may grant exploration credit and teleport; both effects are independent. pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, trigger_id: u32) { let teleport_target = ctx .db @@ -3027,10 +2598,6 @@ pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, tr match crate::instance::resolve_or_create_instance(ctx, player_guid, target_map) { Ok(id) => id, Err(e) => { - // Refused LOUDLY (cap breach / population-guid exhaustion): quest credit above - // already fired; the teleport does not — the player stays outside the portal. - // 1.12 has no "instance is full" packet on the areatrigger path, so the server - // warn is the loud signal (and the player visibly doesn't zone). spacetimedb::log::warn!( "areatrigger {trigger_id}: dungeon entry refused for {player_guid}: {e}" ); @@ -3044,39 +2611,13 @@ pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, tr } } -// =========================================================================================== -// Timed quests, expiry tick -// =========================================================================================== - -/// Is a timed quest EXPIRED? Pure: `deadline_micros == 0` (untimed) never expires; an already -/// `rewarded` or already `failed` row never re-expires (idempotent — the tick must not re-process a -/// terminal row every 0.5s forever); otherwise expired iff `now_micros >= deadline_micros` -/// (inclusive — the deadline instant itself counts as expired, not one tick later). fn is_expired(now_micros: i64, deadline_micros: i64, rewarded: bool, failed: bool) -> bool { deadline_micros != 0 && !rewarded && !failed && now_micros >= deadline_micros } -// The timed-quest expiry tick: every `tick_creatures` cadence (0.5s, via the -// `game_tick_pass!` marker — zero core edits needed elsewhere), scan the quest log for rows whose -// deadline has passed and fail them (`failed = true`, `deadline_micros` cleared back to 0 — a failed -// row's deadline is meaningless past this point). The gateway's `on_quest_update` relay diffs -// `!old.failed && row.failed` to send `SMSG_QUESTUPDATE_FAILEDTIMER`. A failed quest is NOT deleted — -// `apply_turn_in_quest` rejects it, and `apply_accept_quest`'s duplicate guard lets it be re-accepted -// (reset in place), mirroring the repeatable-rewarded exception. crate::game_tick_pass!(fn quest_timer_pass(ctx) { let now = ctx.timestamp.to_micros_since_unix_epoch(); let log = ctx.db.game_character_quest(); - // `is_expired`'s own `deadline_micros != 0` check makes this a cheap no-op scan for the (typical) - // all-untimed quest log — no separate index needed for this table's expected small size. - // REFUSE verdict — the production twin of `debug::debug_expire_quest`, fenced there - // for exactly this reason: the write lands in `game_character_quest`, a MANIFEST table, so - // post-`begin_transfer` it is a lost write cross-database. This pass is BACKGROUND, so the - // "the guid came from a live entity" argument that fences the rest of `quest.rs` does not reach - // it — it scans the quest log directly and fires every 0.5s. - // - // Refusing DEFERS rather than drops: the deadline is left set, so the first pass after the - // transfer settles fails the quest exactly as it would have. It is also the better player - // outcome — a character frozen on a shard-hop loading screen cannot act on a timer. let expired: Vec = log .iter() .filter(|q| is_expired(now, q.deadline_micros, q.rewarded, q.failed)) @@ -3089,11 +2630,6 @@ crate::game_tick_pass!(fn quest_timer_pass(ctx) { } }); -// =========================================================================================== -// Sharing, CMSG_PUSHQUESTTOPARTY -// =========================================================================================== - -/// The shared core behind [`push_quest_to_party`] and its debug twin `debug_push_quest`. pub(crate) fn apply_push_quest_to_party( ctx: &ReducerContext, sender_guid: u64, @@ -3137,8 +2673,6 @@ pub(crate) fn apply_push_quest_to_party( Ok(()) } -/// Gathers one group member's live state for [`share_result`] — the ctx-consuming half; the decision -/// itself is the pure fn so it's unit-testable without a `ReducerContext`. fn evaluate_share( ctx: &ReducerContext, sender: &crate::WorldEntity, @@ -3160,12 +2694,6 @@ fn evaluate_share( let member_active = existing.as_ref().is_some_and(|q| !q.rewarded && !q.failed); let member_finished = existing.as_ref().is_some_and(|q| q.rewarded) && !tmpl.repeatable; let log_full = active_quest_count(ctx, member_guid) >= MAX_QUEST_LOG_SIZE; - // The Gates `apply_accept_quest` itself applies — this is a PREVIEW only (the CANT_TAKE_QUEST - // feedback line); the accept re-applies every one of them, so an inaccurate preview here could - // never grant a quest the member doesn't qualify for. An offline member has no entity to ask, and - // [`lyracore_shared::quest::share_result`] answers TOO_FAR for one before it reads this at all. - // The already-held Gate is answered ahead of this line as well, by HAVE_QUEST/FINISH_QUEST. - // ExclusiveGroup + reputation-requirement gates are OUT of scope. let gates_ok = member_entity .as_ref() .is_some_and(|e| accept_gates(ctx, e, tmpl).is_ok()); @@ -3180,8 +2708,6 @@ fn evaluate_share( ) } -/// How many of `character_guid`'s quest-log rows are ACTIVE (un-rewarded, un-failed) — the -/// `push_quest_to_party` LOG_FULL gate. fn active_quest_count(ctx: &ReducerContext, character_guid: u64) -> usize { ctx.db .game_character_quest() @@ -3191,10 +2717,6 @@ fn active_quest_count(ctx: &ReducerContext, character_guid: u64) -> usize { .count() } -/// Force-expire `character_guid`'s `quest_entry` row — the harness twin of [`quest_timer_pass`]'s -/// natural expiry (`debug_expire_quest`), for the timed-quest runbook without waiting out -/// `limit_time` seconds live. A no-op (`Err`) for a missing row or one already terminal -/// (rewarded/failed) — mirrors the tick's own idempotence. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] pub(crate) fn debug_force_expire( ctx: &ReducerContext, @@ -3232,12 +2754,6 @@ pub(crate) fn apply_abandon_quest( ctx.db.game_character_quest().id().delete(cq.id); Ok(()) } -// The XP-reward formula + its test live in `lyracore_shared::quest::xp_reward` (shared with the gateway's -// completion-popup display so the two can't drift). No module-local duplicate. -// -// The raid quest-credit rule (`allowed_in_raid`, `quest_progresses_for_raid`) + its tests live in -// `lyracore_shared::quest` for the same reason: the Gateway's loot window applies the identical -// rule. No module-local duplicate. #[cfg(test)] mod tests { @@ -3323,53 +2839,40 @@ mod tests { .is_err()); } - /// The pure choice-pick grants choice[k], NOT choice[j]: a 3-row pick-list returns the item whose - /// `choice_index` equals the requested `reward_index`, never a neighbor's. #[test] fn pick_choice_reward_grants_the_chosen_index() { - // (choice_index, item_entry, count) — three distinct items. let choices = [(0u8, 100u32, 1u32), (1, 200, 1), (2, 300, 5)]; assert_eq!(pick_choice_reward(&choices, 0, 7).unwrap(), Some((100, 1))); assert_eq!(pick_choice_reward(&choices, 1, 7).unwrap(), Some((200, 1))); - // choice[2] carries count 5 — the count rides with the chosen item. assert_eq!(pick_choice_reward(&choices, 2, 7).unwrap(), Some((300, 5))); - // Picking index 1 must NOT hand index 0's or 2's item. let got = pick_choice_reward(&choices, 1, 7).unwrap(); assert_ne!(got, Some((100, 1))); assert_ne!(got, Some((300, 5))); } - /// A quest with NO choice rows ignores `reward_index` (returns None → guaranteed-only turn-in). #[test] fn pick_choice_reward_no_choices_is_noop() { assert_eq!(pick_choice_reward(&[], 0, 7).unwrap(), None); - // Even a nonzero index is harmless when there are no choices. assert_eq!(pick_choice_reward(&[], 99, 7).unwrap(), None); } - /// A choice-quest with an out-of-range `reward_index` is REJECTED (Err), not silently granted nothing. #[test] fn pick_choice_reward_out_of_range_is_rejected() { let choices = [(0u8, 100u32, 1u32), (1, 200, 1)]; let err = pick_choice_reward(&choices, 2, 42).unwrap_err(); assert!(err.contains("invalid reward choice 2"), "err was: {err}"); assert!(err.contains("quest 42"), "err names the quest: {err}"); - // A wildly-out-of-range index is rejected just the same (no panic, no wrap). assert!(pick_choice_reward(&choices, u32::MAX, 42).is_err()); } - // ---- Timed quests: is_expired boundary tests ---- - #[test] fn untimed_deadline_never_expires() { - // deadline_micros == 0 is the untimed marker — never expires regardless of `now`. assert!(!is_expired(0, 0, false, false)); assert!(!is_expired(i64::MAX, 0, false, false)); } #[test] fn is_expired_is_inclusive_at_the_deadline_instant() { - // Exactly AT the deadline is expired (inclusive), one micro before is not. assert!(is_expired(1_000, 1_000, false, false)); assert!(!is_expired(999, 1_000, false, false)); assert!(is_expired(1_001, 1_000, false, false)); @@ -3377,9 +2880,7 @@ mod tests { #[test] fn is_expired_never_fires_on_a_terminal_row() { - // A REWARDED row never re-expires (turned in before the deadline hit, or the deadline is stale). assert!(!is_expired(5_000, 1_000, true, false)); - // An already-FAILED row never re-processes (idempotent — the tick must not loop forever). assert!(!is_expired(5_000, 1_000, false, true)); } diff --git a/module/src/realm_chat.rs b/module/src/realm_chat.rs index f89b11bd1..a4b83c8e4 100644 --- a/module/src/realm_chat.rs +++ b/module/src/realm_chat.rs @@ -1,67 +1,39 @@ -//! Realm Chat: non-proximity chat committed on Realm-core with its complete recipient list. -//! -//! The Module decides who hears a line, in the transaction that validates it, and writes the whole -//! audience on one [`RealmChatEvent`] row. Every Gateway receives that row once and delivers it to -//! its own World Sessions on any Shard. The Gateway reads the Speaker Facts on the speaker's Home -//! Shard before the call, because Realm-core holds no Characters and no live entities. -//! -//! A new Chat Kind adds one arm to [`audience`] and an audience rule in its family's own file. The -//! table, the reducer and the Relay stay as they are. On an unsharded Realm, Realm-core is the one -//! database, so both deployments run this same path. A whisper is several lines to two parties, -//! so it has its own reducer, [`realm_whisper`], and its own line plan, [`whisper_lines`]. - use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table, Timestamp}; use lyracore_shared::chat::{chat_kind, chat_tag, language, speakable_language, ChatRefusal}; -/// One Realm Chat Line. Private: the owner-token Coordinator is its only reader. Reaped by the -/// shared event GC. [event] #[table(accessor = game_realm_chat_event)] pub struct RealmChatEvent { #[primary_key] #[auto_inc] pub id: u64, - /// `lyracore_shared::chat::chat_kind`, the `ChatMsg` wire value. pub kind: u8, - /// The guid the packet names. For WHISPER_INFORM, AFK, DND and IGNORED that is the other party. pub speaker_guid: u64, - /// The wire `Language`. The addon language is `0xFFFF_FFFF`. pub language: u32, - /// `lyracore_shared::chat::chat_tag`: 0 none, 1 AFK, 2 DND. pub chat_tag: u8, - /// The CHANNEL display name. Empty for every other kind. pub channel_name: String, pub message: String, - /// Character guids: the Relay's whole audience. pub recipients: Vec, - /// A recipient who ignores `speaker_guid` does not get the line. pub ignorable: bool, pub created_at: Timestamp, } -/// What the Gateway read about the speaker on its Home Shard. #[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub struct SpeakerFacts { - /// `game_character.race`. Decides the languages the speaker knows. pub race: u8, - /// `lyracore_shared::chat::chat_tag_for` of the live entity's `PLAYER_FLAGS`. pub chat_tag: u8, } -/// One chat line a client sent, with the facts only the Gateway can read. #[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] pub struct RealmChatRequest { pub kind: u8, pub language: u32, - /// CHANNEL only. pub channel_name: String, - /// IGNORED only: the Character the ignorer's client dropped a line from. pub target_guid: u64, pub message: String, pub speaker: SpeakerFacts, } -/// Who hears one line, as a family's audience rule answers it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ChatAudience { pub(crate) recipients: Vec, @@ -69,7 +41,6 @@ pub(crate) struct ChatAudience { pub(crate) channel_name: String, } -/// A [`RealmChatEvent`] without `id` and `created_at`. [`emit`] stamps both. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct RealmChatLine { pub(crate) kind: u8, @@ -82,11 +53,6 @@ pub(crate) struct RealmChatLine { pub(crate) ignorable: bool, } -/// Commit one Realm Chat Line for `request_actor`. -/// -/// Operator-gated because the speaker is an argument: Realm-core has no live entity to derive it -/// from, so any other caller could speak as anybody in the Realm. A Refusal rolls the transaction -/// back and returns its stable tag. #[reducer] pub fn realm_chat( ctx: &ReducerContext, @@ -103,8 +69,6 @@ pub fn realm_chat( Ok(()) } -/// Validate one request and build its line. The message is checked first, then the language, then -/// the audience, so a line that fails two Gates reports the same Refusal every time. fn compose( speaker_guid: u64, request: RealmChatRequest, @@ -118,7 +82,6 @@ fn compose( .ok_or(ChatRefusal::EmptyMessage)?; let language = speakable_language(request.kind, request.speaker.race, request.language)?; let audience = audience(&request)?; - // The IGNORED notice carries no tag (cm:ChatHandler.cpp:813). let chat_tag = if request.kind == chat_kind::IGNORED { chat_tag::NONE } else { @@ -136,7 +99,6 @@ fn compose( }) } -/// One arm per audience family. Each family's own file owns its rule. fn audience( ctx: &ReducerContext, speaker_guid: u64, @@ -161,8 +123,6 @@ fn audience( } } -/// IGNORED: the ignorer's client dropped a line from `target_guid` and says so -/// (cm:ChatHandler.cpp:801-815). The notice goes to that Character alone. Nobody holds guid 0. fn ignored_audience(request: &RealmChatRequest) -> Result { if request.target_guid == 0 { return Err(ChatRefusal::UnsupportedKind); @@ -174,28 +134,16 @@ fn ignored_audience(request: &RealmChatRequest) -> Result String { let tag = refusal.as_tag(); spacetimedb::log::info!("chat refused {tag}: speaker {speaker_guid}"); @@ -375,8 +303,6 @@ mod tests { ); } - /// cm:ChatHandler.cpp:306-318 reads an addon payload raw. A trim would change the frame another - /// addon parses. #[test] fn an_addon_line_keeps_its_payload_untrimmed() { let line = compose( @@ -431,8 +357,6 @@ mod tests { } } - /// cm:ChatHandler.cpp:801-815: the notice names the ignorer, carries the ignorer's name and - /// no tag, and goes to the Character whose line was dropped. #[test] fn an_ignored_notice_goes_to_the_dropped_speaker_alone() { let line = compose(30, ignored(10), ignored_audience).unwrap(); @@ -499,8 +423,6 @@ mod tests { } } - /// cm:Player.cpp:16601-16622: the target hears the line in Universal with the speaker's tag, - /// and the speaker gets the echo naming the target with no tag. #[test] fn a_whisper_is_the_incoming_line_then_the_echo() { assert_eq!( @@ -512,7 +434,6 @@ mod tests { ); } - /// cm:Player.cpp:16630-16644: an away target adds its Auto-Reply after the echo. #[test] fn an_afk_target_adds_its_auto_reply_after_the_echo() { assert_eq!( @@ -536,8 +457,6 @@ mod tests { assert_eq!(lines[2], line(0x15, TARGET, 0, "Do not Disturb", SPEAKER)); } - /// cm:ChatHandler.cpp:801-815, fx:ChatFrame.lua:1403-1404: the ignorer gets nothing, and the - /// speaker keeps the echo and learns "Target is ignoring you." #[test] fn an_ignoring_target_gets_nothing_and_the_speaker_learns_it() { assert_eq!( @@ -561,7 +480,6 @@ mod tests { ); } - /// cm:ChatHandler.cpp:268-275: a Human whispering an Orc is refused. #[test] fn a_whisper_to_the_other_team_is_refused() { assert_eq!( @@ -580,7 +498,6 @@ mod tests { ); } - /// `SendAddonMessage` in 1.12 has no whisper target. #[test] fn an_addon_whisper_is_refused() { let mut request = whisper(1, 0, "", false); diff --git a/module/src/realm_core.rs b/module/src/realm_core.rs index ce5df024f..894ebd899 100644 --- a/module/src/realm_core.rs +++ b/module/src/realm_core.rs @@ -1,58 +1,15 @@ -//! Realm-core: the realm-wide **character→shard index** (spec). -//! -//! # What "realm-core" is -//! -//! Realm-core is not a new service and not a new crate — it is **this same module published under a -//! different database name** (`spacetime publish … realm-core`). Every table in the module exists on -//! every database; which tables are *authoritative* where is a deployment fact the GATEWAY knows, not -//! a schema fact. On realm-core the authoritative tables are `game_account`, `game_session`, and the -//! [`CharacterShard`] index below; on a world shard those first two are a write-through cache -//! and this index is a *forwarding receipt* (see below). Everything else in -//! the module is simply inert on realm-core, because nothing ever writes it there. -//! -//! That is the laziest structure that works: no second wasm crate, no shared-schema crate, no -//! conditional compilation — one artifact, N deployments, and the routing lives in the gateway where -//! every other routing decision already lives (`gateway/src/config.rs`'s `ShardMap`). -//! -//! # The index orders routing state -//! -//! `game_character_shard` answers "which (map, instance) — and therefore, through the shard map, -//! which database — owns this character". It stores the LOCATION rather than a database name on -//! purpose: a database name would be invalidated by every shard-map edit (an operator rebalancing a -//! pool would silently strand every indexed character), whereas a location is re-resolvable against -//! whatever map is loaded today. -//! -//! The gateway treats an entry as a hint it must confirm: it accepts the hint only when the shard it -//! names actually holds the character row, and otherwise falls back to probing the connected shards -//! and writes the corrected entry back (`gateway/src/realm_core.rs::locate_home_shard`, and the -//! tests there). So a stale — or entirely absent, or maliciously wrong — index entry costs one extra -//! cache lookup at login and then heals a settled row. Party partition projection uses only its -//! ordered Realm revision and pending phase; a Transfer compare-and-set prevents an old worker from -//! moving that state backward. - use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; -/// Character→shard index entry: "character `character_guid` lives at (`map_id`, `instance_id`)". -/// -/// PRIVATE (no `public`): this is realm operations data, not game state — a public table would hand -/// every connected client a directory of every character's whereabouts. The gateway's coordinator -/// connection authenticates with the owner token, which bypasses RLS, so it subscribes to it exactly -/// the way it already subscribes to the private `game_account` / `game_session`. [session] #[table(accessor = game_character_shard)] pub struct CharacterShard { #[primary_key] pub character_guid: u64, pub map_id: u32, pub instance_id: u64, - /// When this entry was written — diagnostics only (nothing reads it to decide anything; the - /// gateway confirms an entry by probing, never by trusting its age). pub updated_micros: i64, - /// Realm-core-owned order for settled partition changes. World Shard copies have their own - /// independent value and never certify a party member's remote partition. + // World Shard revisions are independent and cannot certify a remote party partition. #[default(1u64)] pub revision: u64, - /// Exact bot crossing that last advanced this Realm locator. Generic player/login repairs clear - /// these fields; a bot publication uses them for idempotence without trusting a destination. #[default(Identity::ZERO)] pub bot_source_identity: Identity, #[default(0u64)] @@ -67,30 +24,13 @@ pub struct CharacterShard { pub pending_destination_instance: u64, } -// A deleted character must not leave a directory entry behind: guids are reused (`create_character` -// allocates max-referenced + 1), so a stale entry could name a shard for a guid that now belongs to -// someone else. Harmless in itself — the gateway confirms every hint by probing — but one fewer wrong -// answer to reason about, for one line. crate::character_owned!(delete, fn sweep_delete_game_character_shard(ctx, character_guid) { ctx.db.game_character_shard().character_guid().delete(character_guid); }); -// CROSS-DATABASE transport: the directory entry deliberately does NOT ride the -// export blob. It is not character data — it is a routing HINT about WHERE the character is, and the -// blob's whole purpose is to change that. The source's row is snapshotted by `begin_transfer` while -// it still names the SOURCE, so importing it would give the destination a forwarding receipt -// pointing back at the shard the character just left: a login there would be bounced to a database -// that no longer holds it, until the gateway's probe healed it. The two writes that DO keep the -// directory honest are `transfer::do_finish` (the source's own row, rewritten to name the -// destination, in the escrow's transaction) and the gateway's `set_character_shard` against -// realm-core, which is the authoritative copy — driven as a required step of -// `world::transfer::run_transfer` immediately after `finish_transfer` commits, and -// re-driven by the login self-heal probe whenever the two disagree. +// Routing hints do not travel; the source snapshot still names the partition being left. crate::character_owned!(not_transported, fn sweep_transfer_game_character_shard()); -/// Upsert the index entry for `character_guid`. Shared by [`set_character_shard`] (the gateway's -/// write, on realm-core) and by `transfer::do_finish` (the module's write, in the SAME transaction -/// that releases the escrow). pub(crate) fn record_shard( ctx: &ReducerContext, character_guid: u64, @@ -135,12 +75,8 @@ pub(crate) fn record_shard( } } -/// Mark one crossing before the source is frozen. Party partition certification treats this Realm -/// phase as pending without trusting a World Shard cache that can change after it is sampled. -/// A human crossing with source revision zero requires an absent locator and inserts pending -/// revision one. Replays must name that same pending crossing; they cannot replace a settled row. #[reducer] -#[allow(clippy::too_many_arguments)] // Exact predecessor, destination, crossing, and Actor are the wire Gate. +#[allow(clippy::too_many_arguments)] pub fn begin_character_shard_transfer( ctx: &ReducerContext, character_guid: u64, @@ -224,10 +160,8 @@ pub fn begin_character_shard_transfer( Ok(()) } -/// Settle only the crossing that owns Realm-core's pending phase. Repeating the same settlement is -/// idempotent; any later crossing has a different predecessor revision and is left untouched. #[reducer] -#[allow(clippy::too_many_arguments)] // Exact predecessor, destination, crossing, and Actor are the wire Gate. +#[allow(clippy::too_many_arguments)] pub fn finish_character_shard_transfer( ctx: &ReducerContext, character_guid: u64, @@ -306,11 +240,8 @@ pub fn finish_character_shard_transfer( Ok(()) } -/// Resume the one Realm pending phase from an observed destination Character. This closes the -/// process-crash window after source finish, when the source plan is gone but the destination copy -/// and Realm phase still identify the same landing partition. #[reducer] -#[allow(clippy::too_many_arguments)] // Exact observed Realm phase, arrival crossing, and Actor are one CAS Gate. +#[allow(clippy::too_many_arguments)] pub fn finish_pending_character_shard_transfer( ctx: &ReducerContext, character_guid: u64, @@ -381,14 +312,6 @@ fn next_shard_revision(current: Option<&CharacterShard>, map_id: u32, instance_i } } -/// Publish a character's location into the realm-core index. Operator-gated like every other -/// gateway-coordination reducer (`provision_account` / `establish_session`): the index is a routing -/// input, so a client that could write it could redirect another player's login. -/// -/// The Gateway's one caller is the login self-heal in `realm_core::locate_home_shard`, which -/// `settle_home_shard` runs at every world entry: it reads this index first, confirms the hint on -/// the Shard it names, and rewrites the entry when the Shard that holds the Character disagrees. -/// A Transfer settles its destination through `finish_character_shard_transfer` instead. #[reducer] pub fn set_character_shard( ctx: &ReducerContext, @@ -412,34 +335,13 @@ pub fn set_character_shard( Ok(()) } -// --------------------------------------------------------------------------------------------- -// Guid-range registry — realm-core answers "which shard mints from where" -// --------------------------------------------------------------------------------------------- - -/// How many guids a slot owns. Slot *n* is `[n · GUID_RANGE_SIZE, (n+1) · GUID_RANGE_SIZE)`, which -/// makes the hand-applied floors (core 0, world-1 1e9, instances 2e9, realm-core 3e9) slots -/// 0/1/2/3 exactly — so the live databases migrate into this registry by being *recorded*, with no -/// guid changing. Decimal and small on purpose (`GuidRange`'s doc, `auth.rs`): readable in logs and SQL, -/// a billion issued identities per Shard shared by Characters and items. Current assignments are -/// far below `2^53`, above which `spacetime call` mangles a u64 -/// argument (danger-zones). pub const GUID_RANGE_SIZE: u64 = 1_000_000_000; -/// Which slot a database that has already minted up to `mark` is *actually* using. Pure. pub(crate) fn slot_of(mark: u64) -> u32 { (mark / GUID_RANGE_SIZE) as u32 } -/// Which slot to assign, given the slots already taken and the one the shard is already minting -/// from. Pure — the whole allocation decision, so the ordering property below is testable without a -/// database. -/// -/// **Order independence is the load-bearing property.** Handing out `max + 1` unconditionally would -/// make the outcome depend on the order the gateway happens to claim in: claim `world-1` (already -/// minting at 1e9, slot 1) first and it would take slot 0, then `lyracore` — which holds -/// guids 1..213, i.e. slot 0 — would be handed slot 1 and mint straight into world-1's live -/// characters. Preferring the shard's own slot when it is free makes every live shard land on the -/// range it is already using, in any order. +// Prefer the Shard's existing slot so claim order cannot change already issued GUID ranges. pub(crate) fn assign_slot(taken: impl Iterator, desired: u32) -> u32 { let taken: Vec = taken.collect(); if !taken.contains(&desired) { @@ -448,17 +350,11 @@ pub(crate) fn assign_slot(taken: impl Iterator, desired: u32) -> u32 taken.iter().max().map_or(0, |m| m + 1) } -/// Which shard owns which guid range. **Realm-core's table**, in the same sense as -/// [`CharacterShard`] above: it exists on every database and is authoritative on exactly one. -/// -/// PRIVATE for the same reason as the index — realm operations data, not game state. #[table(accessor = game_guid_range_registry)] pub struct GuidRangeAssignment { #[primary_key] pub shard_name: String, - /// `#[unique]` is the conflict-safety, not a convention: two gateways claiming at once cannot - /// both land on one slot, because the second insert violates this constraint and its whole - /// transaction aborts. + // The unique slot aborts a concurrent claim rather than assigning the same range twice. #[unique] pub slot: u32, pub base: u64, @@ -466,16 +362,6 @@ pub struct GuidRangeAssignment { pub assigned_micros: i64, } -/// Assign `shard_name` a guid range, once in that shard's lifetime. Idempotent: a shard that -/// already holds one keeps it — re-running this is how the gateway checks, so it must be free. -/// -/// `current_mark` is the shard's own `game_guid_allocator` high-water, which is what makes the -/// four live databases migrate onto their existing floors instead of being shuffled (see -/// [`assign_slot`]). The caller then installs the assigned base on the shard itself with -/// `auth::install_guid_range`; nothing here reaches across databases, because nothing can. -/// -/// Operator-gated like every other gateway-coordination reducer: a client that could write this -/// could hand a shard someone else's range. #[reducer] pub fn claim_guid_range( ctx: &ReducerContext, @@ -535,8 +421,6 @@ mod guid_range_registry_tests { #[test] fn a_shard_keeps_the_slot_it_is_already_minting_from_whatever_the_claim_order() { - // The four live databases, by their floors — claimed in the WORST order (highest - // first), which is exactly what a `max + 1` allocator would mangle. let live = [ ("world-1", 1_000_000_101u64), ("instances", 2_000_000_000), @@ -553,7 +437,6 @@ mod guid_range_registry_tests { ); taken.push(slot); } - // …and a brand-new shard, which has minted nothing, cannot be handed core's slot 0. let fresh = assign_slot(taken.iter().copied(), slot_of(0)); assert!( !taken.contains(&fresh), diff --git a/module/src/region.rs b/module/src/region.rs index f84200b0b..f97c653da 100644 --- a/module/src/region.rs +++ b/module/src/region.rs @@ -1,25 +1,12 @@ -//! Region definitions and epoch-versioned assignments. -//! The Module writes routing data for the Gateway and never branches on a Shard name. - use lyracore_shared::region::RegionMap; use spacetimedb::{reducer, table, ReducerContext, Table}; -/// `(map_id, region_id)` as one u64 primary key. Same trick as `terrain::cell_key` — SpacetimeDB -/// takes a single `#[primary_key]` column, and a packed key is cheaper than a scan-and-match. pub(crate) fn region_key(map_id: u32, region_id: u32) -> u64 { ((map_id as u64) << 32) | region_id as u64 } -/// One region definition: `region_id` on `map_id` covers the inclusive cell rectangle -/// `[gx_min..=gx_max] × [gy_min..=gy_max]`. Content data — replaced wholesale by -/// [`import_map_regions`], never written at runtime. [static] -/// -/// PRIVATE (no `public`): the seam menu is operations data. It is harmless in itself, but a client -/// has no use for it and the gateway's coordinator connection (owner token, RLS bypass) reads it -/// exactly the way it reads `game_character_shard`. #[table(accessor = game_map_region)] pub struct MapRegion { - /// [`region_key`]`(map_id, region_id)`. #[primary_key] pub key: u64, pub map_id: u32, @@ -30,39 +17,17 @@ pub struct MapRegion { pub gy_max: i32, } -/// `region_assignment { map_id, region_id, shard, epoch }` — which database owns a region, and at -/// which epoch. Authoritative on realm-core; inert everywhere else (nothing writes it there). -/// -/// PRIVATE: this table names the realm's databases. Never hand a client the topology. #[table(accessor = game_region_assignment)] pub struct RegionAssignment { - /// [`region_key`]`(map_id, region_id)`. #[primary_key] pub key: u64, pub map_id: u32, pub region_id: u32, - /// The DATABASE NAME that owns this region — gateway routing data. No module game logic reads - /// this column (`partition_discipline_tripwire` enforces it). pub shard: String, - /// Monotonic per `(map_id, region_id)`; see the type docs. pub epoch: u64, - /// When the flip landed — diagnostics only. pub updated_micros: i64, } -/// Replace the realm's whole seam menu with `packed` (the `RegionMap::parse` format). -/// Operator-only, like every other importer. -/// -/// Rejects the WHOLE payload if any line is malformed or breaks the geometry rules (overlap, the -/// ~10×10 cell floor, a reserved region id) rather than importing a partial menu: this is content -/// data with an operator watching, so failing loudly beats a silently half-drawn map. A dropped row -/// would leave its cells on region 0, which routes through the plain shard map — safe, but not what -/// the operator asked for. -// Deliberate simplification: replace-everything, no append variant and no per-map scoping. A -// realm's entire seam menu is a few dozen lines of text (~3-6 useful regions per zone, per the -// spec's floor analysis), so it fits in one `spacetime call` argument with room to spare. Ceiling: -// importing one zone's menu means passing the whole realm's file. Upgrade path: the `_append` -// twin `terrain.rs` already demonstrates, if a menu ever outgrows one call. #[reducer] pub fn import_map_regions(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -93,27 +58,10 @@ pub fn import_map_regions(ctx: &ReducerContext, packed: String) -> Result<(), St Ok(()) } -/// The epoch rule, as a pure function so it can be tested without a node: an assignment may only be -/// replaced by a STRICTLY higher epoch. The first assignment for a region accepts any epoch (there -/// is nothing to supersede). Equal is rejected too — two operators picking the same number is a -/// race, and the second one should hear about it rather than silently win or silently lose. pub(crate) fn epoch_accepted(current: Option, incoming: u64) -> bool { current.is_none_or(|c| incoming > c) } -/// What [`set_region_assignment`] must write, decided PURELY (the reducer only executes it) so the -/// epoch rule *and* the shape of an un-assign are testable without a node. -/// -/// `Ok(shard)` — store this shard name at the incoming epoch. **`Ok("")` is a TOMBSTONE**: an -/// un-assigned region, which the gateway routes exactly like a missing row. `Err(current)` — refuse, -/// the stored row is already at that epoch or newer. -/// -/// An un-assign is a tombstone rather than a delete, and that is the whole point: deleting the row -/// would drop the epoch high-water mark with it, so the very next stale retry -/// (`set_region_assignment 0 2 pool-b 1` after an un-assign at epoch 2) would find `current = None`, -/// be accepted, and RESURRECT the assignment the un-assign just superseded. A tombstone keeps the -/// mark, and the gateway already treats an empty shard as "no region opinion", so routing is -/// unchanged either way. pub(crate) fn assignment_write( current: Option, incoming: u64, @@ -125,18 +73,6 @@ pub(crate) fn assignment_write( Ok(shard.trim().to_string()) } -/// Flip a region's shard assignment (operator-only). Called against **realm-core**. -/// -/// - `epoch` must be strictly greater than the current row's, so a stale retry cannot undo a newer -/// flip. Equal or lower is an error, not a silent no-op — an operator who sees "stale epoch" -/// knows someone else moved first. -/// - An EMPTY `shard` un-assigns the region: the row is kept as a TOMBSTONE at the new epoch and the -/// region falls back to the ordinary `(map_id, instance_id)` shard map, i.e. to today's behavior. -/// That is how a demo flip is reverted, and it costs an epoch bump so the revert itself is -/// ordered — and, because the row survives, so is everything after it ([`assignment_write`]). -/// - The region's GEOMETRY is deliberately not checked: definitions are imported on the world -/// shards by the ETL, and realm-core does not run it. An assignment naming a region no gateway -/// can resolve routes nobody, which is the same no-op as having no row at all. #[reducer] pub fn set_region_assignment( ctx: &ReducerContext, @@ -185,10 +121,6 @@ mod tests { #[test] fn only_a_strictly_higher_epoch_may_replace_an_assignment() { - // Epoch semantics, module half: a retried or reordered operator call can never resurrect a - // superseded assignment, and two operators picking the same number is an error rather than - // a coin flip. The gateway half (highest epoch wins among the rows it can see) is pinned by - // `the_highest_epoch_wins_...` in `gateway/src/config.rs`. assert!( epoch_accepted(None, 0), "the first assignment accepts any epoch" @@ -205,11 +137,6 @@ mod tests { #[test] fn an_unassign_is_a_tombstone_so_a_stale_retry_cannot_resurrect_the_old_assignment() { - // The adversarial case the delete-on-unassign form got WRONG: - // 1. flip region 2 to pool-b at epoch 2, 2. flip back (empty shard) at epoch 3, - // 3. a retried/reordered `… pool-b 2` arrives. - // With the row deleted at step 2, step 3 sees `current = None`, is ACCEPTED, and the region - // is silently back on pool-b at a superseded epoch. With the tombstone, it is refused. assert_eq!( assignment_write(None, 2, "pool-b"), Ok("pool-b".to_string()) @@ -224,7 +151,6 @@ mod tests { Err(3), "the stale retry is refused" ); - // And the tombstone is not a wall: the operator's next real flip still lands. assert_eq!( assignment_write(Some(3), 4, " pool-c "), Ok("pool-c".to_string()) @@ -233,8 +159,6 @@ mod tests { #[test] fn region_keys_are_unique_per_map_and_region() { - // The packed key is the primary key: a collision would let one region silently overwrite - // another's definition or assignment. `region_id` occupies the low half, `map_id` the high. let keys = [ region_key(0, 1), region_key(0, 2), diff --git a/module/src/reputation.rs b/module/src/reputation.rs index 951adb32f..fadcab793 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -1,19 +1,7 @@ -//! Per-player reputation standing. Mirrors [`crate::spell::spellbook`]'s `game_player_spell`: a -//! per-player, owner-scoped, durable store with one row per (character, faction). `grant_reputation` is -//! the single mutation path — quest turn-ins call it today; creature-kill rep is a later slice (vanilla -//! kill-rep needs per-creature `RewRep` data we don't import yet). Only player-facing factions -//! (`game_faction.reputation_index != -1`) get a row; the gateway relays `SMSG_SET_FACTION_STANDING` on -//! insert/update so the client's reputation bar moves live, and folds stored standings into the login -//! `SMSG_INITIALIZE_FACTIONS` so a relog doesn't reset the rep pane to neutral. [entity] - use spacetimedb::{table, Identity, ReducerContext, Table}; use crate::{game_character, game_faction, game_faction_template, game_world_entity}; -/// A character's current standing with one faction. Per-player, owner-scoped (RLS like -/// `game_player_spell`). Logical key `(character_guid, faction_id)` via an `#[auto_inc]` PK + -/// `by_character` btree. Durable (standing persists across logout). `standing` is the raw reputation -/// value clamped to vanilla's [`REP_MIN`, `REP_MAX`]; the client maps it to Hated..Exalted itself. [entity] #[table(accessor = game_player_reputation, public, index(accessor = by_character, btree(columns = [character_guid])))] pub struct PlayerReputation { #[primary_key] @@ -23,45 +11,25 @@ pub struct PlayerReputation { pub owner_identity: Identity, pub faction_id: u32, pub standing: i32, - // END-APPENDED (#[default(0)] → additive auto-migrate). - // The Faction.dbc ReputationListID — the SMALL 0..63 index the 5875 client's SMSG_SET_FACTION_STANDING - // addresses in its rep array. The client does NOT key on faction_id: sending faction_id 72 (Stormwind) - // instead of its rep-index 19 indexes PAST the 64-slot array → null deref → ERROR crash on the first - // rep-granting turn-in (Marshal McBride). We STORE it here (grant_reputation has the faction lookup) so the - // gateway relay sends the right index without joining game_faction (which it doesn't bind). grant_reputation - // only ever writes rows for rep-bar factions and ALWAYS stamps the real index, so a row never carries the - // 0 filler in practice; the 4 pre-migration rows are backfilled at deploy. + // Client reputation slots are 0..63 and differ from Faction.dbc ids. #[default(0)] pub reputation_index: i32, - // END-APPENDED: the player checked "At War" for this faction in the rep pane - // (CMSG_SET_FACTION_ATWAR). Persisted so the checkbox survives relog (folded into the login - // SMSG_INITIALIZE_FACTIONS flag byte); the gateway's interaction-reaction gate treats an - // at-war faction's NPCs as hostile. Defaulted bool → additive auto-migrate. #[default(false)] pub at_war: bool, } -// Character-owned sweep: reputation is durable per-character data, deleted along with its -// character (mirrors the item/spell/skill/talent/quest sweeps). crate::character_owned!(delete, fn sweep_delete_game_player_reputation(ctx, character_guid) { let reps = ctx.db.game_player_reputation(); for r in reps.by_character().filter(&character_guid).collect::>() { reps.id().delete(r.id); } }); -// CROSS-DATABASE transport: standings are durable progression, and the login -// SMSG_INITIALIZE_FACTIONS is built from them — a character arriving without them shows every -// faction at neutral. `id` is a surrogate PK, re-minted. crate::character_owned!(transfer, fn sweep_transfer_game_player_reputation(ctx, character_guid, io) { table = game_player_reputation, by = by_character, remint = id, }); -// Restamp sweep: reputation carries `owner_identity` + an RLS filter like items/spells/skills/ -// talents/quests, so it needs the same re-owning on a gateway-restart relog or the rows go -// RLS-invisible to their own player. No-op when the identity already matches (mirrors -// sweep_restamp_game_item_instance). crate::character_owned!(restamp, fn sweep_restamp_game_player_reputation(ctx, character_guid, identity) { let reps = ctx.db.game_player_reputation(); for mut r in reps.by_character().filter(&character_guid).collect::>() { @@ -72,21 +40,13 @@ crate::character_owned!(restamp, fn sweep_restamp_game_player_reputation(ctx, ch } }); -/// vanilla reputation floor/ceiling: Hated (−42000) .. Exalted (+42000). The standing is clamped here; -/// the client renders the band (Hated/Hostile/.../Exalted) from the raw value, so we store raw points. const REP_MIN: i32 = -42000; const REP_MAX: i32 = 42000; -/// Reputation RANK (vanilla `ReputationRank`, 0=Hated .. 7=Exalted, Neutral=3) for a raw standing. -/// Thresholds are the raw lower bounds mangos uses. Pure — unit-tested. `pub(crate)` so vendor pricing -/// can read it. Neutral is the baseline (an unknown faction → 0 standing → Neutral). pub(crate) fn reputation_rank(standing: i32) -> u8 { lyracore_shared::faction::reputation_rank(standing) } -/// Vendor buy-price discount PERCENT for a raw standing. Vanilla `GetReputationPriceDiscount`: -/// 5% per rank ABOVE Neutral — Friendly 5, Honored 10, Revered 15, Exalted 20; Neutral and below give 0. -/// Buy only (sell is unchanged). Pure — unit-tested. pub(crate) fn reputation_discount_pct(standing: i32) -> u32 { const NEUTRAL: u8 = 3; let rank = reputation_rank(standing); @@ -97,10 +57,6 @@ pub(crate) fn reputation_discount_pct(standing: i32) -> u32 { } } -/// The vendor buy-price discount PERCENT `player_guid` gets at a creature whose FactionTemplate is -/// `faction_template_id`. Resolves the template → its parent Faction.dbc id → the player's standing -/// with that faction → `reputation_discount_pct`. 0 when the vendor has no parent faction, the faction -/// has no rep bar, or the player has no standing row (Neutral). Reuses the `grant_reputation` lookup idiom. pub(crate) fn vendor_discount_pct( ctx: &ReducerContext, player_guid: u64, @@ -115,7 +71,7 @@ pub(crate) fn vendor_discount_pct( return 0; }; if tmpl.faction == 0 { - return 0; // no parent faction → no reputation to discount on + return 0; } let standing = ctx .db @@ -124,12 +80,10 @@ pub(crate) fn vendor_discount_pct( .filter(&player_guid) .find(|r| r.faction_id == tmpl.faction) .map(|r| r.standing) - .unwrap_or(0); // no row → Neutral + .unwrap_or(0); reputation_discount_pct(standing) } -/// Whether an NPC's imported faction relationship refuses a player interaction. -/// Missing faction data fails open so unfactioned fixtures remain usable. pub(crate) fn npc_refuses_interaction( ctx: &ReducerContext, npc: &crate::WorldEntity, @@ -160,20 +114,12 @@ pub(crate) fn npc_refuses_interaction( lyracore_shared::faction::refuses_interaction(reputation, template_hostile) } -/// Add `amount` reputation (may be negative) for `player_guid` with `faction_id`, clamped to -/// [`REP_MIN`, `REP_MAX`]. Adds to the existing row, or creates one seeded at the faction's `base_standing` -/// (the Human starting value). NO-OP when the faction has no reputation bar -/// (`game_faction.reputation_index == -1` — wildlife/monsters the client can't display), or when the -/// faction / player is unknown. The gateway's `game_player_reputation` relay sends -/// `SMSG_SET_FACTION_STANDING` on the resulting insert/update. Quest turn-in is the only caller today. [entity] pub(crate) fn grant_reputation( ctx: &ReducerContext, player_guid: u64, faction_id: u32, amount: i32, ) { - // Only player-facing factions (a rep bar) get a row — skip wildlife/monster factions (index -1) and - // unknown faction ids; the client has nowhere to show them. let (base, rep_index) = match ctx.db.game_faction().faction_id().find(faction_id) { Some(f) if f.reputation_index >= 0 => (f.base_standing, f.reputation_index), _ => return, @@ -185,12 +131,10 @@ pub(crate) fn grant_reputation( .find(|r| r.faction_id == faction_id) { row.standing = (row.standing.saturating_add(amount)).clamp(REP_MIN, REP_MAX); - row.reputation_index = rep_index; // re-stamp (heals a stale pre-migration -1 row on the next grant) + row.reputation_index = rep_index; reps.id().update(row); return; } - // First gain with this faction → seed at base + amount. owner_identity from the live entity (fallback - // the durable character row) so the new row is RLS-visible to the player's connection. let Some(owner) = ctx .db .game_world_entity() @@ -205,7 +149,7 @@ pub(crate) fn grant_reputation( .map(|c| c.owner_identity) }) else { - return; // unknown player + return; }; reps.insert(PlayerReputation { id: 0, @@ -218,15 +162,12 @@ pub(crate) fn grant_reputation( }); } -/// The At-War core, actor-explicit: everything [`set_faction_at_war`] does after resolving -/// WHOSE rep pane this is. Takes the row — the seed arm stamps `owner_identity` off it. pub(crate) fn apply_set_faction_at_war( ctx: &ReducerContext, player: crate::WorldEntity, reputation_index: u32, at_war: bool, ) -> Result<(), String> { - // Reverse map: rep-array slot → faction. The import claims each used slot exactly once. let Some(faction) = ctx .db .game_faction() @@ -242,7 +183,7 @@ pub(crate) fn apply_set_faction_at_war( .find(|r| r.faction_id == faction.faction_id) { row.at_war = at_war; - row.reputation_index = reputation_index as i32; // heal a stale pre-migration filler in passing + row.reputation_index = reputation_index as i32; reps.id().update(row); return Ok(()); } @@ -258,7 +199,6 @@ pub(crate) fn apply_set_faction_at_war( Ok(()) } -/// Save the watched slot without changing standing, visibility or at-war state. pub(crate) fn apply_set_watched_faction( ctx: &ReducerContext, character_guid: u64, @@ -283,22 +223,22 @@ mod rep_tests { #[test] fn rank_thresholds_match_vanilla() { - assert_eq!(reputation_rank(-42000), 0); // Hated (bottom) - assert_eq!(reputation_rank(-1), 2); // just under Neutral → Unfriendly - assert_eq!(reputation_rank(0), 3); // Neutral floor - assert_eq!(reputation_rank(3000), 4); // Friendly floor - assert_eq!(reputation_rank(9000), 5); // Honored floor - assert_eq!(reputation_rank(21000), 6); // Revered - assert_eq!(reputation_rank(42000), 7); // Exalted + assert_eq!(reputation_rank(-42000), 0); + assert_eq!(reputation_rank(-1), 2); + assert_eq!(reputation_rank(0), 3); + assert_eq!(reputation_rank(3000), 4); + assert_eq!(reputation_rank(9000), 5); + assert_eq!(reputation_rank(21000), 6); + assert_eq!(reputation_rank(42000), 7); } #[test] fn discount_is_5pct_per_rank_above_neutral() { - assert_eq!(reputation_discount_pct(0), 0); // Neutral → full price - assert_eq!(reputation_discount_pct(-10000), 0); // hostile → no discount (never a surcharge) - assert_eq!(reputation_discount_pct(3000), 5); // Friendly → 5% + assert_eq!(reputation_discount_pct(0), 0); + assert_eq!(reputation_discount_pct(-10000), 0); + assert_eq!(reputation_discount_pct(3000), 5); assert_eq!(reputation_discount_pct(9000), 10); - assert_eq!(reputation_discount_pct(21000), 15); // Revered → 15% - assert_eq!(reputation_discount_pct(42000), 20); // Exalted → 20% + assert_eq!(reputation_discount_pct(21000), 15); + assert_eq!(reputation_discount_pct(42000), 20); } } diff --git a/module/src/rest.rs b/module/src/rest.rs index 8aeba91cf..4b979b5fd 100644 --- a/module/src/rest.rs +++ b/module/src/rest.rs @@ -1,23 +1,11 @@ -//! Rest state: entering an inn/rest-area sets the RESTED flag — the client draws the zzz icon and -//! turns the XP bar blue (PLAYER_BYTES_2 byte 3) — accrues rested XP LIVE at the full rate while resting, -//! and (via the durable `Character.resting` stamp) makes an OFFLINE logout in a rest area accrue at the -//! full rate vs 1/4 in the open field. Rest-area geometry is a hand-authored fixture (the Lion's Pride -//! Inn) until the AreaTrigger.dbc + tavern-list import lands — the same fixture-first pattern as the -//! graveyards (`world::graveyard::STATIC_CANDIDATES`). Instant logout in a rest area is already the -//! behaviour: the gateway hardcodes `LogoutSpeed::Instant` (there is no 20s field timer to skip). - use spacetimedb::{table, ReducerContext, Table, Timestamp}; -use crate::game_character; // accessor trait +use crate::game_character; #[cfg(feature = "debug_reducers")] -use crate::game_world_entity; // accessor trait (debug_check_rest_at) +use crate::game_world_entity; use crate::world::WorldEntity; use lyracore_shared::packing; -/// A rest-area trigger sphere. 2-D radius test (z-blind, like the graveyard fixtures). Deliberate -/// simplification: a flat const list is exact for the fixture slice; swap for an imported -/// `game_rest_trigger` table when the AreaTrigger.dbc tavern list lands (the graveyard -/// `game_graveyard`-seeded precedent). struct RestTrigger { map_id: u32, x: f32, @@ -26,8 +14,7 @@ struct RestTrigger { } const REST_TRIGGERS: &[RestTrigger] = &[ - // Lion's Pride Inn, Goldshire (Elwynn, map 0). Center + a radius covering the interior. FIXTURE - // ESTIMATE: confirm the exact threshold against the real inn on a real client. + // The Goldshire inn threshold is a fixture estimate. RestTrigger { map_id: 0, x: -9464.0, @@ -36,9 +23,6 @@ const REST_TRIGGERS: &[RestTrigger] = &[ }, ]; -/// Is `(map,x,y)` inside any rest-area trigger? Pure + cheap (a map check + a few float ops over the -/// fixture list) — this is the whole hot-path cost of the per-heartbeat rest check; the DB work below -/// only runs on a threshold crossing. fn in_rest_area(map_id: u32, x: f32, y: f32) -> bool { REST_TRIGGERS.iter().any(|t| { t.map_id == map_id && { @@ -48,10 +32,6 @@ fn in_rest_area(map_id: u32, x: f32, y: f32) -> bool { }) } -/// Rest-state descriptor event → the gateway relays PLAYER_BYTES_2 so the client flips the zzz icon + -/// blue XP bar the instant you cross an inn threshold. Public/broadcast (like the explored relay); the -/// gateway filters to self. Carries the FULL `player_bytes_2` because a partial VALUES overwrites the -/// whole field — byte 0 (facial hair) must ride along, only byte 3 (rest state) changes. #[table(accessor = game_rest_state_event, public)] pub struct RestStateEvent { #[primary_key] @@ -62,43 +42,28 @@ pub struct RestStateEvent { pub created_at: Timestamp, } -// Character-owned sweep: a rest-state event is per-character relay data, deleted with its character. -// Public (no `owner_identity`, no per-owner RLS) → `delete` only, no `restamp`. crate::character_owned!(delete, fn sweep_delete_game_rest_state_event(ctx, character_guid) { let ev = ctx.db.game_rest_state_event(); for r in ev.iter().filter(|r| r.character_guid == character_guid).collect::>() { ev.id().delete(r.id); } }); -// CROSS-DATABASE transport: a rest-state event is a one-shot RELAY row with a GC TTL — -// the gateway turns it into one PLAYER_BYTES_2 packet and it is done. It carries no durable state -// (the durable half is `Character.resting`/`rested_xp`, which rides the character row), so carrying -// it would replay a stale zzz-icon flip at the destination. Exports NOTHING, deliberately: the arm -// exists so the "every manifest table has a transport" ratchet stays a ratchet, and so this decision -// is written down at the table instead of being an omission nobody notices. crate::character_owned!(not_transported, fn sweep_transfer_game_rest_state_event()); -/// Called every movement heartbeat (NOT grid-gated — an inn is smaller than a 50yd grid cell, so a -/// coarse crossing check would miss it). HOT PATH: a cheap `in_rest_area` test vs the already-loaded -/// `mover.resting` — an in-memory compare, zero DB. Only a real CROSSING flips the flag: swaps the -/// PLAYER_BYTES_2 rest byte (mutates `mover`, folded into the caller's single entity update), (re)stamps -/// the durable live-accrual clock, and emits the relay event. `mover.resting` is restored from -/// `Character.resting` at spawn and persisted back at logout, so it survives a relog. pub(crate) fn check_rest_state(ctx: &ReducerContext, mover: &mut WorldEntity) { let now_resting = in_rest_area(mover.map_id, mover.x, mover.y); if now_resting == mover.resting { - return; // no crossing — the common case, no DB touched + return; } - mover.resting = now_resting; // in-memory; folded into the caller's `entities.guid().update(mover)` + mover.resting = now_resting; let now = ctx.timestamp.to_micros_since_unix_epoch(); let chars = ctx.db.game_character(); if let Some(mut c) = chars.guid().find(mover.guid) { c.resting = now_resting; if now_resting { - c.rested_since_micros = now as u64; // start the live-accrual clock + c.rested_since_micros = now as u64; } else { - // Leaving: bank whatever accrued this stay, then stop the clock. let elapsed = (now as u64).saturating_sub(c.rested_since_micros); c.rested_xp = crate::xp::rest_accrue_live(c.rested_xp, elapsed, c.level as u32); c.rested_since_micros = 0; @@ -106,7 +71,6 @@ pub(crate) fn check_rest_state(ctx: &ReducerContext, mover: &mut WorldEntity) { chars.guid().update(c); } - // Swap byte 3 (rest state), keep byte 0 (facial hair) + bytes 1-2. Fold into the caller's update. let rest_byte = if now_resting { packing::REST_STATE_RESTED } else { @@ -122,9 +86,6 @@ pub(crate) fn check_rest_state(ctx: &ReducerContext, mover: &mut WorldEntity) { }); } -/// Bank the live rested accrual onto a character at logout and stop the clock (the offline path takes -/// over from `last_logout_micros`). Called by `persist_entity` on a real logout so no online accrual is -/// lost before the offline span starts. No-op when the clock is already stopped (`rested_since == 0`). pub(crate) fn materialize_on_logout(c: &mut crate::character::Character, now_micros: i64) { if c.rested_since_micros == 0 { return; @@ -134,11 +95,6 @@ pub(crate) fn materialize_on_logout(c: &mut crate::character::Character, now_mic c.rested_since_micros = 0; } -/// Headless trigger for the rest-state hook at an EXPLICIT position — the machine-test counterpart to -/// the movement_update hook (debug_teleport writes position directly and never calls movement_update, so -/// it never fires the rest check). Sets the entity's position to (map,x,y) and runs `check_rest_state` -/// ATOMICALLY (the whole reducer is one transaction), so a stay-session heartbeat can't race the -/// position. A connected client's on_insert relay fires for the event this emits. [entity] #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_check_rest_at( @@ -157,35 +113,14 @@ pub fn debug_check_rest_at( Ok(()) } -// Live rested accrual: while resting ONLINE, grow the pool at the full rest rate. Lossless — -// materialize from the fixed `rested_since` clock and only advance the clock once the increment banks -// ≥1 XP, so short ticks (where the sub-1-XP increment rounds to 0) don't lose accrual. Self-quantized to -// ~30s: this pass fires every 0.5s `tick_creatures` tick, but rested is ~0.1 XP/min so cadence only sets -// the write frequency, not the amount. `rested_since > 0` scopes the scan to ONLINE resting characters -// (logout stops the clock), so offline chars — which accrue via the login path — are never double-paid. crate::game_tick_pass!(fn rested_accrue_pass(ctx) { let now = ctx.timestamp.to_micros_since_unix_epoch(); const QUANTUM_MICROS: i64 = 30_000_000; const TICK_MICROS: i64 = 500_000; if now.rem_euclid(QUANTUM_MICROS) >= TICK_MICROS { - return; // fire the scan once per ~30s window + return; } let chars = ctx.db.game_character(); - // Deliberate simplification: full game_character scan every 30s; add a `by_resting` index if - // the table grows large. - // - // REFUSE verdict. This pass is BACKGROUND, not caller-driven, so "the guid came from - // a live entity" — the argument that fences every other rest write — does not apply: the filter - // reads the DURABLE row's flags and nothing here ever looks at `game_world_entity`. - // `begin_transfer` persists with `set_offline: false`, and it is the `set_offline` branch of - // `persist_entity` that would have called `materialize_on_logout` to stop the clock — so a - // character escrowed while standing in an inn keeps `resting == true` and a running - // `rested_since_micros`, and without this gate the pass keeps writing `rested_xp` to a frozen - // row every 30s for the whole hop (indefinitely, when `recovery` answers `Hold`). - // - // Refusing is LOSSLESS here, which is why it is the right verdict rather than a defer: the - // accrual is materialised from the fixed `rested_since_micros` clock and the clock is left - // running, so the first pass after the transfer settles banks the entire span in one step. let resting: Vec = chars .iter() .filter(|c| c.resting && c.rested_since_micros > 0) @@ -196,7 +131,7 @@ crate::game_tick_pass!(fn rested_accrue_pass(ctx) { let grown = crate::xp::rest_accrue_live(c.rested_xp, elapsed, c.level as u32); if grown > c.rested_xp { c.rested_xp = grown; - c.rested_since_micros = now as u64; // advance only when we banked ≥1 XP (lossless) + c.rested_since_micros = now as u64; chars.guid().update(c); } } diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index 64b9e5ac3..e9e314420 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -1,4 +1,4 @@ -//! Runtime Script Host. See `docs/runtime-script-host.md` for the contract and rationale. +//! Before changing Runtime Script execution, read docs/runtime-script-host.md. use std::cell::RefCell; use std::collections::HashMap; @@ -12,57 +12,25 @@ use piccolo::{ MetaMethod, StashedClosure, Table, UserData, Value, }; -/// Fuel handed to one `Executor::step`. The interpreter checks its budget between operations, so a -/// step can overshoot slightly. The Host refuses the Invocation if that overshoot crosses the total -/// budget. +// Piccolo may overshoot one step; enforce the total Fuel Budget after each step. const FUEL_PER_STEP: i32 = 64; -/// Metered interpreter work one Invocation may perform before it is cut off as a fuel failure. -/// -/// Sized by measurement, not by guess. `REPRESENTATIVE_SCRIPT` — the transpiler-shaped workload of -/// list building, a higher-order function, string work and a host call that this host exists to -/// run — costs 2,054 fuel over 30 steps, so the budget is roughly a hundred of those. Fifty times -/// its list still costs only 96,442. At the other end, a bare `while true do end` reaches this -/// number in under a millisecond of interpreter time, which keeps a runaway script off the 0.5s -/// tick. const FUEL_BUDGET_PER_INVOCATION: i32 = 200_000; -/// Hard cap on `Executor::step` calls per invocation. -/// -/// The fuel budget is the real limit: a working script spends about 68 fuel per step, so the -/// budget above runs out around 3,000 steps and this cap never fires. It exists for the step that -/// returns having burned NO fuel, which would otherwise loop here forever with the budget -/// untouched. +// Bound zero-fuel steps too, or an Invocation could loop without spending its Fuel Budget. const MAX_STEPS_PER_INVOCATION: usize = 20_000; -/// Hard cap, in bytes, on the script-authored text recorded in a [`ScriptDiagnostic`]. A failing -/// script controls its own error message, so this is the only unbounded part of a diagnostic; the -/// script name and event are host-supplied labels. const DIAGNOSTIC_MESSAGE_CAP: usize = 512; -/// Maximum bytes retained for each host-supplied diagnostic label. const DIAGNOSTIC_LABEL_CAP: usize = 128; const TRUNCATION_MARK: &str = "…[truncated]"; -/// Largest amount any host operation accepts. Far above any authored heal or experience award, so a -/// bigger number is a script defect rather than a design; refusing it reads as a Script Diagnostic -/// instead of disappearing into a clamp against max health. const MAX_EFFECT_AMOUNT: i64 = 1_000_000; -/// Most Staged Effects one Invocation may hold. The Fuel Budget meters interpreter work, not -/// durable writes, and a tight loop around a host call buys thousands of them inside the budget. -/// This is the bound on what one script can ask a single reducer transaction to perform. Crossing -/// it fails the Invocation, so a script cannot half-commit its way past the cap either. +// Fuel meters interpreter work; this separate cap bounds staged durable effects. const MAX_STAGED_EFFECTS_PER_INVOCATION: usize = 256; -/// The exact global names an Invocation receives from piccolo's core library. -/// -/// Deliberately absent: `collectgarbage` (interpreter state, not gameplay), `coroutine` (an -/// Invocation is one straight run under one Fuel Budget, so a suspended thread has nothing to -/// resume into), and `_G` (globals-as-state does not survive an Invocation, so handing a script a -/// mirror of its environment only invites the idiom that cannot work). `print` never arrives -/// because the Host builds on `Lua::core()`, which loads no I/O library. const ALLOWED_GLOBALS: &[&str] = &[ "assert", "error", @@ -79,15 +47,6 @@ const ALLOWED_GLOBALS: &[&str] = &[ "type", ]; -/// The allowlisted members of each standard-library table, by library name. -/// -/// Each library is rebuilt from these names into a table made for the Invocation, so a member this -/// list omits is nil and a write to one of these tables is invisible to the next Invocation. -/// -/// `math.random` and `math.randomseed` are omitted: the Module's `getrandom` backend is a fixed -/// stream on purpose (see [`fixed_entropy`]), so `math.random` returns the same sequence on every -/// replica and every replay. Durable randomness comes from `ReducerContext::rng` on the Module -/// side of a host operation, never from the interpreter. const ALLOWED_LIBRARY_MEMBERS: &[(&str, &[&str])] = &[ ( "math", @@ -120,20 +79,9 @@ const ALLOWED_LIBRARY_MEMBERS: &[(&str, &[&str])] = &[ ], ), ("string", &["len", "lower", "reverse", "sub", "upper"]), - // `concat` is the shim's, loaded into the shared globals at Host construction. ("table", &["concat", "pack", "unpack"]), ]; -/// piccolo's stdlib subset omits `table.concat`, which transpiler output uses constantly. Loaded -/// once into the shared globals at host construction, in the form the Runtime Script Prototype -/// proved. -/// -/// Two parameters and a type guard, both deliberate: pinned piccolo passes an inline table -/// constructor's ELEMENT COUNT as an extra argument, so `table.concat({"a", "b"})` arrives here as -/// `("a", "b" table, 2)`. A two-parameter function drops that extra value, and the guard catches -/// the one-argument case where the count lands in `separator`. See -/// `piccolo_leaks_a_table_constructors_element_count_as_an_extra_argument`. The `i`/`j` range -/// arguments are left out for the same reason — with the leak they cannot be told from real ones. const PICCOLO_SHIM: &str = r#" table.concat = function(list, separator) if type(separator) ~= "string" then separator = "" end @@ -146,14 +94,10 @@ table.concat = function(list, separator) end "#; -/// Why an invocation was abandoned. Every one of these discards the invocation's staged effects. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum FailureKind { - /// The source did not compile. Syntax, - /// The script raised an error while running. Runtime, - /// The script ran past its fuel budget or its step cap. Fuel, } @@ -167,8 +111,6 @@ impl FailureKind { } } -/// The bounded record of one failed invocation. Names the script, the event it was running for, -/// and what kind of failure it was, so an Operator can act on it without interpreter internals. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct ScriptDiagnostic { pub script: String, @@ -201,8 +143,6 @@ impl std::fmt::Display for ScriptDiagnostic { } } -/// Cuts `text` down to `cap` bytes including the truncation mark, on a char boundary so the result -/// remains valid UTF-8. fn bounded(text: &str, cap: usize) -> String { if text.len() <= cap { return text.to_string(); @@ -216,12 +156,6 @@ fn bounded(text: &str, cap: usize) -> String { out } -/// A gameplay operation a Runtime Script asked for. Held, not performed, until the invocation -/// that staged it succeeds. -/// -/// Every guid here was resolved by the Host from an Entity Handle. A Runtime Script never supplies -/// one, so a staged effect can only name an entity the Host already put in front of that -/// Invocation. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) enum StagedEffect { GrantXp { @@ -239,13 +173,10 @@ pub(crate) enum StagedEffect { }, } -/// Everything one SUCCESSFUL invocation staged, in the order the script staged it. A failed -/// invocation never produces one of these, so there is no "commit a failed run" state to get wrong. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct StagedEffects(Vec); impl StagedEffects { - /// Apply every staged effect through core gameplay operations, in staging order. pub(crate) fn commit(self, sink: &mut S) { for effect in self.0 { match effect { @@ -267,30 +198,18 @@ impl StagedEffects { } } -/// What one SUCCESSFUL Invocation produced: everything it staged, and the number it returned. -/// -/// A failed Invocation produces a [`ScriptDiagnostic`] instead, so there is no "commit a failed -/// run" state and no answer from a script that did not finish. #[derive(Debug)] pub(crate) struct Invocation { - /// The gameplay operations the script asked for, in staging order. pub effects: StagedEffects, - /// The **Script Answer**: the number the chunk returned, when it returned one. - // ponytail: a number is every answer a caller has needed so far. Widen to a small enum when one - // needs a string or a table back. pub answer: Option, } -/// The seam staged effects commit through: the real database in the Module, a Fake in tests. pub(crate) trait EffectSink { fn grant_xp(&mut self, character_guid: u64, amount: u32); fn heal(&mut self, healer_guid: u64, target_guid: u64, amount: u32); fn send_chat(&mut self, recipient_guid: u64, message: &str); } -/// The curated read of one creature or player, taken before the Invocation ran. A Runtime Script -/// reads these fields off an Entity Handle and can act on the entity through a host operation, but -/// never learns its guid and never reaches a row. #[derive(Clone, PartialEq, Debug)] pub(crate) struct EntityView { pub guid: u64, @@ -305,11 +224,6 @@ pub(crate) struct EntityView { pub z: f32, } -/// What one Invocation is running for: the event label and the entities it involves. -/// -/// `actor` caused the event, `target` is what it acted on. Either can be absent — an event with no -/// target leaves `event.target` nil, which is the defined result a script tests for rather than a -/// failure it has to survive. #[derive(Clone, PartialEq, Debug, Default)] pub(crate) struct ScriptEvent { pub name: String, @@ -318,7 +232,6 @@ pub(crate) struct ScriptEvent { pub payload: ScriptPayload, } -/// Payload fields captured at the core hook, before an Invocation reads the entity snapshot. #[derive(Clone, PartialEq, Debug, Default)] pub(crate) struct ScriptPayload { pub character: bool, @@ -332,31 +245,20 @@ pub(crate) enum ScriptScalar { Text(String), } -/// What an Entity Handle carries: the identity the Host acts on, and nothing a script can read. -/// -/// It lives inside a Lua userdata, which a Runtime Script can pass around but cannot construct, -/// inspect, or forge — so the Host can trust that every handle reaching a host operation is one it -/// minted. The handle lasts exactly as long as the Invocation, because the environment holding it -/// is built fresh for that Invocation and nothing carries to the next one. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct EntityHandle { guid: u64, is_player: bool, } -/// One Runtime Script as the host sees it: a name for diagnostics and the Lua to run. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct RuntimeScript<'a> { pub name: &'a str, pub source: &'a str, } -/// The embedded interpreter plus its compiler cache. Long-lived; holds no Lua state that a -/// Runtime Script can reach from one invocation to the next. pub(crate) struct RuntimeScriptHost { lua: Lua, - /// Compiled chunks keyed by the blake3 hash of their source. Hashing the source means an - /// edited script can never hit a stale entry, so the cache needs no invalidation. chunks: HashMap<[u8; 32], StashedClosure>, compilations: usize, } @@ -364,9 +266,6 @@ pub(crate) struct RuntimeScriptHost { impl RuntimeScriptHost { pub(crate) fn new() -> Self { let mut lua = Lua::core(); - // The shim is a compile-time constant against a pinned piccolo. A failure here is a build - // defect, not a runtime condition; `the_shim_supplies_the_table_concat_piccolo_lacks` - // catches it long before a publish. let shim = lua .try_enter(|ctx| { let closure = @@ -383,43 +282,15 @@ impl RuntimeScriptHost { } } - /// How many chunks this host has compiled since it was built. A cache hit does not raise it, - /// which is how "valid Lua compiles once and runs many times" is observable. - /// - /// An observation point, not a gameplay path: the tests read it, and so does - /// `debug_run_runtime_script` so an Operator can watch the cache work on a live realm. Neither - /// is compiled into a default release build, which is what the allow says. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] pub(crate) fn compilations(&self) -> usize { self.compilations } - /// Drop every compiled chunk. - /// - /// The cache is keyed by a hash of the source, so it can never serve stale code and needs no - /// invalidation for CORRECTNESS. What it lacks is a reason to ever shrink: without this, every - /// script source the Module has seen since the wasm instance started stays compiled in it - /// forever, including the ones no Package ships any more. - /// - /// So there is one eviction point and it is the honest one: a `script` family apply, which is - /// the only thing that changes which sources exist. Called from there, the cache holds the - /// scripts the Shard is actually running, plus whatever `debug_run_runtime_script` has tried - /// since. Nothing durable is lost — a chunk is derived from source and recompiles on demand. pub(crate) fn clear_chunks(&mut self) { self.chunks.clear(); } - /// Run `script` for `event` in a fresh environment under a fuel budget. - /// - /// On success, returns everything the script staged and the Script Answer it returned — nothing - /// has touched the world yet. On any failure, returns a bounded diagnostic and both the staged - /// effects and the answer are dropped unread. - /// - /// Piccolo exposes the caller's generated-Lua line while a Host callback is running, which is - /// why `error`, `assert`, and Host Verb diagnostics carry one. Native VM failures reach this - /// boundary only after Piccolo has unwound those frames, and an unfinished fuel step exposes no - /// frame. Their diagnostics therefore retain the Runtime Script, Event Binding, failure kind, - /// and interpreter message, but cannot reliably name a line with Piccolo 0.3.3. pub(crate) fn invoke( &mut self, script: RuntimeScript<'_>, @@ -432,9 +303,7 @@ impl RuntimeScriptHost { let executor = self.lua.enter(|ctx| { let env = fresh_environment(ctx, event, staging_handle); let closure = ctx.fetch(&chunk); - // Rebinding the cached chunk's `_ENV` upvalue is what makes the compiler cache a - // CODE cache: the compiled prototype is reused, the environment it closes over is not. - // A chunk that never reads or writes a global has no upvalue and needs no rebinding. + // Rebind _ENV for every Invocation so cached code cannot retain script-authored state. if let Some(env_upvalue) = closure.upvalues().first() { env_upvalue.set(&ctx, UpValueState::Closed(Value::Table(env))); } @@ -478,14 +347,13 @@ impl RuntimeScriptHost { } } - let outcome = self.lua.enter(|ctx| { - match ctx.fetch(&executor).take_result::(ctx) { + let outcome = self + .lua + .enter(|ctx| match ctx.fetch(&executor).take_result::(ctx) { Ok(Ok(returned)) => Ok(script_answer(returned)), Ok(Err(error)) => Err(error.to_string()), - // Unreachable: the loop only leaves through `finished`, which means Result mode. Err(mode) => Err(mode.to_string()), - } - }); + }); let answer = match outcome { Ok(answer) => answer, Err(message) => { @@ -498,7 +366,6 @@ impl RuntimeScriptHost { } }; - // The script is gone; nothing else holds the staging buffer. let effects = staged.borrow().clone(); Ok(Invocation { effects: StagedEffects(effects), @@ -535,11 +402,6 @@ impl RuntimeScriptHost { } } -/// The Script Answer in what a chunk returned: a Lua number, and nothing else. -/// -/// A string that reads as a number is not an answer. Lua would coerce it happily, but a script that -/// meant to answer returns a number, so the near miss reads as "no answer" rather than as a silent -/// conversion the author never asked for. fn script_answer(returned: Value<'_>) -> Option { match returned { Value::Integer(number) => Some(number as f64), @@ -548,8 +410,6 @@ fn script_answer(returned: Value<'_>) -> Option { } } -/// Build the environment one Invocation sees: the allowlisted standard library, the `event` table -/// with its Entity Handles, and one global per host operation. fn fresh_environment<'gc>( ctx: Context<'gc>, event: &ScriptEvent, @@ -567,7 +427,6 @@ fn fresh_environment<'gc>( env } -/// Refuse a false value and carry the generated Lua line of the call site. fn located_assert_operation<'gc>(ctx: Context<'gc>) -> Callback<'gc> { Callback::from_fn(&ctx, |ctx, execution, stack| { if stack.get(0).to_bool() { @@ -582,7 +441,6 @@ fn located_assert_operation<'gc>(ctx: Context<'gc>) -> Callback<'gc> { }) } -/// Raise a Lua error that carries the generated Lua line of the call site. fn located_error_operation<'gc>(ctx: Context<'gc>) -> Callback<'gc> { Callback::from_fn(&ctx, |ctx, execution, stack| { let error = Error::from(stack.get(0)); @@ -607,12 +465,6 @@ fn error_with_location<'gc>( .into() } -/// The environment's standard library, rebuilt name by name from [`ALLOWED_GLOBALS`] and -/// [`ALLOWED_LIBRARY_MEMBERS`]. -/// -/// Each library table is a new table holding the allowlisted members, so a script that writes to -/// `string` writes to its own copy. The shared globals this reads from are never handed out, which -/// is what keeps one Invocation's damage to itself. fn allowlisted_standard_library<'gc>(ctx: Context<'gc>) -> Table<'gc> { let core = ctx.globals(); let env = Table::new(&ctx); @@ -634,8 +486,6 @@ fn allowlisted_standard_library<'gc>(ctx: Context<'gc>) -> Table<'gc> { env } -/// `event` as a Runtime Script reads it. A fresh table each Invocation, so a script that writes to -/// it changes nothing the Host will ever read. fn event_table<'gc>(ctx: Context<'gc>, event: &ScriptEvent) -> Table<'gc> { let table = Table::new(&ctx); set(ctx, table, "name", text(ctx, &event.name)); @@ -663,8 +513,6 @@ fn event_table<'gc>(ctx: Context<'gc>, event: &ScriptEvent) -> Table<'gc> { table } -/// Mint the Entity Handle for one entity: an opaque userdata carrying the identity, with a -/// metatable serving the curated fields snapshotted from `view`. fn entity_handle<'gc>(ctx: Context<'gc>, view: &EntityView) -> UserData<'gc> { let fields = Table::new(&ctx); set(ctx, fields, "name", text(ctx, &view.name)); @@ -693,8 +541,6 @@ fn entity_handle<'gc>(ctx: Context<'gc>, view: &EntityView) -> UserData<'gc> { handle } -/// `heal(entity, amount)`. Credits heal-threat to the Invocation's actor, the way a cast heal -/// credits its caster; an event with no actor heals without pulling aggro for anyone. fn heal_operation<'gc>( ctx: Context<'gc>, actor_guid: Option, @@ -722,8 +568,6 @@ fn heal_operation<'gc>( }) } -/// `send_chat(player, text)`. The text is normalized here, by the same rule the chat core applies, -/// so what the Invocation staged is exactly what commits. fn send_chat_operation<'gc>( ctx: Context<'gc>, staged: &Rc>>, @@ -751,7 +595,6 @@ fn send_chat_operation<'gc>( }) } -/// `grant_xp(player, amount)`. fn grant_xp_operation<'gc>( ctx: Context<'gc>, staged: &Rc>>, @@ -777,9 +620,6 @@ fn grant_xp_operation<'gc>( }) } -/// Record one Staged Effect, refusing once the Invocation has reached -/// [`MAX_STAGED_EFFECTS_PER_INVOCATION`]. The refusal fails the Invocation, which discards -/// everything it staged — a script cannot get its first 256 effects committed by overrunning. fn stage<'gc>( ctx: Context<'gc>, staged: &Rc>>, @@ -800,7 +640,6 @@ fn stage<'gc>( Ok(()) } -/// Resolve one host-call argument to the Entity Handle the Host minted for it. fn entity_argument<'gc>( ctx: Context<'gc>, call: &str, @@ -821,7 +660,6 @@ fn entity_argument<'gc>( } } -/// The same, for the operations that only mean anything against a player. fn player_argument<'gc>( ctx: Context<'gc>, call: &str, @@ -839,8 +677,6 @@ fn player_argument<'gc>( Ok(handle) } -/// Resolve an amount argument. Whole numbers inside `1..=`[`MAX_EFFECT_AMOUNT`] only: zero and -/// negatives are a script defect rather than a no-op worth performing. fn amount_argument<'gc>( ctx: Context<'gc>, call: &str, @@ -863,8 +699,6 @@ fn amount_argument<'gc>( Ok(amount as u32) } -/// Resolve a text argument to UTF-8. Lua strings are bytes, so a script can hand over something no -/// durable column can hold. fn text_argument<'gc>( ctx: Context<'gc>, call: &str, @@ -882,22 +716,16 @@ fn text_argument<'gc>( .map_err(|_| host_error(ctx, call, "the message is not valid UTF-8")) } -/// The Lua error a misused host operation raises: the call that was misused and the fault, so the -/// Script Diagnostic it becomes can be acted on without reading the script. fn host_error<'gc>(ctx: Context<'gc>, call: &str, fault: &str) -> Error<'gc> { text(ctx, &format!("{call}: {fault}")) .into_value(ctx) .into() } -/// A Lua string holding `source`. piccolo converts only a `&'static str` implicitly, and most of -/// what this module hands to a script is owned domain text. fn text<'gc>(ctx: Context<'gc>, source: &str) -> piccolo::String<'gc> { piccolo::String::from_slice(&ctx, source) } -/// Set one key on a Lua table. Every key this module writes is a fixed name, so a failure would -/// mean a nil or NaN key the code cannot produce. fn set<'gc>(ctx: Context<'gc>, table: Table<'gc>, key: &'static str, value: impl IntoValue<'gc>) { table .set(ctx, key, value) @@ -905,29 +733,14 @@ fn set<'gc>(ctx: Context<'gc>, table: Table<'gc>, key: &'static str, value: impl } thread_local! { - /// The Module's one Runtime Script Host. The wasm instance is single-threaded, and the host - /// has to outlive a reducer call for its compiler cache to be worth anything. Nothing durable - /// lives here: the cache is derived from script source and rebuilds itself on demand. static HOST: RefCell = RefCell::new(RuntimeScriptHost::new()); } -/// Borrow the Module's Runtime Script Host for the length of one call. -/// -/// `None` means the host was already borrowed — a Runtime Script reached it again through a core -/// operation one of its own Staged Effects committed. Refusing the re-entry keeps it from -/// panicking out of the surrounding reducer, which would take the whole tick down with it; the -/// invocation that tried simply does not happen. +// A borrowed Host means staged effects re-entered it; skip recursion rather than panic. pub(crate) fn with_host(f: impl FnOnce(&mut RuntimeScriptHost) -> R) -> Option { HOST.with(|host| host.try_borrow_mut().ok().map(|mut host| f(&mut host))) } -/// Run every Runtime Script bound to `event`, committing the ones that succeed. -/// -/// This is the failure boundary. A script that fails to compile, raises, or runs out of fuel -/// contributes a diagnostic and nothing else: no effect of its own lands, and the scripts after it -/// still run. The returned diagnostics are the caller's to log. -/// -/// A core hook event has no caller waiting on a Script Answer, so any answer is discarded here. pub(crate) fn run_event( host: &mut RuntimeScriptHost, sink: &mut S, @@ -937,13 +750,6 @@ pub(crate) fn run_event( ask_event(host, sink, event, scripts).0 } -/// [`run_event`], plus the Script Answer the scripts gave. -/// -/// The answer is the FIRST number returned, in the order the scripts were handed over — the caller -/// decided that order, so the answer is decided by it too. The scripts after the answering one -/// still run: they may stage effects of their own, and a Package that wanted them not to orders -/// them ahead of it. A script that fails contributes no answer and does not stop the next one, so a -/// broken script leaves its caller on whatever fallback "no answer" means there. pub(crate) fn ask_event( host: &mut RuntimeScriptHost, sink: &mut S, @@ -964,18 +770,6 @@ pub(crate) fn ask_event( (diagnostics, answer) } -/// Run exactly one Runtime Script chosen by identity, for a caller that already knows which script -/// to invoke and has no Event Binding to look up — the `E_SCRIPTED` spell effect: its `script_id` -/// names the script outright, so there is no `by_event` dispatch to run. Mirrors what -/// `script_binding::fire` does around [`run_event`] (one Host borrow, a succeeding invocation's -/// effects committed to the real database, a failure logged) minus the `by_event` lookup and the -/// priority ordering `fire` needs only when several scripts answer one event. -/// -/// Returns whether the invocation succeeded and committed. Every failure shape collapses to -/// `false` here — a compile/runtime/fuel failure, or the Host already being borrowed by an outer -/// invocation — because the caller has exactly one thing to decide either way: this effect did not -/// happen. The failure is still logged, the same `ScriptDiagnostic` line `fire` logs for its own -/// scripts. pub(crate) fn invoke_by_identity( ctx: &spacetimedb::ReducerContext, script: RuntimeScript<'_>, @@ -1005,8 +799,6 @@ pub(crate) fn invoke_by_identity( } } -/// The production [`EffectSink`]: staged effects become real gameplay operations here, and only -/// here. pub(crate) struct CoreEffects<'a> { pub ctx: &'a spacetimedb::ReducerContext, } @@ -1023,13 +815,10 @@ impl EffectSink for CoreEffects<'_> { } fn heal(&mut self, healer_guid: u64, target_guid: u64, amount: u32) { - // The amount is bounded by `MAX_EFFECT_AMOUNT`, well inside `i32`. crate::spell::apply_direct_heal(self.ctx, healer_guid, target_guid, amount as i32); } fn send_chat(&mut self, recipient_guid: u64, message: &str) { - // The recipient was online when the Invocation read it and may not be by now. That is an - // ordinary outcome, not a script defect, so it does not become a Script Diagnostic. if let Err(reason) = crate::actor::system_message(self.ctx, recipient_guid, message.to_string()) { @@ -1039,11 +828,6 @@ impl EffectSink for CoreEffects<'_> { } impl EntityView { - /// Read the live row for `guid` as the curated view one Invocation sees. - /// - /// `None` when the guid names nothing an Invocation may act on — including a character in - /// transit between Shards, which the acting-entity gate already treats as out of the world. - /// A caller with no participant to name passes 0 and gets `None`. pub(crate) fn read(ctx: &spacetimedb::ReducerContext, guid: u64) -> Option { use crate::game_creature_template; let entity = crate::helpers::acting_entity_by_guid(ctx, guid)?; @@ -1076,9 +860,6 @@ impl EntityView { mod tests { use super::*; - /// The workload this host exists to run: a transpiler-shaped chunk of list building, a higher - /// order function, string work through the shim, and a host call. The fuel budget is sized - /// against this. const REPRESENTATIVE_SCRIPT: &str = r#" local function map(list, f) local out = {} @@ -1092,21 +873,12 @@ local roster = table.concat(shouted, ",") if #roster > 0 then grant_xp(event.actor, 25) end "#; - /// The SAME workload, as the pinned TypeScript toolchain actually emits it. - /// - /// `datascripts/runtime-scripts/fuel-workload.ts` is the source; `packages build` compiles - /// Package scripts with exactly this toolchain, and - /// `datascripts/tests/runtime-scripts.test.ts` recompiles the workload and refuses these bytes - /// if they have gone stale. So the budget is sized against real transpiler output — the - /// `____tbl` guard, the inlined lua library, the `self` conventions and all — not against a - /// hand-written impression of it. const GENERATED_SCRIPT: &str = include_str!("../../datascripts/runtime-scripts/fuel-workload.lua"); const PLAYER_GUID: u64 = 7; const CREATURE_GUID: u64 = 9; - /// One committed gameplay operation, in the order the sink received it. #[derive(Clone, PartialEq, Eq, Debug)] enum Committed { Xp { @@ -1183,7 +955,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end } } - /// An event with a label and no participants, for the tests that only care about the label. fn unattended(name: &str) -> ScriptEvent { ScriptEvent { name: name.to_string(), @@ -1191,7 +962,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end } } - /// The event most tests run against: a player acting on a creature. fn engagement() -> ScriptEvent { ScriptEvent { name: "on_login".to_string(), @@ -1205,7 +975,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end RuntimeScript { name, source } } - /// What one invocation actually puts through the sink: run it, then commit whatever it staged. fn committed( host: &mut RuntimeScriptHost, event: &ScriptEvent, @@ -1217,8 +986,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end Ok(sink.committed) } - /// The experience amounts one invocation granted. Several tests use `grant_xp` as the way a - /// script reports a number back to Rust. fn granted_amounts(committed: &[Committed]) -> Vec { committed .iter() @@ -1304,10 +1071,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end } #[test] - /// Piccolo gives a caller frame only to an active callback. After `Executor::step` returns, - /// its public API exposes no native-fault or fuel-exhaustion frame. Keep the stable context - /// rather than inventing a line or instrumenting generated Lua, which would move its Fuel - /// Budget measurement. fn native_vm_and_fuel_diagnostics_keep_the_stable_invocation_context() { let mut host = RuntimeScriptHost::new(); for (name, source, kind) in [ @@ -1369,10 +1132,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end assert!(diagnostic.event.ends_with(TRUNCATION_MARK)); } - // ---- the allowlisted environment ---- - - /// The whole surface an Invocation gets, pinned. A name arriving here that this list does not - /// carry is a widening of the host API, which is a decision rather than an accident. #[test] fn an_invocation_receives_exactly_the_allowlisted_surface() { let mut host = RuntimeScriptHost::new(); @@ -1486,8 +1245,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end ); } - // ---- the event and its Entity Handles ---- - #[test] fn compiled_typescript_login_handler_sends_chat_through_its_event_parameter() { let mut host = RuntimeScriptHost::new(); @@ -1723,8 +1480,6 @@ welcome(event) ); } - /// The point of an Entity Handle: a script can act on the entity without ever learning which - /// row it is. #[test] fn an_entity_handle_exposes_no_identifier_and_no_row() { let mut host = RuntimeScriptHost::new(); @@ -1765,8 +1520,6 @@ welcome(event) ); } - // ---- host operations ---- - #[test] fn a_staged_heal_commits_against_the_target_and_credits_the_actor() { let mut host = RuntimeScriptHost::new(); @@ -1850,9 +1603,6 @@ welcome(event) ); } - // ---- misuse is a Script Diagnostic, never a panic ---- - - /// Every one of these is a script defect the Host has to name rather than absorb. #[test] fn a_misused_host_operation_names_the_call_and_the_fault() { let mut host = RuntimeScriptHost::new(); @@ -2032,10 +1782,6 @@ welcome(event) ); } - // ---- the Script Answer ---- - - /// The rule a Package Event rests on: the FIRST number wins, later scripts still run, and a - /// failure contributes no answer and stops nothing. #[test] fn the_first_script_to_return_a_number_answers_and_the_rest_still_run() { let mut host = RuntimeScriptHost::new(); @@ -2068,8 +1814,6 @@ welcome(event) ); } - /// A script that fails contributes no answer, whatever it returned before it failed, and the - /// next script may still answer. #[test] fn a_failing_script_answers_nothing_and_the_next_one_still_can() { let mut host = RuntimeScriptHost::new(); @@ -2089,8 +1833,6 @@ welcome(event) assert_eq!(diagnostics.len(), 1); } - /// Nothing but a number is an answer. A string, a boolean, a table and a bare `return` all mean - /// the same thing to a caller: this script did not decide, so keep the fallback. #[test] fn a_return_that_is_not_a_number_answers_nothing() { let mut host = RuntimeScriptHost::new(); @@ -2116,8 +1858,6 @@ welcome(event) } } - /// A float answers as itself, and a Lua integer answers as the same number — a caller reads one - /// kind of answer, never two. #[test] fn an_integer_and_a_float_both_answer_as_one_number() { let mut host = RuntimeScriptHost::new(); @@ -2139,8 +1879,6 @@ welcome(event) } } - /// Nothing bound is the common case for a Package Event nobody scripted: no answer, and the - /// caller keeps its fallback. #[test] fn an_event_with_no_scripts_answers_nothing() { let mut host = RuntimeScriptHost::new(); @@ -2152,20 +1890,10 @@ welcome(event) assert_eq!(answer, None); } - /// A defect in the pinned interpreter, recorded so it cannot change unnoticed: piccolo 0.3.3 passes - /// an inline table constructor's element count as an extra argument, so `f({7, 8, 9})` reaches - /// a three-parameter `f` as `f(table, 3, nil)` instead of `f(table, nil, nil)`. Passing the - /// same table through a local is correct, so it is the call site, not the callee. - /// - /// It reproduces in the Runtime Script Prototype, untouched by this Host. `x = x or default` - /// is the commonest idiom in Lua and in transpiler output, so anything generating Lua for this - /// Host has to know. When this test starts failing, the interpreter has fixed it. Drop the type - /// guard in `PICCOLO_SHIM` at the same time. #[test] fn piccolo_leaks_a_table_constructors_element_count_as_an_extra_argument() { let mut host = RuntimeScriptHost::new(); let event = engagement(); - // `b` should be nil in every one of these. It is the element count instead. for (source, leaked_count) in [ ("grant_xp(event.actor, seen({}) + 1)", 1), ("grant_xp(event.actor, seen({7}) + 1)", 2), @@ -2180,8 +1908,6 @@ welcome(event) [leaked_count] ); } - // The same table through a local is passed correctly, which is what makes it a call-site - // defect rather than a calling-convention one. assert_eq!( granted_amounts( &committed( @@ -2198,14 +1924,10 @@ welcome(event) ); } - /// The two facts `datascripts/runtime-scripts/lua-emit.cjs` rewrites generated Lua against. - /// If either stops holding, that rewrite is either unnecessary or no longer sufficient, and the - /// Package build has to be revisited before the next Runtime Script ships. #[test] fn only_a_trailing_table_constructor_leaks_and_a_one_parameter_passthrough_stops_it() { let mut host = RuntimeScriptHost::new(); let event = engagement(); - // 1 when the second parameter arrived nil, which is what a correct call gives. let preamble = "local function seen(a, b) if b == nil then return 1 end return 2 end\n"; for (shape, source) in [ ( @@ -2225,8 +1947,6 @@ welcome(event) "{shape} must not leak" ); } - // Parentheses do NOT stop it: the leak is in how the call site counts its arguments, not - // in adjusting a multiple-value expression, so the obvious one-token fix is not one. assert_eq!( granted_amounts( &committed( @@ -2251,7 +1971,6 @@ welcome(event) 8, "ab-cd-ef", ), - // No separator: the leaked element count must not be joined in as one. ( "grant_xp(event.actor, #table.concat({\"ab\", \"cd\"}))", 4, @@ -2279,17 +1998,11 @@ welcome(event) let event = engagement(); host.invoke(script("representative", REPRESENTATIVE_SCRIPT), &event) .expect("the workload the budget is sized against must fit"); - // Fifty times the list, still inside the budget: the headroom is real, not marginal. let heavier = REPRESENTATIVE_SCRIPT.replace("1, 20 do", "1, 1000 do"); host.invoke(script("heavier", &heavier), &event) .expect("fifty times the representative workload must still fit"); } - /// Real transpiler output runs on this Host, answers its caller, and fits the Fuel Budget. - /// - /// The three claims are one test on purpose: a chunk that compiles but cannot reach a Host Verb - /// is not evidence that the toolchain works, and a chunk that works but does not fit the budget - /// is not evidence that the budget is sized for one. #[test] fn the_generated_representative_script_runs_answers_and_fits_the_fuel_budget() { let mut host = RuntimeScriptHost::new(); @@ -2299,18 +2012,12 @@ welcome(event) .invoke(script("fire_nova.generated", GENERATED_SCRIPT), &event) .expect("generated Lua must run inside the Fuel Budget"); - // "UNIT1".."UNIT20" joined by 19 commas is 130 characters. The chunk's own answer proves - // the lua library, the string work and the join all ran, not just the Host Verb. assert_eq!(invocation.answer, Some(130.0)); let mut sink = FakeEffects::default(); invocation.effects.commit(&mut sink); assert_eq!(granted_amounts(&sink.committed), [25]); } - /// A diagnostic's line number is a line of the GENERATED Lua — the bytes `game_script.source` - /// holds and an Operator can read — never a line of the TypeScript it came from. There is no - /// source map, and a number that pointed into a file no Shard holds would be worse than none. - /// #[test] fn a_syntax_failure_names_a_line_of_the_generated_lua_not_of_its_typescript() { let mut host = RuntimeScriptHost::new(); @@ -2333,8 +2040,6 @@ welcome(event) generated_lines + 1, diagnostic.message ); - // The lua library prologue and the guard put the generated Lua well past the length of the - // TypeScript, so this number could not be a source-mapped one. assert!(generated_lines > 25); } @@ -2367,9 +2072,6 @@ welcome(event) } } - /// A Runtime Script that reaches the host again through an effect it staged must be refused, - /// not panic the reducer it is running inside. Event bindings make that reachable; the guard - /// is here so it is a refusal from the first day it can happen. #[test] fn re_entering_the_module_host_is_refused_rather_than_a_panic() { let outer = with_host(|_| with_host(|host| host.compilations())); @@ -2385,11 +2087,7 @@ welcome(event) } } -/// -/// It is a FIXED stream on purpose. The Module is replicated and replayed; a random hash seed -/// would let table iteration order differ between two runs of the same reducer sequence. Nothing -/// in the Module uses `getrandom` for security — gameplay randomness comes from `ctx.rng()`, which -/// SpacetimeDB seeds from the reducer timestamp. +// Fixed hash entropy keeps replicated reducer replays deterministic. #[cfg(target_arch = "wasm32")] mod fixed_entropy { const SEED: u64 = 0x9E37_79B9_7F4A_7C15; @@ -2407,21 +2105,19 @@ mod fixed_entropy { if written >= len { break; } - // SAFETY: the caller guarantees `dest` is writable for `len` bytes. + // SAFETY: the caller guarantees dest is writable for len bytes. unsafe { dest.add(written).write(byte) }; written += 1; } } } - /// getrandom 0.2's custom backend, enabled by the `spacetimedb` crate's `custom` feature. #[unsafe(no_mangle)] unsafe fn __getrandom_custom(dest: *mut u8, len: usize) -> u32 { fill(dest, len); 0 } - /// getrandom 0.3's custom backend. #[unsafe(no_mangle)] unsafe extern "Rust" fn __getrandom_v03_custom( dest: *mut u8, diff --git a/module/src/script_binding.rs b/module/src/script_binding.rs index 06f7d38ed..1510312bf 100644 --- a/module/src/script_binding.rs +++ b/module/src/script_binding.rs @@ -1,108 +1,29 @@ -//! **Event Binding**: which Runtime Scripts run for which event, and the dispatch that runs them. -//! -//! `game_script` is the durable half. Every row is a whole Runtime Script a Package ships, put -//! there by the `script` Import Family's apply (`package_import/script.rs`) and by nothing else. -//! This module owns the table and the question the engine asks of it once per event. -//! -//! # The dispatch -//! -//! [`fire_hook`] is called from every generated `fire_*` in `hooks.rs`, after that event's compiled -//! `game_hook!` handlers have run. It is a lookup, an ordering, and one call into the Runtime -//! Script Host: -//! -//! * **Bound and enabled only.** The `by_event` index answers the lookup, and a disabled row is -//! skipped here rather than left out of the table — a Package that ships a script switched off -//! has still shipped it, and an Operator reading `game_script` should see that. -//! * **Priority ascending, then `script_id` ascending.** Total, so two Shards running one plan run -//! it in one order. `priority` is the author's lever and the identifier is the tiebreak, never -//! the Package name or the order the artifacts arrived in. -//! * **The Host is the failure boundary.** `run_event` invokes each script, commits what the ones -//! that succeeded staged, and returns a diagnostic for each one that did not. A failing script -//! stops neither the next script nor the core work after this call, so nothing here has error -//! handling of its own to get wrong. -//! -//! # Asking, as well as firing -//! -//! [`ask`] is the same dispatch with the Script Answer kept: a Package fires a Package Event of its -//! own and reads back the first number a bound script returned, in the same order [`fire`] runs -//! them. Every later script still runs and still stages what it stages. No answer — nothing bound, -//! nothing returning a number, or every script failing — leaves the caller on its own fallback, -//! which is what makes a Runtime Script an override rather than a dependency. -//! -//! # What an event costs when nothing is bound -//! -//! The common case is a Shard with no Package scripts at all, and `on_damage_taken` fires on every -//! swing. So the ordering, the entity reads and the Host borrow all happen AFTER the lookup finds -//! something: an unbound event is one indexed range scan that returns nothing. -//! -//! # Re-entry -//! -//! A Runtime Script's Staged Effect commits through the same core operations the engine uses, and -//! those fire hooks. So a script bound to `on_levelup` that grants XP can reach this function again -//! from inside its own invocation. `with_host` refuses the second borrow and that invocation simply -//! does not happen — refusing rather than recursing, because recursion here would be unbounded and -//! a panic would take the whole reducer down with it. Event Bindings are what made that reachable -//! for the first time. - use spacetimedb::{log, table, ReducerContext}; use crate::runtime_script::{ ask_event, with_host, CoreEffects, EntityView, RuntimeScript, ScriptEvent, ScriptPayload, }; -/// One Runtime Script a Package ships, reconciled onto this Shard. -/// -/// NOT public, for the same reason `game_import_meta` and `game_package_import` are not: nothing -/// subscribes it, so it needs no gateway binding. The apply reducer writes it and an Operator reads -/// it with `spacetime sql`. -/// -/// There is no upload path. A row exists here because an enabled Package's Script Artifact put it -/// there, which is what makes reconciliation total: the apply clears the Package script range and -/// rewrites it, so a Package that left the enabled set takes its scripts with it. The debug lever -/// (`debug_run_runtime_script`) still takes raw source as a reducer argument, but it writes nothing -/// here — it runs a script once against the live world and forgets it. #[table(accessor = game_script, index(accessor = by_event, btree(columns = [event])))] pub struct Script { - /// The identifier this script keeps on every Shard, inside the Package script range. #[primary_key] pub script_id: u32, - /// The unique human-readable name, and the label a Script Diagnostic carries. #[unique] pub name: String, - /// The Package that ships it. pub package: String, - /// The digest of the Datascript source the artifact was generated from, carried verbatim from - /// the artifact — the same value `game_package_import.source_hash` records for the Package. - /// Identifies the REVISION the script came from. pub source_hash: String, - /// BLAKE3 digest of `source`. DERIVED at apply, never authored, so it cannot disagree with the - /// Lua beside it. It is also exactly the Runtime Script Host's compiler-cache key, so two rows - /// with one `content_hash` compile once. pub content_hash: String, - /// The event this script runs for: one name from `GAME_HOOK_EVENT_NAMES`, or a Package Event - /// the shipping Package fires itself. pub event: String, - /// Lower runs first among the scripts bound to one event; `script_id` breaks a tie. pub priority: i32, - /// Whether any event invokes it. A disabled row is applied and skipped, not withheld. pub enabled: bool, - /// The Lua the Runtime Script Host runs. pub source: String, } -/// Run every enabled Runtime Script bound to `event`, in dispatch order. -/// -/// `actor_guid` caused the event and `target_guid` is what it acted on; either may be `0`, or may -/// name something that is not a live entity (a corpse, a gameobject, a character in transit), which -/// reaches the script as an absent `event.actor`/`event.target` rather than as a failure. -/// -/// A Package Event has no caller waiting on a Script Answer, so any answer is dropped. #[allow(dead_code, reason = "retained Package API event dispatch")] pub(crate) fn fire(ctx: &ReducerContext, event: &str, actor_guid: u64, target_guid: u64) { dispatch(ctx, event, actor_guid, target_guid, ScriptPayload::default); } -/// Generated core dispatch supplies its typed payload after the Event Binding lookup. pub(crate) fn fire_hook( ctx: &ReducerContext, event: &str, @@ -113,14 +34,6 @@ pub(crate) fn fire_hook( dispatch(ctx, event, actor_guid, target_guid, payload); } -/// Run every enabled Runtime Script bound to `event`, and read back the Script Answer. -/// -/// The answer is the first number a script returned, in the same dispatch order [`fire`] uses. The -/// scripts after it still run. `None` says no script answered — nothing bound, every bound script -/// returned something that is not a number, every one of them failed, or the Host was already busy -/// — and every one of those means the caller keeps its own fallback. -/// -/// Its only caller is a Package: a Package Event is the one event a Package both fires and answers. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn ask( ctx: &ReducerContext, @@ -131,9 +44,6 @@ pub(crate) fn ask( dispatch(ctx, event, actor_guid, target_guid, ScriptPayload::default) } -/// The lookup, the ordering and the one Host call both [`fire`] and [`ask`] are. One body rather -/// than two, so the two verbs can never drift on the order they run scripts in or on what a failure -/// does; they differ only in whether the Script Answer has a caller waiting for it. fn dispatch( ctx: &ReducerContext, event: &str, @@ -146,8 +56,6 @@ fn dispatch( return None; } - // Read after the lookup: an unbound event must not pay for two entity reads. Read BEFORE the - // invocation, because the Host takes the participants as values — a script never reaches a row. let script_event = ScriptEvent { name: event.to_string(), actor: EntityView::read(ctx, actor_guid), @@ -179,11 +87,6 @@ fn dispatch( answer } -/// Every enabled script, in dispatch order: priority ascending, then `script_id` ascending. -/// -/// Split from [`fire`] so the ordering is testable without a `ReducerContext`, which a native test -/// has no way to build. Total on purpose — two Shards running one plan must run it in one order, -/// so neither the Package name nor the order the rows came back in may reach this. fn dispatch_order(mut bound: Vec