diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 94aa055bc..4c7aa6e55 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,6 +22,7 @@ The commands CI runs, in `.github/workflows/rust.yml`: - [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` - [ ] `cargo test -p lyracore-importer` - [ ] `cargo test -p lyracore-module --lib --features=debug_reducers` +- [ ] `cargo test -p lyracore-module --test build_script` - [ ] `cargo test -p lyracore-module --test package_api_lint` - [ ] `cargo test -p lyracore-package-delta` - [ ] `cargo test -p lyracore-gateway` diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 604c8387c..2805c464d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -57,6 +57,7 @@ jobs: # 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 - run: cargo test -p lyracore-module --test package_api_lint - run: cargo test -p lyracore-package-delta - run: cargo test -p lyracore-gateway @@ -84,9 +85,13 @@ jobs: env: BRANCH: ${{ github.head_ref || github.ref_name }} run: | - if git ls-remote --exit-code --heads https://github.com/LyraCoreProject/packages "refs/heads/$BRANCH" >/dev/null; then - echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" + if paired=$(git ls-remote --exit-code --heads https://github.com/LyraCoreProject/packages "refs/heads/$BRANCH"); then + echo "ref=${paired%%[[:space:]]*}" >> "$GITHUB_OUTPUT" else + status=$? + if [ "$status" -ne 2 ]; then + exit "$status" + fi echo "ref=main" >> "$GITHUB_OUTPUT" fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -98,4 +103,5 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install the official Packages run: for package in official-packages/*/; do ln -s "$PWD/$package" packages/; done + - run: cargo test -p lyracore-module --test package_api_lint - run: cargo check --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f59d623..af05f9a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ and is not reconstructed here. ## [Unreleased] +### Changed + +- Package API version 3 has five roots: `actor`, `hooks`, `package`, `tables`, and + `script_binding`. Actor operations and observations replace direct dependencies on `chat`, + `combat`, `creatures`, `faction`, `gameobject`, `group`, `helpers`, `items`, `loot`, `nav`, `quest`, + `spell`, `stats`, `terrain`, `transfer`, `world`, and `xp`. Package setup, Config and encounters + move from `package_account`, `package_config`, and `encounter` to `package`. Debug fixtures and + unit-test helpers move to `package::fixture` and `package::test` with their existing gates. + Import table accessors and row types through `crate::tables`. See the + [Package API migration notes](docs/package-api.md#module-roots). +- The Package API lint now runs with `cargo test -p lyracore-module --test package_api_lint`. + Module code generation no longer runs the lint. CI checks installed Official Packages with + the named test as well as compiling them. + ### Added - **The Package API, version 1** — [`docs/package-api.md`](./docs/package-api.md) names what a diff --git a/CONTEXT.md b/CONTEXT.md index 07c94df6e..a1ba944a3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -978,8 +978,8 @@ _Avoid_: plugin, addon (when meaning the whole folder), mod, extension **Package API**: The part of the Module a Package may name, versioned and written down at `docs/package-api.md`. The -build lints every Package file against it and fails on a path outside it, so a core refactor breaks a -Package at compile time rather than on a live realm. It is a compatibility contract, never a +named `package_api_lint` test checks every Package Rust file against it and reports paths outside +that contract. CI runs it with the Official Packages installed. It is a compatibility contract, never a sandbox: compiled Package code is trusted either way. _Avoid_: SDK, plugin API, public API, allowlist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67309af98..bdad7b0d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ first commit. A maintainer then marks the issue `in progress`. New content and behaviour beyond 1.12.1 live in a Package. `lyracore packages new ` scaffolds one from the Reference Package ladder in the collection. Use `--from` to choose a rung. [`docs/package-api.md`](docs/package-api.md) lists what -a Package may call, and the build fails on anything else. The Official Package Collection, +a Package may call, and the `package_api_lint` test reports paths outside it. The Official Package Collection, [LyraCoreProject/packages](https://github.com/LyraCoreProject/packages), takes pull requests under its own contributing guide. diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 806870f68..ded274b07 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -119,8 +119,8 @@ _Avoid_: plugin, addon (when meaning the whole folder), mod, extension **Package API**: The part of the Module a Package may name, versioned and written down at `docs/package-api.md`. The -build lints every Package file against it and fails on a path outside it, so a core refactor breaks a -Package at compile time rather than on a live realm. It is a compatibility contract, never a +named `package_api_lint` test checks every Package Rust file against it and reports paths outside +that contract. CI runs it with the Official Packages installed. It is a compatibility contract, never a sandbox: compiled Package code is trusted either way. _Avoid_: SDK, plugin API, public API, allowlist diff --git a/docs/module-build.md b/docs/module-build.md index 452da56f4..57c61e7d7 100644 --- a/docs/module-build.md +++ b/docs/module-build.md @@ -41,8 +41,9 @@ facade re-export, which the scanner checks. Package paths resolve through their `#![cfg(feature = "debug_reducers")]`; discovery observes that gate so an ordinary build cannot reference a function omitted by Rust. -Package source also passes the Package API lint described in `docs/package-api.md`. A path outside -`PACKAGE_API_ROOTS` fails with the Package, file, line, and path. An explicit +The separate `package_api_lint` test checks Package source as described in `docs/package-api.md`. +Code generation does not run this lint. CI runs it after installing the Official Packages. A path outside +the five documented Package API roots fails with the Package, file, line, and path. An explicit `// package-api: exempt ` permits an ordinary boundary exception. A gated API root still requires its debug or test gate, and exemptions cannot bypass that requirement. Core source does not pass through this lint. diff --git a/docs/package-api.md b/docs/package-api.md index 38299a06a..bccbcd190 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -1,8 +1,8 @@ -# Package API, version 2 +# Package API, version 3 The Package API is the part of the Module a Package may name. A Package compiles into the Module wasm and can reach any `crate::` path the compiler resolves; this document says which of those paths -core will keep working, and `module/build.rs` fails the build on the rest. +core will keep working, and `cargo test -p lyracore-module --test package_api_lint` reports paths outside that contract. This is a contract, not a tutorial. Read `packages/README.md` for how a Package is installed and [the Reference Packages](https://github.com/LyraCoreProject/packages#packages) for working examples. @@ -88,7 +88,7 @@ ordering does not promise an atomic operation across Shards. ### Package-owned Accounts -`package_account::create_package_character(ctx, package_name, name, race, class) -> Result` +`package::create_character(ctx, package_name, name, race, class) -> Result` creates a Character with no Session and returns its guid. It applies the Refusals a client-created Character meets: `NAME_IN_USE` for a taken name, `INVALID_RACE_CLASS` for a pair the imported CharBaseInfo does not list, and the guid range Refusal. These Refusals write nothing. @@ -131,7 +131,7 @@ Characters stay Dormant. ### Encounter kernel -`crate::encounter` holds the encounter state machine and the choreography verbs a Package drives it +`crate::package::encounter` holds the encounter state machine and the choreography verbs a Package drives it with: `get_encounter_state`, `set_encounter_state`, `get_encounter_data`, `set_encounter_data`, `watch_hp_threshold`, `reset_hp_fired`, `open_door`, `spawn_wave`, `equip_swap`, `move_to_point`, `encounter_reset`, `encounter_reset_full`, and the four `ENCOUNTER_*` state constants. Core ships no @@ -173,7 +173,7 @@ selected profile is the source of the entitlement. complete old curated header shapes. Imported or Operator-tuned rows remain authoritative. `actor::request_cast(ctx, actor_guid, spell_id, target_guid)` returns -`Result`. `Started` carries a Cast Handle. `Waiting` carries +`Result`. `Started` carries a Cast Handle. `Waiting` carries an existing cast's original identity, spell, target, and current due time, even when the new request names a different spell or target. It never restarts that cast. `Resolved` means effects dispatched synchronously, which does not imply a projectile hit. Channeled spells return `UnsupportedChannel` @@ -183,24 +183,25 @@ requests require the spell in `game_player_spell`. Creature and triggered spell- their existing spellbook exemption. A scheduled cast ends through `on_cast_finished`. Its payload carries the caster, target, scheduled -identity, and `spell::CastFinish`. Packages must match the scheduled identity before updating retained +identity, and `actor::CastFinish`. Packages must match the scheduled identity before updating retained work. The hook runs after the pending row is removed. An instant cast returns its result directly and does not fire this hook. `on_cast_resolved` keeps its existing effect-dispatch contract. -`spell::pending_cast` reads the current Cast Handle by caster. Refusal kinds are supplied by the +`actor::pending_cast` reads the current Cast Handle by caster. Refusal kinds are supplied by the owning Gate. `Other` preserves a Gate's message when no current caller needs a separate policy. `UnlearnedSpell` and `NoLineOfSight` identify the spellbook and targeted visibility Gates. -`spell::cancel_cast_attempt(ctx, caster_guid, scheduled_id)` cancels only that scheduled cast. -`spell::expire_cast_attempt(ctx, caster_guid, scheduled_id, deadline_micros)` also requires the +`actor::cancel_cast_attempt(ctx, caster_guid, scheduled_id)` cancels only that scheduled cast. +`actor::expire_cast_attempt(ctx, caster_guid, scheduled_id, deadline_micros)` also requires the caller's action deadline to have passed. They return whether they removed the cast and report `Cancelled` or `Expired` through `on_cast_finished`. A stale identity cannot cancel a replacement. -`crate::helpers` holds the reads a Package needs before it acts: `live_entity`, `require_character`, -`character_by_guid`, `character_by_name`, `entity_by_owner`, `acting_entity_by_guid`, `entities_near`, -`nearest_entity`, `in_same_partition`, `require_operator`. +`crate::actor` also exposes Character and live-entity reads, nearby entities, partition checks, +quest eligibility, loot entitlement, spell readiness and cast observations. The named re-exports +in `module/src/actor.rs` are the supported operations. Package authorization, Character setup and +encounter operations are named in `module/src/package.rs`. -`group::party_facts(ctx, character_guid)` reads the Character's local durable party mirror. It names +`actor::party_facts(ctx, character_guid)` reads the Character's local durable party mirror. It names the leader and every member, with nullable live position, health, and death facts. An absent local Unit may instead carry a Realm-core-certified map, instance, and locator revision. Pending Transfer, unknown location, and departed-member fences expose no partition. Enemy facts cover hostile @@ -221,7 +222,7 @@ acting from an arbitrary prefix. ### Package Config An Operator-tunable value, seeded by the Package and edited without a republish. -`crate::package_config::ensure_package_config_default(ctx, package, key, default)` inserts only when +`crate::package::ensure_config_default(ctx, package, key, default)` inserts only when the row is absent, so a Package calls it from its own ensure path on every run. The Operator edits with the `set_package_config` reducer. @@ -236,7 +237,7 @@ Events. ### Package fixtures (debug only) -`crate::package_fixture` exists only in a Module built with `debug_reducers`. It holds the setup and +`crate::package::fixture` exists only in a Module built with `debug_reducers`. It holds the setup and observation steps a Package's own debug fixtures need. A release build has no such root. | operation | does | @@ -250,7 +251,7 @@ observation steps a Package's own debug fixtures need. A release build has no su | `record_completed_transfer(ctx, character_guid, map_id, instance_id)` | records a finished Transfer in Realm-core's character-to-shard index | | `declare_next_movement_tick(ctx, delay)` | makes the next creature movement tick fire once, `delay` from now; refuses unless the catch-all tick is the only movement schedule | -A fixture reads the navigation revision through `nav::inputs(ctx, map_id).imported_revision`. +A fixture reads the navigation revision through `actor::movement::inputs(ctx, map_id).imported_revision`. Name this root only from a Package file whose first non-blank line is `#![cfg(feature = "debug_reducers")]`. The lint refuses it anywhere else, and no exemption clears @@ -258,7 +259,7 @@ it, because a release build would compile that file without the root. ### Package tests (test only) -`crate::package_test` exists only in a Module test build. It holds what a Package's own unit tests +`crate::package::test` exists only in a Module test build. It holds what a Package's own unit tests need. | operation | does | @@ -284,42 +285,46 @@ rule `docs/schema.md` states. The Package name in the accessor is what keeps two colliding. The accessor is also the table name Package Teardown empties, so the build refuses a Package table that sets its own `name`. -Core table accessors are named `game_*` and are reached at the crate root: -`use crate::{game_world_entity, game_character};`. Row types are re-exported at the crate root under -their type names: `crate::WorldEntity`, `crate::CharacterQuest`. Both forms are on the surface. -`crate::CHARACTER_OWNED_TABLES` is the generated manifest of character-keyed tables. +Core table accessors and row types are named under `crate::tables`, for example +`use crate::tables::{game_world_entity, WorldEntity};`. The catalog in +`module/src/tables.rs` lists them explicitly. Crate-root row aliases remain for Core and schema +checks; a Package uses the catalog path. `crate::CHARACTER_OWNED_TABLES` is the generated manifest +of character-keyed tables. ### Module roots -The `crate::` roots a Package may name. Everything under a listed root is on the surface at this -version; root granularity is deliberate, so a core refactor inside a root does not churn the -contract. +A Package may name these five roots. Each operation and type is a named declaration or re-export. ``` -actor chat combat creatures encounter faction gameobject -group helpers hooks items loot nav package_account -package_config quest script_binding spell stats -terrain transfer world xp +actor hooks package tables script_binding ``` -Debug only: `package_fixture`, in a file gated on `debug_reducers` (see Package fixtures above). -Test only: `package_test`, in a file gated on `#![cfg(test)]` (see Package tests above). +`actor` holds Actor operations and observations. `actor::movement` holds navigation and movement +operations. `hooks` holds event payloads. `package` holds Package-owned Character setup, Package +Config, authorization and encounter operations. `tables` holds table accessors and row types. +`script_binding` holds Runtime Script dispatch. -Plus, at the crate root: any `game_*` name (a table accessor or registration marker), any -`pkg_*` name (a Package's own generated root module), any type name in UpperCamelCase (a row or -payload type), `character_owned!`, `encounter_package!`, and `CHARACTER_OWNED_TABLES`. +`package::fixture` requires a file gated on `debug_reducers`. `package::test` requires a file gated +on `#![cfg(test)]`. These child paths retain the gates described above. -A root that is absent is core's own business. `auth`, `debug`, `runtime_script`, `test_scan`, -`realm_core`, `gw` and the rest are not promised, and naming one fails the build. +The marker macros listed above, `CHARACTER_OWNED_TABLES`, and generated `pkg_*` modules also remain +available at the crate root. Arbitrary `game_*` or UpperCamelCase names do not qualify. + +The former roots `chat`, `combat`, `creatures`, `encounter`, `faction`, `gameobject`, `group`, +`helpers`, `items`, `loot`, `nav`, `package_account`, `package_config`, `quest`, `spell`, `stats`, +`terrain`, `transfer`, `world`, and `xp` are internal. Migrate calls to the named Actor or Package +operation and table imports to `crate::tables`. There are no compatibility aliases for those +roots in the Package contract. ## The lint -`module/build.rs` reads every file under `packages/*/src/` and fails the build on the first path that +`cargo test -p lyracore-module --test package_api_lint` reads every Rust file under +`packages/*/src/` and fails when a path reaches a crate root outside the list above. The failure names the Package, file, line and path. Core `src/` is never linted because the Package API is a promise core makes to Packages, not to itself. The scanner recognizes `crate::`, `$crate::`, and a `super::` chain that leaves the Package module. -Raw identifiers have their ordinary meaning, so `crate::r#helpers` names the documented `helpers` +Raw identifiers have their ordinary meaning, so `crate::r#actor` names the documented `actor` root. The scanner uses the file path and inline modules to distinguish a crate-root escape from a @@ -335,7 +340,7 @@ that supplies `path` also fail. `include!` fails because it can add unscanned Ru equal. This remains a lexical compatibility check, not Rust name resolution. It does not expand macros or -follow a re-export declared in another file. Rust still compiles every Package after this check; +follow a re-export declared in another file. The compiler and the lint run separately; these limits do not turn the Package API into a sandbox. Comments and string literals are stripped before the scan, so a path quoted in a doc example is @@ -352,7 +357,7 @@ bare marker does not clear. Every exemption is a gap in this document; raise it so the surface can grow or the Package can move off it. An exemption cannot enable a whole-crate alias or `#[path]`. Either spelling can hide dependencies -on other lines or in files the lint cannot locate, so the build always refuses it. +on other lines or in files the lint cannot locate, so the lint always refuses it. ## Action observations @@ -368,14 +373,14 @@ have no waiting, cancellation, or expiry phase. Attack requests also return `Act Its `kind` identifies actionable Gates and `detail` preserves the client message. `Other` is an opaque refusal; Package policy must not classify its text. -`nav::route_step` accepts the same arguments as `nav_step` and returns a `RouteStep`. Its `status` +`actor::movement::route_step` accepts the same arguments as `nav_step` and returns a `RouteStep`. Its `status` is `Complete`, `Partial`, `Blocked`, or `Direct` when navigation is disabled. Complete and Partial describe the planned route. `first_waypoint`, `expansions`, and `clipping` retain search and commit Gate evidence. `endpoint == from` means no movement was approved, even if a complete path was planned. A blocked search holds position. Creature callers retain `nav_step` and its existing collision-gated fallback. -`nav::coverage_generation(ctx, map_id)` returns the active complete coverage generation when its +`actor::movement::coverage_generation(ctx, map_id)` returns the active complete coverage generation when its Gate is enabled, or `None`. Include it with map and instance in deferred destination keys. A changed generation invalidates a retained route decision; `None` preserves unknown coverage. @@ -392,15 +397,18 @@ living body to that authority check. A pending cast or movement-suppressing crow movement. Call it before issuing a movement leg, including continuation between decision turns. The Gate reads current state and does not cancel casts or change position. -`creatures::tick::stop_where_rendered(ctx, &mut mover)` stops the mover's current leg where the -client renders it now, facing along its Route Path. It moves the row to that point and sends a stop -there. The caller writes the row. A mover's stored position can lag its leg, so a stop at the stored -position moves the client back. 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. +`actor::stop_movement(ctx, actor_guid)` stops the current leg where the client renders the Actor +and writes the resulting position. If the body is absent, it removes the stale leg. The caller +must check its movement ownership before stopping a leg. + +`actor::restore_sessionless_body(ctx, character_guid)` returns an existing body or rebuilds a +missing one from an available Character row. It preserves released ghost state and removes the +old movement leg when it rebuilds. With no body, an absent or Transfer-fenced Character returns +`None`. The Package checks controller consent and pending Transfer Intent policy before calling. -`creatures::tick::emit_creature_path(ctx, mover, points, run)` sends the mover along a checked Route +`actor::movement::emit_creature_path(ctx, mover, points, run)` sends the mover along a checked Route Path and writes the row. A mover on a leg starts the path from the point that -`stop_where_rendered` would stop it at, so a renewal between two advance firings does not move the +`actor::stop_movement` would stop it at, so a renewal between two advance firings does not move the client back. `actor::area_trigger_route(ctx, trigger_id)` reads one exact imported AreaTrigger source volume and @@ -410,7 +418,7 @@ volume, exact existing dungeon instance lease, and a Realm-certified party membe partition before it applies the imported landing and writes one Transfer Intent. A Refusal leaves the Character, instance binding, and intent unchanged. -`nav::LEG_MAX_EXPANSIONS` is the expansion cap used by `nav::route_step`. A Package can reserve that work before selecting movement. +`actor::movement::LEG_MAX_EXPANSIONS` is the expansion cap used by `actor::movement::route_step`. A Package can reserve that work before selecting movement. ## Runtime Scripts diff --git a/docs/testing.md b/docs/testing.md index 3a662ece9..87c2c511c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,6 +27,7 @@ cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test -p lyracore-importer cargo test -p lyracore-module --lib --features=debug_reducers +cargo test -p lyracore-module --test build_script cargo test -p lyracore-module --test package_api_lint cargo test -p lyracore-package-delta cargo test -p lyracore-gateway @@ -40,7 +41,9 @@ the published debug reducers. CI also builds the Module with every Official Package installed, so a core change that breaks one fails before the Package's next publish. To run the same check, link each Package from a checkout of -`LyraCoreProject/packages` into `packages/` and repeat the wasm check. +`LyraCoreProject/packages` into `packages/`, run `cargo test -p lyracore-module --test package_api_lint`, +and repeat the wasm check. The named test checks all installed Package source files; the Wasm +compile checks their types and generated registrations. ## Durable tier diff --git a/module/build.rs b/module/build.rs index b5f9ff65a..f237b6057 100644 --- a/module/build.rs +++ b/module/build.rs @@ -1,8 +1,17 @@ //! 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")] @@ -40,56 +49,6 @@ static HOOK_EVENTS: std::sync::LazyLock> = std::sync::LazyLock::n .expect("the checked-in Runtime Script event catalogue must have valid rows") }); -/// The Package API surface, as the module roots a Package may name. `docs/package-api.md` is the -/// contract; this list is what enforces it. Root granularity: everything under a listed root is on -/// the surface, and a root that is absent is core's own business. Adding one here means adding it -/// to the document in the same change. -const PACKAGE_API_ROOTS: &[&str] = &[ - "actor", - "chat", - "combat", - "creatures", - "encounter", - "faction", - "gameobject", - "group", - "helpers", - "hooks", - "items", - "loot", - "nav", - "package_account", - "package_config", - "quest", - "script_binding", - "spell", - "stats", - "terrain", - "transfer", - "world", - "xp", -]; - -/// Roots on the surface only in a Package file whose first non-blank line is the paired gate. Core -/// compiles each root only under that cfg, so naming one in any other file would break a build -/// without it. An exemption cannot clear one for the same reason. -const PACKAGE_API_GATED_ROOTS: &[(&str, &str)] = &[ - ("package_fixture", DEBUG_REDUCERS_FILE_CFG), - ("package_test", TEST_FILE_CFG), -]; - -/// Crate-root names on the surface that are neither a module nor a type: the two marker macros the -/// `game_` prefix below does not already cover, and the generated character-owned table manifest. -const PACKAGE_API_ROOT_ITEMS: &[&str] = &[ - "CHARACTER_OWNED_TABLES", - "character_owned", - "encounter_package", -]; - -/// The comment that clears one out-of-surface path, written on the line that names it. It must -/// carry a reason, so an exemption is always readable where it is used and greppable across a tree. -const PACKAGE_API_EXEMPT: &str = "// package-api: exempt"; - #[allow(clippy::too_many_lines)] // One scan-and-emit step per generated registry. fn main() { let manifest_dir = @@ -151,7 +110,6 @@ fn main() { collect_rs_files(&pkg_src, &mut pkg_files); pkg_files.sort(); for file in &pkg_files { - lint_package_api(&name, &pkg_src, file); scan_file(file, &pkg_src, true, &prefix, &mut registries); } pkg_mods.push((ident, mod_rs)); @@ -633,27 +591,8 @@ fn package_ident(name: &str) -> String { ident } -fn collect_rs_files(dir: &Path, out: &mut Vec) { - let entries = fs::read_dir(dir) - .unwrap_or_else(|e| panic!("build.rs: cannot read dir {}: {e}", dir.display())); - for entry in entries { - let entry = entry.unwrap_or_else(|e| panic!("build.rs: cannot read dir entry: {e}")); - let path = entry.path(); - if path.is_dir() { - collect_rs_files(&path, out); - } else if path.extension().map(|e| e == "rs").unwrap_or(false) { - out.push(path); - } - } -} - -/// `src/foo.rs` -> `crate::foo`. For a directory module (`src/foo/bar.rs`, `src/foo/mod.rs`), this -/// collapses to just `crate::foo`: every directory module in this crate is a thin facade whose -/// `mod.rs` does `pub use bar::*;` for each of its private submodules (see `items/mod.rs`, -/// `spell/mod.rs`, `combat/mod.rs`, `creatures/mod.rs`) — the submodules themselves (`tables`, -/// `spellbook`, ...) are private, so `crate::foo::bar::sweep_fn` would fail to resolve even though -/// the glob re-export makes `crate::foo::sweep_fn` reachable. That facade re-export is -/// VERIFIED per registered marker (`check_facade_reexport`), not assumed. +/// 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!( @@ -674,183 +613,6 @@ fn core_module_path(src_root: &Path, file: &Path) -> String { format!("crate::{top}") } -struct StrippedSource { - code: String, - package_api_exempt_lines: Vec, -} - -/// Blank out comments (line + nested block), string literals (plain, byte, raw), and char -/// literals, PRESERVING newlines and byte-for-char positions — so the marker scan sees only real -/// code and panic line numbers stay true. Lifetimes (`'a`) are left intact (only a real char -/// literal — quote, optional escape, closing quote — is blanked). This is what makes a -/// commented-out marker inert and lets doc comments show real marker syntax. -/// -/// Package API exemptions are collected while the scanner knows it is inside a real line comment. -/// Marker text inside a string cannot clear a finding. -fn strip_source(src: &str) -> StrippedSource { - let b: Vec = src.chars().collect(); - let mut out: Vec = Vec::with_capacity(b.len()); - let mut package_api_exempt_lines = Vec::new(); - let blank = |c: char| if c == '\n' { '\n' } else { ' ' }; - let mut i = 0usize; - while i < b.len() { - 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' { - out.push(' '); - i += 1; - } - let comment: String = b[comment_start..i].iter().collect(); - if comment - .split_once(PACKAGE_API_EXEMPT) - .is_some_and(|(_, reason)| !reason.trim().is_empty()) - { - package_api_exempt_lines.push( - b[..comment_start] - .iter() - .filter(|character| **character == '\n') - .count() - + 1, - ); - } - continue; - } - // Block comment, nested per Rust. - if c == '/' && b.get(i + 1) == Some(&'*') { - let mut depth = 0usize; - while i < b.len() { - if b[i] == '/' && b.get(i + 1) == Some(&'*') { - depth += 1; - out.push(' '); - out.push(' '); - i += 2; - } else if b[i] == '*' && b.get(i + 1) == Some(&'/') { - depth -= 1; - out.push(' '); - out.push(' '); - i += 2; - if depth == 0 { - break; - } - } else { - out.push(blank(b[i])); - i += 1; - } - } - continue; - } - // Raw string r"..." / r#"..."# / br#"..."# — only when the r/b 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' || (c == 'b' && b.get(i + 1) == Some(&'r'))) { - let r_at = if c == 'b' { i + 1 } else { i }; - let mut j = r_at + 1; - while b.get(j) == Some(&'#') { - j += 1; - } - 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)); - } - i = j + 1; - 'raw: while i < b.len() { - if b[i] == '"' { - let mut h = 0usize; - while h < hashes && b.get(i + 1 + h) == Some(&'#') { - h += 1; - } - if h == hashes { - for c in &b[i..=(i + hashes)] { - out.push(blank(*c)); - } - i += hashes + 1; - break 'raw; - } - } - out.push(blank(b[i])); - i += 1; - } - continue; - } - // not a raw string — fall through to emit `c` normally below - } - // Plain / byte string literal with escapes. - if c == '"' || (!prev_is_ident && c == 'b' && b.get(i + 1) == Some(&'"')) { - if c == 'b' { - out.push(' '); - i += 1; - } - out.push(' '); // the opening quote - i += 1; - while i < b.len() { - if b[i] == '\\' { - out.push(' '); - if i + 1 < b.len() { - out.push(blank(b[i + 1])); - } - i += 2; - continue; - } - if b[i] == '"' { - out.push(' '); - i += 1; - break; - } - out.push(blank(b[i])); - i += 1; - } - 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, - Some(_) => b.get(i + 2) == Some(&'\''), - None => false, - }; - if is_char_lit { - out.push(' '); - i += 1; - if b.get(i) == Some(&'\\') { - // Escape: consume the backslash AND its escaped char UNCONDITIONALLY first — - // for '\'' the escaped char IS a quote, and a terminator-scan alone would - // stop on it one char early, leaking the real closing quote. The tail loop - // then covers multi-char escapes ('\u{...}'). - out.push(' '); - i += 1; - if i < b.len() { - out.push(blank(b[i])); - i += 1; - } - while i < b.len() && b[i] != '\'' { - out.push(blank(b[i])); - i += 1; - } - } else { - out.push(blank(b[i])); - i += 1; - } - if b.get(i) == Some(&'\'') { - out.push(' '); - i += 1; - } - continue; - } - // lifetime — keep the quote so positions stay aligned - } - out.push(c); - i += 1; - } - StrippedSource { - code: out.into_iter().collect(), - package_api_exempt_lines, - } -} - fn strip_comments_and_strings(src: &str) -> String { strip_source(src).code } @@ -1215,9 +977,6 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: ); } -const DEBUG_REDUCERS_FILE_CFG: &str = "#![cfg(feature = \"debug_reducers\")]"; -const TEST_FILE_CFG: &str = "#![cfg(test)]"; - /// Keep registry discovery on the same whole-file feature boundary as rustc. /// /// The scanner deliberately supports one exact leading inner attribute instead of interpreting @@ -1232,588 +991,6 @@ fn debug_only_file(source: &str) -> bool { file_gate(source) == Some(DEBUG_REDUCERS_FILE_CFG) } -/// The file's first non-blank line, trimmed: the whole-file gate when it is one. -fn file_gate(source: &str) -> Option<&str> { - source.lines().map(str::trim).find(|line| !line.is_empty()) -} - -/// The gate a gated root needs, or `None` when `root` is not gated. -fn root_gate(root: &str) -> Option<&'static str> { - PACKAGE_API_GATED_ROOTS - .iter() - .find(|(gated, _)| *gated == root) - .map(|(_, gate)| *gate) -} - -/// Whether `root`, the first segment of a crate-root path, is on the Package API surface. -/// -/// Three families beyond the listed roots: `game_*` covers the table accessor traits and the -/// `game_hook!`/`game_tick_pass!` markers, `pkg_*` covers a Package's own generated root (and its -/// siblings'), and an UpperCamelCase name is a row or payload type re-exported at the crate root. -/// A gated root is on the surface only in a file that starts with its gate. -fn on_package_api(root: &str, file_gate: Option<&str>) -> bool { - let upper_camel = root.starts_with(|c: char| c.is_ascii_uppercase()) - && root.chars().any(|c| c.is_ascii_lowercase()); - PACKAGE_API_ROOTS.contains(&root) - || PACKAGE_API_ROOT_ITEMS.contains(&root) - || root.starts_with("game_") - || root.starts_with("pkg_") - || upper_camel - || root_gate(root).is_some_and(|gate| file_gate == Some(gate)) -} - -#[derive(Clone, Copy)] -struct SourceToken<'a> { - text: &'a str, - start: usize, -} - -/// The Rust tokens this lint needs, with byte positions for diagnostics. This is deliberately much -/// smaller than a parser: identifiers, `::`, and punctuation stay distinct; whitespace vanishes. -fn source_tokens(source: &str) -> Vec> { - let bytes = source.as_bytes(); - let mut tokens = Vec::new(); - let mut at = 0usize; - while at < bytes.len() { - if bytes[at].is_ascii_whitespace() { - at += 1; - continue; - } - let start = at; - if bytes[at] == b'r' - && bytes.get(at + 1) == Some(&b'#') - && bytes - .get(at + 2) - .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_') - { - at += 3; - while at < bytes.len() && (bytes[at].is_ascii_alphanumeric() || bytes[at] == b'_') { - at += 1; - } - } else if bytes[at].is_ascii_alphabetic() || bytes[at] == b'_' { - at += 1; - while at < bytes.len() && (bytes[at].is_ascii_alphanumeric() || bytes[at] == b'_') { - at += 1; - } - } else if bytes[at] == b':' && bytes.get(at + 1) == Some(&b':') { - at += 2; - } else { - at += source[at..] - .chars() - .next() - .expect("at is before the end of source") - .len_utf8(); - } - tokens.push(SourceToken { - text: &source[start..at], - start, - }); - } - tokens -} - -fn is_ident(token: SourceToken<'_>) -> bool { - token - .text - .as_bytes() - .first() - .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') -} - -fn ident_name(token: SourceToken<'_>) -> Option<&str> { - is_ident(token).then(|| token.text.strip_prefix("r#").unwrap_or(token.text)) -} - -/// Every matched brace pair in the stripped source, as byte positions of the braces. -fn brace_pairs(tokens: &[SourceToken<'_>]) -> Vec<(usize, usize)> { - let mut open = Vec::new(); - let mut pairs = Vec::new(); - for token in tokens { - match token.text { - "{" => open.push(token.start), - "}" => { - if let Some(start) = open.pop() { - pairs.push((start, token.start)); - } - } - _ => {} - } - } - pairs.sort_unstable(); - pairs -} - -/// Inline `mod name { .. }` bodies. File position supplies the rest of a Package module's depth. -fn inline_modules(tokens: &[SourceToken<'_>], pairs: &[(usize, usize)]) -> Vec<(usize, usize)> { - let mut modules = Vec::new(); - for window in tokens.windows(3) { - if window[0].text != "mod" || !is_ident(window[1]) || window[2].text != "{" { - continue; - } - if let Some(pair) = pairs.iter().find(|(open, _)| *open == window[2].start) { - modules.push(*pair); - } - } - modules.sort_unstable(); - modules -} - -fn inline_module_stack(modules: &[(usize, usize)], at: usize) -> Vec { - modules - .iter() - .enumerate() - .filter_map(|(index, (open, close))| (*open < at && at < *close).then_some(index)) - .collect() -} - -fn module_depth(file_depth: usize, modules: &[(usize, usize)], at: usize) -> usize { - file_depth + inline_module_stack(modules, at).len() -} - -/// `mod.rs` is its directory module; any other file adds its stem as one module segment. -fn package_file_module_depth(package_src: &Path, file: &Path) -> usize { - let relative = file.strip_prefix(package_src).unwrap_or_else(|_| { - panic!( - "build.rs: {} is not under Package source {}", - file.display(), - package_src.display() - ) - }); - let components = relative.components().count(); - if relative.file_name().is_some_and(|name| name == "mod.rs") { - components.saturating_sub(1) - } else { - components - } -} - -/// Alias names introduced for the whole crate by one `crate` use-tree root. The direct form is -/// `crate as core`; the grouped form is `crate::{self as core}`. -fn aliases_after_use_root( - tokens: &[SourceToken<'_>], - root: usize, - statement_end: usize, -) -> Vec { - if root + 2 < statement_end - && tokens[root + 1].text == "as" - && is_ident(tokens[root + 2]) - && tokens[root + 2].text != "_" - { - return vec![root + 2]; - } - if root + 2 >= statement_end || tokens[root + 1].text != "::" || tokens[root + 2].text != "{" { - return Vec::new(); - } - - let mut aliases = Vec::new(); - let mut depth = 0usize; - let mut at_entry = true; - let mut index = root + 3; - while index < statement_end { - match tokens[index].text { - "{" => depth += 1, - "}" if depth == 0 => break, - "}" => depth -= 1, - "," if depth == 0 => at_entry = true, - "self" if depth == 0 && at_entry => { - if index + 2 < statement_end - && tokens[index + 1].text == "as" - && is_ident(tokens[index + 2]) - && tokens[index + 2].text != "_" - { - aliases.push(index + 2); - } - at_entry = false; - } - _ if depth == 0 && at_entry => at_entry = false, - _ => {} - } - index += 1; - } - aliases -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum UnsupportedPackageSyntax { - WholeCrateAlias, - PathAttribute, - IncludeMacro, -} - -/// Syntax whose meaning the Package API lint cannot track reliably. Refuse it where it is -/// declared instead of guessing at Rust name resolution or filesystem-to-module mapping. -fn unsupported_package_syntax( - tokens: &[SourceToken<'_>], - stripped: &str, -) -> Vec<(usize, usize, UnsupportedPackageSyntax, String)> { - let mut found = Vec::new(); - for (use_index, token) in tokens.iter().enumerate() { - if token.text != "use" { - continue; - } - let statement_end = tokens[use_index + 1..] - .iter() - .position(|token| token.text == ";") - .map_or(tokens.len(), |offset| use_index + 1 + offset); - let mut index = use_index + 1; - while index < statement_end { - if tokens[index].text == "crate" { - for name_index in aliases_after_use_root(tokens, index, statement_end) { - let offset = tokens[index].start; - found.push(( - offset, - 0, - UnsupportedPackageSyntax::WholeCrateAlias, - format!("crate alias `{}`", tokens[name_index].text), - )); - } - } - index += 1; - } - } - - // `extern crate self as core` is the older spelling of the same file-local crate alias. - for window in tokens.windows(5) { - if window[0].text == "extern" - && window[1].text == "crate" - && window[2].text == "self" - && window[3].text == "as" - && is_ident(window[4]) - && window[4].text != "_" - { - found.push(( - window[0].start, - 0, - UnsupportedPackageSyntax::WholeCrateAlias, - format!("crate alias `{}`", window[4].text), - )); - } - } - - for (index, window) in tokens.windows(3).enumerate() { - if window[0].text != "#" || window[1].text != "[" { - continue; - } - let Some(attribute_end) = tokens[index + 2..] - .iter() - .position(|token| token.text == "]") - .map(|offset| index + 2 + offset) - else { - continue; - }; - let attribute = &tokens[index + 2..attribute_end]; - let is_path = attribute.first().is_some_and(|token| { - ident_name(*token) == Some("path") - && attribute.get(1).is_some_and(|token| token.text == "=") - }); - let is_conditional_path = attribute.first().is_some_and(|token| { - ident_name(*token) == Some("cfg_attr") - && attribute - .windows(2) - .any(|pair| ident_name(pair[0]) == Some("path") && pair[1].text == "=") - }); - if is_path || is_conditional_path { - found.push(( - window[0].start, - 0, - UnsupportedPackageSyntax::PathAttribute, - if is_path { - "`#[path]`".to_string() - } else { - "`#[cfg_attr(..., path = ...)]`".to_string() - }, - )); - } - } - - // `include!` parses another file as Rust in this module. The lint only discovers `.rs` files, - // so following it would require a second source-discovery rule. Refuse code inclusion while - // keeping data inclusion (`include_str!` and `include_bytes!`) available to Packages. - for window in tokens.windows(2) { - if ident_name(window[0]) == Some("include") && window[1].text == "!" { - found.push(( - window[0].start, - 0, - UnsupportedPackageSyntax::IncludeMacro, - "`include!`".to_string(), - )); - } - } - - for finding in &mut found { - finding.1 = stripped[..finding.0].matches('\n').count() + 1; - } - found.sort_by_key(|finding| finding.0); - found.dedup_by(|left, right| left.0 == right.0 && left.2 == right.2); - found -} - -/// The root and diagnostic spelling of every path introduced after one `::`. A braced group emits -/// one result per entry at its first depth; a glob is one forbidden root of `*`. -fn roots_after_separator( - tokens: &[SourceToken<'_>], - separator: usize, - prefix: &str, -) -> Vec<(usize, String, String)> { - let Some(first) = tokens.get(separator + 1) else { - return Vec::new(); - }; - if first.text != "{" { - if !is_ident(*first) && first.text != "*" { - return Vec::new(); - } - let mut root_index = separator + 1; - let mut root = first.text; - let mut written = format!("{prefix}::{root}"); - if root == "self" - && tokens - .get(root_index + 1) - .is_some_and(|token| token.text == "::") - && tokens - .get(root_index + 2) - .is_some_and(|token| is_ident(*token)) - { - root_index += 2; - root = tokens[root_index].text; - written.push_str("::"); - written.push_str(root); - } - let mut end = root_index; - while tokens.get(end + 1).is_some_and(|token| token.text == "::") - && tokens.get(end + 2).is_some_and(|token| is_ident(*token)) - { - end += 2; - written.push_str("::"); - written.push_str(tokens[end].text); - } - return vec![(root_index, root.to_string(), written)]; - } - - let mut roots = Vec::new(); - let mut depth = 0usize; - let mut at_entry = true; - let mut index = separator + 2; - while index < tokens.len() { - match tokens[index].text { - "{" => depth += 1, - "}" if depth == 0 => break, - "}" => depth -= 1, - "," if depth == 0 => at_entry = true, - _ if depth == 0 && at_entry => { - at_entry = false; - if tokens[index].text == "self" { - if tokens - .get(index + 1) - .is_some_and(|token| token.text == "::") - && tokens.get(index + 2).is_some_and(|token| is_ident(*token)) - { - let root = tokens[index + 2].text.to_string(); - roots.push((index + 2, root.clone(), format!("{prefix}::self::{root}"))); - } - } else if is_ident(tokens[index]) || tokens[index].text == "*" { - roots.push(( - index, - tokens[index].text.to_string(), - format!("{prefix}::{}", tokens[index].text), - )); - } - } - _ => {} - } - index += 1; - } - roots -} - -fn record_rooted_paths( - tokens: &[SourceToken<'_>], - separator: usize, - prefix: &str, - stripped: &str, - exempt_lines: &[usize], - file_gate: Option<&str>, - found: &mut Vec<(usize, usize, String)>, -) { - for (root_index, root, written) in roots_after_separator(tokens, separator, prefix) { - let normalized_root = root.strip_prefix("r#").unwrap_or(&root); - if normalized_root == "self" || on_package_api(normalized_root, file_gate) { - continue; - } - let offset = tokens[root_index].start; - let line = stripped[..offset].matches('\n').count() + 1; - if !exempt_lines.contains(&line) || root_gate(normalized_root).is_some() { - found.push((offset, line, written)); - } - } -} - -/// Every Package path in `source` that reaches a crate root outside the Package API surface, as -/// (1-based line, path as written). `file_depth` is zero for `src/mod.rs`, one for `src/foo.rs` or -/// `src/foo/mod.rs`, and so on. Inline modules add to it at the occurrence. -/// -/// The scan runs on the comment- and string-stripped copy. It recognizes `crate`, `$crate`, and -/// enough leading `super` segments to leave the Package. The exemption lines come from real line -/// comments recorded while stripping source. -fn out_of_surface_paths(source: &str, file_depth: usize) -> Vec<(usize, String)> { - let stripped_source = strip_source(source); - let stripped = stripped_source.code; - let file_gate = file_gate(source); - let tokens = source_tokens(&stripped); - let pairs = brace_pairs(&tokens); - let modules = inline_modules(&tokens, &pairs); - let mut found = Vec::new(); - - let mut index = 0usize; - while index < tokens.len() { - if tokens[index].text == "$" - && tokens - .get(index + 1) - .is_some_and(|token| token.text == "crate") - && tokens - .get(index + 2) - .is_some_and(|token| token.text == "::") - { - record_rooted_paths( - &tokens, - index + 2, - "$crate", - &stripped, - &stripped_source.package_api_exempt_lines, - file_gate, - &mut found, - ); - index += 3; - continue; - } - if tokens[index].text == "crate" - && (index == 0 || tokens[index - 1].text != "$") - && tokens - .get(index + 1) - .is_some_and(|token| token.text == "::") - { - record_rooted_paths( - &tokens, - index + 1, - "crate", - &stripped, - &stripped_source.package_api_exempt_lines, - file_gate, - &mut found, - ); - } else if tokens[index].text == "super" && (index == 0 || tokens[index - 1].text != "::") { - let mut levels = 1usize; - let mut root_end = index; - while tokens - .get(root_end + 1) - .is_some_and(|token| token.text == "::") - && tokens - .get(root_end + 2) - .is_some_and(|token| token.text == "super") - { - levels += 1; - root_end += 2; - } - if levels == module_depth(file_depth, &modules, tokens[index].start) + 1 - && tokens - .get(root_end + 1) - .is_some_and(|token| token.text == "::") - { - let prefix = std::iter::repeat_n("super", levels) - .collect::>() - .join("::"); - record_rooted_paths( - &tokens, - root_end + 1, - &prefix, - &stripped, - &stripped_source.package_api_exempt_lines, - file_gate, - &mut found, - ); - } - index = root_end; - } - index += 1; - } - - found.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.2.cmp(&right.2))); - found.dedup_by(|left, right| left.0 == right.0 && left.2 == right.2); - found - .into_iter() - .map(|(_, line, path)| (line, path)) - .collect() -} - -/// What the build says when a Package names a core path outside the surface. -fn out_of_surface_message(package: &str, file: &Path, line: usize, path: &str) -> String { - let root = path - .split("::") - .find(|segment| !matches!(*segment, "crate" | "$crate" | "super" | "self")) - .map(|segment| segment.strip_prefix("r#").unwrap_or(segment)); - if let Some(gate) = root.and_then(root_gate) { - return format!( - "build.rs: Package `{package}` names `{path}` at {}:{line}, a gated Package API root \ - (docs/package-api.md, version 1). Name it only from a file whose first non-blank line \ - is `{gate}`.", - file.display() - ); - } - format!( - "build.rs: Package `{package}` names `{path}` at {}:{line}, which is outside the Package \ - API surface (docs/package-api.md, version 1). Use a path under a documented root, or, if \ - the Package genuinely needs this one, write `{PACKAGE_API_EXEMPT} ` on that line \ - and raise the gap with the maintainers.", - file.display() - ) -} - -fn unsupported_package_syntax_message( - package: &str, - file: &Path, - line: usize, - kind: UnsupportedPackageSyntax, - written: &str, -) -> String { - let instruction = match kind { - UnsupportedPackageSyntax::WholeCrateAlias => { - "Whole-crate aliases can hide core paths across Rust scopes. Spell each core path as \ - `crate::` instead." - } - UnsupportedPackageSyntax::PathAttribute => { - "Path attributes break Package source discovery and module-depth checks. Use Rust's \ - normal `mod.rs`, `.rs`, or `/mod.rs` layout instead." - } - UnsupportedPackageSyntax::IncludeMacro => { - "`include!` can add Rust source that the Package API lint cannot discover. Put Package \ - Rust in a normal `.rs` source file instead." - } - }; - format!( - "build.rs: Package `{package}` uses unsupported Package API syntax {written} at \ - {}:{line}. {instruction}", - file.display() - ) -} - -/// Fail the build for the first out-of-surface path in one Package file. Core `src/` is never -/// linted: the surface is a promise core makes to Packages, not to itself. -fn lint_package_api(package: &str, package_src: &Path, file: &Path) { - let source = fs::read_to_string(file) - .unwrap_or_else(|e| panic!("build.rs: cannot read {}: {e}", file.display())); - let stripped = strip_comments_and_strings(&source); - let tokens = source_tokens(&stripped); - if let Some((_, line, kind, written)) = unsupported_package_syntax(&tokens, &stripped).first() { - panic!( - "{}", - unsupported_package_syntax_message(package, file, *line, *kind, written) - ); - } - let file_depth = package_file_module_depth(package_src, file); - if let Some((line, path)) = out_of_surface_paths(&source, file_depth).first() { - panic!("{}", out_of_surface_message(package, file, *line, path)); - } -} - /// 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)) { @@ -1829,7 +1006,7 @@ fn scan_marker(content: &str, _file: &Path, marker: &str, mut on_hit: impl FnMut } #[cfg(test)] -mod package_api_lint_tests { +mod build_script_tests { use super::*; #[test] @@ -1908,396 +1085,4 @@ mod package_api_lint_tests { "crate::hooks::pass" ); } - - fn reported(source: &str) -> Vec { - reported_at_depth(source, 0) - } - - fn reported_at_depth(source: &str, file_depth: usize) -> Vec { - out_of_surface_paths(source, file_depth) - .into_iter() - .map(|(line, path)| format!("{line}:{path}")) - .collect() - } - - fn unsupported(source: &str) -> Vec { - let stripped = strip_comments_and_strings(source); - let tokens = source_tokens(&stripped); - unsupported_package_syntax(&tokens, &stripped) - .into_iter() - .map(|(_, line, _, written)| format!("{line}:{written}")) - .collect() - } - - #[test] - fn a_documented_root_and_its_depth_are_on_the_surface() { - let source = "use crate::helpers::live_entity;\nfn f(e: &crate::WorldEntity) {\n crate::creatures::tick::emit_creature_leg(e);\n crate::game_hook!(a);\n}\n"; - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn an_undocumented_root_is_reported_with_its_line_and_path() { - let source = "fn f() {\n let _ = 1;\n crate::auth::create_character();\n}\n"; - assert_eq!(reported(source), vec!["3:crate::auth::create_character"]); - } - - #[test] - fn the_failure_names_the_package_the_file_the_line_and_the_path() { - let message = out_of_surface_message( - "sample_package", - Path::new("packages/sample_package/src/mod.rs"), - 618, - "crate::auth::create_character", - ); - assert!(message.contains("sample_package"), "{message}"); - assert!( - message.contains("packages/sample_package/src/mod.rs:618"), - "{message}" - ); - assert!( - message.contains("crate::auth::create_character"), - "{message}" - ); - assert!(message.contains("docs/package-api.md"), "{message}"); - } - - #[test] - fn an_exemption_clears_its_own_line_and_no_other() { - let source = "fn f() {\n crate::auth::create_character(); // package-api: exempt bots fabricate their own characters\n crate::auth::Account::default();\n}\n"; - assert_eq!(reported(source), vec!["3:crate::auth::Account::default"]); - } - - #[test] - fn a_debug_root_is_refused_outside_a_debug_only_file() { - // A build without `debug_reducers` compiles this file, and an item gate does not change - // what the lint sees. - let source = "#[cfg(feature = \"debug_reducers\")]\nfn f() {\n crate::package_fixture::apply_damage();\n}\n"; - assert_eq!( - reported(source), - vec!["3:crate::package_fixture::apply_damage"] - ); - } - - #[test] - fn a_debug_root_is_on_the_surface_in_a_debug_only_file() { - let source = "\n#![cfg(feature = \"debug_reducers\")]\nuse crate::package_fixture::{apply_damage, top_threat_target};\nfn f() {\n super::package_fixture::client_cast();\n}\n"; - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn an_exemption_cannot_clear_a_debug_root() { - let source = - "crate::package_fixture::apply_damage(); // package-api: exempt fixture damage\n"; - assert_eq!( - reported(source), - vec!["1:crate::package_fixture::apply_damage"] - ); - } - - #[test] - fn a_debug_root_failure_names_the_file_gate() { - let message = out_of_surface_message( - "dungeons", - Path::new("packages/dungeons/src/verify.rs"), - 3, - "crate::package_fixture::apply_damage", - ); - assert!( - message.contains("packages/dungeons/src/verify.rs:3"), - "{message}" - ); - assert!(message.contains(DEBUG_REDUCERS_FILE_CFG), "{message}"); - assert!(!message.contains(PACKAGE_API_EXEMPT), "{message}"); - } - - #[test] - fn a_test_root_is_refused_outside_a_test_only_file() { - // An ordinary build compiles this file, and an item gate does not change what the lint - // sees. - let source = "#[cfg(test)]\nmod tests {\n use crate::package_test::shape_of;\n}\n"; - assert_eq!(reported(source), vec!["3:crate::package_test::shape_of"]); - } - - #[test] - fn a_test_root_is_on_the_surface_in_a_test_only_file() { - let source = "#![cfg(test)]\nuse crate::package_test::{ask_offline, read_scanned};\nfn f() {\n super::super::package_test::shape_of();\n}\n"; - assert!( - reported_at_depth(source, 1).is_empty(), - "{:?}", - reported_at_depth(source, 1) - ); - } - - #[test] - fn each_gated_root_needs_its_own_gate() { - let test_file = "#![cfg(test)]\ncrate::package_fixture::apply_damage();\n"; - let debug_file = - "#![cfg(feature = \"debug_reducers\")]\ncrate::package_test::ask_offline();\n"; - assert_eq!( - reported(test_file), - vec!["2:crate::package_fixture::apply_damage"] - ); - assert_eq!( - reported(debug_file), - vec!["2:crate::package_test::ask_offline"] - ); - } - - #[test] - fn an_exemption_cannot_clear_a_test_root() { - let source = "use crate::package_test::read_scanned; // package-api: exempt scan Core\n"; - assert_eq!( - reported(source), - vec!["1:crate::package_test::read_scanned"] - ); - } - - #[test] - fn a_test_root_failure_names_the_test_gate() { - let message = out_of_surface_message( - "bots", - Path::new("packages/bots/src/goals.rs"), - 9, - "crate::package_test::shape_of", - ); - assert!(message.contains(TEST_FILE_CFG), "{message}"); - assert!(!message.contains(PACKAGE_API_EXEMPT), "{message}"); - } - - #[test] - fn an_exemption_without_a_reason_does_not_clear() { - let source = "crate::auth::Account; // package-api: exempt\n"; - assert_eq!(reported(source), vec!["1:crate::auth::Account"]); - } - - #[test] - fn a_braced_import_is_read_entry_by_entry() { - let source = "use crate::{\n game_world_entity,\n auth::Account,\n quest::{objective_kind, quest_role},\n test_scan,\n};\n"; - assert_eq!( - reported(source), - vec!["3:crate::auth", "5:crate::test_scan"] - ); - } - - #[test] - fn a_path_in_a_comment_or_a_string_is_inert() { - let source = - "// crate::auth::create_character is core's own\nlet s = \"crate::test_scan\";\n"; - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn another_crates_path_is_not_this_crates_path() { - let source = "use lyracore_crate::auth::Account;\n"; - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn a_relative_path_that_leaves_the_package_root_is_reported() { - let source = "use super::auth::Account;\n"; - assert_eq!(reported(source), vec!["1:super::auth::Account"]); - } - - #[test] - fn only_enough_super_segments_to_leave_the_package_are_linted() { - let source = "mod tests {\n use super::super::package_sibling;\n use super::super::super::auth::Account;\n}\n"; - assert_eq!( - reported_at_depth(source, 1), - vec!["3:super::super::super::auth::Account"] - ); - } - - #[test] - fn package_relative_siblings_and_submodules_stay_inside_the_package() { - let source = - "use super::sibling;\nmod tests {\n use super::super::package_root_item;\n}\n"; - assert!( - reported_at_depth(source, 1).is_empty(), - "{:?}", - reported_at_depth(source, 1) - ); - } - - #[test] - fn a_whole_crate_alias_is_unsupported_before_it_can_hide_a_path() { - let source = "use crate as core;\nuse core::auth::Account;\n"; - assert_eq!(unsupported(source), vec!["1:crate alias `core`"]); - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn a_package_alias_named_core_is_not_reclassified_as_the_crate() { - let source = - "mod owned { pub mod auth {} }\nfn f() { use self::owned as core; use core::auth; }\n"; - assert!(unsupported(source).is_empty(), "{:?}", unsupported(source)); - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn grouped_whole_crate_aliases_are_unsupported() { - let source = "use crate::{self as core, helpers};\nuse core::auth;\n"; - assert_eq!(unsupported(source), vec!["1:crate alias `core`"]); - } - - #[test] - fn every_whole_crate_alias_spelling_is_unsupported() { - let source = - "use {crate as first};\nuse crate::{self as second};\nextern crate self as third;\n"; - assert_eq!( - unsupported(source), - vec![ - "1:crate alias `first`", - "2:crate alias `second`", - "3:crate alias `third`" - ] - ); - } - - #[test] - fn raw_whole_crate_aliases_are_unsupported() { - let source = "use crate as r#core;\nuse r#core::auth;\n"; - assert_eq!(unsupported(source), vec!["1:crate alias `r#core`"]); - assert!(reported(source).is_empty(), "{:?}", reported(source)); - } - - #[test] - fn crate_root_globs_are_outside_the_named_surface() { - let source = "use crate::*;\nuse super::*;\n"; - assert_eq!(reported(source), vec!["1:crate::*", "2:super::*"]); - } - - #[test] - fn dollar_crate_and_whitespace_around_separators_do_not_bypass_the_lint() { - let source = "macro_rules! hidden { () => { $crate::auth::Account } }\nuse crate /* gap */ :: test_scan;\n"; - assert_eq!( - reported(source), - vec!["1:$crate::auth::Account", "2:crate::test_scan"] - ); - } - - #[test] - fn an_exemption_remains_line_local_for_relative_and_direct_paths() { - let source = "use super::auth; // package-api: exempt Package setup needs Accounts\nuse crate::test_scan;\n"; - assert_eq!(reported(source), vec!["2:crate::test_scan"]); - } - - #[test] - fn an_exemption_cannot_enable_a_whole_crate_alias() { - let source = "use crate as core; // package-api: exempt legacy spelling\n"; - assert_eq!(unsupported(source), vec!["1:crate alias `core`"]); - } - - #[test] - fn marker_text_inside_a_string_is_not_an_exemption() { - let source = "let note = \"// package-api: exempt not a comment\"; crate::auth::Account;\n"; - assert_eq!(reported(source), vec!["1:crate::auth::Account"]); - } - - #[test] - fn raw_identifiers_are_normalized_for_the_package_api() { - let source = "use crate::r#helpers::live_entity;\nuse crate::r#auth::Account;\n"; - assert_eq!(reported(source), vec!["2:crate::r#auth::Account"]); - } - - #[test] - fn a_raw_inline_module_counts_toward_relative_depth() { - let source = "mod r#nested { use super::package_item; use super::super::auth::Account; }\n"; - assert_eq!(reported(source), vec!["1:super::super::auth::Account"]); - } - - #[test] - fn path_attributes_are_unsupported_even_when_conditional_or_exempted() { - let source = "#[path = \"layout/hidden.rs\"] // package-api: exempt legacy layout\nmod hidden;\n#[cfg_attr(unix, path = \"unix.rs\")]\nmod platform;\n"; - assert_eq!( - unsupported(source), - vec!["1:`#[path]`", "3:`#[cfg_attr(..., path = ...)]`"] - ); - } - - #[test] - fn include_cannot_inject_an_unblessed_path_from_a_non_rust_file() { - let package_src = std::env::temp_dir().join(format!( - "lyracore-package-api-include-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("the system clock is after the Unix epoch") - .as_nanos(), - )); - fs::create_dir_all(&package_src).expect("create temporary Package source"); - let module = package_src.join("mod.rs"); - fs::write( - &module, - "include /* whitespace is allowed */ ! (\"private.inc\");\n", - ) - .expect("write Package module"); - fs::write( - package_src.join("private.inc"), - "crate::auth::Account::default();\n", - ) - .expect("write included Package source"); - - let failure = std::panic::catch_unwind(|| lint_package_api("bots", &package_src, &module)) - .expect_err("include! must fail before a non-.rs file can add a core path"); - let _ = fs::remove_dir_all(&package_src); - let message = failure - .downcast_ref::() - .map(String::as_str) - .or_else(|| failure.downcast_ref::<&str>().copied()) - .expect("lint panic has a message"); - assert!(message.contains("`include!`"), "{message}"); - assert!(message.contains("Package `bots`"), "{message}"); - } - - #[test] - fn raw_or_spaced_include_names_cannot_bypass_the_refusal() { - assert_eq!( - unsupported("r#include ! (\"private.inc\");\ninclude /* gap */ ! (\"hidden.inc\");"), - vec!["1:`include!`", "2:`include!`"] - ); - } - - #[test] - fn data_include_macros_remain_available() { - assert!( - unsupported("let text = include_str!(\"fixture.txt\");\nlet bytes = include_bytes!(\"fixture.bin\");") - .is_empty() - ); - } - - #[test] - fn unsupported_syntax_diagnostics_tell_the_author_what_to_write() { - let alias = unsupported_package_syntax_message( - "bots", - Path::new("packages/bots/src/mod.rs"), - 3, - UnsupportedPackageSyntax::WholeCrateAlias, - "crate alias `core`", - ); - assert!(alias.contains("packages/bots/src/mod.rs:3"), "{alias}"); - assert!(alias.contains("`crate::`"), "{alias}"); - assert!(!alias.contains(PACKAGE_API_EXEMPT), "{alias}"); - - let path = unsupported_package_syntax_message( - "bots", - Path::new("packages/bots/src/mod.rs"), - 5, - UnsupportedPackageSyntax::PathAttribute, - "`#[path]`", - ); - assert!(path.contains("packages/bots/src/mod.rs:5"), "{path}"); - assert!(path.contains("normal `mod.rs`"), "{path}"); - assert!(!path.contains(PACKAGE_API_EXEMPT), "{path}"); - - let include = unsupported_package_syntax_message( - "bots", - Path::new("packages/bots/src/mod.rs"), - 7, - UnsupportedPackageSyntax::IncludeMacro, - "`include!`", - ); - assert!(include.contains("packages/bots/src/mod.rs:7"), "{include}"); - assert!(include.contains("normal `.rs` source file"), "{include}"); - assert!(!include.contains(PACKAGE_API_EXEMPT), "{include}"); - } } diff --git a/module/build_support/source.rs b/module/build_support/source.rs new file mode 100644 index 000000000..e21240a97 --- /dev/null +++ b/module/build_support/source.rs @@ -0,0 +1,201 @@ +//! 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()); + let mut line_comments = Vec::new(); + let blank = |c: char| if c == '\n' { '\n' } else { ' ' }; + let mut i = 0usize; + let mut line = 1usize; + let mut counted_until = 0usize; + while i < b.len() { + 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' { + out.push(' '); + i += 1; + } + 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() { + if b[i] == '/' && b.get(i + 1) == Some(&'*') { + depth += 1; + out.push(' '); + out.push(' '); + i += 2; + } else if b[i] == '*' && b.get(i + 1) == Some(&'/') { + depth -= 1; + out.push(' '); + out.push(' '); + i += 2; + if depth == 0 { + break; + } + } else { + out.push(blank(b[i])); + i += 1; + } + } + 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 }; + let mut j = r_at + 1; + while b.get(j) == Some(&'#') { + j += 1; + } + 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)); + } + i = j + 1; + 'raw: while i < b.len() { + if b[i] == '"' { + let mut h = 0usize; + while h < hashes && b.get(i + 1 + h) == Some(&'#') { + h += 1; + } + if h == hashes { + for c in &b[i..=(i + hashes)] { + out.push(blank(*c)); + } + i += hashes + 1; + break 'raw; + } + } + out.push(blank(b[i])); + i += 1; + } + 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 + i += 1; + while i < b.len() { + if b[i] == '\\' { + out.push(' '); + if i + 1 < b.len() { + out.push(blank(b[i + 1])); + } + i += 2; + continue; + } + if b[i] == '"' { + out.push(' '); + i += 1; + break; + } + out.push(blank(b[i])); + i += 1; + } + 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, + Some(_) => b.get(i + 2) == Some(&'\''), + None => false, + }; + if is_char_lit { + 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{...}'). + out.push(' '); + i += 1; + if i < b.len() { + out.push(blank(b[i])); + i += 1; + } + while i < b.len() && b[i] != '\'' { + out.push(blank(b[i])); + i += 1; + } + } else { + out.push(blank(b[i])); + i += 1; + } + if b.get(i) == Some(&'\'') { + out.push(' '); + i += 1; + } + continue; + } + // lifetime: keep the quote so positions stay aligned + } + out.push(c); + i += 1; + } + StrippedSource { + code: out.into_iter().collect(), + line_comments, + } +} + +/// 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())); + for entry in entries { + let path = entry + .unwrap_or_else(|e| panic!("cannot read dir entry in {}: {e}", dir.display())) + .path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} diff --git a/module/src/actor.rs b/module/src/actor.rs index acf37788a..bddf8a354 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -3,6 +3,8 @@ use crate::import_meta::game_import_meta; use spacetimedb::ReducerContext; +pub(crate) mod movement; + #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) struct ImportRevision { pub source_sha: String, @@ -30,12 +32,11 @@ macro_rules! debug_only { }; } -// Package-only verbs need no caller when no Rust Package is installed. Restore the unused-import -// lint when a Package is present so unused verbs remain visible during Package development. +// Each Package selects the Actor operations it needs from these named exports. macro_rules! package_only { ($(pub(crate) use $path:path as $verb:ident;)+) => { $( - #[cfg_attr(not(has_packages), allow(unused_imports))] + #[allow(unused_imports, reason = "Packages select the Actor operations they need")] pub(crate) use $path as $verb; )+ }; @@ -81,6 +82,18 @@ package_only! { pub(crate) use crate::quest::request_turn_in_quest as request_turn_in_quest; } +package_only! { + pub(crate) use crate::quest::accept_gates as quest_acceptance; + pub(crate) use crate::quest::quest_is_complete as quest_complete; + pub(crate) use crate::quest::character_quest_row as character_quest; + pub(crate) use crate::quest::objective_kind as objective_kind; + pub(crate) use crate::quest::quest_role as quest_role; + pub(crate) use crate::quest::MAX_QUEST_LOG_SIZE as MAX_QUEST_LOG_SIZE; + pub(crate) use crate::quest::AreaTriggerRoute as AreaTriggerRoute; + pub(crate) use crate::creatures::creature_spawn_evidence as creature_spawn_evidence; + pub(crate) use crate::gameobject::gameobject_destination_evidence as gameobject_destination_evidence; +} + #[cfg(all(has_packages, feature = "debug_reducers"))] 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; } @@ -112,6 +125,83 @@ 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; + pub(crate) use crate::helpers::character_by_name as character_by_name; + pub(crate) use crate::helpers::entities_near as entities_near; + pub(crate) use crate::helpers::in_same_partition as in_same_partition; + pub(crate) use crate::faction::is_friendly as is_friendly; + pub(crate) use crate::combat::validate_attack_target as attack_target_gate; + pub(crate) use crate::combat::AttackStart as AttackStart; + pub(crate) use crate::items::has_free_slot as has_free_slot; + pub(crate) use crate::items::item_count as item_count; + pub(crate) use crate::xp::xp_for_kill as xp_for_kill; + pub(crate) use crate::xp::rank_xp_multiplier as rank_xp_multiplier; + 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; + pub(crate) use crate::loot::corpse_eligible_recipients as corpse_eligible_recipients; + pub(crate) use crate::loot::death_entitlement as death_entitlement; + pub(crate) use crate::loot::tag::live_loot_tag_eligibility as live_loot_tag_eligibility; + 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; + pub(crate) use crate::spell::expire_cast_attempt as expire_cast_attempt; + pub(crate) use crate::spell::buff_status as buff_status; + pub(crate) use crate::spell::control_status as control_status; + pub(crate) use crate::spell::has_aura as has_aura; + pub(crate) use crate::spell::knows_spell as knows_spell; + pub(crate) use crate::spell::CastHandle as CastHandle; + pub(crate) use crate::spell::CastStart as CastStart; + pub(crate) use crate::spell::CastFinish as CastFinish; + pub(crate) use crate::spell::CastRefusal as CastRefusal; + pub(crate) use crate::spell::CastRefusalKind as CastRefusalKind; + pub(crate) use crate::spell::BuffStatus as BuffStatus; + pub(crate) use crate::spell::BuffUnavailableReason as BuffUnavailableReason; + pub(crate) use crate::spell::ControlReadError as ControlReadError; + pub(crate) use crate::spell::A_CONTROL as A_CONTROL; + pub(crate) use crate::spell::SPELL_ATTR_CHANNELED as SPELL_ATTR_CHANNELED; + pub(crate) use crate::spell::A_PERIODIC_HEAL as A_PERIODIC_HEAL; + pub(crate) use crate::spell::A_PERIODIC_TRIGGER as A_PERIODIC_TRIGGER; + pub(crate) use crate::spell::A_PROC_TRIGGER as A_PROC_TRIGGER; + pub(crate) use crate::spell::E_HEAL as E_HEAL; + pub(crate) use crate::spell::E_HEAL_MAX_HEALTH as E_HEAL_MAX_HEALTH; + pub(crate) use crate::spell::E_NEXT_SWING as E_NEXT_SWING; + pub(crate) use crate::spell::E_SCRIPTED as E_SCRIPTED; + pub(crate) use crate::spell::T_TARGET_ALLY as T_TARGET_ALLY; + 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; + pub(crate) use crate::group::emit_bot_invite_intent as emit_bot_invite_intent; + pub(crate) use crate::group::emit_bot_leave_intent as emit_bot_leave_intent; + pub(crate) use crate::group::leave_group_for as leave_group_for; + pub(crate) use crate::group::PartyEnemyFacts as PartyEnemyFacts; + pub(crate) use crate::group::PartyFactsUnavailable as PartyFactsUnavailable; + pub(crate) use crate::group::PartyFactsUnavailableReason as PartyFactsUnavailableReason; + pub(crate) use crate::group::PartyMemberFacts as PartyMemberFacts; + pub(crate) use crate::group::PartyPartitionFacts as PartyPartitionFacts; + pub(crate) use crate::group::PartyPartitionState as PartyPartitionState; + pub(crate) use crate::group::PartyUnitFacts as PartyUnitFacts; +} + // ---- NPC services / world ---- package_only! { @@ -130,6 +220,7 @@ package_only! { package_only! { pub(crate) use crate::chat::emit_system_message as system_message; + pub(crate) use crate::chat::apply_send_chat as send_chat; pub(crate) use crate::group::accept_invite_for as accept_group_invite; pub(crate) use crate::sessionless::set_sessionless_action_consent as set_sessionless_action_consent; pub(crate) use crate::sessionless::action_gate as sessionless_action_gate; @@ -192,3 +283,56 @@ impl std::fmt::Display for ActionRefusal { self.detail.fmt(f) } } + +/// 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, + character_guid: u64, +) -> Option { + use crate::tables::{game_creature_spline, game_world_entity}; + use spacetimedb::{Identity, Table}; + + if let Ok(entity) = crate::helpers::live_entity(ctx, character_guid) { + return Some(entity); + } + let character = crate::helpers::character_by_guid(ctx, character_guid)?; + let mut entity = crate::creatures::build_player_entity(ctx, &character, Identity::ZERO); + if character.pending_ghost { + let (dead, health, player_flags, unit_bytes_1) = + crate::world::ghost_restored_fields(entity.player_flags, entity.unit_bytes_1); + entity.dead = dead; + entity.health = health; + entity.player_flags = player_flags; + entity.unit_bytes_1 = unit_bytes_1; + } + let entity = ctx.db.game_world_entity().insert(entity); + ctx.db.game_creature_spline().guid().delete(character_guid); + 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}; + + if ctx + .db + .game_creature_spline() + .guid() + .find(actor_guid) + .is_none() + { + return; + } + if let Some(mut entity) = ctx.db.game_world_entity().guid().find(actor_guid) { + crate::creatures::tick::stop_where_rendered(ctx, &mut entity); + ctx.db.game_world_entity().guid().update(entity); + } else { + ctx.db.game_creature_spline().guid().delete(actor_guid); + } +} diff --git a/module/src/actor/movement.rs b/module/src/actor/movement.rs new file mode 100644 index 000000000..42ef27713 --- /dev/null +++ b/module/src/actor/movement.rs @@ -0,0 +1,15 @@ +#![allow( + unused_imports, + 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, + route_step, walkable, CoverageEvidence, NavigationInputs, RoutePoint, RouteStatus, RouteStep, + LEG_MAX_EXPANSIONS, +}; +pub(crate) use crate::terrain::{ground_z, snap_z, zone_id_at}; diff --git a/module/src/auction.rs b/module/src/auction.rs index 3831fedc3..4b078ec94 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -2568,7 +2568,7 @@ impl ExpirySink for CtxExpiry<'_> { pub fn gw_auction_list_local( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, item_guid: u64, auctioneer_guid: u64, house: u32, @@ -2627,7 +2627,7 @@ pub fn gw_auction_list_local( pub fn gw_auction_hold_listing( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, item_guid: u64, auctioneer_guid: u64, house: u32, @@ -2693,7 +2693,7 @@ pub fn gw_auction_hold_listing( pub fn realm_auction_commit_listing( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, item_guid: u64, item_entry: u32, item_stack_count: u32, @@ -2755,7 +2755,7 @@ pub fn realm_auction_commit_listing( fn require_listing_actor( ctx: &ReducerContext, operation_id: u64, - actor: crate::SessionActor, + actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::account_ownership::require_actor(ctx, actor)?; let seller = CtxSource { ctx } @@ -2777,7 +2777,7 @@ pub fn realm_auction_confirm_listing( ctx: &ReducerContext, operation_id: u64, auction_id: u32, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; require_listing_actor(ctx, operation_id, request_actor)?; @@ -2804,7 +2804,7 @@ pub fn realm_auction_confirm_listing( pub fn realm_auction_settle_listing( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; require_listing_actor(ctx, operation_id, request_actor)?; @@ -2819,7 +2819,7 @@ pub fn realm_auction_settle_listing( pub fn realm_auction_refund_listing( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, item_guid: u64, item_entry: u32, item_stack_count: u32, @@ -2874,7 +2874,7 @@ pub fn realm_auction_refund_listing( pub fn gw_auction_release_listing_hold( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let seller_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -2941,7 +2941,7 @@ fn drive_local_hold( pub fn gw_auction_bid_local( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auctioneer_guid: u64, auction_id: u32, house: u32, @@ -2966,7 +2966,7 @@ pub fn gw_auction_bid_local( pub fn gw_auction_cancel_local( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auctioneer_guid: u64, auction_id: u32, house: u32, @@ -2990,7 +2990,7 @@ pub fn gw_auction_cancel_local( pub fn gw_auction_hold_bid( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auctioneer_guid: u64, auction_id: u32, house: u32, @@ -3015,7 +3015,7 @@ pub fn gw_auction_hold_bid( pub fn gw_auction_hold_cancel( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auctioneer_guid: u64, auction_id: u32, house: u32, @@ -3039,7 +3039,7 @@ pub fn gw_auction_hold_cancel( pub fn realm_auction_decide_bid( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auction_id: u32, house: u32, offer: u32, @@ -3068,7 +3068,7 @@ pub fn realm_auction_decide_bid( pub fn realm_auction_decide_cancel( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auction_id: u32, house: u32, cut: u32, @@ -3097,7 +3097,7 @@ pub fn realm_auction_decide_cancel( pub fn gw_auction_finish_bid( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auction_id: u32, house: u32, offer: u32, @@ -3144,7 +3144,7 @@ pub fn gw_auction_finish_bid( pub fn realm_auction_refund_bid( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auction_id: u32, house: u32, offer: u32, @@ -3173,7 +3173,7 @@ pub fn realm_auction_refund_bid( pub fn gw_auction_confirm_bid_refund( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, auction_id: u32, house: u32, offer: u32, diff --git a/module/src/auth.rs b/module/src/auth.rs index 1a4edadc7..1eab309c3 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -1069,7 +1069,7 @@ pub(crate) fn insert_new_character( pub fn delete_character( ctx: &ReducerContext, account_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let character_guid = crate::account_ownership::require_actor(ctx, request_actor)?; diff --git a/module/src/bridge.rs b/module/src/bridge.rs index 8b059e517..388f29879 100644 --- a/module/src/bridge.rs +++ b/module/src/bridge.rs @@ -45,7 +45,7 @@ pub enum CommandOutcome { } impl CommandOutcome { - pub fn tag(&self) -> &'static str { + pub(crate) fn tag(&self) -> &'static str { match self { Self::Applied => "Applied", Self::Unchanged => "Unchanged", diff --git a/module/src/channel.rs b/module/src/channel.rs index a01ae4ed0..7a825fa9b 100644 --- a/module/src/channel.rs +++ b/module/src/channel.rs @@ -136,7 +136,7 @@ pub struct ChannelRequest { #[reducer] pub fn realm_channel_op( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, op: u8, request: ChannelRequest, ) -> Result<(), String> { diff --git a/module/src/chat.rs b/module/src/chat.rs index 4de156ba9..d38753889 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -50,13 +50,13 @@ pub struct ChatEvent { pub target_guid: u64, } -pub fn is_supported_chat_type(chat_type: u8) -> bool { +pub(crate) fn is_supported_chat_type(chat_type: u8) -> bool { matches!(chat_type, CHAT_SAY | CHAT_YELL | CHAT_TEXT_EMOTE) } /// 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 fn normalized_message(raw: &str) -> Option { +pub(crate) fn normalized_message(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; @@ -67,7 +67,7 @@ pub fn normalized_message(raw: &str) -> Option { /// 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] -pub fn addon_payload(raw: &str) -> Option { +pub(crate) fn addon_payload(raw: &str) -> Option { (!raw.is_empty()).then(|| raw.chars().take(MAX_CHAT_LEN).collect()) } diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 0b24ab47b..de090ca0f 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -6,8 +6,7 @@ //! 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` re-exports this module (`pub use death::*`) so every `crate::combat::` path resolves -//! regardless of which submodule actually defines it. +//! `mod.rs` names the operations shared with other Module files. use spacetimedb::{table, ReducerContext, Table, TimeDuration}; @@ -499,9 +498,7 @@ pub fn debug_verify_lethal_damage_floor_fixture( Ok(()) } -// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_money`, `is_engaged`, ...) -// are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`engage::*`/`swing::*`) so -// every symbol resolves the same as before the split. +// 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 diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index c5ae540b7..46da5a6d3 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -4,19 +4,13 @@ //! 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` re-exports -//! this module (`pub use engage::*`) so every `crate::combat::` path resolves regardless of which -//! submodule actually defines it. +//! 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}; -// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `kill_creature`, -// ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`death::*`/`swing::*`) -// — this ALSO brings `tick_melee`/`ranged_impact` into scope for the `MeleeSchedule`/ -// `RangedImpactSchedule` tables' `scheduled(..)` macros below to resolve, since those two reducers are -// defined in `swing.rs` (mirrors `spell::tables`'s identical cross-file `scheduled(..)` pattern). +// 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; diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index a5a0c4509..9eb15e946 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -5,8 +5,7 @@ //! (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` re-exports this module (`pub use folds::*`) so every `crate::combat::` path -//! resolves regardless of which submodule actually defines it. +//! here. `mod.rs` names the operations shared with other Module files. use spacetimedb::ReducerContext; @@ -15,9 +14,7 @@ use crate::{ game_world_entity, ItemInstance, WorldEntity, }; -// Tables' pure formulas/consts (`dodge_chance_bp`, `armor_mitigation_pct`, `CRIT_MULT`, ...) and the -// sibling submodules' re-exports are all pulled in from `mod.rs` (`pub use tables::*` + `pub use -// folds::*`/`death::*`/`engage::*`/`swing::*`) so every symbol resolves the same as before the split. +// Shared combat operations and formulas are named in the parent module. use super::*; /// Fold aura and equipped-item rating onto one attack-table band. @@ -37,7 +34,7 @@ fn fold_band( /// `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 fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { +pub(crate) fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { fold_band( ctx, target.guid, @@ -50,7 +47,7 @@ pub fn effective_dodge_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) - /// `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 fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { +pub(crate) fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) -> u32 { fold_band( ctx, target.guid, @@ -65,7 +62,7 @@ pub fn effective_parry_bp(ctx: &ReducerContext, target: &WorldEntity, sd: u32) - /// 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 fn effective_block_bp( +pub(crate) fn effective_block_bp( ctx: &ReducerContext, target: &WorldEntity, sd: u32, @@ -234,7 +231,7 @@ pub(crate) fn effective_agility(ctx: &ReducerContext, e: &WorldEntity) -> u32 { /// 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 fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { +pub(crate) fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { (i64::from(e.armor) + i64::from(crate::spell::resistance_bonus( ctx, @@ -250,7 +247,7 @@ pub fn effective_armor(ctx: &ReducerContext, e: &WorldEntity) -> u32 { /// 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 fn effective_block_value(ctx: &ReducerContext, defender: &WorldEntity) -> u32 { +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 { @@ -276,7 +273,7 @@ pub fn effective_block_value(ctx: &ReducerContext, defender: &WorldEntity) -> u3 /// `[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 fn effective_crit_bp(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { +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). @@ -294,7 +291,11 @@ pub fn effective_crit_bp(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { /// 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 fn effective_miss_bp(ctx: &ReducerContext, attacker: &WorldEntity, skill_diff: u32) -> u32 { +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). @@ -308,7 +309,7 @@ pub fn effective_miss_bp(ctx: &ReducerContext, attacker: &WorldEntity, skill_dif /// 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 fn effective_swing_time(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { +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) } @@ -325,7 +326,7 @@ pub fn effective_swing_time(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 /// value (base 1.0) into `run_speed_mult_bp` for the player wire relay; this copy governs SERVER-driven /// movement and the anti-cheat max-speed check (`world::score_and_log_movement`), so a mounted player's /// higher speed is never flagged as a speedhack. No move/mount aura → exactly `base` (baseline-safe). [entity] -pub fn effective_move_speed(ctx: &ReducerContext, guid: u64, base: f32) -> f32 { +pub(crate) fn effective_move_speed(ctx: &ReducerContext, guid: u64, base: f32) -> f32 { let move_pct = crate::spell::speed_bonus(ctx, guid, crate::spell::SPEED_MOVE); let mounted_pct = crate::spell::speed_bonus(ctx, guid, crate::spell::SPEED_MOUNTED); let mounted = crate::mount::active_mount_spell(ctx, guid).is_some(); @@ -467,7 +468,7 @@ pub(crate) fn wear_weapon(ctx: &ReducerContext, player_guid: u64) { /// (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 fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> (u32, u32) { +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. @@ -542,7 +543,7 @@ pub fn swing_range_ctx(ctx: &ReducerContext, attacker: &WorldEntity) -> (u32, u3 /// 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 fn weapon_strike_damage( +pub(crate) fn weapon_strike_damage( ctx: &ReducerContext, caster: &WorldEntity, target: &WorldEntity, diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 6b1c916fa..72d8c6796 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -21,17 +21,62 @@ //! - `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`). //! -//! Everything is re-exported below so every `crate::combat::` path resolves regardless of which -//! submodule actually defines it. +//! Named exports below hold the operations and tables shared with other Module files. -mod death; +pub(crate) mod death; mod engage; mod folds; mod swing; mod tables; -pub(crate) use death::*; -pub use engage::*; -pub use folds::*; -pub use swing::*; -pub use tables::*; +pub(crate) use death::{ + apply_hit, clear_lethal_damage_floor, clear_stale_lethal_damage_floor, commit_death_prevention, + final_damage, fold_incoming_damage, force_creature_death, is_lethal, kill_creature, + kill_player, lethal_floor_protects, set_lethal_damage_floor, Hit, HitSource, + CORPSE_DECAY_MICROS, DRAIN_SOUL_SPELL_ID, RESPAWN_MICROS, SOUL_SHARD_ENTRY, +}; + +#[cfg(test)] +pub(crate) use engage::COMBAT_DROP_MS; +pub(crate) use engage::{ + apply_start_attack, apply_start_ranged_attack, arm_creature_engagement, break_own_attacks, + disengage, enter_combat, is_engaged, is_hostile_target, may_harm, may_help, + melee_combatant_guids, refresh_leash, request_attack, stop_attack_for, stop_duel_combat, + validate_attack_target, +}; +pub use engage::{ + game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, + AttackStart, CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, +}; +pub(crate) use folds::{ + arm_react, aura_attack_power_bonus, clear_queued_strike, effective_agility, effective_crit_bp, + effective_move_speed, effective_strength, effective_swing_time, equipped_offhand_weapon_damage, + equipped_ranged_weapon, find_ammo, queued_strike_on_swing, react_window_active, + retaliate_on_hit, roll_money, roll_ranged_swing, roll_swing, roll_swing_with_range, + seal_holy_on_swing, swing_range_ctx, weapon_strike_damage, wear_weapon, ReactKind, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use folds::{ + effective_armor, effective_block_bp, effective_block_value, effective_dodge_bp, + effective_miss_bp, effective_parry_bp, +}; + +pub use swing::{ranged_impact, tick_melee}; +pub(crate) use tables::{ + agility_crit_bp, apply_offhand_penalty, armor_mitigation_pct, attack_outcome, block_chance_bp, + crit_bp_with_bonus, crushing_chance_bp, dodge_chance_bp, effective_stat, glancing_chance_bp, + is_rage_user, is_translating, melee_attack_power_for, miss_bp_with_bonus, miss_chance_bp, + move_speed_with_pct, parry_chance_bp, player_swing_range_ap, rage_from_damage, + ranged_sheet_values, regen_entity_health, regen_entity_health_in_combat, regen_entity_power, + resist_mitigation_pct, scale_swing_for_rank, swing_time_with_haste, weapon_swing_range_ap, + AttackTable, RegenWindow, AUTO_SHOT_PROJECTILE_SPEED, COMBAT_BACKSTOP_SQ, CREATURE_MELEE_MAX, + CREATURE_MELEE_MIN, CRIT_MULT, CRUSH_DAMAGE_PCT, GLANCING_DAMAGE_PCT, HIT_BLOCK, HIT_CRIT, + HIT_CRUSHING, HIT_DODGE, HIT_GLANCING, HIT_MISS, HIT_NORMAL, HIT_PARRY, LEASH_RADIUS_SQ, + MELEE_LEEWAY_WINDOW_MS, MELEE_RANGE_LEEWAY_CREATURE_SQ, MELEE_RANGE_LEEWAY_SQ, MELEE_RANGE_SQ, + MOVE_FLAG_FORWARD, MOVE_MASK_MOVING, PURSUIT_WINDOW_MS, RANGED_INITIAL_SHOT_MS, + RANGED_RANGE_SQ, WAND_PROJECTILE_SPEED, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use tables::{regen_health_in_combat, swing_profile}; + +pub use tables::DEFAULT_ATTACK_TIME_MS; diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 53c5e0625..a93ae4f2e 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -3,20 +3,17 @@ //! 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` re-exports -//! this module (`pub use swing::*`) so every `crate::combat::` path resolves regardless of which submodule -//! actually defines it. +//! 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; use crate::{ game_item_instance, game_item_template, game_spell, game_spell_cast_event, game_world_entity, - SpellCastEvent, SpellCastEventKind, WorldEntity, + SpellCastEvent, WorldEntity, }; -// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `apply_hit`, -// `enter_combat`, ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use -// folds::*`/`death::*`/`engage::*`) so every symbol resolves the same as before the split. +// Shared combat operations and formulas are named in the parent module. use super::*; /// Swing tick (scheduled; scheduler-only). Two passes: diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index f8d2d6132..0f5a41145 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -3,8 +3,7 @@ //! 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 -//! (`pub use tables::*`), so every `crate::combat::` path resolves regardless of which submodule -//! actually defines the symbol. +//! by name for its callers. use crate::WorldEntity; @@ -152,7 +151,7 @@ pub(crate) const HIT_BLOCK: u8 = 7; // a LANDED hit a shield reduced by a flat b /// 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 fn miss_chance_bp(skill_diff: u32) -> u32 { +pub(crate) fn miss_chance_bp(skill_diff: u32) -> u32 { let raw = if skill_diff > 10 { 700 + (skill_diff - 10) * 40 } else { @@ -164,7 +163,7 @@ pub fn miss_chance_bp(skill_diff: u32) -> u32 { /// 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 fn dodge_chance_bp(agility: u32, skill_diff: u32) -> u32 { +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) } @@ -172,7 +171,7 @@ pub fn dodge_chance_bp(agility: u32, skill_diff: u32) -> u32 { /// 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 fn parry_chance_bp(skill_diff: u32) -> u32 { +pub(crate) fn parry_chance_bp(skill_diff: u32) -> u32 { (PARRY_BP + skill_diff * PARRY_BP_PER_SKILL).min(MAX_PARRY_BP) } @@ -181,14 +180,14 @@ pub fn parry_chance_bp(skill_diff: u32) -> u32 { /// 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 fn block_chance_bp(skill_diff: u32) -> u32 { +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 fn glancing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { +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) } @@ -197,7 +196,7 @@ pub fn glancing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { /// 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 fn crushing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { +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 { return 0; @@ -207,7 +206,7 @@ pub fn crushing_chance_bp(attacker_level: u32, target_level: u32) -> u32 { /// 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 fn melee_attack_power(strength: u32, level: u32) -> u32 { +pub(crate) fn melee_attack_power(strength: u32, level: u32) -> u32 { (level * 3 + strength * 2).saturating_sub(20) } @@ -215,7 +214,7 @@ pub fn melee_attack_power(strength: u32, level: u32) -> u32 { const CLASS_HUNTER: u8 = 3; const CLASS_ROGUE: u8 = 4; -pub fn melee_attack_power_for(class: u8, strength: u32, agility: u32, level: u32) -> u32 { +pub(crate) fn melee_attack_power_for(class: u8, strength: u32, agility: u32, level: u32) -> u32 { match class { CLASS_ROGUE | CLASS_HUNTER => (level * 2 + strength + agility).saturating_sub(20), _ => melee_attack_power(strength, level), @@ -226,13 +225,13 @@ pub fn melee_attack_power_for(class: u8, strength: u32, agility: u32, level: u32 /// 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 fn ranged_attack_power(agility: u32, level: u32) -> u32 { +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 fn ranged_sheet_values( +pub(crate) fn ranged_sheet_values( agility: u32, level: u32, weapon: Option<(u32, u32, u32, u8)>, @@ -255,7 +254,7 @@ pub fn ranged_sheet_values( /// `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 fn player_swing_range_ap(ap: u32, attack_time_ms: u32) -> (u32, u32) { +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) } @@ -264,12 +263,17 @@ pub fn player_swing_range_ap(ap: u32, attack_time_ms: u32) -> (u32, u32) { /// 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 fn weapon_swing_range_ap(ap: u32, dmg_min: u32, dmg_max: u32, delay_ms: u32) -> (u32, u32) { +pub(crate) fn weapon_swing_range_ap( + ap: u32, + dmg_min: u32, + dmg_max: u32, + delay_ms: u32, +) -> (u32, u32) { let bonus = ap * delay_ms / (AP_PER_DPS * 1000); (dmg_min + bonus, dmg_max + bonus) } -pub fn apply_offhand_penalty(min: u32, max: u32) -> (u32, u32) { +pub(crate) fn apply_offhand_penalty(min: u32, max: u32) -> (u32, u32) { ((min / 2).max(1), (max / 2).max(1)) } @@ -286,7 +290,7 @@ pub(crate) fn effective_stat(base: i32, aura: i32, gear: i32) -> u32 { /// 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 fn crit_bp_with_bonus(bonus: i32) -> u32 { +pub(crate) fn crit_bp_with_bonus(bonus: i32) -> u32 { (CRIT_BP as i32 + bonus).clamp(0, 10_000) as u32 } @@ -296,7 +300,7 @@ pub fn crit_bp_with_bonus(bonus: i32) -> u32 { /// 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 fn agility_crit_bp(agility: u32, level: u32) -> u32 { +pub(crate) fn agility_crit_bp(agility: u32, level: u32) -> u32 { agility * 100 / (level + 4) } @@ -304,7 +308,7 @@ pub fn agility_crit_bp(agility: u32, level: u32) -> u32 { /// 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 fn miss_bp_with_bonus(miss: u32, bonus: i32) -> u32 { +pub(crate) fn miss_bp_with_bonus(miss: u32, bonus: i32) -> u32 { (miss as i32 - bonus).max(0) as u32 } @@ -312,7 +316,7 @@ pub fn miss_bp_with_bonus(miss: u32, bonus: i32) -> u32 { /// 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 fn swing_time_with_haste(base_ms: u32, haste_pct: i32) -> u32 { +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 } @@ -321,7 +325,7 @@ pub fn swing_time_with_haste(base_ms: u32, haste_pct: i32) -> u32 { /// 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] -pub fn move_speed_with_pct(base: f32, pct: i32) -> f32 { +pub(crate) fn move_speed_with_pct(base: f32, pct: i32) -> f32 { base * (100 + pct).max(10) as f32 / 100.0 } @@ -332,7 +336,7 @@ pub fn move_speed_with_pct(base: f32, pct: i32) -> f32 { /// 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 fn rank_damage_multiplier_permille(rank: u8) -> u32 { +pub(crate) fn rank_damage_multiplier_permille(rank: u8) -> u32 { match rank { 0 => 1000, // normal — identity (baseline) 1 => 1400, // elite @@ -346,7 +350,7 @@ pub fn rank_damage_multiplier_permille(rank: u8) -> u32 { /// 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 fn scale_swing_for_rank(min: u32, max: u32, rank: u8) -> (u32, u32) { +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)) @@ -357,7 +361,7 @@ pub fn scale_swing_for_rank(min: u32, max: u32, rank: u8) -> (u32, u32) { /// (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 fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { +pub(crate) fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { if armor == 0 { return 0; } @@ -370,7 +374,7 @@ pub fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { /// 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 fn resist_mitigation_pct(resistance: u32, caster_level: u32) -> u32 { +pub(crate) fn resist_mitigation_pct(resistance: u32, caster_level: u32) -> u32 { if resistance == 0 || caster_level == 0 { return 0; } @@ -384,6 +388,7 @@ pub fn resist_mitigation_pct(resistance: u32, caster_level: u32) -> u32 { /// 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, pub base_max: u32, @@ -392,7 +397,8 @@ pub struct SwingProfile { pub final_max: u32, } -pub fn swing_profile( +#[cfg(any(test, feature = "debug_reducers"))] +pub(crate) fn swing_profile( base_min: u32, base_max: u32, target_armor: u32, @@ -519,7 +525,8 @@ fn health_regen_per_tick(spirit: u32, level: u32) -> u32 { /// /// 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. -pub fn regen_power( +#[cfg(test)] +pub(crate) fn regen_power( power_type: u8, current: u32, max: u32, @@ -588,7 +595,8 @@ fn regen_power_with_flat( /// 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. -pub fn regen_health(current: u32, max: u32, spirit: u32, level: u32) -> u32 { +#[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) } @@ -688,7 +696,8 @@ pub(crate) fn regen_entity_health( /// 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. -pub fn regen_health_in_combat( +#[cfg(any(test, feature = "debug_reducers"))] +pub(crate) fn regen_health_in_combat( current: u32, max: u32, spirit: u32, diff --git a/module/src/combo.rs b/module/src/combo.rs index 508793251..6cf904b4f 100644 --- a/module/src/combo.rs +++ b/module/src/combo.rs @@ -23,12 +23,12 @@ pub struct ComboPoint { } /// One generator tick: bump, capped at `MAX_COMBO`. Pure → unit-tested. -pub fn capped_inc(points: u32) -> u32 { +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 fn finisher_damage(per_point: i32, combo: u32) -> i32 { +pub(crate) fn finisher_damage(per_point: i32, combo: u32) -> i32 { per_point.saturating_mul(combo as i32) } @@ -38,7 +38,7 @@ pub fn finisher_damage(per_point: i32, combo: u32) -> i32 { /// 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 fn finisher_duration_ms(base_ms: u32, combo: u32) -> u32 { +pub(crate) fn finisher_duration_ms(base_ms: u32, combo: u32) -> u32 { base_ms + combo.min(MAX_COMBO) * 3_000 } @@ -51,14 +51,14 @@ fn find_row(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) -> Option u32 { +pub(crate) fn combo_points(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) -> 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 fn add_combo_point(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) { +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) { r.points = capped_inc(r.points); @@ -74,7 +74,7 @@ pub fn add_combo_point(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) } /// Finisher consume: drop (owner, target) back to empty (deletes the row). -pub fn spend_combo(ctx: &ReducerContext, owner_guid: u64, target_guid: u64) { +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) { combos.id().delete(r.id); diff --git a/module/src/config.rs b/module/src/config.rs index 0ed1945bd..fd9d64b8c 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -244,7 +244,7 @@ pub fn restore_taxi_fixture(ctx: &ReducerContext) -> Result<(), String> { /// 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 fn validate_realm_address(raw: &str) -> Result { +pub(crate) fn validate_realm_address(raw: &str) -> Result { let address = raw.trim(); if address.is_empty() { return Err("realm address must not be blank".to_string()); diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 53a1c0276..4a8222285 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -19,7 +19,7 @@ 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 fn within_assist_radius(ax: f32, ay: f32, bx: f32, by: f32) -> bool { +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 } @@ -29,13 +29,13 @@ pub fn within_assist_radius(ax: f32, ay: f32, bx: f32, by: f32) -> bool { /// 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 fn hp_pct_below(health: u32, max_health: u32, pct: u32) -> bool { +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 fn should_flee(health: u32, max_health: u32) -> bool { +pub(crate) fn should_flee(health: u32, max_health: u32) -> bool { hp_pct_below(health, max_health, FLEE_HEALTH_PCT) } @@ -45,7 +45,7 @@ pub fn should_flee(health: u32, max_health: u32) -> bool { /// 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 fn flee_eligible(creature_type: u8) -> bool { +pub(crate) fn flee_eligible(creature_type: u8) -> bool { creature_type == crate::spell::HUMANOID_TYPE } @@ -56,7 +56,7 @@ 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 fn rout_window_open(now_ms: u32, rout_ends_ms: u32) -> bool { +pub(crate) fn rout_window_open(now_ms: u32, rout_ends_ms: u32) -> bool { rout_ends_ms != 0 && now_ms < rout_ends_ms } @@ -64,27 +64,27 @@ pub fn rout_window_open(now_ms: u32, rout_ends_ms: u32) -> bool { /// 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 fn may_start_rout(eligible: bool, rout_ends_ms: u32) -> bool { +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. -pub fn rout_close_ms(now_ms: u32) -> u32 { +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 fn pursuit_deadline_ms(now_ms: u32) -> u32 { +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 fn beyond_combat_backstop(dist_sq: f32) -> bool { +pub(crate) fn beyond_combat_backstop(dist_sq: f32) -> bool { dist_sq > crate::combat::COMBAT_BACKSTOP_SQ } @@ -92,7 +92,7 @@ pub fn beyond_combat_backstop(dist_sq: f32) -> bool { /// `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 fn should_evade(now_ms: u32, pursuit_ends_ms: u32, target_dist_sq: f32) -> bool { +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 @@ -104,7 +104,7 @@ pub fn should_evade(now_ms: u32, pursuit_ends_ms: u32, target_dist_sq: f32) -> b /// 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 fn wounded_slow_factor(health: u32, max_health: u32) -> f32 { +pub(crate) fn wounded_slow_factor(health: u32, max_health: u32) -> f32 { if max_health == 0 { return 1.0; } @@ -141,7 +141,7 @@ const GREY_LEVEL_DIFF: i32 = 8; /// 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] -pub fn finite_point(x: f32, y: f32, z: f32) -> bool { +pub(crate) fn finite_point(x: f32, y: f32, z: f32) -> bool { x.is_finite() && y.is_finite() && z.is_finite() } @@ -158,7 +158,7 @@ pub fn finite_point(x: f32, y: f32, z: f32) -> bool { /// 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 fn is_aggro_candidate(dead: bool, godmode: bool) -> bool { +pub(crate) fn is_aggro_candidate(dead: bool, godmode: bool) -> bool { !dead && !godmode } @@ -169,7 +169,11 @@ pub fn is_aggro_candidate(dead: bool, godmode: bool) -> bool { /// `[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 fn aggro_radius(creature_level: u32, player_level: u32, template_aggro_range: u32) -> f32 { +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) } @@ -207,7 +211,7 @@ pub fn aggro_radius(creature_level: u32, player_level: u32, template_aggro_range /// `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 const fn combat_active_radius(template_aggro_max: f32) -> f32 { +pub(crate) const fn combat_active_radius(template_aggro_max: f32) -> f32 { let ceiling = if template_aggro_max > MAX_AGGRO_RADIUS { template_aggro_max } else { @@ -252,7 +256,7 @@ const STEALTH_DETECT_MAX: f32 = 30.0; /// 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 fn stealth_detect_range(creature_level: u32, target_level: u32) -> f32 { +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) } @@ -262,7 +266,7 @@ pub fn stealth_detect_range(creature_level: u32, target_level: u32) -> f32 { /// 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 fn flee_step(cx: f32, cy: f32, tx: f32, ty: f32, dist: f32) -> (f32, f32) { +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 { @@ -292,7 +296,14 @@ const FEAR_JITTER: f32 = std::f32::consts::FRAC_PI_3; /// 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 fn feared_flee_step(cx: f32, cy: f32, sx: f32, sy: f32, dist: f32, rand: u32) -> (f32, f32) { +pub(crate) fn feared_flee_step( + cx: f32, + cy: f32, + sx: f32, + sy: f32, + dist: f32, + rand: u32, +) -> (f32, f32) { let (dx, dy) = (cx - sx, cy - sy); let base = if dx == 0.0 && dy == 0.0 { 0.0 @@ -337,7 +348,7 @@ pub(crate) const FLEE_LEG_YD: f32 = 28.0; /// 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 fn spline_t(now_us: u64, start_micros: u64, dur_ms: u32) -> f32 { +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 { return 1.0; @@ -352,7 +363,14 @@ pub fn spline_t(now_us: u64, start_micros: u64, dur_ms: u32) -> f32 { /// `(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 fn chase_step(cx: f32, cy: f32, tx: f32, ty: f32, max_step: f32, stop_dist: f32) -> (f32, f32) { +pub(crate) fn chase_step( + cx: f32, + cy: f32, + tx: f32, + ty: f32, + max_step: f32, + stop_dist: f32, +) -> (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. @@ -396,7 +414,13 @@ pub(crate) const WANDER_CHANCE_PCT: u32 = 35; /// 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 fn wander_point(home_x: f32, home_y: f32, rand_a: u32, rand_b: u32, radius: f32) -> (f32, f32) { +pub(crate) fn wander_point( + home_x: f32, + home_y: f32, + rand_a: u32, + rand_b: u32, + radius: f32, +) -> (f32, f32) { let angle = (rand_a as f32 / u32::MAX as f32) * std::f32::consts::TAU; let r = (rand_b as f32 / u32::MAX as f32) * radius; (home_x + r * angle.cos(), home_y + r * angle.sin()) @@ -436,7 +460,7 @@ pub(crate) const SENSE_PERIOD_MICROS: i64 = MOVE_TICK_MICROS * SENSE_EVERY_N_TIC /// 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 fn is_sense_tick_for_interval(now_micros: i64, interval_micros: i64) -> bool { +pub(crate) fn is_sense_tick_for_interval(now_micros: i64, interval_micros: i64) -> bool { let interval = if interval_micros > 0 { interval_micros } else { @@ -446,7 +470,7 @@ pub fn is_sense_tick_for_interval(now_micros: i64, interval_micros: i64) -> bool (now_micros / interval) % every_n == 0 } -pub fn tick_secs_for_interval(interval_micros: i64) -> f32 { +pub(crate) fn tick_secs_for_interval(interval_micros: i64) -> f32 { let interval = if interval_micros > 0 { interval_micros } else { @@ -459,11 +483,11 @@ pub fn tick_secs_for_interval(interval_micros: i64) -> f32 { /// 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 fn fear_step_for_tick(tick_secs: f32) -> f32 { +pub(crate) fn fear_step_for_tick(tick_secs: f32) -> f32 { FEAR_STEP * (tick_secs / MOVE_TICK_SECS) } -pub fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { +pub(crate) fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { let interval = if interval_micros > 0 { interval_micros } else { @@ -550,7 +574,7 @@ impl TickScope { /// 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 fn leg_in_flight(now_ms: u32, leg_ends_ms: u32) -> bool { +pub(crate) fn leg_in_flight(now_ms: u32, leg_ends_ms: u32) -> bool { now_ms < leg_ends_ms } @@ -573,7 +597,7 @@ pub(crate) fn leg_toward(from: (f32, f32), to: (f32, f32), speed: f32) -> Option /// 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 fn next_waypoint_idx(cur_idx: usize, len: usize) -> usize { +pub(crate) fn next_waypoint_idx(cur_idx: usize, len: usize) -> usize { if len == 0 { 0 } else { @@ -583,7 +607,7 @@ pub 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 fn nearest_waypoint_idx(cx: f32, cy: f32, pts: &[(f32, f32)]) -> usize { +pub(crate) fn nearest_waypoint_idx(cx: f32, cy: f32, pts: &[(f32, f32)]) -> usize { pts.iter() .enumerate() .min_by(|(_, a), (_, b)| { diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index e4a0ad881..b7c3815e6 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -45,7 +45,18 @@ mod eventai_quest_credit; #[path = "harness/eventai_tracer.rs"] mod eventai_tracer; -pub(crate) use eventai_legacy::*; +pub(crate) use eventai_legacy::{ + effective_rule_id, ActionKind, Diagnostic, DiagnosticKind, RepeatPolicy, Rule, RuleAction, + TargetPolicy, ACTION_CALL_FOR_HELP, ACTION_CAST, ACTION_EMOTE, ACTION_FLEE_FOR_ASSIST, + ACTION_SAY, ACTION_SET_PHASE, ACTION_SET_RANGED_POSTURE, ACTION_SUMMON, ACTION_YELL, + CAST_AURA_ABSENT, CAST_INTERRUPT_PREVIOUS, CAST_PLAYER_ONLY, CAST_TARGET_CASTING, + CAST_TRIGGERED, EVENT_CREATURE_HP, EVENT_FRIENDLY_HP_DEFICIT, EVENT_ON_AGGRO, EVENT_ON_DEATH, + EVENT_ON_SPAWN, EVENT_TARGET_RANGE, EVENT_TIMED_IN_COMBAT, REPEAT, REPEAT_ONCE, + SOURCE_FLAG_COMBAT_ACTION, SOURCE_FLAG_RANDOM_ACTION, TARGET_AI_SENDER, TARGET_BENEFICIARY, + TARGET_CURRENT, TARGET_EVENT, TARGET_FARTHEST_HOSTILE, TARGET_INVOKER, TARGET_NEAREST_AREA, + TARGET_NO_EXPLICIT, TARGET_RANDOM_THREAT, TARGET_RANDOM_THREAT_PLAYER, TARGET_SECOND_THREAT, + 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)] diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index 70bbf6a30..bee114cd2 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -45,25 +45,62 @@ pub(crate) use mobility::summon_lifetime_after; #[cfg(feature = "debug_reducers")] pub(crate) use mobility::verify_summon_expiry_boundaries_for_debug; pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state}; -pub use mobility::{ - expire_eventai_summon, fire_eventai_forced_despawn, CreatureAiForcedDespawn, - CreatureAiSummonExpiry, CreatureAiSummonOrigin, -}; +pub use mobility::{CreatureAiForcedDespawn, CreatureAiSummonExpiry, CreatureAiSummonOrigin}; + #[cfg(feature = "debug_reducers")] pub(crate) use mobility::{mark_summon_origin_for_debug, remove_guardians}; pub(crate) use mobility::{place_temporary_summon, summon_life_seq}; -pub(crate) use model::*; +pub(crate) use model::{ + normalized_revision, ActionResult, AiEventKind, AuraStackCondition, BroadcastLine, + CallForHelpInstruction, CastInstruction, CreatureEntryCondition, CreatureHealthCondition, + CreatureInstruction, CreatureReactState, CreatureState, CycleActor, DeathCondition, + DefinitionRevision, EmoteInstruction, EngagedFight, EvadeInstruction, EventAiDefinition, + EventAiRequest, EventAiRule, EventAiSubject, EventAiUnit, EventCondition, EventContext, + EventKind, EventPredicate, ExecutionPolicy, FacingCondition, FacingInstruction, + ForceDespawnInstruction, FriendlyAuraSelection, FriendlyCrowdControlCondition, + FriendlyHealthDeficitCondition, FriendlyMissingAuraCondition, IdleMovementIntent, + ImmobilizationInstruction, IncrementPhaseInstruction, InstructionSelection, InstructionTarget, + KillCondition, MissingTextTemplateNoEffect, MovementOperation, MovementSwitch, + NotifyEncounterInstruction, OutOfCombatSightCondition, PatrolIntent, PatrolPause, + PercentageCondition, PhaseSet, PostureAdmission, QuestTakenPredicate, RandomEmoteInstruction, + RandomMovementIntent, RandomPhaseInstruction, RandomPhaseRangeInstruction, RangedMode, + RangedModeInstruction, RangedPostureInstruction, ReceiveAiEventCondition, + ReceiveEmoteCondition, RecurrencePolicy, RemoveAuraInstruction, RemoveGuardiansInstruction, + RuleState, ScaleAllThreatInstruction, ScaleSelectedThreatInstruction, + SetLethalDamageFloorInstruction, SetPhaseInstruction, SetReactStateInstruction, + SetStandStateInstruction, SpawnAtActorInstruction, SpawnCondition, SpawnMapCondition, + SpawnZoneOrAreaCondition, SpeakInstruction, SpeechMode, SpellCastTarget, SpellCasterAdmission, + SpellCasterRole, SpellEventCondition, SpellStartMode, SpellTargetRole, StartRelayInstruction, + SummonInstruction, SummonLocation, TargetRangeCondition, ThrowAiEventInstruction, TimeWindow, + WalkingMode, +}; pub use movement::{CreatureAiMovementIntent, CreatureAiMovementPathWaypoint}; use presentation::import_verified_rajaxx_spawn_protection; pub(crate) use presentation::{ CreaturePresentationInstruction, CreaturePresentationMount, FlagOverride, }; -pub use relay::*; +pub use relay::{ + game_creature_ai_relay_arrival, game_creature_ai_relay_continuation, + game_creature_ai_relay_definition, game_creature_ai_relay_run, RelayArrival, RelayContinuation, + RelayDefinition, RelayFlagOperation, RelayForcedMovement, RelayMovement, RelayRun, +}; + +pub(crate) use relay::{ + cancel_relay_runs_for_instance, cancel_relay_runs_for_new_life, cancel_relay_runs_for_source, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use relay::{run_relay_arrival, start_imported_relay}; + #[cfg(feature = "debug_reducers")] pub(crate) use relay::{ replace_relay_catalogue_for_debug, replace_relays_for_debug, replace_single_relay_for_debug, }; -pub use tables::*; +pub use tables::{ + game_creature_ai_broadcast_text, game_creature_ai_definition, game_creature_ai_event, + game_creature_ai_rule_state, game_creature_ai_spell_metadata, game_creature_ai_state, + game_creature_ai_summon, CreatureAiBroadcastText, CreatureAiDefinition, CreatureAiEvent, + CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, CreatureAiSummon, +}; use spacetimedb::ReducerContext; @@ -71,3 +108,11 @@ use spacetimedb::ReducerContext; pub(crate) fn evaluate_context(ctx: &ReducerContext, request: EventAiRequest) -> u64 { engine::evaluate(&mut engine::DatabaseWorld::new(ctx), request) } + +pub use edges::{game_creature_ai_reset_deferral, game_creature_ai_returning_home}; + +pub use mobility::{ + game_creature_ai_forced_despawn, game_creature_ai_summon_expiry, game_creature_ai_summon_origin, +}; + +pub use movement::{game_creature_ai_movement_intent, game_creature_ai_movement_path_waypoint}; diff --git a/module/src/creatures/hunter_pet.rs b/module/src/creatures/hunter_pet.rs index 6bbdb3126..92fb62df1 100644 --- a/module/src/creatures/hunter_pet.rs +++ b/module/src/creatures/hunter_pet.rs @@ -10,11 +10,9 @@ use crate::{ const CLASS_HUNTER: u8 = 3; const INITIAL_HAPPINESS: u32 = 166_500; -pub const PET_KIND_SUMMONED: u8 = 0; -pub const PET_KIND_HUNTER: u8 = 1; /// The one durable Hunter-pet identity currently owned by a character. The table is module-private; -/// callers use the narrow owner/id lookups below. +/// callers read a pet through its owner lookup below. #[derive(Clone)] #[table( accessor = game_hunter_pet, @@ -104,7 +102,7 @@ fn classify_pet_kind(hunter_pet_id: Option) -> PetKind { } } -pub fn hunter_pet_for_owner(ctx: &ReducerContext, owner_guid: u64) -> Option { +pub(crate) fn hunter_pet_for_owner(ctx: &ReducerContext, owner_guid: u64) -> Option { ctx.db .game_hunter_pet() .by_owner() @@ -112,13 +110,13 @@ pub fn hunter_pet_for_owner(ctx: &ReducerContext, owner_guid: u64) -> Option Option { - ctx.db.game_hunter_pet().pet_id().find(pet_id) -} - /// 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 fn publish_hunter_pet_protocol(ctx: &ReducerContext, pet: &HunterPet, live_pet_guid: u64) { +pub(crate) fn publish_hunter_pet_protocol( + ctx: &ReducerContext, + pet: &HunterPet, + live_pet_guid: u64, +) { let row = HunterPetProtocol { pet_id: pet.pet_id, owner_guid: pet.owner_guid, @@ -141,7 +139,7 @@ pub fn publish_hunter_pet_protocol(ctx: &ReducerContext, pet: &HunterPet, live_p } } -pub fn live_pet_kind(ctx: &ReducerContext, pet_guid: u64) -> PetKind { +pub(crate) fn live_pet_kind(ctx: &ReducerContext, pet_guid: u64) -> PetKind { classify_pet_kind( ctx.db .game_live_pet_kind() diff --git a/module/src/creatures/mod.rs b/module/src/creatures/mod.rs index 730e30b5c..9705106b9 100644 --- a/module/src/creatures/mod.rs +++ b/module/src/creatures/mod.rs @@ -25,13 +25,109 @@ pub(crate) mod presentation; mod spawn; pub(crate) mod tick; -pub use ai::*; -pub use eventai::*; +pub(crate) use ai::{ + beyond_combat_backstop, chase_step, combat_active_radius, flee_eligible, + is_sense_tick_for_interval, pursuit_deadline_ms, rout_window_open, + sense_period_secs_for_interval, should_evade, should_flee, tick_secs_for_interval, TickScope, + GLOBAL_TICK_INSTANCE, MOVE_TICK_MICROS, +}; +#[cfg(test)] +pub(crate) use ai::{MOVE_TICK_SECS, SENSE_EVERY_N_TICKS}; +pub(crate) use eventai::{ + begin_death_dispatch, cancel_relay_runs_for_instance, cancel_relay_runs_for_new_life, + cancel_relay_runs_for_source, creature_ai_on_aggro, creature_ai_on_creature_death, + creature_ai_on_creature_spawn, creature_ai_on_unit_death, current_definition_revision, + drop_summon_expiry, eventai_on_evade, eventai_on_reached_home, eventai_on_receive_emote, + eventai_on_spell_hit, finish_death_dispatch, place_temporary_summon, react_state, + reset_creature_lifecycle, reset_engagement, seed_on_aggro_fixtures, CreatureInstruction, + CreatureReactState, SummonLocation, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use eventai::{run_relay_arrival, start_imported_relay}; + +pub use eventai::{ + game_creature_ai_broadcast_text, game_creature_ai_definition, game_creature_ai_event, + game_creature_ai_relay_arrival, game_creature_ai_relay_continuation, + game_creature_ai_relay_definition, game_creature_ai_relay_run, game_creature_ai_rule_state, + game_creature_ai_spell_metadata, game_creature_ai_state, game_creature_ai_summon, + CreatureAiBroadcastText, CreatureAiDefinition, CreatureAiEvent, CreatureAiForcedDespawn, + CreatureAiMovementIntent, CreatureAiMovementPathWaypoint, CreatureAiResetDeferral, + CreatureAiReturningHome, CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, + CreatureAiSummon, CreatureAiSummonExpiry, CreatureAiSummonOrigin, RelayArrival, + RelayContinuation, RelayDefinition, RelayRun, +}; + +#[cfg(feature = "debug_reducers")] +pub(crate) use eventai::{ + mark_summon_origin_for_debug, remove_guardians, replace_definition_for_debug, + replace_relay_catalogue_for_debug, replace_relays_for_debug, replace_single_relay_for_debug, + verify_summon_expiry_boundaries_for_debug, +}; pub(crate) use hostile_summon::{check_hostile_summon, summon_hostile}; -pub use hunter_pet::*; -pub use pet::*; -pub use pet_care::*; -pub use pet_progression::*; -pub use presentation::*; -pub use spawn::*; -pub use tick::*; +pub use hunter_pet::{ + game_hunter_pet, game_hunter_pet_protocol, game_live_pet_kind, HunterPet, HunterPetProtocol, + LivePetKind, PetKind, +}; + +pub(crate) use hunter_pet::{ + clear_live_pet_kind, hunter_pet_for_owner, live_pet_kind, mark_summoned_pet, + publish_hunter_pet_protocol, tame_creature, validate_tame, +}; + +pub(crate) use pet::{apply_pet_command, despawn_pets, pet_of, summon_pet}; +pub use pet::{game_pet_command, PetCommand}; + +pub use pet_care::{game_pet_care_schedule, PetCareSchedule, CARE_INTERVAL_MICROS}; + +pub(crate) use pet_care::{apply_item_target_spell, on_pet_death, scale_hunter_pet_swing}; + +pub use pet_progression::PetHappinessState; +pub(crate) use pet_progression::{ + award_hunter_pet_kill_progression, pet_xp_to_next_level, update_pet_loyalty_from_care, +}; +pub use presentation::{ + game_creature_presentation, game_creature_relay_temporary_faction, CreaturePresentation, + CreatureRelayTemporaryFaction, +}; + +pub use spawn::{ + game_creature_cast, game_creature_family, game_creature_gossip_menu_override, + game_creature_spawn, game_creature_spell, game_creature_template, game_creature_waypoint, + game_gossip_menu, game_gossip_menu_profile, game_gossip_menu_profile_option, + game_gossip_option, game_npc_text, game_npc_text_slot, CreatureCast, CreatureFamily, + CreatureGossipMenuOverride, CreatureSpawn, CreatureSpell, CreatureTemplate, CreatureWaypoint, + GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, NpcText, NpcTextSlot, + MOVEMENT_IDLE, MOVEMENT_RANDOM, +}; + +#[cfg(test)] +pub(crate) use spawn::rank_hp_multiplier_permille; +pub(crate) use spawn::{ + build_creature_entity, build_player_entity, clear_gossip_menu_overrides_for_instance, + creature_spawn_evidence, current_life_seq, insert_creature_entity, rolled_creature_stats, + scale_creature_damage_for_level, scale_health_for_rank, set_creature_gossip_menu, timer_never, +}; + +pub use tick::{ + game_creature_move_event, game_creature_move_schedule, game_creature_spline, CreatureMoveEvent, + CreatureMoveSchedule, CreatureSpline, +}; + +pub(crate) use tick::despawn_creature_entity; + +pub(crate) use hunter_pet::{ + sweep_delete_game_hunter_pet, sweep_delete_game_hunter_pet_protocol, + sweep_transfer_game_hunter_pet, sweep_transfer_game_hunter_pet_protocol, +}; + +pub(crate) use pet::{sweep_delete_game_pet_command, sweep_transfer_game_pet_command}; + +pub(crate) use spawn::cast_condition; + +pub use eventai::{game_creature_ai_reset_deferral, game_creature_ai_returning_home}; + +pub use eventai::{ + game_creature_ai_forced_despawn, game_creature_ai_summon_expiry, game_creature_ai_summon_origin, +}; + +pub use eventai::{game_creature_ai_movement_intent, game_creature_ai_movement_path_waypoint}; diff --git a/module/src/creatures/pet.rs b/module/src/creatures/pet.rs index 4bf60367e..5b6286479 100644 --- a/module/src/creatures/pet.rs +++ b/module/src/creatures/pet.rs @@ -210,7 +210,7 @@ pub fn debug_pet_command( /// 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 fn pet_of(ctx: &ReducerContext, owner_guid: u64) -> Option { +pub(crate) fn pet_of(ctx: &ReducerContext, owner_guid: u64) -> Option { if owner_guid == 0 { return None; } @@ -238,7 +238,7 @@ fn despawn_pet_entity(ctx: &ReducerContext, pet: WorldEntity) { /// so the pet vanishes for every client. Called on owner logout/disconnect (`remove_from_world`), owner /// death (`repop`), and at the top of `summon_pet` (a re-summon replaces the old pet). Idempotent — a /// no-op when the owner has no pet. [entity] -pub fn despawn_pets(ctx: &ReducerContext, owner_guid: u64) { +pub(crate) fn despawn_pets(ctx: &ReducerContext, owner_guid: u64) { if let Some(pet) = pet_of(ctx, owner_guid) { despawn_pet_entity(ctx, pet); } @@ -282,7 +282,7 @@ fn build_pet_entity(ctx: &ReducerContext, owner: &WorldEntity, entry: u32) -> Op /// 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 fn summon_pet(ctx: &ReducerContext, caster_guid: u64, entry: u32) { +pub(crate) fn summon_pet(ctx: &ReducerContext, caster_guid: u64, entry: u32) { if entry == 0 { spacetimedb::log::info!( "E_SUMMON_PET with no creature entry (p0=0) for {caster_guid} — skipped" diff --git a/module/src/creatures/pet_care.rs b/module/src/creatures/pet_care.rs index b25e65b58..da214415e 100644 --- a/module/src/creatures/pet_care.rs +++ b/module/src/creatures/pet_care.rs @@ -30,7 +30,7 @@ pub enum HappinessState { Happy, } -pub fn happiness_state(value: u32) -> HappinessState { +pub(crate) fn happiness_state(value: u32) -> HappinessState { if value < HAPPINESS_BAND { HappinessState::Unhappy } else if value < HAPPINESS_BAND * 2 { diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index 120c2cb23..88c3770ac 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -24,7 +24,7 @@ struct PetProgress { } /// Vanilla Hunter pets need one quarter of the player XP for the same level transition. -pub fn pet_xp_to_next_level(level: u32) -> u32 { +pub(crate) fn pet_xp_to_next_level(level: u32) -> u32 { crate::xp::xp_to_next_level(level) / 4 } @@ -101,7 +101,7 @@ fn kill_loyalty_bonus(pet_level: u32, loyalty_level: u8) -> u32 { /// 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 fn update_pet_loyalty_from_care( +pub(crate) fn update_pet_loyalty_from_care( ctx: &ReducerContext, pet_id: u64, state: PetHappinessState, diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 6cc1a10cd..d7c3f7775 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -174,7 +174,7 @@ pub struct CreatureGossipMenuOverride { } /// Install or remove one exact creature's runtime gossip menu. Packages and relays share this owner. -pub fn set_creature_gossip_menu( +pub(crate) fn set_creature_gossip_menu( ctx: &ReducerContext, creature_guid: u64, menu_id: Option, @@ -357,7 +357,6 @@ pub struct CreatureWaypoint { /// `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; -pub const MOVEMENT_WAYPOINT: u8 = 2; #[cfg(feature = "debug_reducers")] #[reducer] @@ -542,7 +541,7 @@ pub(crate) fn current_life_seq(ctx: &spacetimedb::ReducerContext, creature_guid: /// 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 fn rolled_creature_stats( +pub(crate) fn rolled_creature_stats( min_level: u32, max_level: u32, min_health: u32, @@ -572,7 +571,7 @@ pub fn rolled_creature_stats( /// 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 fn rank_hp_multiplier_permille(rank: u8) -> u32 { +pub(crate) fn rank_hp_multiplier_permille(rank: u8) -> u32 { match rank { 0 => 1000, // normal — identity (baseline) 1 => 1500, // elite @@ -586,7 +585,7 @@ pub fn rank_hp_multiplier_permille(rank: u8) -> u32 { /// 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 fn scale_health_for_rank(base_health: u32, rank: u8) -> u32 { +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. @@ -596,7 +595,7 @@ pub fn scale_health_for_rank(base_health: u32, rank: u8) -> 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 fn scale_creature_damage_for_level( +pub(crate) fn scale_creature_damage_for_level( min: u32, max: u32, template_level: u32, @@ -643,7 +642,7 @@ pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut enti crate::hooks::fire_on_creature_spawn(ctx, &payload); } -pub fn build_creature_entity( +pub(crate) fn build_creature_entity( spawn: &CreatureSpawn, tmpl: &CreatureTemplate, rand: u32, @@ -760,7 +759,7 @@ pub fn build_creature_entity( /// 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 fn build_player_entity( +pub(crate) fn build_player_entity( ctx: &ReducerContext, character: &Character, owner_identity: Identity, @@ -815,7 +814,7 @@ pub fn build_player_entity( display_id: display, native_display_id: display, unit_flags: constants::unit_flags::PLAYER_CONTROLLED, - base_attack_time_ms: crate::DEFAULT_ATTACK_TIME_MS, // unarmed 2.0s + base_attack_time_ms: crate::combat::DEFAULT_ATTACK_TIME_MS, // unarmed 2.0s dynamic_flags: 0, dead: false, player_bytes: packing::player_bytes( diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 7d0135454..386408a02 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -30,9 +30,7 @@ mod lifecycle; // 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}; -// Re-export so `crate::creatures::tick::despawn_creature_entity` (and, via `creatures::mod.rs`'s own -// `pub use tick::*`, `crate::creatures::despawn_creature_entity`) still resolves — `encounter.rs`/ -// `instance.rs` call it by that exact path. +// Encounter and instance removal share the same creature despawn operation. pub(crate) use lifecycle::despawn_creature_entity; // =========================================================================================== diff --git a/module/src/debug/auth.rs b/module/src/debug/auth.rs index 97d67fb85..79ab388ee 100644 --- a/module/src/debug/auth.rs +++ b/module/src/debug/auth.rs @@ -4,7 +4,7 @@ use crate::auth::{game_session, game_session_reaper_schedule}; /// Exercise the real recurring scheduler without waiting for the production interval. #[reducer] -pub fn debug_accelerate_session_reaper(ctx: &ReducerContext) -> Result<(), String> { +pub(crate) fn debug_accelerate_session_reaper(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let schedules = ctx.db.game_session_reaper_schedule(); let mut schedule = schedules diff --git a/module/src/debug/encounter.rs b/module/src/debug/encounter.rs index 1faed1b87..b7bd150f6 100644 --- a/module/src/debug/encounter.rs +++ b/module/src/debug/encounter.rs @@ -8,7 +8,11 @@ use spacetimedb::{log, reducer, ReducerContext}; /// 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 fn debug_encounter_watch_hp(ctx: &ReducerContext, entry: u32, pct: u8) -> Result<(), String> { +pub(crate) fn debug_encounter_watch_hp( + ctx: &ReducerContext, + entry: u32, + pct: u8, +) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::encounter::watch_hp_threshold(ctx, entry, pct) } diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index bbe8b6742..14f8a0b4e 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -4,11 +4,12 @@ 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; use crate::{ - build_creature_entity, game_config, game_creature_spawn, game_creature_template, - game_gameobject, game_instance, game_world_entity, CreatureSpawn, ServerConfig, + game_config, game_creature_spawn, game_creature_template, game_gameobject, game_instance, + game_world_entity, CreatureSpawn, ServerConfig, }; /// THE FIXTURE DUNGEON (headless, zero imports): resolve-or-create an instance of diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index cb228e664..0a0714537 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -36,25 +36,19 @@ mod package_config; mod readout; mod runtime_script; -pub use audit::*; -pub use auth::*; -pub use encounter::*; -pub use eventai::*; -pub use fingerprint::*; -pub use instance::*; -pub use mail::*; -pub use meeting_stone::*; -pub use package_config::*; -pub use readout::*; -pub use runtime_script::*; +pub use fingerprint::{game_catalogue_fingerprint, CatalogueFingerprint}; + +pub use instance::debug_set_vmap_enabled; + +pub use readout::{game_debug_readout, DebugReadout}; use lyracore_shared::{constants, spatial}; use spacetimedb::{log, reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::{ - build_creature_entity, game_aura, game_character, game_config, game_creature_move_schedule, - game_creature_spawn, game_creature_spline, game_creature_template, game_entity_motion, - game_gameobject, game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, + game_aura, game_character, game_config, game_creature_move_schedule, game_creature_spawn, + game_creature_spline, game_creature_template, game_entity_motion, game_gameobject, + game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, game_gameobject_unlocked, game_ground_area, game_item_instance, game_item_template, game_melee_attack, game_quest_template, game_spell, game_spell_effect, game_world_entity, CreatureMoveSchedule, CreatureSpawn, GroundArea, ItemInstance, ServerConfig, @@ -390,7 +384,7 @@ pub fn debug_spawn_player_entity(ctx: &ReducerContext, character_guid: u64) -> R // `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::build_player_entity(ctx, &character, character.owner_identity); + 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 @@ -526,7 +520,7 @@ pub fn debug_spawn_ground_area( )) .ok_or("duration overflow")?; // Perf catalog 1.20: the tick schedule is demand-armed (it disarms when the area table drains). - crate::arm_ground_area_tick(ctx); + crate::spell::arm_ground_area_tick(ctx); ctx.db.game_ground_area().insert(GroundArea { id: 0, spell_id, @@ -2074,3 +2068,5 @@ pub fn debug_set_power(ctx: &ReducerContext, guid: u64, power: u32) -> Result<() entities.guid().update(e); Ok(()) } + +use crate::creatures::build_creature_entity; diff --git a/module/src/debug/package_config.rs b/module/src/debug/package_config.rs index 9864e502a..ef08b1812 100644 --- a/module/src/debug/package_config.rs +++ b/module/src/debug/package_config.rs @@ -10,7 +10,7 @@ use spacetimedb::{reducer, ReducerContext}; /// 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 fn debug_seed_package_config( +pub(crate) fn debug_seed_package_config( ctx: &ReducerContext, package_name: String, key: String, diff --git a/module/src/debug/runtime_script.rs b/module/src/debug/runtime_script.rs index 22d72dc76..29530526c 100644 --- a/module/src/debug/runtime_script.rs +++ b/module/src/debug/runtime_script.rs @@ -15,7 +15,7 @@ use crate::runtime_script::{run_event, CoreEffects, EntityView, RuntimeScript, S /// 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 fn debug_run_runtime_script( +pub(crate) fn debug_run_runtime_script( ctx: &ReducerContext, script_name: String, event: String, diff --git a/module/src/encounter.rs b/module/src/encounter.rs index d81d15973..b66fecb39 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -80,7 +80,7 @@ impl EncounterBinding { Self::ZulGurubOhgan, ]; - pub fn map_id(self) -> u32 { + pub(crate) fn map_id(self) -> u32 { match self { Self::BlackfathomDeepsKelris => 48, Self::BlackrockDepthsTombOfSeven => 230, @@ -98,25 +98,6 @@ impl EncounterBinding { Self::ZulGurubOhgan => 309, } } - - pub fn encounter_id(self) -> u32 { - match self { - Self::BlackfathomDeepsKelris => 1, - Self::BlackrockDepthsTombOfSeven => 4, - Self::DireMaulAlzzin => 0, - Self::RazorfenKraulWardKeepers => 1, - Self::ShadowfangKeepRethilgore => 2, - Self::ShadowfangKeepFenrus => 3, - Self::ShadowfangKeepNandos => 4, - Self::SunkenTempleAvatar => 4, - Self::WailingCavernsAnacondra => 0, - Self::WailingCavernsCobrahn => 1, - Self::WailingCavernsPythas => 2, - Self::WailingCavernsSerpentis => 3, - Self::WailingCavernsMutanus => 5, - Self::ZulGurubOhgan => 5, - } - } } /// Named notification delivered to the encounter package that owns [`EncounterBinding`]. @@ -220,8 +201,11 @@ fn encounter_state_for_signal(signal: EncounterSignal) -> Option { // =========================================================================================== 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; +#[allow(dead_code, reason = "Packages select the encounter states they need")] 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 @@ -299,18 +283,18 @@ pub struct EncounterEquip { /// 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 fn hp_fired_key(entry: u32) -> u32 { +pub(crate) fn hp_fired_key(entry: u32) -> u32 { RESERVED_ENCOUNTER_ID_BIT | entry } /// Is `encounter_id` in the kernel-reserved namespace? Pure. -pub fn is_reserved_encounter_id(encounter_id: u32) -> bool { +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 fn hp_pct(health: u32, max_health: u32) -> u32 { +pub(crate) fn hp_pct(health: u32, max_health: u32) -> u32 { if max_health == 0 { return 100; } @@ -324,7 +308,7 @@ pub fn hp_pct(health: u32, max_health: u32) -> u32 { /// 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 fn crossed_thresholds(watches: &[u8], lowest_fired: u32, new_pct: u32) -> Vec { +pub(crate) fn crossed_thresholds(watches: &[u8], lowest_fired: u32, new_pct: u32) -> Vec { let mut fired: Vec = watches .iter() .copied() @@ -338,18 +322,15 @@ pub fn crossed_thresholds(watches: &[u8], lowest_fired: u32, new_pct: u32) -> Ve /// 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 fn wave_guid(entry: u32, next_low: u64) -> u64 { +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) } -pub fn entry_of_unit_guid(guid: u64) -> u32 { - ((guid >> 24) & 0x00FF_FFFF) as u32 -} - /// 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. -pub fn wave_offset(index: usize) -> (f32, f32) { +#[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)) } @@ -358,6 +339,7 @@ pub fn wave_offset(index: usize) -> (f32, f32) { /// 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; // =========================================================================================== @@ -400,13 +382,15 @@ fn upsert_state_row( } /// Current state of `(instance_id, encounter_id)` — `ENCOUNTER_NOT_STARTED` when no row exists. -pub fn get_encounter_state(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> u8 { +#[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) .map(|r| r.state) .unwrap_or(ENCOUNTER_NOT_STARTED) } -pub fn set_encounter_state( +#[allow(dead_code, reason = "retained Package API encounter choreography")] +pub(crate) fn set_encounter_state( ctx: &ReducerContext, instance_id: u64, encounter_id: u32, @@ -423,7 +407,8 @@ pub fn set_encounter_state( } /// `GetData`: the row's scratch u32 — 0 when no row exists. -pub fn get_encounter_data(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) -> u32 { +#[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) .map(|r| r.payload) .unwrap_or(0) @@ -431,7 +416,8 @@ pub fn get_encounter_data(ctx: &ReducerContext, instance_id: u64, encounter_id: /// `SetData`: set the scratch u32, preserving `state`. Upserts. Same reserved-namespace refusal /// as [`set_encounter_state`]. -pub fn set_encounter_data( +#[allow(dead_code, reason = "retained Package API operation")] +pub(crate) fn set_encounter_data( ctx: &ReducerContext, instance_id: u64, encounter_id: u32, @@ -456,7 +442,8 @@ pub fn set_encounter_data( /// 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). -pub fn watch_hp_threshold(ctx: &ReducerContext, entry: u32, pct: u8) -> Result<(), String> { +#[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) { return Err(format!("hp watch pct must be 1..=99, got {pct}")); } @@ -477,7 +464,8 @@ pub fn watch_hp_threshold(ctx: &ReducerContext, entry: u32, pct: u8) -> Result<( /// 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`]). -pub fn reset_hp_fired(ctx: &ReducerContext, instance_id: u64, entry: u32) { +#[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(); if let Some(row) = state_row(ctx, instance_id, hp_fired_key(entry)) { t.id().delete(row.id); @@ -550,7 +538,12 @@ fn probe_hp_thresholds(ctx: &ReducerContext, target_guid: u64) { } } -pub fn open_door(ctx: &ReducerContext, go_entry: u32, instance_id: u64) -> Result { +#[allow(dead_code, reason = "retained Package API encounter choreography")] +pub(crate) fn open_door( + ctx: &ReducerContext, + go_entry: u32, + instance_id: u64, +) -> Result { let tmpl = ctx .db .game_gameobject_template() @@ -594,7 +587,8 @@ pub fn open_door(ctx: &ReducerContext, go_entry: u32, instance_id: u64) -> Resul // A wave's full placement (entries, count, anchor position, instance, encounter id). #[allow(clippy::too_many_arguments)] -pub fn spawn_wave( +#[allow(dead_code, reason = "retained Package API encounter choreography")] +pub(crate) fn spawn_wave( ctx: &ReducerContext, instance_id: u64, encounter_id: u32, @@ -663,7 +657,7 @@ pub fn spawn_wave( /// 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 fn equip_swap( +pub(crate) fn equip_swap( ctx: &ReducerContext, creature_guid: u64, main_hand: u32, @@ -715,7 +709,7 @@ pub fn equip_swap( /// 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 fn move_to_point( +pub(crate) fn move_to_point( ctx: &ReducerContext, creature_guid: u64, x: f32, @@ -773,7 +767,8 @@ pub fn move_to_point( /// 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). -pub fn encounter_reset(ctx: &ReducerContext, instance_id: u64, encounter_id: u32) { +#[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); let tracked: Vec = ctx .db @@ -785,7 +780,11 @@ pub fn encounter_reset(ctx: &ReducerContext, instance_id: u64, encounter_id: u32 despawn_tracked(ctx, &tracked); } -pub fn encounter_reset_full( +#[allow( + dead_code, + reason = "Packages select the encounter operations they need" +)] +pub(crate) fn encounter_reset_full( ctx: &ReducerContext, instance_id: u64, encounter_id: u32, diff --git a/module/src/faction.rs b/module/src/faction.rs index 485377e50..0e1b2a0f9 100644 --- a/module/src/faction.rs +++ b/module/src/faction.rs @@ -97,20 +97,20 @@ 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 fn compute_hostile(a: &FactionTemplate, b: &FactionTemplate) -> bool { +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 fn compute_friendly(a: &FactionTemplate, b: &FactionTemplate) -> bool { +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 fn is_hostile(ctx: &ReducerContext, attacker_faction: u32, target_faction: u32) -> bool { +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)) { (Some(a), Some(b)) => compute_hostile(&a, &b), @@ -122,7 +122,11 @@ pub fn is_hostile(ctx: &ReducerContext, attacker_faction: u32, target_faction: u /// 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 fn is_friendly(ctx: &ReducerContext, attacker_faction: u32, target_faction: u32) -> bool { +pub(crate) fn is_friendly( + 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)) { (Some(a), Some(b)) => compute_friendly(&a, &b), diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 7e3845401..984b431c9 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -32,6 +32,8 @@ pub mod go_type { /// 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; @@ -358,14 +360,14 @@ pub const POOL_TAG: u64 = 1 << 47; /// 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 fn pool_point_guid(point_id: u64) -> u64 { +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 fn pool_point_id_of(guid: u64) -> Option { +pub(crate) fn pool_point_id_of(guid: u64) -> Option { if (guid >> 48) == 0xF110 && (guid & POOL_TAG) != 0 { Some(guid & 0x7FFF_FFFF_FFFF) } else { diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index eae36ca20..4f84bb28b 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -1,8 +1,5 @@ //! -//! `GraveyardLoc`/`GraveyardZone` stay `pub` tables and every accessor/type here is re-exported at -//! the crate root via `pub use graveyard::*;` in `lib.rs` (mirroring `world.rs`'s own `pub use`) — -//! every existing `crate::game_graveyard`/`crate::GraveyardLoc` path (seed.rs, debug.rs) is -//! byte-identical after the move. +//! Graveyard resolution and its imported tables. `tables.rs` exports the accessors and row types. use spacetimedb::{table, ReducerContext, Table}; diff --git a/module/src/group.rs b/module/src/group.rs index 53130ae14..67871fe48 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -671,7 +671,7 @@ pub struct CompanionTargetFacts { /// 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 fn companion_target_facts( +pub(crate) fn companion_target_facts( ctx: &ReducerContext, bot_guid: u64, target_guid: u64, @@ -859,7 +859,7 @@ fn known_party_partition( /// 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 fn party_facts( +pub(crate) fn party_facts( ctx: &ReducerContext, character_guid: u64, ) -> Result, PartyFactsUnavailable> { @@ -2078,7 +2078,7 @@ pub(crate) enum Plane { pub fn realm_group_op( ctx: &ReducerContext, op: u8, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, arg_a: u8, arg_b: u8, @@ -2252,7 +2252,7 @@ pub fn sync_group_mirror( loot_threshold: u8, master_looter_guid: u64, members: Vec, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, partitions: Vec, roster_revision: u64, group_kind: u8, diff --git a/module/src/guild/fee.rs b/module/src/guild/fee.rs index 1d16df6c4..10b26619f 100644 --- a/module/src/guild/fee.rs +++ b/module/src/guild/fee.rs @@ -773,7 +773,7 @@ impl FeeLedger for CtxLedger<'_> { pub fn gw_guild_fee_hold( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, request: GuildFeeRequest, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -791,7 +791,7 @@ pub fn gw_guild_fee_hold( pub fn realm_guild_fee_decide( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, terms: GuildFeeTerms, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -806,7 +806,7 @@ pub fn realm_guild_fee_decide( pub fn gw_guild_fee_finish( ctx: &ReducerContext, operation_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, accepted: bool, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/guild/membership.rs b/module/src/guild/membership.rs index 286d9c6b3..225074029 100644 --- a/module/src/guild/membership.rs +++ b/module/src/guild/membership.rs @@ -28,7 +28,7 @@ pub struct GuildInvite { /// `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 fn invite( +pub(crate) fn invite( ctx: &ReducerContext, actor_guid: u64, request: GuildInviteRequest, @@ -82,7 +82,7 @@ pub fn invite( /// `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 fn accept( +pub(crate) fn accept( ctx: &ReducerContext, actor_guid: u64, actor_account: u64, @@ -130,7 +130,7 @@ pub fn accept( /// `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 fn decline( +pub(crate) fn decline( ctx: &ReducerContext, actor_guid: u64, actor_name: &str, @@ -175,7 +175,7 @@ fn leave_outcome(is_leader: bool, member_count: usize) -> Result Result<(), GuildRefusal> { +pub(crate) fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let guild = ctx .db @@ -224,7 +224,11 @@ fn remove_gate(actor_rank: u32, target_rank: u32) -> Result<(), GuildRefusal> { } /// `Remove` (`cm:GuildHandler.cpp:136-190`): the actor expels `target_guid` from its own Guild. -pub fn remove(ctx: &ReducerContext, actor_guid: u64, target_guid: u64) -> Result<(), GuildRefusal> { +pub(crate) fn remove( + ctx: &ReducerContext, + actor_guid: u64, + target_guid: u64, +) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let actor_rights = super::rank_rights(ctx, actor.guild_id, actor.rank_id); if !has_right(actor_rights, rights::REMOVE) { @@ -268,7 +272,7 @@ fn promote_target_rank( /// `Promote` (`cm:GuildHandler.cpp:269-320`): `target_guid` moves one Guild Rank up (its rank id /// decreases by one). -pub fn promote( +pub(crate) fn promote( ctx: &ReducerContext, actor_guid: u64, target_guid: u64, @@ -319,7 +323,11 @@ fn demote_target_rank( /// `Demote` (`cm:GuildHandler.cpp:322-380`): `target_guid` moves one Guild Rank down (its rank id /// increases by one). -pub fn demote(ctx: &ReducerContext, actor_guid: u64, target_guid: u64) -> Result<(), GuildRefusal> { +pub(crate) fn demote( + ctx: &ReducerContext, + actor_guid: u64, + target_guid: u64, +) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let actor_rights = super::rank_rights(ctx, actor.guild_id, actor.rank_id); if !has_right(actor_rights, rights::DEMOTE) { @@ -353,7 +361,7 @@ pub fn demote(ctx: &ReducerContext, actor_guid: u64, target_guid: u64) -> Result /// `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 fn set_leader( +pub(crate) fn set_leader( ctx: &ReducerContext, actor_guid: u64, target_guid: u64, @@ -400,7 +408,7 @@ pub fn set_leader( } /// `Disband` (`cm:GuildHandler.cpp:424-435`): the Guild Leader dissolves its own Guild. -pub fn disband(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { +pub(crate) fn disband(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { let actor = member(ctx, actor_guid).ok_or(GuildRefusal::NotInGuild)?; let guild = ctx .db diff --git a/module/src/guild/mod.rs b/module/src/guild/mod.rs index 3bab3ca47..1c47bf461 100644 --- a/module/src/guild/mod.rs +++ b/module/src/guild/mod.rs @@ -213,7 +213,7 @@ pub enum GuildOp { #[reducer] pub fn realm_guild_op( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, op: GuildOp, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -312,7 +312,7 @@ fn sign_off(ctx: &ReducerContext, actor_guid: u64) -> Result<(), GuildRefusal> { /// 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 fn create_guild( +pub(crate) fn create_guild( ctx: &ReducerContext, leader_guid: u64, leader_name: &str, @@ -369,7 +369,7 @@ pub fn create_guild( /// 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 fn add_member( +pub(crate) fn add_member( ctx: &ReducerContext, guild_id: u32, character_guid: u64, @@ -395,7 +395,7 @@ pub fn add_member( } /// The membership row of `character_guid`, if it is in a Guild. -pub fn member(ctx: &ReducerContext, character_guid: u64) -> Option { +pub(crate) fn member(ctx: &ReducerContext, character_guid: u64) -> Option { ctx.db .game_guild_member() .character_guid() @@ -403,7 +403,7 @@ pub fn member(ctx: &ReducerContext, character_guid: u64) -> Option } /// The Guild Ranks of `guild_id`, highest first. -pub fn ranks(ctx: &ReducerContext, guild_id: u32) -> Vec { +pub(crate) fn ranks(ctx: &ReducerContext, guild_id: u32) -> Vec { let mut ranks: Vec = ctx .db .game_guild_rank() @@ -415,7 +415,7 @@ pub fn ranks(ctx: &ReducerContext, guild_id: u32) -> Vec { } /// The lowest Guild Rank's id: where a new member starts. -pub fn lowest_rank(ctx: &ReducerContext, guild_id: u32) -> u32 { +pub(crate) fn lowest_rank(ctx: &ReducerContext, guild_id: u32) -> u32 { ctx.db .game_guild_rank() .by_guild() @@ -426,7 +426,7 @@ pub fn lowest_rank(ctx: &ReducerContext, guild_id: u32) -> u32 { } /// The Rank Rights of one Guild Rank. An unknown rank reads 0 (`cm:Guild.cpp:660-666`). -pub fn rank_rights(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> u32 { +pub(crate) fn rank_rights(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> u32 { ctx.db .game_guild_rank() .by_guild() @@ -436,7 +436,7 @@ pub fn rank_rights(ctx: &ReducerContext, guild_id: u32, rank_id: u32) -> u32 { } /// Write one Guild Event. `recipient_guid == 0` broadcasts to the Guild's online members. -pub fn push_event( +pub(crate) fn push_event( ctx: &ReducerContext, guild_id: u32, recipient_guid: u64, diff --git a/module/src/guild/petition.rs b/module/src/guild/petition.rs index a7e7d7dad..1848da737 100644 --- a/module/src/guild/petition.rs +++ b/module/src/guild/petition.rs @@ -274,7 +274,7 @@ pub(crate) fn open( /// `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 fn sign( +pub(crate) fn sign( ctx: &ReducerContext, actor_guid: u64, actor_account: u64, @@ -338,7 +338,7 @@ pub fn sign( /// `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 fn offer( +pub(crate) fn offer( ctx: &ReducerContext, actor_guid: u64, request: GuildPetitionOffer, @@ -365,7 +365,7 @@ pub fn offer( } /// `DeclinePetition` (`cm:PetitionsHandler.cpp:424-450`): the owner learns who declined. -pub fn decline( +pub(crate) fn decline( ctx: &ReducerContext, actor_guid: u64, charter_item_guid: u64, @@ -385,7 +385,7 @@ pub fn decline( } /// `RenamePetition` (`cm:PetitionsHandler.cpp:284-319`). -pub fn rename( +pub(crate) fn rename( ctx: &ReducerContext, actor_guid: u64, request: GuildPetitionRename, @@ -409,7 +409,7 @@ pub fn rename( /// 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 fn turn_in( +pub(crate) fn turn_in( ctx: &ReducerContext, actor_guid: u64, actor_account: u64, @@ -459,7 +459,11 @@ pub fn turn_in( /// `ClosePetition`: the owner drops its Petition, as the Gateway does before a new Charter /// purchase when the old Charter is gone. -pub fn close(ctx: &ReducerContext, actor_guid: u64, petition_id: u32) -> Result<(), GuildRefusal> { +pub(crate) fn close( + ctx: &ReducerContext, + actor_guid: u64, + petition_id: u32, +) -> Result<(), GuildRefusal> { let petition = ctx .db .game_guild_petition() @@ -522,7 +526,7 @@ pub(super) fn withdraw(ctx: &ReducerContext, character_guid: u64) { #[reducer] pub fn gw_destroy_guild_charter( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, charter_item_guid: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/gw.rs b/module/src/gw.rs index 802d8666e..314188798 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -165,7 +165,7 @@ fn actor(ctx: &ReducerContext, actor_guid: u64) -> Result, x: f32, @@ -187,7 +187,7 @@ pub fn gw_movement_update( /// `gw_movement_update` argument list with the actor made explicit per entry. #[derive(spacetimedb::SpacetimeType)] pub struct GwMove { - pub actor: crate::SessionActor, + pub actor: crate::account_ownership::SessionActor, pub opcode: u16, pub movement_info: Vec, pub x: f32, @@ -261,7 +261,7 @@ pub fn gw_movement_batch(ctx: &ReducerContext, moves: Vec) -> Result<(), #[reducer] pub fn gw_attack( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -274,7 +274,7 @@ pub fn gw_attack( #[reducer] pub fn gw_cast_at( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, spell_id: u32, target_guid: u64, ) -> Result<(), String> { @@ -290,7 +290,7 @@ pub fn gw_cast_at( #[reducer] pub fn gw_cast_spell_at( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, spell_id: u32, target_guid: u64, x: f32, @@ -332,7 +332,7 @@ fn loot_operator_error(detail: String) -> String { #[reducer] pub fn gw_take_loot( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, corpse_guid: u64, loot_slot: u8, ) -> Result<(), String> { @@ -347,7 +347,7 @@ pub fn gw_take_loot( #[reducer] pub fn gw_open_creature_loot( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, corpse_guid: u64, ) -> Result<(), String> { require_operator(ctx).map_err(loot_operator_error)?; @@ -360,7 +360,7 @@ pub fn gw_open_creature_loot( #[reducer] pub fn gw_ranged_attack( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, spell_id: u32, ) -> Result<(), String> { @@ -374,7 +374,7 @@ pub fn gw_ranged_attack( #[reducer] pub fn gw_stop_attack( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -387,7 +387,7 @@ pub fn gw_stop_attack( #[reducer] pub fn gw_set_sheathed( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, state: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -400,7 +400,7 @@ pub fn gw_set_sheathed( #[reducer] pub fn gw_accept_quest( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, giver_guid: u64, quest_entry: u32, ) -> Result<(), String> { @@ -414,7 +414,7 @@ pub fn gw_accept_quest( #[reducer] pub fn gw_turn_in_quest( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, giver_guid: u64, quest_entry: u32, reward_index: u32, @@ -446,7 +446,7 @@ fn item_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result<(), String> { require_operator(ctx)?; @@ -460,7 +460,7 @@ pub fn gw_use_item( #[reducer] pub fn gw_cast_item_target( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, spell_id: u32, slot: u8, ) -> Result<(), String> { @@ -474,7 +474,7 @@ pub fn gw_cast_item_target( #[reducer] pub fn gw_loot_money( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx).map_err(loot_operator_error)?; @@ -487,7 +487,7 @@ pub fn gw_loot_money( #[reducer] pub fn gw_buy_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, vendor_guid: u64, item_entry: u32, count: u32, @@ -502,7 +502,7 @@ pub fn gw_buy_item( #[reducer] pub fn gw_sell_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, vendor_guid: u64, slot: u8, ) -> Result<(), String> { @@ -516,7 +516,7 @@ pub fn gw_sell_item( #[reducer] pub fn gw_equip_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, from_slot: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -529,7 +529,7 @@ pub fn gw_equip_item( #[reducer] pub fn gw_use_gameobject( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, go_guid: u64, ) -> Result<(), String> { require_operator(ctx).map_err(loot_operator_error)?; @@ -556,7 +556,7 @@ fn trainer_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result<(), String> { @@ -570,7 +570,7 @@ pub fn gw_trainer_buy( #[reducer] pub fn gw_respond_resurrect( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, accept: bool, ) -> Result<(), String> { require_operator(ctx)?; @@ -581,7 +581,10 @@ pub fn gw_respond_resurrect( /// [`crate::actor::repop`] behind the gateway gate — a dead actor releases to the graveyard. #[reducer] -pub fn gw_repop(ctx: &ReducerContext, request_actor: crate::SessionActor) -> Result<(), String> { +pub fn gw_repop( + ctx: &ReducerContext, + request_actor: crate::account_ownership::SessionActor, +) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; actor(ctx, actor_guid)?; @@ -592,7 +595,7 @@ pub fn gw_repop(ctx: &ReducerContext, request_actor: crate::SessionActor) -> Res #[reducer] pub fn gw_self_resurrect( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -604,7 +607,7 @@ pub fn gw_self_resurrect( #[reducer] pub fn gw_spirit_res( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -616,7 +619,7 @@ pub fn gw_spirit_res( #[reducer] pub fn gw_accept_group_invite( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -629,7 +632,7 @@ pub fn gw_accept_group_invite( #[reducer] pub fn gw_set_target( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -642,7 +645,7 @@ pub fn gw_set_target( #[reducer] pub fn gw_send_chat( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, chat_type: u8, language: u8, message: String, @@ -657,7 +660,7 @@ pub fn gw_send_chat( #[reducer] pub fn gw_send_emote( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, text_emote: u32, emote_anim: u32, target_guid: u64, @@ -684,7 +687,7 @@ pub fn gw_send_emote( pub fn gw_player_login( ctx: &ReducerContext, account_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; enter_world( @@ -702,7 +705,7 @@ pub fn gw_player_login( pub fn gw_player_world_port( ctx: &ReducerContext, account_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; enter_world( @@ -717,7 +720,7 @@ pub fn gw_player_world_port( fn enter_world( ctx: &ReducerContext, account_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, entry: crate::world::WorldEntry, ) -> Result<(), String> { let character_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -764,7 +767,7 @@ fn enter_world( #[reducer] pub fn gw_set_away( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, kind: u8, message: String, ) -> Result<(), String> { @@ -794,7 +797,7 @@ fn contact_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result<(), String> { @@ -808,7 +811,7 @@ pub fn gw_add_friend( #[reducer] pub fn gw_del_friend( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -821,7 +824,7 @@ pub fn gw_del_friend( #[reducer] pub fn gw_add_ignore( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -834,7 +837,7 @@ pub fn gw_add_ignore( #[reducer] pub fn gw_del_ignore( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -865,7 +868,7 @@ fn group_actor(ctx: &ReducerContext, actor_guid: u64) -> Result Result<(), String> { require_operator(ctx)?; @@ -878,7 +881,7 @@ pub fn gw_group_invite( #[reducer] pub fn gw_group_decline( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -891,7 +894,7 @@ pub fn gw_group_decline( #[reducer] pub fn gw_initiate_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -904,7 +907,7 @@ pub fn gw_initiate_trade( #[reducer] pub fn gw_begin_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -917,7 +920,7 @@ pub fn gw_begin_trade( #[reducer] pub fn gw_cancel_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -930,7 +933,7 @@ pub fn gw_cancel_trade( #[reducer] pub fn gw_set_trade_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, trade_slot: u8, inv_slot: u8, ) -> Result<(), String> { @@ -944,7 +947,7 @@ pub fn gw_set_trade_item( #[reducer] pub fn gw_clear_trade_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, trade_slot: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -957,7 +960,7 @@ pub fn gw_clear_trade_item( #[reducer] pub fn gw_set_trade_gold( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, copper: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -971,7 +974,7 @@ pub fn gw_set_trade_gold( #[reducer] pub fn gw_accept_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -983,7 +986,7 @@ pub fn gw_accept_trade( #[reducer] pub fn gw_unaccept_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -995,7 +998,7 @@ pub fn gw_unaccept_trade( #[reducer] pub fn gw_busy_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -1007,7 +1010,7 @@ pub fn gw_busy_trade( #[reducer] pub fn gw_ignore_trade( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -1019,7 +1022,7 @@ pub fn gw_ignore_trade( #[reducer] pub fn gw_duel_accept( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, flag_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1033,7 +1036,7 @@ pub fn gw_duel_accept( #[reducer] pub fn gw_duel_cancel( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, flag_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1047,7 +1050,7 @@ pub fn gw_duel_cancel( #[reducer] pub fn gw_group_leave( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -1059,7 +1062,7 @@ pub fn gw_group_leave( #[reducer] pub fn gw_group_uninvite( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1072,7 +1075,7 @@ pub fn gw_group_uninvite( #[reducer] pub fn gw_group_loot_method( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, loot_setting: u8, master_guid: u64, loot_threshold: u8, @@ -1087,7 +1090,7 @@ pub fn gw_group_loot_method( #[reducer] pub fn gw_push_quest_to_party( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, quest_entry: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1104,7 +1107,7 @@ pub fn gw_push_quest_to_party( #[reducer] pub fn gw_move_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, from_slot: u8, to_slot: u8, ) -> Result<(), String> { @@ -1119,7 +1122,7 @@ pub fn gw_move_item( #[reducer] pub fn gw_destroy_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, slot: u8, count: u32, ) -> Result<(), String> { @@ -1133,7 +1136,7 @@ pub fn gw_destroy_item( #[reducer] pub fn gw_split_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, from_slot: u8, to_slot: u8, count: u32, @@ -1149,7 +1152,7 @@ pub fn gw_split_item( #[reducer] pub fn gw_unequip_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, from_slot: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -1164,7 +1167,7 @@ pub fn gw_unequip_item( #[reducer] pub fn gw_auto_bank_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, slot: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -1177,7 +1180,7 @@ pub fn gw_auto_bank_item( #[reducer] pub fn gw_buyback_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, vendor_guid: u64, slot: u8, ) -> Result<(), String> { @@ -1191,7 +1194,7 @@ pub fn gw_buyback_item( #[reducer] pub fn gw_repair_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, npc_guid: u64, slot: u8, ) -> Result<(), String> { @@ -1206,7 +1209,7 @@ pub fn gw_repair_item( #[reducer] pub fn gw_buy_bank_slot( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, banker_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1223,7 +1226,7 @@ pub fn gw_buy_bank_slot( #[reducer] pub fn gw_disenchant( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, slot: u8, ) -> Result<(), String> { require_operator(ctx)?; @@ -1236,7 +1239,7 @@ pub fn gw_disenchant( #[reducer] pub fn gw_enchant_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_slot: u8, enchant_id: u32, ) -> Result<(), String> { @@ -1248,7 +1251,10 @@ pub fn gw_enchant_item( /// [`crate::professions::apply_fish`] with the fisher named by guid. #[reducer] -pub fn gw_fish(ctx: &ReducerContext, request_actor: crate::SessionActor) -> Result<(), String> { +pub fn gw_fish( + ctx: &ReducerContext, + request_actor: crate::account_ownership::SessionActor, +) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; actor(ctx, actor_guid)?; @@ -1259,7 +1265,7 @@ pub fn gw_fish(ctx: &ReducerContext, request_actor: crate::SessionActor) -> Resu #[reducer] pub fn gw_bind_home( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, innkeeper_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1278,7 +1284,7 @@ pub fn gw_bind_home( #[reducer] pub fn gw_skin( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, corpse_guid: u64, ) -> Result<(), String> { require_operator(ctx).map_err(loot_operator_error)?; @@ -1291,7 +1297,7 @@ pub fn gw_skin( #[reducer] pub fn gw_loot_roll( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, corpse_guid: u64, loot_slot: u32, vote: u8, @@ -1306,7 +1312,7 @@ pub fn gw_loot_roll( #[reducer] pub fn gw_loot_master_give( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, corpse_guid: u64, loot_slot: u8, target_guid: u64, @@ -1327,7 +1333,7 @@ pub fn gw_loot_master_give( #[reducer] pub fn gw_cast_spell( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, spell_id: u32, target_guid: u64, ) -> Result<(), String> { @@ -1341,7 +1347,7 @@ pub fn gw_cast_spell( #[reducer] pub fn gw_cancel_cast( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -1353,7 +1359,7 @@ pub fn gw_cancel_cast( #[reducer] pub fn gw_cancel_aura( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, spell_id: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1370,7 +1376,7 @@ pub fn gw_cancel_aura( #[reducer] pub fn gw_gossip_select( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, npc_guid: u64, option_id: u32, option_row_id: u32, @@ -1385,7 +1391,7 @@ pub fn gw_gossip_select( #[reducer] pub fn gw_inspect( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, target_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1399,7 +1405,7 @@ pub fn gw_inspect( #[reducer] pub fn gw_learn_talent( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, talent_id: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1411,7 +1417,7 @@ pub fn gw_learn_talent( #[reducer] pub fn gw_reset_talents( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, trainer_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1424,7 +1430,7 @@ pub fn gw_reset_talents( #[reducer] pub fn gw_pick_lock( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, go_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1437,7 +1443,7 @@ pub fn gw_pick_lock( #[reducer] pub fn gw_set_action_button( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, button: u8, action: u32, action_type: u8, @@ -1452,7 +1458,7 @@ pub fn gw_set_action_button( #[reducer] pub fn gw_set_faction_at_war( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, reputation_index: u32, at_war: bool, ) -> Result<(), String> { @@ -1466,7 +1472,7 @@ pub fn gw_set_faction_at_war( #[reducer] pub fn gw_set_watched_faction( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, reputation_index: i32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1479,7 +1485,7 @@ pub fn gw_set_watched_faction( #[reducer] pub fn gw_abandon_quest( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, quest_entry: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1494,7 +1500,7 @@ pub fn gw_abandon_quest( #[reducer] pub fn gw_reclaim_corpse( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, _corpse_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; @@ -1507,7 +1513,7 @@ pub fn gw_reclaim_corpse( #[reducer] pub fn gw_enter_areatrigger( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, trigger_id: u32, ) -> Result<(), String> { require_operator(ctx)?; @@ -1521,7 +1527,7 @@ pub fn gw_enter_areatrigger( #[reducer] pub fn gw_pet_command( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, data: u32, target_guid: u64, ) -> Result<(), String> { @@ -1537,7 +1543,7 @@ pub fn gw_pet_command( #[reducer] pub fn gw_client_command( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, cmd: String, payload: String, ) -> Result<(), String> { @@ -1553,7 +1559,7 @@ pub fn gw_client_command( #[reducer] pub fn gw_gm_command( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, alpha_test_tools: bool, text: String, ) -> Result<(), String> { @@ -1580,7 +1586,7 @@ mod tests { fn movement(actor_guid: u64, opcode: u16, x: f32, move_time_ms: u32, body: &[u8]) -> GwMove { GwMove { - actor: crate::SessionActor { + actor: crate::account_ownership::SessionActor { guid: actor_guid, ownership: None, }, diff --git a/module/src/helpers.rs b/module/src/helpers.rs index 643c1f759..6aedaaf33 100644 --- a/module/src/helpers.rs +++ b/module/src/helpers.rs @@ -9,7 +9,7 @@ use spacetimedb::{Identity, ReducerContext, Table}; /// deploy CLI). Defends `import_*` / account-provisioning from a DIRECT anonymous SpacetimeDB connection /// that bypasses the gateway — the one check that can't move to the gateway. Fail-closed: rejects until /// `claim_operator` has run. See [`crate::auth::claim_operator`]. -pub fn require_operator(ctx: &ReducerContext) -> Result<(), String> { +pub(crate) fn require_operator(ctx: &ReducerContext) -> Result<(), String> { match ctx.db.game_operator().id().find(0) { Some(op) if op.identity == ctx.sender() => Ok(()), Some(_) => Err("operator only".to_string()), @@ -42,7 +42,7 @@ pub fn require_operator(ctx: &ReducerContext) -> Result<(), String> { /// 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 fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option { +pub(crate) fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option { gate_by_guid( ctx, ctx.db.game_world_entity().by_owner().filter(&owner).next(), @@ -55,7 +55,7 @@ pub fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option Option { +pub(crate) fn acting_entity_by_guid(ctx: &ReducerContext, guid: u64) -> Option { gate_by_guid(ctx, ctx.db.game_world_entity().guid().find(guid), |e| { e.guid }) @@ -82,14 +82,14 @@ pub fn acting_entity_by_guid(ctx: &ReducerContext, guid: u64) -> Option Option { +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 fn character_by_name(ctx: &ReducerContext, name: &str) -> Option { +pub(crate) fn character_by_name(ctx: &ReducerContext, name: &str) -> Option { gate_by_guid( ctx, ctx.db @@ -268,7 +268,7 @@ pub(crate) fn event_recipient_identity(bound: Option) -> Identity { /// 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 fn live_entity(ctx: &ReducerContext, guid: u64) -> Result { +pub(crate) fn live_entity(ctx: &ReducerContext, guid: u64) -> Result { ctx.db .game_world_entity() .guid() @@ -287,7 +287,7 @@ pub fn live_entity(ctx: &ReducerContext, guid: u64) -> Result Result { +pub(crate) fn require_character(ctx: &ReducerContext, guid: u64) -> Result { character_by_guid(ctx, guid).ok_or_else(|| format!("no character {guid}")) } diff --git a/module/src/instance.rs b/module/src/instance.rs index e2c5cfae2..01e3be863 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -774,7 +774,7 @@ pub fn ensure_instance( instance_id: u64, map_id: u32, party_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor(ctx, request_actor)?; @@ -823,7 +823,7 @@ pub fn ensure_instance( pub fn evict_instance_population( ctx: &ReducerContext, instance_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor(ctx, request_actor)?; diff --git a/module/src/items/mod.rs b/module/src/items/mod.rs index 417d0399b..8794b7a75 100644 --- a/module/src/items/mod.rs +++ b/module/src/items/mod.rs @@ -33,11 +33,57 @@ pub(crate) fn refused(refusal: ItemRefusal) -> String { refusal.as_tag().to_string() } -pub(crate) use economy::*; // the apply_* cores are pub(crate) (no pub items to re-export) +#[cfg(feature = "debug_reducers")] +pub(crate) use economy::apply_repair_item; +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::*; -pub(crate) use inventory::*; // ditto -pub(crate) use ops::*; // ditto -pub use properties::*; -pub use rules::*; -pub use tables::*; +pub(crate) use guids::{allocate_item_guids, next_item_guid}; +pub(crate) use inventory::{ + apply_auto_bank_item, apply_equip_item, apply_equip_profile_upgrade, apply_item_destroy, + 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, + grant_letter_item, grant_starter_item, has_free_slot, item_count, item_stat_bonus, + remove_items, request_grant_item, request_profile_item, request_take_loot, + 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, + select_property, +}; +pub use properties::{ + game_item_enchantment, game_item_property_weight, game_item_random_property, ItemEnchantment, + ItemPropertyWeight, ItemRandomProperty, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use rules::binds_on_equip; +pub(crate) use rules::{binds_on_grant, merge_amount, EquipStat}; + +pub use rules::weapon_subclass; + +pub use tables::{ + game_character_buyback, game_item_instance, game_item_template, game_npc_vendor, BuybackEntry, + ItemInstance, ItemTemplate, NpcVendor, ALL_PLAYABLE_CLASS_MASK, ALL_PLAYABLE_RACE_MASK, +}; +pub(crate) use tables::{item_in_slot, item_is_broken}; + +pub(crate) use tables::{ + sweep_delete_game_character_buyback, sweep_delete_game_item_instance, + sweep_restamp_game_item_instance, sweep_transfer_game_character_buyback, + sweep_transfer_game_item_instance, +}; + +pub(crate) use rules::{bonding, equip_slot, invtype}; + +#[cfg(test)] +pub(crate) use rules::tests; diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 27217afa0..9954ae000 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -12,7 +12,7 @@ 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_corpse_loot; // the loot.rs accessor trait — re-exported at crate root (`pub use loot::*`) +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) diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index 2c92adcd7..a9ce269ee 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -43,7 +43,7 @@ pub(crate) fn meets_required_reputation( /// 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 fn sell_value(unit_price: u32, count: u32) -> u32 { +pub(crate) fn sell_value(unit_price: u32, count: u32) -> u32 { unit_price.saturating_mul(count) } @@ -51,14 +51,14 @@ pub fn sell_value(unit_price: u32, count: u32) -> u32 { /// `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 fn buy_cost(unit_price: u32, count: u32) -> u32 { +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. -pub fn repair_cost(max_durability: u32, durability: u32) -> u32 { +pub(crate) fn repair_cost(max_durability: u32, durability: u32) -> u32 { max_durability.saturating_sub(durability) } @@ -66,7 +66,7 @@ pub fn repair_cost(max_durability: u32, durability: u32) -> u32 { /// 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 fn merge_amount(src: u32, dst: u32, max_stack: u32) -> u32 { +pub(crate) fn merge_amount(src: u32, dst: u32, max_stack: u32) -> u32 { src.min(max_stack.saturating_sub(dst)) } @@ -76,7 +76,7 @@ pub fn merge_amount(src: u32, dst: u32, max_stack: u32) -> u32 { /// 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 fn can_equip_mainhand(class: u8, inventory_type: u8) -> bool { +pub(crate) fn can_equip_mainhand(class: u8, inventory_type: u8) -> bool { class == 2 && matches!(inventory_type, 13 | 17 | 21) } @@ -87,8 +87,11 @@ 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 + #[cfg(test)] pub const BIND_ON_USE: u8 = 3; + #[cfg(test)] pub const QUEST_ITEM: u8 = 4; + #[cfg(test)] pub const QUEST_ITEM2: u8 = 5; } @@ -96,11 +99,11 @@ pub mod bonding { /// `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 fn binds_on_grant(item_bonding: u8) -> bool { +pub(crate) fn binds_on_grant(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_PICKUP } -pub fn binds_on_equip(item_bonding: u8) -> bool { +pub(crate) fn binds_on_equip(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_EQUIP } @@ -133,6 +136,7 @@ pub mod equip_slot { } pub mod invtype { + #[cfg(test)] pub const NON_EQUIP: u8 = 0; pub const HEAD: u8 = 1; pub const NECK: u8 = 2; @@ -171,7 +175,7 @@ pub mod invtype { /// 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 fn inventory_type_to_slot(inventory_type: u8) -> Option { +pub(crate) fn inventory_type_to_slot(inventory_type: u8) -> Option { use equip_slot as e; use invtype as t; Some(match inventory_type { @@ -225,7 +229,12 @@ pub(crate) fn paired_equip_slot(slot: u8) -> Option { /// 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 fn can_equip_into(class: u8, inventory_type: u8, dest_slot: u8, can_dual_wield: bool) -> bool { +pub(crate) fn can_equip_into( + class: u8, + inventory_type: u8, + dest_slot: u8, + 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 { @@ -248,13 +257,15 @@ pub fn can_equip_into(class: u8, inventory_type: u8, dest_slot: u8, can_dual_wie resolved == canonical } -pub use lyracore_shared::item::{armor_subclass, weapon_subclass, Proficiency}; +#[cfg(test)] +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 fn meets_required_level(player_level: u32, required_level: u8) -> bool { +pub(crate) fn meets_required_level(player_level: u32, required_level: u8) -> bool { player_level >= required_level as u32 } diff --git a/module/src/lib.rs b/module/src/lib.rs index 87013770c..dd85ffbb6 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -8,10 +8,10 @@ //! the gameplay logic, `transfer` is the escrowed cross-database character-transport subsystem, //! and `seed` is the `init` lifecycle reducer that populates a fresh database. //! -//! Each module is re-exported below so submodules can reference any table uniformly via -//! `crate::{, }`. The `#[table(accessor = X)]` macro generates a trait -//! named `X` (inheriting the struct's `pub` visibility) that provides `ctx.db.X()`; the glob -//! re-exports carry those traits to the crate root. Scheduled tables are co-located with the +//! 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()` / @@ -338,8 +338,7 @@ mod loot; /// 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. Not re-exported below, for the same reason `import_meta` is not: only the importer and -/// `mail_reward` read these tables, and the Gateway reads none. +/// 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 @@ -364,31 +363,28 @@ 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; -pub mod nav; +mod nav; /// Operator reducers every build carries: the post-publish repair pass. mod operations; -/// Package-owned Accounts: the operation a Package creates its session-less Characters through, and -/// the record of which Package owns each Account. +/// 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. Not re-exported below, for the same reason `import_meta` is not: -/// nothing outside this module reads its table. +/// their own ensure/init path. mod package_config; -/// The debug-only Package API root for Package fixtures. Not re-exported below: Packages name it by -/// path, which is what the Package API lint checks. +/// Debug operations exported through `package::fixture`. #[cfg(feature = "debug_reducers")] -mod package_fixture; +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. Not re-exported below, for the same reason -/// `import_meta` is not: nothing outside this module reads its table. +/// 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; -/// The test-only Package API root for Package unit tests. Not re-exported below, for the same -/// reason as `package_fixture`. +/// Unit-test operations exported through `package::test`. #[cfg(test)] -mod package_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 @@ -407,8 +403,7 @@ mod rest; // default build. mod runtime_script; /// The `game_script` table and the Event Binding dispatch: which Runtime Scripts run for which -/// event. Not re-exported below, for the same reason `package_import` is not — nothing outside this -/// module reads its table. +/// event. mod script_binding; mod seed; mod sessionless; @@ -416,9 +411,10 @@ mod skill; mod skilldata; mod spell; mod stats; +mod tables; mod talent; mod taxi; -pub mod terrain; +mod terrain; #[cfg(test)] mod test_scan; mod threat; @@ -428,7 +424,7 @@ 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. -pub mod vmap; +mod vmap; // Architecture Tests for structural and publish invariants. #[cfg(test)] mod tripwires; @@ -438,67 +434,241 @@ mod weather; mod world; mod xp; -pub use account_ownership::*; -pub use action_bar::*; -pub use auction::*; -pub use auth::*; -pub use away::CharacterAway; // re-exported for the gateway schema-parity test -pub use breath::*; -pub use breath_relay::BreathRelayEvent; // gateway schema-parity relay -pub use bridge::*; -// Named, like `realm_chat`: re-exported for the gateway schema-parity test. -pub use channel::{ChatChannel, ChatChannelBan, ChatChannelMember, ChatChannelNoticeEvent}; -pub use character::*; -pub use chat::*; -pub use combat::*; -pub use config::*; -pub use corpse::*; -pub use creatures::*; +pub use tables::{ + game_account, game_alpha_test_tools_enrollment, game_guid_allocator, game_guid_range, + game_operator, game_session, game_session_reaper_schedule, Account, AlphaTestToolsEnrollment, + GuidAllocator, GuidRange, Operator, Session, SessionReaperSchedule, +}; +pub use tables::{ + game_account_character_owner, game_account_claim, game_account_fence, AccountCharacterOwner, + AccountClaim, AccountFence, +}; +pub use tables::{ + game_active_taxi_flight, game_character_taxi_node, game_taxi_flight_schedule, + game_taxi_passenger_spline, game_taxi_service_reply, ActiveTaxiFlight, CharacterTaxiNode, + TaxiFlightSchedule, TaxiPassengerSpline, TaxiServiceReply, +}; +pub use tables::{ + game_addon_message, game_party_command_dispatch_lane, game_party_command_intent, + game_party_command_issuer, game_party_command_receipt, AddonMessage, PartyCommandDispatchLane, + PartyCommandIntent, PartyCommandIssuer, PartyCommandReceipt, +}; +pub use tables::{ + game_area, game_area_trigger, game_char_base_info, game_config, game_race_info, game_realm, + game_start_item, game_start_position, game_taxi_node, game_taxi_path, game_taxi_path_node, + CharBaseInfo, GameArea, GameAreaTrigger, GameTaxiNode, GameTaxiPath, GameTaxiPathNode, + RaceInfo, Realm, ServerConfig, StartItem, StartPosition, +}; +pub use tables::{ + game_areatrigger_teleport, game_character_quest, game_character_quest_event_credit, + game_creature_quest, game_gameobject_quest, game_quest_cast_objective, + game_quest_event_requirement, game_quest_objective, game_quest_reward_choice, + game_quest_reward_item, game_quest_reward_spell, game_quest_template, game_quest_text, + AreatriggerTeleport, CharacterQuest, CharacterQuestEventCredit, CreatureQuest, GameObjectQuest, + QuestCastObjective, QuestEventRequirement, QuestObjective, QuestRewardChoice, QuestRewardItem, + QuestRewardSpell, QuestTemplate, QuestText, +}; +pub use tables::{ + game_auction, game_auction_bid_decision, game_auction_bid_hold, game_auction_expiry, + game_auction_hold, game_auction_house, game_auction_notice, game_auction_operation_receipt, + Auction, AuctionBidDecision, AuctionBidHold, AuctionExpiry, AuctionHold, + AuctionHouseDefinition, AuctionNotice, AuctionOperationReceipt, +}; +pub use tables::{ + game_aura, game_aura_schedule, game_createinfo_spell, game_dynamic_object, game_ground_area, + game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, game_player_spell, + game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, + game_spell_cast_event, game_spell_cd, game_spell_chain, game_spell_cooldown, game_spell_effect, + game_spell_impact_event, game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, + AuraSchedule, CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, + PendingSpellImpact, PlayerSpell, ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, + SpellCastEvent, SpellCd, SpellChain, SpellCooldown, SpellEffect, SpellImpactEvent, SpellLearn, + SpellProcEvent, SpellReagent, +}; +pub use tables::{ + game_bot_invite_intent, game_group, game_group_event, game_group_invite, game_group_member, + game_group_member_partition, game_group_roster_revision, game_group_target_icon, + BotInviteIntent, Group, GroupEvent, GroupInvite, GroupMember, GroupMemberPartition, + GroupRosterRevision, GroupTargetIcon, PartyPartitionState, +}; +pub use tables::{ + game_bot_transfer_intent, game_transfer_in, game_transfer_out, game_transfer_reaper_schedule, + BotTransferIntent, TransferIn, TransferOut, TransferReaperSchedule, +}; +pub use tables::{game_breath_relay_event, BreathRelayEvent}; +pub use tables::{game_breath_schedule, game_breath_state, BreathSchedule, BreathState}; #[cfg(feature = "debug_reducers")] -pub use debug::*; -pub use duel::*; -pub use encounter::*; -pub use exploration::CharacterExplored; // re-exported for the gateway schema-parity test -pub use faction::*; -pub use gameobject::*; -pub use gc::*; -pub use gm::*; -pub use graveyard::*; -pub use group::*; -pub use gw::*; -pub use instance::*; -pub use items::*; -pub use load::*; -pub use loot::*; -pub use mail::Mail; // re-exported for the gateway schema-parity test -pub use mail_escrow::MailEscrow; // re-exported for the gateway schema-parity test -pub use mail_text::ItemText; // re-exported for the gateway schema-parity test -pub use mail_timer::{MailArrival, MailTimer}; // re-exported for the gateway schema-parity test -pub use meeting_stone::{MeetingStone, MeetingStoneSeeker}; // gateway schema-parity test -pub use motion::*; -pub use quest::*; -// Named, not globbed: the `realm_chat` reducer's generated type would shadow the module name. -pub use realm_chat::RealmChatEvent; // re-exported for the gateway schema-parity test -pub use realm_core::*; -pub use region::*; -pub use reputation::*; -pub use rest::RestStateEvent; // re-exported for the gateway schema-parity test (4c) -pub use sessionless::*; -pub use skill::*; -pub use skilldata::*; -pub use spell::stacking::SpellGroupRule; // Keeps this generated table in schema-parity coverage. -pub use spell::*; -pub use stats::*; -pub use talent::*; -pub use taxi::*; -pub use threat::*; -pub use trade::*; -pub use trainer::*; -// Re-exported for the gateway schema-parity test. -pub use transfer::{BotTransferIntent, TransferIn, TransferOut}; -pub use weather::*; -pub use world::*; -pub use xp::*; +pub use tables::{ + game_catalogue_fingerprint, game_debug_readout, CatalogueFingerprint, DebugReadout, +}; +pub use tables::{ + game_channel_event, game_channel_member, game_character_contact, game_chat_event, + game_emote_event, game_roll_event, game_system_message_event, game_whisper_event, ChannelEvent, + ChannelMember, ChatEvent, ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, + WhisperEvent, +}; +pub use tables::{game_character, Character}; +pub use tables::{game_character_away, CharacterAway}; +pub use tables::{ + game_character_buyback, game_item_enchantment, game_item_instance, game_item_property_weight, + game_item_random_property, game_item_template, game_npc_vendor, BuybackEntry, ItemEnchantment, + ItemInstance, ItemPropertyWeight, ItemRandomProperty, ItemTemplate, NpcVendor, +}; +pub use tables::{game_character_explored, CharacterExplored}; +pub use tables::{ + game_character_shard, game_guid_range_registry, CharacterShard, GuidRangeAssignment, +}; +pub use tables::{ + game_character_talent, game_talent, game_talent_tab, CharacterTalent, Talent, TalentTab, +}; +pub use tables::{ + game_chat_channel, game_chat_channel_ban, game_chat_channel_member, + game_chat_channel_notice_event, ChatChannel, ChatChannelBan, ChatChannelMember, + ChatChannelNoticeEvent, +}; +pub use tables::{game_class_level_stats, game_level_stats, ClassLevelStats, LevelStats}; +pub use tables::{ + game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, + CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, +}; +pub use tables::{game_combo_point, ComboPoint}; +pub use tables::{game_corpse, Corpse}; +pub use tables::{ + game_corpse_loot, game_corpse_loot_eligible, game_creature_loot, game_creature_loot_tag_group, + game_creature_quest_tap, game_creature_quest_tap_member, game_fishing_loot, + game_gameobject_loot, game_loot_roll, game_loot_roll_promotion_receipt, game_loot_roll_vote, + game_pickpocket_loot, game_skinning_loot, CorpseLoot, CorpseLootEligible, CreatureLoot, + CreatureLootTagGroup, CreatureQuestTap, CreatureQuestTapMember, GameFishingLoot, + GameObjectLoot, GamePickpocketLoot, GameSkinningLoot, LootRoll, LootRollPromotionReceipt, + LootRollVote, +}; +pub use tables::{game_createinfo_action, game_player_action, CreateinfoAction, PlayerAction}; +pub use tables::{ + game_creature_ai_broadcast_text, game_creature_ai_definition, game_creature_ai_event, + game_creature_ai_forced_despawn, game_creature_ai_movement_intent, + game_creature_ai_movement_path_waypoint, game_creature_ai_relay_arrival, + game_creature_ai_relay_continuation, game_creature_ai_relay_definition, + game_creature_ai_relay_run, game_creature_ai_reset_deferral, game_creature_ai_returning_home, + game_creature_ai_rule_state, game_creature_ai_spell_metadata, game_creature_ai_state, + game_creature_ai_summon, game_creature_ai_summon_expiry, game_creature_ai_summon_origin, + game_creature_cast, game_creature_family, game_creature_gossip_menu_override, + game_creature_move_event, game_creature_move_schedule, game_creature_presentation, + game_creature_relay_temporary_faction, game_creature_spawn, game_creature_spell, + game_creature_spline, game_creature_template, game_creature_waypoint, game_gossip_menu, + game_gossip_menu_profile, game_gossip_menu_profile_option, game_gossip_option, game_hunter_pet, + game_hunter_pet_protocol, game_live_pet_kind, game_npc_text, game_npc_text_slot, + game_pet_care_schedule, game_pet_command, CreatureAiBroadcastText, CreatureAiDefinition, + CreatureAiEvent, CreatureAiForcedDespawn, CreatureAiMovementIntent, + CreatureAiMovementPathWaypoint, CreatureAiResetDeferral, CreatureAiReturningHome, + CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, CreatureAiSummon, + CreatureAiSummonExpiry, CreatureAiSummonOrigin, CreatureCast, CreatureFamily, + CreatureGossipMenuOverride, CreatureMoveEvent, CreatureMoveSchedule, CreaturePresentation, + CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpell, CreatureSpline, CreatureTemplate, + CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, + HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, PetCommand, + RelayArrival, RelayContinuation, RelayDefinition, RelayRun, +}; +pub use tables::{game_creature_dead_callback_cast_admission, CreatureDeadCallbackCastAdmission}; +pub use tables::{game_creature_distraction, CreatureDistraction}; +pub use tables::{game_creature_lethal_damage_floor, CreatureLethalDamageFloor}; +pub use tables::{ + game_dr_state, game_spell_group, game_spell_group_rule, DrState, SpellGroup, SpellGroupRule, +}; +pub use tables::{game_duel, game_duel_event, game_duel_schedule, Duel, DuelEvent, DuelSchedule}; +pub use tables::{ + game_encounter_equip, game_encounter_hp_watch, game_encounter_spawn, game_encounter_state, + EncounterEquip, EncounterHpWatch, EncounterSpawn, EncounterState, +}; +pub use tables::{ + game_entity_motion, game_movement_violation, game_teleport_event, game_world_entity, + game_world_state, game_world_state_name, EntityMotion, MovementViolation, TeleportEvent, + WorldEntity, WorldState, WorldStateName, +}; +pub use tables::{ + game_entity_motion_pending, game_motion_publish_schedule, MotionPublishSchedule, PendingMotion, +}; +pub use tables::{game_event_reaper_schedule, EventReaperSchedule}; +pub use tables::{game_faction, game_faction_template, Faction, FactionTemplate}; +pub use tables::{ + game_gameobject, game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, + game_gameobject_trap, game_gameobject_trap_cooldown, game_gameobject_unlocked, game_lock, + GameLock, GameObject, GameObjectPool, GameObjectPoolMember, GameObjectTemplate, GameObjectTrap, + GameObjectTrapCooldown, GameObjectUnlocked, +}; +pub use tables::{ + game_gateway_lease, game_gateway_lease_reaper_schedule, game_gateway_session, GatewayLease, + GatewayLeaseReaperSchedule, GatewaySession, +}; +pub use tables::{game_go_collider, GoCollider}; +pub use tables::{game_go_model, GoModel}; +pub use tables::{game_graveyard, game_graveyard_zone, GraveyardLoc, GraveyardZone}; +pub use tables::{ + game_guild, game_guild_event, game_guild_member, game_guild_rank, Guild, GuildEvent, + GuildMember, GuildRank, +}; +pub use tables::{game_guild_fee_decision, game_guild_fee_hold, GuildFeeDecision, GuildFeeHold}; +pub use tables::{game_guild_invite, GuildInvite}; +pub use tables::{ + game_guild_petition, game_guild_petition_signature, GuildPetition, GuildPetitionSignature, +}; +pub use tables::{game_import_meta, ImportMeta}; +pub use tables::{ + game_instance, game_instance_binding, game_instance_reaper_schedule, game_instance_removal, + GameInstance, GameInstanceBinding, InstanceReaperSchedule, InstanceRemoval, +}; +pub use tables::{game_item_text, ItemText}; +pub use tables::{game_levelup_event, game_xp_event, LevelupEvent, XpEvent}; +pub use tables::{game_mail, Mail}; +pub use tables::{game_mail_arrival, game_mail_timer, MailArrival, MailTimer}; +pub use tables::{ + game_mail_delivery, game_mail_escrow, game_mail_escrow_reaper_schedule, MailDelivery, + MailEscrow, MailEscrowReaperSchedule, +}; +pub use tables::{ + game_mail_loot, game_mail_template, game_quest_reward_mail, MailLoot, MailTemplate, + QuestRewardMail, +}; +pub use tables::{game_map_region, game_region_assignment, MapRegion, RegionAssignment}; +pub use tables::{ + game_meeting_stone, game_meeting_stone_party, game_meeting_stone_reminder_schedule, + game_meeting_stone_seeker, MeetingStone, MeetingStoneParty, MeetingStoneReminderSchedule, + MeetingStoneSeeker, +}; +pub use tables::{game_nav_chunk, game_navigation_revision, NavChunk, NavigationRevision}; +pub use tables::{game_package_account, PackageAccount}; +pub use tables::{game_package_config, PackageConfig}; +pub use tables::{game_package_import, PackageImport}; +pub use tables::{game_package_teardown, PackageTeardown}; +pub use tables::{game_player_reputation, PlayerReputation}; +pub use tables::{game_player_skill, PlayerSkill}; +pub use tables::{game_realm_chat_event, RealmChatEvent}; +pub use tables::{ + game_region_load, game_shard_load, game_shard_load_total, RegionLoad, ShardLoad, ShardLoadTotal, +}; +pub use tables::{game_rest_state_event, RestStateEvent}; +pub use tables::{game_script, Script}; +pub use tables::{game_sessionless_action_consent, SessionlessActionConsent}; +pub use tables::{ + game_skill_ability, game_skill_availability, game_skill_line, SkillAbility, SkillAvailability, + SkillLine, +}; +pub use tables::{game_taunt_lock, game_threat, TauntLock, ThreatEntry}; +pub use tables::{game_terrain_chunk, TerrainChunk}; +pub use tables::{ + game_trade_event, game_trade_session, game_trade_slot, TradeEvent, TradeSession, TradeSlot, +}; +pub use tables::{game_trainer_spell, TrainerSpell}; +pub use tables::{ + game_vmap_chunk, game_vmap_generation, game_vmap_generation_chunk, + game_vmap_generation_receipt, game_vmap_indoor_cell, game_vmap_nav_coverage, + game_vmap_nav_coverage_manifest, VmapChunk, VmapGeneration, VmapGenerationChunk, + VmapGenerationReceipt, VmapIndoorCell, VmapNavCoverage, VmapNavCoverageManifest, +}; +pub use tables::{ + game_weather, game_weather_schedule, game_zone_weather, WeatherSchedule, ZoneWeather, + ZoneWeatherChance, +}; +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). diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 130c0c9c5..9e9fe7084 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -26,9 +26,25 @@ use lyracore_shared::loot::LootRefusal; use lyracore_shared::loot_roll::event_kind as roll_event_kind; // apply_loot_money's MONEY_SHARE push mod rolls; -pub use rolls::*; +pub use rolls::{ + game_corpse_loot_eligible, game_loot_roll, game_loot_roll_promotion_receipt, + game_loot_roll_vote, CorpseLootEligible, LootRoll, LootRollPromotionReceipt, LootRollVote, +}; + +pub(crate) use rolls::{ + apply_group_loot_rules, apply_master_give, cast_vote_on, force_resolve_rolls_for_disband, + sweep_loot_rolls, +}; + pub(crate) mod tag; -pub use tag::*; +pub(crate) use tag::{ + corpse_access, corpse_access_gate, corpse_eligible_for_access, corpse_eligible_recipients, + death_entitlement, +}; +pub use tag::{ + game_creature_loot_tag_group, game_creature_quest_tap, game_creature_quest_tap_member, + CreatureLootTagGroup, CreatureQuestTap, CreatureQuestTapMember, +}; // The profession reducers (skinning/fishing/enchanting) live in the sibling top-level // `crate::professions` module — re-exported here so every existing @@ -125,7 +141,7 @@ pub struct CreatureLoot { /// 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 fn loot_drops(chance_bp: u32, roll_0_9999: u32) -> bool { +pub(crate) fn loot_drops(chance_bp: u32, roll_0_9999: u32) -> bool { roll_0_9999 < chance_bp } @@ -133,7 +149,7 @@ pub fn loot_drops(chance_bp: u32, roll_0_9999: u32) -> bool { /// 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 fn group_pick(member_bp: &[u32], roll_0_9999: u32) -> Option { +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() { cum = cum.saturating_add(bp); @@ -150,7 +166,7 @@ pub fn group_pick(member_bp: &[u32], roll_0_9999: u32) -> Option { /// 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 fn rank_money_multiplier_permille(rank: u8) -> u32 { +pub(crate) fn rank_money_multiplier_permille(rank: u8) -> u32 { match rank { 0 => 1000, // normal — identity (baseline) 1 => 1500, // elite @@ -165,7 +181,7 @@ pub fn rank_money_multiplier_permille(rank: u8) -> u32 { /// 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 fn scale_money_for_rank(money: u32, rank: u8) -> u32 { +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 } diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 898b44a67..f5692f372 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -748,7 +748,7 @@ pub fn realm_loot_op( corpse_guid: u64, slot: u8, item_entry: u32, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, vote: u8, deadline_micros: i64, recipients: Vec, @@ -854,7 +854,7 @@ pub fn settle_loot_roll( corpse_guid: u64, slot: u8, winner_guid: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor(ctx, request_actor)?; @@ -870,7 +870,7 @@ pub fn settle_loot_roll( pub fn clear_promoted_loot_roll( ctx: &ReducerContext, roll_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor(ctx, request_actor)?; diff --git a/module/src/mail.rs b/module/src/mail.rs index 5d37f5fac..4e22faad9 100644 --- a/module/src/mail.rs +++ b/module/src/mail.rs @@ -647,7 +647,7 @@ pub(crate) fn returned(row: Mail, arrives: Timestamp) -> Mail { #[reducer] pub fn realm_mail_mark_read( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -660,7 +660,7 @@ pub fn realm_mail_mark_read( #[allow(clippy::too_many_arguments)] // a reducer's arguments are the wire pub fn realm_mail_send( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, recipient_guid: u64, subject: String, body: String, @@ -686,7 +686,7 @@ pub fn realm_mail_send( #[reducer] pub fn realm_mail_take_item( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -696,7 +696,7 @@ pub fn realm_mail_take_item( #[reducer] pub fn realm_mail_take_money( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -706,7 +706,7 @@ pub fn realm_mail_take_money( #[reducer] pub fn realm_mail_item_room( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let payee_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -721,7 +721,7 @@ pub fn realm_mail_item_room( #[reducer] pub fn realm_mail_delete( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -733,7 +733,7 @@ pub fn realm_mail_delete( #[reducer] pub fn realm_mail_return( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, same_account: bool, ) -> Result<(), String> { diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index 954459525..3271d2160 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -974,7 +974,7 @@ pub(crate) fn apply_reap(sink: &mut S) { pub fn realm_mail_fence( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, recipient_guid: u64, subject: String, body: String, @@ -1013,7 +1013,7 @@ pub fn realm_mail_fence( pub fn realm_mail_commit( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, recipient_guid: u64, subject: String, body: String, @@ -1064,7 +1064,7 @@ pub fn realm_mail_commit( pub fn realm_mail_take_money_fence( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, expect_money: u32, ) -> Result<(), String> { @@ -1082,7 +1082,7 @@ pub fn realm_mail_take_money_fence( pub fn realm_mail_payout( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, amount: u32, ) -> Result<(), String> { @@ -1094,7 +1094,7 @@ pub fn realm_mail_payout( pub fn realm_mail_take_item_fence( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, expect_entry: u32, ) -> Result<(), String> { @@ -1113,7 +1113,7 @@ pub fn realm_mail_take_item_fence( pub fn realm_mail_item_payout( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, item_entry: u32, item_stack_count: u32, @@ -1144,7 +1144,7 @@ pub fn realm_mail_item_payout( fn require_escrow_actor( ctx: &ReducerContext, escrow_id: u64, - actor: crate::SessionActor, + actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::account_ownership::require_actor(ctx, actor)?; if let Some(row) = ctx.db.game_mail_escrow().escrow_id().find(escrow_id) { @@ -1157,7 +1157,7 @@ fn require_escrow_actor( pub fn realm_mail_confirm_delivery( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_escrow_actor(ctx, escrow_id, request_actor)?; @@ -1167,7 +1167,7 @@ pub fn realm_mail_confirm_delivery( pub fn realm_mail_settle( ctx: &ReducerContext, escrow_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_escrow_actor(ctx, escrow_id, request_actor)?; diff --git a/module/src/mail_text.rs b/module/src/mail_text.rs index a2e14455b..3aeb2dadd 100644 --- a/module/src/mail_text.rs +++ b/module/src/mail_text.rs @@ -190,7 +190,7 @@ pub(crate) fn drop_character_letters(ctx: &ReducerContext, character_guid: u64) #[reducer] pub fn realm_mail_copy_text( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -207,7 +207,7 @@ pub fn realm_mail_copy_text( #[reducer] pub fn gw_mail_grant_letter( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, item_text_id: u32, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -239,7 +239,7 @@ pub(crate) fn apply_mark_letter_granted( #[reducer] pub fn realm_mail_mark_letter_granted( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, mail_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/meeting_stone/mod.rs b/module/src/meeting_stone/mod.rs index 266e811c3..0f1d49eea 100644 --- a/module/src/meeting_stone/mod.rs +++ b/module/src/meeting_stone/mod.rs @@ -107,7 +107,7 @@ fn refused(refusal: MeetingStoneRefusal, detail: &str) -> String { #[reducer] pub fn gw_admit_meeting_stone( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, go_guid: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -122,7 +122,7 @@ pub fn gw_admit_meeting_stone( fn admit( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, actor_guid: u64, go_guid: u64, ) -> Result<(), MeetingStoneRefusal> { @@ -212,7 +212,7 @@ impl From for StoneOpError { pub fn realm_meeting_stone_op( ctx: &ReducerContext, op: u8, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, area_id: u32, seekers: Vec, ) -> Result<(), String> { diff --git a/module/src/nav.rs b/module/src/nav.rs index b6f36b639..d5c746df8 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -7,7 +7,9 @@ //! Blob layout + cell math live in `lyracore_shared::nav` (one copy, unit-tested there; the importer //! rasterizes through the SAME functions). -use lyracore_shared::nav::{self, OBS_BYTES, OBS_DIM, WALK_BYTES, WALK_DIM}; +#[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}; @@ -51,7 +53,7 @@ pub struct NavigationInputs { /// 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 fn inputs(ctx: &ReducerContext, map_id: u32) -> NavigationInputs { +pub(crate) fn inputs(ctx: &ReducerContext, map_id: u32) -> NavigationInputs { NavigationInputs { imported_revision: ctx .db @@ -83,7 +85,7 @@ pub(crate) fn record_change(ctx: &ReducerContext) -> Result<(), String> { /// 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. -pub fn walkable(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { +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 .db @@ -98,7 +100,8 @@ pub fn walkable(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option Option { +#[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)?); let chunk = ctx .db @@ -213,7 +216,7 @@ 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 fn nav_enabled(ctx: &ReducerContext) -> bool { +pub(crate) fn nav_enabled(ctx: &ReducerContext) -> bool { ctx.db .game_config() .id() @@ -223,7 +226,7 @@ pub fn nav_enabled(ctx: &ReducerContext) -> bool { } /// The vmap-derived coverage gate. Missing config row = false, like `nav_enabled`. -pub fn nav_coverage_enabled(ctx: &ReducerContext) -> bool { +pub(crate) fn nav_coverage_enabled(ctx: &ReducerContext) -> bool { ctx.db .game_config() .id() @@ -302,7 +305,7 @@ fn fetch_cell( } /// Closed doors apply even where static geometry falls back to the coarse navigation grid. -pub fn has_los( +pub(crate) fn has_los( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -332,6 +335,10 @@ pub enum RouteStatus { } #[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq, Eq)] +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API route value") +)] pub enum CoverageEvidence { Unknown, /// Every consulted navigation cell has a matching derived row from the active generation's @@ -358,6 +365,10 @@ impl From<(f32, f32)> for RoutePoint { } #[derive(spacetimedb::SpacetimeType, Clone, Debug, PartialEq)] +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API route value") +)] pub struct RouteClip { pub attempted: RoutePoint, pub hit: RoutePoint, @@ -366,6 +377,10 @@ pub struct RouteClip { /// `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), + allow(dead_code, reason = "retained Package API route value") +)] pub struct RouteStep { pub from: RoutePoint, pub endpoint: RoutePoint, @@ -376,13 +391,21 @@ pub struct RouteStep { pub coverage: CoverageEvidence, } +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API route value") +)] pub struct RoutePath { pub step: RouteStep, pub points: Vec<(f32, f32, f32)>, } /// Retain a bounded part of one route, including its turns, after checking every segment. -pub fn route_path( +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API operation") +)] +pub(crate) fn route_path( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -402,6 +425,10 @@ pub 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") +)] pub(crate) fn route_path_with_budget( ctx: &ReducerContext, map_id: u32, @@ -569,6 +596,10 @@ pub(crate) fn route_path_with_budget( result } +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API route value") +)] struct WalkingStep { attempted: (f32, f32, f32), endpoint: (f32, f32), @@ -577,6 +608,10 @@ struct WalkingStep { /// 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. +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API operation") +)] fn sample_walking_step( from: (f32, f32), towards: (f32, f32), @@ -614,6 +649,10 @@ fn sample_walking_step( /// 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") +)] fn fit_wire_path( start: (f32, f32, f32), points: &[(f32, f32, f32)], @@ -724,7 +763,11 @@ 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. -pub fn route_step( +#[cfg_attr( + not(has_packages), + allow(dead_code, reason = "retained Package API operation") +)] +pub(crate) fn route_step( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -827,7 +870,7 @@ pub fn route_step( /// 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. -pub fn nav_step( +pub(crate) fn nav_step( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -957,7 +1000,8 @@ fn gate_step( /// 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. -pub fn debug_find_leg( +#[cfg(feature = "debug_reducers")] +pub(crate) fn debug_find_leg( ctx: &ReducerContext, map_id: u32, from: (f32, f32), diff --git a/module/src/package.rs b/module/src/package.rs new file mode 100644 index 000000000..7aace3439 --- /dev/null +++ b/module/src/package.rs @@ -0,0 +1,34 @@ +#![allow( + unused_imports, + 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)] +pub(crate) use crate::package_test as test; + +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, + move_to_point, open_door, reset_hp_fired, set_encounter_data, set_encounter_state, + spawn_wave, watch_hp_threshold, wave_guid, EncounterSignal, DOOR_OPEN_STATE, + ENCOUNTER_DONE, ENCOUNTER_FAILED, ENCOUNTER_IN_PROGRESS, ENCOUNTER_NOT_STARTED, + }; +} diff --git a/module/src/package_fixture.rs b/module/src/package_fixture.rs index 4901499f6..7b7f1eb22 100644 --- a/module/src/package_fixture.rs +++ b/module/src/package_fixture.rs @@ -10,7 +10,25 @@ 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 use crate::threat::top_threat_target; +pub(crate) use crate::threat::top_threat_target; + +pub(crate) use crate::account_ownership::SessionActor; +pub(crate) use crate::combat::{ + apply_hit, enter_combat, final_damage, fold_incoming_damage, kill_creature, Hit, HitSource, +}; +pub(crate) use crate::creatures::tick::emit_move_spline; +pub(crate) use crate::creatures::{ + build_creature_entity, despawn_creature_entity, insert_creature_entity, timer_never, +}; +pub(crate) use crate::group::sync_group_mirror; +pub(crate) use crate::items::{grant_item, remove_items}; +pub(crate) use crate::loot::tag::clear as clear_loot_tag; +pub(crate) use crate::nav::record_change as record_navigation_change; +pub(crate) use crate::spell::{ + cast_triggered, do_cancel_aura, learn_spell, start_creature_spell, + CreatureSpellCasterAdmission, CreatureSpellStart, CreatureSpellStartMode, CreatureSpellTarget, +}; +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 @@ -61,7 +79,7 @@ pub fn client_cast( ) -> Result<(), String> { crate::gw::gw_cast_at( ctx, - crate::SessionActor { + SessionActor { guid: caster_guid, ownership: None, }, @@ -79,7 +97,7 @@ pub fn admit_to_instance( map_id: u32, instance_id: u64, party_id: u64, - request_actor: crate::SessionActor, + request_actor: SessionActor, ) -> Result<(), String> { crate::instance::ensure_instance(ctx, instance_id, map_id, party_id, request_actor)?; let admitted = crate::instance::resolve_or_create_instance(ctx, character_guid, map_id)?; diff --git a/module/src/quest.rs b/module/src/quest.rs index 9a89928f5..12716ada1 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -2452,15 +2452,16 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() y: player.y, z: player.z, orientation: player.orientation, - respawn_at: crate::timer_never(ctx), - despawn_at: crate::timer_never(ctx), - movement_type: crate::MOVEMENT_IDLE, + respawn_at: crate::creatures::timer_never(ctx), + despawn_at: crate::creatures::timer_never(ctx), + movement_type: crate::creatures::MOVEMENT_IDLE, respawn_secs: 0, life_seq: 0, }; - let wolf_entity = crate::build_creature_entity(&spawn, &wolf_template, 0, player.instance_id); + let wolf_entity = + crate::creatures::build_creature_entity(&spawn, &wolf_template, 0, player.instance_id); ctx.db.game_creature_spawn().insert(spawn); - crate::insert_creature_entity(ctx, wolf_entity); + crate::creatures::insert_creature_entity(ctx, wolf_entity); if !crate::loot::tag::record_first_threat(ctx, RQC_WOLF_GUID, RQC_PLAYER) { return Err("the raid quest credit fixture kill was not tapped".to_string()); } diff --git a/module/src/realm_chat.rs b/module/src/realm_chat.rs index aa7fb5718..f89b11bd1 100644 --- a/module/src/realm_chat.rs +++ b/module/src/realm_chat.rs @@ -90,7 +90,7 @@ pub(crate) struct RealmChatLine { #[reducer] pub fn realm_chat( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, request: RealmChatRequest, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -211,7 +211,7 @@ pub struct WhisperRequest { #[reducer] pub fn realm_whisper( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, request: WhisperRequest, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/realm_core.rs b/module/src/realm_core.rs index 1907594fe..ce5df024f 100644 --- a/module/src/realm_core.rs +++ b/module/src/realm_core.rs @@ -152,7 +152,7 @@ pub fn begin_character_shard_transfer( source_module_identity: Identity, transfer_intent_id: u64, controller_generation: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor_for(ctx, request_actor, character_guid)?; @@ -239,7 +239,7 @@ pub fn finish_character_shard_transfer( source_module_identity: Identity, transfer_intent_id: u64, controller_generation: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor_for(ctx, request_actor, character_guid)?; @@ -322,7 +322,7 @@ pub fn finish_pending_character_shard_transfer( source_module_identity: Identity, transfer_intent_id: u64, controller_generation: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor_for(ctx, request_actor, character_guid)?; @@ -395,7 +395,7 @@ pub fn set_character_shard( character_guid: u64, map_id: u32, instance_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; crate::account_ownership::require_actor_for(ctx, request_actor, character_guid)?; diff --git a/module/src/region.rs b/module/src/region.rs index 6ffa52e59..f84200b0b 100644 --- a/module/src/region.rs +++ b/module/src/region.rs @@ -6,7 +6,7 @@ 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 fn region_key(map_id: u32, region_id: u32) -> u64 { +pub(crate) fn region_key(map_id: u32, region_id: u32) -> u64 { ((map_id as u64) << 32) | region_id as u64 } diff --git a/module/src/script_binding.rs b/module/src/script_binding.rs index 54f11815d..06f7d38ed 100644 --- a/module/src/script_binding.rs +++ b/module/src/script_binding.rs @@ -97,7 +97,7 @@ pub struct Script { /// 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. -#[cfg_attr(not(has_packages), allow(dead_code))] +#[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); } diff --git a/module/src/seed.rs b/module/src/seed.rs index e141fce69..d14b8b16e 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -25,14 +25,15 @@ //! //! Touches every domain, so it imports each table's accessor trait + row type from the crate root. +use crate::creatures::build_creature_entity; use lyracore_shared::constants; use spacetimedb::{reducer, Identity, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::{ - build_creature_entity, game_account, game_aura_schedule, game_breath_schedule, game_character, - game_config, game_creature_loot, game_creature_move_schedule, game_creature_spawn, - game_creature_template, game_creature_waypoint, game_duel_schedule, game_event_reaper_schedule, - game_gameobject, game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, + game_account, game_aura_schedule, game_breath_schedule, game_character, game_config, + game_creature_loot, game_creature_move_schedule, game_creature_spawn, game_creature_template, + game_creature_waypoint, game_duel_schedule, game_event_reaper_schedule, game_gameobject, + game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, game_gateway_lease_reaper_schedule, game_graveyard, game_graveyard_zone, game_ground_area_schedule, game_instance_reaper_schedule, game_item_template, game_melee_schedule, game_motion_publish_schedule, game_pet_care_schedule, game_realm, @@ -1653,7 +1654,22 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { // own file; `init` and the `debug_seed_*` reducers reach them through this re-export, so callers // keep the `seed::seed_*_fixture` paths. mod fixtures; -pub(crate) use fixtures::*; +#[cfg(feature = "debug_reducers")] +pub(crate) use fixtures::seed_scenario_fixtures; +pub(crate) use fixtures::{ + base_effect, base_item, base_spell, profession_trainer_template, seed_demon_skin_fixture, + seed_drain_soul_fixture, seed_fixture_catalogue, seed_frost_armor_fixture, + seed_hunter_tame_fixture, seed_mana_burn_fixture, seed_mount_fixture, seed_pw_shield_fixture, + seed_regen_fixture, seed_soul_shard_item, seed_stacking_probe_fixture, seed_stealth_fixture, + seed_taxi_fixture, seed_test_proc_fixtures, tempered_blade_template, test_wolf_template, + tough_jerky_template, FIXTURE_BLADE, FIXTURE_DAZED_SPELL, FIXTURE_FACTION, FIXTURE_JERKY, + FIXTURE_MOUNT_SPELL, PROFESSION_TRAINER_ENTRY, TEST_PROC_CHARGES, TEST_PROC_COIN, + TEST_PROC_COOLDOWN, TEST_PROC_MARK, TEST_PROC_PPM, TEST_PROC_ZAP, TEST_WOLF_ENTRY, +}; +#[cfg(test)] +pub(crate) use fixtures::{ + FIXTURE_REINS, RIDING_TRAINER_ENTRY, TEST_TAME_BEAST_SPELL, TEST_TAME_BOAR_ENTRY, +}; /// The createinfo starting kits — the spells a fresh character knows before any training, copied /// into `game_player_spell` at creation (`grant_createinfo_spells`). `race == 0` rows are class kits diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index 35ed6677e..c3afa7775 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -11,13 +11,13 @@ use spacetimedb::{ReducerContext, Table}; +use crate::items::{ALL_PLAYABLE_CLASS_MASK, ALL_PLAYABLE_RACE_MASK}; use crate::{ game_createinfo_spell, game_creature_family, game_creature_spawn, game_creature_template, game_faction, game_item_template, game_spell, game_spell_effect, game_spell_proc_event, game_taxi_node, game_taxi_path, game_taxi_path_node, game_world_entity, CreateinfoSpell, CreatureFamily, CreatureSpawn, CreatureTemplate, Faction, GameTaxiNode, GameTaxiPath, - GameTaxiPathNode, ItemTemplate, Spell, SpellEffect, SpellProcEvent, ALL_PLAYABLE_CLASS_MASK, - ALL_PLAYABLE_RACE_MASK, + GameTaxiPathNode, ItemTemplate, Spell, SpellEffect, SpellProcEvent, }; /// Canonical fixture-NPC/item constructors — the single source of truth for the synthetic rows @@ -357,7 +357,7 @@ pub(crate) fn seed_taxi_fixture(ctx: &ReducerContext) { }); crate::creatures::insert_creature_entity( ctx, - crate::build_creature_entity(&spawn, &template, 0, 0), + crate::creatures::build_creature_entity(&spawn, &template, 0, 0), ); } diff --git a/module/src/sessionless.rs b/module/src/sessionless.rs index b938623be..474fc971c 100644 --- a/module/src/sessionless.rs +++ b/module/src/sessionless.rs @@ -54,7 +54,7 @@ pub(crate) fn action_gate( use crate::actor::{ActionRefusal, ActionRefusalKind}; crate::account_ownership::require_actor( ctx, - crate::SessionActor { + crate::account_ownership::SessionActor { guid: character_guid, ownership: None, }, @@ -130,7 +130,7 @@ pub(crate) fn transfer_authority_gate( use crate::actor::{ActionRefusal, ActionRefusalKind}; crate::account_ownership::require_actor( ctx, - crate::SessionActor { + crate::account_ownership::SessionActor { guid: character_guid, ownership: None, }, diff --git a/module/src/skill.rs b/module/src/skill.rs index 788411e11..b6e5134b0 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -83,7 +83,7 @@ pub mod skill_line { pub const RIDING: u32 = lyracore_shared::trainer::RIDING_SKILL_LINE; } -pub fn is_profession_line(line: u32) -> bool { +pub(crate) fn is_profession_line(line: u32) -> bool { use skill_line::*; matches!( line, @@ -109,7 +109,7 @@ pub const APPRENTICE_CAP: u16 = 75; /// Map a vanilla weapon `ItemTemplate.subclass` to its `skill_line`. Anything unrecognized (or no weapon) /// resolves to `UNARMED` — the line an empty-handed swing trains, so a fist/unmapped weapon never errors. -pub fn weapon_subclass_to_skill_line(subclass: u8) -> u32 { +pub(crate) fn weapon_subclass_to_skill_line(subclass: u8) -> u32 { use skill_line::*; match subclass { 0 => AXE_1H, @@ -128,19 +128,19 @@ pub fn weapon_subclass_to_skill_line(subclass: u8) -> u32 { /// The skill cap for a unit of `level` — `level * SKILL_PER_LEVEL`. Both the max a tracked skill can reach /// and the baseline value an UNtracked skill reads as (so combat against an equal-level foe nets 0). -pub fn skill_cap_for_level(level: u32) -> u32 { +pub(crate) fn skill_cap_for_level(level: u32) -> u32 { level * SKILL_PER_LEVEL } /// The attack-table skill difference (in skill points) driving miss/dodge/parry/block: how far the /// defender's defense skill exceeds the attacker's weapon skill, clamped at 0 (a higher-skilled attacker /// adds nothing — the band floors). The single definition both live combat and the readback use. -pub fn skill_diff(defense_skill: u32, weapon_skill: u32) -> u32 { +pub(crate) fn skill_diff(defense_skill: u32, weapon_skill: u32) -> u32 { defense_skill.saturating_sub(weapon_skill) } /// One skill-up step: `current + 1`, clamped to `cap`. A skill already at cap is unchanged. -pub fn next_skill(current: u32, cap: u32) -> u32 { +pub(crate) fn next_skill(current: u32, cap: u32) -> u32 { (current + 1).min(cap) } @@ -252,7 +252,7 @@ fn intellect_assist(intellect: u32, level: u32) -> f64 { /// grinding a grey mob still trains a lagging weapon skill here. That is a statement about observable /// behaviour we match — no implementation of it was copied. Pure → unit-tested at the seam, the /// monotonicity contract, and the clamp edges. -pub fn combat_skillup_chance_bp( +pub(crate) fn combat_skillup_chance_bp( current: u32, cap: u32, level: u32, @@ -279,7 +279,7 @@ pub fn combat_skillup_chance_bp( /// SENTINEL: `gray <= orange` (notably the default-band `orange=1, gray=0`) means "no difficulty / always /// skill up" → returns 10000. The seeded recipes/nodes carry the default band, so every craft/gather /// deterministically +1s. -pub fn skillup_chance_bp(current: u32, orange: u32, gray: u32) -> u32 { +pub(crate) fn skillup_chance_bp(current: u32, orange: u32, gray: u32) -> u32 { if gray <= orange { return 10_000; // degenerate/disabled band (incl. the default sentinel) => always skill up } @@ -299,7 +299,7 @@ pub fn skillup_chance_bp(current: u32, orange: u32, gray: u32) -> u32 { /// tier (Journeyman 150 over Apprentice 75) lifts the ceiling; re-buying the same/lower tier is a no-op /// (the idempotency the trainer's already-known gate also enforces). `learn_profession` uses exactly this /// for the present-row case (a NEW row instead just inserts `1/offered_cap`). -pub fn raised_cap(current_cap: u16, offered_cap: u16) -> u16 { +pub(crate) fn raised_cap(current_cap: u16, offered_cap: u16) -> u16 { current_cap.max(offered_cap) } @@ -382,7 +382,7 @@ fn skill_value_or_cap(ctx: &ReducerContext, guid: u64, line: u32, level: u32) -> /// A unit's EFFECTIVE weapon skill: the trained value of its equipped weapon's line, else the `level*5` /// baseline. A creature (no items) always reads the baseline → its swings are unchanged. -pub fn effective_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { +pub(crate) fn effective_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) -> u32 { let line = equipped_weapon_skill_line(ctx, attacker.guid); skill_value_or_cap(ctx, attacker.guid, line, attacker.level) } @@ -391,7 +391,7 @@ pub fn effective_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) -> u /// `A_MOD_COMBAT(COMBAT_DEFENSE)` aura and Defense from working equipped items. More defense raises /// `skill_diff` against an attacker, so it tightens the attacker's miss/dodge/parry/block bands — a /// defender's avoidance. With neither source, this is exactly the trained or baseline value. [entity] -pub fn effective_defense_skill(ctx: &ReducerContext, target: &WorldEntity) -> u32 { +pub(crate) fn effective_defense_skill(ctx: &ReducerContext, target: &WorldEntity) -> u32 { let base = skill_value_or_cap(ctx, target.guid, skill_line::DEFENSE, target.level); let aura = crate::spell::combat_field_bonus(ctx, target.guid, crate::spell::COMBAT_DEFENSE); let gear = @@ -402,7 +402,11 @@ pub fn effective_defense_skill(ctx: &ReducerContext, target: &WorldEntity) -> u3 /// The attack-table skill difference for a swing of `attacker` at `target`: the defender's defense skill /// minus the attacker's weapon skill (clamped ≥0). With no tracked rows this is exactly /// `(target.level - attacker.level) * 5` — the pure level-derived `skill_diff` (baseline-safe). [entity] -pub fn skill_diff_ctx(ctx: &ReducerContext, attacker: &WorldEntity, target: &WorldEntity) -> u32 { +pub(crate) fn skill_diff_ctx( + ctx: &ReducerContext, + attacker: &WorldEntity, + target: &WorldEntity, +) -> u32 { skill_diff( effective_defense_skill(ctx, target), effective_weapon_skill(ctx, attacker), @@ -475,7 +479,7 @@ fn roll_and_raise_skill( /// Skill-up the attacker's equipped weapon line on a landed player→creature hit. Gated by the caller on /// `attacker.is_player()` + a real hit; trains the line the swing actually used, at the /// headroom/level/intellect chance (`combat_skillup_chance_bp`, `weapon = true`). -pub fn gain_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) { +pub(crate) fn gain_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) { let line = equipped_weapon_skill_line(ctx, attacker.guid); roll_and_raise_skill( ctx, @@ -490,7 +494,7 @@ pub fn gain_weapon_skill(ctx: &ReducerContext, attacker: &WorldEntity) { /// Skill-up the defender's Defense line on a landed hit it survived. Gated by the caller on the defender /// being a player, at the headroom/level chance (`combat_skillup_chance_bp`, `weapon = false` — vanilla /// gives the intellect assist to weapon lines only, so Defense never gets it). -pub fn gain_defense_skill(ctx: &ReducerContext, target: &WorldEntity) { +pub(crate) fn gain_defense_skill(ctx: &ReducerContext, target: &WorldEntity) { roll_and_raise_skill( ctx, target.guid, @@ -1103,7 +1107,7 @@ pub fn debug_learn_riding_from_trainer( /// fresh paladin swords/axes/polearms and made the weapon masters pointless (live find, faladin). /// Existing characters are GRANDFATHERED: `ensure_player_skills` only inserts missing lines, never /// deletes, so an existing character keeps its wide set. -pub fn class_starting_weapon_skill_lines(class: u8) -> &'static [u32] { +pub(crate) fn class_starting_weapon_skill_lines(class: u8) -> &'static [u32] { use skill_line::*; match class { // Warrior: 1H axes, 1H maces, 1H swords (createinfo grants exactly these three). @@ -1128,7 +1132,7 @@ pub fn class_starting_weapon_skill_lines(class: u8) -> &'static [u32] { } } -pub fn class_weapon_skill_lines(class: u8) -> &'static [u32] { +pub(crate) fn class_weapon_skill_lines(class: u8) -> &'static [u32] { use skill_line::*; match class { // Warrior: axes, maces, polearms, swords, fist, daggers (no staves; ranged → UNARMED fallback, not listed). @@ -1164,7 +1168,7 @@ pub fn class_weapon_skill_lines(class: u8) -> &'static [u32] { /// character lacks, so a relog never duplicates (mirrors `grant_starter_item`). Seeding at cap /// keeps a fresh login baseline (defense = weapon = level*5 → 0 skill_diff vs an equal-level foe). /// `class` is the vanilla class id (1=Warrior … 11=Druid) from `Character.class`. [entity] -pub fn ensure_player_skills( +pub(crate) fn ensure_player_skills( ctx: &ReducerContext, guid: u64, owner: Identity, diff --git a/module/src/spell/cast/mod.rs b/module/src/spell/cast/mod.rs index 611eebccb..ff7036c7c 100644 --- a/module/src/spell/cast/mod.rs +++ b/module/src/spell/cast/mod.rs @@ -8,15 +8,25 @@ //! back into `pushback_cast` (the on-damage chokepoint), so co-locating it with the rest of the //! crowd-control model removes a cross-module callback instead of adding one. //! -//! Both siblings glob-import each other's surface via `crate::spell::*` (not `super::*` — they are -//! siblings, not parent/child, and each needs symbols the OTHER defines: `resolve` calls -//! `targeting::{select_targets, apply_effect, aura_apply, recipe_reagents}`; `targeting`'s E_TRIGGER arm -//! calls back into `resolve::resolve_cast_at`). This `mod.rs` glob re-exports both into -//! `crate::spell::cast::*`, and `spell::mod` globs `cast::*` in turn — so every `crate::spell::` -//! call site outside this module is unchanged by the split. +//! Resolve and targeting share operations through the named exports here and in `spell::mod`. +//! Resolve calls targeting's effect dispatch, and triggered effects call back into resolve. -mod resolve; +pub(crate) mod resolve; mod targeting; -pub(crate) use resolve::*; -pub(crate) use targeting::*; +#[cfg(test)] +pub(crate) use resolve::passive_applies_effect_kind; +#[cfg(feature = "debug_reducers")] +pub(crate) use resolve::resolve_cast; +pub(crate) use resolve::{ + apply_spell_auras, begin_cast, begin_cast_with_admission, cast_triggered, + check_cast_start_gates, check_pending_cast_los, clear_dead_callback_cast_admission, + resolve_cast_at, resolve_cast_at_typed, start_creature_spell, CreatureSpellCasterAdmission, + CreatureSpellStart, CreatureSpellStartMode, CreatureSpellTarget, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use targeting::blink_forward; +pub(crate) use targeting::{ + apply_direct_heal, apply_effect, aura_apply, dynamic_object_guid, faction_target_violation, + recipe_reagents, revive_amount, select_targets, ItemGrantOutcome, +}; diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 7b1f1aa8d..f8ea28434 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -1,9 +1,8 @@ //! The cast core — `resolve_cast_at` (+ its extracted `check_cast_gates` gate sweep) / `resolve_cast` / //! `begin_cast` / the passive-apply path (`passive_applies_effect_kind` / `apply_spell_auras`). 381 //! split: the targeting/effect-dispatch half (`select_targets` / `aura_apply` / `apply_effect`) lives in -//! the sibling `targeting` module; the cast-bar teardown family moved into `control.rs`. `cast::mod` -//! glob re-exports both cast siblings, and `spell::mod` globs `cast::*` in turn, so every -//! `crate::spell::` call site is unchanged by the split. +//! the sibling `targeting` module; the cast-bar teardown family moved into `control.rs`. The parent modules +//! name the operations shared with other Module files. use spacetimedb::{log, ReducerContext, ScheduleAt, Table, TimeDuration}; diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index ff9c39d0b..9934828af 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -30,7 +30,7 @@ fn has_control_mechanic(ctx: &ReducerContext, unit_guid: u64, mechanic: i32) -> /// gate "cast this debuff only if the target lacks it" (TARGET_MISSING_AURA) and "buff self only if /// missing" (SELF_MISSING_AURA), so a creature doesn't waste casts re-applying a live aura. A unit with /// no aura from that spell → `false`. [entity] -pub fn has_aura(ctx: &ReducerContext, unit_guid: u64, spell_id: u32) -> bool { +pub(crate) fn has_aura(ctx: &ReducerContext, unit_guid: u64, spell_id: u32) -> bool { auras_on(ctx, unit_guid).any(|a| a.spell_id == spell_id) } @@ -63,7 +63,7 @@ pub enum ControlReadError { /// Read all control auras for one unit within a declared limit. Oversized state is unavailable, so /// an arbitrary prefix cannot decide whether the party may damage the unit. #[cfg_attr(not(has_packages), allow(dead_code))] -pub fn control_status( +pub(crate) fn control_status( ctx: &ReducerContext, unit_guid: u64, limit: usize, @@ -92,7 +92,7 @@ pub fn control_status( /// Classify a spell from its complete effect set. A spell has at most three effects. #[cfg_attr(not(has_packages), allow(dead_code))] -pub fn spell_control( +pub(crate) fn spell_control( ctx: &ReducerContext, spell_id: u32, ) -> Result, ControlReadError> { @@ -125,7 +125,7 @@ pub fn spell_control( /// Whether a target needs the requested buff. Oversized or ambiguous catalogue data is unavailable, /// so callers hold instead of choosing from an arbitrary prefix. #[cfg_attr(not(has_packages), allow(dead_code))] -pub fn buff_status( +pub(crate) fn buff_status( ctx: &ReducerContext, target_guid: u64, spell_id: u32, @@ -181,7 +181,7 @@ pub(crate) fn is_non_melee_spell_casting(ctx: &ReducerContext, unit_guid: u64) - /// (no melee swing, no creature aggro/cast) NOR MOVE — the combat melee tick and EVERY creature pass /// gate on this. Pure read over `game_aura` (no entity write); `false` for any unit without an /// A_CONTROL(M_STUN) aura, so an un-stunned unit's combat/AI is unaffected. [entity] -pub fn is_stunned(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_stunned(ctx: &ReducerContext, unit_guid: u64) -> bool { has_control_mechanic(ctx, unit_guid, M_STUN) } @@ -191,7 +191,7 @@ pub fn is_stunned(ctx: &ReducerContext, unit_guid: u64) -> bool { /// of `is_stunned` (presence over the same `by_target` scan, FIRST match short-circuits) — A_DISARM is NOT an /// A_CONTROL mechanic, so it scans the kind directly. `false` for any unit without an A_DISARM aura, so an /// un-disarmed unit's swing is byte-identical (baseline-safe). [entity] -pub fn is_disarmed(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_disarmed(ctx: &ReducerContext, unit_guid: u64) -> bool { auras_on(ctx, unit_guid).any(|a| a.eff_kind == A_DISARM) } @@ -200,7 +200,7 @@ pub fn is_disarmed(ctx: &ReducerContext, unit_guid: u64) -> bool { /// unlike `is_stunned` — root does NOT gate the swing or the action passes. A STUN also implies no /// movement, so the movement gates check `is_stunned(..) || is_rooted(..)`. Pure read; `false` without /// an A_CONTROL(M_ROOT) aura, so an un-rooted unit's movement is unaffected. [entity] -pub fn is_rooted(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_rooted(ctx: &ReducerContext, unit_guid: u64) -> bool { has_control_mechanic(ctx, unit_guid, M_ROOT) } @@ -210,7 +210,7 @@ pub fn is_rooted(ctx: &ReducerContext, unit_guid: u64) -> bool { /// the one mechanic that MOVES the unit: stun/poly freeze (no act, no move), root pins (act, no move), /// fear routs (no act, forced flee). Pure read over `game_aura` (no entity write); `false` for any unit /// without an A_CONTROL(M_FEAR) aura, so an un-feared unit is unaffected. [entity] -pub fn is_feared(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_feared(ctx: &ReducerContext, unit_guid: u64) -> bool { has_control_mechanic(ctx, unit_guid, M_FEAR) } @@ -219,27 +219,16 @@ pub fn is_feared(ctx: &ReducerContext, unit_guid: u64) -> bool { /// AWAY from this source, so it both DETECTS fear (`Some`) and supplies the bearing. Reuses the same /// `by_target` aura scan as `is_feared` (which is `fear_source(..).is_some()` in spirit) but returns the /// caster instead of a bool. [entity] -pub fn fear_source(ctx: &ReducerContext, unit_guid: u64) -> Option { +pub(crate) fn fear_source(ctx: &ReducerContext, unit_guid: u64) -> Option { auras_on(ctx, unit_guid) .find(|a| a.eff_kind == A_CONTROL && a.eff_p0 == M_FEAR) .map(|a| a.caster_guid) } -/// Is `unit_guid` INCAPACITATED — FROZEN and unable to ACT? True under STUN *or* POLYMORPH (M_POLY): both -/// fully neutralize the unit (no act AND no move), vs root which only pins it (a rooted unit still acts) -/// and fear which routs it (no act but force-moved). It is the "no act AND no move" core: the MOVEMENT -/// gate (`is_movement_blocked`) folds it in, and the ACTION gate (`is_action_blocked`) folds it in too. -/// Baseline-safe: with no poly aura `is_incapacitated == is_stunned` (a unit without a control aura is -/// `false`). [entity] -pub fn is_incapacitated(ctx: &ReducerContext, unit_guid: u64) -> bool { - is_stunned(ctx, unit_guid) || has_control_mechanic(ctx, unit_guid, M_POLY) -} - /// Pure CC-axis lattice: given the four independent control booleans (already resolved off a live /// `A_CONTROL` scan), derive the three composite gates the action/movement pipeline reads — lifted /// verbatim from `is_action_blocked`/`is_movement_blocked`/`is_self_movement_suppressed`'s own boolean -/// expressions (which each build on the same `incapacitated = stunned || poly` core `is_incapacitated` -/// computes) so the fear/root/poly axis composition is unit-tested without a live ctx. Returns +/// expressions, which share `incapacitated = stunned || poly` so the fear/root/poly axis composition is unit-tested without a live ctx. Returns /// `(action_blocked, movement_blocked, self_movement_suppressed)`. pub(crate) fn cc_blocks( stunned: bool, @@ -247,7 +236,7 @@ pub(crate) fn cc_blocks( feared: bool, poly: bool, ) -> (bool, bool, bool) { - let incapacitated = stunned || poly; // is_incapacitated + let incapacitated = stunned || poly; let action_blocked = incapacitated || feared; // is_action_blocked let movement_blocked = incapacitated || rooted; // is_movement_blocked let self_movement_suppressed = movement_blocked || feared; // is_self_movement_suppressed @@ -258,9 +247,9 @@ pub(crate) fn cc_blocks( /// under INCAPACITATE (stun/poly) *or* FEAR: a feared unit loses control of its actions exactly like an /// incapacitated one — the only difference is movement (incapacitate freezes it in place, fear force-flees /// it). The ACTION gates — the melee swing, the creature aggro/assist/cast passes, and the cast chokepoint -/// — gate on THIS (not on `is_incapacitated`), so fear silences as well as routs. Baseline-safe: with no +/// Fear silences as well as routs. Baseline-safe: with no /// poly/fear aura `is_action_blocked == is_stunned`. [entity] -pub fn is_action_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_action_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { cc_blocks( is_stunned(ctx, unit_guid), is_rooted(ctx, unit_guid), @@ -275,7 +264,7 @@ pub fn is_action_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { /// frozen, it is force-fled — see `is_self_movement_suppressed`.) The fear-flee pass uses this to know a /// stun/root/poly OUTRANKS fear and keeps the unit put. `false` for any un-CC'd unit (the common path), so /// movement is unaffected. [entity] -pub fn is_movement_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_movement_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { cc_blocks( is_stunned(ctx, unit_guid), is_rooted(ctx, unit_guid), @@ -292,7 +281,7 @@ pub fn is_movement_blocked(ctx: &ReducerContext, unit_guid: u64) -> bool { /// !is_movement_blocked`, so a feared-but-not-frozen creature is moved by EXACTLY one pass (fear-flee), a /// frozen one by none, and the two never both fire. `false` for any un-CC'd creature (the common path). /// [entity] -pub fn is_self_movement_suppressed(ctx: &ReducerContext, unit_guid: u64) -> bool { +pub(crate) fn is_self_movement_suppressed(ctx: &ReducerContext, unit_guid: u64) -> bool { cc_blocks( is_stunned(ctx, unit_guid), is_rooted(ctx, unit_guid), diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 6cdd761e7..12b333e99 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -5,8 +5,7 @@ //! module" — is true again: every fn below takes a `ReducerContext` and has a side effect, so none of //! them belong in a "pure spell math" file, and (per the `test_scan` playbook) none of them get a direct //! unit test here either — there is no `ReducerContext` harness in this crate by design. `mod.rs` -//! glob re-exports this module (`pub(crate) use effects::*`), so every `crate::spell::` call site is -//! unchanged by the move. +//! names the operations shared with other Module files. // `Table` provides `.insert()`/`.update()`/`.delete()` on a table handle; `ReducerContext` is the live db // handle every fn here threads. diff --git a/module/src/spell/math.rs b/module/src/spell/math.rs index 091c62567..475da0e62 100644 --- a/module/src/spell/math.rs +++ b/module/src/spell/math.rs @@ -39,7 +39,7 @@ pub(crate) fn healed_value(health: u32, max_health: u32, basis: i32) -> u32 { } /// New health after `basis` direct damage, FLOORED at 1 (the periodic DoT pass never kills). Negative = 0. -pub fn damaged_value(health: u32, basis: i32) -> u32 { +pub(crate) fn damaged_value(health: u32, basis: i32) -> u32 { health.saturating_sub(basis.max(0) as u32).max(1) } @@ -54,7 +54,7 @@ pub(crate) fn energized_value(power: u32, max_power: u32, basis: i32) -> u32 { /// Level is clamped into `[spell_level, max_level]` (`max_level == 0` ⇒ no cap, only the floor). A /// `die_sides` of 0 is deterministic (the random `roll` is ignored). Pure — the scaling/rounding is /// unit-tested; live casts pass `ctx.random()` for the roll. -pub fn effect_amount( +pub(crate) fn effect_amount( base_points: i32, die_sides: i32, per_level: f32, @@ -236,7 +236,7 @@ pub(crate) fn is_behind(ax: f32, ay: f32, tx: f32, ty: f32, target_orientation: /// auto-face their victim so they're exempt. // Deliberate simplification: front 180°, not vanilla's stricter ~120° arc — kills the // attack-while-fleeing exploit; tighten to 2π/3 if side-swings ever read as cheese. -pub fn is_facing(ax: f32, ay: f32, attacker_orientation: f32, tx: f32, ty: f32) -> bool { +pub(crate) fn is_facing(ax: f32, ay: f32, attacker_orientation: f32, tx: f32, ty: f32) -> bool { let (dx, dy) = (tx - ax, ty - ay); if dx == 0.0 && dy == 0.0 { return true; @@ -246,7 +246,8 @@ pub fn is_facing(ax: f32, ay: f32, attacker_orientation: f32, tx: f32, ty: f32) /// Absolute micros at which a `cast_time_ms` cast finishes, given `now_micros`. Pure mirror of the /// `checked_add(TimeDuration::from_micros(..))` deadline math used for the PendingCast schedule. -pub fn cast_finish_micros(now_micros: i64, cast_time_ms: u32) -> i64 { +#[cfg(test)] +pub(crate) fn cast_finish_micros(now_micros: i64, cast_time_ms: u32) -> i64 { now_micros + (cast_time_ms as i64) * 1000 } diff --git a/module/src/spell/mod.rs b/module/src/spell/mod.rs index 3adb08ffa..829768f47 100644 --- a/module/src/spell/mod.rs +++ b/module/src/spell/mod.rs @@ -12,12 +12,14 @@ //! relay reuses the proven dirty_reset discipline (the gateway reads slot/spell_id/level/flags). [event] //! -//! Everything is re-exported below so every `crate::spell::` path resolves regardless of which -//! submodule actually defines it. +//! Named exports below hold the operations and tables shared with other Module files. -mod cast; +pub(crate) mod cast; mod outcome; -pub(crate) use outcome::*; +pub(crate) use outcome::{ + cast_readiness, pending_cast, request_cast, CastFinish, CastHandle, CastRefusal, + CastRefusalKind, CastStart, +}; mod control; mod effects; mod math; @@ -32,20 +34,126 @@ mod taxonomy; #[cfg(test)] mod tests; -// Re-export the full surface. `pub use` for submodules exposing at least one `pub` item; `pub(crate) use` -// for submodules whose surface is entirely `pub(crate)` (avoids the "glob reexports nothing public" -// warning). -pub(crate) use cast::*; -pub use control::*; -pub(crate) use effects::*; -pub use math::*; -pub use scheduler::*; -pub(crate) use self_resurrect::*; -pub use spellbook::*; -pub use tables::*; -pub(crate) use taxonomy::*; +// Table rows retain public visibility for schema parity. Other operations stay inside the Module. +#[cfg(test)] +pub(crate) use cast::passive_applies_effect_kind; +pub(crate) use cast::{ + apply_direct_heal, apply_effect, apply_spell_auras, aura_apply, begin_cast, + begin_cast_with_admission, cast_triggered, check_cast_start_gates, check_pending_cast_los, + clear_dead_callback_cast_admission, faction_target_violation, recipe_reagents, resolve_cast_at, + resolve_cast_at_typed, revive_amount, select_targets, start_creature_spell, + CreatureSpellCasterAdmission, CreatureSpellStart, CreatureSpellStartMode, CreatureSpellTarget, + ItemGrantOutcome, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use cast::{blink_forward, resolve_cast}; +pub(crate) use control::{ + break_auras_on_damage, break_channel, buff_status, cancel_cast_attempt, control_status, + expire_cast_attempt, fear_source, has_aura, interrupt_cast, interrupt_cast_and_lock, + is_action_blocked, is_disarmed, is_immune_to_mechanic, is_movement_blocked, + is_non_melee_spell_casting, is_self_movement_suppressed, pushback_cast, remove_auras_on_death, + shed_auras, spell_control, +}; +#[cfg(test)] +pub(crate) use control::{breaks_on_damage, cc_blocks}; +pub use control::{BuffStatus, BuffUnavailableReason, ControlReadError, UnitControl}; +pub(crate) use effects::{ + absorb_incoming, apply_target_damage, arm_spell_retaliation, dispel_target, recompute_sheet, + recompute_vitals, +}; +#[cfg(test)] +pub(crate) use math::{ + absorb_draw, can_afford, cast_finish_micros, int_spell_power, intellect_crit_bp, + spell_crit_bp_with_bonus, stance_damage_taken_pct, SPELL_CRIT_BP, +}; +pub(crate) use math::{ + add_flat_pool_bonus, apply_damage_pct, apply_resistance, apply_spell_crit, aura_moves_sheet, + aura_moves_vitals, auras_on, break_stealth, client_form_for_stance, combat_field_bonus, + combat_health_regen_pct, compose_magnitude, cost_gate_blocks, damage_taken_bonus, + damaged_value, detect_range_mod, dispel_category_matches, distance_3d, drain_shields, + effect_amount, energized_value, form_recast_toggles_off, healed_value, healing_power, + is_behind, is_facing, is_on_cooldown, is_ready_at, is_stealthed, level_gate_blocks, + projectile_travel_ms, rederive_pool, remove_seal_auras, resist_magic_damage, resistance_bonus, + school_index, school_locked, seal_amount, seal_amount_and_spell, should_arm_spell_retaliation, + speed_bonus, spell_crit_bp, spell_miss_chance_bp, spell_power, stance_allows, + stance_damage_done_pct, stance_switch_clears_power, stance_threat_pct, stat_bonus, + stat_pct_bonus, +}; +#[cfg(feature = "debug_reducers")] +pub(crate) use scheduler::arm_ground_area_tick; +#[cfg(test)] +pub(crate) use scheduler::is_due_for_expiry; +pub(crate) use scheduler::{ + apply_cast_spell_at, do_cancel_aura, do_cancel_cast, do_cast_spell, do_resurrect_response, + fire_pending_cast, +}; +pub use scheduler::{fire_spell_impact, tick_auras, tick_ground_areas}; +pub(crate) use self_resurrect::{ + clear_resurrect_request, clear_resurrect_request_and_option, do_self_resurrect, + record_self_resurrect_option, +}; + +pub(crate) use spellbook::{ + apply_racial_passives, forget_spell, knows_spell, learn_spell, learn_spell_with_dependents, + strip_spell_auras, +}; +pub use spellbook::{ + game_createinfo_spell, game_player_spell, game_spell_chain, game_spell_learn, CreateinfoSpell, + PlayerSpell, SpellChain, SpellLearn, +}; + +pub use tables::{ + game_aura, game_aura_schedule, game_dynamic_object, game_ground_area, + game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, + game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, + game_spell_cast_event, game_spell_cd, game_spell_cooldown, game_spell_effect, + game_spell_impact_event, game_spell_proc_event, game_spell_reagent, Aura, AuraSchedule, + DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, PendingSpellImpact, + ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, SpellCastEvent, SpellCd, + SpellCooldown, SpellEffect, SpellImpactEvent, SpellProcEvent, SpellReagent, CAST_FAIL_NO_POWER, +}; + +pub(crate) use tables::SpellCastEventKind; +#[cfg(test)] +pub(crate) use taxonomy::{ + ALL_AURA_KINDS, ALL_INSTANT_KINDS, P_COMBAT_FIELD, P_ENCHANT_ID, P_FLAG, P_FLAT_MANA, + P_ITEM_ENTRY, P_NONE, P_POWER_TYPE, P_RAW, P_SCHOOL_MASK, P_SPELL_ID, P_STAT_ID, STANCE_BATTLE, + STANCE_BERSERKER, T_SCRIPTED, +}; +pub(crate) use taxonomy::{ + A_ABSORB, A_COMBAT_HEALTH_REGEN_PCT, A_CONTROL, A_DISARM, A_FLAG, A_IMMUNITY, A_MOD_COMBAT, + A_MOD_DAMAGE_TAKEN, A_MOD_DETECT_RANGE, A_MOD_RESISTANCE, A_MOD_SPEED, A_MOD_STAT, + A_MOD_STAT_PCT, A_MOUNTED, A_PERIODIC_DAMAGE, A_PERIODIC_ENERGIZE, A_PERIODIC_HEAL, + A_PERIODIC_TRIGGER, A_PROC_DAMAGE, A_PROC_TRIGGER, A_RETALIATE, A_SEAL, A_SELF_RESURRECT, + A_SPELLMOD_FLAT, A_SPELLMOD_PCT, A_STEALTH, BEAST_TYPE, COMBAT_ATTACK_POWER, COMBAT_BLOCK, + COMBAT_CRIT, COMBAT_DEFENSE, COMBAT_DMG_DONE, COMBAT_DODGE, COMBAT_HIT, COMBAT_PARRY, + COMBAT_SPELL_POWER, COMBAT_THREAT, CRITTER_TYPE, DISARM_SWING_RETAINED_PCT, E_ADD_COMBO, + E_BLINK, E_CHARGE, E_CONVERT_RESOURCE, E_CREATE_ITEM, E_DAMAGE, E_DISMOUNT, E_DISPEL, + E_DISTRACT, E_DUEL, E_ENERGIZE, E_FEED_PET, E_FINISHER_DAMAGE, E_HEAL, E_HEAL_MAX_HEALTH, + E_INTERRUPT, E_JUDGEMENT, E_NEXT_SWING, E_PERSISTENT_AREA, E_PICKPOCKET, E_POWER_BURN, + E_RECALL_HOME, E_REDUCE_THREAT, E_RESURRECT, E_SCRIPTED, E_SELF_RESURRECT, E_SET_STANCE, + E_SUMMON_HOSTILE, E_SUMMON_PET, E_TAME_CREATURE, E_TAUNT, E_TRIGGER, E_WEAPON_STRIKE, + HUMANOID_TYPE, INT_PER_SPELL_POWER, INT_SPELL_POWER_BASE, KIND_AURA_BIT, M_FEAR, M_POLY, + M_ROOT, M_STUN, PROJECTILE_SPEED_YPS, P_DISPLAY_ID, P_ENTRY, P_GAMEOBJECT_ENTRY, P_MECHANIC, + P_PCT_MAX_POWER, P_SPEED_KIND, RESIST_ARMOR, SPEED_MOUNTED, SPEED_MOVE, SPEED_SWING, + SPELLMOD_OP_CASTING_TIME, SPELLMOD_OP_DAMAGE, SPELL_ATTR_CHANNELED, + SPELL_ATTR_FINISHER_DURATION, SPELL_ATTR_INCAP_OPENER, SPELL_ATTR_PASSIVE, + SPELL_ATTR_REQ_BEHIND, SPELL_ATTR_REQ_DAGGER, SPELL_ATTR_REQ_OVERPOWER, SPELL_ATTR_REQ_REVENGE, + SPELL_ATTR_REQ_STEALTH, SPELL_ATTR_STEALTH_SAFE, SPELL_POWER_COEFF_PCT, STANCE_BEAR, + STANCE_CAT, STANCE_DEFENSIVE, STANCE_DEFENSIVE_DMG_DONE_PCT, STANCE_DEFENSIVE_DR_PCT, + STANCE_DEFENSIVE_THREAT_PCT, STANCE_DIRE_BEAR, STAT_AGI, STAT_ALL, STAT_INT, STAT_SPI, + STAT_STA, STAT_STR, T_AREA_ALLY, T_AREA_ENEMY, T_CHAIN_ENEMY, T_SELF, T_TARGET_ALLY, + T_TARGET_ANY, T_TARGET_ENEMY, +}; // `stacking` is reached explicitly via `crate::spell::stacking::` (its tables' generated // accessor traits — `game_spell_group`/`game_spell_group_rule`/`game_dr_state` — collide-free but its // pure types (`ApplyDecision`, `AuraSummary`, ...) are deliberately NOT globbed into this module's flat // namespace, since `cast/targeting.rs`/`scheduler.rs`/`math.rs` reference them qualified for // readability at each of the few call sites). + +pub(crate) use tables::{sweep_delete_game_aura, sweep_transfer_game_aura}; + +pub(crate) use spellbook::{ + sweep_delete_game_player_spell, sweep_restamp_game_player_spell, + sweep_transfer_game_player_spell, +}; diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 100430422..0103653bb 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -16,7 +16,7 @@ use super::*; /// Scheduler-only. A caster or target that becomes ineligible during the cast produces a Refusal, /// which is logged and relayed as an interruption. The scheduler auto-deletes the one-shot row. #[reducer] -pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { +pub(crate) fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { if ctx.sender() != ctx.database_identity() { return; } diff --git a/module/src/spell/stacking.rs b/module/src/spell/stacking.rs index cb32233b7..40ee1d911 100644 --- a/module/src/spell/stacking.rs +++ b/module/src/spell/stacking.rs @@ -2,7 +2,7 @@ use spacetimedb::{log, table, ReducerContext, Table}; use crate::game_world_entity; // Aura/SpellEffect row types, the `game_aura`/`game_spell_chain` accessors, and the A_CONTROL/P_MECHANIC/ -// M_* taxonomy consts are all re-exported by `mod.rs` (`pub use tables::*` / `pub(crate) use taxonomy::*`). +// The parent module names the shared M_* taxonomy constants. use super::*; // =========================================================================================== diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index 018d578d9..ed7830238 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -1,7 +1,6 @@ //! Spell/aura/cooldown table structs (the [static] definition tables + the [event]/[entity]/[server] //! runtime tables). The `#[table]` macro generates the per-table accessor traits here; `mod.rs` -//! re-exports them (`pub use tables::*`) so every `crate::spell::` / `ctx.db.game_
()` -//! path resolves. +//! exports each accessor and row type by name. use spacetimedb::{table, Identity, ReducerContext, ScheduleAt, Table, Timestamp}; diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index b788a404c..1a4fbaba1 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -1,5 +1,5 @@ //! KINDS / MECHANICS / param-tag / target / combat-field / stat / resistance taxonomy + the -//! spell-power scaling tuning constants. `mod.rs` re-exports these (`pub(crate) use taxonomy::*`) +//! spell-power scaling tuning constants. `mod.rs` exports the constants it shares by name //! so `crate::spell::` resolves. // =========================================================================================== @@ -489,5 +489,5 @@ const _RESERVED_NON_KIND_TAXONOMY: &[u8] = &[ // (No `_MECHANICS` dead-code scaffold needed: all four mechanics are wired into real gates — M_STUN and // M_ROOT via their single-mechanic checkers `is_stunned`/`is_rooted`, and M_POLY/M_FEAR via the composite -// predicates `is_incapacitated`(M_POLY) / `is_feared`(M_FEAR), which the action/movement gates +// the stun/polymorph and fear predicates that the action/movement Gates // (`is_action_blocked`, `is_movement_blocked`, `is_self_movement_suppressed`) then compose.) diff --git a/module/src/stats.rs b/module/src/stats.rs index 20636fbfd..4779022da 100644 --- a/module/src/stats.rs +++ b/module/src/stats.rs @@ -48,11 +48,11 @@ pub struct LevelStats { } /// Lookup key for `game_class_level_stats`. -pub fn class_level_key(class: u8, level: u32) -> u32 { +pub(crate) fn class_level_key(class: u8, level: u32) -> u32 { ((class as u32) << 8) | (level & 0xFF) } /// Lookup key for `game_level_stats`. -pub fn race_class_level_key(race: u8, class: u8, level: u32) -> u32 { +pub(crate) fn race_class_level_key(race: u8, class: u8, level: u32) -> u32 { ((race as u32) << 16) | ((class as u32) << 8) | (level & 0xFF) } @@ -61,7 +61,7 @@ pub fn race_class_level_key(race: u8, class: u8, level: u32) -> u32 { // =========================================================================================== /// Vanilla health from stamina: the first 20 points give 1 HP each, every point beyond gives 10. -pub fn hp_from_stamina(stamina: u32) -> u32 { +pub(crate) fn hp_from_stamina(stamina: u32) -> u32 { if stamina <= 20 { stamina } else { @@ -70,7 +70,7 @@ pub fn hp_from_stamina(stamina: u32) -> u32 { } /// Vanilla mana from intellect: the first 20 points give 1 mana each, every point beyond gives 15. -pub fn mana_from_intellect(intellect: u32) -> u32 { +pub(crate) fn mana_from_intellect(intellect: u32) -> u32 { if intellect <= 20 { intellect } else { @@ -84,7 +84,7 @@ pub fn mana_from_intellect(intellect: u32) -> u32 { /// Max health for (race, class, level): class/level base HP + the stamina contribution. Falls back /// to the flat placeholder curve when the curve data isn't loaded, so login never breaks on a miss. -pub fn max_health_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> u32 { +pub(crate) fn max_health_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> u32 { let base = ctx .db .game_class_level_stats() @@ -104,7 +104,7 @@ pub fn max_health_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> /// Max power for (race, class, level), power-type-aware: mana classes get class/level base mana + the /// intellect contribution; **rage = 1000** (a 100-point bar stored ×10 — what the live slice uses); /// **energy = 100**. Falls back to the power-type constant (or 0 mana) when the curve isn't loaded. -pub fn max_power_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> u32 { +pub(crate) fn max_power_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> u32 { match power_type::for_class(class) { power_type::MANA => { let base = ctx @@ -131,7 +131,7 @@ pub fn max_power_for(ctx: &ReducerContext, race: u8, class: u8, level: u32) -> u /// energy classes (Rogue) also spawn full (energy is non-persisted in vanilla — always 100/100 at /// login), and rage starts at 0. Pure so it's unit-testable without a live DB. Lives next to /// `max_power_for`, which owns power-type semantics. -pub fn starting_power(pt: u8, max_power: u32) -> u32 { +pub(crate) fn starting_power(pt: u8, max_power: u32) -> u32 { if pt == power_type::MANA || pt == power_type::ENERGY { max_power } else { @@ -144,7 +144,7 @@ pub fn starting_power(pt: u8, max_power: u32) -> u32 { /// Falls back to all-zero when the row isn't loaded (the sheet then reads 0s until the importer /// runs — login is unaffected, and these are display-only state). Reads the SAME `game_level_stats` /// row `max_health_for`/`max_power_for` use, so the stamina/intellect stay consistent with HP/mana. -pub fn base_attributes_for( +pub(crate) fn base_attributes_for( ctx: &ReducerContext, race: u8, class: u8, diff --git a/module/src/tables.rs b/module/src/tables.rs new file mode 100644 index 000000000..69e9d7bf0 --- /dev/null +++ b/module/src/tables.rs @@ -0,0 +1,247 @@ +//! The table catalog: every core table accessor and row type, named by its owning family, plus a +//! row field type where a Package builds that row. The crate root re-exports this list for the +//! schema parity test and the Package API. + +pub use crate::account_ownership::{ + game_account_character_owner, game_account_claim, game_account_fence, AccountCharacterOwner, + AccountClaim, AccountFence, +}; +pub use crate::action_bar::{ + game_createinfo_action, game_player_action, CreateinfoAction, PlayerAction, +}; +pub use crate::auction::{ + game_auction, game_auction_bid_decision, game_auction_bid_hold, game_auction_expiry, + game_auction_hold, game_auction_house, game_auction_notice, game_auction_operation_receipt, + Auction, AuctionBidDecision, AuctionBidHold, AuctionExpiry, AuctionHold, + AuctionHouseDefinition, AuctionNotice, AuctionOperationReceipt, +}; +pub use crate::auth::{ + game_account, game_alpha_test_tools_enrollment, game_guid_allocator, game_guid_range, + game_operator, game_session, game_session_reaper_schedule, Account, AlphaTestToolsEnrollment, + GuidAllocator, GuidRange, Operator, Session, SessionReaperSchedule, +}; +pub use crate::away::{game_character_away, CharacterAway}; +pub use crate::breath::{game_breath_schedule, game_breath_state, BreathSchedule, BreathState}; +pub use crate::breath_relay::{game_breath_relay_event, BreathRelayEvent}; +pub use crate::bridge::{ + game_addon_message, game_party_command_dispatch_lane, game_party_command_intent, + game_party_command_issuer, game_party_command_receipt, AddonMessage, PartyCommandDispatchLane, + PartyCommandIntent, PartyCommandIssuer, PartyCommandReceipt, +}; +pub use crate::channel::{ + game_chat_channel, game_chat_channel_ban, game_chat_channel_member, + game_chat_channel_notice_event, ChatChannel, ChatChannelBan, ChatChannelMember, + ChatChannelNoticeEvent, +}; +pub use crate::character::{game_character, Character}; +pub use crate::chat::{ + game_channel_event, game_channel_member, game_character_contact, game_chat_event, + game_emote_event, game_roll_event, game_system_message_event, game_whisper_event, ChannelEvent, + ChannelMember, ChatEvent, ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, + WhisperEvent, +}; +pub use crate::combat::death::{game_creature_lethal_damage_floor, CreatureLethalDamageFloor}; +pub use crate::combat::{ + game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, + CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, +}; +pub use crate::combo::{game_combo_point, ComboPoint}; +pub use crate::config::{ + game_area, game_area_trigger, game_char_base_info, game_config, game_race_info, game_realm, + game_start_item, game_start_position, game_taxi_node, game_taxi_path, game_taxi_path_node, + CharBaseInfo, GameArea, GameAreaTrigger, GameTaxiNode, GameTaxiPath, GameTaxiPathNode, + RaceInfo, Realm, ServerConfig, StartItem, StartPosition, +}; +pub use crate::corpse::{game_corpse, Corpse}; +pub use crate::creatures::distraction::{game_creature_distraction, CreatureDistraction}; +pub use crate::creatures::{ + game_creature_ai_broadcast_text, game_creature_ai_definition, game_creature_ai_event, + game_creature_ai_forced_despawn, game_creature_ai_movement_intent, + game_creature_ai_movement_path_waypoint, game_creature_ai_relay_arrival, + game_creature_ai_relay_continuation, game_creature_ai_relay_definition, + game_creature_ai_relay_run, game_creature_ai_reset_deferral, game_creature_ai_returning_home, + game_creature_ai_rule_state, game_creature_ai_spell_metadata, game_creature_ai_state, + game_creature_ai_summon, game_creature_ai_summon_expiry, game_creature_ai_summon_origin, + game_creature_cast, game_creature_family, game_creature_gossip_menu_override, + game_creature_move_event, game_creature_move_schedule, game_creature_presentation, + game_creature_relay_temporary_faction, game_creature_spawn, game_creature_spell, + game_creature_spline, game_creature_template, game_creature_waypoint, game_gossip_menu, + game_gossip_menu_profile, game_gossip_menu_profile_option, game_gossip_option, game_hunter_pet, + game_hunter_pet_protocol, game_live_pet_kind, game_npc_text, game_npc_text_slot, + game_pet_care_schedule, game_pet_command, CreatureAiBroadcastText, CreatureAiDefinition, + CreatureAiEvent, CreatureAiForcedDespawn, CreatureAiMovementIntent, + CreatureAiMovementPathWaypoint, CreatureAiResetDeferral, CreatureAiReturningHome, + CreatureAiRuleState, CreatureAiSpellMetadata, CreatureAiState, CreatureAiSummon, + CreatureAiSummonExpiry, CreatureAiSummonOrigin, CreatureCast, CreatureFamily, + CreatureGossipMenuOverride, CreatureMoveEvent, CreatureMoveSchedule, CreaturePresentation, + CreatureRelayTemporaryFaction, CreatureSpawn, CreatureSpell, CreatureSpline, CreatureTemplate, + CreatureWaypoint, GossipMenu, GossipMenuProfile, GossipMenuProfileOption, GossipOption, + HunterPet, HunterPetProtocol, LivePetKind, NpcText, NpcTextSlot, PetCareSchedule, PetCommand, + RelayArrival, RelayContinuation, RelayDefinition, RelayRun, +}; +#[cfg(feature = "debug_reducers")] +pub use crate::debug::{ + game_catalogue_fingerprint, game_debug_readout, CatalogueFingerprint, DebugReadout, +}; +pub use crate::duel::{ + game_duel, game_duel_event, game_duel_schedule, Duel, DuelEvent, DuelSchedule, +}; +pub use crate::encounter::{ + game_encounter_equip, game_encounter_hp_watch, game_encounter_spawn, game_encounter_state, + EncounterEquip, EncounterHpWatch, EncounterSpawn, EncounterState, +}; +pub use crate::exploration::{game_character_explored, CharacterExplored}; +pub use crate::faction::{game_faction, game_faction_template, Faction, FactionTemplate}; +pub use crate::gameobject::{ + game_gameobject, game_gameobject_pool, game_gameobject_pool_member, game_gameobject_template, + game_gameobject_trap, game_gameobject_trap_cooldown, game_gameobject_unlocked, game_lock, + GameLock, GameObject, GameObjectPool, GameObjectPoolMember, GameObjectTemplate, GameObjectTrap, + GameObjectTrapCooldown, GameObjectUnlocked, +}; +pub use crate::gc::{game_event_reaper_schedule, EventReaperSchedule}; +pub use crate::gm::{game_world_config, GmWorldConfig}; +pub use crate::go_collider::{game_go_collider, GoCollider}; +pub use crate::go_model::{game_go_model, GoModel}; +pub use crate::graveyard::{game_graveyard, game_graveyard_zone, GraveyardLoc, GraveyardZone}; +pub use crate::group::{ + game_bot_invite_intent, game_group, game_group_event, game_group_invite, game_group_member, + game_group_member_partition, game_group_roster_revision, game_group_target_icon, + BotInviteIntent, Group, GroupEvent, GroupInvite, GroupMember, GroupMemberPartition, + GroupRosterRevision, GroupTargetIcon, PartyPartitionState, +}; +pub use crate::guild::fee::{ + game_guild_fee_decision, game_guild_fee_hold, GuildFeeDecision, GuildFeeHold, +}; +pub use crate::guild::membership::{game_guild_invite, GuildInvite}; +pub use crate::guild::petition::{ + game_guild_petition, game_guild_petition_signature, GuildPetition, GuildPetitionSignature, +}; +pub use crate::guild::{ + game_guild, game_guild_event, game_guild_member, game_guild_rank, Guild, GuildEvent, + GuildMember, GuildRank, +}; +pub use crate::gw::{ + game_gateway_lease, game_gateway_lease_reaper_schedule, game_gateway_session, GatewayLease, + GatewayLeaseReaperSchedule, GatewaySession, +}; +pub use crate::import_meta::{game_import_meta, ImportMeta}; +pub use crate::instance::{ + game_instance, game_instance_binding, game_instance_reaper_schedule, game_instance_removal, + GameInstance, GameInstanceBinding, InstanceReaperSchedule, InstanceRemoval, +}; +pub use crate::items::{ + game_character_buyback, game_item_enchantment, game_item_instance, game_item_property_weight, + game_item_random_property, game_item_template, game_npc_vendor, BuybackEntry, ItemEnchantment, + ItemInstance, ItemPropertyWeight, ItemRandomProperty, ItemTemplate, NpcVendor, +}; +pub use crate::load::{ + game_region_load, game_shard_load, game_shard_load_total, RegionLoad, ShardLoad, ShardLoadTotal, +}; +pub use crate::loot::{ + game_corpse_loot, game_corpse_loot_eligible, game_creature_loot, game_creature_loot_tag_group, + game_creature_quest_tap, game_creature_quest_tap_member, game_fishing_loot, + game_gameobject_loot, game_loot_roll, game_loot_roll_promotion_receipt, game_loot_roll_vote, + game_pickpocket_loot, game_skinning_loot, CorpseLoot, CorpseLootEligible, CreatureLoot, + CreatureLootTagGroup, CreatureQuestTap, CreatureQuestTapMember, GameFishingLoot, + GameObjectLoot, GamePickpocketLoot, GameSkinningLoot, LootRoll, LootRollPromotionReceipt, + LootRollVote, +}; +pub use crate::mail::{game_mail, Mail}; +pub use crate::mail_catalogue::{ + game_mail_loot, game_mail_template, game_quest_reward_mail, MailLoot, MailTemplate, + QuestRewardMail, +}; +pub use crate::mail_escrow::{ + game_mail_delivery, game_mail_escrow, game_mail_escrow_reaper_schedule, MailDelivery, + MailEscrow, MailEscrowReaperSchedule, +}; +pub use crate::mail_text::{game_item_text, ItemText}; +pub use crate::mail_timer::{game_mail_arrival, game_mail_timer, MailArrival, MailTimer}; +pub use crate::meeting_stone::{ + game_meeting_stone, game_meeting_stone_party, game_meeting_stone_reminder_schedule, + game_meeting_stone_seeker, MeetingStone, MeetingStoneParty, MeetingStoneReminderSchedule, + MeetingStoneSeeker, +}; +pub use crate::motion::{ + game_entity_motion_pending, game_motion_publish_schedule, MotionPublishSchedule, PendingMotion, +}; +pub use crate::nav::{game_nav_chunk, game_navigation_revision, NavChunk, NavigationRevision}; +pub use crate::package_account::{game_package_account, PackageAccount}; +pub use crate::package_config::{game_package_config, PackageConfig}; +pub use crate::package_import::{game_package_import, PackageImport}; +pub use crate::package_teardown::{game_package_teardown, PackageTeardown}; +pub use crate::quest::{ + game_areatrigger_teleport, game_character_quest, game_character_quest_event_credit, + game_creature_quest, game_gameobject_quest, game_quest_cast_objective, + game_quest_event_requirement, game_quest_objective, game_quest_reward_choice, + game_quest_reward_item, game_quest_reward_spell, game_quest_template, game_quest_text, + AreatriggerTeleport, CharacterQuest, CharacterQuestEventCredit, CreatureQuest, GameObjectQuest, + QuestCastObjective, QuestEventRequirement, QuestObjective, QuestRewardChoice, QuestRewardItem, + QuestRewardSpell, QuestTemplate, QuestText, +}; +pub use crate::realm_chat::{game_realm_chat_event, RealmChatEvent}; +pub use crate::realm_core::{ + game_character_shard, game_guid_range_registry, CharacterShard, GuidRangeAssignment, +}; +pub use crate::region::{game_map_region, game_region_assignment, MapRegion, RegionAssignment}; +pub use crate::reputation::{game_player_reputation, PlayerReputation}; +pub use crate::rest::{game_rest_state_event, RestStateEvent}; +pub use crate::script_binding::{game_script, Script}; +pub use crate::sessionless::{game_sessionless_action_consent, SessionlessActionConsent}; +pub use crate::skill::{game_player_skill, PlayerSkill}; +pub use crate::skilldata::{ + game_skill_ability, game_skill_availability, game_skill_line, SkillAbility, SkillAvailability, + SkillLine, +}; +pub use crate::spell::cast::resolve::{ + game_creature_dead_callback_cast_admission, CreatureDeadCallbackCastAdmission, +}; +pub use crate::spell::stacking::{ + game_dr_state, game_spell_group, game_spell_group_rule, DrState, SpellGroup, SpellGroupRule, +}; +pub use crate::spell::{ + game_aura, game_aura_schedule, game_createinfo_spell, game_dynamic_object, game_ground_area, + game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, game_player_spell, + game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, + game_spell_cast_event, game_spell_cd, game_spell_chain, game_spell_cooldown, game_spell_effect, + game_spell_impact_event, game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, + AuraSchedule, CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, + PendingSpellImpact, PlayerSpell, ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, + SpellCastEvent, SpellCd, SpellChain, SpellCooldown, SpellEffect, SpellImpactEvent, SpellLearn, + SpellProcEvent, SpellReagent, +}; +pub use crate::stats::{game_class_level_stats, game_level_stats, ClassLevelStats, LevelStats}; +pub use crate::talent::{ + game_character_talent, game_talent, game_talent_tab, CharacterTalent, Talent, TalentTab, +}; +pub use crate::taxi::{ + game_active_taxi_flight, game_character_taxi_node, game_taxi_flight_schedule, + game_taxi_passenger_spline, game_taxi_service_reply, ActiveTaxiFlight, CharacterTaxiNode, + TaxiFlightSchedule, TaxiPassengerSpline, TaxiServiceReply, +}; +pub use crate::terrain::{game_terrain_chunk, TerrainChunk}; +pub use crate::threat::{game_taunt_lock, game_threat, TauntLock, ThreatEntry}; +pub use crate::trade::{ + game_trade_event, game_trade_session, game_trade_slot, TradeEvent, TradeSession, TradeSlot, +}; +pub use crate::trainer::{game_trainer_spell, TrainerSpell}; +pub use crate::transfer::{ + game_bot_transfer_intent, game_transfer_in, game_transfer_out, game_transfer_reaper_schedule, + BotTransferIntent, TransferIn, TransferOut, TransferReaperSchedule, +}; +pub use crate::vmap::{ + game_vmap_chunk, game_vmap_generation, game_vmap_generation_chunk, + game_vmap_generation_receipt, game_vmap_indoor_cell, game_vmap_nav_coverage, + game_vmap_nav_coverage_manifest, VmapChunk, VmapGeneration, VmapGenerationChunk, + VmapGenerationReceipt, VmapIndoorCell, VmapNavCoverage, VmapNavCoverageManifest, +}; +pub use crate::weather::{ + game_weather, game_weather_schedule, game_zone_weather, WeatherSchedule, ZoneWeather, + ZoneWeatherChance, +}; +pub use crate::world::{ + game_entity_motion, game_movement_violation, game_teleport_event, game_world_entity, + game_world_state, game_world_state_name, EntityMotion, MovementViolation, TeleportEvent, + WorldEntity, WorldState, WorldStateName, +}; +pub use crate::xp::{game_levelup_event, game_xp_event, LevelupEvent, XpEvent}; diff --git a/module/src/talent.rs b/module/src/talent.rs index fcf9b229c..06abe2e9a 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -32,7 +32,7 @@ pub struct Talent { pub max_rank: u8, pub spell_id: u32, // PASSIVE aura spell (0 = none); applied with points = rank * base_points pub required_talent_id: u32, // 0 = none; else the prereq talent must be at rank >= 1 - pub required_points_in_tree: u32, // tier gate (tier * POINTS_PER_TIER); 0 for tier 0 + pub required_points_in_tree: u32, // tier Gate, five points per tier; 0 for tier 0 /// An ABILITY this talent TEACHES at rank 1 (0 = none) — a learnable active spell added to the player's /// spellbook (`spell::learn_spell`). For an ability talent (Death Wish, …) `spell_id` (the passive) is 0 /// and this is the granted active. END-appended `#[default(0)]` → auto-migrates. [static] @@ -137,12 +137,10 @@ crate::character_owned!(restamp, fn sweep_restamp_game_character_talent(ctx, cha /// The character level at which the FIRST talent point is granted (vanilla: level 10). pub const TALENT_START_LEVEL: u32 = 10; -/// Points that must be spent in a tree to unlock each next tier (vanilla: 5 per tier row). -pub const POINTS_PER_TIER: u32 = 5; /// Talent points AVAILABLE to spend: `(level - 9)` earned (1/level from L10, 51 at L60) minus the points /// already `spent`, saturating at 0. Pure — unit-tested. -pub fn talent_points_available(level: u32, spent: u32) -> u32 { +pub(crate) fn talent_points_available(level: u32, spent: u32) -> u32 { level .saturating_sub(TALENT_START_LEVEL - 1) .saturating_sub(spent) @@ -151,7 +149,7 @@ pub fn talent_points_available(level: u32, spent: u32) -> u32 { /// Validate a learn of a talent: returns the NEXT rank on success, else a distinguished error. Gates, in /// order: a point must be available; the talent must be below max rank; the tier must be unlocked (enough /// points spent in that tree); the prerequisite (if any) must be met. Pure — unit-tested. -pub fn validate_learn( +pub(crate) fn validate_learn( current_rank: u8, max_rank: u8, available: u32, @@ -180,7 +178,7 @@ pub fn validate_learn( /// every demo-seeded talent (which never set a rank requirement) AND for a genuine Talent.dbc prereq that /// only requires the SAME-tab talent below it at rank 1 — so a real import behaves exactly like the /// pre-import demo gate unless the DBC row explicitly requires a higher rank. Pure — unit-tested. -pub fn prereq_satisfied( +pub(crate) fn prereq_satisfied( required_talent_id: u32, learned_prereq_rank: u8, required_talent_rank: u8, @@ -605,7 +603,7 @@ fn apply_talent_rank( /// Re-apply every learned passive talent's aura to `guid` at login (mirrors `ensure_player_skills`). Each /// learned talent's spell is applied at its current rank; idempotent (aura_apply refreshes by effect_id), /// so a relog never stacks. A character with no learned talents applies nothing (baseline-safe). [entity] -pub fn apply_learned_talents(ctx: &ReducerContext, guid: u64, owner: Identity, level: u32) { +pub(crate) fn apply_learned_talents(ctx: &ReducerContext, guid: u64, owner: Identity, level: u32) { let talents = ctx.db.game_talent(); let learned: Vec<(u32, u8)> = ctx .db diff --git a/module/src/taxi.rs b/module/src/taxi.rs index eae6d50ba..2c301a7b4 100644 --- a/module/src/taxi.rs +++ b/module/src/taxi.rs @@ -930,7 +930,7 @@ fn activation_reply( #[spacetimedb::reducer] pub fn gw_taxi_node_status( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, npc_guid: u64, request_id: u64, ) -> Result<(), String> { @@ -968,7 +968,7 @@ pub fn gw_taxi_node_status( #[spacetimedb::reducer] pub fn gw_open_taxi( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, npc_guid: u64, request_id: u64, ) -> Result<(), String> { @@ -1010,7 +1010,7 @@ pub fn gw_open_taxi( #[spacetimedb::reducer] pub fn gw_activate_taxi( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, npc_guid: u64, source_client_node_id: u32, destination_client_node_id: u32, @@ -1134,7 +1134,7 @@ pub(crate) fn arm_taxi_flight(ctx: &ReducerContext, character_guid: u64) { #[spacetimedb::reducer] pub fn gw_arm_taxi_flight( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; let character_guid = crate::account_ownership::require_actor(ctx, request_actor)?; @@ -1227,7 +1227,7 @@ pub fn advance_taxi_flight(ctx: &ReducerContext, schedule: TaxiFlightSchedule) { #[spacetimedb::reducer] pub fn gw_ack_taxi_reply( ctx: &ReducerContext, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, request_id: u64, ) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/terrain.rs b/module/src/terrain.rs index a952bead0..4eb59e91b 100644 --- a/module/src/terrain.rs +++ b/module/src/terrain.rs @@ -38,7 +38,7 @@ pub struct TerrainChunk { /// arithmetic. This sits in the 0.5s movement tick, so it must never scan. The cell math + bilinear interpolation live in /// `lyracore_shared::terrain` (unit-tested there; the importer's self-check shares the SAME code, /// so import-time verification covers this exact function). -pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { +pub(crate) fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); let chunk = ctx .db @@ -57,7 +57,7 @@ pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option Option { +pub(crate) fn area_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); let chunk = ctx .db @@ -110,7 +110,7 @@ pub fn area_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option Option { +pub(crate) fn liquid_level_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); let chunk = ctx .db @@ -155,7 +155,7 @@ pub fn debug_check_submerged(ctx: &ReducerContext, guid: u64) -> Result<(), Stri /// /// The single canonical zone resolver: `world::graveyard`'s release pick /// and `loot::apply_fish`'s catch roll both call this instead of each keeping its own one-hop walk. -pub fn zone_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { +pub(crate) fn zone_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { area_at(ctx, map_id, x, y).map(|area| zone_of(&area)) } @@ -163,7 +163,12 @@ pub fn zone_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option Option { +pub(crate) fn area_at( + ctx: &ReducerContext, + map_id: u32, + x: f32, + y: f32, +) -> Option { if ctx.db.game_area().count() == 0 { return None; } @@ -174,7 +179,7 @@ pub fn area_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option u32 { +pub(crate) fn zone_of(area: &crate::GameArea) -> u32 { if area.parent_area_id != 0 { area.parent_area_id } else { diff --git a/module/src/threat.rs b/module/src/threat.rs index beb2c2976..bbc419c94 100644 --- a/module/src/threat.rs +++ b/module/src/threat.rs @@ -104,7 +104,7 @@ pub const HEAL_THREAT_PERMILLE: i64 = 500; // =========================================================================================== /// Threat generated by `damage` points of damage: `damage * DAMAGE_THREAT_PERMILLE / 1000`. Pure. -pub fn threat_from_damage(damage: u32) -> i64 { +pub(crate) fn threat_from_damage(damage: u32) -> i64 { damage as i64 * DAMAGE_THREAT_PERMILLE / 1000 } @@ -112,7 +112,7 @@ pub fn threat_from_damage(damage: u32) -> i64 { /// split evenly (vanilla halves the heal and divides it among the mobs in combat with the healed unit): /// `effective_heal * HEAL_THREAT_PERMILLE / 1000 / mob_count`, integer-floored. `mob_count == 0` → 0 (no /// one is fighting the healed unit, so a heal aggroes nothing). A single mob gets the full 0.5×. Pure. -pub fn threat_from_heal_each(effective_heal: u32, mob_count: u32) -> i64 { +pub(crate) fn threat_from_heal_each(effective_heal: u32, mob_count: u32) -> i64 { if mob_count == 0 { return 0; } @@ -122,7 +122,7 @@ pub fn threat_from_heal_each(effective_heal: u32, mob_count: u32) -> i64 { /// A threat row's value after a Feint-style one-time reduction: `(current - amount)`, FLOORED at 0 so a /// reduction larger than the current threat empties the row rather than going negative. A non-positive /// `amount` is a no-op (returns `current`) — Feint never ADDS threat. Pure — unit-tested. -pub fn threat_after_reduction(current: i64, amount: i64) -> i64 { +pub(crate) fn threat_after_reduction(current: i64, amount: i64) -> i64 { if amount <= 0 { return current; } @@ -132,7 +132,7 @@ pub fn threat_after_reduction(current: i64, amount: i64) -> i64 { /// Scale a nonnegative threat value by `100 + percent`, rounded down to the next whole threat /// point. The wide intermediate makes the result stable at the `i64` boundary. A `-100` percent /// scale returns zero; callers retain the row so a later source action can observe it. -pub fn threat_after_percent_scale(current: i64, percent: i32) -> i64 { +pub(crate) fn threat_after_percent_scale(current: i64, percent: i32) -> i64 { let multiplier = i64::from(percent).saturating_add(100).max(0); let scaled = i128::from(current.max(0)) * i128::from(multiplier) / 100; scaled.min(i128::from(i64::MAX)) as i64 @@ -145,7 +145,7 @@ pub fn threat_after_percent_scale(current: i64, percent: i32) -> i64 { /// Add `amount` threat from `source_guid` to `creature_guid`'s table — UPSERT: bump the existing /// `(creature, source)` row, else insert a fresh one. A non-positive `amount` (a 0-damage swing, a /// fully-absorbed hit, an overheal) is a NO-OP so the table is never polluted with zero rows. [entity] -pub fn add_threat(ctx: &ReducerContext, creature_guid: u64, source_guid: u64, amount: i64) { +pub(crate) fn add_threat(ctx: &ReducerContext, creature_guid: u64, source_guid: u64, amount: i64) { // A NEGATIVE amount is never legitimate here (threat sources only ADD) — it signals an upstream // calculation bug, so surface it rather than swallowing it silently. Zero is a legitimate no-op // (a 0-damage swing / fully-absorbed hit), so it stays quiet. @@ -199,7 +199,7 @@ pub fn add_threat(ctx: &ReducerContext, creature_guid: u64, source_guid: u64, am /// Apply one percent scale to `operation.source_guid`'s existing threat row. A source absent from /// the table stays absent. A zero result remains an entry, matching a percent change rather than a /// threat clear. -pub fn scale_selected_threat(ctx: &ReducerContext, operation: ScaleSelectedThreat) { +pub(crate) fn scale_selected_threat(ctx: &ReducerContext, operation: ScaleSelectedThreat) { let threats = ctx.db.game_threat(); if let Some(mut row) = threats .by_creature() @@ -214,7 +214,7 @@ pub fn scale_selected_threat(ctx: &ReducerContext, operation: ScaleSelectedThrea /// Apply one percent scale to every existing threat row on `operation.creature_guid`. Rows stay in /// place when they reach zero, and each row is collected before an update so the table is never /// changed while it is read. -pub fn scale_all_threat(ctx: &ReducerContext, operation: ScaleAllThreat) { +pub(crate) fn scale_all_threat(ctx: &ReducerContext, operation: ScaleAllThreat) { let threats = ctx.db.game_threat(); let rows: Vec = threats .by_creature() @@ -231,7 +231,7 @@ pub fn scale_all_threat(ctx: &ReducerContext, operation: ScaleAllThreat) { /// `source_guid`'s current threat on `creature_guid`'s table (0 when absent — the retarget hysteresis /// reads this for the CURRENT target, which may not be on the table after a pure proximity pull). [entity] -pub fn threat_of(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) -> i64 { +pub(crate) fn threat_of(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) -> i64 { ctx.db .game_threat() .by_creature() @@ -247,7 +247,7 @@ pub fn threat_of(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) -> /// as the aggro target (their stale row lingers until `clear_for_unit`, but is ignored here). Ties break /// toward the LOWEST `source_guid` (deterministic); the retarget caller applies hysteresis (switches only /// on a STRICTLY higher threat than the current target), so a tie never flaps the target. [entity] -pub fn top_threat_target(ctx: &ReducerContext, creature_guid: u64) -> Option { +pub(crate) fn top_threat_target(ctx: &ReducerContext, creature_guid: u64) -> Option { let entities = ctx.db.game_world_entity(); let creature = entities.guid().find(creature_guid)?; let (creature_map, creature_instance) = (creature.map_id, creature.instance_id); @@ -326,7 +326,7 @@ pub(crate) fn party_threat_facts( /// at/above the new top it adds nothing. Also arms (or refreshes) the `TAUNT_FORCED_MS` forced-target /// window: the retarget pass asks `forced_target` FIRST, so the creature stays pinned on the taunter /// regardless of threat until the window expires — the threat top governs what happens AFTER. [entity] -pub fn taunt(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) { +pub(crate) fn taunt(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) { let max = ctx .db .game_threat() @@ -365,7 +365,7 @@ pub fn taunt(ctx: &ReducerContext, creature_guid: u64, source_guid: u64) { /// read side in one place: validity uses the SAME predicate as `top_threat_target`'s source /// check, and an expired/invalid lock is LAZILY REAPED here, so no caller ever sees a stale row. /// The retarget pass consults this BEFORE the threat compare. [entity] -pub fn forced_target(ctx: &ReducerContext, creature_guid: u64, now_ms: u64) -> Option { +pub(crate) fn forced_target(ctx: &ReducerContext, creature_guid: u64, now_ms: u64) -> Option { let locks = ctx.db.game_taunt_lock(); let lock = locks.creature_guid().find(creature_guid)?; let entities = ctx.db.game_world_entity(); @@ -385,7 +385,7 @@ pub fn forced_target(ctx: &ReducerContext, creature_guid: u64, now_ms: u64) -> O /// Drop `creature_guid`'s taunt lock unconditionally — the death/teardown path (a dead creature's /// lock is meaningless). No-op when absent. [entity] -pub fn clear_taunt_lock(ctx: &ReducerContext, creature_guid: u64) { +pub(crate) fn clear_taunt_lock(ctx: &ReducerContext, creature_guid: u64) { ctx.db .game_taunt_lock() .creature_guid() @@ -397,7 +397,7 @@ pub fn clear_taunt_lock(ctx: &ReducerContext, creature_guid: u64) { /// those creatures — the "healing pulls aggro" mechanic. No-op when the healer isn't a player (a creature /// self-heal doesn't drive player-threat semantics), when no creature is fighting the healed unit, or when /// the effective heal is 0 (overheal). Collect-then-add (no table write while iterating). [entity] -pub fn add_heal_threat( +pub(crate) fn add_heal_threat( ctx: &ReducerContext, healer_guid: u64, healed_guid: u64, @@ -439,7 +439,7 @@ pub fn add_heal_threat( /// logging out). Folded into `combat::disengage`, so "leave combat" clears threat symmetrically in ONE /// place (a creature that evades/flees/dies drops its memory; a player who dies/logs is forgotten by every /// mob). Collect-then-delete (never mutate while iterating). [entity] -pub fn clear_for_unit(ctx: &ReducerContext, guid: u64) { +pub(crate) fn clear_for_unit(ctx: &ReducerContext, guid: u64) { let threats = ctx.db.game_threat(); // Perf catalog 1.17: two indexed collects instead of one full scan. A row can only appear in both // halves if a unit were a source on its OWN table (self-threat, which nothing generates); the @@ -457,7 +457,7 @@ pub fn clear_for_unit(ctx: &ReducerContext, guid: u64) { /// Wipe the entire threat table — used by the creature-roster re-import (`import_creature_spawns`), which /// deletes every creature entity, so their threat tables would otherwise orphan. Collect-then-delete. -pub fn clear_all(ctx: &ReducerContext) { +pub(crate) fn clear_all(ctx: &ReducerContext) { let threats = ctx.db.game_threat(); let all: Vec = threats.iter().map(|r| r.id).collect(); for id in all { @@ -470,7 +470,7 @@ pub fn clear_all(ctx: &ReducerContext) { /// re-points each mob at its new top-threat source (the tank), with zero retarget-code changes. DISTINCT /// from Fade (an `A_MOD_COMBAT(COMBAT_THREAT)` percent on FUTURE threat folded inside `add_threat`); this /// is a one-shot subtraction on the EXISTING rows. A non-positive `amount` is a no-op (never ADDS threat). -pub fn reduce_threat_from_source(ctx: &ReducerContext, source_guid: u64, amount: i64) { +pub(crate) fn reduce_threat_from_source(ctx: &ReducerContext, source_guid: u64, amount: i64) { if amount <= 0 { return; } diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index a60cd012c..40107bfa9 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1,7 +1,16 @@ //! Character Transfer. See `docs/character-transfer.md` for the contract and rationale. mod transport; -pub(crate) use transport::*; +#[cfg(test)] +pub(crate) use transport::{ + admit_command_issuer_import, decode_rows, encode_rows, export_rows_via, import_rows_via, + manifest, CommandIssuerImportRefusal, ManifestEntry, TransportArm, HOT_TABLES, + MANIFEST_EXCLUDE, NOT_TRANSPORTED, TRANSFER_TABLES_OLD_BLOBS_MAY_LACK, +}; +pub(crate) use transport::{ + build_export_blob, check_manifest, decode_blob, decode_character_row, export_rows, import_rows, + move_rows, not_transported, payload_for_this_build, Destination, ExportBlob, RowIo, TableRows, +}; #[cfg(test)] mod harness; @@ -1068,7 +1077,7 @@ impl ReapSink for CtxShard<'_> { pub fn begin_transfer( ctx: &ReducerContext, transfer_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, dest_map_id: u32, dest_instance_id: u64, dest_x: f32, @@ -1270,7 +1279,7 @@ pub fn import_character(ctx: &ReducerContext, transfer_id: u64) -> Result<(), St fn require_transfer_actor( ctx: &ReducerContext, transfer_id: u64, - actor: crate::SessionActor, + actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { crate::account_ownership::require_actor(ctx, actor)?; let character = ctx @@ -1310,7 +1319,7 @@ pub fn import_character_blob( ctx: &ReducerContext, transfer_id: u64, blob: Vec, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1331,7 +1340,7 @@ pub fn import_player_character_blob( source_map_id: u32, source_instance_id: u64, source_locator_revision: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1383,7 +1392,7 @@ pub fn import_bot_character_blob( source_map_id: u32, source_instance_id: u64, source_locator_revision: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1589,7 +1598,7 @@ pub(crate) fn apply_import_blob( pub fn confirm_import( ctx: &ReducerContext, transfer_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1650,7 +1659,7 @@ pub(crate) fn apply_confirm(sink: &mut S, transfer_id: u64) -> R pub fn release_transfer( ctx: &ReducerContext, transfer_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1678,7 +1687,7 @@ pub fn release_player_transfer_arrival( source_map_id: u32, source_instance_id: u64, source_locator_revision: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; @@ -1788,7 +1797,7 @@ pub(crate) fn apply_release(sink: &mut S, transfer_id: u64) -> R pub fn finish_transfer( ctx: &ReducerContext, transfer_id: u64, - request_actor: crate::SessionActor, + request_actor: crate::account_ownership::SessionActor, ) -> Result<(), String> { require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 73871f110..641dcbf4d 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1045,6 +1045,77 @@ mod partition_discipline_tripwire { out } + fn shard_id_reads_in_file(path: &str, content: &str) -> Vec<(usize, &'static str)> { + if !matches!(path, "module/src/lib.rs" | "module/src/tables.rs") { + return shard_id_reads(content); + } + // Named table reexports declare the schema; they do not read its rows. + let code = super::character_owned_tripwire::production_code(content); + let mut masked = code.as_bytes().to_vec(); + let mut depth = 0usize; + let mut i = 0; + while i < code.len() { + if depth == 0 && code[i..].starts_with("pub use ") { + if let Some(end) = code[i..].find(';').map(|end| i + end + 1) { + let declaration = &code[i + 8..end - 1]; + let tokens: Vec<_> = declaration + .split(|c: char| !c.is_alphanumeric() && c != '_') + .filter(|token| !token.is_empty()) + .collect(); + if matches!(tokens.first(), Some(&"crate" | &"tables")) + && !tokens.contains(&"as") + && !declaration.contains('*') + { + for byte in &mut masked[i..end] { + if *byte != b'\n' { + *byte = b' '; + } + } + i = end; + continue; + } + } + } + match code.as_bytes()[i] { + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), + _ => {} + } + i += code[i..].chars().next().unwrap().len_utf8(); + } + shard_id_reads(std::str::from_utf8(&masked).unwrap()) + } + + #[test] + fn table_catalog_reexports_do_not_hide_shard_reads() { + let source = "pub use crate::region::{game_region_assignment, RegionAssignment};\n\ + fn f(row: Row) { let RegionAssignment { shard, .. } = row; row.shard; }\n"; + for catalog in ["module/src/lib.rs", "module/src/tables.rs"] { + assert_eq!( + shard_id_reads_in_file(catalog, source), + vec![(2, ".shard"), (2, "RegionAssignment")] + ); + for declaration in [ + "pub use crate::region::RegionAssignment as Assignment;", + "fn f() { pub use crate::region::RegionAssignment; }", + ] { + assert_eq!( + shard_id_reads_in_file(catalog, declaration), + vec![(1, "RegionAssignment")] + ); + } + } + assert_eq!( + shard_id_reads_in_file("module/src/quest.rs", source), + vec![ + (1, "RegionAssignment"), + (1, "game_region_assignment"), + (2, ".shard"), + (2, "RegionAssignment"), + ] + ); + } + #[test] fn the_scan_finds_a_shard_field_read_but_not_prose_or_longer_names() { let src = "fn f(ctx: &ReducerContext, row: Row) {\n if row.shard == DB {}\n \ @@ -1092,7 +1163,7 @@ mod partition_discipline_tripwire { } let content = std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("cannot read {rel}: {e}")); - for (line, form) in shard_id_reads(&content) { + for (line, form) in shard_id_reads_in_file(&rel, &content) { violations.push(format!("{rel}:{line} names a shard id (`{form}`)")); } } diff --git a/module/src/vmap.rs b/module/src/vmap.rs index 5a6012e47..a5915abc3 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -680,7 +680,7 @@ pub fn finalize_vmap_nav_coverage(ctx: &ReducerContext, generation_id: u64) -> R /// generation selects none, and `nav.rs::fetcher` degrades to terrain-only exactly as if coverage /// had never been prepared. Re-evaluated on every call rather than snapshotted at activation time, /// because preparation can retrofit an already active generation. -pub fn active_vmap_nav_coverage_manifest( +pub(crate) fn active_vmap_nav_coverage_manifest( ctx: &ReducerContext, map_id: u32, ) -> Option { @@ -767,7 +767,7 @@ pub(crate) fn active_generation_id(ctx: &ReducerContext, map_id: u32) -> Option< /// Static geometry consumes only with the global Gate and a complete active generation. /// This decides whether navigation still needs its coarse grid fallback. -pub fn vmap_enabled(ctx: &ReducerContext, map_id: u32) -> bool { +pub(crate) fn vmap_enabled(ctx: &ReducerContext, map_id: u32) -> bool { rays_enabled(ctx) && active_generation_id(ctx, map_id).is_some() } @@ -808,7 +808,7 @@ fn fetcher(ctx: &ReducerContext, map_id: u32) -> impl FnMut(u16, u16) -> Option< } /// Exact sight includes WMO geometry and closed doors in the requested instance. -pub fn los_ray( +pub(crate) fn los_ray( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -821,7 +821,7 @@ pub fn los_ray( } /// Exact collision includes WMO, static doodads and closed doors. -pub fn collision_ray( +pub(crate) fn collision_ray( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -862,7 +862,8 @@ fn ray( } /// Operator geometry probe, independent of the gameplay Gate. -pub fn probe_rays( +#[cfg(feature = "debug_reducers")] +pub(crate) fn probe_rays( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -883,7 +884,7 @@ const FLOOR_PROBE_DOWN_YD: f32 = 200.0; /// Highest static or closed-door surface in the vertical probe segment in this partition. /// The segment starts slightly above `probe_z` so a point already on the surface can find it. /// Returns `None` when the gameplay Gate is off or no collision triangle lies in that segment. -pub fn floor_z( +pub(crate) fn floor_z( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -920,7 +921,8 @@ pub(crate) fn walking_floor_z( } /// Static and closed-door floor probe, independent of the gameplay Gate. -pub fn probe_floor_z( +#[cfg(feature = "debug_reducers")] +pub(crate) fn probe_floor_z( ctx: &ReducerContext, map_id: u32, instance_id: u64, @@ -947,7 +949,7 @@ pub fn probe_floor_z( /// The downward budget is `AREA_PROBE_DOWN_YD`, NOT the 200 yd `FLOOR_PROBE_DOWN_YD`: a floor /// probe wants the deepest deck it can reach, an area query wants only the surface this point is /// standing on, and the long budget reports a point in the open as inside a WMO far below it. -pub fn area_info( +pub(crate) fn area_info( ctx: &ReducerContext, map_id: u32, x: f32, @@ -1054,7 +1056,7 @@ fn cell_may_be_indoor(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> bool /// skips the ray cast for a cell with no interior geometry, then `area_info`, which is already /// `None` with vmap off, with no active generation, or with nothing under the probe. So an /// operator running without vmap data never gets an indoor dismount, and never gets a false one. -pub fn is_indoor(ctx: &ReducerContext, map_id: u32, x: f32, y: f32, z: f32) -> bool { +pub(crate) fn is_indoor(ctx: &ReducerContext, map_id: u32, x: f32, y: f32, z: f32) -> bool { cell_may_be_indoor(ctx, map_id, x, y) && area_info(ctx, map_id, x, y, z).is_some_and(|info| info.indoor) } diff --git a/module/src/world.rs b/module/src/world.rs index c171e1f6d..ad3a25215 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -442,20 +442,20 @@ impl WorldEntity { /// Is this entity a player (vs a server-authored creature/Unit)? Tests the PLAYER bit of /// `OBJECT_FIELD_TYPE`. The single spelling of this check — drives damage amount, the /// death-vs-floor decision in the swing tick, the loot/corpse owner test, etc. - pub fn is_player(&self) -> bool { + pub(crate) fn is_player(&self) -> bool { self.type_mask & lyracore_shared::constants::type_mask::PLAYER_BIT != 0 } /// The entity's RACE id — byte 0 of `unit_bytes_0` (race|class|gender|powertype). The one /// spelling of the unpack; the packing layout itself is pinned by lyracore-shared's /// `unit_bytes_0` tests. - pub fn race(&self) -> u8 { + pub(crate) fn race(&self) -> u8 { (self.unit_bytes_0 & 0xFF) as u8 } /// The entity's CLASS id — byte 1 of `unit_bytes_0`. The single spelling of this shift; callers /// should use it rather than hand-unpacking the bitfield. - pub fn class(&self) -> u8 { + pub(crate) fn class(&self) -> u8 { ((self.unit_bytes_0 >> 8) & 0xFF) as u8 } } @@ -544,7 +544,7 @@ const MIN_DT_S: f32 = 0.05; /// `max_speed` the mover's effective allowed speed (RUN through `effective_move_speed`, so a snare lowers /// and Sprint raises it). Returns `(kind, magnitude)` for the worst violation, or `None` when plausible. /// Magnitude: teleport = the jump distance (yd); speed = the observed multiple of normal (×). -pub fn movement_violation(dist_2d: f32, dt_s: f32, max_speed: f32) -> Option<(u8, f32)> { +pub(crate) fn movement_violation(dist_2d: f32, dt_s: f32, max_speed: f32) -> Option<(u8, f32)> { // Teleport ceiling first (distance-only, applies even with an unknown dt). if dist_2d > TELEPORT_MAX_YD { return Some((MOVE_VIOLATION_TELEPORT, dist_2d)); @@ -1063,7 +1063,7 @@ pub(crate) fn apply_player_login( // `debug_spawn_player_entity` runs, factored into `build_player_entity`. `owner_identity` is the // connection's bound identity (`ctx.sender()`); `account.id` is identical to `character.account_id` // (gated above), so the builder reads `account_id` from the character row. - let mut entity = crate::build_player_entity(ctx, &character, owner); + let mut entity = crate::creatures::build_player_entity(ctx, &character, owner); // a preserved ghost rebuilds AS a ghost, `build_player_entity` always builds // alive (it's the shared player construction, creature-side code), so the released-ghost state // `persist_entity` stamped is re-applied here, the one player-owned call site. Health 1 matches diff --git a/module/src/xp.rs b/module/src/xp.rs index 245e5964a..32cbb0ee6 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -73,7 +73,7 @@ pub struct LevelupEvent { /// Indexed L1..59 (L60 = cap → 0). Returns 0 for level 0 and any level >= the cap so the ding loop /// (`grant_xp`) terminates and the rested-pool math (`rest_pool_after`) treats a capped char as /// "no level to fill". This is the single source of the leveling curve. -pub fn xp_to_next_level(level: u32) -> u32 { +pub(crate) fn xp_to_next_level(level: u32) -> u32 { // XP_PER_LEVEL[i] = XP to go from level (i+1) to (i+2); i.e. index 0 = L1->L2 = 400. const XP_PER_LEVEL: [u32; 59] = [ 400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, 8800, 10100, 11400, 12900, 14400, @@ -92,7 +92,7 @@ pub fn xp_to_next_level(level: u32) -> u32 { /// when the real cmangos `player_classlevelstats`/`player_levelstats` curve (importer P3) isn't /// loaded. The ding (`award_xp`) and `player_login` call `stats::max_health_for`, which uses this when /// a row is missing — so login/leveling work identically before an import (L1 = 60 either way). -pub fn max_health_for_level(level: u32) -> u32 { +pub(crate) fn max_health_for_level(level: u32) -> u32 { 60 + 15 * level.saturating_sub(1) } diff --git a/module/tests/build_script.rs b/module/tests/build_script.rs new file mode 100644 index 000000000..62cfaddc5 --- /dev/null +++ b/module/tests/build_script.rs @@ -0,0 +1,8 @@ +//! Runs `build.rs`'s own unit tests: marker parsing, file gates and registry wrappers. +//! +//! Cargo compiles a build script only as a build script, so its `#[cfg(test)]` module has no other +//! way to run. The generation half has no caller in this target. +#![allow(dead_code)] + +#[path = "../build.rs"] +mod build_script; diff --git a/module/tests/package_api/contract.rs b/module/tests/package_api/contract.rs new file mode 100644 index 000000000..7a2f45a93 --- /dev/null +++ b/module/tests/package_api/contract.rs @@ -0,0 +1,311 @@ +//! Package API version 3: the crate-root paths a Package may name. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use super::paths::{tokens, tree_leaves, RootedPath}; +use super::source::{strip_source, DEBUG_REDUCERS_FILE_CFG, TEST_FILE_CFG}; + +/// The Package API roots. A gated `package` child requires its whole-file gate. +pub const ROOTS: &[&str] = &["actor", "hooks", "package", "tables", "script_binding"]; + +/// `package` children that exist only in a build with their whole-file gate. Core compiles each +/// only under that cfg, so naming one from any other file breaks a build without it. +const GATED_PACKAGE_CHILDREN: &[(&str, &str)] = &[ + ("fixture", DEBUG_REDUCERS_FILE_CFG), + ("test", TEST_FILE_CFG), +]; + +/// Crate-root names that are neither a root nor a table: the marker macros and the generated +/// character-owned table manifest. +const ROOT_ITEMS: &[&str] = &[ + "CHARACTER_OWNED_TABLES", + "character_owned", + "encounter_package", + "game_client_command", + "game_hook", + "game_package_characters", + "game_tick_pass", +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + Allowed, + Outside { + replacement: Option, + }, + GatedChild { + child: &'static str, + gate: &'static str, + }, + /// The `package` module itself, whose import would carry its gated children past their gate. + PackageModule, +} + +/// The Package API roots and items, with the table catalog for migration diagnostics. +pub struct Contract { + catalog: BTreeSet, +} + +impl Contract { + /// The contract for the catalog in `tables_rs`, Core's `module/src/tables.rs`. + pub fn read(tables_rs: &Path) -> Self { + let source = fs::read_to_string(tables_rs) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", tables_rs.display())); + Self::from_catalog(&source) + } + + /// Every name the catalog's `use crate::::{..};` lines bind. The crate root + /// re-exports the catalog, so each is also a crate-root name. + pub fn from_catalog(source: &str) -> Self { + let code = strip_source(source).code; + let tokens = tokens(&code); + let mut leaves = Vec::new(); + for (index, window) in tokens.windows(3).enumerate() { + if window[0].text == "use" && window[1].text == "crate" && window[2].text == "::" { + tree_leaves(&tokens, index + 3, &RootedPath::default(), &mut leaves); + } + } + let catalog: BTreeSet = leaves + .into_iter() + .map(|leaf| match leaf.alias.or(leaf.segments.last().cloned()) { + Some(name) if name != "*" => name, + _ => panic!( + "the table catalog names each table; `{}` does not", + leaf.written + ), + }) + .collect(); + assert!(!catalog.is_empty(), "the table catalog names no table"); + Self { catalog } + } + + pub fn catalogs(&self, name: &str) -> bool { + self.catalog.contains(name) + } + + /// Whether a Package file whose whole-file gate is `file_gate` may name the crate-root path + /// `segments`. + pub fn verdict(&self, segments: &[String], file_gate: Option<&str>) -> Verdict { + let root = segments.first().map_or("", String::as_str); + if root == "package" { + let child = segments.get(1).map(String::as_str); + return match child { + None | Some("*") => Verdict::PackageModule, + Some(child) => match GATED_PACKAGE_CHILDREN.iter().find(|(c, _)| *c == child) { + Some((child, gate)) if file_gate != Some(*gate) => { + Verdict::GatedChild { child, gate } + } + _ => Verdict::Allowed, + }, + }; + } + let generated_package = root + .strip_prefix("pkg_") + .is_some_and(|name| !name.is_empty()); + if ROOTS.contains(&root) || ROOT_ITEMS.contains(&root) || generated_package { + return Verdict::Allowed; + } + Verdict::Outside { + replacement: if self.catalog.contains(root) { + Some(format!("crate::tables::{}", segments.join("::"))) + } else { + replacement(segments).map(str::to_string) + }, + } + } +} + +/// The Version 3 path for the longest Version 2 prefix of `segments`, if one is documented. +fn replacement(segments: &[String]) -> Option<&'static str> { + (1..=segments.len()).rev().find_map(|len| { + let written = segments[..len].join("::"); + VERSION_2_REPLACEMENTS + .iter() + .find(|(old, _)| *old == written) + .map(|(_, new)| *new) + }) +} + +/// Version 2 crate-root paths an official Package named, and their Version 3 path. Paths that +/// did not change are absent. +#[rustfmt::skip] +const VERSION_2_REPLACEMENTS: &[(&str, &str)] = &[ + ("SessionActor", "crate::package::fixture::SessionActor"), + ("chat::CHAT_YELL", "lyracore_shared::chat::broadcast_chat::YELL"), + ("chat::apply_send_chat", "crate::actor::send_chat"), + ("combat::AttackStart", "crate::actor::AttackStart"), + ("combat::Hit", "crate::package::fixture::Hit"), + ("combat::HitSource", "crate::package::fixture::HitSource"), + ("combat::MeleeAttack", "crate::tables::MeleeAttack"), + ("combat::apply_hit", "crate::package::fixture::apply_hit"), + ("combat::enter_combat", "crate::package::fixture::enter_combat"), + ("combat::final_damage", "crate::package::fixture::final_damage"), + ("combat::fold_incoming_damage", "crate::package::fixture::fold_incoming_damage"), + ("combat::kill_creature", "crate::package::fixture::kill_creature"), + ("combat::request_attack", "crate::actor::request_attack"), + ("combat::validate_attack_target", "crate::actor::attack_target_gate"), + ("creatures::build_creature_entity", "crate::package::fixture::build_creature_entity"), + ("creatures::build_player_entity", "crate::package::character::build_player_entity"), + ("creatures::creature_spawn_evidence", "crate::actor::creature_spawn_evidence"), + ("creatures::despawn_creature_entity", "crate::package::fixture::despawn_creature_entity"), + ("creatures::insert_creature_entity", "crate::package::fixture::insert_creature_entity"), + ("creatures::tick::emit_creature_leg", "crate::actor::movement::emit_creature_leg"), + ("creatures::tick::emit_creature_path", "crate::actor::movement::emit_creature_path"), + ("creatures::tick::emit_move_spline", "crate::package::fixture::emit_move_spline"), + ("creatures::timer_never", "crate::package::fixture::timer_never"), + ("encounter", "crate::package::encounter"), + ("encounter::DOOR_OPEN_STATE", "crate::package::encounter::DOOR_OPEN_STATE"), + ("encounter::ENCOUNTER_DONE", "crate::package::encounter::ENCOUNTER_DONE"), + ("encounter::equip_swap", "crate::package::encounter::equip_swap"), + ("encounter::get_encounter_state", "crate::package::encounter::get_encounter_state"), + ("encounter::move_to_point", "crate::package::encounter::move_to_point"), + ("encounter::open_door", "crate::package::encounter::open_door"), + ("encounter::set_encounter_state", "crate::package::encounter::set_encounter_state"), + ("encounter::spawn_wave", "crate::package::encounter::spawn_wave"), + ("encounter::watch_hp_threshold", "crate::package::encounter::watch_hp_threshold"), + ("encounter::wave_guid", "crate::package::encounter::wave_guid"), + ("faction::is_friendly", "crate::actor::is_friendly"), + ("gameobject::GameObject", "crate::tables::GameObject"), + ("gameobject::GameObjectTemplate", "crate::tables::GameObjectTemplate"), + ("gameobject::apply_use_gameobject", "crate::actor::use_gameobject"), + ("gameobject::gameobject_destination_evidence", "crate::actor::gameobject_destination_evidence"), + ("gameobject::go_type::CHEST", "crate::actor::go_type::CHEST"), + ("gameobject::go_type::GOOBER", "crate::actor::go_type::GOOBER"), + ("gameobject::go_type::QUESTGIVER", "crate::actor::go_type::QUESTGIVER"), + ("group::GROUP_MAX_MEMBERS", "lyracore_shared::group::GROUP_MAX_MEMBERS"), + ("group::GroupMemberPartition", "crate::tables::GroupMemberPartition"), + ("group::PartyEnemyFacts", "crate::actor::PartyEnemyFacts"), + ("group::PartyFactsUnavailable", "crate::actor::PartyFactsUnavailable"), + ("group::PartyFactsUnavailableReason", "crate::actor::PartyFactsUnavailableReason"), + ("group::PartyMemberFacts", "crate::actor::PartyMemberFacts"), + ("group::PartyPartitionFacts", "crate::actor::PartyPartitionFacts"), + ("group::PartyPartitionState", "crate::tables::PartyPartitionState"), + ("group::PartyUnitFacts", "crate::actor::PartyUnitFacts"), + ("group::emit_bot_invite_intent", "crate::actor::emit_bot_invite_intent"), + ("group::emit_bot_leave_intent", "crate::actor::emit_bot_leave_intent"), + ("group::group_of", "crate::actor::group_of"), + ("group::leave_group_for", "crate::actor::leave_group_for"), + ("group::party_facts", "crate::actor::party_facts"), + ("group::sync_group_mirror", "crate::package::fixture::sync_group_mirror"), + ("helpers::character_by_guid", "crate::actor::character_by_guid"), + ("helpers::character_by_name", "crate::actor::character_by_name"), + ("helpers::entities_near", "crate::actor::entities_near"), + ("helpers::in_same_partition", "crate::actor::in_same_partition"), + ("helpers::live_entity", "crate::actor::live_entity"), + ("helpers::require_operator", "crate::package::require_operator"), + ("items::grant_item", "crate::package::fixture::grant_item"), + ("items::has_free_slot", "crate::actor::has_free_slot"), + ("items::item_count", "crate::actor::item_count"), + ("items::remove_items", "crate::package::fixture::remove_items"), + ("loot::corpse_access", "crate::actor::corpse_access"), + ("loot::corpse_eligible_for_access", "crate::actor::corpse_eligible_for_access"), + ("loot::corpse_eligible_recipients", "crate::actor::corpse_eligible_recipients"), + ("loot::death_entitlement", "crate::actor::death_entitlement"), + ("loot::tag::LiveLootTagEligibility", "crate::actor::LiveLootTagEligibility"), + ("loot::tag::clear", "crate::package::fixture::clear_loot_tag"), + ("loot::tag::live_loot_tag_eligibility", "crate::actor::live_loot_tag_eligibility"), + ("nav::CoverageEvidence", "crate::actor::movement::CoverageEvidence"), + ("nav::LEG_MAX_EXPANSIONS", "crate::actor::movement::LEG_MAX_EXPANSIONS"), + ("nav::NavChunk", "crate::tables::NavChunk"), + ("nav::NavigationInputs", "crate::actor::movement::NavigationInputs"), + ("nav::RoutePoint", "crate::actor::movement::RoutePoint"), + ("nav::RouteStatus", "crate::actor::movement::RouteStatus"), + ("nav::RouteStep", "crate::actor::movement::RouteStep"), + ("nav::coverage_generation", "crate::actor::movement::coverage_generation"), + ("nav::game_nav_chunk", "crate::tables::game_nav_chunk"), + ("nav::game_navigation_revision", "crate::tables::game_navigation_revision"), + ("nav::has_los", "crate::actor::movement::has_los"), + ("nav::inputs", "crate::actor::movement::inputs"), + ("nav::record_change", "crate::package::fixture::record_navigation_change"), + ("nav::route_path", "crate::actor::movement::route_path"), + ("nav::route_path_with_budget", "crate::actor::movement::route_path_with_budget"), + ("nav::route_segment_clear", "crate::actor::movement::route_segment_clear"), + ("nav::route_step", "crate::actor::movement::route_step"), + ("nav::walkable", "crate::actor::movement::walkable"), + ("package_account::create_package_character", "crate::package::create_character"), + ("package_config::ensure_package_config_default", "crate::package::ensure_config_default"), + ("package_config::game_package_config", "crate::tables::game_package_config"), + ("package_fixture", "crate::package::fixture"), + ("package_fixture::admit_to_instance", "crate::package::fixture::admit_to_instance"), + ("package_fixture::apply_damage", "crate::package::fixture::apply_damage"), + ("package_fixture::client_cast", "crate::package::fixture::client_cast"), + ("package_fixture::declare_next_movement_tick", "crate::package::fixture::declare_next_movement_tick"), + ("package_fixture::record_completed_transfer", "crate::package::fixture::record_completed_transfer"), + ("package_fixture::remove_live_character", "crate::package::fixture::remove_live_character"), + ("package_fixture::require_no_imported_content", "crate::package::fixture::require_no_imported_content"), + ("package_fixture::top_threat_target", "crate::package::fixture::top_threat_target"), + ("package_test", "crate::package::test"), + ("package_test::EntityView", "crate::package::test::EntityView"), + ("package_test::RuntimeScript", "crate::package::test::RuntimeScript"), + ("package_test::ask_offline", "crate::package::test::ask_offline"), + ("package_test::code_of", "crate::package::test::code_of"), + ("package_test::read_scanned", "crate::package::test::read_scanned"), + ("package_test::shape_of", "crate::package::test::shape_of"), + ("quest::AreaTriggerRoute", "crate::actor::AreaTriggerRoute"), + ("quest::CharacterQuest", "crate::tables::CharacterQuest"), + ("quest::MAX_QUEST_LOG_SIZE", "crate::actor::MAX_QUEST_LOG_SIZE"), + ("quest::accept_gates", "crate::actor::quest_acceptance"), + ("quest::character_quest_row", "crate::actor::character_quest"), + ("quest::objective_kind::COLLECT_ITEM", "crate::actor::objective_kind::COLLECT_ITEM"), + ("quest::objective_kind::EXPLORE_AREATRIGGER", "crate::actor::objective_kind::EXPLORE_AREATRIGGER"), + ("quest::objective_kind::KILL_CREATURE", "crate::actor::objective_kind::KILL_CREATURE"), + ("quest::objective_kind::USE_GAMEOBJECT", "crate::actor::objective_kind::USE_GAMEOBJECT"), + ("quest::quest_is_complete", "crate::actor::quest_complete"), + ("quest::quest_role::END", "crate::actor::quest_role::END"), + ("quest::quest_role::START", "crate::actor::quest_role::START"), + ("spell::A_CONTROL", "crate::actor::A_CONTROL"), + ("spell::A_PERIODIC_HEAL", "crate::actor::A_PERIODIC_HEAL"), + ("spell::A_PERIODIC_TRIGGER", "crate::actor::A_PERIODIC_TRIGGER"), + ("spell::A_PROC_TRIGGER", "crate::actor::A_PROC_TRIGGER"), + ("spell::BuffStatus", "crate::actor::BuffStatus"), + ("spell::BuffUnavailableReason", "crate::actor::BuffUnavailableReason"), + ("spell::CastFinish", "crate::actor::CastFinish"), + ("spell::CastHandle", "crate::actor::CastHandle"), + ("spell::CastRefusal", "crate::actor::CastRefusal"), + ("spell::CastRefusalKind", "crate::actor::CastRefusalKind"), + ("spell::CastStart", "crate::actor::CastStart"), + ("spell::ControlReadError", "crate::actor::ControlReadError"), + ("spell::CreatureSpellCasterAdmission", "crate::package::fixture::CreatureSpellCasterAdmission"), + ("spell::CreatureSpellStart", "crate::package::fixture::CreatureSpellStart"), + ("spell::CreatureSpellStartMode", "crate::package::fixture::CreatureSpellStartMode"), + ("spell::CreatureSpellTarget", "crate::package::fixture::CreatureSpellTarget"), + ("spell::E_HEAL", "crate::actor::E_HEAL"), + ("spell::E_HEAL_MAX_HEALTH", "crate::actor::E_HEAL_MAX_HEALTH"), + ("spell::E_NEXT_SWING", "crate::actor::E_NEXT_SWING"), + ("spell::E_SCRIPTED", "crate::actor::E_SCRIPTED"), + ("spell::PlayerSpell", "crate::tables::PlayerSpell"), + ("spell::SPELL_ATTR_CHANNELED", "lyracore_shared::spell::SPELL_ATTR_CHANNELED"), + ("spell::Spell", "crate::tables::Spell"), + ("spell::SpellChain", "crate::tables::SpellChain"), + ("spell::SpellEffect", "crate::tables::SpellEffect"), + ("spell::T_TARGET_ALLY", "crate::actor::T_TARGET_ALLY"), + ("spell::T_TARGET_ENEMY", "crate::actor::T_TARGET_ENEMY"), + ("spell::buff_status", "crate::actor::buff_status"), + ("spell::cancel_cast_attempt", "crate::actor::cancel_cast_attempt"), + ("spell::cast_triggered", "crate::package::fixture::cast_triggered"), + ("spell::control_status", "crate::actor::control_status"), + ("spell::do_cancel_aura", "crate::package::fixture::do_cancel_aura"), + ("spell::expire_cast_attempt", "crate::actor::expire_cast_attempt"), + ("spell::has_aura", "crate::actor::has_aura"), + ("spell::knows_spell", "crate::actor::knows_spell"), + ("spell::learn_spell", "crate::package::fixture::learn_spell"), + ("spell::pending_cast", "crate::actor::pending_cast"), + ("spell::stacking::SpellGroup", "crate::tables::SpellGroup"), + ("spell::stacking::game_spell_group", "crate::tables::game_spell_group"), + ("spell::start_creature_spell", "crate::package::fixture::start_creature_spell"), + ("stats::set_character_level", "crate::package::character::set_character_level"), + ("terrain::TerrainChunk", "crate::tables::TerrainChunk"), + ("terrain::game_terrain_chunk", "crate::tables::game_terrain_chunk"), + ("terrain::ground_z", "crate::actor::movement::ground_z"), + ("terrain::snap_z", "crate::actor::movement::snap_z"), + ("terrain::zone_id_at", "crate::actor::movement::zone_id_at"), + ("transfer::game_bot_transfer_intent", "crate::tables::game_bot_transfer_intent"), + ("transfer::game_transfer_in", "crate::tables::game_transfer_in"), + ("world::cascade_delete_character", "crate::package::character::cascade_delete_character"), + ("world::remove_live_character", "crate::package::fixture::remove_live_character"), + ("xp::grant_xp", "crate::package::fixture::grant_xp"), + ("xp::rank_xp_multiplier", "crate::actor::rank_xp_multiplier"), + ("xp::xp_for_kill", "crate::actor::xp_for_kill"), +]; diff --git a/module/tests/package_api/fixtures/clean/src/fixture.rs b/module/tests/package_api/fixtures/clean/src/fixture.rs new file mode 100644 index 000000000..7fa63a443 --- /dev/null +++ b/module/tests/package_api/fixtures/clean/src/fixture.rs @@ -0,0 +1,7 @@ +#![cfg(feature = "debug_reducers")] + +use crate::package::fixture::{apply_damage, SessionActor}; + +fn grant() { + super::super::package::fixture::grant_xp(); +} diff --git a/module/tests/package_api/fixtures/clean/src/mod.rs b/module/tests/package_api/fixtures/clean/src/mod.rs new file mode 100644 index 000000000..bb243894e --- /dev/null +++ b/module/tests/package_api/fixtures/clean/src/mod.rs @@ -0,0 +1,25 @@ +//! A Package that names only Package API version 3. `crate::spell::pending_cast` here is inert. + +mod fixture; +mod runner; +#[cfg(test)] +mod tests; + +use crate::actor::{self, movement::route_step, ActionRefusalKind}; +use crate::package::character::build_player_entity; +use crate::package::encounter::{self, ENCOUNTER_DONE}; +use crate::tables::{game_package_config, PartyPartitionState}; +use crate::tables::{game_character, Character, WorldEntity}; + +crate::game_hook!(on_login, fn welcome(ctx, payload) { + crate::actor::system_message(ctx, payload.character_guid, "crate::helpers::live_entity"); + let _ = crate::script_binding::ask(ctx, "welcome", payload.character_guid, 0); + let _ = crate::hooks::LevelupPayload::default; + let _ = crate::pkg_clean::runner::step; +}); + +fn setup(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { + crate::package::require_operator(ctx)?; + let _ = super::tables::game_world_entity; + Ok(()) +} diff --git a/module/tests/package_api/fixtures/clean/src/runner/mod.rs b/module/tests/package_api/fixtures/clean/src/runner/mod.rs new file mode 100644 index 000000000..810395a3c --- /dev/null +++ b/module/tests/package_api/fixtures/clean/src/runner/mod.rs @@ -0,0 +1,5 @@ +mod movement; + +pub(crate) fn step() { + super::super::actor::stop_attack(); +} diff --git a/module/tests/package_api/fixtures/clean/src/runner/movement.rs b/module/tests/package_api/fixtures/clean/src/runner/movement.rs new file mode 100644 index 000000000..a1f4f4094 --- /dev/null +++ b/module/tests/package_api/fixtures/clean/src/runner/movement.rs @@ -0,0 +1,6 @@ +use super::super::super::actor::movement::{route_path, RoutePoint}; +use super::step; + +pub(crate) fn walk() { + super::super::super::actor::movement::snap_z(); +} diff --git a/module/tests/package_api/fixtures/clean/src/tests.rs b/module/tests/package_api/fixtures/clean/src/tests.rs new file mode 100644 index 000000000..593540142 --- /dev/null +++ b/module/tests/package_api/fixtures/clean/src/tests.rs @@ -0,0 +1,8 @@ +#![cfg(test)] + +use crate::package::test::{ask_offline, RuntimeScript}; + +#[test] +fn the_manifest_names_tables() { + assert!(!crate::CHARACTER_OWNED_TABLES.is_empty()); +} diff --git a/module/tests/package_api/fixtures/denied/src/goals.rs b/module/tests/package_api/fixtures/denied/src/goals.rs new file mode 100644 index 000000000..d34632e9d --- /dev/null +++ b/module/tests/package_api/fixtures/denied/src/goals.rs @@ -0,0 +1,8 @@ +#![cfg(test)] + +use super::super::spell::{pending_cast, CastStart}; +use crate as core; + +fn hit() { + crate::package::fixture::apply_damage(); +} diff --git a/module/tests/package_api/fixtures/denied/src/mod.rs b/module/tests/package_api/fixtures/denied/src/mod.rs new file mode 100644 index 000000000..e157b0923 --- /dev/null +++ b/module/tests/package_api/fixtures/denied/src/mod.rs @@ -0,0 +1,10 @@ +mod goals; + +use crate::helpers::require_operator; +use crate::package::fixture::apply_damage; +use crate::package as api; + +fn run(ctx: &spacetimedb::ReducerContext, _: crate::UnusedUpperCamel) { + crate::game_operation(ctx); + crate::auth::create_character(ctx); // package-api: exempt the Package owns its Characters +} diff --git a/module/tests/package_api/mod.rs b/module/tests/package_api/mod.rs new file mode 100644 index 000000000..32d49e583 --- /dev/null +++ b/module/tests/package_api/mod.rs @@ -0,0 +1,219 @@ +//! The Package API lint: every crate-root path an installed Package names is on Package API +//! version 3, as `docs/package-api.md` documents it. + +mod contract; +mod paths; +#[path = "../../build_support/source.rs"] +mod source; + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +pub use contract::Contract; +use contract::Verdict; +pub use contract::ROOTS; +pub use paths::UnsupportedSyntax; +use source::{collect_rs_files, file_gate, strip_source}; + +/// A line comment can exempt an ordinary path outside the Package API when it names a reason. +const PACKAGE_API_EXEMPT: &str = "// package-api: exempt"; + +#[derive(Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub package: String, + /// The file as `packages//src/`. + pub file: PathBuf, + pub line: usize, + pub finding: Finding, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Finding { + Outside { + path: String, + replacement: Option, + }, + GatedChild { + path: String, + child: &'static str, + gate: &'static str, + }, + PackageModule { + path: String, + }, + Unsupported { + kind: UnsupportedSyntax, + written: String, + }, +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let package = &self.package; + let at = format!("{}:{}", self.file.display(), self.line); + match &self.finding { + Finding::Outside { path, replacement } => { + write!( + f, + "Package `{package}` names `{path}` at {at}, outside Package API version 3 \ + (docs/package-api.md). " + )?; + match replacement { + Some(replacement) => write!(f, "Use `{replacement}`."), + None => write!( + f, + "Name a path under {}, or write `{PACKAGE_API_EXEMPT} ` on that \ + line and raise the gap with the maintainers.", + ROOTS + .iter() + .map(|root| format!("`crate::{root}`")) + .collect::>() + .join(", ") + ), + } + } + Finding::GatedChild { path, child, gate } => write!( + f, + "Package `{package}` names `{path}` at {at}. `crate::package::{child}` is Package \ + API only in a file whose first non-blank line is `{gate}`." + ), + Finding::PackageModule { path } => write!( + f, + "Package `{package}` names the `crate::package` module itself as `{path}` at {at}. \ + Name `crate::package::` instead, so `fixture` and `test` cannot leave their \ + file gates through the module." + ), + Finding::Unsupported { kind, written } => { + let instruction = match kind { + UnsupportedSyntax::WholeCrateAlias => { + "Whole-crate aliases can hide core paths across Rust scopes. Spell each \ + core path as `crate::` instead." + } + UnsupportedSyntax::PathAttribute => { + "Path attributes break Package source discovery and module-depth checks. \ + Use Rust's normal `mod.rs`, `.rs`, or `/mod.rs` layout \ + instead." + } + UnsupportedSyntax::IncludeMacro => { + "`include!` can add Rust source that the Package API lint cannot \ + discover. Put Package Rust in a normal `.rs` source file instead." + } + }; + write!( + f, + "Package `{package}` uses unsupported syntax {written} at {at}. {instruction}" + ) + } + } + } +} + +/// Check every Rust file of every Package under `packages_dir`, a directory per Package, the +/// same Packages the build compiles: those with a `src/` directory. +pub fn check_inventory(contract: &Contract, packages_dir: &Path) -> Result<(), Vec> { + let mut diagnostics = Vec::new(); + if !packages_dir.is_dir() { + return Ok(()); + } + let mut packages: Vec = fs::read_dir(packages_dir) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", packages_dir.display())) + .map(|entry| entry.expect("readable Package entry").path()) + .filter(|package| package.join("src").is_dir()) + .collect(); + packages.sort(); + for package in packages { + let name = package + .file_name() + .expect("a Package directory has a name") + .to_string_lossy() + .into_owned(); + let src = package.join("src"); + let mut files = Vec::new(); + collect_rs_files(&src, &mut files); + files.sort(); + for file in files { + let source = fs::read_to_string(&file) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); + let relative = file.strip_prefix(&src).expect("collected beneath src/"); + diagnostics.extend(check_source(contract, &name, relative, &source)); + } + } + if diagnostics.is_empty() { + Ok(()) + } else { + Err(diagnostics) + } +} + +/// Every Package API finding in one Package file, in line order. `relative_path` is the file's +/// path beneath its Package's `src/`. +pub fn check_source( + contract: &Contract, + package: &str, + relative_path: &Path, + source: &str, +) -> Vec { + let stripped = strip_source(source); + let exempt_lines: Vec = stripped + .line_comments + .iter() + .filter(|(_, comment)| { + comment + .split_once(PACKAGE_API_EXEMPT) + .is_some_and(|(_, reason)| !reason.trim().is_empty()) + }) + .map(|(line, _)| *line) + .collect(); + // `mod.rs` is its directory's module; any other file adds its stem as one module segment. + let components = relative_path.components().count(); + let depth = if relative_path + .file_name() + .is_some_and(|name| name == "mod.rs") + { + components - 1 + } else { + components + }; + let gate = file_gate(source); + let found = paths::file_paths(&stripped.code, depth); + + let mut findings: Vec<(usize, Finding)> = found + .unsupported + .into_iter() + .map(|(line, kind, written)| (line, Finding::Unsupported { kind, written })) + .collect(); + for (line, path) in found.paths { + let written = path.written; + let finding = match contract.verdict(&path.segments, gate) { + Verdict::Allowed => continue, + Verdict::Outside { .. } if exempt_lines.contains(&line) => continue, + Verdict::Outside { replacement } => Finding::Outside { + path: written, + replacement, + }, + Verdict::GatedChild { child, gate } => Finding::GatedChild { + path: written, + child, + gate, + }, + Verdict::PackageModule => Finding::PackageModule { path: written }, + }; + findings.push((line, finding)); + } + findings.sort_by_key(|(line, _)| *line); + + let file = Path::new("packages") + .join(package) + .join("src") + .join(relative_path); + findings + .into_iter() + .map(|(line, finding)| Diagnostic { + package: package.to_string(), + file: file.clone(), + line, + finding, + }) + .collect() +} diff --git a/module/tests/package_api/paths.rs b/module/tests/package_api/paths.rs new file mode 100644 index 000000000..fd617ab8d --- /dev/null +++ b/module/tests/package_api/paths.rs @@ -0,0 +1,374 @@ +//! Every crate-root path a Package source file names, read lexically from stripped source. + +/// One crate-root path as the Package wrote it. +#[derive(Clone, Debug, Default)] +pub struct RootedPath { + /// The segments after the crate root, raw identifiers normalized and `self` dropped. A glob + /// ends in `*`. + pub segments: Vec, + /// The path as written, from its root keyword (`crate`, `$crate` or `super::..`). + pub written: String, + /// The name a `use` binds with `as`, when it renames. + pub alias: Option, + /// Byte offset of the last segment in the stripped source. + offset: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnsupportedSyntax { + WholeCrateAlias, + PathAttribute, + IncludeMacro, +} + +/// What one Package file names beyond its own modules. +pub struct FilePaths { + /// Every crate-root path, one per use-tree leaf, as (1-based line, path). + pub paths: Vec<(usize, RootedPath)>, + /// Syntax whose meaning this scan cannot track, as (1-based line, kind, spelling). + pub unsupported: Vec<(usize, UnsupportedSyntax, String)>, +} + +#[derive(Clone, Copy)] +pub struct Token<'a> { + pub text: &'a str, + start: usize, +} + +/// The Rust tokens this lint needs, with byte positions for diagnostics. This is deliberately much +/// smaller than a parser: identifiers, `::`, and punctuation stay distinct; whitespace vanishes. +pub fn tokens(stripped: &str) -> Vec> { + let mut tokens = Vec::new(); + let mut at = 0usize; + while at < stripped.len() { + let remaining = &stripped[at..]; + let first = remaining + .chars() + .next() + .expect("at is before the end of source"); + if is_space(first) { + at += first.len_utf8(); + continue; + } + let start = at; + let raw_ident = remaining + .strip_prefix("r#") + .and_then(|text| text.chars().next()) + .is_some_and(ident_start); + if raw_ident || ident_start(first) { + if raw_ident { + at += 2; + } + while let Some(c) = stripped[at..].chars().next().filter(|c| ident_char(*c)) { + at += c.len_utf8(); + } + } else if remaining.starts_with("::") { + at += 2; + } else { + at += first.len_utf8(); + } + tokens.push(Token { + text: &stripped[start..at], + start, + }); + } + tokens +} + +pub fn is_ident(token: Token<'_>) -> bool { + token.text.chars().next().is_some_and(ident_start) +} + +fn is_space(c: char) -> bool { + c.is_whitespace() || matches!(c, '\u{200e}' | '\u{200f}') +} + +// Rust punctuation is ASCII. Keep non-ASCII identifier characters together without needing the +// full Unicode identifier tables; the compiler checks their validity. +fn ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || (!c.is_ascii() && !is_space(c)) +} + +fn ident_start(c: char) -> bool { + ident_char(c) && !c.is_ascii_digit() +} + +fn ident_name(token: Token<'_>) -> Option<&str> { + is_ident(token).then(|| token.text.strip_prefix("r#").unwrap_or(token.text)) +} + +fn line_at(stripped: &str, offset: usize) -> usize { + stripped[..offset].matches('\n').count() + 1 +} + +/// Every leaf of the use tree or plain path that starts at `tokens[at]`, the token after a root's +/// `::`, appended below `parent`. Returns the index of the first token after the tree. +pub fn tree_leaves( + tokens: &[Token<'_>], + at: usize, + parent: &RootedPath, + out: &mut Vec, +) -> usize { + let mut leaf = parent.clone(); + let mut index = at; + while let Some(token) = tokens.get(index).copied() { + if token.text == "{" { + return group_leaves(tokens, index, &leaf, out); + } + if token.text == "*" || is_ident(token) { + leaf.written.push_str("::"); + leaf.written.push_str(token.text); + if token.text != "self" { + let name = ident_name(token).unwrap_or(token.text); + leaf.segments.push(name.to_string()); + leaf.offset = token.start; + } + index += 1; + if token.text != "*" && tokens.get(index).is_some_and(|next| next.text == "::") { + index += 1; + continue; + } + } + break; + } + if tokens.get(index).is_some_and(|token| token.text == "as") { + if let Some(alias) = tokens.get(index + 1) { + leaf.alias = Some(alias.text.to_string()); + index += 2; + } + } + if leaf.segments.len() > parent.segments.len() || leaf.written.ends_with("::self") { + out.push(leaf); + } + index +} + +/// The leaves of one braced group whose `{` is `tokens[open]`. +fn group_leaves( + tokens: &[Token<'_>], + open: usize, + parent: &RootedPath, + out: &mut Vec, +) -> usize { + let mut index = open + 1; + while let Some(token) = tokens.get(index) { + match token.text { + "}" => return index + 1, + "," => index += 1, + _ => { + let next = tree_leaves(tokens, index, parent, out); + index = next.max(index + 1); + } + } + } + index +} + +/// Inline `mod name { .. }` bodies, as byte ranges. File position supplies the rest of a Package +/// module's depth. +fn inline_modules(tokens: &[Token<'_>]) -> Vec<(usize, usize)> { + let mut open = Vec::new(); + let mut pairs = Vec::new(); + for token in tokens { + match token.text { + "{" => open.push(token.start), + "}" => { + if let Some(start) = open.pop() { + pairs.push((start, token.start)); + } + } + _ => {} + } + } + tokens + .windows(3) + .filter(|w| w[0].text == "mod" && is_ident(w[1]) && w[2].text == "{") + .filter_map(|w| pairs.iter().find(|(open, _)| *open == w[2].start).copied()) + .collect() +} + +/// The crate-root paths and unsupported syntax in one Package file. `file_depth` is zero for +/// `src/mod.rs`, one for `src/foo.rs` or `src/foo/mod.rs`, and so on. Inline modules add to it. +/// +/// A path is rooted at `crate`, `$crate`, or enough leading `super` segments to leave the Package. +pub fn file_paths(stripped: &str, file_depth: usize) -> FilePaths { + let tokens = tokens(stripped); + let modules = inline_modules(&tokens); + let mut leaves = Vec::new(); + let mut index = 0usize; + while index < tokens.len() { + let token = tokens[index]; + let after = |offset: usize, text: &str| { + tokens + .get(index + offset) + .is_some_and(|token| token.text == text) + }; + let (root, separator, required_supers) = + if token.text == "$" && after(1, "crate") && after(2, "::") { + ("$crate".to_string(), index + 2, None) + } else if token.text == "crate" + && after(1, "::") + && (index == 0 || tokens[index - 1].text != "$") + { + ("crate".to_string(), index + 1, None) + } else if token.text == "super" && (index == 0 || tokens[index - 1].text != "::") { + let mut levels = 1usize; + let mut end = index; + while tokens.get(end + 1).is_some_and(|t| t.text == "::") + && tokens.get(end + 2).is_some_and(|t| t.text == "super") + { + levels += 1; + end += 2; + } + let depth = file_depth + + modules + .iter() + .filter(|(open, close)| *open < token.start && token.start < *close) + .count(); + if tokens.get(end + 1).is_none_or(|t| t.text != "::") { + index = end + 1; + continue; + } + (vec!["super"; levels].join("::"), end + 1, Some(depth + 1)) + } else { + index += 1; + continue; + }; + let parent = RootedPath { + written: root, + ..RootedPath::default() + }; + let mut root_leaves = Vec::new(); + index = tree_leaves(&tokens, separator + 1, &parent, &mut root_leaves).max(separator + 1); + for mut leaf in root_leaves { + if let Some(required) = required_supers { + let levels = leaf + .written + .split("::") + .take_while(|s| *s == "super") + .count(); + if levels != required { + continue; + } + let grouped_supers = leaf.segments.iter().take_while(|s| *s == "super").count(); + leaf.segments.drain(..grouped_supers); + } + leaves.push(leaf); + } + } + let paths = leaves + .into_iter() + .filter(|leaf| !leaf.segments.is_empty()) + .map(|leaf| (line_at(stripped, leaf.offset), leaf)) + .collect(); + FilePaths { + paths, + unsupported: unsupported_syntax(&tokens, stripped), + } +} + +/// Syntax whose meaning the lint cannot track reliably. It is refused where it is declared +/// instead of guessing at Rust name resolution or filesystem-to-module mapping. +fn unsupported_syntax( + tokens: &[Token<'_>], + stripped: &str, +) -> Vec<(usize, UnsupportedSyntax, String)> { + let mut found = Vec::new(); + let mut alias = |start: usize, name: &str| { + found.push(( + start, + UnsupportedSyntax::WholeCrateAlias, + format!("crate alias `{name}`"), + )); + }; + for (index, token) in tokens.iter().enumerate() { + // A whole-crate rename, including `self` beneath anonymous use groups. + if token.text != "crate" || !in_use_statement(tokens, index) { + continue; + } + let renames = |at: usize| { + tokens.get(at).is_some_and(|t| t.text == "as") + && tokens.get(at + 1).is_some_and(|t| t.text != "_") + }; + if renames(index + 1) { + alias(token.start, tokens[index + 2].text); + } else if tokens.get(index + 1).is_some_and(|t| t.text == "::") { + let mut leaves = Vec::new(); + tree_leaves(tokens, index + 2, &RootedPath::default(), &mut leaves); + for leaf in leaves { + if leaf.segments.is_empty() { + if let Some(name) = leaf.alias.filter(|name| name != "_") { + alias(token.start, &name); + } + } + } + } + } + // `extern crate self as core` is the older spelling of the same file-local crate alias. + for window in tokens.windows(5) { + if window[0].text == "extern" + && window[1].text == "crate" + && window[2].text == "self" + && window[3].text == "as" + && window[4].text != "_" + { + alias(window[0].start, window[4].text); + } + } + found.extend(path_attributes(tokens)); + // `include!` parses another file as Rust in this module, beyond `.rs` discovery. Data + // inclusion (`include_str!`, `include_bytes!`) stays available. + for window in tokens.windows(2) { + if ident_name(window[0]) == Some("include") && window[1].text == "!" { + found.push(( + window[0].start, + UnsupportedSyntax::IncludeMacro, + "`include!`".to_string(), + )); + } + } + found.sort_by_key(|finding| finding.0); + found.dedup_by(|left, right| left.0 == right.0 && left.1 == right.1); + found + .into_iter() + .map(|(start, kind, written)| (line_at(stripped, start), kind, written)) + .collect() +} + +/// Whether `tokens[index]` sits inside a `use` statement. +fn in_use_statement(tokens: &[Token<'_>], index: usize) -> bool { + tokens[..index] + .iter() + .rev() + .take_while(|token| token.text != ";") + .any(|token| token.text == "use") +} + +/// `#[path = ..]` and `#[cfg_attr(.., path = ..)]`, which break Package source discovery. +fn path_attributes(tokens: &[Token<'_>]) -> Vec<(usize, UnsupportedSyntax, String)> { + let mut found = Vec::new(); + for (index, window) in tokens.windows(2).enumerate() { + if window[0].text != "#" || window[1].text != "[" { + continue; + } + let Some(end) = tokens[index + 2..].iter().position(|t| t.text == "]") else { + continue; + }; + let attribute = &tokens[index + 2..index + 2 + end]; + let names_path = + |pair: &[Token<'_>]| ident_name(pair[0]) == Some("path") && pair[1].text == "="; + let written = match attribute.first().and_then(|t| ident_name(*t)) { + Some("path") if attribute.len() > 1 && names_path(&attribute[..2]) => "`#[path]`", + Some("cfg_attr") if attribute.windows(2).any(names_path) => { + "`#[cfg_attr(..., path = ...)]`" + } + _ => continue, + }; + found.push(( + window[0].start, + UnsupportedSyntax::PathAttribute, + written.to_string(), + )); + } + found +} diff --git a/module/tests/package_api_lint.rs b/module/tests/package_api_lint.rs index a30d52e48..975dad973 100644 --- a/module/tests/package_api_lint.rs +++ b/module/tests/package_api_lint.rs @@ -1,9 +1,542 @@ -//! Runs `build.rs`'s own unit tests — the Package API lint's path scanner — under `cargo test`. -//! -//! Cargo compiles a build script only as a build script, so its `#[cfg(test)]` module has no other -//! way to run. Declaring the file as a module here needs no seam and cannot drift from what the -//! build itself executes; the codegen half of the file simply has no caller in this target. -#![allow(dead_code)] - -#[path = "../build.rs"] -mod build_script; +//! The Package API lint, an Architecture Test: every installed Package names only Package API +//! version 3. Core CI runs it with no Package installed; the official Packages job runs it with +//! the collection installed. + +mod package_api; + +use std::path::{Path, PathBuf}; + +use package_api::{check_inventory, check_source, Contract, Diagnostic, Finding}; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn contract() -> Contract { + Contract::read(&manifest_dir().join("src/tables.rs")) +} + +#[test] +fn the_documented_version_and_roots_match_the_lint() { + let document = std::fs::read_to_string(manifest_dir().join("../docs/package-api.md")) + .expect("read the Package API contract"); + assert_eq!(document.lines().next(), Some("# Package API, version 3")); + let roots = document + .split_once("### Module roots") + .expect("the contract documents Module roots") + .1 + .split_once("```") + .expect("the contract lists roots in a code block") + .1 + .split_once("```") + .expect("the root list has a closing fence") + .0; + let mut documented: Vec<_> = roots.split_whitespace().collect(); + let mut checked = package_api::ROOTS.to_vec(); + documented.sort_unstable(); + checked.sort_unstable(); + assert_eq!(documented, checked); +} + +#[test] +fn table_aliases_at_the_crate_root_require_the_tables_path() { + let diagnostics = diagnostics_at( + "mod.rs", + "use crate::{Character, game_character};\nlet entity = super::WorldEntity;\n", + ); + let replacements: Vec<_> = diagnostics + .into_iter() + .map(|diagnostic| match diagnostic.finding { + Finding::Outside { replacement, .. } => replacement, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + replacements, + [ + Some("crate::tables::Character".to_string()), + Some("crate::tables::game_character".to_string()), + Some("crate::tables::WorldEntity".to_string()), + ] + ); +} + +#[test] +fn a_crate_alias_after_a_nested_use_group_is_unsupported() { + let source = + "use {crate::actor::{self, live_entity}, crate as core};\nuse core::auth::Account;\n"; + assert_eq!(reported(source), ["1:crate alias `core`"]); +} + +#[test] +fn anonymous_use_groups_cannot_hide_a_whole_crate_alias() { + let source = "use crate::{{self as core}}; fn f() { core::auth::secret(); }"; + assert_eq!(reported(source), ["1:crate alias `core`"]); + assert!(reported("use crate::{{actor::{self as api}}, {self as _}};").is_empty()); +} + +#[test] +fn unicode_whole_crate_aliases_are_unsupported() { + assert_eq!( + reported("use crate as 核心; fn f() { 核心::auth::secret(); }"), + ["1:crate alias `核心`"] + ); + assert_eq!( + reported("use crate::{{self as 核}};"), + ["1:crate alias `核`"] + ); +} + +#[test] +fn unicode_modules_contribute_to_relative_path_depth() { + assert_eq!( + reported("mod 核心 { use super::super::auth; }"), + ["1:super::super::auth"] + ); + assert_eq!( + reported("mod r#核心 { use super::{super::auth}; }"), + ["1:super::super::auth"] + ); +} + +#[test] +fn relative_use_groups_accumulate_their_super_segments() { + let source = "use super::{sibling, {super::auth}}; fn f() { auth::secret(); }"; + assert_eq!(reported_at("child.rs", source), ["1:super::super::auth"]); + let nested = "mod nested { use super::{super::{sibling, super::auth}}; }"; + assert_eq!( + reported_at("child.rs", nested), + ["1:super::super::super::auth"] + ); + assert!(reported_at("child.rs", "use super::{sibling, actor::{self as api}};").is_empty()); +} + +#[test] +fn raw_c_strings_with_quotes_and_core_paths_are_inert() { + let source = "let _ = cr#\"quoted \" crate::auth::secret \"\"#;\n\ + let _ = cr##\"\"# // package-api: exempt fake\n\ + crate::auth::secret \"##;\n\ + crate::auth::secret();\n"; + assert_eq!(reported(source), ["4:crate::auth::secret"]); +} + +#[test] +fn comment_lines_follow_multiline_literals_and_nested_block_comments() { + let source = "let _ = cr#\"first\nsecond\"#;\n\ + /* first\n/* nested */ last */\n\ + crate::auth::secret(); // package-api: exempt owned gap\n\ + crate::auth::secret(); // ordinary comment\n"; + assert_eq!(reported(source), ["6:crate::auth::secret"]); +} + +#[test] +fn enabled_packages_use_package_api() { + let packages = manifest_dir().join("../packages"); + if let Err(diagnostics) = check_inventory(&contract(), &packages) { + let lines: Vec = diagnostics.iter().map(Diagnostic::to_string).collect(); + panic!( + "{} Package API finding(s):\n{}", + lines.len(), + lines.join("\n") + ); + } +} + +fn diagnostics_at(relative_path: &str, source: &str) -> Vec { + check_source(&contract(), "sample", Path::new(relative_path), source) +} + +/// Each finding as `line:path`, the path as written. +fn reported_at(relative_path: &str, source: &str) -> Vec { + diagnostics_at(relative_path, source) + .into_iter() + .map(|diagnostic| { + let path = match diagnostic.finding { + Finding::Outside { path, .. } + | Finding::GatedChild { path, .. } + | Finding::PackageModule { path } => path, + Finding::Unsupported { written, .. } => written, + }; + format!("{}:{path}", diagnostic.line) + }) + .collect() +} + +fn reported(source: &str) -> Vec { + reported_at("mod.rs", source) +} + +#[test] +fn the_contract_reads_each_name_the_table_catalog_exports() { + let contract = contract(); + for name in [ + "game_character", + "Character", + "game_package_config", + "PartyPartitionState", + "game_debug_readout", + ] { + assert!(contract.catalogs(name), "{name}"); + } + for name in [ + "game_operation", + "UnusedUpperCamel", + "SessionActor", + "spell", + ] { + assert!(!contract.catalogs(name), "{name}"); + } +} + +#[test] +#[should_panic(expected = "names each table")] +fn a_glob_in_the_table_catalog_is_refused() { + Contract::from_catalog("pub use crate::character::*;\n"); +} + +#[test] +fn the_fixture_inventory_reports_each_finding_with_its_location() { + let fixtures = manifest_dir().join("tests/package_api/fixtures"); + let diagnostics = check_inventory(&contract(), &fixtures).expect_err("denied has findings"); + let lines: Vec = diagnostics.iter().map(Diagnostic::to_string).collect(); + let outside = "outside Package API version 3 (docs/package-api.md)."; + let any_root = "Name a path under `crate::actor`, `crate::hooks`, `crate::package`, \ + `crate::tables`, `crate::script_binding`, or write `// package-api: exempt \ + ` on that line and raise the gap with the maintainers."; + let debug_gate = "#![cfg(feature = \"debug_reducers\")]"; + assert_eq!( + lines, + [ + format!( + "Package `denied` names `super::super::spell::pending_cast` at \ + packages/denied/src/goals.rs:3, {outside} Use `crate::actor::pending_cast`." + ), + format!( + "Package `denied` names `super::super::spell::CastStart` at \ + packages/denied/src/goals.rs:3, {outside} Use `crate::actor::CastStart`." + ), + "Package `denied` uses unsupported syntax crate alias `core` at \ + packages/denied/src/goals.rs:4. Whole-crate aliases can hide core paths across Rust \ + scopes. Spell each core path as `crate::` instead." + .to_string(), + format!( + "Package `denied` names `crate::package::fixture::apply_damage` at \ + packages/denied/src/goals.rs:7. `crate::package::fixture` is Package API only in \ + a file whose first non-blank line is `{debug_gate}`." + ), + format!( + "Package `denied` names `crate::helpers::require_operator` at \ + packages/denied/src/mod.rs:3, {outside} Use `crate::package::require_operator`." + ), + format!( + "Package `denied` names `crate::package::fixture::apply_damage` at \ + packages/denied/src/mod.rs:4. `crate::package::fixture` is Package API only in \ + a file whose first non-blank line is `{debug_gate}`." + ), + "Package `denied` names the `crate::package` module itself as `crate::package` at \ + packages/denied/src/mod.rs:5. Name `crate::package::` instead, so `fixture` \ + and `test` cannot leave their file gates through the module." + .to_string(), + format!( + "Package `denied` names `crate::UnusedUpperCamel` at \ + packages/denied/src/mod.rs:7, {outside} {any_root}" + ), + format!( + "Package `denied` names `crate::game_operation` at \ + packages/denied/src/mod.rs:8, {outside} {any_root}" + ), + ] + ); +} + +#[test] +fn version_3_roots_tables_markers_and_generated_roots_are_allowed() { + let source = "use crate::actor::movement::route_step;\n\ + use crate::tables::{game_spell, Spell};\n\ + use crate::tables::{game_character, Character, PartyPartitionState};\n\ + use crate::package::{require_operator, character, encounter::{self, ENCOUNTER_DONE}};\n\ + fn f(p: &crate::hooks::LevelupPayload) {\n\ + crate::script_binding::ask();\n\ + crate::game_tick_pass!(fn pass(ctx) {});\n\ + crate::character_owned!(delete, fn sweep_delete_pkg_sample_row(ctx, guid) {});\n\ + let _ = crate::CHARACTER_OWNED_TABLES;\n\ + crate::pkg_sibling::shared();\n\ + }\n"; + assert!(reported(source).is_empty(), "{:?}", reported(source)); +} + +#[test] +fn a_removed_family_root_names_its_version_3_replacement() { + let diagnostics = diagnostics_at( + "mod.rs", + "let cast = crate::spell::pending_cast(ctx, guid);\n\ + let start = crate::spell::CastStart::Started;\n\ + crate::helpers::require_character(ctx);\n\ + use crate::encounter::{self, Unlisted};\n", + ); + let replacements: Vec<_> = diagnostics + .into_iter() + .map(|diagnostic| match diagnostic.finding { + Finding::Outside { path, replacement } => (path, replacement), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + replacements, + [ + ( + "crate::spell::pending_cast".to_string(), + Some("crate::actor::pending_cast".to_string()) + ), + ( + "crate::spell::CastStart::Started".to_string(), + Some("crate::actor::CastStart".to_string()) + ), + ("crate::helpers::require_character".to_string(), None), + ( + "crate::encounter::self".to_string(), + Some("crate::package::encounter".to_string()) + ), + ( + "crate::encounter::Unlisted".to_string(), + Some("crate::package::encounter".to_string()) + ), + ] + ); +} + +#[test] +fn an_unused_upper_camel_name_is_outside_the_package_api() { + assert_eq!( + reported("fn f(_: crate::UnusedUpperCamel) {}\n"), + ["1:crate::UnusedUpperCamel"] + ); +} + +#[test] +fn an_invented_game_operation_is_outside_the_package_api() { + assert_eq!( + reported("crate::game_operation(ctx);\n"), + ["1:crate::game_operation"] + ); +} + +#[test] +fn a_gated_child_is_refused_from_an_ordinary_file() { + // An ordinary build compiles these files; an item gate does not change what the lint sees. + let debug_item = "#[cfg(feature = \"debug_reducers\")]\nfn f() {\n crate::package::fixture::apply_damage();\n}\n"; + let test_module = "#[cfg(test)]\nmod tests {\n use crate::package::test::shape_of;\n}\n"; + assert_eq!( + reported(debug_item), + ["3:crate::package::fixture::apply_damage"] + ); + assert_eq!(reported(test_module), ["3:crate::package::test::shape_of"]); +} + +#[test] +fn a_gated_child_is_allowed_in_its_gated_file() { + let debug_file = "\n#![cfg(feature = \"debug_reducers\")]\nuse crate::package::fixture::{apply_damage, top_threat_target};\nfn f() {\n super::super::package::fixture::client_cast();\n}\n"; + let test_file = "#![cfg(test)]\nuse crate::package::test::{ask_offline, read_scanned};\nfn f() {\n super::super::package::test::shape_of();\n}\n"; + assert!(reported_at("verify.rs", debug_file).is_empty()); + assert!(reported_at("tests.rs", test_file).is_empty()); +} + +#[test] +fn each_gated_child_needs_its_own_gate() { + let test_file = "#![cfg(test)]\ncrate::package::fixture::apply_damage();\n"; + let debug_file = + "#![cfg(feature = \"debug_reducers\")]\ncrate::package::test::ask_offline();\n"; + assert_eq!( + reported(test_file), + ["2:crate::package::fixture::apply_damage"] + ); + assert_eq!( + reported(debug_file), + ["2:crate::package::test::ask_offline"] + ); +} + +#[test] +fn an_alias_cannot_carry_a_gated_child_past_its_gate() { + let source = "use crate::package as api;\n\ + use crate::package;\n\ + use crate::package::{self as api, require_operator};\n\ + use crate::{package as api};\n\ + use crate::package::*;\n\ + use super::package;\n\ + use crate::package::fixture as f;\n\ + use crate::package::{fixture::{self as f}};\n"; + let kinds: Vec = diagnostics_at("mod.rs", source) + .into_iter() + .map(|diagnostic| match diagnostic.finding { + Finding::PackageModule { path } => format!("{}:module {path}", diagnostic.line), + Finding::GatedChild { path, .. } => format!("{}:gated {path}", diagnostic.line), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + kinds, + [ + "1:module crate::package", + "2:module crate::package", + "3:module crate::package::self", + "4:module crate::package", + "5:module crate::package::*", + "6:module super::package", + "7:gated crate::package::fixture", + "8:gated crate::package::fixture::self", + ] + ); +} + +#[test] +fn an_exemption_cannot_clear_a_gated_child_or_the_package_module() { + let source = "crate::package::fixture::apply_damage(); // package-api: exempt fixture damage\n\ + use crate::package::test::read_scanned; // package-api: exempt scan Core\n\ + use crate::package as api; // package-api: exempt shorter paths\n"; + assert_eq!( + reported(source), + [ + "1:crate::package::fixture::apply_damage", + "2:crate::package::test::read_scanned", + "3:crate::package", + ] + ); +} + +#[test] +fn an_exemption_clears_its_own_line_and_no_other() { + let source = "fn f() {\n crate::auth::create_character(); // package-api: exempt the Package owns its Characters\n crate::auth::Account::default();\n}\n"; + assert_eq!(reported(source), ["3:crate::auth::Account::default"]); +} + +#[test] +fn an_exemption_without_a_reason_does_not_clear() { + assert_eq!( + reported("crate::auth::Account; // package-api: exempt\n"), + ["1:crate::auth::Account"] + ); +} + +#[test] +fn marker_text_inside_a_string_is_not_an_exemption() { + let source = "let note = \"// package-api: exempt not a comment\"; crate::auth::Account;\n"; + assert_eq!(reported(source), ["1:crate::auth::Account"]); +} + +#[test] +fn a_path_in_a_comment_or_a_string_is_inert() { + let source = "// crate::spell::pending_cast is Version 2\n\ + /* crate::package::fixture::apply_damage */\n\ + let s = \"crate::game_operation\";\n\ + let r = r#\"crate::UnusedUpperCamel\"#;\n"; + assert!(reported(source).is_empty(), "{:?}", reported(source)); +} + +#[test] +fn a_nested_use_tree_is_read_leaf_by_leaf() { + let source = "use crate::{\n tables::game_world_entity,\n auth::Account,\n actor::{movement::{route_path, RoutePoint}, quest_complete},\n spell::{pending_cast, CastStart},\n package::{character::build_player_entity, fixture::apply_damage},\n};\n"; + assert_eq!( + reported(source), + [ + "3:crate::auth::Account", + "5:crate::spell::pending_cast", + "5:crate::spell::CastStart", + "6:crate::package::fixture::apply_damage", + ] + ); +} + +#[test] +fn another_crates_path_is_not_this_crates_path() { + let source = + "use lyracore_crate::auth::Account;\nuse lyracore_shared::spell::SPELL_ATTR_CHANNELED;\n"; + assert!(reported(source).is_empty(), "{:?}", reported(source)); +} + +#[test] +fn a_relative_path_that_leaves_the_package_is_checked() { + assert_eq!( + reported("use super::auth::Account;\n"), + ["1:super::auth::Account"] + ); + let nested = "mod tests {\n use super::super::package_sibling;\n use super::super::super::auth::Account;\n}\n"; + assert_eq!( + reported_at("goals.rs", nested), + ["3:super::super::super::auth::Account"] + ); + let raw_module = "mod r#nested { use super::package_item; use super::super::auth::Account; }\n"; + assert_eq!(reported(raw_module), ["1:super::super::auth::Account"]); +} + +#[test] +fn package_relative_siblings_and_submodules_stay_inside_the_package() { + let source = "use super::sibling;\nmod tests {\n use super::super::package_root_item;\n}\n"; + assert!(reported_at("goals.rs", source).is_empty()); + assert!(reported_at("runner/movement.rs", "use super::super::auth;\n").is_empty()); +} + +#[test] +fn raw_identifiers_are_normalized_for_the_package_api() { + let source = "use crate::r#actor::live_entity;\nuse crate::r#auth::Account;\nuse crate::package::r#fixture::apply_damage;\n"; + assert_eq!( + reported(source), + [ + "2:crate::r#auth::Account", + "3:crate::package::r#fixture::apply_damage" + ] + ); +} + +#[test] +fn dollar_crate_and_spaced_separators_do_not_bypass_the_lint() { + let source = "macro_rules! hidden { () => { $crate::auth::Account } }\nuse crate /* gap */ :: test_scan;\n"; + assert_eq!( + reported(source), + ["1:$crate::auth::Account", "2:crate::test_scan"] + ); +} + +#[test] +fn a_crate_root_glob_is_outside_the_package_api() { + assert_eq!( + reported("use crate::*;\nuse super::*;\n"), + ["1:crate::*", "2:super::*"] + ); +} + +#[test] +fn every_whole_crate_alias_spelling_is_unsupported() { + let source = "use {crate as first};\nuse crate::{self as second, actor};\nextern crate self as third;\nuse crate as r#fourth; // package-api: exempt legacy spelling\n"; + assert_eq!( + reported(source), + [ + "1:crate alias `first`", + "2:crate alias `second`", + "3:crate alias `third`", + "4:crate alias `r#fourth`", + ] + ); +} + +#[test] +fn a_package_alias_named_core_is_not_the_crate() { + let source = + "mod owned { pub mod auth {} }\nfn f() { use self::owned as core; use core::auth; }\n"; + assert!(reported(source).is_empty(), "{:?}", reported(source)); +} + +#[test] +fn path_attributes_are_unsupported_even_when_conditional_or_exempted() { + let source = "#[path = \"layout/hidden.rs\"] // package-api: exempt legacy layout\nmod hidden;\n#[cfg_attr(unix, path = \"unix.rs\")]\nmod platform;\n"; + assert_eq!( + reported(source), + ["1:`#[path]`", "3:`#[cfg_attr(..., path = ...)]`"] + ); +} + +#[test] +fn include_is_unsupported_and_data_includes_are_not() { + let source = "include /* gap */ ! (\"private.inc\");\nr#include!(\"hidden.inc\");\nlet text = include_str!(\"fixture.txt\");\nlet bytes = include_bytes!(\"fixture.bin\");\n"; + assert_eq!(reported(source), ["1:`include!`", "2:`include!`"]); +}