Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
186e683
refactor(module): move the post-publish repair pass out of the debug …
Mathih13 Oct 10, 2026
9a86307
test(module): delete tests that pin source text
Mathih13 Oct 10, 2026
4451125
test(gateway): delete tests that pin source text
Mathih13 Oct 10, 2026
8a510e4
test(module): name each Architecture Test's invariant and prove it fires
Mathih13 Oct 10, 2026
f18aa54
refactor(test-support): move the Rust body scanner into lyracore-test…
Mathih13 Oct 10, 2026
b2e64bc
refactor(gateway): deny panic constructs at untrusted-input boundarie…
Mathih13 Oct 10, 2026
954d65a
test(gateway): prove provision writes the account to both databases
Mathih13 Oct 10, 2026
d30492a
docs: describe current behavior and move long module guides
Mathih13 Oct 10, 2026
eeb4550
test: cover reducer and startup wiring through durable fixtures
Mathih13 Oct 10, 2026
739da3e
fix(ci): fail closed when scanning Module print macros
Mathih13 Oct 10, 2026
fcf3721
test(module): cover normal world-entry mount and GM projections
Mathih13 Oct 10, 2026
8f775a0
fix(gateway): retain Coordinator subscriptions during comment cleanup
Mathih13 Oct 10, 2026
620883a
docs(test): clarify current Transfer assertion diagnostics
Mathih13 Oct 10, 2026
d1619bc
docs: move the remaining binding parity module guide
Mathih13 Oct 10, 2026
7951aed
docs: point Rust comments at moved docs and drop dangling ones
Mathih13 Oct 10, 2026
738fde4
docs: normalize spacing artifacts in swept Rust comments
Mathih13 Oct 10, 2026
d22d08a
docs: repair comment sweep damage in Rust sources
Mathih13 Oct 10, 2026
f2b3193
test(transfer): name the export loop in the guid assertion message
Mathih13 Oct 10, 2026
5b1d4b7
test(module): cover operator gates and the transfer fence through dur…
Mathih13 Oct 10, 2026
eea4733
docs(comments): drop work-item numbers from Rust comments
Mathih13 Oct 10, 2026
67d87c7
docs(comments): drop remaining work-item tags from Rust comments
Mathih13 Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/module-durable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ jobs:
cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::roster_relay_durable_tests::a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror -- --ignored --exact
cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::meeting_stone_durable_tests::seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror -- --ignored --exact
cargo test -p lyracore-gateway --bin lyracore-gateway stdb::armor::tests::resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken -- --ignored --exact
cargo test -p lyracore-gateway --test provision_cli provision_writes_the_account_to_the_world_shard_and_realm_core -- --ignored --exact
cargo test -p lyracore-gateway --test startup_relays -- --ignored --test-threads=1

# No Package registers a client command here, so the Module's fixture command carries it.
- name: Run the cross-Shard command transport tests
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check formatting
run: cargo fmt --all -- --check
# The SpacetimeDB build aborts on a print-family macro anywhere in Module source, even in a
# comment or a test. Use `writeln!(std::io::stderr(), ...)` for a test diagnostic.
- name: Check for print macros in Module source
run: |
roots=(module/src)
for source in packages/*/src; do
[ ! -d "$source" ] || roots+=("$source")
done
status=0
grep -rnE --include='*.rs' 'e?print(ln)?!' "${roots[@]}" || status=$?
[ "$status" -eq 1 ]

clippy:
runs-on: ubuntu-latest
Expand Down
8 changes: 0 additions & 8 deletions crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,6 @@ enum Artifact {
Script(ScriptArtifact),
}

/// Prints [`HOOK_EVENT_NAMES`], one event per line, in catalogue order, and exits.
///
/// `--print-events` must be the only argument: this mode reads no artifact, no other file and the
/// clock never enters it, so the output is byte-stable across machines and runs. A future Package's
/// TypeScript authoring surface is generated from this output, so it stays the one place outside the
/// Module's own build that knows the Event Binding catalogue — `HOOK_EVENT_NAMES` here is already a
/// second copy of `module/build.rs`'s `HOOK_EVENTS`; this prints that copy rather than hand-writing
/// a third.
fn print_events(args: &[String]) -> ExitCode {
if args.len() != 1 {
eprintln!(
Expand Down
129 changes: 1 addition & 128 deletions crates/lyracore-package-delta/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,131 +1,4 @@
//! The **Package Delta** artifact and the claim conflict tracer.
//!
//! A Package Delta is the durable record of what one Package changes in the spell tables. It names
//! the Package, the source it was generated from, and one claim per row: the table, the typed
//! primary key, the operation, and the columns the Package sets there. A base import replaces whole
//! data families, so a Package's row edits have to be a replayable artifact rather than a one-shot
//! edit that the next import silently reverts.
//!
//! This library is pure and deterministic. It reads no files, keeps no state, reads no clock, and
//! writes nothing. It parses, canonicalizes, and traces; applying a delta to a shard is a separate
//! concern. The crate also ships `lyracore-delta-check`, a command that reads named artifact files
//! and prints the plan they form — the IO lives there, outside everything a caller links against.
//!
//! # The artifact
//!
//! ```json
//! {
//! "version": 1,
//! "package": "example.sharper-fireballs",
//! "source_hash": "0000000000000000000000000000000000000000000000000000000000000000",
//! "claims": [
//! {
//! "table": "game_spell",
//! "key": { "spell_id": 133 },
//! "operation": "update",
//! "fields": {
//! "cooldown_ms": { "type": "u32", "value": 1500 },
//! "family_flags": { "type": "u64", "value": "18446744073709551615" }
//! }
//! },
//! {
//! "table": "game_spell_effect",
//! "key": { "spell_id": 133, "effect_index": 0 },
//! "operation": "update",
//! "fields": { "base_points": { "type": "i32", "value": 120 } }
//! }
//! ]
//! }
//! ```
//!
//! Every value states its type. An unsigned 64-bit value is a decimal **string**, because a JSON
//! number loses precision above 2^53 and would come back silently wrong. A `game_spell_effect` key
//! names the spell and the effect index; the packed durable key
//! (`(spell_id << 2) | effect_index`) is derived, never authored.
//!
//! # What it refuses
//!
//! Unknown versions, tables, columns, type tags and malformed keys fail at [`PackageDelta::parse`],
//! before an applier sees the artifact. So does row deletion: uninstalling a Package replays the
//! affected families without its claims, and there is no pre-image a delete could be undone from.
//! An `update` names only the columns it changes; an `insert` carries the whole row.
//!
//! # What it traces
//!
//! [`trace`] merges the claims of several Packages. Different columns of one row merge. The same
//! column, or two inserts at one primary key, is a [`ClaimConflict`] naming both Packages and the
//! exact claim. There are no priority numbers: the tracer reports that a human has to choose.
//!
//! # Import Families
//!
//! Every claimable table belongs to one Import Family, the unit a base import clears and reloads
//! and the unit an apply runs for. [`Table`] is one closed enum with its variants grouped by
//! family, and [`Table::family`] names the owner, so an applier called for one family can tell that
//! a claim belongs to the import it is running. This build's catalogue is the spell, item, quest,
//! loot, cast, trainer, gossip, globals, spellmeta, creatures, gameobjects and creature-ai
//! families. `game_creature_quest`/`game_gameobject_quest` (quest givers) and
//! `game_creature_loot`/`game_npc_vendor` (creature-scoped loot and vendor stock) are out of it.
//! So is `game_start_item`, which two importer passes own between them, `game_creature_waypoint`,
//! which carries no map to route by, and the two gameobject pool tables, which no base import
//! writes. So are the `EventAI` definition tables, whose rules are a nested payload no scalar column
//! shape can state: a Package Delta claims typed rows, never a script blob. See
//! [`Table::columns`], [`LOOT_FAMILY`], [`GLOBALS_FAMILY`], [`CREATURE_FAMILY`],
//! [`GAMEOBJECT_FAMILY`] and [`CREATURE_AI_FAMILY`] for why.
//!
//! # Spatial claims and routing
//!
//! Two tables are SPATIAL: `game_creature_spawn` and `game_gameobject`. Their claims carry the map
//! in the primary key rather than in a column, and [`PrimaryKey::map_id`] reads it. A spatial claim
//! reaches only the Shards whose World Import Scope owns that map; the importer applies that filter
//! with the scope it already builds for the base import, so there is one routing concept and this
//! crate only has to state which map a row sits on. Every other table is a global catalogue every
//! Shard loads whole.
//!
//! A spawn's durable guid is derived from the key, never authored: `game_creature_spawn` packs its
//! creature template and spawn identifier into one guid, `game_gameobject` its spawn identifier
//! alone. The map is not part of either guid, which is why it is identity for routing and not a
//! claimable column: a Package cannot move a placed row onto a map another Shard owns.
//!
//! Most tables permit insert and partial update. Six are update-only, because their primary key
//! names something no Package may invent: a creature template (`game_creature_cast`,
//! `game_gossip_menu`), a race, class or level the client fixes (`game_class_level_stats`,
//! `game_level_stats`, `game_start_position`), or an `AreaTrigger.dbc` trigger id
//! (`game_areatrigger_teleport`). [`DeltaError::InsertNotSupported`] refuses every insert on those
//! at parse time, regardless of identifier.
//!
//! # Identifier policy
//!
//! An inserted row must sit in its family's Package identifier band, which no client and no import
//! can reach — for spells, [`PACKAGE_SPELL_ID_FLOOR`]..=[`PACKAGE_SPELL_ID_CEIL`]; for items,
//! [`PACKAGE_ITEM_ID_FLOOR`]..=[`PACKAGE_ITEM_ID_CEIL`]; for quests,
//! [`PACKAGE_QUEST_ID_FLOOR`]..=[`PACKAGE_QUEST_ID_CEIL`]; for loot rows,
//! [`PACKAGE_LOOT_ID_FLOOR`]..=[`PACKAGE_LOOT_ID_CEIL`]; for `game_creature_spell` rows,
//! [`PACKAGE_CAST_ID_FLOOR`]..=[`PACKAGE_CAST_ID_CEIL`]; for `game_trainer_spell` rows,
//! [`PACKAGE_TRAINER_ID_FLOOR`]..=[`PACKAGE_TRAINER_ID_CEIL`]; for the insertable gossip rows,
//! [`PACKAGE_GOSSIP_ID_FLOOR`]..=[`PACKAGE_GOSSIP_ID_CEIL`]; for the insertable globals rows,
//! [`PACKAGE_GLOBALS_ID_FLOOR`]..=[`PACKAGE_GLOBALS_ID_CEIL`]; for `game_spell_learn` rows,
//! [`PACKAGE_SPELLMETA_ID_FLOOR`]..=[`PACKAGE_SPELLMETA_ID_CEIL`]; for the creature template and
//! spawn rows, [`PACKAGE_CREATURE_ID_FLOOR`]..=[`PACKAGE_CREATURE_ID_CEIL`]; for the gameobject
//! template, trap and spawn rows,
//! [`PACKAGE_GAMEOBJECT_ID_FLOOR`]..=[`PACKAGE_GAMEOBJECT_ID_CEIL`]; for the broadcast text,
//! summon placement and quest event requirement rows,
//! [`PACKAGE_CREATURE_AI_ID_FLOOR`]..=[`PACKAGE_CREATURE_AI_ID_CEIL`]. `game_spell_chain` and
//! `game_spell_proc_event` key on a spell identifier rather than one of their own, so an insert
//! there takes the Package SPELL band. An update may name any row. Tuning real data is the point —
//! except a fixture-reserved one, which no Package may touch under any operation, and except the
//! update-only tables above. See [`ids`] for the bands, the formula a family's band follows, and
//! why they sit where they do.
//!
//! # The other artifact
//!
//! [`script`] holds the **Script Artifact**: the whole Runtime Scripts one Package ships. It is a
//! second kind rather than more tables in the claim schema because a Runtime Script has no base
//! import behind it, so a Package owns the whole row and two Packages meeting on one is a collision
//! rather than a merge.
//!
//! Both kinds live in one Package's generated directory, so a reader that walks `*.json` meets
//! both. [`artifact_kind`] reads the root `kind` member alone and says which is which; each
//! parser refuses the other kind by name rather than complaining about its members.
//! Package Delta format. See `docs/package-delta-format.md` for the contract and rationale.

#![forbid(unsafe_code)]
#![warn(missing_docs, clippy::pedantic)]
Expand Down
2 changes: 0 additions & 2 deletions crates/lyracore-package-delta/tests/script_artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ fn an_artifact_reads_every_member_of_every_script() {
assert_eq!(script.source(), "grant_xp(event.actor, 10)");
}

/// A Package that ships nothing is a legal statement, not an empty file to guess about. It is how
/// a Package that used to ship scripts says it no longer does.
#[test]
fn an_artifact_may_ship_no_scripts_at_all() {
let parsed = ScriptArtifact::parse(&artifact("example.quiet", &[])).expect("artifact parses");
Expand Down
3 changes: 0 additions & 3 deletions crates/lyracore-package-delta/tests/script_refusals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,6 @@ fn both_ends_of_the_package_band_are_shippable() {
}
}

/// The band's placement, not just its ends: a fixture-reserved identifier is UNSPELLABLE as a
/// script rather than refused by a second check, because the whole band sits below every reserved
/// one. `ids.rs` asserts that at compile time; this states the consequence a package author sees.
#[test]
fn no_reserved_identifier_is_inside_the_package_script_band() {
for reserved in [
Expand Down
20 changes: 9 additions & 11 deletions crates/lyracore-shared/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ pub mod player_flags {
pub const AFK: u32 = 0x0002;
/// `PLAYER_FLAGS_DND` (0x04), cm:Player.h:371.
pub const DND: u32 = 0x0004;
/// `PLAYER_FLAGS_GHOST` (0x10). The gameplay GHOST state set on Release Spirit: the
/// player is dead-but-walking (can move/run to the corpse, can't act). Distinct from the ghost
/// *render* — that's `unit_vis_flags::GHOST` in `UNIT_FIELD_BYTES_1`.

pub const GHOST: u32 = 0x0010;
}

Expand Down Expand Up @@ -128,7 +126,7 @@ pub mod unit_vis_flags {
/// `UNIT_FIELD_BYTES_2` BYTE 0 — the sheath state (vanilla 1.12), stored in
/// `game_world_entity.unit_bytes_2`. Says whether a weapon is DRAWN or STOWED; the per-item
/// `item_template.sheath` byte (a different field, sent in the item query) says WHERE a stowed
/// weapon hangs. Both are needed to render a sheathed weapon correctly. [#101]
/// weapon hangs. Both are needed to render a sheathed weapon correctly.
pub mod sheath_state {
/// Nothing drawn — weapons hang in their stow positions. The state every row starts in.
pub const UNARMED: u8 = 0;
Expand Down Expand Up @@ -344,14 +342,14 @@ pub mod taxi_fixture {
(0xF130_u64 << 48) | ((FLIGHT_MASTER_ENTRY as u64) << 24) | 1;
}

/// Gossip menu OPTION (work-item 217): `game_gossip_option.action` codes as they land verbatim from
/// Gossip menu OPTION: `game_gossip_option.action` codes as they land verbatim from
/// the cmangos dump's `gossip_menu_option.OptionType`/`option_id` column (the importer copies it
/// through unchanged — this module documents what the values MEAN so the gateway dispatch and the
/// importer agree without duplicating magic numbers). `[V]` — confirm against your own dump; only
/// GOSSIP/BANKER/VENDOR/TAXI/TRAINER/INNKEEPER are read by the dispatcher today, the rest are
/// inert (submenu navigation remains deferred).
pub mod gossip_option {
pub const GOSSIP: u32 = 1; // plain text / submenu link (submenu navigation deferred, work-item 217)
pub const GOSSIP: u32 = 1;
/// Quests reach the window through its QUEST section, never an option row, so the importer drops
/// these — the dump's rows carry the literal placeholder label "GOSSIP_OPTION_QUESTGIVER".
pub const QUESTGIVER: u32 = 2;
Expand All @@ -366,15 +364,15 @@ pub mod gossip_option {
/// unlearn my talents." row imports with `action=GOSSIP` (cmangos gates it in C++ code at
/// GossipHello, not via this column), so the importer reclassifies that specific row's text to
/// this action at import time (`resolve_gossip_option_text`'s caller in `importer/src/main.rs`).
/// Gated to level 10+ (talents don't exist below that) by `filtered_gossip_options` — #516.
/// Gated to level 10+ (talents don't exist below that) by `filtered_gossip_options`.
pub const UNLEARNTALENTS: u32 = 16;
}

/// Minimum level a talent point exists at (mirrors vanilla's `PLAYER_LEVEL_MIN_TALENTS` — the client
/// hides the talent pane below this). Gates the "I wish to unlearn my talents." gossip option.
pub const MIN_TALENT_LEVEL: u8 = 10;

/// `game_gossip_option.cond_type` — the MINIMAL condition set work-item 217 enforces (quest-status
/// `game_gossip_option.cond_type`, the MINIMAL condition set enforces (quest-status
/// gates, the common case in the dump). Anything the importer can't map to one of these folds to
/// `NONE` (fail-open + logged), so an unsupported condition never wrongly HIDES an option. The one
/// exception is `NEVER`, for a gate whose subject does not exist here at all.
Expand Down Expand Up @@ -402,7 +400,7 @@ mod tests {

/// `packed_with` must touch BYTE 0 only. Byte 1 is PvP flags, byte 2 pet flags, byte 3 the
/// shapeshift form — a sheath toggle that cleared a druid's form byte would pop them out of Bear
/// on every `Z` press, which is exactly the class of bug a blind `= state as u32` would ship. [#101]
/// on every `Z` press, which is exactly the class of bug a blind `= state as u32` would ship.
#[test]
fn packing_a_sheath_state_preserves_the_other_three_bytes() {
let neighbours = 0xAB_CD_EF_00_u32; // bytes 1-3 occupied, byte 0 clear
Expand Down Expand Up @@ -449,7 +447,7 @@ mod tests {
assert_eq!(npc_flags::AUCTIONEER, 0x1000);
}

/// Work-item 041: pin the shared GameObject QUESTGIVER type id against cmangos — this is the
/// Pins the shared GameObject QUESTGIVER type id against cmangos. This is the
/// SINGLE source both `module/src/gameobject.rs::go_type::QUESTGIVER` and the gateway's
/// `CMSG_GAMEOBJ_USE` dispatch read, so a silent edit here would desync both sides at once.
#[test]
Expand All @@ -463,7 +461,7 @@ mod tests {
assert_eq!(go_type::MEETINGSTONE, 23);
}

/// Work-item 217: the gossip option action codes the dispatcher matches on must be pairwise
/// The gossip option action codes the dispatcher matches on must be pairwise
/// distinct — a collision here would silently misroute one action to another's handler.
#[test]
fn gossip_option_actions_are_distinct() {
Expand Down
4 changes: 2 additions & 2 deletions crates/lyracore-shared/src/env.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Environmental damage (058): fall damage from the client-reported airborne time. Shared by the
//! Environmental damage: fall damage from the client-reported airborne time. Shared by the
//! module (applies the damage in `movement_update`) and the gateway (sends the matching
//! `SMSG_ENVIRONMENTAL_DAMAGE_LOG` flavor line) so the two can never drift.

Expand Down Expand Up @@ -77,7 +77,7 @@ pub fn drowning_damage(max_health: u32) -> u32 {
/// folded into the mangos height curve `damage% = 1.8%*yd - 24.26%` (safe under ~13.5 yd, which is
/// ~1.18 s airborne). `fall_time_ms` is the client's MovementInfo fall time on MSG_MOVE_FALL_LAND —
/// it INCLUDES the jump ascent, so a flat-ground hop (~750 ms) lands at 0. Client-authoritative like
/// all movement (a hacked client can lie; the 255 anti-cheat item owns that). Capped at max_health.
/// all movement (a hacked client can lie; the movement anti-cheat owns that). Capped at max_health.
pub fn fall_damage(fall_time_ms: u32, max_health: u32) -> u32 {
let t = fall_time_ms as f32 / 1000.0;
let height_yd = 0.5 * 19.29 * t * t;
Expand Down
12 changes: 5 additions & 7 deletions crates/lyracore-shared/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ pub mod event_kind {
pub const DECLINE: u8 = 2;
/// You are no longer in a group (left / kicked / disbanded) → `SMSG_GROUP_DESTROYED`.
pub const DESTROYED: u8 = 3;
// --- work-item 187 (group loot methods) / work-item 221 (money-loot split) — the roll/master-
// --- (group loot methods) / (money-loot split), the roll/master-
// loot/money-share relay REUSES this same per-recipient event table + relay (see
// `crate::loot_roll`'s module doc for why: identical shape — one recipient, a kind byte, a small
// payload — so a parallel new table would only add binding-checklist ceremony for zero
Expand Down Expand Up @@ -325,8 +325,6 @@ pub mod bot_op {
/// Module. Gateway reads at most one head from each lane per dispatch turn.
pub const COMMAND_DISPATCH_LANES: u8 = 8;

/// A terminal target receipt and its source response remain recoverable for this long after the
/// command's admission deadline.
pub const COMMAND_RESULT_WINDOW_MICROS: i64 = 30_000_000;

/// Why the Module refused a party Durable Request. The tag is the whole reducer error text, so
Expand Down Expand Up @@ -783,7 +781,7 @@ mod tests {
}
}

/// AC 3, 4: a full destination Subgroup refuses, an out-of-range one refuses, and the
/// 3, 4: a full destination Subgroup refuses, an out-of-range one refuses, and the
/// Assistant bit survives an accepted move.
#[test]
fn a_subgroup_move_refuses_a_full_or_out_of_range_destination() {
Expand Down Expand Up @@ -813,7 +811,7 @@ mod tests {
assert_eq!(mover.moved_to_subgroup(3, 5), Ok(None));
}

/// AC 6: a swap between two full Subgroups needs no capacity Gate: the pair trade places
/// 6: a swap between two full Subgroups needs no capacity Gate: the pair trade places
/// atomically, so neither Subgroup ever holds six members mid-swap.
#[test]
fn a_subgroup_swap_needs_no_capacity_gate() {
Expand All @@ -829,7 +827,7 @@ mod tests {
);
}

/// AC 7: cm:GroupHandler.cpp:939: two members already in one Subgroup swap to a no-op.
/// 7: cm:GroupHandler.cpp:939: two members already in one Subgroup swap to a no-op.
#[test]
fn a_subgroup_swap_within_one_subgroup_is_a_no_op() {
let first = RaidSlot::new(4, true).unwrap();
Expand Down Expand Up @@ -918,7 +916,7 @@ mod tests {
}
}

// ---- Realm-core party ops (issue #22, group slice) ----
// ---- Realm-core party ops (group slice) ----

/// The intent op byte is a MIGRATION value as well as a wire one: `op` was END-appended to
/// `game_bot_invite_intent` with a `0` default, so every row written before the column existed
Expand Down
Loading
Loading