From d9d71986a8509aba8edc96ff5e78a665d78f7336 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 18:47:47 +0200 Subject: [PATCH 01/16] docs(tickets): refresh the meeting stone tickets on current main Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/tickets/issue-532/README.md | 389 ++++++++++++++++++ .../issue-532/T1-meeting-stone-queue.md | 212 ++++++++++ .../issue-532/T2-roster-revision-relay.md | 127 ++++++ .../tickets/issue-532/T3-stone-matching.md | 181 ++++++++ .../issue-532/T4-integrate-verify-pr.md | 84 ++++ 5 files changed, 993 insertions(+) create mode 100644 .claude/tickets/issue-532/README.md create mode 100644 .claude/tickets/issue-532/T1-meeting-stone-queue.md create mode 100644 .claude/tickets/issue-532/T2-roster-revision-relay.md create mode 100644 .claude/tickets/issue-532/T3-stone-matching.md create mode 100644 .claude/tickets/issue-532/T4-integrate-verify-pr.md diff --git a/.claude/tickets/issue-532/README.md b/.claude/tickets/issue-532/README.md new file mode 100644 index 000000000..56059bb88 --- /dev/null +++ b/.claude/tickets/issue-532/README.md @@ -0,0 +1,389 @@ +# Issue #532 meeting stones: the 1.12.1 dungeon queue on Realm-core, split into tickets + +Issue #532 schedules this work. The maintainer deferred it from the issue #3 push on 2026-09-23 and +filed #532 to carry it. + +Closed spec #2 set the partition rule: state not coupled to space has one authoritative copy on +Realm-core, and party membership is authoritative there. + +## Settled maintainer decisions (#532) + +1. **Meeting stones only.** 1.12.1 has no LFG/LFM browse window, so none is built. No Package + addon window either. +2. **Humans only.** A Seeker needs a World Session and a live Account Claim. Playerbots do not + queue; a leader invites them into a stone party by ordinary invite, and the party counts their + class like any member's. + +## Sources and citation keys + +| Key | Source | Pin | +|---|---|---| +| `cm:` | cmangos mangos-classic `src/game/` | `ca0775fe352fb67a0e82dc6051f0563187d569a5` | +| `vm:` | vmangos core `src/game/` | `4b350a09fca8b5797975e343ae6300fbb5f9937b` | +| `fx:` | stock 1.12 FrameXML (`## Interface: 11200`), `AtheneGenesis/Vanilla_enUS_FrameXML` | `64ba0a1fa7cf42679284b608562bde094bbee75f` | +| `wwm:` | `wow_world_messages` 0.3.0 and `wow_world_base` 0.3.0 | cargo registry | + +cmangos LFG code is `LFG/{LFGHandler,LFGMgr,LFGQueue}.cpp` and `LFG/LFGDefines.h`. vmangos has the +same four files. The two queue implementations are the same algorithm line for line; vmangos only +swaps packet builders and sleeps 1 s instead of 500 ms (`vm:LFG/LFGQueue.cpp:66-224`, +`cm:LFG/LFGQueue.cpp:63-225`). + +## What 1.12.1 ships + +**No LFG/LFM window.** The 1.12 FrameXML has no LFG frame. The only LFG widget, a "I would like to +join a group" checkbox on the Who tab that calls `SetLookingForGroup`, sits inside an XML comment +(`fx:FriendsFrame.xml:1212-1301`, string `LFG_LABEL` at `fx:GlobalStrings.lua:2581`). Neither core +handles `CMSG_SET_LOOKING_FOR_GROUP` 0x200 (`cm:Server/Opcodes.cpp:540` `STATUS_NEVER`, +`vm:Server/Protocol/Opcodes.cpp:603` unhandled), and `wwm:` has no vanilla parser for it. The +LFG/LFM tool with dungeon, zone and quest entries, comments and browse results is the TBC tool: +`CMSG_SET_LFG_COMMENT` 0x366 and `SMSG_LFG_DISABLED` exist in `wwm:` only as `_tbc_wrath` types. + +`MSG_LOOKING_FOR_GROUP` 0x1FF exists. The client message is empty, the server message is one `u32`. +vmangos answers it with `0` (`vm:Handlers/MiscHandler.cpp:277-282`); cmangos ignores it +(`cm:Server/Opcodes.cpp:539`). The stock UI never reads the answer. + +**Meeting stones with a server queue.** This is the whole 1.12 LFG feature. + +| Fact | Source | +|---|---| +| A meeting stone is GameObject type 23. `data0` min level, `data1` max level, `data2` AreaTable id of the dungeon | `cm:Entities/GameObject.h:266-272`, `vm:Objects/GameObjectDefines.h:437-443` | +| The client sends `CMSG_MEETINGSTONE_JOIN(go guid)` on use, not `CMSG_GAMEOBJ_USE`. vmangos says so and returns at once from `Use` for type 23. T4's client check confirms it | `vm:Objects/GameObject.cpp:1836-1841` | +| Join gates: interactable GO of type 23, else silent. In a party, in this order: not leader → `JOINFAILED(1)`, raid → `JOINFAILED(3)`, full → `JOINFAILED(2)`. No level check on the server | `cm:LFG/LFGHandler.cpp:34-84`, `vm:LFG/LFGHandler.cpp:31-75` | +| Queue key is dungeon area and team. A leader queues the whole party; anyone else queues alone. Re-joining overwrites the area and the wait time | `cm:LFG/LFGMgr.cpp:37-89`, `cm:LFG/LFGQueue.cpp:252-277` | +| Status bytes: 0 LEAVE_QUEUE, 1 JOINED_QUEUE, 2 PARTY_MEMBER_LEFT_LFG, 3 PARTY_MEMBER_REMOVED_PARTY_REMOVED, 4 LOOKING_FOR_NEW_PARTY_IN_QUEUE, 5 NONE. Failure bytes 1 PARTYLEADER, 2 FULL_GROUP, 3 RAID_GROUP | `cm:LFG/LFGDefines.h:50-67`, `wwm:` `MeetingStoneStatus`, `MeetingStoneFailure` | +| `SMSG_MEETINGSTONE_SETQUEUE` is `u32 area, u8 status`. `JOINFAILED` is `u8`. `MEMBER_ADDED` is a guid. `COMPLETE` and `IN_PROGRESS` are empty | `cm:LFG/LFGHandler.cpp:139-152`, `cm:LFG/LFGMgr.cpp:288-309` | +| Leave: alone → `SETQUEUE(0, LEAVE_QUEUE)`. Leader of a queued party → the party leaves, every member gets `SETQUEUE(0, LEAVE_QUEUE)`. Anyone else in a party → `SETQUEUE(0, NONE)` to self | `cm:LFG/LFGHandler.cpp:86-110`, `cm:LFG/LFGQueue.cpp:392-454` | +| `CMSG_MEETINGSTONE_INFO` arrives at world entry. Queued party → `SETQUEUE(area, JOINED)`, else `SETQUEUE(0, NONE)` | `cm:LFG/LFGHandler.cpp:112-137`, `wwm:` `CMSG_MEETINGSTONE_INFO` doc | +| Roles by class: Druid and Paladin T/H/D, Priest and Shaman H/D, Warrior T/D, Hunter, Mage, Rogue, Warlock D. A party has 1 tank, 1 healer, 3 damage roles | `cm:LFG/LFGMgr.cpp:91-106`, `cm:LFG/LFGMgr.h:51` | +| A queued party's Open Roles come from its members' classes, with a class priority table breaking ties | `cm:Groups/Group.cpp:1589-1685`, `cm:LFG/LFGMgr.cpp:108-158` | +| Fill: a solo Seeker of the same area and team takes an Open Role. The priority checks compare `bool`s, so in practice the longest-waiting capable Seeker wins each role | `cm:LFG/LFGQueue.cpp:99-171,292-390` (the `bool` at 300 and 316) | +| Formation: with 5 or more solo Seekers, the first becomes leader and one more joins; the fill pass adds the rest | `cm:LFG/LFGQueue.cpp:173-221` | +| Stone adds: existing members get `MEMBER_ADDED(guid)` before the add. At 5 members every member gets `COMPLETE` then `SETQUEUE(0, NONE)` | `cm:LFG/LFGQueue.cpp:372-383,420-454` | +| A queued party gets `IN_PROGRESS` every 5 minutes | `cm:LFG/LFGMgr.cpp:55`, `cm:LFG/LFGQueue.cpp:149-163` | +| Member leaves a queued party: leaver `SETQUEUE(0, NONE)`; if the leader stayed, the rest get `SETQUEUE(area, PARTY_MEMBER_LEFT_LFG)` and the party stays queued; if the leader left, the party leaves the queue with `LEAVE_QUEUE` | `cm:Groups/Group.cpp:437-447,464-479`, `vm:Group/Group.cpp:477-518` | +| Kick from a queued party: the rest get `SETQUEUE(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED)` and the party leaves the queue. The kicked Character gets `SETQUEUE(area, LOOKING_FOR_NEW_PARTY_IN_QUEUE)` and is queued alone | `cm:Groups/Group.cpp:413-435`, `vm:Group/Group.cpp:449-474` | +| Disband: every member `SETQUEUE(0, NONE)`, party leaves the queue | `cm:Groups/Group.cpp:550-562`, `vm:Group/Group.cpp:586-602` | +| A manual invite into a queued party recomputes Open Roles; reaching 5 completes it | `cm:Groups/Group.cpp:376-382`, `cm:LFG/LFGQueue.cpp:237-250` | +| Converting to a raid and passing the lead touch no LFG state in either core | `cm:Groups/Group.cpp:208-222`, `vm:Group/Group.cpp:236-257` | +| Logout drops a solo Seeker silently | `cm:Server/WorldSession.cpp:662-666`, `vm:Server/WorldSession.cpp:721-725` | +| Client UI: a minimap button while queued, click → confirm → `CancelMeetingStoneRequest()` → `CMSG_MEETINGSTONE_LEAVE`. The client renders every status as its own `ERR_MEETING_STONE_*` line | `fx:Minimap.xml:175-235`, `fx:StaticPopup.lua:170-179`, `fx:GlobalStrings.lua:847,1698-1710,2678-2679,4354` | +| The queue exists from patch 1.3 | `vm:World.cpp:1882-1885` | + +**Not 1.12.** cmangos `GameObject::Use` casts Meeting Stone Summon 23598 for type 23 +(`cm:Entities/GameObject.cpp:1925-1954`). That is the 2.0 summoning stone, shared code in the +classic core. vmangos returns (`vm:Objects/GameObject.cpp:1836-1841`) and the 1.12 FrameXML has no +summon UI. The talent-based roles behind `LFG.Matchmaking` default off +(`cm:World/World.cpp:740-741`) and are a cmangos addition. + +**No coupling to the LookingForGroup channel.** It is a Chat Channel on Realm-core now +(`lyracore_shared::channel`, prefix `LookingForGroup`), with the 1.12.1 dbc flags 0x0. No LFG code +in either core joins, leaves or restricts it: `Channel.RestrictedLfg` is read by nothing +(`cm:World/World.cpp:678`), and `Player::LeaveLFGChannel` has no caller +(`cm:Entities/Player.cpp:4796-4806`). Nothing here touches channels. + +### Where the cores disagree or are buggy + +| Behavior | cmangos | vmangos | We do | +|---|---|---|---| +| `MSG_LOOKING_FOR_GROUP` | ignored | replies `u32 0` | vmangos. It targets 1.12 and the reply is harmless | +| Solo Seeker accepts a manual invite | dropped from the queue, `LEAVE_QUEUE` unless the party is queued for the same area, playerbots builds only (`cm:Groups/Group.cpp:360-374`) | stays in the solo queue while grouped | cmangos. A grouped Character in the solo queue can be matched into a second party | +| `INFO` for a solo Seeker | `NONE`, because `m_offlinePlayers` is never written | same | the real state. Both cores read a map nothing fills | +| Formation trigger | 5 solos across all areas, then 4 more in the first Seeker's area; head-of-line blocks | same | 5 solos in one area and team | +| Leader of a new party | lowest guid | same | longest wait | +| A queued party converts to a raid | stays queued and keeps filling | same | leaves the queue with `LEAVE_QUEUE`. JOIN refuses raids, so a queued raid is the state the join gate exists to prevent | + +## State of the world (verified on `main` at `da11dcfc`, 2026-10-02) + +- Nothing meeting-stone related exists. `grep -rliE 'lfg|meetingstone|meeting_stone'` over + `gateway/ module/ importer/ crates/` finds a chat string in + `gateway/src/stdb/world_view/relay_bench.rs`, the LookingForGroup channel flags in + `lyracore_shared::channel`, and the reservation comments in `lyracore_shared::group`. +- `lyracore_shared::constants::go_type` has `QUESTGIVER = 2` and `CHEST = 3` only + (`constants.rs:105-113`). +- The importer imports every spawned GO type. Type 23 falls into the `other` arm of + `go_template_row` (`importer/src/main.rs:703-706`), which writes `data0 = data1 = 0`. The stone's + level range and area are lost. `got` names `DATA0`, `DATA1`, `DATA3` and `DATA5`, not `DATA2` + (`main.rs:874-894`). The per-type side table precedent is `game_gameobject_trap` + (`module/src/gameobject.rs:117-127`): the importer captures its fields into `GoMeta` + (`main.rs:4940-4964`), emits rows in `gameobject_template_rows` (`main.rs:5083-5088`), clears + and reloads them with the `gameobjects` family (`main.rs:5549-5559`), and counts them in that + family's provenance stamp (`main.rs:5765-5770`). The trap table also rides the Package Delta + `gameobjects` Import Family (`crates/lyracore-package-delta/src/schema.rs`, + `module/src/package_import/gameobjects.rs`). +- `use_gameobject` treats unknown types as a benign no-op (`module/src/gameobject.rs:980-983`), so + a type-23 `CMSG_GAMEOBJ_USE` does nothing today. The shared GO Gate is the private `usable_go` + (`gameobject.rs:672-715`): GO present, live Character, same map and instance, + `USE_RANGE_SQ = LOOT_RANGE_SQ = 100` (10 yd), template present. Its `ActionRefusalKind`s are + `MissingTarget`, `MissingActor`, `OtherPartition` and `OutOfRange`. `taxi::is_in_flight` + (`module/src/taxi.rs:789`) answers the flight check. +- `SMSG_GAMEOBJECT_QUERY_RESPONSE` sends `raw_data: [data0, data1, 0, 0, 0, 0]` + (`gateway/src/codec/gameobject.rs:130-151`), read through `GameObjectTemplateView` + (`codec/gameobject.rs:44-50`) and `stdb/reads/templates.rs:34-52`. +- Raids landed. `Group.group_type` holds a `GroupKind` byte (`module/src/group.rs:94-96`); + `raid_convert_on` (`group.rs:1673-1683`) flips it and has no queue hook. `has_room` follows the + kind (`group.rs:610-612`), and `accept_invite_on` (`group.rs:1400-1517`) assigns a `RaidSlot` and + reconciles Instance Removal for the acceptor and a new party's leader. `remove_member` + (`group.rs:1845-1918`) revokes Loot Tag membership, resolves rolls on disband and reconciles + Instance Removal. `leave_group_on(.., cause)` and `uninvite_on` both end in `remove_member`. + `SET_LEADER` exists as `set_leader_on`. +- Party authority: `realm_group_op(op, request_actor: SessionActor, target_guid, arg_a, arg_b, + arg_c)` (`group.rs:1993-2062`) runs the group cores with `Plane::RealmCore` and advances + `game_group_roster_revision` through `advance_group_revision` (`group.rs:2102-2116`) for every + group `realm_op_groups` names before and after the op. Realm ops 0-15 are taken + (`lyracore_shared::group::realm_op`). ACCEPT's `arg_a`/`arg_b` are documented as kept free for + meeting stones (`crates/lyracore-shared/src/group.rs:239-240`). +- Plane: a member row's `owner_identity` is the local Character's identity on `Plane::Shard` and + zero on `Plane::RealmCore` (`group.rs:1490-1502`). On a single-database realm the Gateway calls + `gw_group_*` reducers (`Plane::Shard`) for membership ops. +- Gateway party flow: `party::run` (`gateway/src/world/party.rs:912-1009`) calls Realm-core through + `run_on_authority_visible`, then pushes mirrors. Accept, leave and kick use + `sync_membership_mirrors` (`party.rs:1016-1039`), which goes through + `sync_group_mirrors_required` (`party.rs:1380-1428`, three attempts within a 5 s window per + shard). Other ops use the best-effort `sync_mirrors` (`party.rs:1435-1495`). Both push only the + actor's before and after groups, to every World Shard. `Op::realm_args` (`party.rs:403`) packs + ACCEPT as `(ACCEPT, 0, 0, 0, 0)`. A playerbot's automatic accept is `answer_for_session_less` + (`party.rs:873-905`). +- **No Roster Revision Relay exists.** Nothing watches `game_group_roster_revision`. Mirrors move + only through `party::run`, world entry (`on_world_entry`, `party.rs:1505`), deleted-Character + cleanup, and `reconcile_deleted_character_parties` (`party.rs:1327-1371`), which pushes every + party known to Realm-core or any shard. That pass already runs at startup and on every Realm-core + reconnect (`spawn_character_gone_relay`, `gateway/src/stdb/subscriptions.rs:4223-4252`). The + Coordinator caches `game_group_roster_revision` on every connection of a sharded realm + (`stdb/connection.rs`, `sharded_tables` block), but `reads/party.rs:207-216` + `group_roster_revision` answers 1 for a missing row, so it cannot tell "no mirror" from + revision 1. +- `game_group_event` is the per-recipient relay. The Gateway routes a row by `recipient_guid` + (`gateway/src/stdb/world_view.rs:2728-2747` `group_event_appeared`, wired at `world_view.rs:1098` + for a shard and `:1275` for Realm-core) and decodes it in `group_event_outbound` + (`subscriptions.rs:2193`). Kinds: 0-3 membership, 4-8 loot rolls, 9 retired (party chat is a + Realm Chat Line now), 10-11 quest sharing, 12-19 unassigned, 20 leader announcement, 21-26 Group + Broadcasts, **27-30 reserved for meeting stones** (`crates/lyracore-shared/src/group.rs:173-182`). + No test covers every kind at once; `quest.rs:247` covers 0-11. +- Account Claims live on Realm-core, one per Account (`module/src/account_ownership.rs:27-35`), + 60 s without renewal (`CLAIM_MICROS`). A claim ends in three places, and each one already calls + `channel::leave_all` for its Character: `claim_account` replacing a dead generation + (`account_ownership.rs:186-190`), `release_account_claim` (`:224-239`), and + `reap_account_claims` (`:549-566`), which the Gateway lease reaper runs every 15 s + (`module/src/gw.rs:118-127`, `LEASE_REAP_MICROS`). `require_actor` (`:475-524`) accepts a + tokenless actor whose Character has no live claim, so JOIN must refuse a tokenless actor itself. +- Chat Channel membership is the precedent for Realm-core state tied to a claim: its tables sit in + the `NOT_CHARACTER_OWNED` list of `module/src/tripwires.rs` ("membership ends with the Account + Claim that admitted it"), not behind `character_owned!` markers. +- The Gateway's newer protocol families use a dispatch seam in `gateway/src/world/handlers/`: + `dispatch_channel_action` with a narrow `ChannelActionStore` trait implemented for `Coordinator` + and a Fake, wired into the chain in `gateway/src/world/mod.rs:1559`. The Coordinator picks the + database; handlers never do. Party ops still use `world/party.rs` and `world/social.rs`. +- Realm-core tables the Gateway reads sit in the base list of `coordinator_queries` + (`stdb/connection.rs:1011`), for example the Chat Channel tables at `connection.rs:1209-1212`. +- An opcode `wwm:` cannot parse ends the World Session (`gateway/src/world/framing_tests.rs:199`). + The three meeting-stone client messages and `MSG_LOOKING_FOR_GROUP` all parse. +- `lyracore_shared::faction::team_for_race` maps race to team (469 Alliance, 67 Horde). +- Fixture ids: `docs/danger-zones.md` reserves `509_0000`-`509_9999`. The auction, mail and + creature-cycle fixtures use `509_00xx`-`509_05xx` and `509_36xx`. `509_6000`-`509_6099` is free. +- Bindings: `docs/danger-zones.md` §1 item 2 holds the regeneration command + (`spacetime generate --lang rust --out-dir gateway/src/stdb/bindings --module-path module + --include-private --build-options='--features=debug_reducers' -y`), its hand-patches, and + `scripts/check-gateway-bindings.py`. +- CI: `.github/workflows/module-durable.yml` discovers every `module/tests/*.rs` target by itself, + but runs Gateway durable tests by exact name. A new Gateway durable test needs a workflow edit, + and the agents' token cannot push `.github/workflows/*`. + +## Design + +### Placement + +| Question | Answer | Why | +|---|---|---| +| Is the Character at a stone, in range, in its level range? | Module on the Home Shard, `gw_admit_meeting_stone` | Only that shard holds the GO and the Character | +| Queue state and matching | Module on Realm-core, `realm_meeting_stone_op` | Seekers on different shards meet in one queue. Matching changes party membership, which lives there | +| Party formation | the group cores in `module/src/group.rs`, on Realm-core | One group path | +| Class and race of Seekers | Gateway reads them from the shard caches and passes them in | Realm-core has no Character rows. Same shape as `invite_gate` and the channel `SpeakerFacts` | +| Client packets | Gateway, `world/handlers/meeting_stone.rs`, a `dispatch_meeting_stone_action` seam | The house shape for a protocol family on Realm-core | +| Notifications | `game_group_event` kinds 27-30, existing relay | One recipient, a kind byte, a small payload | +| Solo Seeker lifetime | the three Account Claim end points | The claim already ends Chat Channel membership there | +| Mirrors of stone-formed parties | Gateway Roster Revision Relay | The matcher changes parties the acting Character is not in | + +On a single-database realm every call goes to the one database, as `run_bot_invite` already does. + +### Durable state, all in `module/src/meeting_stone.rs` + +- `game_meeting_stone` [static, private]: `entry` (template) PK, `min_level`, `max_level`, + `area_id`. Imported with the `gameobjects` family. `max_level = 0` means no upper bound. The + Gateway reads it with the owner token, like `game_mail_escrow`. +- `game_meeting_stone_seeker` [entity, private]: one row per queued Character. `character_guid` + PK, `area_id`, `team`, `class`, `group_id` (0 = alone), `queued_at`. Index on + `(area_id, team)` and on `group_id`. The Gateway subscribes it to answer `INFO`. +- `game_meeting_stone_party` [entity, private]: one row per queued party. `group_id` PK, + `area_id`, `team`, `queued_at`, `next_reminder_at`. Module only. +- `game_meeting_stone_reminder_schedule` [scheduled], every 5 s, added by T3: sends `IN_PROGRESS` + reminders. Nothing else needs a tick. + +Invariant: a party row exists if and only if the party is queued, and its Seeker rows are exactly +its current members. The group cores keep it in the same transaction that changes membership. + +### Flows + +```text +CMSG_MEETINGSTONE_JOIN(go) + Gateway: Home Shard gw_admit_meeting_stone(actor, go) refused → silent + Gateway: stone area from the Home Shard cache; area not in wwm Area → silent + Gateway: facts = actor, or every party member: (guid, race, class) + Gateway: authority realm_meeting_stone_op(JOIN, actor, area, facts) + refused NotLeader/RaidGroup/PartyFull → SMSG_MEETINGSTONE_JOINFAILED(1/3/2) + Realm-core, one transaction: enqueue, SETQUEUE(area, JOINED) events, match the bucket, + form or fill parties through the group cores, advance Roster Revisions + Gateway: relay events; Roster Revision Relay pushes changed parties to World Shards + +CMSG_MEETINGSTONE_LEAVE → realm_meeting_stone_op(LEAVE) → events +CMSG_MEETINGSTONE_INFO → Durable Read of the Seeker row → SETQUEUE(area, JOINED) or (0, NONE) +MSG_LOOKING_FOR_GROUP → MSG_LOOKING_FOR_GROUP(0) +CMSG_GROUP_ACCEPT → realm_group_op(ACCEPT, arg_a = class, arg_b = race) +Account Claim ends → the claim's Character loses its solo Seeker row, no packet +``` + +| `lyracore_shared::meeting_stone::event_kind` | Value | SMSG | Payload | +|---|---:|---|---| +| `QUEUE` | 27 | `SETQUEUE` | `lyracore_shared::meeting_stone` grammar for area and status | +| `MEMBER_ADDED` | 28 | `MEMBER_ADDED` | `other_guid` = added Character | +| `IN_PROGRESS` | 29 | `IN_PROGRESS` | none | +| `COMPLETE` | 30 | `COMPLETE` | none | + +## Decisions + +1. Ship meeting stones only. 1.12.1 has no LFG/LFM window (`fx:FriendsFrame.xml:1212-1301`). + Maintainer decision in #532. +2. Answer `MSG_LOOKING_FOR_GROUP` with `0`, as vmangos does. Leave 0x200 unparsed; the stock UI + never sends it. +3. No summoning. 23598 on use is 2.0 behavior. +4. Queue on Realm-core, one Seeker row per Character plus one row per queued party. INFO is then + one indexed read. +5. Match inside the transaction that changes a bucket, not on a 1 s tick. The core's priority + checks reduce to longest wait per role, so a tick adds latency and no behavior. +6. Class roles and 1/1/3 roles from cmangos. No talent roles; `LFG.Matchmaking` defaults off. +7. Form a party when one area and team holds 5 solo Seekers. Leader is the longest wait. This + removes the cores' head-of-line bug. +8. Joining any party drops the solo Seeker, with cmangos's notice rule. +9. `INFO` reports the real state. The cores' restore map is dead code. +10. A solo Seeker lives as long as its Account Claim. The three places that end a claim and call + `channel::leave_all` also drop the solo Seeker, through one shared call. A Transfer keeps the + claim, so the Seeker survives crossing shards. No claim sweep tick: the lease reaper already + closes an expired claim within 15 s. +11. Humans only. JOIN refuses a tokenless actor. Maintainer decision in #532. +12. The Module enforces the stone's level range for the using Character, silently. Neither core + does, the client pre-checks with `ERR_MEETING_STONE_INVALID_LEVEL`, and 1.12 has no failure + code for it. +13. Reuse `usable_go` for range and partition. One GO interaction Gate, 10 yd, not the cores' 5 yd. +14. Stone facts in a new `game_meeting_stone` table, the trap precedent. A new template column + would ripple through the Package Delta schema, every template literal and the Datascript + typings. +15. The query response for type 23 fills `raw_data[0..3]` from the stone row so the client tooltip + shows the level range. +16. Notifications reuse `game_group_event`, kinds 27-30. Verified free on `da11dcfc`; + `lyracore_shared::group` reserves them. `JOINFAILED` goes back synchronously from the Refusal. +17. Realm-core learns class and race from the Gateway: JOIN facts, and ACCEPT's reserved + `arg_a`/`arg_b`. An unknown class holds a seat and no role. +18. A Roster Revision Relay pushes any Realm-core roster change to stale World Shard mirrors. The + matcher changes parties the actor is not in, so `party::run`'s actor-scoped push misses them. + Main has no such relay, so T2 stays. It shrinks: it reuses `sync_group_mirrors_required`'s + retries, and the existing Realm-core reconnect pass already covers changes missed while + disconnected. +19. Only the Gateway can say whether a stone's area encodes. If `wwm:` `Area::try_from` refuses it, + the Gateway drops the JOIN before any state changes. +20. `IN_PROGRESS` every 5 minutes from a 5 s Realm-core reminder tick (T3). +21. Raids exist, so T1 refuses a raid at JOIN with `JOINFAILED(3)` in the core's order: not + leader, raid, full. No marker is left for later. +22. A queued party that converts to a raid leaves the queue with `SETQUEUE(0, LEAVE_QUEUE)` to every + member. Both cores leave it queued, but they refuse a raid at JOIN, and a queued raid would + keep taking stone adds past the 1/1/3 role model. T3 calls `dequeue_party` from + `raid_convert_on`. +23. Passing the lead keeps the party queued with no event. Both cores do this, and the party row + is keyed by `group_id`, so nothing changes. +24. The Seeker and party tables go in `tripwires.rs`'s `NOT_CHARACTER_OWNED` list, the Chat Channel + precedent. A solo Seeker ends with its claim. A party Seeker ends with its membership, and a + deleted Character leaves its party through `remove_member` on both planes, where T3's hook + drops the row. +25. Packages cannot author meeting stones in this work. `game_meeting_stone` does not join the + Package Delta `gameobjects` Import Family. A Package type-23 template has no stone row and + is refused as `NotAMeetingStone`. No Package ships a stone today; adding the table to the + family later is additive. +26. The Gateway side is a `dispatch_meeting_stone_action` seam in `world/handlers/`, with a narrow + `MeetingStoneActionStore` trait for the Coordinator and a Fake. Party code stays where it is. +27. A stone add writes the member's `owner_identity` the way `push_event` resolves a recipient: + the local Character row's identity when the database holds one (a single-database realm), + else zero (Realm-core, where each mirror re-derives it). +28. Debug fixtures use ids `509_6000`-`509_6099`. + +## Maintainer judgment + +1. **CI for the two new Gateway durable tests (T2, T4).** `module-durable.yml` names each Gateway + durable test, and the agents' token cannot push a workflow change. + a. T4 puts the two exact `cargo test ... --ignored --exact` lines in the PR description, and the + maintainer pushes the workflow edit to the feature branch over SSH before merge. + *Recommended.* The cross-shard proof is the only test of the realm-wide claim, so it should + run in CI. + b. Leave both tests local-only and run them by hand before each release. + c. Grant the agents' token the `workflow` scope so T4 can push the edit itself. + +## Coordination + +This section describes merged reality on `da11dcfc`. No sibling ticket set is open against these +files. + +- **Raids are on main** (#543 convert, #550 leadership, #551 Subgroup moves, #552 Group + Broadcasts, #557 quest credit and loot scale, #558 raid chat, #566 Instance Removal timer, #574 + integration). They use realm ops 6-15 and event kinds 20-26, and left ACCEPT's `arg_a`/`arg_b` + and kinds 27-30 alone. Their accept and removal cores (`RaidSlot` assignment, Instance Removal + reconciliation, Loot Tag revocation) are what T3's join core extraction must keep intact. +- **Realm Chat is on main** (#539, #542, #546, #555, #558, #565, #568, #575, #576). Party and raid + chat are Realm Chat Lines on `game_realm_chat_event`; kind 9 is retired and 12-19 are unassigned. + Chat Channels live on Realm-core and end membership with the Account Claim, which is the hook + T1 reuses. Nothing here touches channels or chat. +- **Guilds, mail and auctions are on main.** They add Realm-core tables, `realm_*` and `gw_*` + reducers and Gateway dispatch seams, and share no file region with this work beyond the + regenerated bindings and the base subscription list. +- **Playerbots tests moved to the Packages repository** (#595). The party-command durable tests and + the playerbots Headless Client acceptance harness are no longer in Core. The sharded Gateway + durable shape left in Core is `gateway/src/stdb/subscriptions_character_gone_durable_tests.rs`. +- **Perf work** (#578 playerbots capacity, #587 creature movement writes) does not touch the party, + group event or GameObject paths used here. + +## Execution order + +```text +T1 meeting stone queue tracer (alone) + ├── T2 Roster Revision Relay (Gateway) ──┐ + └── T3 stone matching (Module) ──────────┤ parallel worktrees + └── T4 integrate, verify, PR +``` + +| # | Ticket | Model | Est. tokens | File ownership | +|---|---|---|---:|---| +| T1 | Queue and leave at a meeting stone, alone or as a party | Opus | ~200k | everything it touches; see ticket | +| T2 | Roster Revision Relay | Opus | ~110k | Gateway only: `world/party_mirror.rs` (new), `sync_group_mirrors_required`'s signature in `world/party.rs`, the mirrored-revision read, the relay region of `stdb/subscriptions.rs`, `main.rs`, a new durable test file | +| T3 | Stone matching and party-queue rules | Opus | ~200k | Module only: `module/src/meeting_stone.rs`, `module/src/group.rs` cores, `module/src/debug/meeting_stone.rs`, `module/tests/meeting_stone_matching.rs` | +| T4 | Integrate, verify cross-shard, docs, PR | Opus | ~150k | union, docs, verification doc, PR | + +T2 touches no Module file and no bindings. T3 touches no Gateway file except regenerated bindings. + +## Shared rules + +- Read `AGENTS.md`, `CODING_STANDARDS.md`, `CONTEXT.md`, `docs/architecture.md` §2.3 and §5.3, + `docs/danger-zones.md` §1, and this file before editing. +- Apply `.claude/skills/unslop/SKILL.md` to prose. No issue numbers in code comments. No em dashes. +- Do not touch a production or development realm. Live wire runs are operator-gated. +- Prefix every cargo command with `nice -n 10 env CARGO_BUILD_JOBS=4`. One cargo command at a time. +- Schema: new tables only, END-append with typed `#[default(...)]` if a column is ever added. Never + drop a table. A new table or reducer means regenerating bindings with the `danger-zones.md` §1 + item 2 command and its hand-patches, then `scripts/check-gateway-bindings.py`. Never hand-merge + generated bindings; regenerate from the merged module. +- A new reducer argument changes the module reducer, the Gateway call and every durable test that + calls it, in one commit. +- Wire values that cross crates live in `lyracore-shared` and are pinned by tests. +- A Refusal never ends a session. Transport loss and untagged errors stay `Err`. +- Stay inside your file ownership. Report a cross-ticket gap instead of editing a sibling's region. +- Format touched files individually. Run `cargo clippy` and `cargo test` for each touched crate, the + wasm check for Module changes, and `lyracore preflight` after schema changes. Before excusing a + failure, prove it fails on the unmodified base. +- Commit unsigned (`git -c commit.gpgsign=false commit`) on your worktree branch with a + conventional-commit message. Do not push, open a PR or comment on GitHub unless the ticket + assigns it. Return the branch, exact test commands and results, and handoff notes. diff --git a/.claude/tickets/issue-532/T1-meeting-stone-queue.md b/.claude/tickets/issue-532/T1-meeting-stone-queue.md new file mode 100644 index 000000000..7b87a6748 --- /dev/null +++ b/.claude/tickets/issue-532/T1-meeting-stone-queue.md @@ -0,0 +1,212 @@ +# T1: Queue and leave at a meeting stone, alone or as a party + +Parent: issue #532, meeting stones. **Tracer. Runs alone. Blocks T2, T3, T4.** +Model: Opus. Estimated size: ~200k tokens. +Base: `main` at `da11dcfc` or later, branch `feat/meeting-stones`. + +## Problem + +A 1.12.1 client can right-click a meeting stone, and nothing answers. The importer drops the stone's +level range and dungeon area (`importer/src/main.rs:703-706`). No table holds a queue, no reducer +accepts one, and the Gateway has no arm for `CMSG_MEETINGSTONE_JOIN`, `_LEAVE` or `_INFO`. The +queue must be realm-wide: a Character who queued in Westfall and then walked into the Deadmines +instance is on another shard and still waiting. + +This ticket fixes every contract the other tickets build on: schema, reducer signatures, shared +bytes, event kinds and the Gateway flow. T2 and T3 then change no signature and share no file. + +## Delivery + +### Shared contract, `crates/lyracore-shared` + +- `constants.rs` `go_type`: add `MEETINGSTONE = 23` with a pin test, like `QUESTGIVER`. +- New `meeting_stone.rs`, exported from `lib.rs`: + - `realm_op`: `JOIN = 0`, `LEAVE = 1`. Its own byte space; `realm_meeting_stone_op` is a separate + reducer from `realm_group_op`. + - `queue_status`: 0 `LEAVE_QUEUE` .. 5 `NONE`, and `join_failure`: 1 `PARTY_LEADER`, + 2 `FULL_GROUP`, 3 `RAID_GROUP` (`cm:LFG/LFGDefines.h:50-67`). + - `MeetingStoneRefusal` with stable `meeting_stone:*` tags and `ALL`, the `GroupRefusal` shape: + `NotLeader`, `RaidGroup`, `PartyFull`, `ActorUnavailable`, `NotAMeetingStone`, `OutOfRange`, + `OtherPartition`, `LevelOutOfRange`, `UnknownArea`. + - `join_failure_for(refusal) -> Option`: `NotLeader` 1, `PartyFull` 2, `RaidGroup` 3. Every + other Refusal is silent, as in both cores. + - `event_kind`: `QUEUE = 27`, `MEMBER_ADDED = 28`, `IN_PROGRESS = 29`, `COMPLETE = 30`. + - `encode_queue(area_id, status)` and `decode_queue`, which fails closed on a status above 5. +- `group.rs`: in the `event_kind` doc table, replace "27-30: reserved for meeting stones" with a + pointer to `crate::meeting_stone::event_kind`. In the `realm_op` doc, ACCEPT reads `arg_a` as the + acceptor's class and `arg_b` as its race, 0 for unknown. +- One unit test that every `game_group_event` kind is distinct: `group` 0-3 and 20-26, `loot_roll` + 4-8, `quest::share_event_kind` 10-11, `meeting_stone` 27-30, and that none is 9. + +### Importer, `importer/src/main.rs` + +- Name `got::DATA2` (dump column 10). +- Capture `data2` in `GoMeta` for raw type 23, beside the trap fields (`main.rs:4940-4964`). +- In `gameobject_template_rows`, emit a `game_meeting_stone` row `(entry, data0, data1, data2)` for + every used template with raw type 23. Return it beside the trap rows, clear and reload it with the + `gameobjects` family exactly as `game_gameobject_trap` is (`main.rs:5549-5559`), and add its + count to that family's provenance stamp (`main.rs:5765-5770`). +- The type census prints `type 23 MEETINGSTONE (meeting stone flow, not use-dispatched)`, + the `QUESTGIVER` line's shape. +- The template row keeps its `other`-arm shape. The stone row is the only copy of the three values. +- The Package Delta `gameobjects` family does not change (README decision 25). + +### Module, `module/src/meeting_stone.rs` (new) + +Tables as in the README's durable-state list, with `SeekerFacts { character_guid, race, class }` +as a `SpacetimeType`. All three tables private. No schedule table in this ticket. + +- `gw_admit_meeting_stone(request_actor: SessionActor, go_guid)` on the Home Shard. + `require_operator`, then `require_actor`; a tokenless actor (`ownership: None`) is + `ActorUnavailable`. `taxi::is_in_flight` is `ActorUnavailable`. Call `gameobject::usable_go` and + map `MissingActor`, `OtherPartition`, `OutOfRange` and `MissingTarget` to `ActorUnavailable`, + `OtherPartition`, `OutOfRange` and `NotAMeetingStone`. A template that is not type 23 or has no + stone row is `NotAMeetingStone`. A level outside `min_level..=max_level` is `LevelOutOfRange`; + `max_level = 0` has no upper bound. Writes nothing. +- `realm_meeting_stone_op(op, request_actor: SessionActor, area_id, seekers: Vec)`, + the authority op. `require_operator`, `require_actor`, tokenless is `ActorUnavailable`, area 0 is + `UnknownArea`, more than `GROUP_MAX_MEMBERS` facts is an untagged error. Team comes from the + leader's or actor's race through `lyracore_shared::faction::team_for_race`. A missing fact means + class 0. + - JOIN, not in a party: upsert the solo Seeker with `queued_at = now`. Push `QUEUE(area, JOINED)` + to the actor. Re-joining replaces area and wait time. + - JOIN, in a party, checked in the core's order (`cm:LFG/LFGHandler.cpp:58-77`): not its leader + is `NotLeader`; `GroupKind::Raid` is `RaidGroup`; five members is `PartyFull`. + - JOIN, leader of a Party with room: upsert the party row with `next_reminder_at = now + 5 min`, + replace its Seeker rows with one per current member, push `QUEUE(area, JOINED)` to every member. + - LEAVE: a solo Seeker is deleted and gets `QUEUE(0, LEAVE_QUEUE)`. A leader of a queued party + deletes the party and its Seekers, and every member gets `QUEUE(0, LEAVE_QUEUE)`. Anyone else in + a party gets `QUEUE(0, NONE)`. Nobody queued and no party: `Ok`, no event. + - Membership reads go through `group::checked_group_membership`, `group_kind_of` and + `members_of`. Events go through `group::push_event`. Widen `checked_group_membership` and + `gameobject::usable_go` to `pub(crate)`; change nothing else in `group.rs` or `gameobject.rs`. +- `meeting_stone::claim_ended(ctx, character_guid)` deletes that Character's solo Seeker row, no + event. A party Seeker row stays. Call it at the three points in `account_ownership.rs` that call + `channel::leave_all` (`claim_account` replacing a dead generation, `release_account_claim`, + `reap_account_claims`). If the two calls then always travel together, fold them into one + `account_ownership` helper so the next claim-scoped feature has one place to hook. +- `tripwires.rs`: add `game_meeting_stone_seeker` and `game_meeting_stone_party` to + `NOT_CHARACTER_OWNED` with the reason "Realm-core Meeting Stone Queue; a solo Seeker ends with + its Account Claim, a party Seeker with its party membership". No `character_owned!` markers. +- `debug/meeting_stone.rs`, behind `debug_reducers`, ids `509_6000`-`509_6099` (grep first): stage + a stone template, stone row and spawned GO; stage Characters with chosen race, class and level + beside it, each with a live Account Claim; stage a Party or Raid led by one of them; backdate a + Seeker's `queued_at` and a party's `next_reminder_at`. T3's durable tests use these, so make them + general enough for five Characters. + +T1 does no matching. A queued party waits until T3. + +### Gateway + +- Regenerate bindings once, with the hand-patches, and run `scripts/check-gateway-bindings.py`. +- `stdb/connection.rs`: add `game_meeting_stone` and `game_meeting_stone_seeker` to the base list + of `coordinator_queries`, beside the Chat Channel tables. Both are small, and a single-database + realm needs them. +- `stdb/reads/templates.rs` and `codec/gameobject.rs`: add `data2` to `GameObjectTemplateView`. + For a type-23 template fill `data0..data2` from its stone row; send + `raw_data: [data0, data1, data2, 0, 0, 0]`. Other types keep `data2 = 0`. +- Reads: the stone area for a spawned GO guid on this shard, and the Seeker's area for a Character. +- `stdb/reducers.rs`: wrappers for both reducers returning `Ran` or `Refused(MeetingStoneRefusal)`. + An untagged error stays `Err`. +- `world/handlers/meeting_stone.rs` (new), the `dispatch_channel_action` shape: + `dispatch_meeting_stone_action` over a narrow `MeetingStoneActionStore` trait, implemented for + `Coordinator` and for a Fake. The Coordinator picks the database: admission on the actor's Home + Shard, the op on `realm_store()` or the one database. Wire it into the chain in + `world/mod.rs` beside `dispatch_channel_action`. Opcodes: + - `CMSG_MEETINGSTONE_JOIN`: admission; stone area; `Area::try_from` refuses → debug log, no + further call; facts for the actor, or for every member of the actor's Realm-core roster via + `presence::character_anywhere`; then the op. `join_failure_for` decides whether to answer + `SMSG_MEETINGSTONE_JOINFAILED`. + - `CMSG_MEETINGSTONE_LEAVE`: the op, nothing else. + - `CMSG_MEETINGSTONE_INFO`: `SETQUEUE(area, JOINED)` when the Character has a Seeker row, else + `SETQUEUE(0, NONE)`. + - `MSG_LOOKING_FOR_GROUP`: answer `MSG_LOOKING_FOR_GROUP { unknown1: 0 }`. + - All four drop silently outside the world. +- `world/party.rs`: ACCEPT carries the acceptor's class and race. Pack them in `Op::realm_args` + only, so one place fills `arg_a`/`arg_b`. Both Realm-core accept paths supply them: the client + accept through `run` and the playerbot accept in `answer_for_session_less`. Read them through + `presence::character_anywhere`, 0 when unknown. The single-database `group_accept` path is + unchanged; T3 reads the local Character row there. +- Codec builders for `SETQUEUE`, `JOINFAILED`, `MEMBER_ADDED`, `IN_PROGRESS`, `COMPLETE` using + `wwm:` types. +- `stdb/subscriptions.rs` `group_event_outbound`: arms for kinds 27-30. A bad payload logs a warning + and sends nothing. + +## Acceptance criteria + +1. An imported world has one `game_meeting_stone` row per spawned type-23 template, carrying the + dump's `data0`, `data1`, `data2`. +2. `CMSG_GAMEOBJECT_QUERY` for a stone answers `raw_data[0..3] = [min, max, area]`. Other GO types + answer byte-identically to today. +3. A lone Character in range and level range who joins gets exactly one `SETQUEUE(area, JOINED)` + and one Seeker row on the authority. Joining again at another stone replaces the row. +4. Out of range, another map or instance, wrong GO type, outside the level range, taxi flight, or a + tokenless actor: no durable change, no packet. +5. A party member who is not the leader gets `JOINFAILED(1)`. The leader of a Raid gets + `JOINFAILED(3)`. The leader of a full Party gets `JOINFAILED(2)`. None changes durable state. +6. A Party leader's join queues the party: one party row, one Seeker row per member with the class + the Gateway supplied, `SETQUEUE(area, JOINED)` to every member on any shard. +7. `LEAVE` sends `LEAVE_QUEUE` to a solo Seeker, `LEAVE_QUEUE` to every member when the leader of a + queued party leaves, and `NONE` to a non-leader in a party, per the README table. +8. `INFO` answers `SETQUEUE(area, JOINED)` for any Character with a Seeker row, and `SETQUEUE(0, NONE)` + otherwise, on world entry and after a Transfer. +9. Logout drops the solo Seeker with no packet. A crashed Gateway's solo Seekers are gone after one + lease reaper pass past their claims' expiry. A new claim on the same Account drops the previous + Character's solo Seeker. Party Seeker rows survive all three. +10. `MSG_LOOKING_FOR_GROUP` gets `MSG_LOOKING_FOR_GROUP(0)`. +11. On a sharded Gateway, `realm_meeting_stone_op` runs on Realm-core only; the admission runs on + the Character's Home Shard only. On a single database both run on that database. +12. The Realm-core ACCEPT carries the acceptor's class and race, for a client and for a playerbot. +13. A Refusal never ends the World Session. Transport loss does. + +## Verification rungs + +- Unit: shared tags, `ALL`, kind distinctness, payload round trip and fail-closed decode; the level + range rule with inclusive bounds and `max_level = 0`; the JOIN classification given membership + and kind, in the core's order; importer stone rows from a fixture dump, including an unspawned + stone that yields none. +- Codec: `SETQUEUE` bytes equal `u32 area, u8 status` (`cm:LFG/LFGHandler.cpp:146-152`); the + status and failure bytes equal `wwm:` `MeetingStoneStatus::as_int` and + `MeetingStoneFailure::as_int`; the stone query response. +- Gateway Store Fake: an in-file `mod tests` in `world/handlers/meeting_stone.rs` with a Fake + `MeetingStoneActionStore`, the `channel.rs` shape. One test per routing branch: admission refused + stops before Realm-core; each join failure byte; unknown `Area` stops before Realm-core; party + facts come from every member across shards; leave; `INFO` both answers; `MSG_LOOKING_FOR_GROUP`; + unsharded runs everything on the one store. ACCEPT class and race go in `party_tests.rs`. Relay + arms for kinds 27-30 go beside the existing `group_event_outbound` tests. +- Durable Module: `module/tests/meeting_stone_queue.rs` with `support::Standalone`, the + `auction_buyout.rs` shape. Stage through the debug fixtures and drive the real reducers. Cover + criteria 3 to 7 and 9 against `game_meeting_stone_seeker`, `game_meeting_stone_party` and + `game_group_event` rows. CI picks the new target up by itself. +- `lyracore preflight` after the schema change. + +## CONTEXT.md terms + +Add a `### Meeting stones` section: + +- **Meeting Stone**: a type-23 GameObject at a dungeon entrance. Using it puts the Character, or + the Party it leads, in the Meeting Stone Queue for the stone's dungeon area, inside the stone's + level range. _Avoid_: summoning stone, LFG tool. +- **Meeting Stone Queue**: Realm-core's realm-wide list of Seekers and queued Parties, per dungeon + area and team. _Avoid_: LFG queue, matchmaking queue. +- **Seeker**: one Character in the Meeting Stone Queue, alone or as a member of a queued Party. It + carries the class and team the Gateway supplied. A solo Seeker lasts as long as its Account + Claim. _Avoid_: queued player, LFG player. + +## File ownership + +Everything this ticket touches. T2 and T3 start from its merged branch. + +## Non-goals + +- No matching, no stone adds, no reminders, no group-core hooks, no raid-convert dequeue. T3 owns + them. +- No mirror push for parties the actor is not in. T2 owns it. +- No summoning, no LFG window, no channel change, no playerbot queueing, no Package Delta change. + +## Definition of done + +Touched files formatted. `cargo clippy` and `cargo test` clean for `lyracore-shared`, +`lyracore-importer`, `lyracore-module` (plus the wasm check) and `lyracore-gateway`. Binding check +clean. Preflight clean. The durable test passes locally. Commit on the feature branch and report +the exact commands, the fixture reducer names and anything T3 must know. diff --git a/.claude/tickets/issue-532/T2-roster-revision-relay.md b/.claude/tickets/issue-532/T2-roster-revision-relay.md new file mode 100644 index 000000000..82c98b66c --- /dev/null +++ b/.claude/tickets/issue-532/T2-roster-revision-relay.md @@ -0,0 +1,127 @@ +# T2: Roster Revision Relay + +Parent: issue #532, meeting stones. Depends on T1. **Runs in parallel with T3.** +Model: Opus. Estimated size: ~110k tokens. + +## Problem + +World Shard party mirrors change only when this Gateway's own `party::run` pushes the acting +Character's before and after groups (`gateway/src/world/party.rs:1003-1007`), at world entry, on +deleted-Character cleanup, and in `reconcile_deleted_character_parties` (`party.rs:1327-1371`). A +roster change the Module makes on Realm-core for a party the actor is not in never reaches the +mirrors. Main has no relay for it; verified on `da11dcfc`. + +T3 makes that normal. A Seeker joining a queue can form a party from five other Seekers. A kick can +put the kicked Character into a second party. Those parties' mirrors stay stale until someone's next +op or world entry, so `/p`, the XP split, loot rules and the dungeon binding read the wrong roster +on every shard. + +Realm-core already advances `game_group_roster_revision` for every roster change, and each Gateway +connection of a sharded realm caches that table. The mirror can follow the revision. + +## What already exists, and is reused + +- `sync_group_mirrors_required` (`party.rs:1380-1428`) reads the Realm-core roster or its disband + tombstone, certifies partitions, and pushes to every World Shard with three attempts inside a + 5 s retry window. The relay calls it; it writes no retry logic of its own. +- A Realm-core reconnect already starts `reconcile_deleted_character_parties`, which pushes every + known party (`gateway/src/stdb/subscriptions.rs:4237-4248`). Changes missed while disconnected + need no new pass. +- `spawn_bot_invite_relay` and `arm_bot_invite_relay` (`subscriptions.rs:4084-4140`) are the shape + for a callback relay that re-arms on reconnect and runs its work off the SDK callback thread. + +## Delivery + +**Roster Revision Relay**: when a Realm-core `game_group_roster_revision` row is inserted or +updated, push that party's authoritative roster to every World Shard whose mirrored revision is +older or missing. + +- `gateway/src/world/party_mirror.rs` (new): + - A pure selection of stale shards from the Realm-core revision and each shard's mirrored + revision. A shard with no row for the party is stale. + - A coalescing set of dirty party ids with one worker thread per Gateway process. The worker + drains the set, selects stale shards from the caches, and calls `sync_group_mirrors_required` + for those shards only. Several revisions of one party before the worker runs cause one push. + - A push that still fails logs a warning naming the party and shard. The party then waits for + its next revision or the reconnect pass. Never panic the process. +- `gateway/src/world/party.rs`: give `sync_group_mirrors_required` the target shards as an + argument; its three existing callers pass `store.world_stores()`. Change nothing else in + `party.rs`. +- Mirrored revision read: `stdb/reads/party.rs:207-216` `group_roster_revision` answers 1 for a + missing row. Add a read that returns `Option` (the shard's row, or `None`), a matching + `WorldStore` method in `world/store.rs` with a default, its `stdb/world_store.rs` impl, and the + `InMemoryStore` Fake in `world/tests.rs`. Leave `group_roster_revision` as it is. +- `gateway/src/stdb/subscriptions.rs`: `spawn_roster_revision_relay` and `arm_roster_revision_relay` + after `arm_bot_invite_relay`. Register `on_insert` and `on_update` for `game_group_roster_revision` + on the Realm-core connection only. Callbacks enqueue a party id and return; they never call a + reducer. Re-arm through Realm-core's `on_reconnect`. The initial subscription apply may enqueue + every row; the stale check finds them current and pushes nothing, so it needs no special case. +- `gateway/src/main.rs`: call `coordinator.spawn_roster_revision_relay()` beside the other relays, + and add an Architecture Test beside the bot relay ones proving `main` calls it and the re-arm is + installed. +- Arm nothing when Realm-core is not a distinct database. + +Keep `party::run`'s inline push. It gives the acting session its own party on its next packet. The +relay then finds those shards current and skips them. + +## Acceptance criteria + +1. A roster change committed by calling `realm_group_op` directly on Realm-core, with no + `party::run`, reaches every World Shard mirror. Each shard's `game_group_roster_revision` then + equals Realm-core's. +2. A disband reaches every shard as the tombstone and the mirror forgets the party. +3. A shard already holding the revision gets no `sync_group_mirror` call. +4. Five revisions of one party before the worker runs cause one push per stale shard. +5. No reducer call runs on an SDK callback thread. A slow or unavailable shard delays only the + worker. +6. After a Realm-core reconnect the relay is armed again. +7. A single-database Gateway spawns no relay. +8. A failed push is logged with party and shard after `sync_group_mirrors_required`'s attempts. + +## Verification rungs + +- Unit: stale-shard selection, including a missing row and an equal revision; coalescing. +- Gateway Store Fake: tests in `gateway/src/world/party_mirror.rs` using + `party_tests::party_topology`. Change the `FakeParty` roster directly, as the matcher would, run + the worker once, assert both shard mirrors. Cover the tombstone and the skip. +- Durable Gateway: `gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs`, the + `subscriptions_character_gone_durable_tests.rs` shape: a real Realm-core plus two World Shards, a + Coordinator with the relay spawned, `realm_group_op` INVITE and ACCEPT sent straight to Realm-core + with the private CLI, then poll both shards' `game_group_member` and + `game_group_roster_revision`. Run it locally; CI wiring is a README maintainer question. +- Architecture Test in `main.rs`. + +## CONTEXT.md terms + +Under "Sharding and transfer", after **Roster Revision**: + +- **Roster Revision Relay**: the Gateway's push of a party's Realm-core roster to each World Shard + whose mirror holds an older Roster Revision or none, whatever changed it on Realm-core. _Avoid_: + mirror sync thread, roster watcher. + +## File ownership + +- `gateway/src/world/party_mirror.rs` (new) and its tests. +- `gateway/src/world/mod.rs`: one module declaration line. +- `gateway/src/world/party.rs`: `sync_group_mirrors_required`'s signature and its three call sites. +- `gateway/src/stdb/reads/party.rs`, `gateway/src/world/store.rs`, `gateway/src/stdb/world_store.rs`, + `gateway/src/world/tests.rs`: the mirrored-revision read and its Fake, nothing else. +- `gateway/src/stdb/subscriptions.rs`: the new spawn and arm functions, placed after + `arm_bot_invite_relay`. Do not touch `group_event_outbound`. +- `gateway/src/main.rs`: the call and its Architecture Test. +- `gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs` (new) and its module line. +- `CONTEXT.md`: the one entry above. + +No Module files, no bindings. T3 owns the Module and may regenerate bindings. + +## Non-goals + +- No meeting-stone logic. The relay does not know why a revision moved. +- No change to `sync_group_mirror`, `party::run`, or the reconciliation passes. +- No new retry policy and no new full reconciliation pass. + +## Definition of done + +Touched files formatted. `cargo clippy -p lyracore-gateway` and `cargo test -p lyracore-gateway` +clean, including the new durable test where the standalone toolchain exists. Commit on your +worktree branch and report the exact commands and results. diff --git a/.claude/tickets/issue-532/T3-stone-matching.md b/.claude/tickets/issue-532/T3-stone-matching.md new file mode 100644 index 000000000..d40d47c0c --- /dev/null +++ b/.claude/tickets/issue-532/T3-stone-matching.md @@ -0,0 +1,181 @@ +# T3: Stone matching and party-queue rules + +Parent: issue #532, meeting stones. Depends on T1. **Runs in parallel with T2.** +Model: Opus. Estimated size: ~200k tokens. + +## Problem + +After T1, Seekers wait forever. Nobody is grouped, a queued party never fills, and a queued party +that loses or kicks a member keeps stale Seeker rows. Vanilla groups Seekers by class role, +announces each stone add, completes the party at five, and reminds a waiting party every five +minutes. It also reacts to every membership change of a queued party. All of it is gameplay, so it +belongs in the Module on Realm-core, and every party it forms or changes must go through the same +group cores a player's invite uses. + +## Delivery + +All in the Module. No Gateway file. Keep T1's table shapes and reducer signatures. + +### Roles, pure and unit-tested, in `module/src/meeting_stone.rs` + +- Class roles (`cm:LFG/LFGMgr.cpp:91-106`): Druid and Paladin tank, healer, damage. Priest and + Shaman healer, damage. Warrior tank, damage. Hunter, Mage, Rogue, Warlock damage. Class 0 fills + nothing. +- Class priority per role (`cm:LFG/LFGMgr.cpp:108-158`). +- A party's **Open Roles** from its members' classes in join order: one tank, one healer, three + damage. Each member takes the first of tank, healer, damage its class fills and that is still + open, unless another not-yet-placed member has a higher priority for it + (`cm:Groups/Group.cpp:1589-1685`). Worked example: paladin then warrior leaves three damage open. +- The next stone add: for tank, then healer, then damage, if open, the longest-waiting Seeker whose + class fills it; ties by guid. This is what `cm:LFG/LFGQueue.cpp:292-390` does once its `bool` + priority compare is read literally. + +### The bucket pass, `match_bucket(ctx, area_id, team)` + +Run it at the end of every transaction that can create a match: T1's JOIN, and the hooks below that +free an Open Role or queue a Character. Loop until nothing changes: + +1. Each queued party in the bucket, oldest `queued_at` first, takes stone adds while it has fewer + than five members. Before an add, push `MEMBER_ADDED(guid)` to the current members, then + delete the solo Seeker, add the Character through the shared join core, and write its party + Seeker row. At five members push `COMPLETE` then `QUEUE(0, NONE)` to every member and delete + the party and its Seeker rows (`cm:LFG/LFGQueue.cpp:372-383,420-454`). +2. If the bucket still holds five or more solo Seekers, form a party: the longest wait leads, the + next joins. Push `MEMBER_ADDED(member)` to the leader, create the party through the join core, + queue it with `QUEUE(area, JOINED)` to both (`cm:LFG/LFGQueue.cpp:173-221`), then go to 1. + +A solo Seeker whose Account Claim is closed or past `expires_micros` is deleted silently when the +pass meets it and is never added. T1 drops most of them when the claim ends; this check covers the +up to 15 s between expiry and the lease reaper. Read the bucket through the `(area_id, team)` +index; never scan the whole Seeker table. + +### Group cores, `module/src/group.rs` + +- Extract the part of `accept_invite_on` (`group.rs:1400-1517`) that creates a party for a leader + when needed, inserts the member and pushes the roster into one join core. ACCEPT and the bucket + pass both call it. There is no second way to add a member. The core keeps everything the accept + does today: the `has_room` and `RaidSlot` rules, Vanilla's GROUP loot at Uncommon for a new + party, folding the inviter's other pending invites into a new party, `push_list_to_all`, and + `instance::reconcile_instance_removal` for the joiner and a new party's leader. +- Member identity in the join core (README decision 27): the local Character row's + `owner_identity` when this database holds the row, else zero. Use the existing Character lookup + chokepoint; `character_fence_tripwire` forbids a raw `game_character` lookup. +- Every transaction that changes a party roster advances its Roster Revision through + `advance_group_revision`, the helper `realm_group_op` uses. That includes every party the bucket + pass forms or fills during a `realm_meeting_stone_op` JOIN, and a party a kick re-queue fills. + Advancing a party twice in one transaction is fine. T2's relay pushes whatever this advances. +- `realm_group_op` passes ACCEPT's `arg_a` class and `arg_b` race into the accept core. On the + single-database plane (`gw_group_accept`, `Plane::Shard`) read the acceptor's class from its local + `game_character` row. +- Hooks for queued parties, per the README fact table. Keep `remove_member` the one removal core; + tell kick from leave with a cause argument or with work in `uninvite_on` before it calls + `remove_member`. Deletion already arrives as `leave_group_on(.., CHARACTER_DELETED)` on + Realm-core and as the `sweep_delete_game_group_member` marker on a single database; both end in + `remove_member`. + - Accept: a solo Seeker who joins any party loses its solo row, and gets `QUEUE(0, LEAVE_QUEUE)` + unless the party is queued for the same area (`cm:Groups/Group.cpp:360-374`). A queued party + gains the joiner's Seeker row with its class; five members completes it. A playerbot invited + into a queued party gets a Seeker row the same way. + - Leave or Character deletion, leader unchanged: leaver `QUEUE(0, NONE)`, the rest + `QUEUE(area, PARTY_MEMBER_LEFT_LFG)`, the leaver's Seeker row goes, the party stays queued, then + the bucket pass (`cm:Groups/Group.cpp:437-447,478-479`). + - Leave, leader changed: leaver `QUEUE(0, NONE)`, the rest `QUEUE(0, LEAVE_QUEUE)`, the party + leaves the queue (`cm:Groups/Group.cpp:464-476`). + - Kick from a party that survives: the rest get `QUEUE(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED)` then + `QUEUE(0, LEAVE_QUEUE)`, the party leaves the queue. The kicked Character gets + `QUEUE(area, LOOKING_FOR_NEW_PARTY_IN_QUEUE)`, becomes a solo Seeker with its stored class and + team, gets `QUEUE(area, JOINED)`, then the bucket pass (`cm:Groups/Group.cpp:413-435`). + - Disband, including a kick or leave from two members: every former member `QUEUE(0, NONE)`, the + party leaves the queue, nobody is re-queued (`cm:Groups/Group.cpp:550-562`). + - Raid convert (README decision 22): `raid_convert_on` calls + `meeting_stone::dequeue_party(ctx, group_id, LEAVE_QUEUE)` when the party is queued. Every + member gets `QUEUE(0, LEAVE_QUEUE)` before the raid list. + - Set leader: nothing (README decision 23). + +### Reminders + +Add `game_meeting_stone_reminder_schedule`, a 5 s scheduled reducer with the scheduler-only gate. +Seed it in `seed.rs::seed_scheduler_arming` and re-arm it in +`debug/repair.rs::debug_repair_after_publish`, the weather precedent (`rearm_weather_schedule`). +Every party row with `next_reminder_at <= now` pushes `IN_PROGRESS` to each member and moves +`next_reminder_at` on by five minutes (`cm:LFG/LFGMgr.cpp:55`, `cm:LFG/LFGQueue.cpp:149-163`). +The party table is small, private and not spatial. + +### Event order + +Insert each recipient's events in wire order inside one transaction, for example `MEMBER_ADDED` +before the roster LIST it precedes in cmangos. T4 proves the relay keeps that order. + +## Acceptance criteria + +1. Five solo Seekers of one area and team form one party. With a tank, a healer and three damage + classes it completes at once: every member ends with `COMPLETE` then `QUEUE(0, NONE)`, and no + Seeker or party row remains. +2. Five mage Seekers form a party of leader, first member and one more damage add. It stays queued + with tank and healer open, and two mages keep waiting. +3. A queued warrior and mage party takes the longest-waiting healer-capable Seeker as healer. A + second priest waits. +4. Existing members get `MEMBER_ADDED(guid)` for each stone add. The added Character does not. +5. Horde and Alliance Seekers of one area never meet. Different areas never meet. +6. A Seeker without a live Account Claim is never added and its row is gone after the pass. +7. Each leave, kick, disband, accept and raid-convert case in the hooks list produces exactly the + listed events and queue rows, on both planes. +8. A kicked Character re-queued alone joins a second queued party in the same transaction when a + role fits. +9. Every party the Module forms or changes has a higher Roster Revision after the transaction. +10. A party row whose reminder is due sends `IN_PROGRESS` to every member once and schedules the + next five minutes later. +11. A stone-formed party has the same `game_group` defaults as an invited one, group loot at + Uncommon, because both go through the one join core. +12. A Character who joins a stone party inside a dungeon instance its new party owns has its + Instance Removal cancelled, as an invited one does. + +## Verification rungs + +- Unit: the role table and priorities against the cmangos rows cited above; Open Roles for the + paladin-then-warrior case, a two-warrior party and a full party; the stone-add pick order with ties; + the formation threshold at four and five Seekers. +- Durable Module: `module/tests/meeting_stone_matching.rs` with `support::Standalone`, staged + through T1's debug fixtures. One test per criterion 1-3 and 5-12, asserting `game_group`, + `game_group_member`, `game_group_roster_revision`, the Seeker and party tables and the ordered + `game_group_event` rows per recipient. Criterion 7 runs through `realm_group_op` for the + Realm-core plane and through the `gw_group_*` reducers for the single-database plane. The + existing `raid_convert.rs`, `instance_removal.rs` and `loot_tag.rs` durable tests must stay green + after the join core extraction. +- The Module wasm check and `cargo test -p lyracore-module --lib` for the tripwires. + +## CONTEXT.md terms + +In the `### Meeting stones` section T1 created: + +- **Open Role**: one of a queued Party's one tank, one healer and three damage roles that no + member's class fills yet. _Avoid_: vacancy, role slot. +- **Stone Add**: the Module adding a Seeker to a queued Party, or forming a Party from five waiting + Seekers, through the party authority's join core. _Avoid_: matchmaking, auto-invite. + +## File ownership + +- `module/src/meeting_stone.rs`: everything except T1's table shapes and reducer signatures. +- `module/src/group.rs`: the join core, the accept, leave, uninvite, raid convert and + `remove_member` cores, `realm_group_op`'s ACCEPT arm and revision advances. +- `module/src/seed.rs` and `module/src/debug/repair.rs`: the reminder schedule's seed and re-arm. +- `module/src/debug/meeting_stone.rs`: may add fixtures. A new table or reducer means regenerating + bindings and saying so in the handoff. T2 touches no bindings. +- `module/tests/meeting_stone_matching.rs` (new). +- `CONTEXT.md`: the two entries above. + +No Gateway files except regenerated bindings. No importer. + +## Non-goals + +- No talent roles, no summoning, no playerbot queueing. +- No mirror push. T2 owns it. +- No change to T1's Gateway flow. + +## Definition of done + +Touched files formatted. Module wasm check, `cargo clippy -p lyracore-module`, +`cargo test -p lyracore-module` and the new durable test clean. The reminder schedule table means +bindings are regenerated; the binding check and `cargo test -p lyracore-gateway` are clean too. +Commit on your worktree branch and report the exact commands, results and any schema or fixture +change T4 must know. diff --git a/.claude/tickets/issue-532/T4-integrate-verify-pr.md b/.claude/tickets/issue-532/T4-integrate-verify-pr.md new file mode 100644 index 000000000..cd9a5346a --- /dev/null +++ b/.claude/tickets/issue-532/T4-integrate-verify-pr.md @@ -0,0 +1,84 @@ +# T4: Integrate, verify across shards, document, open the PR + +Parent: issue #532, meeting stones. **Runs last, after T1, T2 and T3.** +Model: Opus. Estimated size: ~150k tokens. + +## Problem + +T1, T2 and T3 each prove their own rung. Nothing yet proves the realm-wide claim: Seekers on +different World Shards meet in one queue, the stone forms one party on Realm-core, and every World +Shard mirror carries it. The relay's event order inside one transaction is also unproven, and the +docs do not know meeting stones exist. + +## Delivery + +1. Merge T1, then T2, then T3 on the feature branch. T3 adds a schedule table, so regenerate + bindings once more from the merged module with the `danger-zones.md` §1 item 2 command and + hand-patches. Never hand-merge generated files. +2. Cross-shard durable proof, `gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs`, the + `subscriptions_character_gone_durable_tests.rs` shape: Realm-core plus two World Shards, a + Coordinator with the Roster Revision Relay spawned. Stage a Deadmines stone and five Characters, + three on one shard and two on the other, with their Account Claims on Realm-core and classes that + fill tank, healer and three damage. Drive `dispatch_meeting_stone_action` for each through the + real Coordinator. T1's fixtures stage one database; extend them for the split if needed. +3. Event order. Assert that one recipient's events from one transaction reach its session in row-id + order. If the relay can reorder them, sort per recipient before enqueueing in + `world_view::group_event_appeared` and say so in the PR. +4. CI. Write the exact `cargo test -p lyracore-gateway --bin lyracore-gateway -- --ignored + --exact` lines for this test and T2's durable test. The agents' token cannot push + `.github/workflows/*`; follow the maintainer's answer to the README question. Until then, put the + lines in the PR description. +5. `docs/meeting-stone-client-check.md`, the `auction-house-client-check.md` shape, status + outstanding, for a human with a real 5875 client. Check: right-click sends `JOIN`, not + `GAMEOBJ_USE`; the tooltip shows the level range; the client refuses an out-of-range level itself; + the minimap button appears and its tooltip names the dungeon; each `ERR_MEETING_STONE_*` line for + each status; `INFO` after a world port keeps the button; leaving through the minimap confirm; + converting a queued party to a raid drops the button; a stone-formed party's frame, `/p` and XP + split across two shards. +6. Docs: + - `docs/architecture.md`: add the Meeting Stone Queue to Realm-core's holdings in §1 and §3.1; + add the class and race facts to the §2.3 Gateway read list beside presence; add the Roster + Revision Relay to §5.3. + - `docs/schema.md`: the new tables in the §2 inventory and the recount. + - `CONTEXT.md`: check the T1, T2 and T3 entries read as one section and use the `_Avoid_` words + nowhere in new code or prose. + - `CHANGELOG.md`: one line under `[Unreleased]` → Added. +7. Open the PR with the `file-pr` skill after rebasing on `main`. Title in the repo's style, for + example `feat(meeting-stones): queue and group Seekers realm-wide`. The body states the problem, + the solution, that 1.12.1 has no LFG window, the two settled maintainer decisions (stones only, + humans only), the raid-convert dequeue that departs from both cores, the CI state, and the model + and harness blurb. Use `Refs #532`, not `Closes`, while the client check is outstanding. + +## Acceptance criteria + +1. The five staged Characters end in one party on Realm-core with five members, the tank, healer and + damage classes placed, and no Seeker or party row left. +2. Both World Shards' `game_group_member` mirrors hold the five members, and each shard's + `game_group_roster_revision` equals Realm-core's, without any party op from those Characters. +3. Each Character's session receives its stone events in the order the Module inserted them, from + its first `SETQUEUE(area, JOINED)` to the closing `COMPLETE` then `SETQUEUE(0, NONE)`. +4. A Seeker who crosses into the Instance Pool after queueing stays queued and gets + `SETQUEUE(area, JOINED)` for `INFO` on arrival. +5. Every workspace check below is clean, or each exception is proven to fail on the base commit. +6. The client check document exists with outstanding status and exact steps. + +## Verification rungs + +- Format touched files individually, `cargo clippy` and `cargo test` for `lyracore-shared`, + `lyracore-importer`, `lyracore-module`, `lyracore-gateway` and `lyracore-package-delta`. +- The Module wasm check, `scripts/check-gateway-bindings.py`, `lyracore preflight`. +- The durable Module tests from T1 and T3, the durable Gateway tests from T2 and this ticket. +- The real-client check is the named human rung. A Headless Client run is optional. + +## CONTEXT.md terms + +None new. Consolidate. + +## File ownership + +The union after merge, the new durable test, the docs listed above and the PR. + +## Definition of done + +The PR is open against `main`, rebased, green, and links the client check document as the one +outstanding human step. From 8c2684e66699d89b5e3df22ed5c66f8ca182cd9a Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 20:41:47 +0200 Subject: [PATCH 02/16] feat(shared): add the meeting stone wire contract Add go_type::MEETINGSTONE (23) and lyracore_shared::meeting_stone: the realm_meeting_stone_op bytes, the SETQUEUE status and JOINFAILED reason bytes, the tagged MeetingStoneRefusal, the group event kinds 27 to 30 and the QUEUE payload. ACCEPT now documents arg_a as class and arg_b as race. A test pins every game_group_event kind as distinct. Refs #532 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lyracore-shared/src/constants.rs | 10 + crates/lyracore-shared/src/group.rs | 46 ++++- crates/lyracore-shared/src/lib.rs | 1 + crates/lyracore-shared/src/meeting_stone.rs | 191 ++++++++++++++++++++ 4 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 crates/lyracore-shared/src/meeting_stone.rs diff --git a/crates/lyracore-shared/src/constants.rs b/crates/lyracore-shared/src/constants.rs index 3381c99b1..0d39ed3fd 100644 --- a/crates/lyracore-shared/src/constants.rs +++ b/crates/lyracore-shared/src/constants.rs @@ -110,6 +110,10 @@ pub mod go_type { /// `GAMEOBJECT_TYPE_CHEST` (3) — the only GameObject type whose right-click belongs to the /// gateway loot-window lifecycle. pub const CHEST: u8 = 3; + /// `GAMEOBJECT_TYPE_MEETINGSTONE` (23). The client sends `CMSG_MEETINGSTONE_JOIN` on use, not + /// `CMSG_GAMEOBJ_USE`. `data0` is the minimum level, `data1` the maximum and `data2` the dungeon + /// area (cm:Entities/GameObject.h:266-272). + pub const MEETINGSTONE: u8 = 23; } /// `UNIT_FIELD_BYTES_1` byte-3 visibility flags (vanilla 1.12). Drives client render state, stored in @@ -453,6 +457,12 @@ mod tests { assert_eq!(go_type::QUESTGIVER, 2); } + /// cmangos `GAMEOBJECT_TYPE_MEETINGSTONE`. The importer, the Module and the Gateway read it. + #[test] + fn go_type_meetingstone_is_cmangos_type_23() { + assert_eq!(go_type::MEETINGSTONE, 23); + } + /// Work-item 217: 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] diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index 4b0eb1c1a..84f117d72 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -179,7 +179,7 @@ impl RaidSlot { /// - 12-19: unassigned, left for the Realm-core chat seam. /// - 20: the leader announcement, below. /// - 21-26: Group Broadcasts, below. -/// - 27-30: reserved for meeting stones. +/// - 27-30: the Meeting Stone Queue, `crate::meeting_stone::event_kind`. pub mod event_kind { /// You are invited (`other_*` = the inviter) → `SMSG_GROUP_INVITE`. pub const INVITE: u8 = 0; @@ -236,8 +236,9 @@ pub mod event_kind { /// /// Argument slots (`realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c)`), per op: /// - [`INVITE`] / [`UNINVITE`]: `target_guid` is the invitee/kicked member; the rest unused. -/// - [`ACCEPT`] / [`DECLINE`]: `actor_guid` alone; every other slot unused. `ACCEPT` keeps -/// `arg_a`/`arg_b` free for meeting stones, which will send class and race there. +/// - [`ACCEPT`]: `arg_a` is the acceptor's class and `arg_b` its race, 0 when the Gateway cannot +/// read them. Realm-core holds no Character rows, so the Seeker row a queued party gains needs them. +/// - [`DECLINE`]: `actor_guid` alone; every other slot unused. /// - [`LEAVE`]: `actor_guid` leaves; `arg_a` = a [`super::leave_cause`] value. /// - [`LOOT_METHOD`]: `arg_a` = loot setting, `target_guid` = the master looter, `arg_b` = the /// quality threshold. (That is `CMSG_LOOT_METHOD`'s own field order, kept so the gateway hands the @@ -647,6 +648,45 @@ impl RosterPayload { mod tests { use super::*; + /// Every producer of `game_group_event` shares one kind byte space, and kind 9 stays retired. + #[test] + fn every_group_event_kind_is_distinct_and_none_reuses_kind_9() { + use crate::loot_roll::event_kind as roll; + use crate::meeting_stone::event_kind as stone; + use crate::quest::share_event_kind as share; + let kinds = [ + event_kind::INVITE, + event_kind::LIST, + event_kind::DECLINE, + event_kind::DESTROYED, + roll::ROLL_START, + roll::ROLL_VOTE, + roll::ROLL_WON, + roll::MASTER_LIST, + roll::MONEY_SHARE, + share::QUEST_SHARE, + share::QUEST_PUSH_RESULT, + event_kind::SET_LEADER, + event_kind::READY_CHECK, + event_kind::READY_CHECK_ANSWER, + event_kind::TARGET_ICON_UPDATE, + event_kind::TARGET_ICON_LIST, + event_kind::MINIMAP_PING, + event_kind::RANDOM_ROLL, + stone::QUEUE, + stone::MEMBER_ADDED, + stone::IN_PROGRESS, + stone::COMPLETE, + ]; + let mut sorted = kinds; + sorted.sort_unstable(); + assert_eq!( + sorted, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30] + ); + assert_eq!(event_kind::LOOT_ROLL_RESERVED_START, roll::ROLL_START); + } + #[test] fn every_group_refusal_tag_round_trips() { for refusal in GroupRefusal::ALL { diff --git a/crates/lyracore-shared/src/lib.rs b/crates/lyracore-shared/src/lib.rs index 98bf41f8a..72a2221b4 100644 --- a/crates/lyracore-shared/src/lib.rs +++ b/crates/lyracore-shared/src/lib.rs @@ -23,6 +23,7 @@ pub mod item; pub mod loot; pub mod loot_roll; pub mod mail; +pub mod meeting_stone; pub mod movement_path; pub mod nav; pub mod opcodes; diff --git a/crates/lyracore-shared/src/meeting_stone.rs b/crates/lyracore-shared/src/meeting_stone.rs new file mode 100644 index 000000000..90b8be92f --- /dev/null +++ b/crates/lyracore-shared/src/meeting_stone.rs @@ -0,0 +1,191 @@ +//! The Meeting Stone Queue wire contract: the `realm_meeting_stone_op` op bytes, the queue status +//! and join failure bytes the client renders, the typed [`MeetingStoneRefusal`], and the event kinds +//! and payload the Module writes to `game_group_event`. Both the Module and the Gateway import it. + +/// The `op` byte of `realm_meeting_stone_op`. Its own byte space, separate from +/// [`crate::group::realm_op`], because the queue has its own reducer. +pub mod realm_op { + /// `CMSG_MEETINGSTONE_JOIN`: queue the actor, or the Party it leads, for `area_id`. + pub const JOIN: u8 = 0; + /// `CMSG_MEETINGSTONE_LEAVE`: leave the queue. + pub const LEAVE: u8 = 1; +} + +/// `SMSG_MEETINGSTONE_SETQUEUE`'s status byte (cm:LFG/LFGDefines.h:50-58). +pub mod queue_status { + pub const LEAVE_QUEUE: u8 = 0; + pub const JOINED_QUEUE: u8 = 1; + pub const PARTY_MEMBER_LEFT_LFG: u8 = 2; + pub const PARTY_MEMBER_REMOVED_PARTY_REMOVED: u8 = 3; + pub const LOOKING_FOR_NEW_PARTY_IN_QUEUE: u8 = 4; + pub const NONE: u8 = 5; +} + +/// `SMSG_MEETINGSTONE_JOINFAILED`'s reason byte (cm:LFG/LFGDefines.h:61-67). +pub mod join_failure { + pub const PARTY_LEADER: u8 = 1; + pub const FULL_GROUP: u8 = 2; + pub const RAID_GROUP: u8 = 3; +} + +/// The `game_group_event` kinds the Meeting Stone Queue writes. They share the byte space that +/// [`crate::group::event_kind`] documents. +pub mod event_kind { + /// `SMSG_MEETINGSTONE_SETQUEUE`. Payload: [`super::encode_queue`]. + pub const QUEUE: u8 = 27; + /// `SMSG_MEETINGSTONE_MEMBER_ADDED`. `other_guid` is the added Character. + pub const MEMBER_ADDED: u8 = 28; + /// `SMSG_MEETINGSTONE_IN_PROGRESS`. No payload. + pub const IN_PROGRESS: u8 = 29; + /// `SMSG_MEETINGSTONE_COMPLETE`. No payload. + pub const COMPLETE: u8 = 30; +} + +/// Why the Module refused a Meeting Stone Durable Request. The tag is the whole reducer error text. +/// Only the three party Refusals have a client answer ([`join_failure_for`]); both cores drop the +/// rest silently. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MeetingStoneRefusal { + /// The actor is in a Party it does not lead. + NotLeader, + /// The actor leads a Raid. + RaidGroup, + /// The actor's Party already has five members. + PartyFull, + /// The actor has no World Session, no live entity, or is on a taxi flight. + ActorUnavailable, + /// The GameObject is gone, is not type 23, or has no imported stone row. + NotAMeetingStone, + /// The stone is out of reach. + OutOfRange, + /// The stone is on another map or in another instance. + OtherPartition, + /// The actor's level is outside the stone's range. + LevelOutOfRange, + /// The stone names no dungeon area. + UnknownArea, +} + +impl MeetingStoneRefusal { + pub const ALL: [Self; 9] = [ + Self::NotLeader, + Self::RaidGroup, + Self::PartyFull, + Self::ActorUnavailable, + Self::NotAMeetingStone, + Self::OutOfRange, + Self::OtherPartition, + Self::LevelOutOfRange, + Self::UnknownArea, + ]; + + pub fn as_tag(self) -> &'static str { + match self { + Self::NotLeader => "meeting_stone:not_leader", + Self::RaidGroup => "meeting_stone:raid_group", + Self::PartyFull => "meeting_stone:party_full", + Self::ActorUnavailable => "meeting_stone:actor_unavailable", + Self::NotAMeetingStone => "meeting_stone:not_a_meeting_stone", + Self::OutOfRange => "meeting_stone:out_of_range", + Self::OtherPartition => "meeting_stone:other_partition", + Self::LevelOutOfRange => "meeting_stone:level_out_of_range", + Self::UnknownArea => "meeting_stone:unknown_area", + } + } + + pub fn parse_tag(tag: &str) -> Option { + Self::ALL + .into_iter() + .find(|refusal| refusal.as_tag() == tag) + } +} + +/// The `SMSG_MEETINGSTONE_JOINFAILED` reason for a Refusal, or `None` when the client gets nothing +/// (cm:LFG/LFGHandler.cpp:58-77). +pub fn join_failure_for(refusal: MeetingStoneRefusal) -> Option { + match refusal { + MeetingStoneRefusal::NotLeader => Some(join_failure::PARTY_LEADER), + MeetingStoneRefusal::PartyFull => Some(join_failure::FULL_GROUP), + MeetingStoneRefusal::RaidGroup => Some(join_failure::RAID_GROUP), + _ => None, + } +} + +/// `area,status` in decimal, the [`event_kind::QUEUE`] payload. +pub fn encode_queue(area_id: u32, status: u8) -> String { + format!("{area_id},{status}") +} + +/// `None` for a malformed payload or a status the client does not know. +pub fn decode_queue(payload: &str) -> Option<(u32, u8)> { + let (area, status) = payload.split_once(',')?; + let status: u8 = status.parse().ok()?; + if status > queue_status::NONE { + return None; + } + Some((area.parse().ok()?, status)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_meeting_stone_refusal_tag_round_trips() { + for refusal in MeetingStoneRefusal::ALL { + assert!(refusal.as_tag().starts_with("meeting_stone:")); + assert_eq!( + MeetingStoneRefusal::parse_tag(refusal.as_tag()), + Some(refusal) + ); + } + assert_eq!(MeetingStoneRefusal::parse_tag("meeting_stone:"), None); + assert_eq!(MeetingStoneRefusal::parse_tag("group:not_leader"), None); + } + + /// The three party Refusals answer with cmangos's failure byte; every other one is silent. + #[test] + fn only_the_party_refusals_answer_the_client() { + use MeetingStoneRefusal::*; + let answered: Vec<_> = MeetingStoneRefusal::ALL + .into_iter() + .filter_map(|refusal| join_failure_for(refusal).map(|byte| (refusal, byte))) + .collect(); + assert_eq!(answered, [(NotLeader, 1), (RaidGroup, 3), (PartyFull, 2)]); + } + + /// cm:LFG/LFGDefines.h:50-67. + #[test] + fn the_status_and_failure_bytes_are_the_vanilla_values() { + use queue_status::*; + assert_eq!( + [ + LEAVE_QUEUE, + JOINED_QUEUE, + PARTY_MEMBER_LEFT_LFG, + PARTY_MEMBER_REMOVED_PARTY_REMOVED, + LOOKING_FOR_NEW_PARTY_IN_QUEUE, + NONE + ], + [0, 1, 2, 3, 4, 5] + ); + assert_eq!( + [ + join_failure::PARTY_LEADER, + join_failure::FULL_GROUP, + join_failure::RAID_GROUP + ], + [1, 2, 3] + ); + } + + #[test] + fn a_queue_payload_round_trips_and_fails_closed() { + assert_eq!(encode_queue(1581, queue_status::JOINED_QUEUE), "1581,1"); + assert_eq!(decode_queue("1581,1"), Some((1581, 1))); + assert_eq!(decode_queue("0,5"), Some((0, 5))); + for bad in ["1581,6", "1581", "1581,", ",1", "x,1", "1581,1,2", ""] { + assert_eq!(decode_queue(bad), None, "{bad:?}"); + } + } +} From 6f9a86276cfcec73a4570ac16cd798022ef676c8 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 20:41:47 +0200 Subject: [PATCH 03/16] feat(importer): keep a meeting stone's level range and dungeon area A type-23 template imports in the inert shape, which dropped data0 to data2. Every spawned stone now also gets a game_meeting_stone row with the dump's minimum level, maximum level and area. The gameobjects family clears and reloads it and counts it in its provenance stamp. Refs #532 Co-Authored-By: Claude Opus 5.5 (1M context) --- importer/src/main.rs | 102 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/importer/src/main.rs b/importer/src/main.rs index eec4191ad..2e9424941 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -618,6 +618,7 @@ pub(crate) const GO_BUTTON: u8 = 1; const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // shared const (041) — no drift const GO_CHEST: u8 = 3; const GO_GOOBER: u8 = 10; +const GO_MEETINGSTONE: u8 = lyracore_shared::constants::go_type::MEETINGSTONE; const RELAY_TRAP_TEMPLATE_ENTRY: u64 = 180_391; /// Synthetic — must equal module `go_type::GATHER`. Vanilla's REAL type 25 is /// GAMEOBJECT_TYPE_FISHINGHOLE; see the TYPE-25 COLLISION GUARD in `classify_go_type`. @@ -889,6 +890,8 @@ pub(crate) mod got { // raw dump columns. pub const DATA0: usize = 8; pub const DATA1: usize = 9; + /// MEETINGSTONE: the dungeon's AreaTable id (cm:Entities/GameObject.h:266-272). + pub const DATA2: usize = 10; pub const DATA3: usize = 11; pub const DATA5: usize = 13; } @@ -4061,8 +4064,12 @@ fn build_dump_plan( ); let go_spawns = collect_gameobject_spawns(dump, &scope); - let (go_template_rows, go_trap_rows, chest_loot_ids_used) = - gameobject_template_rows(&go_spawns); + let GameobjectTemplateRows { + templates: go_template_rows, + traps: go_trap_rows, + meeting_stones: go_meeting_stone_rows, + chest_loot_ids: chest_loot_ids_used, + } = gameobject_template_rows(&go_spawns); let trainer_rows = build_trainer_spell_rows(dump, &entries, profession_tier_values); // Quests: map quest_template + the giver-relation tables (creature AND gameobject) for quests given @@ -4158,6 +4165,7 @@ fn build_dump_plan( mail_loot_rows: &mail_loot_rows, go_template_rows: &go_template_rows, go_trap_rows: &go_trap_rows, + go_meeting_stone_rows: &go_meeting_stone_rows, trainer_rows: &trainer_rows, creature_casts: &creature_casts, creature_rotation_rows: &creature_rotation_rows, @@ -4263,6 +4271,8 @@ struct GoMeta { data1: u32, trap_spell_id: u32, trap_cooldown_secs: u32, + /// MEETINGSTONE only: the dump's `data2`, the dungeon area. 0 for every other type. + meeting_stone_area_id: u32, size: f32, } @@ -4301,6 +4311,7 @@ struct MappedContent<'a> { mail_loot_rows: &'a [String], go_template_rows: &'a [String], go_trap_rows: &'a [String], + go_meeting_stone_rows: &'a [String], trainer_rows: &'a [String], creature_casts: &'a [String], creature_rotation_rows: &'a [String], @@ -4947,6 +4958,11 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject } else { 0 }; + let meeting_stone_area_id = if raw_type == u32::from(GO_MEETINGSTONE) { + field(row, got::DATA2).parse().unwrap_or(0) + } else { + 0 + }; // 0 on an unparseable/absent column — the gateway reads that as "no size stored" and // sends 1.0, never an invisible prop. let size: f32 = field(row, got::SIZE).parse().unwrap_or(0.0); @@ -4960,6 +4976,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject data1, trap_spell_id, trap_cooldown_secs, + meeting_stone_area_id, size, }, )) @@ -5040,9 +5057,18 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject } } -/// The `game_gameobject_template` + `game_gameobject_trap` rows for the spawned gameobjects, and the -/// CHEST loot ids they reference. -fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec, Vec) { +/// The rows the `gameobjects` family loads for the spawned gameobjects, and the CHEST loot ids they +/// reference. +struct GameobjectTemplateRows { + templates: Vec, + traps: Vec, + /// One `game_meeting_stone` row per spawned MEETINGSTONE template. The template row keeps its + /// inert shape, so this is the only copy of the stone's level range and dungeon area. + meeting_stones: Vec, + chest_loot_ids: Vec, +} + +fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows { let GameobjectSpawns { go_meta, dropped_type25, @@ -5056,6 +5082,7 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec = Vec::new(); let mut go_trap_rows: Vec = Vec::new(); + let mut go_meeting_stone_rows: Vec = Vec::new(); let mut chest_loot_ids_used: Vec = Vec::new(); let mut go_type_histogram: std::collections::BTreeMap = std::collections::BTreeMap::new(); @@ -5086,6 +5113,12 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec (Vec, Vec2} QUESTGIVER {count} (quest flow, not use-dispatched)"); continue; } + if type_id == GO_MEETINGSTONE { + // The client sends CMSG_MEETINGSTONE_JOIN, never CMSG_GAMEOBJ_USE, for a stone. + eprintln!( + " type {type_id:>2} MEETINGSTONE {count} (meeting stone flow, not use-dispatched)" + ); + continue; + } match GO_LIVE_TYPE_NAMES.iter().find(|&&(t, _)| t == type_id) { Some((_, name)) => eprintln!(" type {type_id:>2} {name:<10} {count} (LIVE)"), None => eprintln!(" type {type_id:>2} (unhandled) {count} INERT — use() no-ops"), @@ -5124,7 +5164,12 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec 0".into()); + stmts.push("DELETE FROM game_meeting_stone WHERE entry > 0".into()); stmts.push("DELETE FROM game_gameobject_template WHERE entry > 0".into()); push_insert(stmts, "game_gameobject_template", "entry,type_id,display_id,name,data0,data1,gather_skill_line,respawn_secs,gather_gray,lock_id,size", go_template_rows); push_insert( @@ -5557,6 +5604,12 @@ fn push_quest_and_gameobject_statements( "entry,spell_id,cooldown_secs", go_trap_rows, ); + push_insert( + stmts, + "game_meeting_stone", + "entry,min_level,max_level,area_id", + go_meeting_stone_rows, + ); } // Trainer spell lists (family "trainers"). if family_active(args, "trainers") { @@ -5724,6 +5777,7 @@ fn family_stamps( quests, go_template_rows, go_trap_rows, + go_meeting_stone_rows, trainer_rows, creature_casts, creature_rotation_rows, @@ -5766,7 +5820,7 @@ fn family_stamps( if family_active(args, "gameobjects") { stamps.push(( "gameobjects", - (go_template_rows.len() + go_trap_rows.len()) as u64, + (go_template_rows.len() + go_trap_rows.len() + go_meeting_stone_rows.len()) as u64, )); } if family_active(args, "trainers") { @@ -6197,6 +6251,40 @@ mod tests { assert_eq!(go_initial_state(GO_QUESTGIVER, 1), 0); } + /// Only a spawned stone gets a row, and the row is the dump's `data0`, `data1`, `data2`. The + /// Deadmines stone (Westfall) is level 15 to 20 for area 1581. + #[test] + fn gameobject_family_emits_one_meeting_stone_row_per_spawned_stone() { + let dump = "INSERT INTO `gameobject_template` VALUES \ + (179584,23,6973,'Meeting Stone',0,0,0,1,15,20,1581,0,0,0),\ + (179585,23,6973,'Unspawned Stone',0,0,0,1,30,40,717,0,0,0); \ + INSERT INTO `gameobject` VALUES \ + (60,179584,0,1,-8949.95,-132.493,83.5312,0,0,0,0,0);"; + let mut args = test_args(); + args.family = Some("gameobjects".to_string()); + let plan = build_dump_plan(dump, &args, &None, &None).unwrap(); + + let stones: Vec<_> = plan + .stmts + .iter() + .filter(|statement| statement.contains("game_meeting_stone")) + .collect(); + assert_eq!( + stones, + [ + "DELETE FROM game_meeting_stone WHERE entry > 0", + "INSERT INTO game_meeting_stone (entry,min_level,max_level,area_id) VALUES \ + (179584,15,20,1581)", + ] + ); + assert!(plan.stmts.iter().any(|statement| { + statement.starts_with("INSERT INTO game_gameobject_template") + && statement.contains("(179584,23,6973,'Meeting Stone',0,0,0,0,0,0,1)") + })); + // One template row and one stone row. + assert_eq!(plan.stamps, vec![("gameobjects", 2)]); + } + #[test] fn gameobject_family_emits_the_reachable_relay_trap_profile() { let dump = "INSERT INTO `gameobject_template` VALUES \ From 0ec6508524f61ec079040a571eafdff9755fe044 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 20:43:40 +0200 Subject: [PATCH 04/16] feat(module): queue and leave at a meeting stone gw_admit_meeting_stone checks a Character at a stone on its Home Shard: World Session, no taxi flight, the shared GameObject Gate, type 23 with a stone row, and the stone's level range. realm_meeting_stone_op queues a lone Character or the Party its leader leads on the party authority, with cmangos's not-leader, raid and full-party Refusals, and answers LEAVE per cmangos. A solo Seeker ends where its Account Claim ends; the claim code now ends Chat Channel membership and the solo Seeker through one helper. Debug fixtures stage stones, Characters with live claims, groups and backdated queue times. Bindings regenerated with the danger-zones hand patches. Refs #532 Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 16 + ...ug_backdate_meeting_stone_party_reducer.rs | 74 +++ ...g_backdate_meeting_stone_seeker_reducer.rs | 81 +++ ...g_stage_meeting_stone_character_reducer.rs | 134 ++++ ...debug_stage_meeting_stone_group_reducer.rs | 87 +++ .../debug_stage_meeting_stone_reducer.rs | 134 ++++ .../game_meeting_stone_party_table.rs | 230 +++++++ .../game_meeting_stone_seeker_table.rs | 230 +++++++ .../stdb/bindings/game_meeting_stone_table.rs | 228 +++++++ .../gw_admit_meeting_stone_reducer.rs | 83 +++ .../stdb/bindings/meeting_stone_party_type.rs | 61 ++ .../bindings/meeting_stone_seeker_type.rs | 66 ++ .../src/stdb/bindings/meeting_stone_type.rs | 58 ++ gateway/src/stdb/bindings/mod.rs | 238 ++++++- .../realm_meeting_stone_op_reducer.rs | 96 +++ .../src/stdb/bindings/seeker_facts_type.rs | 17 + module/src/account_ownership.rs | 21 +- module/src/debug/meeting_stone.rs | 254 ++++++++ module/src/debug/mod.rs | 2 + module/src/gameobject.rs | 2 +- module/src/group.rs | 4 +- module/src/lib.rs | 3 + module/src/meeting_stone.rs | 483 ++++++++++++++ module/src/tripwires.rs | 4 + module/tests/meeting_stone_queue.rs | 589 ++++++++++++++++++ 25 files changed, 3185 insertions(+), 10 deletions(-) create mode 100644 gateway/src/stdb/bindings/debug_backdate_meeting_stone_party_reducer.rs create mode 100644 gateway/src/stdb/bindings/debug_backdate_meeting_stone_seeker_reducer.rs create mode 100644 gateway/src/stdb/bindings/debug_stage_meeting_stone_character_reducer.rs create mode 100644 gateway/src/stdb/bindings/debug_stage_meeting_stone_group_reducer.rs create mode 100644 gateway/src/stdb/bindings/debug_stage_meeting_stone_reducer.rs create mode 100644 gateway/src/stdb/bindings/game_meeting_stone_party_table.rs create mode 100644 gateway/src/stdb/bindings/game_meeting_stone_seeker_table.rs create mode 100644 gateway/src/stdb/bindings/game_meeting_stone_table.rs create mode 100644 gateway/src/stdb/bindings/gw_admit_meeting_stone_reducer.rs create mode 100644 gateway/src/stdb/bindings/meeting_stone_party_type.rs create mode 100644 gateway/src/stdb/bindings/meeting_stone_seeker_type.rs create mode 100644 gateway/src/stdb/bindings/meeting_stone_type.rs create mode 100644 gateway/src/stdb/bindings/realm_meeting_stone_op_reducer.rs create mode 100644 gateway/src/stdb/bindings/seeker_facts_type.rs create mode 100644 module/src/debug/meeting_stone.rs create mode 100644 module/src/meeting_stone.rs create mode 100644 module/tests/meeting_stone_queue.rs diff --git a/CONTEXT.md b/CONTEXT.md index 0b7e22bdd..031571dd7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -830,6 +830,22 @@ One Character's endorsement of a Petition. One per Realm Account; nine found the An NPC with the petitioner flag. One that is also a Tabard Designer sells Guild Charters. _Avoid_: guild registrar +### Meeting stones + +**Meeting Stone**: +A type-23 GameObject at a dungeon entrance. Using it puts the Character, or the Party it leads, in +the Meeting Stone Queue for the stone's dungeon area, inside the stone's level range. +_Avoid_: summoning stone, LFG tool + +**Meeting Stone Queue**: +Realm-core's realm-wide list of Seekers and queued Parties, per dungeon area and team. +_Avoid_: LFG queue, matchmaking queue + +**Seeker**: +One Character in the Meeting Stone Queue, alone or as a member of a queued Party. It carries the +class and team the Gateway supplied. A solo Seeker lasts as long as its Account Claim. +_Avoid_: queued player, LFG player + ### World clock and weather **Realm Clock**: diff --git a/gateway/src/stdb/bindings/debug_backdate_meeting_stone_party_reducer.rs b/gateway/src/stdb/bindings/debug_backdate_meeting_stone_party_reducer.rs new file mode 100644 index 000000000..f66f2fa09 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_backdate_meeting_stone_party_reducer.rs @@ -0,0 +1,74 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugBackdateMeetingStonePartyArgs { + pub group_id: u64, + pub secs: u32, +} + +impl From for super::Reducer { + fn from(args: DebugBackdateMeetingStonePartyArgs) -> Self { + Self::DebugBackdateMeetingStoneParty { + group_id: args.group_id, + secs: args.secs, + } + } +} + +impl __sdk::InModule for DebugBackdateMeetingStonePartyArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_backdate_meeting_stone_party`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_backdate_meeting_stone_party { + /// Request that the remote module invoke the reducer `debug_backdate_meeting_stone_party` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_backdate_meeting_stone_party:debug_backdate_meeting_stone_party_then`] to run a callback after the reducer completes. + fn debug_backdate_meeting_stone_party(&self, group_id: u64, secs: u32) -> __sdk::Result<()> { + self.debug_backdate_meeting_stone_party_then(group_id, secs, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_backdate_meeting_stone_party` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_backdate_meeting_stone_party_then( + &self, + group_id: u64, + secs: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_backdate_meeting_stone_party for super::RemoteReducers { + fn debug_backdate_meeting_stone_party_then( + &self, + group_id: u64, + secs: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugBackdateMeetingStonePartyArgs { group_id, secs }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/debug_backdate_meeting_stone_seeker_reducer.rs b/gateway/src/stdb/bindings/debug_backdate_meeting_stone_seeker_reducer.rs new file mode 100644 index 000000000..63de38ab8 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_backdate_meeting_stone_seeker_reducer.rs @@ -0,0 +1,81 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugBackdateMeetingStoneSeekerArgs { + pub character_guid: u64, + pub secs: u32, +} + +impl From for super::Reducer { + fn from(args: DebugBackdateMeetingStoneSeekerArgs) -> Self { + Self::DebugBackdateMeetingStoneSeeker { + character_guid: args.character_guid, + secs: args.secs, + } + } +} + +impl __sdk::InModule for DebugBackdateMeetingStoneSeekerArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_backdate_meeting_stone_seeker`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_backdate_meeting_stone_seeker { + /// Request that the remote module invoke the reducer `debug_backdate_meeting_stone_seeker` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_backdate_meeting_stone_seeker:debug_backdate_meeting_stone_seeker_then`] to run a callback after the reducer completes. + fn debug_backdate_meeting_stone_seeker( + &self, + character_guid: u64, + secs: u32, + ) -> __sdk::Result<()> { + self.debug_backdate_meeting_stone_seeker_then(character_guid, secs, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_backdate_meeting_stone_seeker` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_backdate_meeting_stone_seeker_then( + &self, + character_guid: u64, + secs: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_backdate_meeting_stone_seeker for super::RemoteReducers { + fn debug_backdate_meeting_stone_seeker_then( + &self, + character_guid: u64, + secs: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugBackdateMeetingStoneSeekerArgs { + character_guid, + secs, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/debug_stage_meeting_stone_character_reducer.rs b/gateway/src/stdb/bindings/debug_stage_meeting_stone_character_reducer.rs new file mode 100644 index 000000000..7e94f2504 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_stage_meeting_stone_character_reducer.rs @@ -0,0 +1,134 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugStageMeetingStoneCharacterArgs { + pub guid: u64, + pub account_id: u64, + pub race: u8, + pub class: u8, + pub level: u8, + pub map_id: u32, + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl From for super::Reducer { + fn from(args: DebugStageMeetingStoneCharacterArgs) -> Self { + Self::DebugStageMeetingStoneCharacter { + guid: args.guid, + account_id: args.account_id, + race: args.race, + class: args.class, + level: args.level, + map_id: args.map_id, + x: args.x, + y: args.y, + z: args.z, + } + } +} + +impl __sdk::InModule for DebugStageMeetingStoneCharacterArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_stage_meeting_stone_character`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_stage_meeting_stone_character { + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_character` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_stage_meeting_stone_character:debug_stage_meeting_stone_character_then`] to run a callback after the reducer completes. + fn debug_stage_meeting_stone_character( + &self, + guid: u64, + account_id: u64, + race: u8, + class: u8, + level: u8, + map_id: u32, + x: f32, + y: f32, + z: f32, + ) -> __sdk::Result<()> { + self.debug_stage_meeting_stone_character_then( + guid, + account_id, + race, + class, + level, + map_id, + x, + y, + z, + |_, _| {}, + ) + } + + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_character` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_stage_meeting_stone_character_then( + &self, + guid: u64, + account_id: u64, + race: u8, + class: u8, + level: u8, + map_id: u32, + x: f32, + y: f32, + z: f32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_stage_meeting_stone_character for super::RemoteReducers { + fn debug_stage_meeting_stone_character_then( + &self, + guid: u64, + account_id: u64, + race: u8, + class: u8, + level: u8, + map_id: u32, + x: f32, + y: f32, + z: f32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugStageMeetingStoneCharacterArgs { + guid, + account_id, + race, + class, + level, + map_id, + x, + y, + z, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/debug_stage_meeting_stone_group_reducer.rs b/gateway/src/stdb/bindings/debug_stage_meeting_stone_group_reducer.rs new file mode 100644 index 000000000..cad4e7377 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_stage_meeting_stone_group_reducer.rs @@ -0,0 +1,87 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugStageMeetingStoneGroupArgs { + pub leader_guid: u64, + pub member_guids: Vec, + pub raid: bool, +} + +impl From for super::Reducer { + fn from(args: DebugStageMeetingStoneGroupArgs) -> Self { + Self::DebugStageMeetingStoneGroup { + leader_guid: args.leader_guid, + member_guids: args.member_guids, + raid: args.raid, + } + } +} + +impl __sdk::InModule for DebugStageMeetingStoneGroupArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_stage_meeting_stone_group`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_stage_meeting_stone_group { + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_group` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_stage_meeting_stone_group:debug_stage_meeting_stone_group_then`] to run a callback after the reducer completes. + fn debug_stage_meeting_stone_group( + &self, + leader_guid: u64, + member_guids: Vec, + raid: bool, + ) -> __sdk::Result<()> { + self.debug_stage_meeting_stone_group_then(leader_guid, member_guids, raid, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_group` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_stage_meeting_stone_group_then( + &self, + leader_guid: u64, + member_guids: Vec, + raid: bool, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_stage_meeting_stone_group for super::RemoteReducers { + fn debug_stage_meeting_stone_group_then( + &self, + leader_guid: u64, + member_guids: Vec, + raid: bool, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugStageMeetingStoneGroupArgs { + leader_guid, + member_guids, + raid, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/debug_stage_meeting_stone_reducer.rs b/gateway/src/stdb/bindings/debug_stage_meeting_stone_reducer.rs new file mode 100644 index 000000000..31f8fdfda --- /dev/null +++ b/gateway/src/stdb/bindings/debug_stage_meeting_stone_reducer.rs @@ -0,0 +1,134 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugStageMeetingStoneArgs { + pub entry: u32, + pub go_guid: u64, + pub min_level: u32, + pub max_level: u32, + pub area_id: u32, + pub map_id: u32, + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl From for super::Reducer { + fn from(args: DebugStageMeetingStoneArgs) -> Self { + Self::DebugStageMeetingStone { + entry: args.entry, + go_guid: args.go_guid, + min_level: args.min_level, + max_level: args.max_level, + area_id: args.area_id, + map_id: args.map_id, + x: args.x, + y: args.y, + z: args.z, + } + } +} + +impl __sdk::InModule for DebugStageMeetingStoneArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_stage_meeting_stone`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_stage_meeting_stone { + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_stage_meeting_stone:debug_stage_meeting_stone_then`] to run a callback after the reducer completes. + fn debug_stage_meeting_stone( + &self, + entry: u32, + go_guid: u64, + min_level: u32, + max_level: u32, + area_id: u32, + map_id: u32, + x: f32, + y: f32, + z: f32, + ) -> __sdk::Result<()> { + self.debug_stage_meeting_stone_then( + entry, + go_guid, + min_level, + max_level, + area_id, + map_id, + x, + y, + z, + |_, _| {}, + ) + } + + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_stage_meeting_stone_then( + &self, + entry: u32, + go_guid: u64, + min_level: u32, + max_level: u32, + area_id: u32, + map_id: u32, + x: f32, + y: f32, + z: f32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_stage_meeting_stone for super::RemoteReducers { + fn debug_stage_meeting_stone_then( + &self, + entry: u32, + go_guid: u64, + min_level: u32, + max_level: u32, + area_id: u32, + map_id: u32, + x: f32, + y: f32, + z: f32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugStageMeetingStoneArgs { + entry, + go_guid, + min_level, + max_level, + area_id, + map_id, + x, + y, + z, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/game_meeting_stone_party_table.rs b/gateway/src/stdb/bindings/game_meeting_stone_party_table.rs new file mode 100644 index 000000000..57e259f6f --- /dev/null +++ b/gateway/src/stdb/bindings/game_meeting_stone_party_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::meeting_stone_party_type::MeetingStoneParty; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_meeting_stone_party`. +/// +/// Obtain a handle from the [`GameMeetingStonePartyTableAccess::game_meeting_stone_party`] method on [`super::RemoteTables`], +/// like `ctx.db.game_meeting_stone_party()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_party().on_insert(...)`. +pub struct GameMeetingStonePartyTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_meeting_stone_party`. +pub struct GameMeetingStonePartyTableAccessor; + +impl __sdk::TableAccessor for GameMeetingStonePartyTableAccessor { + type Row = MeetingStoneParty; + type Handle<'db> = GameMeetingStonePartyTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_meeting_stone_party() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_meeting_stone_party`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameMeetingStonePartyTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameMeetingStonePartyTableHandle`], which mediates access to the table `game_meeting_stone_party`. + fn game_meeting_stone_party(&self) -> GameMeetingStonePartyTableHandle<'_>; +} + +impl GameMeetingStonePartyTableAccess for super::RemoteTables { + fn game_meeting_stone_party(&self) -> GameMeetingStonePartyTableHandle<'_> { + GameMeetingStonePartyTableHandle { + imp: self + .imp + .get_table::("game_meeting_stone_party"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameMeetingStonePartyInsertCallbackId(__sdk::CallbackId); +pub struct GameMeetingStonePartyDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameMeetingStonePartyTableHandle<'ctx> { + type Row = MeetingStoneParty; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameMeetingStonePartyTableHandle<'ctx> { + type Row = MeetingStoneParty; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameMeetingStonePartyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyInsertCallbackId { + GameMeetingStonePartyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStonePartyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameMeetingStonePartyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyDeleteCallbackId { + GameMeetingStonePartyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStonePartyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameMeetingStonePartyTableHandle<'ctx> { + type InsertCallbackId = GameMeetingStonePartyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyInsertCallbackId { + GameMeetingStonePartyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStonePartyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameMeetingStonePartyTableHandle<'ctx> { + type DeleteCallbackId = GameMeetingStonePartyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyDeleteCallbackId { + GameMeetingStonePartyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStonePartyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameMeetingStonePartyUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameMeetingStonePartyTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStonePartyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyUpdateCallbackId { + GameMeetingStonePartyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStonePartyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameMeetingStonePartyTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStonePartyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStonePartyUpdateCallbackId { + GameMeetingStonePartyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStonePartyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `group_id` unique index on the table `game_meeting_stone_party`, +/// which allows point queries on the field of the same name +/// via the [`GameMeetingStonePartyGroupIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_party().group_id().find(...)`. +pub struct GameMeetingStonePartyGroupIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameMeetingStonePartyTableHandle<'ctx> { + /// Get a handle on the `group_id` unique index on the table `game_meeting_stone_party`. + pub fn group_id(&self) -> GameMeetingStonePartyGroupIdUnique<'ctx> { + GameMeetingStonePartyGroupIdUnique { + imp: self.imp.get_unique_constraint::("group_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameMeetingStonePartyGroupIdUnique<'ctx> { + /// Find the subscribed row whose `group_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_meeting_stone_party"); + _table.add_unique_constraint::("group_id", |row| &row.group_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `MeetingStoneParty`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_meeting_stone_partyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `MeetingStoneParty`. + fn game_meeting_stone_party(&self) -> __sdk::__query_builder::Table; +} + +impl game_meeting_stone_partyQueryTableAccess for __sdk::QueryTableAccessor { + fn game_meeting_stone_party(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_meeting_stone_party") + } +} diff --git a/gateway/src/stdb/bindings/game_meeting_stone_seeker_table.rs b/gateway/src/stdb/bindings/game_meeting_stone_seeker_table.rs new file mode 100644 index 000000000..ee7d594f4 --- /dev/null +++ b/gateway/src/stdb/bindings/game_meeting_stone_seeker_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::meeting_stone_seeker_type::MeetingStoneSeeker; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_meeting_stone_seeker`. +/// +/// Obtain a handle from the [`GameMeetingStoneSeekerTableAccess::game_meeting_stone_seeker`] method on [`super::RemoteTables`], +/// like `ctx.db.game_meeting_stone_seeker()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_seeker().on_insert(...)`. +pub struct GameMeetingStoneSeekerTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_meeting_stone_seeker`. +pub struct GameMeetingStoneSeekerTableAccessor; + +impl __sdk::TableAccessor for GameMeetingStoneSeekerTableAccessor { + type Row = MeetingStoneSeeker; + type Handle<'db> = GameMeetingStoneSeekerTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_meeting_stone_seeker() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_meeting_stone_seeker`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameMeetingStoneSeekerTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameMeetingStoneSeekerTableHandle`], which mediates access to the table `game_meeting_stone_seeker`. + fn game_meeting_stone_seeker(&self) -> GameMeetingStoneSeekerTableHandle<'_>; +} + +impl GameMeetingStoneSeekerTableAccess for super::RemoteTables { + fn game_meeting_stone_seeker(&self) -> GameMeetingStoneSeekerTableHandle<'_> { + GameMeetingStoneSeekerTableHandle { + imp: self + .imp + .get_table::("game_meeting_stone_seeker"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameMeetingStoneSeekerInsertCallbackId(__sdk::CallbackId); +pub struct GameMeetingStoneSeekerDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameMeetingStoneSeekerTableHandle<'ctx> { + type Row = MeetingStoneSeeker; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameMeetingStoneSeekerTableHandle<'ctx> { + type Row = MeetingStoneSeeker; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameMeetingStoneSeekerInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerInsertCallbackId { + GameMeetingStoneSeekerInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneSeekerInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameMeetingStoneSeekerDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerDeleteCallbackId { + GameMeetingStoneSeekerDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneSeekerDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameMeetingStoneSeekerTableHandle<'ctx> { + type InsertCallbackId = GameMeetingStoneSeekerInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerInsertCallbackId { + GameMeetingStoneSeekerInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneSeekerInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameMeetingStoneSeekerTableHandle<'ctx> { + type DeleteCallbackId = GameMeetingStoneSeekerDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerDeleteCallbackId { + GameMeetingStoneSeekerDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneSeekerDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameMeetingStoneSeekerUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameMeetingStoneSeekerTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneSeekerUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerUpdateCallbackId { + GameMeetingStoneSeekerUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneSeekerUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameMeetingStoneSeekerTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneSeekerUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneSeekerUpdateCallbackId { + GameMeetingStoneSeekerUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneSeekerUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `character_guid` unique index on the table `game_meeting_stone_seeker`, +/// which allows point queries on the field of the same name +/// via the [`GameMeetingStoneSeekerCharacterGuidUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_seeker().character_guid().find(...)`. +pub struct GameMeetingStoneSeekerCharacterGuidUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameMeetingStoneSeekerTableHandle<'ctx> { + /// Get a handle on the `character_guid` unique index on the table `game_meeting_stone_seeker`. + pub fn character_guid(&self) -> GameMeetingStoneSeekerCharacterGuidUnique<'ctx> { + GameMeetingStoneSeekerCharacterGuidUnique { + imp: self.imp.get_unique_constraint::("character_guid"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameMeetingStoneSeekerCharacterGuidUnique<'ctx> { + /// Find the subscribed row whose `character_guid` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_meeting_stone_seeker"); + _table.add_unique_constraint::("character_guid", |row| &row.character_guid); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `MeetingStoneSeeker`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_meeting_stone_seekerQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `MeetingStoneSeeker`. + fn game_meeting_stone_seeker(&self) -> __sdk::__query_builder::Table; +} + +impl game_meeting_stone_seekerQueryTableAccess for __sdk::QueryTableAccessor { + fn game_meeting_stone_seeker(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_meeting_stone_seeker") + } +} diff --git a/gateway/src/stdb/bindings/game_meeting_stone_table.rs b/gateway/src/stdb/bindings/game_meeting_stone_table.rs new file mode 100644 index 000000000..4544d6602 --- /dev/null +++ b/gateway/src/stdb/bindings/game_meeting_stone_table.rs @@ -0,0 +1,228 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::meeting_stone_type::MeetingStone; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_meeting_stone`. +/// +/// Obtain a handle from the [`GameMeetingStoneTableAccess::game_meeting_stone`] method on [`super::RemoteTables`], +/// like `ctx.db.game_meeting_stone()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone().on_insert(...)`. +pub struct GameMeetingStoneTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_meeting_stone`. +pub struct GameMeetingStoneTableAccessor; + +impl __sdk::TableAccessor for GameMeetingStoneTableAccessor { + type Row = MeetingStone; + type Handle<'db> = GameMeetingStoneTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_meeting_stone() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_meeting_stone`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameMeetingStoneTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameMeetingStoneTableHandle`], which mediates access to the table `game_meeting_stone`. + fn game_meeting_stone(&self) -> GameMeetingStoneTableHandle<'_>; +} + +impl GameMeetingStoneTableAccess for super::RemoteTables { + fn game_meeting_stone(&self) -> GameMeetingStoneTableHandle<'_> { + GameMeetingStoneTableHandle { + imp: self.imp.get_table::("game_meeting_stone"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameMeetingStoneInsertCallbackId(__sdk::CallbackId); +pub struct GameMeetingStoneDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameMeetingStoneTableHandle<'ctx> { + type Row = MeetingStone; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameMeetingStoneTableHandle<'ctx> { + type Row = MeetingStone; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameMeetingStoneInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneInsertCallbackId { + GameMeetingStoneInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameMeetingStoneDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneDeleteCallbackId { + GameMeetingStoneDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameMeetingStoneTableHandle<'ctx> { + type InsertCallbackId = GameMeetingStoneInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneInsertCallbackId { + GameMeetingStoneInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameMeetingStoneTableHandle<'ctx> { + type DeleteCallbackId = GameMeetingStoneDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneDeleteCallbackId { + GameMeetingStoneDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameMeetingStoneUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameMeetingStoneTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneUpdateCallbackId { + GameMeetingStoneUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameMeetingStoneTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneUpdateCallbackId { + GameMeetingStoneUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `entry` unique index on the table `game_meeting_stone`, +/// which allows point queries on the field of the same name +/// via the [`GameMeetingStoneEntryUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone().entry().find(...)`. +pub struct GameMeetingStoneEntryUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameMeetingStoneTableHandle<'ctx> { + /// Get a handle on the `entry` unique index on the table `game_meeting_stone`. + pub fn entry(&self) -> GameMeetingStoneEntryUnique<'ctx> { + GameMeetingStoneEntryUnique { + imp: self.imp.get_unique_constraint::("entry"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameMeetingStoneEntryUnique<'ctx> { + /// Find the subscribed row whose `entry` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u32) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_meeting_stone"); + _table.add_unique_constraint::("entry", |row| &row.entry); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `MeetingStone`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_meeting_stoneQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `MeetingStone`. + fn game_meeting_stone(&self) -> __sdk::__query_builder::Table; +} + +impl game_meeting_stoneQueryTableAccess for __sdk::QueryTableAccessor { + fn game_meeting_stone(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_meeting_stone") + } +} diff --git a/gateway/src/stdb/bindings/gw_admit_meeting_stone_reducer.rs b/gateway/src/stdb/bindings/gw_admit_meeting_stone_reducer.rs new file mode 100644 index 000000000..744b1332a --- /dev/null +++ b/gateway/src/stdb/bindings/gw_admit_meeting_stone_reducer.rs @@ -0,0 +1,83 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::session_actor_type::SessionActor; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GwAdmitMeetingStoneArgs { + pub request_actor: SessionActor, + pub go_guid: u64, +} + +impl From for super::Reducer { + fn from(args: GwAdmitMeetingStoneArgs) -> Self { + Self::GwAdmitMeetingStone { + request_actor: args.request_actor, + go_guid: args.go_guid, + } + } +} + +impl __sdk::InModule for GwAdmitMeetingStoneArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `gw_admit_meeting_stone`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait gw_admit_meeting_stone { + /// Request that the remote module invoke the reducer `gw_admit_meeting_stone` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`gw_admit_meeting_stone:gw_admit_meeting_stone_then`] to run a callback after the reducer completes. + fn gw_admit_meeting_stone( + &self, + request_actor: SessionActor, + go_guid: u64, + ) -> __sdk::Result<()> { + self.gw_admit_meeting_stone_then(request_actor, go_guid, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `gw_admit_meeting_stone` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn gw_admit_meeting_stone_then( + &self, + request_actor: SessionActor, + go_guid: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl gw_admit_meeting_stone for super::RemoteReducers { + fn gw_admit_meeting_stone_then( + &self, + request_actor: SessionActor, + go_guid: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + GwAdmitMeetingStoneArgs { + request_actor, + go_guid, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/meeting_stone_party_type.rs b/gateway/src/stdb/bindings/meeting_stone_party_type.rs new file mode 100644 index 000000000..58ea39b54 --- /dev/null +++ b/gateway/src/stdb/bindings/meeting_stone_party_type.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct MeetingStoneParty { + pub group_id: u64, + pub area_id: u32, + pub team: u32, + pub queued_at: __sdk::Timestamp, + pub next_reminder_at: __sdk::Timestamp, +} + +impl __sdk::InModule for MeetingStoneParty { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `MeetingStoneParty`. +/// +/// Provides typed access to columns for query building. +pub struct MeetingStonePartyCols { + pub group_id: __sdk::__query_builder::Col, + pub area_id: __sdk::__query_builder::Col, + pub team: __sdk::__query_builder::Col, + pub queued_at: __sdk::__query_builder::Col, + pub next_reminder_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for MeetingStoneParty { + type Cols = MeetingStonePartyCols; + fn cols(table_name: &'static str) -> Self::Cols { + MeetingStonePartyCols { + group_id: __sdk::__query_builder::Col::new(table_name, "group_id"), + area_id: __sdk::__query_builder::Col::new(table_name, "area_id"), + team: __sdk::__query_builder::Col::new(table_name, "team"), + queued_at: __sdk::__query_builder::Col::new(table_name, "queued_at"), + next_reminder_at: __sdk::__query_builder::Col::new(table_name, "next_reminder_at"), + } + } +} + +/// Indexed column accessor struct for the table `MeetingStoneParty`. +/// +/// Provides typed access to indexed columns for query building. +pub struct MeetingStonePartyIxCols { + pub group_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for MeetingStoneParty { + type IxCols = MeetingStonePartyIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + MeetingStonePartyIxCols { + group_id: __sdk::__query_builder::IxCol::new(table_name, "group_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for MeetingStoneParty {} diff --git a/gateway/src/stdb/bindings/meeting_stone_seeker_type.rs b/gateway/src/stdb/bindings/meeting_stone_seeker_type.rs new file mode 100644 index 000000000..69c6838ea --- /dev/null +++ b/gateway/src/stdb/bindings/meeting_stone_seeker_type.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct MeetingStoneSeeker { + pub character_guid: u64, + pub area_id: u32, + pub team: u32, + pub class: u8, + pub group_id: u64, + pub queued_at: __sdk::Timestamp, +} + +impl __sdk::InModule for MeetingStoneSeeker { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `MeetingStoneSeeker`. +/// +/// Provides typed access to columns for query building. +pub struct MeetingStoneSeekerCols { + pub character_guid: __sdk::__query_builder::Col, + pub area_id: __sdk::__query_builder::Col, + pub team: __sdk::__query_builder::Col, + pub class: __sdk::__query_builder::Col, + pub group_id: __sdk::__query_builder::Col, + pub queued_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for MeetingStoneSeeker { + type Cols = MeetingStoneSeekerCols; + fn cols(table_name: &'static str) -> Self::Cols { + MeetingStoneSeekerCols { + character_guid: __sdk::__query_builder::Col::new(table_name, "character_guid"), + area_id: __sdk::__query_builder::Col::new(table_name, "area_id"), + team: __sdk::__query_builder::Col::new(table_name, "team"), + class: __sdk::__query_builder::Col::new(table_name, "class"), + group_id: __sdk::__query_builder::Col::new(table_name, "group_id"), + queued_at: __sdk::__query_builder::Col::new(table_name, "queued_at"), + } + } +} + +/// Indexed column accessor struct for the table `MeetingStoneSeeker`. +/// +/// Provides typed access to indexed columns for query building. +pub struct MeetingStoneSeekerIxCols { + pub character_guid: __sdk::__query_builder::IxCol, + pub group_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for MeetingStoneSeeker { + type IxCols = MeetingStoneSeekerIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + MeetingStoneSeekerIxCols { + character_guid: __sdk::__query_builder::IxCol::new(table_name, "character_guid"), + group_id: __sdk::__query_builder::IxCol::new(table_name, "group_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for MeetingStoneSeeker {} diff --git a/gateway/src/stdb/bindings/meeting_stone_type.rs b/gateway/src/stdb/bindings/meeting_stone_type.rs new file mode 100644 index 000000000..ef4d6acab --- /dev/null +++ b/gateway/src/stdb/bindings/meeting_stone_type.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct MeetingStone { + pub entry: u32, + pub min_level: u32, + pub max_level: u32, + pub area_id: u32, +} + +impl __sdk::InModule for MeetingStone { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `MeetingStone`. +/// +/// Provides typed access to columns for query building. +pub struct MeetingStoneCols { + pub entry: __sdk::__query_builder::Col, + pub min_level: __sdk::__query_builder::Col, + pub max_level: __sdk::__query_builder::Col, + pub area_id: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for MeetingStone { + type Cols = MeetingStoneCols; + fn cols(table_name: &'static str) -> Self::Cols { + MeetingStoneCols { + entry: __sdk::__query_builder::Col::new(table_name, "entry"), + min_level: __sdk::__query_builder::Col::new(table_name, "min_level"), + max_level: __sdk::__query_builder::Col::new(table_name, "max_level"), + area_id: __sdk::__query_builder::Col::new(table_name, "area_id"), + } + } +} + +/// Indexed column accessor struct for the table `MeetingStone`. +/// +/// Provides typed access to indexed columns for query building. +pub struct MeetingStoneIxCols { + pub entry: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for MeetingStone { + type IxCols = MeetingStoneIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + MeetingStoneIxCols { + entry: __sdk::__query_builder::IxCol::new(table_name, "entry"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for MeetingStone {} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index a49cc81fd..c173d1d48 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -147,6 +147,8 @@ pub mod debug_assert_unreachable_goal_stops_at_wall_reducer; pub mod debug_audit_class_kits_reducer; pub mod debug_audit_quest_chains_reducer; pub mod debug_auto_bank_item_reducer; +pub mod debug_backdate_meeting_stone_party_reducer; +pub mod debug_backdate_meeting_stone_seeker_reducer; pub mod debug_backfill_cell_ids_reducer; pub mod debug_backfill_go_grid_reducer; pub mod debug_begin_cast_reducer; @@ -279,6 +281,9 @@ pub mod debug_stage_loot_roll_fixture_reducer; pub mod debug_stage_mail_expiry_fixture_reducer; pub mod debug_stage_mail_legacy_fixture_reducer; pub mod debug_stage_mail_take_fixture_reducer; +pub mod debug_stage_meeting_stone_character_reducer; +pub mod debug_stage_meeting_stone_group_reducer; +pub mod debug_stage_meeting_stone_reducer; pub mod debug_stage_ranged_lethal_damage_floor_fixture_reducer; pub mod debug_stage_reward_letter_fixture_reducer; pub mod debug_stress_relay_reducer; @@ -546,6 +551,9 @@ pub mod game_mail_table; pub mod game_mail_template_table; pub mod game_mail_timer_table; pub mod game_map_region_table; +pub mod game_meeting_stone_party_table; +pub mod game_meeting_stone_seeker_table; +pub mod game_meeting_stone_table; pub mod game_melee_attack_table; pub mod game_melee_schedule_table; pub mod game_motion_publish_schedule_table; @@ -721,6 +729,7 @@ pub mod gw_ack_taxi_reply_reducer; pub mod gw_activate_taxi_reducer; pub mod gw_add_friend_reducer; pub mod gw_add_ignore_reducer; +pub mod gw_admit_meeting_stone_reducer; pub mod gw_arm_taxi_flight_reducer; pub mod gw_attack_reducer; pub mod gw_auction_bid_local_reducer; @@ -871,6 +880,9 @@ pub mod mail_timer_type; pub mod mail_type; pub mod map_region_type; pub mod mark_bot_transfer_arrival_ready_reducer; +pub mod meeting_stone_party_type; +pub mod meeting_stone_seeker_type; +pub mod meeting_stone_type; pub mod melee_attack_type; pub mod melee_schedule_type; pub mod missing_text_template_no_effect_type; @@ -975,6 +987,7 @@ pub mod realm_mail_take_item_fence_reducer; pub mod realm_mail_take_item_reducer; pub mod realm_mail_take_money_fence_reducer; pub mod realm_mail_take_money_reducer; +pub mod realm_meeting_stone_op_reducer; pub mod realm_type; pub mod realm_whisper_reducer; pub mod reap_gateway_leases_reducer; @@ -1051,6 +1064,7 @@ pub mod scale_all_threat_instruction_type; pub mod scale_selected_threat_instruction_type; pub mod school_lockout_type; pub mod script_type; +pub mod seeker_facts_type; pub mod server_config_type; pub mod session_actor_type; pub mod session_reaper_schedule_type; @@ -1294,6 +1308,8 @@ pub use debug_assert_unreachable_goal_stops_at_wall_reducer::debug_assert_unreac pub use debug_audit_class_kits_reducer::debug_audit_class_kits; pub use debug_audit_quest_chains_reducer::debug_audit_quest_chains; pub use debug_auto_bank_item_reducer::debug_auto_bank_item; +pub use debug_backdate_meeting_stone_party_reducer::debug_backdate_meeting_stone_party; +pub use debug_backdate_meeting_stone_seeker_reducer::debug_backdate_meeting_stone_seeker; pub use debug_backfill_cell_ids_reducer::debug_backfill_cell_ids; pub use debug_backfill_go_grid_reducer::debug_backfill_go_grid; pub use debug_begin_cast_reducer::debug_begin_cast; @@ -1426,6 +1442,9 @@ pub use debug_stage_loot_roll_fixture_reducer::debug_stage_loot_roll_fixture; pub use debug_stage_mail_expiry_fixture_reducer::debug_stage_mail_expiry_fixture; pub use debug_stage_mail_legacy_fixture_reducer::debug_stage_mail_legacy_fixture; pub use debug_stage_mail_take_fixture_reducer::debug_stage_mail_take_fixture; +pub use debug_stage_meeting_stone_character_reducer::debug_stage_meeting_stone_character; +pub use debug_stage_meeting_stone_group_reducer::debug_stage_meeting_stone_group; +pub use debug_stage_meeting_stone_reducer::debug_stage_meeting_stone; pub use debug_stage_ranged_lethal_damage_floor_fixture_reducer::debug_stage_ranged_lethal_damage_floor_fixture; pub use debug_stage_reward_letter_fixture_reducer::debug_stage_reward_letter_fixture; pub use debug_stress_relay_reducer::debug_stress_relay; @@ -1693,6 +1712,9 @@ pub use game_mail_table::*; pub use game_mail_template_table::*; pub use game_mail_timer_table::*; pub use game_map_region_table::*; +pub use game_meeting_stone_party_table::*; +pub use game_meeting_stone_seeker_table::*; +pub use game_meeting_stone_table::*; pub use game_melee_attack_table::*; pub use game_melee_schedule_table::*; pub use game_motion_publish_schedule_table::*; @@ -1868,6 +1890,7 @@ pub use gw_ack_taxi_reply_reducer::gw_ack_taxi_reply; pub use gw_activate_taxi_reducer::gw_activate_taxi; pub use gw_add_friend_reducer::gw_add_friend; pub use gw_add_ignore_reducer::gw_add_ignore; +pub use gw_admit_meeting_stone_reducer::gw_admit_meeting_stone; pub use gw_arm_taxi_flight_reducer::gw_arm_taxi_flight; pub use gw_attack_reducer::gw_attack; pub use gw_auction_bid_local_reducer::gw_auction_bid_local; @@ -2018,6 +2041,9 @@ pub use mail_timer_type::MailTimer; pub use mail_type::Mail; pub use map_region_type::MapRegion; pub use mark_bot_transfer_arrival_ready_reducer::mark_bot_transfer_arrival_ready; +pub use meeting_stone_party_type::MeetingStoneParty; +pub use meeting_stone_seeker_type::MeetingStoneSeeker; +pub use meeting_stone_type::MeetingStone; pub use melee_attack_type::MeleeAttack; pub use melee_schedule_type::MeleeSchedule; pub use missing_text_template_no_effect_type::MissingTextTemplateNoEffect; @@ -2122,6 +2148,7 @@ pub use realm_mail_take_item_fence_reducer::realm_mail_take_item_fence; pub use realm_mail_take_item_reducer::realm_mail_take_item; pub use realm_mail_take_money_fence_reducer::realm_mail_take_money_fence; pub use realm_mail_take_money_reducer::realm_mail_take_money; +pub use realm_meeting_stone_op_reducer::realm_meeting_stone_op; pub use realm_type::Realm; pub use realm_whisper_reducer::realm_whisper; pub use reap_gateway_leases_reducer::reap_gateway_leases; @@ -2198,6 +2225,7 @@ pub use scale_all_threat_instruction_type::ScaleAllThreatInstruction; pub use scale_selected_threat_instruction_type::ScaleSelectedThreatInstruction; pub use school_lockout_type::SchoolLockout; pub use script_type::Script; +pub use seeker_facts_type::SeekerFacts; pub use server_config_type::ServerConfig; pub use session_actor_type::SessionActor; pub use session_reaper_schedule_type::SessionReaperSchedule; @@ -2491,6 +2519,14 @@ pub enum Reducer { character_guid: u64, slot: u8, }, + DebugBackdateMeetingStoneParty { + group_id: u64, + secs: u32, + }, + DebugBackdateMeetingStoneSeeker { + character_guid: u64, + secs: u32, + }, DebugBackfillCellIds, DebugBackfillGoGrid, DebugBeginCast { @@ -3039,6 +3075,33 @@ pub enum Reducer { DebugStageMailExpiryFixture, DebugStageMailLegacyFixture, DebugStageMailTakeFixture, + DebugStageMeetingStone { + entry: u32, + go_guid: u64, + min_level: u32, + max_level: u32, + area_id: u32, + map_id: u32, + x: f32, + y: f32, + z: f32, + }, + DebugStageMeetingStoneCharacter { + guid: u64, + account_id: u64, + race: u8, + class: u8, + level: u8, + map_id: u32, + x: f32, + y: f32, + z: f32, + }, + DebugStageMeetingStoneGroup { + leader_guid: u64, + member_guids: Vec, + raid: bool, + }, DebugStageRangedLethalDamageFloorFixture { attacker_guid: u64, target_guid: u64, @@ -3292,6 +3355,10 @@ pub enum Reducer { request_actor: SessionActor, target_guid: u64, }, + GwAdmitMeetingStone { + request_actor: SessionActor, + go_guid: u64, + }, GwArmTaxiFlight { request_actor: SessionActor, }, @@ -4090,6 +4157,12 @@ pub enum Reducer { mail_id: u64, expect_money: u32, }, + RealmMeetingStoneOp { + op: u8, + request_actor: SessionActor, + area_id: u32, + seekers: Vec, + }, RealmWhisper { request_actor: SessionActor, request: WhisperRequest, @@ -4311,6 +4384,10 @@ impl __sdk::Reducer for Reducer { Reducer::DebugAuditClassKits => "debug_audit_class_kits", Reducer::DebugAuditQuestChains => "debug_audit_quest_chains", Reducer::DebugAutoBankItem { .. } => "debug_auto_bank_item", + Reducer::DebugBackdateMeetingStoneParty { .. } => "debug_backdate_meeting_stone_party", + Reducer::DebugBackdateMeetingStoneSeeker { .. } => { + "debug_backdate_meeting_stone_seeker" + } Reducer::DebugBackfillCellIds => "debug_backfill_cell_ids", Reducer::DebugBackfillGoGrid => "debug_backfill_go_grid", Reducer::DebugBeginCast { .. } => "debug_begin_cast", @@ -4468,6 +4545,11 @@ impl __sdk::Reducer for Reducer { Reducer::DebugStageMailExpiryFixture => "debug_stage_mail_expiry_fixture", Reducer::DebugStageMailLegacyFixture => "debug_stage_mail_legacy_fixture", Reducer::DebugStageMailTakeFixture => "debug_stage_mail_take_fixture", + Reducer::DebugStageMeetingStone { .. } => "debug_stage_meeting_stone", + Reducer::DebugStageMeetingStoneCharacter { .. } => { + "debug_stage_meeting_stone_character" + } + Reducer::DebugStageMeetingStoneGroup { .. } => "debug_stage_meeting_stone_group", Reducer::DebugStageRangedLethalDamageFloorFixture { .. } => { "debug_stage_ranged_lethal_damage_floor_fixture" } @@ -4548,6 +4630,7 @@ impl __sdk::Reducer for Reducer { Reducer::GwActivateTaxi { .. } => "gw_activate_taxi", Reducer::GwAddFriend { .. } => "gw_add_friend", Reducer::GwAddIgnore { .. } => "gw_add_ignore", + Reducer::GwAdmitMeetingStone { .. } => "gw_admit_meeting_stone", Reducer::GwArmTaxiFlight { .. } => "gw_arm_taxi_flight", Reducer::GwAttack { .. } => "gw_attack", Reducer::GwAuctionBidLocal { .. } => "gw_auction_bid_local", @@ -4715,6 +4798,7 @@ impl __sdk::Reducer for Reducer { Reducer::RealmMailTakeItemFence { .. } => "realm_mail_take_item_fence", Reducer::RealmMailTakeMoney { .. } => "realm_mail_take_money", Reducer::RealmMailTakeMoneyFence { .. } => "realm_mail_take_money_fence", + Reducer::RealmMeetingStoneOp { .. } => "realm_meeting_stone_op", Reducer::RealmWhisper { .. } => "realm_whisper", Reducer::ReapGatewayLeases { .. } => "reap_gateway_leases", Reducer::ReapInstances { .. } => "reap_instances", @@ -5092,6 +5176,20 @@ Reducer::DebugAutoBankItem{ } => __sats::bsatn::to_vec(&debug_auto_bank_item_reducer::DebugAutoBankItemArgs { character_guid: character_guid.clone(), slot: slot.clone(), +}), + Reducer::DebugBackdateMeetingStoneParty{ + group_id, + secs, +} => __sats::bsatn::to_vec(&debug_backdate_meeting_stone_party_reducer::DebugBackdateMeetingStonePartyArgs { + group_id: group_id.clone(), + secs: secs.clone(), +}), + Reducer::DebugBackdateMeetingStoneSeeker{ + character_guid, + secs, +} => __sats::bsatn::to_vec(&debug_backdate_meeting_stone_seeker_reducer::DebugBackdateMeetingStoneSeekerArgs { + character_guid: character_guid.clone(), + secs: secs.clone(), }), Reducer::DebugBackfillCellIds => __sats::bsatn::to_vec(&debug_backfill_cell_ids_reducer::DebugBackfillCellIdsArgs { }), @@ -6075,7 +6173,58 @@ Reducer::DebugStageMailLegacyFixture => __sats::bsatn::to_vec(&debug_stage_mail_ }), Reducer::DebugStageMailTakeFixture => __sats::bsatn::to_vec(&debug_stage_mail_take_fixture_reducer::DebugStageMailTakeFixtureArgs { }), -Reducer::DebugStageRangedLethalDamageFloorFixture{ +Reducer::DebugStageMeetingStone{ + entry, + go_guid, + min_level, + max_level, + area_id, + map_id, + x, + y, + z, +} => __sats::bsatn::to_vec(&debug_stage_meeting_stone_reducer::DebugStageMeetingStoneArgs { + entry: entry.clone(), + go_guid: go_guid.clone(), + min_level: min_level.clone(), + max_level: max_level.clone(), + area_id: area_id.clone(), + map_id: map_id.clone(), + x: x.clone(), + y: y.clone(), + z: z.clone(), +}), + Reducer::DebugStageMeetingStoneCharacter{ + guid, + account_id, + race, + class, + level, + map_id, + x, + y, + z, +} => __sats::bsatn::to_vec(&debug_stage_meeting_stone_character_reducer::DebugStageMeetingStoneCharacterArgs { + guid: guid.clone(), + account_id: account_id.clone(), + race: race.clone(), + class: class.clone(), + level: level.clone(), + map_id: map_id.clone(), + x: x.clone(), + y: y.clone(), + z: z.clone(), +}), + Reducer::DebugStageMeetingStoneGroup{ + leader_guid, + member_guids, + raid, +} => __sats::bsatn::to_vec(&debug_stage_meeting_stone_group_reducer::DebugStageMeetingStoneGroupArgs { + leader_guid: leader_guid.clone(), + member_guids: member_guids.clone(), + raid: raid.clone(), +}), + Reducer::DebugStageRangedLethalDamageFloorFixture{ attacker_guid, target_guid, damage, @@ -6530,6 +6679,13 @@ Reducer::DebugVerifyRangedLethalDamageFloorFixture{ } => __sats::bsatn::to_vec(&gw_add_ignore_reducer::GwAddIgnoreArgs { request_actor: request_actor.clone(), target_guid: target_guid.clone(), +}), + Reducer::GwAdmitMeetingStone{ + request_actor, + go_guid, +} => __sats::bsatn::to_vec(&gw_admit_meeting_stone_reducer::GwAdmitMeetingStoneArgs { + request_actor: request_actor.clone(), + go_guid: go_guid.clone(), }), Reducer::GwArmTaxiFlight{ request_actor, @@ -7971,6 +8127,17 @@ Reducer::PlayerbotsFixtureCommandApply{ request_actor: request_actor.clone(), mail_id: mail_id.clone(), expect_money: expect_money.clone(), +}), + Reducer::RealmMeetingStoneOp{ + op, + request_actor, + area_id, + seekers, +} => __sats::bsatn::to_vec(&realm_meeting_stone_op_reducer::RealmMeetingStoneOpArgs { + op: op.clone(), + request_actor: request_actor.clone(), + area_id: area_id.clone(), + seekers: seekers.clone(), }), Reducer::RealmWhisper{ request_actor, @@ -8459,6 +8626,9 @@ pub struct DbUpdate { game_mail_template: __sdk::TableUpdate, game_mail_timer: __sdk::TableUpdate, game_map_region: __sdk::TableUpdate, + game_meeting_stone: __sdk::TableUpdate, + game_meeting_stone_party: __sdk::TableUpdate, + game_meeting_stone_seeker: __sdk::TableUpdate, game_melee_attack: __sdk::TableUpdate, game_melee_schedule: __sdk::TableUpdate, game_motion_publish_schedule: __sdk::TableUpdate, @@ -9160,6 +9330,15 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_map_region" => db_update .game_map_region .append(game_map_region_table::parse_table_update(table_update)?), + "game_meeting_stone" => db_update + .game_meeting_stone + .append(game_meeting_stone_table::parse_table_update(table_update)?), + "game_meeting_stone_party" => db_update.game_meeting_stone_party.append( + game_meeting_stone_party_table::parse_table_update(table_update)?, + ), + "game_meeting_stone_seeker" => db_update.game_meeting_stone_seeker.append( + game_meeting_stone_seeker_table::parse_table_update(table_update)?, + ), "game_melee_attack" => db_update .game_melee_attack .append(game_melee_attack_table::parse_table_update(table_update)?), @@ -10320,6 +10499,21 @@ impl __sdk::DbUpdate for DbUpdate { diff.game_map_region = cache .apply_diff_to_table::("game_map_region", &self.game_map_region) .with_updates_by_pk(|row| &row.key); + diff.game_meeting_stone = cache + .apply_diff_to_table::("game_meeting_stone", &self.game_meeting_stone) + .with_updates_by_pk(|row| &row.entry); + diff.game_meeting_stone_party = cache + .apply_diff_to_table::( + "game_meeting_stone_party", + &self.game_meeting_stone_party, + ) + .with_updates_by_pk(|row| &row.group_id); + diff.game_meeting_stone_seeker = cache + .apply_diff_to_table::( + "game_meeting_stone_seeker", + &self.game_meeting_stone_seeker, + ) + .with_updates_by_pk(|row| &row.character_guid); diff.game_melee_attack = cache .apply_diff_to_table::("game_melee_attack", &self.game_melee_attack) .with_updates_by_pk(|row| &row.attacker_guid); @@ -11305,6 +11499,15 @@ impl __sdk::DbUpdate for DbUpdate { "game_map_region" => db_update .game_map_region .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_meeting_stone" => db_update + .game_meeting_stone + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_meeting_stone_party" => db_update + .game_meeting_stone_party + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_meeting_stone_seeker" => db_update + .game_meeting_stone_seeker + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_melee_attack" => db_update .game_melee_attack .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -12161,6 +12364,15 @@ impl __sdk::DbUpdate for DbUpdate { "game_map_region" => db_update .game_map_region .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_meeting_stone" => db_update + .game_meeting_stone + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_meeting_stone_party" => db_update + .game_meeting_stone_party + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_meeting_stone_seeker" => db_update + .game_meeting_stone_seeker + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_melee_attack" => db_update .game_melee_attack .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -12668,6 +12880,9 @@ pub struct AppliedDiff<'r> { game_mail_template: __sdk::TableAppliedDiff<'r, MailTemplate>, game_mail_timer: __sdk::TableAppliedDiff<'r, MailTimer>, game_map_region: __sdk::TableAppliedDiff<'r, MapRegion>, + game_meeting_stone: __sdk::TableAppliedDiff<'r, MeetingStone>, + game_meeting_stone_party: __sdk::TableAppliedDiff<'r, MeetingStoneParty>, + game_meeting_stone_seeker: __sdk::TableAppliedDiff<'r, MeetingStoneSeeker>, game_melee_attack: __sdk::TableAppliedDiff<'r, MeleeAttack>, game_melee_schedule: __sdk::TableAppliedDiff<'r, MeleeSchedule>, game_motion_publish_schedule: __sdk::TableAppliedDiff<'r, MotionPublishSchedule>, @@ -13626,6 +13841,21 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_map_region, event, ); + callbacks.invoke_table_row_callbacks::( + "game_meeting_stone", + &self.game_meeting_stone, + event, + ); + callbacks.invoke_table_row_callbacks::( + "game_meeting_stone_party", + &self.game_meeting_stone_party, + event, + ); + callbacks.invoke_table_row_callbacks::( + "game_meeting_stone_seeker", + &self.game_meeting_stone_seeker, + event, + ); callbacks.invoke_table_row_callbacks::( "game_melee_attack", &self.game_melee_attack, @@ -14963,6 +15193,9 @@ impl __sdk::SpacetimeModule for RemoteModule { game_mail_template_table::register_table(client_cache); game_mail_timer_table::register_table(client_cache); game_map_region_table::register_table(client_cache); + game_meeting_stone_table::register_table(client_cache); + game_meeting_stone_party_table::register_table(client_cache); + game_meeting_stone_seeker_table::register_table(client_cache); game_melee_attack_table::register_table(client_cache); game_melee_schedule_table::register_table(client_cache); game_motion_publish_schedule_table::register_table(client_cache); @@ -15246,6 +15479,9 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_mail_template", "game_mail_timer", "game_map_region", + "game_meeting_stone", + "game_meeting_stone_party", + "game_meeting_stone_seeker", "game_melee_attack", "game_melee_schedule", "game_motion_publish_schedule", diff --git a/gateway/src/stdb/bindings/realm_meeting_stone_op_reducer.rs b/gateway/src/stdb/bindings/realm_meeting_stone_op_reducer.rs new file mode 100644 index 000000000..119ddad15 --- /dev/null +++ b/gateway/src/stdb/bindings/realm_meeting_stone_op_reducer.rs @@ -0,0 +1,96 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::seeker_facts_type::SeekerFacts; +use super::session_actor_type::SessionActor; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct RealmMeetingStoneOpArgs { + pub op: u8, + pub request_actor: SessionActor, + pub area_id: u32, + pub seekers: Vec, +} + +impl From for super::Reducer { + fn from(args: RealmMeetingStoneOpArgs) -> Self { + Self::RealmMeetingStoneOp { + op: args.op, + request_actor: args.request_actor, + area_id: args.area_id, + seekers: args.seekers, + } + } +} + +impl __sdk::InModule for RealmMeetingStoneOpArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `realm_meeting_stone_op`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait realm_meeting_stone_op { + /// Request that the remote module invoke the reducer `realm_meeting_stone_op` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`realm_meeting_stone_op:realm_meeting_stone_op_then`] to run a callback after the reducer completes. + fn realm_meeting_stone_op( + &self, + op: u8, + request_actor: SessionActor, + area_id: u32, + seekers: Vec, + ) -> __sdk::Result<()> { + self.realm_meeting_stone_op_then(op, request_actor, area_id, seekers, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `realm_meeting_stone_op` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn realm_meeting_stone_op_then( + &self, + op: u8, + request_actor: SessionActor, + area_id: u32, + seekers: Vec, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl realm_meeting_stone_op for super::RemoteReducers { + fn realm_meeting_stone_op_then( + &self, + op: u8, + request_actor: SessionActor, + area_id: u32, + seekers: Vec, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + RealmMeetingStoneOpArgs { + op, + request_actor, + area_id, + seekers, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/seeker_facts_type.rs b/gateway/src/stdb/bindings/seeker_facts_type.rs new file mode 100644 index 000000000..5016c94fe --- /dev/null +++ b/gateway/src/stdb/bindings/seeker_facts_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SeekerFacts { + pub character_guid: u64, + pub race: u8, + pub class: u8, +} + +impl __sdk::InModule for SeekerFacts { + type Module = super::RemoteModule; +} diff --git a/module/src/account_ownership.rs b/module/src/account_ownership.rs index c76d14ec8..ff844d256 100644 --- a/module/src/account_ownership.rs +++ b/module/src/account_ownership.rs @@ -183,10 +183,10 @@ pub fn claim_account( .ok_or("Account generation exhausted")?, None => 1, }; - // The prior generation is dead: released, or expired before the reaper closed it. Its - // Character leaves every Chat Channel before the new generation exists. A no-op after release. + // The prior generation is dead: released, or expired before the reaper closed it. What it + // admitted ends before the new generation exists. A no-op after release. if let Some(row) = &prior { - crate::channel::leave_all(ctx, row.character_guid); + claim_ended(ctx, row.character_guid); } let row = AccountClaim { account_id, @@ -233,12 +233,20 @@ pub fn release_account_claim(ctx: &ReducerContext, token: WorldSessionToken) -> let character_guid = row.character_guid; row.closed = true; ctx.db.game_account_claim().account_id().update(row); - crate::channel::leave_all(ctx, character_guid); + claim_ended(ctx, character_guid); } } Ok(()) } +/// End what an Account Claim admitted for its Character: every Chat Channel Membership and a solo +/// Seeker. Each place that releases, replaces or reaps a claim calls this, so the next claim-scoped +/// state hooks here once. +fn claim_ended(ctx: &ReducerContext, character_guid: u64) { + crate::channel::leave_all(ctx, character_guid); + crate::meeting_stone::claim_ended(ctx, character_guid); +} + /// The Gateway must obtain this generation from Realm-core and install it on every configured /// World Shard before admission. Older deliveries cannot lower a Shard's retained generation. #[reducer] @@ -543,7 +551,8 @@ pub(crate) fn require_actor_for( } /// Close open Account Claims whose deadline has passed, at most [`REAP_LIMIT`] per call. Called -/// from the Gateway lease schedule, so a crashed Gateway's Characters leave their Chat Channels. +/// from the Gateway lease schedule, so a crashed Gateway's Characters leave their Chat Channels +/// and the Meeting Stone Queue. /// [`claim_account`] and [`require_actor`] already treat an expired claim as dead, so closing one /// changes no admission outcome. pub(crate) fn reap_account_claims(ctx: &ReducerContext) { @@ -560,7 +569,7 @@ pub(crate) fn reap_account_claims(ctx: &ReducerContext) { let character_guid = row.character_guid; row.closed = true; ctx.db.game_account_claim().account_id().update(row); - crate::channel::leave_all(ctx, character_guid); + claim_ended(ctx, character_guid); } } diff --git a/module/src/debug/meeting_stone.rs b/module/src/debug/meeting_stone.rs new file mode 100644 index 000000000..1d36f7a8e --- /dev/null +++ b/module/src/debug/meeting_stone.rs @@ -0,0 +1,254 @@ +//! Meeting Stone Queue fixtures for the durable tests. Fixture ids sit in `509_6000`-`509_6099`. +//! +//! The levers stage the world a Meeting Stone needs: a stone and its spawned GameObject, Characters +//! with a chosen race, class and level, each with a live entity and a live Account Claim, and a +//! Party or Raid led by one of them. Two more move a Seeker's or a queued party's time back, so a +//! test never waits on the clock. + +use spacetimedb::{reducer, ReducerContext, Table, TimeDuration}; + +use lyracore_shared::constants::go_type; +use lyracore_shared::group::{GroupKind, RaidSlot}; + +use crate::meeting_stone::{ + game_meeting_stone, game_meeting_stone_party, game_meeting_stone_seeker, MeetingStone, +}; +use crate::{ + game_account_claim, game_character, game_gameobject, game_gameobject_template, game_group, + game_group_member, game_world_entity, AccountClaim, Group, GroupMember, +}; + +/// How long a fixture Account Claim lives. Longer than any durable test runs. +const FIXTURE_CLAIM_MICROS: i64 = 60 * 60 * 1_000_000; + +/// Stage Meeting Stone template `entry` for `area_id` and levels `min_level..=max_level`, spawned +/// as GameObject `go_guid` at `(x, y, z)` on `map_id` in the open world. Pass a `go_guid` below +/// 2^53: `spacetime call` reads integer arguments through a double. A second call replaces all +/// three rows. +#[allow(clippy::too_many_arguments)] +#[reducer] +pub fn debug_stage_meeting_stone( + ctx: &ReducerContext, + entry: u32, + go_guid: u64, + min_level: u32, + max_level: u32, + area_id: u32, + map_id: u32, + x: f32, + y: f32, + z: f32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let templates = ctx.db.game_gameobject_template(); + templates.entry().delete(entry); + templates.insert(crate::GameObjectTemplate { + entry, + type_id: go_type::MEETINGSTONE, + display_id: 0, + name: format!("Meeting Stone {entry}"), + data0: 0, + data1: 0, + gather_skill_line: 0, + respawn_secs: 0, + gather_gray: 0, + lock_id: 0, + size: 0.0, + }); + let stones = ctx.db.game_meeting_stone(); + stones.entry().delete(entry); + stones.insert(MeetingStone { + entry, + min_level, + max_level, + area_id, + }); + let (grid_x, grid_y) = lyracore_shared::spatial::grid_cell(x, y); + ctx.db.game_gameobject().guid().delete(go_guid); + let go = ctx.db.game_gameobject().insert(crate::GameObject { + guid: go_guid, + template_entry: entry, + map_id, + x, + y, + z, + orientation: 0.0, + state: 0, + created_at: ctx.timestamp, + respawn_at_micros: 0, + instance_id: 0, + grid_x, + grid_y, + cell: lyracore_shared::spatial::cell_id_at(x, y), + rotation_0: 0.0, + rotation_1: 0.0, + rotation_2: 0.0, + rotation_3: 0.0, + }); + crate::go_collider::register(ctx, &go); + Ok(()) +} + +/// Stage Character `guid` with `race`, `class` and `level`, in the world at `(x, y, z)` on `map_id`, +/// with a live Account Claim of `account_id`. Its World Session Token is +/// `{account_id, generation: 1, request_nonce: guid}`. A second call replaces the Character, its +/// entity and the claim, and leaves its group and queue rows alone. +#[allow(clippy::too_many_arguments)] +#[reducer] +pub fn debug_stage_meeting_stone_character( + ctx: &ReducerContext, + guid: u64, + account_id: u64, + race: u8, + class: u8, + level: u8, + map_id: u32, + x: f32, + y: f32, + z: f32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + ctx.db.game_world_entity().guid().delete(guid); + let mut character = crate::helpers::character_by_guid(ctx, 1) + .ok_or("the seeded Tester is the fixture template")?; + character.guid = guid; + character.name = format!("Seeker{guid}"); + character.race = race; + character.class = class; + character.level = level; + character.online = true; + character.first_login = false; + (character.map_id, character.x, character.y, character.z) = (map_id, x, y, z); + character.pending_instance_id = 0; + if crate::helpers::character_by_guid(ctx, guid).is_some() { + ctx.db.game_character().guid().update(character); + } else { + ctx.db.game_character().insert(character); + } + super::debug_spawn_player_entity(ctx, guid)?; + let expires_micros = ctx + .timestamp + .to_micros_since_unix_epoch() + .checked_add(FIXTURE_CLAIM_MICROS) + .ok_or("claim deadline overflow")?; + let claims = ctx.db.game_account_claim(); + claims.account_id().delete(account_id); + claims.insert(AccountClaim { + account_id, + generation: 1, + request_nonce: u128::from(guid), + character_guid: guid, + expires_micros, + closed: false, + }); + Ok(()) +} + +/// Stage a Party, or a Raid when `raid` is set, led by `leader_guid` with `member_guids` after the +/// leader in join order. Writes the group rows directly, so it fires no group event. Refuses a +/// Character already in a group. +#[reducer] +pub fn debug_stage_meeting_stone_group( + ctx: &ReducerContext, + leader_guid: u64, + member_guids: Vec, + raid: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let guids: Vec = std::iter::once(leader_guid).chain(member_guids).collect(); + if let Some(grouped) = guids + .iter() + .find(|guid| crate::group::group_of(ctx, **guid).is_some()) + { + return Err(format!("{grouped} is already in a group")); + } + let kind = if raid { + GroupKind::Raid + } else { + GroupKind::Party + }; + if guids.len() > kind.member_cap() { + return Err(format!("{} members do not fit a {kind:?}", guids.len())); + } + let group = ctx.db.game_group().insert(Group { + group_id: 0, + leader_guid, + loot_method: crate::group::loot_method::GROUP, + loot_threshold: 2, + rr_cursor: 0, + master_looter_guid: 0, + group_type: kind.wire(), + }); + let mut slots: Vec = Vec::new(); + for guid in guids { + let slot = match kind { + GroupKind::Party => RaidSlot::default(), + GroupKind::Raid => RaidSlot::for_raid_joiner(slots.iter().copied()) + .ok_or("the raid has no free Subgroup")?, + }; + slots.push(slot); + let owner_identity = crate::helpers::character_by_guid(ctx, guid) + .map_or(spacetimedb::Identity::ZERO, |character| { + character.owner_identity + }); + ctx.db.game_group_member().insert(GroupMember { + id: 0, + group_id: group.group_id, + character_guid: guid, + owner_identity, + raid_slot: slot.wire(), + }); + } + Ok(()) +} + +/// Move Seeker `character_guid`'s `queued_at` back by `secs`. +#[reducer] +pub fn debug_backdate_meeting_stone_seeker( + ctx: &ReducerContext, + character_guid: u64, + secs: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let seekers = ctx.db.game_meeting_stone_seeker(); + let mut seeker = seekers + .character_guid() + .find(character_guid) + .ok_or_else(|| format!("{character_guid} is not a Seeker"))?; + seeker.queued_at = seeker + .queued_at + .checked_sub(seconds(secs)) + .ok_or("queued_at underflow")?; + seekers.character_guid().update(seeker); + Ok(()) +} + +/// Move queued party `group_id`'s `queued_at` and `next_reminder_at` back by `secs`, so it is the +/// older party and its reminder can fall due. +#[reducer] +pub fn debug_backdate_meeting_stone_party( + ctx: &ReducerContext, + group_id: u64, + secs: u32, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let parties = ctx.db.game_meeting_stone_party(); + let mut party = parties + .group_id() + .find(group_id) + .ok_or_else(|| format!("party {group_id} is not queued"))?; + party.queued_at = party + .queued_at + .checked_sub(seconds(secs)) + .ok_or("queued_at underflow")?; + party.next_reminder_at = party + .next_reminder_at + .checked_sub(seconds(secs)) + .ok_or("next_reminder_at underflow")?; + parties.group_id().update(party); + Ok(()) +} + +fn seconds(secs: u32) -> TimeDuration { + TimeDuration::from_micros(i64::from(secs) * 1_000_000) +} diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index d1e44a1ec..99120b336 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -44,6 +44,7 @@ mod eventai; mod fingerprint; mod instance; mod mail; +mod meeting_stone; mod package_config; mod readout; mod repair; @@ -56,6 +57,7 @@ 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 repair::*; diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index c1b455ec8..22c51aaaa 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -669,7 +669,7 @@ fn locked_shut(ctx: &ReducerContext, go_guid: u64, lock_id: u32) -> bool { /// The typed GameObject target Gate shared by lock picking and both use adapters. It resolves the /// GameObject, template and live Character, then applies partition and range Gates once. -fn usable_go( +pub(crate) fn usable_go( ctx: &ReducerContext, caster_guid: u64, go_guid: u64, diff --git a/module/src/group.rs b/module/src/group.rs index f1ae93cea..d5b258acc 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -387,7 +387,7 @@ pub(crate) fn refused(refusal: GroupRefusal, detail: &str) -> String { } #[derive(Debug, PartialEq, Eq)] -enum GroupOpError { +pub(crate) enum GroupOpError { Refused(GroupRefusal), Invariant(String), } @@ -1090,7 +1090,7 @@ pub fn party_facts( /// Resolve a membership and its required parent for mutation cores. `Ok(None)` means the /// Character has no membership; a membership without its Group is a durable invariant failure. -fn checked_group_membership( +pub(crate) fn checked_group_membership( ctx: &ReducerContext, character_guid: u64, ) -> Result, GroupOpError> { diff --git a/module/src/lib.rs b/module/src/lib.rs index ead189b3e..128884dce 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -354,6 +354,8 @@ mod mail_reward; mod mail_text; /// The Mail Timer, Mail Expiry and the Mail Arrival event. mod mail_timer; +/// The Meeting Stone Queue on Realm-core, and the Home Shard Gate for using a stone. +mod meeting_stone; /// Batched movement republish: the PRIVATE `game_entity_motion_pending` staging table that /// `movement_update` writes, and the 20 Hz `publish_motion` tick that drains it into the public /// `game_entity_motion` relay in one transaction. @@ -459,6 +461,7 @@ 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. diff --git a/module/src/meeting_stone.rs b/module/src/meeting_stone.rs new file mode 100644 index 000000000..8f8c4094a --- /dev/null +++ b/module/src/meeting_stone.rs @@ -0,0 +1,483 @@ +//! The Meeting Stone Queue: a Character, or the Party it leads, waits at a dungeon's Meeting Stone +//! for other Seekers of the same dungeon area and team. +//! +//! Two Durable Requests split the work. [`gw_admit_meeting_stone`] runs on the Character's Home +//! Shard, the only database that holds the stone and the live entity, and writes nothing. +//! [`realm_meeting_stone_op`] runs on Realm-core, where party membership is authoritative, so +//! Seekers on different World Shards wait in one queue. Realm-core holds no Character rows, so the +//! Gateway conveys each Seeker's race and class as [`SeekerFacts`]. +//! +//! Notifications are `game_group_event` rows of the +//! [`lyracore_shared::meeting_stone::event_kind`] kinds. A Refusal rolls its transaction back and +//! returns its stable tag; the Gateway answers the party Refusals with `SMSG_MEETINGSTONE_JOINFAILED`. +//! +//! A solo Seeker lasts as long as the Account Claim of its World Session: [`claim_ended`] runs +//! where a claim is released, replaced or reaped. A party Seeker lasts as long as its membership. + +use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table, TimeDuration, Timestamp}; + +use lyracore_shared::constants::go_type; +use lyracore_shared::faction::team_for_race; +use lyracore_shared::group::{GroupKind, GROUP_MAX_MEMBERS}; +use lyracore_shared::meeting_stone::{ + encode_queue, event_kind, queue_status, realm_op, MeetingStoneRefusal, +}; + +/// A queued party is reminded that the queue is still working every five minutes +/// (cm:LFG/LFGMgr.cpp:55). +const REMINDER_INTERVAL_MICROS: i64 = 5 * 60 * 1_000_000; + +/// One Meeting Stone template's level range and dungeon area, from the dump's `data0`, `data1` and +/// `data2`. Private: the Gateway reads it with the owner token. Imported with the `gameobjects` +/// family; a Package does not author it. [static] +#[table(accessor = game_meeting_stone)] +pub struct MeetingStone { + #[primary_key] + pub entry: u32, + pub min_level: u32, + /// 0 means no upper bound. + pub max_level: u32, + /// The dungeon's AreaTable id. + pub area_id: u32, +} + +/// One Seeker: a Character in the Meeting Stone Queue, alone or as a member of a queued Party. +/// Private: the Gateway reads it with the owner token to answer `CMSG_MEETINGSTONE_INFO`. [entity] +#[table( + accessor = game_meeting_stone_seeker, + index(accessor = by_bucket, btree(columns = [area_id, team])), + index(accessor = by_group, btree(columns = [group_id])) +)] +pub struct MeetingStoneSeeker { + #[primary_key] + pub character_guid: u64, + pub area_id: u32, + /// `lyracore_shared::faction` team id. Seekers of different teams never meet. + pub team: u32, + /// The class the Gateway conveyed. 0 when it could not read one. + pub class: u8, + /// The queued party. 0 for a solo Seeker. + pub group_id: u64, + pub queued_at: Timestamp, +} + +/// One queued Party. A row exists exactly while the party is queued, and its Seeker rows are its +/// current members. Private and Module only. [entity] +#[table(accessor = game_meeting_stone_party)] +pub struct MeetingStoneParty { + #[primary_key] + pub group_id: u64, + pub area_id: u32, + pub team: u32, + pub queued_at: Timestamp, + /// When the members next get `SMSG_MEETINGSTONE_IN_PROGRESS`. + pub next_reminder_at: Timestamp, +} + +/// What the Gateway read about one Seeker from whichever World Shard holds it. +#[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] +pub struct SeekerFacts { + pub character_guid: u64, + pub race: u8, + pub class: u8, +} + +fn refused(refusal: MeetingStoneRefusal, detail: &str) -> String { + let tag = refusal.as_tag(); + spacetimedb::log::info!("meeting stone refused {tag}: {detail}"); + tag.to_string() +} + +/// May `actor_guid` use the Meeting Stone `go_guid`? Runs on the actor's Home Shard and writes +/// nothing. Refuses an actor without a World Session, one on a taxi flight, a GameObject out of +/// reach or not a stone, and a level outside the stone's range. Neither core checks the level; +/// the client does, and 1.12 has no failure code for it, so the Gateway drops this Refusal. +#[reducer] +pub fn gw_admit_meeting_stone( + ctx: &ReducerContext, + request_actor: crate::SessionActor, + go_guid: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + admit(ctx, request_actor, actor_guid, go_guid).map_err(|refusal| { + refused( + refusal, + &format!("admission of {actor_guid} at gameobject {go_guid}"), + ) + }) +} + +fn admit( + ctx: &ReducerContext, + request_actor: crate::SessionActor, + actor_guid: u64, + go_guid: u64, +) -> Result<(), MeetingStoneRefusal> { + // Humans only. An Operator-driven Character without a World Session never queues. + if request_actor.ownership.is_none() || crate::taxi::is_in_flight(ctx, actor_guid) { + return Err(MeetingStoneRefusal::ActorUnavailable); + } + let (_, template) = crate::gameobject::usable_go(ctx, actor_guid, go_guid) + .map_err(|refusal| gate_refusal(refusal.kind))?; + if template.type_id != go_type::MEETINGSTONE { + return Err(MeetingStoneRefusal::NotAMeetingStone); + } + let stone = ctx + .db + .game_meeting_stone() + .entry() + .find(template.entry) + .ok_or(MeetingStoneRefusal::NotAMeetingStone)?; + let level = crate::helpers::live_entity(ctx, actor_guid) + .map_err(|_| MeetingStoneRefusal::ActorUnavailable)? + .level; + if !level_in_range(level, stone.min_level, stone.max_level) { + return Err(MeetingStoneRefusal::LevelOutOfRange); + } + Ok(()) +} + +/// The shared GameObject Gate's answer as a Meeting Stone Refusal. `usable_go` produces only the +/// four kinds named here. +fn gate_refusal(kind: crate::actor::ActionRefusalKind) -> MeetingStoneRefusal { + use crate::actor::ActionRefusalKind; + match kind { + ActionRefusalKind::MissingTarget => MeetingStoneRefusal::NotAMeetingStone, + ActionRefusalKind::OtherPartition => MeetingStoneRefusal::OtherPartition, + ActionRefusalKind::OutOfRange => MeetingStoneRefusal::OutOfRange, + _ => MeetingStoneRefusal::ActorUnavailable, + } +} + +/// Inclusive on both ends. A `max_level` of 0 has no upper bound. +fn level_in_range(level: u32, min_level: u32, max_level: u32) -> bool { + level >= min_level && (max_level == 0 || level <= max_level) +} + +/// The actor's party, as the JOIN Gate needs it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Membership { + leads: bool, + kind: GroupKind, + members: usize, +} + +/// A grouped actor queues only as the leader of a Party with room, checked in cmangos's order: +/// not the leader, then a raid, then a full party (cm:LFG/LFGHandler.cpp:58-77). +fn party_join_gate(membership: Membership) -> Result<(), MeetingStoneRefusal> { + if !membership.leads { + return Err(MeetingStoneRefusal::NotLeader); + } + if membership.kind == GroupKind::Raid { + return Err(MeetingStoneRefusal::RaidGroup); + } + if membership.members >= GROUP_MAX_MEMBERS { + return Err(MeetingStoneRefusal::PartyFull); + } + Ok(()) +} + +enum StoneOpError { + Refused(MeetingStoneRefusal), + /// A broken durable relationship or a malformed request. Untagged, so the Gateway treats it + /// as a failure with an unknown outcome. + Invalid(String), +} + +impl From for StoneOpError { + fn from(refusal: MeetingStoneRefusal) -> Self { + Self::Refused(refusal) + } +} + +/// Run one Meeting Stone Queue op for `request_actor` on the party authority. +/// +/// Operator-gated because the actor is an argument: Realm-core has no live entity to derive it +/// from. `area_id` and `seekers` are JOIN's: the stone's dungeon area, and the facts of the actor +/// or of every member of the party it leads. A missing member's class is 0. LEAVE reads neither. +/// More than [`GROUP_MAX_MEMBERS`] facts, facts that omit the actor, or an unknown op byte is an +/// untagged error. +#[reducer] +pub fn realm_meeting_stone_op( + ctx: &ReducerContext, + op: u8, + request_actor: crate::SessionActor, + area_id: u32, + seekers: Vec, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + if seekers.len() > GROUP_MAX_MEMBERS { + return Err(format!( + "meeting stone op carries {} seeker facts, more than a party holds", + seekers.len() + )); + } + let outcome = if request_actor.ownership.is_none() { + Err(MeetingStoneRefusal::ActorUnavailable.into()) + } else { + match op { + realm_op::JOIN => join(ctx, actor_guid, area_id, &seekers), + realm_op::LEAVE => leave(ctx, actor_guid), + other => return Err(format!("unknown meeting stone op {other}")), + } + }; + outcome.map_err(|error| match error { + StoneOpError::Refused(refusal) => refused( + refusal, + &format!("op {op} for {actor_guid}, area {area_id}"), + ), + StoneOpError::Invalid(reason) => { + spacetimedb::log::error!("meeting stone op {op} for {actor_guid} failed: {reason}"); + reason + } + }) +} + +fn membership_of( + ctx: &ReducerContext, + character_guid: u64, +) -> Result)>, StoneOpError> { + let membership = crate::group::checked_group_membership(ctx, character_guid) + .map_err(|error| StoneOpError::Invalid(format!("{error:?}")))?; + Ok(membership.map(|(_, group)| { + let members = crate::group::members_of(ctx, group.group_id); + (group, members) + })) +} + +fn join( + ctx: &ReducerContext, + actor_guid: u64, + area_id: u32, + seekers: &[SeekerFacts], +) -> Result<(), StoneOpError> { + if area_id == 0 { + return Err(MeetingStoneRefusal::UnknownArea.into()); + } + let actor = seekers + .iter() + .find(|facts| facts.character_guid == actor_guid) + .ok_or_else(|| { + StoneOpError::Invalid(format!("seeker facts omit the actor {actor_guid}")) + })?; + let team = team_for_race(actor.race); + let joined = encode_queue(area_id, queue_status::JOINED_QUEUE); + let Some((group, members)) = membership_of(ctx, actor_guid)? else { + upsert_seeker(ctx, actor_guid, area_id, team, actor.class, 0); + crate::group::push_event(ctx, actor_guid, event_kind::QUEUE, 0, joined); + return Ok(()); + }; + party_join_gate(Membership { + leads: group.leader_guid == actor_guid, + kind: crate::group::group_kind_of(&group), + members: members.len(), + })?; + let group_id = group.group_id; + let next_reminder_at = ctx + .timestamp + .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) + .ok_or_else(|| StoneOpError::Invalid("reminder time overflow".into()))?; + let row = MeetingStoneParty { + group_id, + area_id, + team, + queued_at: ctx.timestamp, + next_reminder_at, + }; + let parties = ctx.db.game_meeting_stone_party(); + if parties.group_id().find(group_id).is_some() { + parties.group_id().update(row); + } else { + parties.insert(row); + } + let seeker_rows = ctx.db.game_meeting_stone_seeker(); + let departed: Vec = seeker_rows + .by_group() + .filter(group_id) + .map(|seeker| seeker.character_guid) + .filter(|guid| !members.iter().any(|m| m.character_guid == *guid)) + .collect(); + for guid in departed { + seeker_rows.character_guid().delete(guid); + } + let class_of = |guid: u64| { + seekers + .iter() + .find(|facts| facts.character_guid == guid) + .map_or(0, |facts| facts.class) + }; + for member in &members { + let guid = member.character_guid; + upsert_seeker(ctx, guid, area_id, team, class_of(guid), group_id); + } + for member in &members { + crate::group::push_event( + ctx, + member.character_guid, + event_kind::QUEUE, + 0, + joined.clone(), + ); + } + Ok(()) +} + +/// Queue `character_guid`, replacing any earlier row and its wait time. +fn upsert_seeker( + ctx: &ReducerContext, + character_guid: u64, + area_id: u32, + team: u32, + class: u8, + group_id: u64, +) { + let row = MeetingStoneSeeker { + character_guid, + area_id, + team, + class, + group_id, + queued_at: ctx.timestamp, + }; + let seekers = ctx.db.game_meeting_stone_seeker(); + if seekers.character_guid().find(character_guid).is_some() { + seekers.character_guid().update(row); + } else { + seekers.insert(row); + } +} + +/// cm:LFG/LFGHandler.cpp:86-110. A solo Seeker leaves with LEAVE_QUEUE. The leader of a queued +/// party takes the whole party out, and every member gets LEAVE_QUEUE. Anyone else in a party only +/// gets NONE. Nobody queued and no party: nothing happens. +fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { + let left = encode_queue(0, queue_status::LEAVE_QUEUE); + let Some((group, members)) = membership_of(ctx, actor_guid)? else { + let seekers = ctx.db.game_meeting_stone_seeker(); + if seekers.character_guid().find(actor_guid).is_some() { + seekers.character_guid().delete(actor_guid); + crate::group::push_event(ctx, actor_guid, event_kind::QUEUE, 0, left); + } + return Ok(()); + }; + let queued = ctx + .db + .game_meeting_stone_party() + .group_id() + .find(group.group_id) + .is_some(); + if group.leader_guid == actor_guid && queued { + dequeue_party_rows(ctx, group.group_id); + for member in &members { + crate::group::push_event( + ctx, + member.character_guid, + event_kind::QUEUE, + 0, + left.clone(), + ); + } + } else { + crate::group::push_event( + ctx, + actor_guid, + event_kind::QUEUE, + 0, + encode_queue(0, queue_status::NONE), + ); + } + Ok(()) +} + +/// Delete a queued party's row and every Seeker row it holds. +fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { + ctx.db + .game_meeting_stone_party() + .group_id() + .delete(group_id); + let seekers = ctx.db.game_meeting_stone_seeker(); + let guids: Vec = seekers + .by_group() + .filter(group_id) + .map(|seeker| seeker.character_guid) + .collect(); + for guid in guids { + seekers.character_guid().delete(guid); + } +} + +/// The Account Claim that admitted `character_guid` ended: drop its solo Seeker row, silently. The +/// Character's World Session is gone, so no packet could reach it. A party Seeker row stays, because +/// it ends with the party membership, not the session. +pub(crate) fn claim_ended(ctx: &ReducerContext, character_guid: u64) { + let seekers = ctx.db.game_meeting_stone_seeker(); + if seekers + .character_guid() + .find(character_guid) + .is_some_and(|seeker| seeker.group_id == 0) + { + seekers.character_guid().delete(character_guid); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_level_range_is_inclusive_and_a_zero_maximum_has_no_upper_bound() { + assert!(!level_in_range(14, 15, 20)); + assert!(level_in_range(15, 15, 20)); + assert!(level_in_range(20, 15, 20)); + assert!(!level_in_range(21, 15, 20)); + assert!(level_in_range(60, 15, 0)); + assert!(!level_in_range(14, 15, 0)); + } + + fn party(leads: bool, kind: GroupKind, members: usize) -> Membership { + Membership { + leads, + kind, + members, + } + } + + #[test] + fn a_party_leader_with_room_queues_the_party() { + for members in 2..GROUP_MAX_MEMBERS { + assert_eq!( + party_join_gate(party(true, GroupKind::Party, members)), + Ok(()) + ); + } + } + + /// cm:LFG/LFGHandler.cpp:58-77 checks the leader first, then the raid, then the size, so a + /// member of a full raid hears only that it does not lead. + #[test] + fn the_join_gate_checks_leader_then_raid_then_size() { + use MeetingStoneRefusal::*; + assert_eq!( + party_join_gate(party(false, GroupKind::Raid, 40)), + Err(NotLeader) + ); + assert_eq!( + party_join_gate(party(false, GroupKind::Party, 2)), + Err(NotLeader) + ); + assert_eq!( + party_join_gate(party(true, GroupKind::Raid, 40)), + Err(RaidGroup) + ); + assert_eq!( + party_join_gate(party(true, GroupKind::Raid, 2)), + Err(RaidGroup) + ); + assert_eq!( + party_join_gate(party(true, GroupKind::Party, 5)), + Err(PartyFull) + ); + } +} diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 41adbf9f2..ff28a2efc 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -180,6 +180,10 @@ pub(crate) mod character_owned_tripwire { ], "Realm-core Chat Channel state; membership ends with the Account Claim that admitted it", ), + ( + &["game_meeting_stone_seeker"], + "Realm-core Meeting Stone Queue; a solo Seeker ends with its Account Claim, a party Seeker with its party membership", + ), (&["game_gateway_session"], "live Session routing state"), ( &["game_entity_motion_pending"], diff --git a/module/tests/meeting_stone_queue.rs b/module/tests/meeting_stone_queue.rs new file mode 100644 index 000000000..d70d90ad6 --- /dev/null +++ b/module/tests/meeting_stone_queue.rs @@ -0,0 +1,589 @@ +//! The Meeting Stone Queue on a private Standalone: the Home Shard admission, JOIN and LEAVE for a +//! lone Seeker and for a Party, and a solo Seeker ending with its Account Claim. One database plays +//! both the Home Shard and Realm-core, as on a single-database realm. + +mod support; + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde_json::json; +use support::Standalone; + +// Fixture ids sit in 509_6000-509_6099. +const DEADMINES_STONE: (u32, u64) = (509_6000, 509_6001); +const STOCKADE_STONE: (u32, u64) = (509_6002, 509_6003); +/// The Deadmines (AreaTable 1581), levels 15 to 20. +const DEADMINES: u32 = 1581; +/// The Stockade (AreaTable 717), levels 22 to 30. +const STOCKADE: u32 = 717; +const STONE_AT: (f32, f32, f32) = (-8949.95, -132.493, 83.5312); + +const HUMAN: u8 = 1; +const WARRIOR: u8 = 1; +const PRIEST: u8 = 5; +const MAGE: u8 = 8; + +const JOIN: &str = "0"; +const LEAVE: &str = "1"; + +// SMSG_MEETINGSTONE_SETQUEUE status bytes, cm:LFG/LFGDefines.h:50-58. +const LEAVE_QUEUE: u8 = 0; +const JOINED_QUEUE: u8 = 1; +const NONE: u8 = 5; + +fn start(name: &str) -> Standalone { + let mut realm = Standalone::start(name); + realm.publish_module(); + realm.assert_call("claim_operator", &[]); + realm.assert_call("install_guid_range", &["0"]); + // Group events are reaped by the shared GC. Disarming it keeps every QUEUE row for the whole + // test, so each assertion reads the rows an action added by id. + realm.assert_sql("DELETE FROM game_event_reaper_schedule"); + stage_stone(&realm, DEADMINES_STONE, 15, 20, DEADMINES); + stage_stone(&realm, STOCKADE_STONE, 22, 30, STOCKADE); + realm +} + +fn stage_stone(realm: &Standalone, (entry, go): (u32, u64), min: u32, max: u32, area: u32) { + let (x, y, z) = STONE_AT; + realm.assert_call( + "debug_stage_meeting_stone", + &[ + &entry.to_string(), + &go.to_string(), + &min.to_string(), + &max.to_string(), + &area.to_string(), + "0", + &x.to_string(), + &y.to_string(), + &z.to_string(), + ], + ); +} + +/// A Character `offset` yards east of the stones on `map`, with a live claim of its own Account. +fn stage_character(realm: &Standalone, guid: u64, class: u8, level: u8, map: u32, offset: f32) { + stage_character_on(realm, guid, guid, class, level, map, offset); +} + +fn stage_character_on( + realm: &Standalone, + guid: u64, + account_id: u64, + class: u8, + level: u8, + map: u32, + offset: f32, +) { + let (x, y, z) = STONE_AT; + realm.assert_call( + "debug_stage_meeting_stone_character", + &[ + &guid.to_string(), + &account_id.to_string(), + &HUMAN.to_string(), + &class.to_string(), + &level.to_string(), + &map.to_string(), + &(x + offset).to_string(), + &y.to_string(), + &z.to_string(), + ], + ); +} + +fn stage_group(realm: &Standalone, leader: u64, members: &[u64], raid: bool) { + realm.assert_call( + "debug_stage_meeting_stone_group", + &[ + &leader.to_string(), + &json!(members).to_string(), + &raid.to_string(), + ], + ); +} + +fn token(account_id: u64, guid: u64) -> serde_json::Value { + json!({ "account_id": account_id, "generation": 1, "request_nonce": guid }) +} + +/// The World Session actor the fixture claim admits. +fn session(guid: u64) -> String { + session_on(guid, guid) +} + +fn session_on(guid: u64, account_id: u64) -> String { + json!({ "guid": guid, "ownership": { "some": token(account_id, guid) } }).to_string() +} + +fn facts(seekers: &[(u64, u8)]) -> String { + json!(seekers + .iter() + .map(|&(guid, class)| json!({ "character_guid": guid, "race": HUMAN, "class": class })) + .collect::>()) + .to_string() +} + +fn admit(realm: &Standalone, actor: &str, (_, go): (u32, u64)) { + realm.assert_call("gw_admit_meeting_stone", &[actor, &go.to_string()]); +} + +fn stone_op(realm: &Standalone, op: &str, actor: &str, area: u32, seekers: &[(u64, u8)]) { + realm.assert_call( + "realm_meeting_stone_op", + &[op, actor, &area.to_string(), &facts(seekers)], + ); +} + +fn refused(realm: &Standalone, reducer: &str, args: &[&str], tag: &str) { + let result = realm.call(reducer, args); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!( + !result.status.success() && output.contains(tag), + "{reducer} should refuse with {tag}: {output}" + ); +} + +/// Every QUEUE event so far, as `(id, recipient, payload)` in insert order. +fn queue_events(realm: &Standalone) -> Vec<(u64, u64, String)> { + let mut rows: Vec<_> = realm + .query_rows("SELECT id, recipient_guid, payload FROM game_group_event WHERE kind = 27") + .into_iter() + .map(|row| { + ( + row["id"].parse().unwrap(), + row["recipient_guid"].parse().unwrap(), + row["payload"].clone(), + ) + }) + .collect(); + rows.sort_unstable(); + rows +} + +/// The QUEUE events `action` adds, as `(recipient, payload)` in insert order. +fn queue_events_of(realm: &Standalone, action: impl FnOnce()) -> Vec<(u64, String)> { + let seen = queue_events(realm).last().map_or(0, |event| event.0); + action(); + queue_events(realm) + .into_iter() + .filter(|event| event.0 > seen) + .map(|(_, recipient, payload)| (recipient, payload)) + .collect() +} + +fn queue(area: u32, status: u8) -> String { + format!("{area},{status}") +} + +/// Every Seeker row by guid: `(area_id, team, class, group_id)`. +fn seekers(realm: &Standalone) -> BTreeMap { + realm + .query_rows("SELECT * FROM game_meeting_stone_seeker") + .into_iter() + .map(|row| { + ( + row["character_guid"].parse().unwrap(), + ( + row["area_id"].clone(), + row["team"].clone(), + row["class"].clone(), + row["group_id"].clone(), + ), + ) + }) + .collect() +} + +fn parties(realm: &Standalone) -> Vec> { + realm.query_rows("SELECT * FROM game_meeting_stone_party") +} + +fn group_id_of(realm: &Standalone, guid: u64) -> String { + realm.query_rows(&format!( + "SELECT group_id FROM game_group_member WHERE character_guid = {guid}" + ))[0]["group_id"] + .clone() +} + +fn solo(area: u32, class: u8) -> (String, String, String, String) { + ( + area.to_string(), + "469".to_string(), + class.to_string(), + "0".to_string(), + ) +} + +/// Criterion 3: one SETQUEUE(area, JOINED) and one Seeker row; a second stone replaces the row. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_lone_seeker_queues_once_and_a_second_stone_replaces_its_row() { + let realm = start("meeting-stone-solo"); + const ALDO: u64 = 509_6010; + stage_character(&realm, ALDO, WARRIOR, 18, 0, 0.0); + + admit(&realm, &session(ALDO), DEADMINES_STONE); + let joined = queue_events_of(&realm, || { + stone_op(&realm, JOIN, &session(ALDO), DEADMINES, &[(ALDO, WARRIOR)]) + }); + assert_eq!(joined, [(ALDO, queue(DEADMINES, JOINED_QUEUE))]); + assert_eq!( + seekers(&realm), + BTreeMap::from([(ALDO, solo(DEADMINES, WARRIOR))]) + ); + + // Level 22 is inside the Stockade range and above the Deadmines one. + stage_character(&realm, ALDO, WARRIOR, 22, 0, 0.0); + admit(&realm, &session(ALDO), STOCKADE_STONE); + let joined = queue_events_of(&realm, || { + stone_op(&realm, JOIN, &session(ALDO), STOCKADE, &[(ALDO, WARRIOR)]) + }); + assert_eq!(joined, [(ALDO, queue(STOCKADE, JOINED_QUEUE))]); + assert_eq!( + seekers(&realm), + BTreeMap::from([(ALDO, solo(STOCKADE, WARRIOR))]) + ); + assert!(parties(&realm).is_empty()); +} + +/// Criterion 4: every admission Refusal leaves no row and no event. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn the_stone_admission_refuses_without_writing_anything() { + let realm = start("meeting-stone-admission"); + const BRAM: u64 = 509_6020; + let deadmines = DEADMINES_STONE.1.to_string(); + let before = queue_events(&realm); + let admission = |actor: &str, go: &str, tag: &str| { + refused(&realm, "gw_admit_meeting_stone", &[actor, go], tag); + }; + + stage_character(&realm, BRAM, MAGE, 15, 0, 30.0); + admission(&session(BRAM), &deadmines, "meeting_stone:out_of_range"); + stage_character(&realm, BRAM, MAGE, 15, 1, 0.0); + admission(&session(BRAM), &deadmines, "meeting_stone:other_partition"); + stage_character(&realm, BRAM, MAGE, 14, 0, 0.0); + admission( + &session(BRAM), + &deadmines, + "meeting_stone:level_out_of_range", + ); + stage_character(&realm, BRAM, MAGE, 21, 0, 0.0); + admission( + &session(BRAM), + &deadmines, + "meeting_stone:level_out_of_range", + ); + stage_character(&realm, BRAM, MAGE, 20, 0, 0.0); + admit(&realm, &session(BRAM), DEADMINES_STONE); + + realm.assert_sql(&format!( + "INSERT INTO game_active_taxi_flight (character_guid, path_id, source_node_id, \ + destination_node_id, mount_display_id, fare, current_node_index, started_micros) \ + VALUES ({BRAM}, 1, 1, 2, 0, 0, 0, 0)" + )); + admission( + &session(BRAM), + &deadmines, + "meeting_stone:actor_unavailable", + ); + realm.assert_sql(&format!( + "DELETE FROM game_active_taxi_flight WHERE character_guid = {BRAM}" + )); + + // Without a World Session the actor carries no token. Its claim is gone, so the shared + // ownership Gate lets it through and the stone refuses it. + realm.assert_sql(&format!( + "DELETE FROM game_account_claim WHERE account_id = {BRAM}" + )); + admission( + &support::actor(&BRAM.to_string()), + &deadmines, + "meeting_stone:actor_unavailable", + ); + refused( + &realm, + "realm_meeting_stone_op", + &[ + JOIN, + &support::actor(&BRAM.to_string()), + &DEADMINES.to_string(), + &facts(&[(BRAM, MAGE)]), + ], + "meeting_stone:actor_unavailable", + ); + stage_character(&realm, BRAM, MAGE, 20, 0, 0.0); + + realm.assert_sql(&format!( + "UPDATE game_gameobject_template SET type_id = 3 WHERE entry = {}", + DEADMINES_STONE.0 + )); + admission( + &session(BRAM), + &deadmines, + "meeting_stone:not_a_meeting_stone", + ); + stage_stone(&realm, DEADMINES_STONE, 15, 20, DEADMINES); + realm.assert_sql(&format!( + "DELETE FROM game_meeting_stone WHERE entry = {}", + DEADMINES_STONE.0 + )); + admission( + &session(BRAM), + &deadmines, + "meeting_stone:not_a_meeting_stone", + ); + admission( + &session(BRAM), + "509609999", + "meeting_stone:not_a_meeting_stone", + ); + + assert!(seekers(&realm).is_empty()); + assert_eq!(queue_events(&realm), before); +} + +/// Criterion 5: the three party Refusals in cmangos's order, none of which writes anything. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_party_join_refusal_changes_nothing() { + let realm = start("meeting-stone-party-refusals"); + const LEADER: u64 = 509_6030; + const MEMBER: u64 = 509_6031; + const FULL_LEADER: u64 = 509_6032; + const RAID_LEADER: u64 = 509_6040; + const RAIDER: u64 = 509_6041; + let full_party: Vec = (FULL_LEADER + 1..FULL_LEADER + 5).collect(); + for guid in [LEADER, MEMBER, FULL_LEADER, RAID_LEADER, RAIDER] + .into_iter() + .chain(full_party.iter().copied()) + { + stage_character(&realm, guid, WARRIOR, 18, 0, 0.0); + } + stage_group(&realm, LEADER, &[MEMBER], false); + stage_group(&realm, FULL_LEADER, &full_party, false); + stage_group(&realm, RAID_LEADER, &[RAIDER], true); + let before = queue_events(&realm); + let join = |actor: u64, tag: &str| { + refused( + &realm, + "realm_meeting_stone_op", + &[ + JOIN, + &session(actor), + &DEADMINES.to_string(), + &facts(&[(actor, WARRIOR)]), + ], + tag, + ); + }; + + join(MEMBER, "meeting_stone:not_leader"); + join(RAIDER, "meeting_stone:not_leader"); + join(RAID_LEADER, "meeting_stone:raid_group"); + join(FULL_LEADER, "meeting_stone:party_full"); + + assert!(seekers(&realm).is_empty()); + assert!(parties(&realm).is_empty()); + assert_eq!(queue_events(&realm), before); +} + +/// Criterion 6: one party row, one Seeker row per member with the class the Gateway supplied, and +/// SETQUEUE(area, JOINED) to every member. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_party_leaders_join_queues_every_member() { + let realm = start("meeting-stone-party-join"); + const CARA: u64 = 509_6050; + const DUNN: u64 = 509_6051; + const ELLA: u64 = 509_6052; + stage_character(&realm, CARA, WARRIOR, 18, 0, 0.0); + // The members stand elsewhere: a party queues from its leader's stone alone. + stage_character(&realm, DUNN, PRIEST, 30, 1, 0.0); + stage_character(&realm, ELLA, MAGE, 10, 0, 500.0); + stage_group(&realm, CARA, &[DUNN, ELLA], false); + let group_id = group_id_of(&realm, CARA); + + admit(&realm, &session(CARA), DEADMINES_STONE); + let joined = queue_events_of(&realm, || { + stone_op( + &realm, + JOIN, + &session(CARA), + DEADMINES, + &[(CARA, WARRIOR), (DUNN, PRIEST)], + ) + }); + + let mut recipients: Vec = joined.iter().map(|(guid, _)| *guid).collect(); + recipients.sort_unstable(); + assert_eq!(recipients, [CARA, DUNN, ELLA]); + assert!(joined + .iter() + .all(|(_, payload)| *payload == queue(DEADMINES, JOINED_QUEUE))); + let member = |class: u8| { + ( + DEADMINES.to_string(), + "469".to_string(), + class.to_string(), + group_id.clone(), + ) + }; + // ELLA's facts were missing, so she holds a seat and no class. + assert_eq!( + seekers(&realm), + BTreeMap::from([ + (CARA, member(WARRIOR)), + (DUNN, member(PRIEST)), + (ELLA, member(0)), + ]) + ); + let party = parties(&realm); + assert_eq!(party.len(), 1); + assert_eq!(party[0]["group_id"], group_id); + assert_eq!(party[0]["area_id"], DEADMINES.to_string()); + assert_eq!(party[0]["team"], "469"); +} + +/// Criterion 7: LEAVE per cm:LFG/LFGHandler.cpp:86-110. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn leave_answers_the_seeker_the_party_and_the_member() { + let realm = start("meeting-stone-leave"); + const FINN: u64 = 509_6060; + const GALE: u64 = 509_6061; + const HUGO: u64 = 509_6062; + const IONA: u64 = 509_6063; + for (guid, class) in [(FINN, MAGE), (GALE, WARRIOR), (HUGO, PRIEST), (IONA, MAGE)] { + stage_character(&realm, guid, class, 18, 0, 0.0); + } + + // Nobody queued, no party: nothing happens. + let nothing = queue_events_of(&realm, || stone_op(&realm, LEAVE, &session(FINN), 0, &[])); + assert!(nothing.is_empty()); + + stone_op(&realm, JOIN, &session(FINN), DEADMINES, &[(FINN, MAGE)]); + let left = queue_events_of(&realm, || stone_op(&realm, LEAVE, &session(FINN), 0, &[])); + assert_eq!(left, [(FINN, queue(0, LEAVE_QUEUE))]); + assert!(seekers(&realm).is_empty()); + + stage_group(&realm, GALE, &[HUGO, IONA], false); + stone_op( + &realm, + JOIN, + &session(GALE), + DEADMINES, + &[(GALE, WARRIOR), (HUGO, PRIEST), (IONA, MAGE)], + ); + let member_left = queue_events_of(&realm, || stone_op(&realm, LEAVE, &session(HUGO), 0, &[])); + assert_eq!(member_left, [(HUGO, queue(0, NONE))]); + assert_eq!(seekers(&realm).len(), 3, "a member's LEAVE changes no row"); + assert_eq!(parties(&realm).len(), 1); + + let party_left = queue_events_of(&realm, || stone_op(&realm, LEAVE, &session(GALE), 0, &[])); + let mut recipients: Vec = party_left.iter().map(|(guid, _)| *guid).collect(); + recipients.sort_unstable(); + assert_eq!(recipients, [GALE, HUGO, IONA]); + assert!(party_left + .iter() + .all(|(_, payload)| *payload == queue(0, LEAVE_QUEUE))); + assert!(seekers(&realm).is_empty()); + assert!(parties(&realm).is_empty()); + + // The leader of a party that is not queued gets NONE, like any member. + let unqueued = queue_events_of(&realm, || stone_op(&realm, LEAVE, &session(GALE), 0, &[])); + assert_eq!(unqueued, [(GALE, queue(0, NONE))]); +} + +/// Criterion 9: release, reap and replacement each drop a solo Seeker without a packet. A party +/// Seeker survives all three. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_solo_seeker_ends_with_its_account_claim() { + let realm = start("meeting-stone-claims"); + const RELEASED: u64 = 509_6070; + const REAPED: u64 = 509_6071; + const REPLACED: u64 = 509_6072; + const LEADER: u64 = 509_6080; + const MEMBER: u64 = 509_6081; + const SEATED: u64 = 509_6082; + // `claim_account` needs a real Account row, and the seeded Account 1 is one. Its claim admits + // SEATED first and REPLACED after. + const SEEDED_ACCOUNT: u64 = 1; + let replace_seeded_claim = |nonce: &str| { + realm.assert_sql(&format!( + "UPDATE game_account_claim SET expires_micros = 0 WHERE account_id = {SEEDED_ACCOUNT}" + )); + realm.assert_call("claim_account", &["1", "1", nonce]); + }; + for guid in [RELEASED, REAPED, LEADER, MEMBER] { + stage_character(&realm, guid, MAGE, 18, 0, 0.0); + } + stage_character_on(&realm, SEATED, SEEDED_ACCOUNT, MAGE, 18, 0, 0.0); + stage_group(&realm, LEADER, &[MEMBER, SEATED], false); + stone_op( + &realm, + JOIN, + &session(LEADER), + DEADMINES, + &[(LEADER, MAGE), (MEMBER, MAGE), (SEATED, MAGE)], + ); + for guid in [RELEASED, REAPED] { + stone_op(&realm, JOIN, &session(guid), DEADMINES, &[(guid, MAGE)]); + } + let before = queue_events(&realm); + + // Replaced, for a party Seeker: the row stays. + replace_seeded_claim("9001"); + stage_character_on(&realm, REPLACED, SEEDED_ACCOUNT, MAGE, 18, 0, 0.0); + stone_op( + &realm, + JOIN, + &session_on(REPLACED, SEEDED_ACCOUNT), + DEADMINES, + &[(REPLACED, MAGE)], + ); + let joined = queue_events(&realm); + + // Released. + for guid in [RELEASED, LEADER] { + realm.assert_call("release_account_claim", &[&token(guid, guid).to_string()]); + } + assert!(!seekers(&realm).contains_key(&RELEASED)); + + // Replaced, for a solo Seeker. + replace_seeded_claim("9002"); + assert!(!seekers(&realm).contains_key(&REPLACED)); + + // Reaped by the Gateway lease schedule. + realm.assert_sql(&format!( + "UPDATE game_account_claim SET expires_micros = 0 WHERE account_id = {REAPED} \ + OR account_id = {MEMBER}" + )); + realm.assert_call("gw_heartbeat", &[]); + assert!( + support::poll_until(Duration::from_secs(30), || !seekers(&realm) + .contains_key(&REAPED)), + "the claim reaper never took {REAPED} out of the queue" + ); + + let remaining: Vec = seekers(&realm).into_keys().collect(); + assert_eq!(remaining, [LEADER, MEMBER, SEATED]); + assert_eq!( + joined.len(), + before.len() + 1, + "only REPLACED's own JOIN notified anyone" + ); + assert_eq!( + queue_events(&realm), + joined, + "an ending claim sends no packet" + ); +} From 97ebb05569ce387eeb701e023c9e5f3d173a11b3 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 20:43:40 +0200 Subject: [PATCH 05/16] feat(gateway): answer meeting stone join, leave and status A dispatch_meeting_stone_action seam handles CMSG_MEETINGSTONE_JOIN, _LEAVE, _INFO and MSG_LOOKING_FOR_GROUP. JOIN is admitted on the Home Shard, then queued on Realm-core with the race and class of the actor or of every party member. A refused party JOIN answers JOINFAILED. The group event relay renders kinds 27 to 30. A stone's query response carries its level range and area. The Realm-core ACCEPT now conveys the acceptor's class and race for a client and for a playerbot. Refs #532 Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/codec/gameobject.rs | 24 +- gateway/src/codec/meeting_stone.rs | 112 ++++ gateway/src/codec/mod.rs | 2 + gateway/src/stdb/connection.rs | 5 + gateway/src/stdb/reads/meeting_stone.rs | 43 ++ gateway/src/stdb/reads/mod.rs | 1 + gateway/src/stdb/reads/templates.rs | 32 +- gateway/src/stdb/reducers.rs | 53 +- gateway/src/stdb/subscriptions.rs | 19 + gateway/src/world/handlers/meeting_stone.rs | 679 ++++++++++++++++++++ gateway/src/world/handlers/mod.rs | 7 +- gateway/src/world/mod.rs | 43 +- gateway/src/world/party.rs | 71 +- gateway/src/world/party_tests.rs | 151 ++++- gateway/src/world/store.rs | 8 +- gateway/src/world/tests.rs | 43 +- gateway/tests/schema_parity.rs | 10 + 17 files changed, 1248 insertions(+), 55 deletions(-) create mode 100644 gateway/src/codec/meeting_stone.rs create mode 100644 gateway/src/stdb/reads/meeting_stone.rs create mode 100644 gateway/src/world/handlers/meeting_stone.rs diff --git a/gateway/src/codec/gameobject.rs b/gateway/src/codec/gameobject.rs index c745041e3..07707bdf9 100644 --- a/gateway/src/codec/gameobject.rs +++ b/gateway/src/codec/gameobject.rs @@ -47,6 +47,8 @@ pub struct GameObjectTemplateView { pub name: String, pub data0: u32, pub data1: u32, + /// A Meeting Stone's dungeon area. 0 for every other type. + pub data2: u32, } /// Build the CREATE_OBJECT for a gameobject: a GAMEOBJECT-type object with a stationary position @@ -146,7 +148,7 @@ pub fn build_gameobject_query_response( name3: String::new(), name4: String::new(), name5: String::new(), - raw_data: [t.data0, t.data1, 0, 0, 0, 0], + raw_data: [t.data0, t.data1, t.data2, 0, 0, 0], }), }, } @@ -361,6 +363,7 @@ mod tests { name: "Reinforced Chest".to_string(), data0: 1, data1: 2, + data2: 0, }; let found = build_gameobject_query_response(50100, Some(&t)); assert_eq!(found.entry_id, 50100); @@ -378,4 +381,23 @@ mod tests { ); assert!(missing.found.is_none()); } + + /// A Meeting Stone's tooltip reads its level range from `data0`/`data1` and its dungeon from + /// `data2` (cm:Entities/GameObject.h:266-272). + #[test] + fn a_meeting_stone_query_response_carries_its_level_range_and_area() { + let stone = GameObjectTemplateView { + type_id: lyracore_shared::constants::go_type::MEETINGSTONE, + display_id: 6973, + name: "Meeting Stone".to_string(), + data0: 15, + data1: 20, + data2: 1581, + }; + let found = build_gameobject_query_response(179584, Some(&stone)) + .found + .expect("a known template must reply found"); + assert_eq!(found.info_type, 23); + assert_eq!(found.raw_data, [15, 20, 1581, 0, 0, 0]); + } } diff --git a/gateway/src/codec/meeting_stone.rs b/gateway/src/codec/meeting_stone.rs new file mode 100644 index 000000000..6e9cdf927 --- /dev/null +++ b/gateway/src/codec/meeting_stone.rs @@ -0,0 +1,112 @@ +//! Meeting Stone Queue messages. `SMSG_MEETINGSTONE_COMPLETE` and `_IN_PROGRESS` are empty, so +//! they need no builder. + +use wow_world_messages::vanilla::{ + Area, MSG_LOOKING_FOR_GROUP_Server, MeetingStoneFailure, MeetingStoneStatus, + SMSG_MEETINGSTONE_JOINFAILED, SMSG_MEETINGSTONE_MEMBER_ADDED, SMSG_MEETINGSTONE_SETQUEUE, +}; +use wow_world_messages::Guid; + +/// `u32 area, u8 status` (cm:LFG/LFGHandler.cpp:146-152). `None` when `wow_world_messages` has no +/// `Area` for the id or the status byte is unknown, since neither could be encoded. +pub fn build_meetingstone_setqueue(area_id: u32, status: u8) -> Option { + Some(SMSG_MEETINGSTONE_SETQUEUE { + area: Area::try_from(area_id).ok()?, + status: MeetingStoneStatus::try_from(status).ok()?, + }) +} + +/// `None` for a reason byte outside 1 to 3. +pub fn build_meetingstone_joinfailed(reason: u8) -> Option { + Some(SMSG_MEETINGSTONE_JOINFAILED { + reason: MeetingStoneFailure::try_from(reason).ok()?, + }) +} + +/// A stone add told to the members already in the party (cm:LFG/LFGMgr.cpp:288-309). +pub fn build_meetingstone_member_added(guid: u64) -> SMSG_MEETINGSTONE_MEMBER_ADDED { + SMSG_MEETINGSTONE_MEMBER_ADDED { + guid: Guid::new(guid), + } +} + +/// The answer to `MSG_LOOKING_FOR_GROUP`. vmangos sends 0 (vm:Handlers/MiscHandler.cpp:277-282); +/// the stock UI never reads it. +pub fn build_looking_for_group() -> MSG_LOOKING_FOR_GROUP_Server { + MSG_LOOKING_FOR_GROUP_Server { unknown1: 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use lyracore_shared::meeting_stone::{join_failure, queue_status}; + use wow_world_messages::vanilla::ServerMessage; + + fn body(message: &impl ServerMessage) -> Vec { + let mut framed = Vec::new(); + message.write_unencrypted_server(&mut framed).unwrap(); + framed.split_off(4) + } + + /// cm:LFG/LFGHandler.cpp:146-152 writes the area as a u32 and the status as a u8. + #[test] + fn setqueue_is_the_area_then_the_status_byte() { + let packet = build_meetingstone_setqueue(1581, queue_status::JOINED_QUEUE).unwrap(); + assert_eq!(body(&packet), [0x2D, 0x06, 0, 0, 1]); + let none = build_meetingstone_setqueue(0, queue_status::NONE).unwrap(); + assert_eq!(body(&none), [0, 0, 0, 0, 5]); + assert!(build_meetingstone_setqueue(1581, 6).is_none()); + } + + /// The shared status and failure bytes are the `wow_world_messages` values. + #[test] + fn the_shared_bytes_match_the_wire_enums() { + use queue_status::*; + for (byte, status) in [ + (LEAVE_QUEUE, MeetingStoneStatus::LeaveQueue), + (JOINED_QUEUE, MeetingStoneStatus::JoinedQueue), + ( + PARTY_MEMBER_LEFT_LFG, + MeetingStoneStatus::PartyMemberLeftLfg, + ), + ( + PARTY_MEMBER_REMOVED_PARTY_REMOVED, + MeetingStoneStatus::PartyMemberRemovedPartyRemoved, + ), + ( + LOOKING_FOR_NEW_PARTY_IN_QUEUE, + MeetingStoneStatus::LookingForNewPartyInQueue, + ), + (NONE, MeetingStoneStatus::None), + ] { + assert_eq!(status.as_int(), byte); + } + for (byte, failure) in [ + ( + join_failure::PARTY_LEADER, + MeetingStoneFailure::MeetingstoneFailPartyleader, + ), + ( + join_failure::FULL_GROUP, + MeetingStoneFailure::MeetingstoneFailFullGroup, + ), + ( + join_failure::RAID_GROUP, + MeetingStoneFailure::MeetingstoneFailRaidGroup, + ), + ] { + assert_eq!(failure.as_int(), byte); + assert_eq!(body(&build_meetingstone_joinfailed(byte).unwrap()), [byte]); + } + assert!(build_meetingstone_joinfailed(0).is_none()); + } + + #[test] + fn member_added_is_the_guid_and_looking_for_group_is_zero() { + assert_eq!( + body(&build_meetingstone_member_added(0x0102)), + 0x0102u64.to_le_bytes() + ); + assert_eq!(body(&build_looking_for_group()), [0, 0, 0, 0]); + } +} diff --git a/gateway/src/codec/mod.rs b/gateway/src/codec/mod.rs index 40fd79567..0a12f3490 100644 --- a/gateway/src/codec/mod.rs +++ b/gateway/src/codec/mod.rs @@ -34,6 +34,7 @@ mod item; mod leveling; mod loot; mod mail; +mod meeting_stone; mod member_stats; mod movement; mod npc; @@ -68,6 +69,7 @@ pub use item::*; pub use leveling::*; pub use loot::*; pub use mail::*; +pub use meeting_stone::*; pub use member_stats::*; pub use movement::*; pub use npc::*; diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 117442976..0d8473b87 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1210,6 +1210,11 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { "SELECT * FROM game_chat_channel_member", "SELECT * FROM game_chat_channel_ban", "SELECT * FROM game_chat_channel_notice_event", + // The Meeting Stone Queue. Stones are static and read on the Home Shard to name a stone's + // dungeon area; Seekers are written on Realm-core and answer `CMSG_MEETINGSTONE_INFO`. + // Both are small, and an unsharded Realm reads both on its one connection. + "SELECT * FROM game_meeting_stone", + "SELECT * FROM game_meeting_stone_seeker", // Server-wide tunables. The gateway reads ONE column: `hosts_instances`, at // startup, to answer "when this realm creates a dungeon instance, will anything actually // spawn its population" (`ShardMap::check_instance_hosting`). Before this subscription the diff --git a/gateway/src/stdb/reads/meeting_stone.rs b/gateway/src/stdb/reads/meeting_stone.rs new file mode 100644 index 000000000..6a0c5c442 --- /dev/null +++ b/gateway/src/stdb/reads/meeting_stone.rs @@ -0,0 +1,43 @@ +//! Meeting Stone reads. A stone row is static content on every database; a Seeker row lives on +//! the party authority. Both tables are keyed by primary key, so each read is one unique find. + +use anyhow::Result; +use lyracore_shared::constants::go_type; + +use super::super::connection::Coordinator; +use super::*; + +impl Coordinator { + /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database. `None` when no + /// such GameObject is cached here, or it is not a stone with an imported stone row. + pub(crate) fn meeting_stone_area(&self, go_guid: u64) -> Result> { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db + .game_gameobject() + .guid() + .find(&go_guid) + .and_then(|go| { + db.game_gameobject_template() + .entry() + .find(&go.template_entry) + }) + .filter(|template| template.type_id == go_type::MEETINGSTONE) + .and_then(|template| db.game_meeting_stone().entry().find(&template.entry)) + .map(|stone| stone.area_id)) + } + + /// The area `character_guid` waits for in the Meeting Stone Queue, read from the party + /// authority: Realm-core, or the one database of an unsharded Realm. + pub(crate) fn queued_area(&self, character_guid: u64) -> Result> { + let realm = self.realm_core()?; + let guard = realm.0.coord(); + Ok(guard + .conn + .db + .game_meeting_stone_seeker() + .character_guid() + .find(&character_guid) + .map(|seeker| seeker.area_id)) + } +} diff --git a/gateway/src/stdb/reads/mod.rs b/gateway/src/stdb/reads/mod.rs index 24be65e5c..c1797fd34 100644 --- a/gateway/src/stdb/reads/mod.rs +++ b/gateway/src/stdb/reads/mod.rs @@ -12,6 +12,7 @@ mod chat; mod guild; mod items; mod mail; +mod meeting_stone; mod npc_vendor; mod party; mod pet; diff --git a/gateway/src/stdb/reads/templates.rs b/gateway/src/stdb/reads/templates.rs index 51445921a..761410470 100644 --- a/gateway/src/stdb/reads/templates.rs +++ b/gateway/src/stdb/reads/templates.rs @@ -30,26 +30,32 @@ impl Coordinator { })) } - /// Read a gameobject template by entry for a `CMSG_GAMEOBJECT_QUERY` reply. + /// Read a gameobject template by entry for a `CMSG_GAMEOBJECT_QUERY` reply. A Meeting Stone's + /// `data0..data2` come from its stone row, the only copy of its level range and dungeon area, + /// so the client tooltip shows the range. pub fn gameobject_template( &self, entry: u32, ) -> Result> { - Ok(self - .0 - .coord() - .conn - .db - .game_gameobject_template() - .entry() - .find(&entry) - .map(|t| crate::codec::GameObjectTemplateView { + let guard = self.0.coord(); + let db = &guard.conn.db; + Ok(db.game_gameobject_template().entry().find(&entry).map(|t| { + let stone = (t.type_id == lyracore_shared::constants::go_type::MEETINGSTONE) + .then(|| db.game_meeting_stone().entry().find(&entry)) + .flatten(); + let (data0, data1, data2) = match stone { + Some(stone) => (stone.min_level, stone.max_level, stone.area_id), + None => (t.data_0, t.data_1, 0), + }; + crate::codec::GameObjectTemplateView { type_id: t.type_id, display_id: t.display_id, name: t.name, - data0: t.data_0, - data1: t.data_1, - })) + data0, + data1, + data2, + } + })) } /// The `type_id` of a SPAWNED gameobject, by its live guid (join `game_gameobject` → diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 66592966f..0cc9b87da 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -15,7 +15,7 @@ use crate::world::guild_fee; use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; use crate::world::{ ChannelOutcome, ChatOutcome, ContactOutcome, ItemActionResult, LootActionStatus, - LootWindowRefusal, LootWindowRequestStatus, + LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, }; use lyracore_shared::auction::AuctionRefusal; use lyracore_shared::channel::ChannelRefusal; @@ -24,6 +24,7 @@ use lyracore_shared::group::GroupRefusal; use lyracore_shared::guild::GuildRefusal; use lyracore_shared::item::ItemRefusal; use lyracore_shared::loot::{LootBoundaryFailure, LootRefusal}; +use lyracore_shared::meeting_stone::MeetingStoneRefusal; use lyracore_shared::social::ContactRefusal; use lyracore_shared::trainer::TrainerRefusal; @@ -2024,6 +2025,45 @@ impl Coordinator { )) } + /// `gw_admit_meeting_stone` on this Home Shard: may the actor use the stone? Writes nothing. + pub fn admit_meeting_stone( + &self, + actor_guid: u64, + go_guid: u64, + ) -> Result { + let coord = self.0.call_pipe(); + meeting_stone_outcome(call_reducer!( + coord.conn.reducers, + "gw_admit_meeting_stone", + gw_admit_meeting_stone_then(self.session_actor(actor_guid), go_guid) + )) + } + + /// `realm_meeting_stone_op`: run one Meeting Stone Queue op on Realm-core, or on the one + /// database of an unsharded Realm. The Module applies every queue rule. + pub fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result { + let realm = self.realm_core()?; + let seekers = seekers + .into_iter() + .map(|facts| SeekerFacts { + character_guid: facts.character_guid, + race: facts.race, + class: facts.class, + }) + .collect(); + meeting_stone_outcome(call_reducer!( + realm.0.call_pipe().conn.reducers, + "realm_meeting_stone_op", + realm_meeting_stone_op_then(op, realm.session_actor(actor_guid), area_id, seekers) + )) + } + /// GM playtest dot-command: resolve current Account authority on Realm-core, then convey it to /// the Home Shard through one Store operation. pub fn gm_command(&self, account_name: &str, actor_guid: u64, text: String) -> Result<()> { @@ -4178,6 +4218,17 @@ fn channel_outcome(result: Result<()>) -> Result { } } +fn meeting_stone_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(MeetingStoneOutcome::Ran), + Err(error) => match reducer_refusal_reason(&error).and_then(MeetingStoneRefusal::parse_tag) + { + Some(refusal) => Ok(MeetingStoneOutcome::Refused(refusal)), + None => Err(error), + }, + } +} + /// The Module's typed party Refusal. Only a reducer the Module rejected carries a tag; a timeout, /// transport, or SDK failure stays an error with an unknown outcome. fn group_refusal(error: &anyhow::Error) -> Option { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 7df86701d..4ac3e0b14 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -2197,6 +2197,7 @@ pub(crate) fn group_event_outbound( ) -> Vec { use lyracore_shared::group::event_kind as group_kind; use lyracore_shared::loot_roll::event_kind as roll_kind; + use lyracore_shared::meeting_stone::event_kind as stone_kind; use lyracore_shared::quest::share_event_kind as quest_share_kind; let msg = match row.kind { // Sent even when no shard can name the inviter (falls back to an empty name, like @@ -2441,6 +2442,23 @@ pub(crate) fn group_event_outbound( ))) }, ), + stone_kind::QUEUE => { + let packet = lyracore_shared::meeting_stone::decode_queue(&row.payload) + .and_then(|(area_id, status)| codec::build_meetingstone_setqueue(area_id, status)); + if packet.is_none() { + log::warn!( + "meeting stone QUEUE relay: unencodable payload {:?} (event {})", + row.payload, + row.id + ); + } + packet.map(ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE) + } + stone_kind::MEMBER_ADDED => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_MEMBER_ADDED( + codec::build_meetingstone_member_added(row.other_guid), + )), + stone_kind::IN_PROGRESS => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_IN_PROGRESS), + stone_kind::COMPLETE => Some(ServerOpcodeMessage::SMSG_MEETINGSTONE_COMPLETE), other => { log::warn!("group event relay: unknown kind {other} (id {})", row.id); None @@ -4746,6 +4764,7 @@ mod tests { name: "Duel Arbiter".into(), data0: 0, data1: 0, + data2: 0, }; let row = duel_event(0, 0); let out = duel_event_outbound(&row, Some(&template)); diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs new file mode 100644 index 000000000..c82d2110e --- /dev/null +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -0,0 +1,679 @@ +//! Meeting Stone dispatcher: join, leave and the queue status query, plus the vanilla +//! `MSG_LOOKING_FOR_GROUP` answer. The client sends `CMSG_MEETINGSTONE_JOIN` when it uses a stone. +//! +//! A JOIN takes two Durable Requests. The actor's Home Shard admits it at the stone, then the party +//! authority queues it. Realm-core holds no Characters, so this file reads each Seeker's race and +//! class realm-wide and conveys them. Queue notices return on the group event relay +//! (`stdb::subscriptions::group_event_outbound`); only a refused party JOIN is answered here. + +use super::super::*; +use super::chat::is_transport_failure; +use lyracore_shared::group::GROUP_MAX_MEMBERS; +use lyracore_shared::meeting_stone::{ + join_failure_for, queue_status, realm_op, MeetingStoneRefusal, +}; +use wow_world_messages::vanilla::Area; + +/// One Seeker's facts as the party authority needs them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SeekerFacts { + pub(crate) character_guid: u64, + pub(crate) race: u8, + pub(crate) class: u8, +} + +/// How the Module answered one Meeting Stone Durable Request. A timeout, transport or SDK failure +/// stays an `Err` with an unknown durable outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MeetingStoneOutcome { + Ran, + Refused(MeetingStoneRefusal), +} + +pub(crate) trait MeetingStoneActionStore { + /// Durable Request on the actor's Home Shard: may it use the stone `go_guid`? Writes nothing. + fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result; + /// Durable Read of the Home Shard cache: the dungeon area of the stone spawned as `go_guid`. + fn meeting_stone_area(&self, go_guid: u64) -> Result>; + /// Durable Read of the party authority: the members of `actor_guid`'s party in join order, or + /// `None` outside a party. + fn party_members(&self, actor_guid: u64) -> Result>>; + /// Race and class of `character_guid` from whichever World Shard holds it. + fn seeker_facts(&self, character_guid: u64) -> Result>; + /// Durable Request on the party authority. The Coordinator picks the database. + fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result; + /// Durable Read of the party authority: the area `character_guid` is queued for. + fn queued_area(&self, character_guid: u64) -> Result>; +} + +impl MeetingStoneActionStore for crate::stdb::Coordinator { + fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result { + crate::stdb::Coordinator::admit_meeting_stone(self, actor_guid, go_guid) + } + + fn meeting_stone_area(&self, go_guid: u64) -> Result> { + crate::stdb::Coordinator::meeting_stone_area(self, go_guid) + } + + fn party_members(&self, actor_guid: u64) -> Result>> { + let authority = self.realm_core()?; + Ok( + crate::stdb::Coordinator::group_roster(&authority, actor_guid) + .map(|roster| roster.members.iter().map(|member| member.guid).collect()), + ) + } + + fn seeker_facts(&self, character_guid: u64) -> Result> { + Ok( + presence::character_anywhere(self, character_guid)?.map(|character| SeekerFacts { + character_guid, + race: character.race, + class: character.class, + }), + ) + } + + fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result { + crate::stdb::Coordinator::meeting_stone_op(self, actor_guid, op, area_id, seekers) + } + + fn queued_area(&self, character_guid: u64) -> Result> { + crate::stdb::Coordinator::queued_area(self, character_guid) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct MeetingStonePlayer { + pub(crate) account_id: u64, + /// `None` outside the world. + pub(crate) self_guid: Option, +} + +pub(crate) enum MeetingStoneActionOutcome { + Handled { outbound: Vec }, + PassThrough(ClientOpcodeMessage), +} + +/// Consume the three meeting stone opcodes and `MSG_LOOKING_FOR_GROUP`, and pass everything else +/// on. All four are dropped silently outside the world. +pub(crate) fn dispatch_meeting_stone_action( + store: &St, + player: MeetingStonePlayer, + msg: ClientOpcodeMessage, +) -> Result { + let action = match msg { + ClientOpcodeMessage::CMSG_MEETINGSTONE_JOIN(join) => Action::Join(join.guid.guid()), + ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE => Action::Leave, + ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO => Action::Info, + ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP => Action::LookingForGroup, + other => return Ok(MeetingStoneActionOutcome::PassThrough(other)), + }; + let Some(actor_guid) = player.self_guid else { + return Ok(MeetingStoneActionOutcome::Handled { + outbound: Vec::new(), + }); + }; + let outbound = match action { + Action::Join(go_guid) => join(store, player.account_id, actor_guid, go_guid)?, + Action::Leave => { + run_op( + store, + player.account_id, + actor_guid, + realm_op::LEAVE, + 0, + Vec::new(), + )?; + Vec::new() + } + Action::Info => info(store, player.account_id, actor_guid)?, + Action::LookingForGroup => vec![Outbound::One(ServerOpcodeMessage::MSG_LOOKING_FOR_GROUP( + codec::build_looking_for_group(), + ))], + }; + Ok(MeetingStoneActionOutcome::Handled { outbound }) +} + +enum Action { + Join(u64), + Leave, + Info, + LookingForGroup, +} + +/// A Store call that ends the World Session only on transport loss. Anything else is logged at +/// debug and answered with nothing: the client has no message for it, and a Refusal or a peer +/// Shard read this Gateway cannot finish must not close the session. +fn recoverable(what: &str, account_id: u64, result: Result) -> Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(error) if is_transport_failure(&error) => Err(error), + Err(error) => { + log::debug!("world: meeting stone {what} dropped (account {account_id}): {error:#}"); + Ok(None) + } + } +} + +/// Admission at the stone, then the queue. Every failure before the queue is silent, as in both +/// cores; a refused party JOIN answers `SMSG_MEETINGSTONE_JOINFAILED`. +fn join( + store: &St, + account_id: u64, + actor_guid: u64, + go_guid: u64, +) -> Result> { + match recoverable( + "admission", + account_id, + store.admit_meeting_stone(actor_guid, go_guid), + )? { + Some(MeetingStoneOutcome::Ran) => {} + Some(MeetingStoneOutcome::Refused(refusal)) => { + log::debug!( + "world: meeting stone admission refused {} (account {account_id})", + refusal.as_tag() + ); + return Ok(Vec::new()); + } + None => return Ok(Vec::new()), + } + let Some(Some(area_id)) = recoverable( + "stone area read", + account_id, + store.meeting_stone_area(go_guid), + )? + else { + return Ok(Vec::new()); + }; + // Only the Gateway can tell whether the client can be told about this area, so it drops a + // stone it could never answer for before any state changes. + if Area::try_from(area_id).is_err() { + log::debug!("world: meeting stone {go_guid} names unknown area {area_id}"); + return Ok(Vec::new()); + } + let Some(seekers) = recoverable( + "seeker facts read", + account_id, + seeker_facts(store, actor_guid), + )? + else { + return Ok(Vec::new()); + }; + let outcome = run_op( + store, + account_id, + actor_guid, + realm_op::JOIN, + area_id, + seekers, + )?; + let failure = match outcome { + Some(MeetingStoneOutcome::Refused(refusal)) => join_failure_for(refusal), + _ => None, + }; + Ok(failure + .and_then(codec::build_meetingstone_joinfailed) + .map(|packet| Outbound::One(ServerOpcodeMessage::SMSG_MEETINGSTONE_JOINFAILED(packet))) + .into_iter() + .collect()) +} + +/// The facts of the actor, or of every member of the party it is in. Only a Raid holds more than +/// a Party's five, and the queue refuses a Raid, so the actor's own facts are enough to hear that. +fn seeker_facts( + store: &St, + actor_guid: u64, +) -> Result> { + let guids = match store.party_members(actor_guid)? { + Some(members) if members.len() <= GROUP_MAX_MEMBERS => members, + _ => vec![actor_guid], + }; + let mut facts = Vec::with_capacity(guids.len()); + for guid in guids { + facts.extend(store.seeker_facts(guid)?); + } + Ok(facts) +} + +/// One queue op. `None` when it failed short of a transport loss. +fn run_op( + store: &St, + account_id: u64, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, +) -> Result> { + let outcome = recoverable( + "op", + account_id, + store.meeting_stone_op(actor_guid, op, area_id, seekers), + )?; + if let Some(MeetingStoneOutcome::Refused(refusal)) = outcome { + log::debug!( + "world: meeting stone op {op} refused {} (account {account_id})", + refusal.as_tag() + ); + } + Ok(outcome) +} + +/// `CMSG_MEETINGSTONE_INFO`: the queue status the client shows after a loading screen. A Seeker +/// row answers JOINED for its area, alone or in a party; anything else answers NONE. Both cores +/// read a restore map nothing fills, so they never report a solo Seeker; this reports the real +/// state. +fn info( + store: &St, + account_id: u64, + actor_guid: u64, +) -> Result> { + let Some(queued) = recoverable("status read", account_id, store.queued_area(actor_guid))? + else { + return Ok(Vec::new()); + }; + let (area_id, status) = match queued { + Some(area_id) => (area_id, queue_status::JOINED_QUEUE), + None => (0, queue_status::NONE), + }; + Ok(match codec::build_meetingstone_setqueue(area_id, status) { + Some(packet) => vec![Outbound::One( + ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE(packet), + )], + None => { + log::warn!("world: meeting stone status for {actor_guid} names unknown area {area_id}"); + Vec::new() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use wow_world_messages::vanilla::{CMSG_MEETINGSTONE_JOIN, CMSG_PING}; + use wow_world_messages::Guid; + + const ACTOR: u64 = 7; + const STONE: u64 = 900; + /// The Deadmines. + const AREA: u32 = 1581; + const HUMAN: u8 = 1; + const ORC: u8 = 2; + const WARRIOR: u8 = 1; + const PRIEST: u8 = 5; + + /// One `meeting_stone_op` call: actor, op, area and the conveyed facts. + type QueueOp = (u64, u8, u32, Vec); + + /// Records which database each call is meant for: `home` is the actor's Home Shard and + /// `authority` the party authority. The Coordinator maps both to its one database on an + /// unsharded realm. + #[derive(Default)] + struct FakeMeetingStones { + admission: Option, + stone_area: Option, + party: Option>, + /// Facts by guid, as the World Shards hold them. + facts: HashMap, + op_outcome: Option>, + queued: Option, + calls: Mutex>, + ops: Mutex>, + } + + impl FakeMeetingStones { + fn at_stone() -> Self { + Self { + admission: Some(MeetingStoneOutcome::Ran), + stone_area: Some(AREA), + facts: HashMap::from([(ACTOR, facts(ACTOR, HUMAN, WARRIOR))]), + ..Default::default() + } + } + + fn record(&self, database: &'static str, call: &'static str) { + self.calls.lock().unwrap().push((database, call)); + } + + fn calls(&self) -> Vec<(&'static str, &'static str)> { + self.calls.lock().unwrap().clone() + } + + fn ops(&self) -> Vec { + self.ops.lock().unwrap().clone() + } + } + + fn facts(character_guid: u64, race: u8, class: u8) -> SeekerFacts { + SeekerFacts { + character_guid, + race, + class, + } + } + + impl MeetingStoneActionStore for FakeMeetingStones { + fn admit_meeting_stone( + &self, + _actor_guid: u64, + _go_guid: u64, + ) -> Result { + self.record("home", "admit"); + Ok(self.admission.expect("admission configured")) + } + + fn meeting_stone_area(&self, _go_guid: u64) -> Result> { + self.record("home", "stone_area"); + Ok(self.stone_area) + } + + fn party_members(&self, _actor_guid: u64) -> Result>> { + self.record("authority", "party_members"); + Ok(self.party.clone()) + } + + fn seeker_facts(&self, character_guid: u64) -> Result> { + Ok(self.facts.get(&character_guid).copied()) + } + + fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result { + self.record("authority", "op"); + self.ops + .lock() + .unwrap() + .push((actor_guid, op, area_id, seekers)); + match &self.op_outcome { + None => Ok(MeetingStoneOutcome::Ran), + Some(Ok(outcome)) => Ok(*outcome), + Some(Err(error)) => Err(anyhow!(error.clone())), + } + } + + fn queued_area(&self, _character_guid: u64) -> Result> { + self.record("authority", "queued_area"); + Ok(self.queued) + } + } + + fn in_world() -> MeetingStonePlayer { + MeetingStonePlayer { + account_id: 1, + self_guid: Some(ACTOR), + } + } + + fn join_stone() -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_MEETINGSTONE_JOIN(CMSG_MEETINGSTONE_JOIN { + guid: Guid::new(STONE), + }) + } + + fn handled(store: &FakeMeetingStones, msg: ClientOpcodeMessage) -> Vec { + match dispatch_meeting_stone_action(store, in_world(), msg).unwrap() { + MeetingStoneActionOutcome::Handled { outbound } => outbound, + MeetingStoneActionOutcome::PassThrough(_) => panic!("the opcode was not consumed"), + } + } + + fn joinfailed_reason(outbound: &[Outbound]) -> u8 { + match outbound { + [Outbound::One(ServerOpcodeMessage::SMSG_MEETINGSTONE_JOINFAILED(packet))] => { + packet.reason.as_int() + } + other => panic!("expected one JOINFAILED, got {} packets", other.len()), + } + } + + fn setqueue(outbound: &[Outbound]) -> (u32, u8) { + match outbound { + [Outbound::One(ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE(packet))] => { + (packet.area.as_int(), packet.status.as_int()) + } + other => panic!("expected one SETQUEUE, got {} packets", other.len()), + } + } + + #[test] + fn a_refused_admission_stops_before_the_party_authority() { + let store = FakeMeetingStones { + admission: Some(MeetingStoneOutcome::Refused( + MeetingStoneRefusal::LevelOutOfRange, + )), + ..FakeMeetingStones::at_stone() + }; + + assert!(handled(&store, join_stone()).is_empty()); + assert_eq!(store.calls(), [("home", "admit")]); + } + + #[test] + fn a_lone_actor_joins_with_its_own_facts_on_the_party_authority() { + let store = FakeMeetingStones::at_stone(); + + assert!(handled(&store, join_stone()).is_empty()); + assert_eq!( + store.calls(), + [ + ("home", "admit"), + ("home", "stone_area"), + ("authority", "party_members"), + ("authority", "op"), + ] + ); + assert_eq!( + store.ops(), + [( + ACTOR, + realm_op::JOIN, + AREA, + vec![facts(ACTOR, HUMAN, WARRIOR)] + )] + ); + } + + /// Each party Refusal answers with cmangos's byte (cm:LFG/LFGHandler.cpp:58-77). + #[test] + fn each_party_refusal_answers_its_join_failure_byte() { + for (refusal, reason) in [ + (MeetingStoneRefusal::NotLeader, 1), + (MeetingStoneRefusal::PartyFull, 2), + (MeetingStoneRefusal::RaidGroup, 3), + ] { + let store = FakeMeetingStones { + op_outcome: Some(Ok(MeetingStoneOutcome::Refused(refusal))), + ..FakeMeetingStones::at_stone() + }; + assert_eq!( + joinfailed_reason(&handled(&store, join_stone())), + reason, + "{refusal:?}" + ); + } + let silent = FakeMeetingStones { + op_outcome: Some(Ok(MeetingStoneOutcome::Refused( + MeetingStoneRefusal::ActorUnavailable, + ))), + ..FakeMeetingStones::at_stone() + }; + assert!(handled(&silent, join_stone()).is_empty()); + } + + #[test] + fn a_stone_whose_area_the_client_cannot_name_stops_before_the_party_authority() { + let store = FakeMeetingStones { + stone_area: Some(0xFFFF_FFFF), + ..FakeMeetingStones::at_stone() + }; + + assert!(handled(&store, join_stone()).is_empty()); + assert_eq!(store.calls(), [("home", "admit"), ("home", "stone_area")]); + } + + /// The members stand on different World Shards; the Fake's facts table is that realm-wide + /// read. A member no Shard can name is left out, and the Module gives it class 0. + #[test] + fn a_party_join_carries_every_members_facts() { + const PRIEST_GUID: u64 = 8; + const UNKNOWN: u64 = 9; + let store = FakeMeetingStones { + party: Some(vec![ACTOR, PRIEST_GUID, UNKNOWN]), + facts: HashMap::from([ + (ACTOR, facts(ACTOR, ORC, WARRIOR)), + (PRIEST_GUID, facts(PRIEST_GUID, ORC, PRIEST)), + ]), + ..FakeMeetingStones::at_stone() + }; + + handled(&store, join_stone()); + assert_eq!( + store.ops(), + [( + ACTOR, + realm_op::JOIN, + AREA, + vec![facts(ACTOR, ORC, WARRIOR), facts(PRIEST_GUID, ORC, PRIEST)] + )] + ); + } + + /// A Raid's 40 members are not read for a JOIN the queue refuses anyway. + #[test] + fn a_raid_join_carries_only_the_actors_facts() { + let store = FakeMeetingStones { + party: Some((ACTOR..ACTOR + 6).collect()), + op_outcome: Some(Ok(MeetingStoneOutcome::Refused( + MeetingStoneRefusal::RaidGroup, + ))), + ..FakeMeetingStones::at_stone() + }; + + assert_eq!(joinfailed_reason(&handled(&store, join_stone())), 3); + assert_eq!(store.ops()[0].3, [facts(ACTOR, HUMAN, WARRIOR)]); + } + + #[test] + fn leave_runs_the_op_and_answers_nothing_itself() { + let store = FakeMeetingStones::default(); + + assert!(handled(&store, ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE).is_empty()); + assert_eq!(store.calls(), [("authority", "op")]); + assert_eq!(store.ops(), [(ACTOR, realm_op::LEAVE, 0, Vec::new())]); + } + + #[test] + fn info_answers_joined_for_a_seeker_and_none_otherwise() { + let queued = FakeMeetingStones { + queued: Some(AREA), + ..Default::default() + }; + assert_eq!( + setqueue(&handled( + &queued, + ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO + )), + (AREA, queue_status::JOINED_QUEUE) + ); + assert_eq!(queued.calls(), [("authority", "queued_area")]); + + let idle = FakeMeetingStones::default(); + assert_eq!( + setqueue(&handled(&idle, ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO)), + (0, queue_status::NONE) + ); + } + + /// vmangos answers 0 (vm:Handlers/MiscHandler.cpp:277-282). + #[test] + fn looking_for_group_answers_zero() { + let store = FakeMeetingStones::default(); + match handled(&store, ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP).as_slice() { + [Outbound::One(ServerOpcodeMessage::MSG_LOOKING_FOR_GROUP(answer))] => { + assert_eq!(answer.unknown1, 0) + } + _ => panic!("expected one MSG_LOOKING_FOR_GROUP"), + } + assert!(store.calls().is_empty()); + } + + #[test] + fn every_opcode_is_silent_outside_the_world() { + let store = FakeMeetingStones::at_stone(); + let outside = MeetingStonePlayer { + account_id: 1, + self_guid: None, + }; + for msg in [ + join_stone(), + ClientOpcodeMessage::CMSG_MEETINGSTONE_LEAVE, + ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO, + ClientOpcodeMessage::MSG_LOOKING_FOR_GROUP, + ] { + match dispatch_meeting_stone_action(&store, outside, msg).unwrap() { + MeetingStoneActionOutcome::Handled { outbound } => assert!(outbound.is_empty()), + MeetingStoneActionOutcome::PassThrough(_) => panic!("not consumed"), + } + } + assert!(store.calls().is_empty()); + } + + /// A Refusal or an untagged error keeps the World Session; a lost transport ends it. + #[test] + fn only_a_lost_transport_ends_the_session() { + let untagged = FakeMeetingStones { + op_outcome: Some(Err("realm_meeting_stone_op reducer failed: boom".into())), + ..FakeMeetingStones::at_stone() + }; + assert!(handled(&untagged, join_stone()).is_empty()); + + let lost = FakeMeetingStones { + op_outcome: Some(Err( + "realm_meeting_stone_op reducer transport disconnected: channel closed".into(), + )), + ..FakeMeetingStones::at_stone() + }; + assert!(dispatch_meeting_stone_action(&lost, in_world(), join_stone()).is_err()); + } + + /// The Coordinator picks the database, so a Fake cannot show it. The admission runs on the + /// session's own handle, which is its Home Shard; the queue op runs on Realm-core, which is the + /// one database of an unsharded Realm. + #[test] + fn the_coordinator_admits_at_home_and_queues_on_the_party_authority() { + let src = include_str!("../../stdb/reducers.rs"); + let admit = crate::test_scan::code_of(src, "pub fn admit_meeting_stone("); + assert!(admit.contains("self.0.call_pipe()") && !admit.contains("realm_core")); + let op = crate::test_scan::code_of(src, "pub fn meeting_stone_op("); + assert!(op.contains("let realm = self.realm_core()?;")); + assert!(op.contains("realm.0.call_pipe().conn.reducers")); + } + + #[test] + fn other_opcodes_pass_through() { + let store = FakeMeetingStones::default(); + let msg = ClientOpcodeMessage::CMSG_PING(CMSG_PING::default()); + assert!(matches!( + dispatch_meeting_stone_action(&store, in_world(), msg).unwrap(), + MeetingStoneActionOutcome::PassThrough(_) + )); + } +} diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 19bab584f..c9337885e 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -6,7 +6,7 @@ use super::*; // Two shapes live here. A `handle_*` handler is code-motion of the former dispatch match arms // (bodies verbatim): it sends on the socket itself and returns `Ok(None)` once it consumes its // opcode, else `Ok(Some(msg))` to pass the message on. A `dispatch_*_action` seam (auction, channel, -// chat, item, melee, quest and vendor) owns a whole protocol family instead: it takes a narrow store +// chat, item, meeting stone, melee, quest and vendor) owns a whole protocol family instead: it takes a narrow store // trait and a player context, decides refusal-versus-fatal itself, and returns the outbound batch // for the world session to send, so the family can be tested without a socket. @@ -22,6 +22,7 @@ mod guild; mod item; mod loot; mod mail; +mod meeting_stone; mod melee; mod member_stats; mod query; @@ -70,6 +71,10 @@ pub(crate) use loot::{ LootWindowRefusal, LootWindowRequestStatus, LootWindowStore, OpenLootState, }; pub(crate) use mail::handle_mail; +pub(crate) use meeting_stone::{ + dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStoneActionStore, + MeetingStoneOutcome, MeetingStonePlayer, SeekerFacts, +}; pub(crate) use melee::{ dispatch_melee_action, MeleeActionOutcome, MeleeActionPlayer, MeleeActionStore, }; diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 17d2ef838..ca0216113 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -57,25 +57,26 @@ pub(crate) use handlers::{ use handlers::{ decode_auction_browse, dispatch_auction_action, dispatch_auction_browse_action, dispatch_cast, dispatch_channel_action, dispatch_chat_action, dispatch_duel_action, dispatch_guild_action, - dispatch_item_action, dispatch_loot_window, dispatch_melee_action, dispatch_member_stats, - dispatch_quest_action, dispatch_taxi_action, dispatch_vendor_action, handle_bank, handle_char, - handle_combat, handle_loot, handle_mail, handle_query, handle_trade, handle_trainer, - quest_giver_menu, queue_reply_then_arm, AuctionActionOutcome, AuctionActionPlayer, CastOutcome, - CastPlayer, CastTransition, ChannelActionOutcome, ChatActionOutcome, ChatActionPlayer, - DuelActionOutcome, DuelActionPlayer, GuildActionOutcome, GuildActionPlayer, ItemActionOutcome, - ItemActionPlayer, LootWindowOutcome, LootWindowPlayer, MeleeActionOutcome, MeleeActionPlayer, - MemberStatsOutcome, MemberStatsPlayer, OpenLootState, QuestActionOutcome, QuestActionPlayer, - TaxiActionOutcome, TaxiActionPlayer, VendorActionOutcome, VendorActionPlayer, - CMSG_AUCTION_LIST_ITEMS_OPCODE, + dispatch_item_action, dispatch_loot_window, dispatch_meeting_stone_action, + dispatch_melee_action, dispatch_member_stats, dispatch_quest_action, dispatch_taxi_action, + dispatch_vendor_action, handle_bank, handle_char, handle_combat, handle_loot, handle_mail, + handle_query, handle_trade, handle_trainer, quest_giver_menu, queue_reply_then_arm, + AuctionActionOutcome, AuctionActionPlayer, CastOutcome, CastPlayer, CastTransition, + ChannelActionOutcome, ChatActionOutcome, ChatActionPlayer, DuelActionOutcome, DuelActionPlayer, + GuildActionOutcome, GuildActionPlayer, ItemActionOutcome, ItemActionPlayer, LootWindowOutcome, + LootWindowPlayer, MeetingStoneActionOutcome, MeetingStonePlayer, MeleeActionOutcome, + MeleeActionPlayer, MemberStatsOutcome, MemberStatsPlayer, OpenLootState, QuestActionOutcome, + QuestActionPlayer, TaxiActionOutcome, TaxiActionPlayer, VendorActionOutcome, + VendorActionPlayer, CMSG_AUCTION_LIST_ITEMS_OPCODE, }; pub(crate) use handlers::{ member_stats_tick, zone_weather_message, AuctionBrowseRequest, AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, ChannelOutcome, ChannelRequest, ChannelRoster, CharacterFacts, ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, GuildOutcome, GuildRequest, ItemActionResult, LootActionStatus, LootWindowRefusal, - LootWindowRequestStatus, MemberPresence, MemberStatsRecord, MemberStatsStore, PlaceBidOutcome, - PlaceBidRequest, RealmChatRequest, SpeakerFacts, TrainerBuyOutcome, WeatherStore, - WhisperRequest, WhisperTargetFacts, + LootWindowRequestStatus, MeetingStoneOutcome, MemberPresence, MemberStatsRecord, + MemberStatsStore, PlaceBidOutcome, PlaceBidRequest, RealmChatRequest, SeekerFacts, + SpeakerFacts, TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, }; use login_queue::{Admission, LoginQueue}; use social::handle_social; @@ -1572,6 +1573,22 @@ fn dispatch( } ChannelActionOutcome::PassThrough(msg) => msg, }; + let msg = match dispatch_meeting_stone_action( + store, + MeetingStonePlayer { + account_id: conn.account_id, + self_guid: social::self_guid(conn), + }, + msg, + )? { + MeetingStoneActionOutcome::Handled { outbound } => { + for message in outbound { + send(tx, message)?; + } + return Ok(()); + } + MeetingStoneActionOutcome::PassThrough(msg) => msg, + }; let msg = match dispatch_guild_action( store, GuildActionPlayer { diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 8b3b6a019..ec3eefd45 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -399,11 +399,50 @@ impl GroupBroadcastCooldowns { /// `realm_group_op`'s argument slots after the actor: `(op, target_guid, arg_a, arg_b, arg_c)`. type RealmOpArgs = (u8, u64, u8, u8, u64); +/// The class and race of a Character accepting an invite. Realm-core holds no Character rows, so an +/// ACCEPT conveys them for the Seeker row a queued party gains. 0 when no World Shard names the +/// Character. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct AcceptorFacts { + class: u8, + race: u8, +} + +impl AcceptorFacts { + /// What `realm_group_op` receives for every op but ACCEPT. + const NONE: Self = Self { class: 0, race: 0 }; + + /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0s rather than losing + /// the accept: the Module treats an unknown class as a seat with no role. + fn of(store: &St, guid: u64) -> Self { + match presence::character_anywhere(store, guid) { + Ok(Some(character)) => Self { + class: character.class, + race: character.race, + }, + Ok(None) => Self::NONE, + Err(error) => { + log::warn!("party: class and race of acceptor {guid} unread: {error:#}"); + Self::NONE + } + } + } + + /// [`Self::of`] for an ACCEPT, [`Self::NONE`] for any other op. + fn for_op(store: &St, guid: u64, op: Op) -> Self { + match op { + Op::Accept => Self::of(store, guid), + _ => Self::NONE, + } + } +} + impl Op { - fn realm_args(self) -> RealmOpArgs { + /// The argument slots for this op. Only ACCEPT reads `acceptor`. + fn realm_args(self, acceptor: AcceptorFacts) -> RealmOpArgs { match self { Op::Invite(target) => (realm_op::INVITE, target, 0, 0, 0), - Op::Accept => (realm_op::ACCEPT, 0, 0, 0, 0), + Op::Accept => (realm_op::ACCEPT, 0, acceptor.class, acceptor.race, 0), Op::Decline => (realm_op::DECLINE, 0, 0, 0, 0), Op::Leave => (realm_op::LEAVE, 0, 0, 0, 0), Op::Uninvite(target) => (realm_op::UNINVITE, target, 0, 0, 0), @@ -469,26 +508,28 @@ impl Op { /// Run `op` as `self_guid` through `realm_group_op` on `authority`, the database that holds the /// party: Realm-core when sharded, the home shard otherwise. It returns before the Coordinator /// cache holds the commit, so it is only for an op that pushes no Group mirror after it: a Group -/// Broadcast, a Target Icon request, a declined invite, or an op on a single database. +/// Broadcast, a Target Icon request, a declined invite, or an op on a single database. An ACCEPT +/// changes a roster, so it never comes here. fn run_on_authority( authority: &A, self_guid: u64, op: Op, ) -> Result { - let (code, target, arg_a, arg_b, arg_c) = op.realm_args(); + let (code, target, arg_a, arg_b, arg_c) = op.realm_args(AcceptorFacts::NONE); authority.realm_group_op(code, self_guid, target, arg_a, arg_b, arg_c) } /// [`run_on_authority`] that returns only after the Coordinator cache holds the commit, so the /// mirror push after it reads the op's own roster. Every op that changes a roster uses it, for a /// World Session and for a session-less Character alike. It waits on the Coordinator pump, so the -/// caller must run on its own thread. +/// caller must run on its own thread. `acceptor` is read only for an ACCEPT. fn run_on_authority_visible( authority: &A, self_guid: u64, op: Op, + acceptor: AcceptorFacts, ) -> Result { - let (code, target, arg_a, arg_b, arg_c) = op.realm_args(); + let (code, target, arg_a, arg_b, arg_c) = op.realm_args(acceptor); authority.realm_group_op_visible(code, self_guid, target, arg_a, arg_b, arg_c) } @@ -880,7 +921,8 @@ fn answer_for_session_less(store: &St, realm: &dyn Worl return; } } - let joined = match run_on_authority_visible(realm, guid, Op::Accept) { + let acceptor = AcceptorFacts::of(store, guid); + let joined = match run_on_authority_visible(realm, guid, Op::Accept, acceptor) { Ok(PartyOutcome::Ran) => { log::info!("party: session-less {guid} accepted its group invite"); return; @@ -987,7 +1029,9 @@ pub(crate) fn run( if matches!(op, Op::Leave | Op::Uninvite(_)) { crate::world::loot::flush_pending_promotions(store, realm.as_ref()); } - if let PartyOutcome::Refused(refusal) = run_on_authority_visible(realm.as_ref(), self_guid, op)? + let acceptor = AcceptorFacts::for_op(store, self_guid, op); + if let PartyOutcome::Refused(refusal) = + run_on_authority_visible(realm.as_ref(), self_guid, op, acceptor)? { return Ok(PartyOutcome::Refused(refusal)); } @@ -1165,9 +1209,12 @@ pub(crate) fn run_bot_invite( return Ok(refusal.into()); } let before = realm.group_roster(inviter_guid)?; - if let PartyOutcome::Refused(refusal) = - run_on_authority_visible(realm, inviter_guid, Op::Invite(target_guid))? - { + if let PartyOutcome::Refused(refusal) = run_on_authority_visible( + realm, + inviter_guid, + Op::Invite(target_guid), + AcceptorFacts::NONE, + )? { return Ok(PartyOutcome::Refused(refusal)); } answer_for_session_less(store, realm, target_guid); @@ -1199,7 +1246,7 @@ pub(crate) fn run_bot_leave(store: &St, leaver_guid: u64) -> Res None => store, }; let leave = run_server_leave(store, realm, leaver_guid, 1, |realm, character_guid| { - run_on_authority_visible(realm, character_guid, Op::Leave) + run_on_authority_visible(realm, character_guid, Op::Leave, AcceptorFacts::NONE) })?; if leave.outcome == PartyOutcome::Ran { sync_membership_mirrors(store, realm, leaver_guid, leave.previous_roster); diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 16f8f4502..d56f58348 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1108,7 +1108,7 @@ fn deleted_character_leave_returns_with_the_committed_roster_visible() { #[test] fn a_world_session_runs_its_realm_party_op_on_the_visibility_pipe() { let run = crate::test_scan::code_of(include_str!("party.rs"), "pub(crate) fn run<"); - assert!(run.contains("run_on_authority_visible(realm.as_ref(), self_guid, op)")); + assert!(run.contains("run_on_authority_visible(realm.as_ref(), self_guid, op, acceptor)")); let visible = crate::test_scan::code_of(include_str!("party.rs"), "fn run_on_authority_visible<"); assert!(visible.contains("authority.realm_group_op_visible(")); @@ -1473,7 +1473,7 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { party_state.ops.clone(), vec![ (realm_op::INVITE, GINGER, BOT, 0, 0, 0), - (realm_op::ACCEPT, BOT, 0, 0, 0, 0) + (realm_op::ACCEPT, BOT, 0, 1, 1, 0) ], "the accept must run on realm-core with the BOT as the actor — never the inviter, and never 0" ); @@ -1654,7 +1654,7 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin state.ops.clone(), vec![ (realm_op::INVITE, GINGER, BOT, 0, 0, 0), - (realm_op::ACCEPT, BOT, 0, 0, 0, 0), + (realm_op::ACCEPT, BOT, 0, 1, 1, 0), // …and the decline is the bot's own too, not the inviter's. (realm_op::DECLINE, BOT, 0, 0, 0, 0), ], @@ -1724,8 +1724,8 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { vec![ // INVITE: the target rides `target_guid`, nothing else is used. (realm_op::INVITE, GINGER, VIM, 0, 0, 0), - // ACCEPT: the actor alone. - (realm_op::ACCEPT, VIM, 0, 0, 0, 0), + // ACCEPT: the acceptor's class in arg_a and race in arg_b, the fixture's Human Warrior. + (realm_op::ACCEPT, VIM, 0, 1, 1, 0), // LOOT_METHOD: setting in arg_a, MASTER in target_guid, threshold in arg_b — // CMSG_LOOT_METHOD's own field order. (realm_op::LOOT_METHOD, GINGER, VIM, 2, 4, 0), @@ -1901,7 +1901,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() // World entry asks for the Party's Target Icons after the list. (realm_op::TARGET_ICON, GINGER, 0, 0xFF, 0, 0), (realm_op::INVITE, GINGER, VIM, 0, 0, 0), - (realm_op::ACCEPT, GINGER, 0, 0, 0, 0), + (realm_op::ACCEPT, GINGER, 0, 1, 1, 0), (realm_op::DECLINE, GINGER, 0, 0, 0, 0), // CMSG_LOOT_METHOD's own field order: setting in arg_a, MASTER in target_guid, // threshold in arg_b. @@ -1977,7 +1977,7 @@ fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { party_state.ops.clone(), vec![ (realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0), - (realm_op::ACCEPT, FAR_BOT, 0, 0, 0, 0) + (realm_op::ACCEPT, FAR_BOT, 0, 1, 1, 0) ], "both halves must run on realm-core, attributed to the right actor each time — the bot as \ itself for both the invite and (through the session-less answer) the accept" @@ -2094,7 +2094,7 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { party_state.ops.clone(), vec![ (realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0), - (realm_op::ACCEPT, FAR_BOT, 0, 0, 0, 0), + (realm_op::ACCEPT, FAR_BOT, 0, 1, 1, 0), (realm_op::LEAVE, BOT, 0, 0, 0, 0), ], "the invite runs INVITE (plus the session-less answer) and the leave runs LEAVE, each \ @@ -4201,6 +4201,141 @@ fn the_relay_renders_each_group_broadcast_kind() { } } +/// **Each Meeting Stone kind renders its vanilla packet**: SETQUEUE is `u32 area, u8 status` +/// (cm:LFG/LFGHandler.cpp:146-152), MEMBER_ADDED the added guid, and COMPLETE and IN_PROGRESS are +/// empty (cm:LFG/LFGMgr.cpp:288-309). +#[test] +fn the_relay_renders_each_meeting_stone_kind() { + use lyracore_shared::meeting_stone::event_kind; + let (realm, _world, _instances, _calls) = party_topology(); + let cases: [(u8, u64, &str, Wire); 4] = [ + ( + event_kind::QUEUE, + 0, + "1581,1", + (0x0295, vec![0x2D, 0x06, 0, 0, 1]), + ), + ( + event_kind::MEMBER_ADDED, + VIM, + "", + (0x0299, VIM.to_le_bytes().to_vec()), + ), + (event_kind::IN_PROGRESS, 0, "", (0x0298, Vec::new())), + (event_kind::COMPLETE, 0, "", (0x0297, Vec::new())), + ]; + for (kind, other_guid, payload, expected) in cases { + let row = group_event(kind, other_guid, payload); + + let packets = + crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row); + + let sent: Vec<_> = packets.iter().map(wire).collect(); + assert_eq!(sent, [expected], "kind {kind} payload {payload:?}"); + } +} + +/// A QUEUE payload the client could not read, or an area it has no id for, reaches no client. +#[test] +fn the_relay_drops_a_meeting_stone_queue_it_cannot_encode() { + use lyracore_shared::meeting_stone::event_kind; + let (realm, _world, _instances, _calls) = party_topology(); + for payload in ["1581", "1581,6", "x,1", "4294967295,1"] { + let row = group_event(event_kind::QUEUE, 0, payload); + assert!( + crate::stdb::subscriptions::group_event_outbound(realm.as_ref(), GINGER, &row) + .is_empty(), + "payload {payload:?}" + ); + } +} + +/// A Dwarf Priest stands on `instances`, so the acceptor's facts differ from the party fixture's +/// Human Warrior and cross a shard when the asking session is on `world`. +fn dwarf_priest_topology() -> ( + std::sync::Arc, + std::sync::Arc, + std::sync::Arc, +) { + let dwarf_priest = |guid: u64, name: &str| codec::CharacterView { + race: 3, + class: 5, + ..character(guid, name) + }; + let realm = std::sync::Arc::new(InMemoryStore { + shard: "lyracore-realm".into(), + is_realm: true, + ..Default::default() + }); + let world = std::sync::Arc::new(InMemoryStore { + shard: "world".into(), + realm: Some(realm.clone()), + characters: vec![character(GINGER, "Ginger")], + live_guids: vec![GINGER], + entity_partitions: std::sync::Mutex::new(vec![(GINGER, 0, 0)]), + ..Default::default() + }); + let instances = std::sync::Arc::new(InMemoryStore { + shard: "instances".into(), + realm: Some(realm.clone()), + characters: vec![dwarf_priest(VIM, "Vim"), dwarf_priest(FAR_BOT, "Farbotty")], + live_guids: vec![VIM, FAR_BOT], + entity_partitions: std::sync::Mutex::new(vec![(VIM, 0, 0), (FAR_BOT, 0, 0)]), + offline_guids: vec![FAR_BOT], + ..Default::default() + }); + for shard in [&world, &instances] { + *shard.peers.lock().unwrap() = vec![world.clone(), instances.clone()]; + } + (realm, world, instances) +} + +fn accepts(realm: &InMemoryStore) -> Vec<(u8, u64, u64, u8, u8, u64)> { + realm + .party + .lock() + .unwrap() + .ops + .iter() + .copied() + .filter(|op| op.0 == realm_op::ACCEPT) + .collect() +} + +/// A client's Realm-core ACCEPT conveys the acceptor's class in `arg_a` and race in `arg_b`, so a +/// queued party can give the joiner a Seeker row. +#[test] +fn a_client_accept_carries_the_acceptors_class_and_race() { + let (realm, world, instances) = dwarf_priest_topology(); + party::run(world.as_ref(), 7, GINGER, party::Op::Invite(VIM)).unwrap(); + + party::run(instances.as_ref(), 8, VIM, party::Op::Accept).unwrap(); + + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, VIM, 0, 5, 3, 0)]); +} + +/// A playerbot's automatic accept reads its facts from the Shard that holds it, here not the +/// inviter's. +#[test] +fn a_playerbot_accept_carries_its_class_and_race_from_its_own_shard() { + let (realm, world, _instances) = dwarf_priest_topology(); + + party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 3, 0)]); +} + +/// An acceptor no World Shard names conveys 0s, and the accept still runs. +#[test] +fn an_accept_nobody_can_name_conveys_zero_class_and_race() { + let (realm, world, _instances, _calls) = party_topology(); + realm.party.lock().unwrap().invites.push((99, GINGER)); + + party::run(world.as_ref(), 9, 99, party::Op::Accept).unwrap(); + + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, 99, 0, 0, 0, 0)]); +} + /// A payload that does not decode reaches no client. #[test] fn the_relay_drops_a_group_broadcast_it_cannot_decode() { diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index ed4589a7e..2a9c96bd8 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -1,7 +1,8 @@ //! `WorldStore`: the broad storage/coordination seam used by the world session. Deep protocol //! families may add focused supertraits such as [`AuctionActionStore`], [`ChannelActionStore`], //! [`ChatActionStore`], -//! [`ItemActionStore`], [`MeleeActionStore`], [`QuestActionStore`], [`TaxiActionStore`] and +//! [`ItemActionStore`], [`MeetingStoneActionStore`], [`MeleeActionStore`], [`QuestActionStore`], +//! [`TaxiActionStore`] and //! [`VendorActionStore`] so their wire mapping and failure policy can be tested without //! implementing this entire interface — a migrated family's operations live only on its own trait, //! never here. Kept as one broad trait for the remaining @@ -10,8 +11,8 @@ use super::handlers::{ AuctionActionStore, CastStore, ChannelActionStore, ChatActionStore, DuelActionStore, - GuildActionStore, ItemActionStore, LootWindowStore, MeleeActionStore, MemberStatsStore, - QuestActionStore, TaxiActionStore, VendorActionStore, WeatherStore, + GuildActionStore, ItemActionStore, LootWindowStore, MeetingStoneActionStore, MeleeActionStore, + MemberStatsStore, QuestActionStore, TaxiActionStore, VendorActionStore, WeatherStore, }; use super::*; @@ -31,6 +32,7 @@ pub trait WorldStore: + GuildActionStore + ItemActionStore + LootWindowStore + + MeetingStoneActionStore + MeleeActionStore + MemberStatsStore + QuestActionStore diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 1aec1ab64..9b3ca0a7c 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -2,9 +2,10 @@ use super::handlers::{ resolve_online_character, AuctionActionStore, AuctionInteraction, CastStore, ChannelActionStore, ChannelOutcome, ChannelRequest, ChannelRoster, ChatActionStore, ChatOutcome, DuelActionStore, GuildActionStore, ItemActionStore, LootWindowRefusal, - LootWindowRequestStatus, LootWindowStore, MeleeActionStore, MemberPresence, MemberSnapshot, - MemberStatsStore, QuestActionStore, RealmChatRequest, ResolvedTarget, SpeakerFacts, - TaxiActionStore, VendorActionStore, WeatherStore, WhisperRequest, WhisperTargetFacts, + LootWindowRequestStatus, LootWindowStore, MeetingStoneActionStore, MeetingStoneOutcome, + MeleeActionStore, MemberPresence, MemberSnapshot, MemberStatsStore, QuestActionStore, + RealmChatRequest, ResolvedTarget, SpeakerFacts, TaxiActionStore, VendorActionStore, + WeatherStore, WhisperRequest, WhisperTargetFacts, }; use super::party::PartyOutcome; use super::*; @@ -4375,6 +4376,42 @@ impl ChannelActionStore for InMemoryStore { } } +/// The Meeting Stone family is tested against its own Fake in `handlers/meeting_stone.rs`. Here no +/// stone exists and nobody is queued. +impl MeetingStoneActionStore for InMemoryStore { + fn admit_meeting_stone(&self, _actor_guid: u64, _go_guid: u64) -> Result { + Ok(MeetingStoneOutcome::Refused( + lyracore_shared::meeting_stone::MeetingStoneRefusal::NotAMeetingStone, + )) + } + + fn meeting_stone_area(&self, _go_guid: u64) -> Result> { + Ok(None) + } + + fn party_members(&self, _actor_guid: u64) -> Result>> { + Ok(None) + } + + fn seeker_facts(&self, _character_guid: u64) -> Result> { + Ok(None) + } + + fn meeting_stone_op( + &self, + _actor_guid: u64, + _op: u8, + _area_id: u32, + _seekers: Vec, + ) -> Result { + Ok(MeetingStoneOutcome::Ran) + } + + fn queued_area(&self, _character_guid: u64) -> Result> { + Ok(None) + } +} + impl DuelActionStore for InMemoryStore { fn duel_accept(&self, _account_id: u64, _actor_guid: u64, _flag_guid: u64) -> Result<()> { Ok(()) diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index bd4f2d8f3..1d3d140bb 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -780,6 +780,14 @@ parity_test!(parity_game_chat_channel_notice_event, "game_chat_channel_notice_ev id, notice, channel_name, subject_guid, actor_guid, old_flags, new_flags, channel_flags, text, recipients, created_at, }); +// The Meeting Stone Queue. A drifted stone column names the wrong dungeon area; a drifted Seeker +// column answers `CMSG_MEETINGSTONE_INFO` wrongly. +parity_test!(parity_game_meeting_stone, "game_meeting_stone", lyracore_module::MeetingStone, bindings::meeting_stone_type::MeetingStone, { + entry, min_level, max_level, area_id, +}); +parity_test!(parity_game_meeting_stone_seeker, "game_meeting_stone_seeker", lyracore_module::MeetingStoneSeeker, bindings::meeting_stone_seeker_type::MeetingStoneSeeker, { + character_guid, area_id, team, class, group_id, queued_at, +}); // Private (no per-player subscriber to decode these — every wire-visible roll transition still // rides `game_group_event`, unchanged). Subscribed so the gateway's loot-roll relay // (`world::loot::relay_tick`) can promote a world shard's staging roll onto realm-core and read @@ -1227,6 +1235,8 @@ const MANIFEST_TABLES: &[&str] = &[ "game_chat_channel_member", "game_chat_channel_ban", "game_chat_channel_notice_event", + "game_meeting_stone", + "game_meeting_stone_seeker", "game_loot_roll", "game_loot_roll_vote", "game_guid_allocator", From 66a97ca5ec3b2f1fcc8d59c17445aa759969ad77 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 20:43:40 +0200 Subject: [PATCH 06/16] docs(tickets): correct the T1 tripwire and visibility notes The party table has no Character guid, so only the Seeker table belongs in NOT_CHARACTER_OWNED. Widening checked_group_membership also widens its error type. Refs #532 Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/tickets/issue-532/README.md | 5 +++-- .claude/tickets/issue-532/T1-meeting-stone-queue.md | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.claude/tickets/issue-532/README.md b/.claude/tickets/issue-532/README.md index 56059bb88..8a4cfa02f 100644 --- a/.claude/tickets/issue-532/README.md +++ b/.claude/tickets/issue-532/README.md @@ -298,10 +298,11 @@ Account Claim ends → the claim's Character loses its solo Seeker row, no `raid_convert_on`. 23. Passing the lead keeps the party queued with no event. Both cores do this, and the party row is keyed by `group_id`, so nothing changes. -24. The Seeker and party tables go in `tripwires.rs`'s `NOT_CHARACTER_OWNED` list, the Chat Channel +24. The Seeker table goes in `tripwires.rs`'s `NOT_CHARACTER_OWNED` list, the Chat Channel precedent. A solo Seeker ends with its claim. A party Seeker ends with its membership, and a deleted Character leaves its party through `remove_member` on both planes, where T3's hook - drops the row. + drops the row. The party table has no Character guid column, so the census test rejects an + entry for it as stale. 25. Packages cannot author meeting stones in this work. `game_meeting_stone` does not join the Package Delta `gameobjects` Import Family. A Package type-23 template has no stone row and is refused as `NotAMeetingStone`. No Package ships a stone today; adding the table to the diff --git a/.claude/tickets/issue-532/T1-meeting-stone-queue.md b/.claude/tickets/issue-532/T1-meeting-stone-queue.md index 7b87a6748..b2d60d53b 100644 --- a/.claude/tickets/issue-532/T1-meeting-stone-queue.md +++ b/.claude/tickets/issue-532/T1-meeting-stone-queue.md @@ -78,16 +78,18 @@ as a `SpacetimeType`. All three tables private. No schedule table in this ticket deletes the party and its Seekers, and every member gets `QUEUE(0, LEAVE_QUEUE)`. Anyone else in a party gets `QUEUE(0, NONE)`. Nobody queued and no party: `Ok`, no event. - Membership reads go through `group::checked_group_membership`, `group_kind_of` and - `members_of`. Events go through `group::push_event`. Widen `checked_group_membership` and - `gameobject::usable_go` to `pub(crate)`; change nothing else in `group.rs` or `gameobject.rs`. + `members_of`. Events go through `group::push_event`. Widen `checked_group_membership`, its + error type `GroupOpError`, and `gameobject::usable_go` to `pub(crate)`; change nothing else in + `group.rs` or `gameobject.rs`. - `meeting_stone::claim_ended(ctx, character_guid)` deletes that Character's solo Seeker row, no event. A party Seeker row stays. Call it at the three points in `account_ownership.rs` that call `channel::leave_all` (`claim_account` replacing a dead generation, `release_account_claim`, `reap_account_claims`). If the two calls then always travel together, fold them into one `account_ownership` helper so the next claim-scoped feature has one place to hook. -- `tripwires.rs`: add `game_meeting_stone_seeker` and `game_meeting_stone_party` to - `NOT_CHARACTER_OWNED` with the reason "Realm-core Meeting Stone Queue; a solo Seeker ends with - its Account Claim, a party Seeker with its party membership". No `character_owned!` markers. +- `tripwires.rs`: add `game_meeting_stone_seeker` to `NOT_CHARACTER_OWNED` with the reason + "Realm-core Meeting Stone Queue; a solo Seeker ends with its Account Claim, a party Seeker with + its party membership". `game_meeting_stone_party` has no Character guid column, so it needs no + entry, and the census test fails on one. No `character_owned!` markers. - `debug/meeting_stone.rs`, behind `debug_reducers`, ids `509_6000`-`509_6099` (grep first): stage a stone template, stone row and spawned GO; stage Characters with chosen race, class and level beside it, each with a live Account Claim; stage a Party or Raid led by one of them; backdate a From a72536455f9044a183e9d319e2bc3924135fad14 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 23:14:00 +0200 Subject: [PATCH 07/16] feat(module): match meeting stone Seekers by Open Role The bucket pass runs in every transaction that can create a match. A queued Party takes Stone Adds for its Open Roles, longest wait first, and five solo Seekers in one area and team form a Party. Each Stone Add goes through the new join core, which the accepted invite now uses too. The group cores keep a queued Party's queue state in step with its roster: accept, leave, deletion, kick, disband and raid convert. A 5 s reminder tick sends IN_PROGRESS to a waiting Party every five minutes. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 10 + ...e_meeting_stone_reminder_schedule_table.rs | 242 ++++ .../meeting_stone_reminder_schedule_type.rs | 52 + gateway/src/stdb/bindings/mod.rs | 43 + .../bindings/remind_queued_parties_reducer.rs | 70 + module/src/debug/repair.rs | 4 + module/src/group.rs | 298 +++-- module/src/loot/tag.rs | 24 +- module/src/meeting_stone.rs | 843 +++++++++++- module/src/seed.rs | 11 +- module/src/tripwires.rs | 2 +- module/tests/meeting_stone_matching.rs | 1133 +++++++++++++++++ 12 files changed, 2558 insertions(+), 174 deletions(-) create mode 100644 gateway/src/stdb/bindings/game_meeting_stone_reminder_schedule_table.rs create mode 100644 gateway/src/stdb/bindings/meeting_stone_reminder_schedule_type.rs create mode 100644 gateway/src/stdb/bindings/remind_queued_parties_reducer.rs create mode 100644 module/tests/meeting_stone_matching.rs diff --git a/CONTEXT.md b/CONTEXT.md index 031571dd7..c32d2ec80 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -846,6 +846,16 @@ One Character in the Meeting Stone Queue, alone or as a member of a queued Party class and team the Gateway supplied. A solo Seeker lasts as long as its Account Claim. _Avoid_: queued player, LFG player +**Open Role**: +One of a queued Party's one tank, one healer and three damage roles that no member's class fills +yet. +_Avoid_: vacancy, role slot + +**Stone Add**: +The Module adding a Seeker to a queued Party, or forming a Party from five waiting Seekers, through +the party authority's join core. +_Avoid_: matchmaking, auto-invite + ### World clock and weather **Realm Clock**: diff --git a/gateway/src/stdb/bindings/game_meeting_stone_reminder_schedule_table.rs b/gateway/src/stdb/bindings/game_meeting_stone_reminder_schedule_table.rs new file mode 100644 index 000000000..bb0d85827 --- /dev/null +++ b/gateway/src/stdb/bindings/game_meeting_stone_reminder_schedule_table.rs @@ -0,0 +1,242 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::meeting_stone_reminder_schedule_type::MeetingStoneReminderSchedule; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_meeting_stone_reminder_schedule`. +/// +/// Obtain a handle from the [`GameMeetingStoneReminderScheduleTableAccess::game_meeting_stone_reminder_schedule`] method on [`super::RemoteTables`], +/// like `ctx.db.game_meeting_stone_reminder_schedule()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_reminder_schedule().on_insert(...)`. +pub struct GameMeetingStoneReminderScheduleTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_meeting_stone_reminder_schedule`. +pub struct GameMeetingStoneReminderScheduleTableAccessor; + +impl __sdk::TableAccessor for GameMeetingStoneReminderScheduleTableAccessor { + type Row = MeetingStoneReminderSchedule; + type Handle<'db> = GameMeetingStoneReminderScheduleTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_meeting_stone_reminder_schedule() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_meeting_stone_reminder_schedule`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameMeetingStoneReminderScheduleTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameMeetingStoneReminderScheduleTableHandle`], which mediates access to the table `game_meeting_stone_reminder_schedule`. + fn game_meeting_stone_reminder_schedule( + &self, + ) -> GameMeetingStoneReminderScheduleTableHandle<'_>; +} + +impl GameMeetingStoneReminderScheduleTableAccess for super::RemoteTables { + fn game_meeting_stone_reminder_schedule( + &self, + ) -> GameMeetingStoneReminderScheduleTableHandle<'_> { + GameMeetingStoneReminderScheduleTableHandle { + imp: self + .imp + .get_table::("game_meeting_stone_reminder_schedule"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameMeetingStoneReminderScheduleInsertCallbackId(__sdk::CallbackId); +pub struct GameMeetingStoneReminderScheduleDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type Row = MeetingStoneReminderSchedule; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type Row = MeetingStoneReminderSchedule; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameMeetingStoneReminderScheduleInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleInsertCallbackId { + GameMeetingStoneReminderScheduleInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneReminderScheduleInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameMeetingStoneReminderScheduleDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleDeleteCallbackId { + GameMeetingStoneReminderScheduleDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneReminderScheduleDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type InsertCallbackId = GameMeetingStoneReminderScheduleInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleInsertCallbackId { + GameMeetingStoneReminderScheduleInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameMeetingStoneReminderScheduleInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type DeleteCallbackId = GameMeetingStoneReminderScheduleDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleDeleteCallbackId { + GameMeetingStoneReminderScheduleDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameMeetingStoneReminderScheduleDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameMeetingStoneReminderScheduleUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneReminderScheduleUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleUpdateCallbackId { + GameMeetingStoneReminderScheduleUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneReminderScheduleUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameMeetingStoneReminderScheduleTableHandle<'ctx> { + type UpdateCallbackId = GameMeetingStoneReminderScheduleUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameMeetingStoneReminderScheduleUpdateCallbackId { + GameMeetingStoneReminderScheduleUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameMeetingStoneReminderScheduleUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `scheduled_id` unique index on the table `game_meeting_stone_reminder_schedule`, +/// which allows point queries on the field of the same name +/// via the [`GameMeetingStoneReminderScheduleScheduledIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_meeting_stone_reminder_schedule().scheduled_id().find(...)`. +pub struct GameMeetingStoneReminderScheduleScheduledIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameMeetingStoneReminderScheduleTableHandle<'ctx> { + /// Get a handle on the `scheduled_id` unique index on the table `game_meeting_stone_reminder_schedule`. + pub fn scheduled_id(&self) -> GameMeetingStoneReminderScheduleScheduledIdUnique<'ctx> { + GameMeetingStoneReminderScheduleScheduledIdUnique { + imp: self.imp.get_unique_constraint::("scheduled_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameMeetingStoneReminderScheduleScheduledIdUnique<'ctx> { + /// Find the subscribed row whose `scheduled_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("game_meeting_stone_reminder_schedule"); + _table.add_unique_constraint::("scheduled_id", |row| &row.scheduled_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `MeetingStoneReminderSchedule`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_meeting_stone_reminder_scheduleQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `MeetingStoneReminderSchedule`. + fn game_meeting_stone_reminder_schedule( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl game_meeting_stone_reminder_scheduleQueryTableAccess for __sdk::QueryTableAccessor { + fn game_meeting_stone_reminder_schedule( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_meeting_stone_reminder_schedule") + } +} diff --git a/gateway/src/stdb/bindings/meeting_stone_reminder_schedule_type.rs b/gateway/src/stdb/bindings/meeting_stone_reminder_schedule_type.rs new file mode 100644 index 000000000..7204c589d --- /dev/null +++ b/gateway/src/stdb/bindings/meeting_stone_reminder_schedule_type.rs @@ -0,0 +1,52 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct MeetingStoneReminderSchedule { + pub scheduled_id: u64, + pub scheduled_at: __sdk::ScheduleAt, +} + +impl __sdk::InModule for MeetingStoneReminderSchedule { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `MeetingStoneReminderSchedule`. +/// +/// Provides typed access to columns for query building. +pub struct MeetingStoneReminderScheduleCols { + pub scheduled_id: __sdk::__query_builder::Col, + pub scheduled_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for MeetingStoneReminderSchedule { + type Cols = MeetingStoneReminderScheduleCols; + fn cols(table_name: &'static str) -> Self::Cols { + MeetingStoneReminderScheduleCols { + scheduled_id: __sdk::__query_builder::Col::new(table_name, "scheduled_id"), + scheduled_at: __sdk::__query_builder::Col::new(table_name, "scheduled_at"), + } + } +} + +/// Indexed column accessor struct for the table `MeetingStoneReminderSchedule`. +/// +/// Provides typed access to indexed columns for query building. +pub struct MeetingStoneReminderScheduleIxCols { + pub scheduled_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for MeetingStoneReminderSchedule { + type IxCols = MeetingStoneReminderScheduleIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + MeetingStoneReminderScheduleIxCols { + scheduled_id: __sdk::__query_builder::IxCol::new(table_name, "scheduled_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for MeetingStoneReminderSchedule {} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index c173d1d48..574b26f1b 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -552,6 +552,7 @@ pub mod game_mail_template_table; pub mod game_mail_timer_table; pub mod game_map_region_table; pub mod game_meeting_stone_party_table; +pub mod game_meeting_stone_reminder_schedule_table; pub mod game_meeting_stone_seeker_table; pub mod game_meeting_stone_table; pub mod game_melee_attack_table; @@ -881,6 +882,7 @@ pub mod mail_type; pub mod map_region_type; pub mod mark_bot_transfer_arrival_ready_reducer; pub mod meeting_stone_party_type; +pub mod meeting_stone_reminder_schedule_type; pub mod meeting_stone_seeker_type; pub mod meeting_stone_type; pub mod melee_attack_type; @@ -1049,6 +1051,7 @@ pub mod release_account_claim_reducer; pub mod release_bot_transfer_arrival_reducer; pub mod release_player_transfer_arrival_reducer; pub mod release_transfer_reducer; +pub mod remind_queued_parties_reducer; pub mod remove_aura_instruction_type; pub mod remove_guardians_instruction_type; pub mod renew_account_claim_reducer; @@ -1713,6 +1716,7 @@ pub use game_mail_template_table::*; pub use game_mail_timer_table::*; pub use game_map_region_table::*; pub use game_meeting_stone_party_table::*; +pub use game_meeting_stone_reminder_schedule_table::*; pub use game_meeting_stone_seeker_table::*; pub use game_meeting_stone_table::*; pub use game_melee_attack_table::*; @@ -2042,6 +2046,7 @@ pub use mail_type::Mail; pub use map_region_type::MapRegion; pub use mark_bot_transfer_arrival_ready_reducer::mark_bot_transfer_arrival_ready; pub use meeting_stone_party_type::MeetingStoneParty; +pub use meeting_stone_reminder_schedule_type::MeetingStoneReminderSchedule; pub use meeting_stone_seeker_type::MeetingStoneSeeker; pub use meeting_stone_type::MeetingStone; pub use melee_attack_type::MeleeAttack; @@ -2210,6 +2215,7 @@ pub use release_account_claim_reducer::release_account_claim; pub use release_bot_transfer_arrival_reducer::release_bot_transfer_arrival; pub use release_player_transfer_arrival_reducer::release_player_transfer_arrival; pub use release_transfer_reducer::release_transfer; +pub use remind_queued_parties_reducer::remind_queued_parties; pub use remove_aura_instruction_type::RemoveAuraInstruction; pub use remove_guardians_instruction_type::RemoveGuardiansInstruction; pub use renew_account_claim_reducer::renew_account_claim; @@ -4223,6 +4229,9 @@ pub enum Reducer { transfer_id: u64, request_actor: SessionActor, }, + RemindQueuedParties { + schedule: MeetingStoneReminderSchedule, + }, RenewAccountClaim { token: WorldSessionToken, }, @@ -4813,6 +4822,7 @@ impl __sdk::Reducer for Reducer { Reducer::ReleaseBotTransferArrival { .. } => "release_bot_transfer_arrival", Reducer::ReleasePlayerTransferArrival { .. } => "release_player_transfer_arrival", Reducer::ReleaseTransfer { .. } => "release_transfer", + Reducer::RemindQueuedParties { .. } => "remind_queued_parties", Reducer::RenewAccountClaim { .. } => "renew_account_claim", Reducer::RenewAccountFence { .. } => "renew_account_fence", Reducer::RestoreTaxiFixture => "restore_taxi_fixture", @@ -8245,6 +8255,11 @@ Reducer::RecordRegionLoad{ } => __sats::bsatn::to_vec(&release_transfer_reducer::ReleaseTransferArgs { transfer_id: transfer_id.clone(), request_actor: request_actor.clone(), +}), + Reducer::RemindQueuedParties{ + schedule, +} => __sats::bsatn::to_vec(&remind_queued_parties_reducer::RemindQueuedPartiesArgs { + schedule: schedule.clone(), }), Reducer::RenewAccountClaim{ token, @@ -8628,6 +8643,7 @@ pub struct DbUpdate { game_map_region: __sdk::TableUpdate, game_meeting_stone: __sdk::TableUpdate, game_meeting_stone_party: __sdk::TableUpdate, + game_meeting_stone_reminder_schedule: __sdk::TableUpdate, game_meeting_stone_seeker: __sdk::TableUpdate, game_melee_attack: __sdk::TableUpdate, game_melee_schedule: __sdk::TableUpdate, @@ -9336,6 +9352,13 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_meeting_stone_party" => db_update.game_meeting_stone_party.append( game_meeting_stone_party_table::parse_table_update(table_update)?, ), + "game_meeting_stone_reminder_schedule" => { + db_update.game_meeting_stone_reminder_schedule.append( + game_meeting_stone_reminder_schedule_table::parse_table_update( + table_update, + )?, + ) + } "game_meeting_stone_seeker" => db_update.game_meeting_stone_seeker.append( game_meeting_stone_seeker_table::parse_table_update(table_update)?, ), @@ -10508,6 +10531,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_meeting_stone_party, ) .with_updates_by_pk(|row| &row.group_id); + diff.game_meeting_stone_reminder_schedule = cache + .apply_diff_to_table::( + "game_meeting_stone_reminder_schedule", + &self.game_meeting_stone_reminder_schedule, + ) + .with_updates_by_pk(|row| &row.scheduled_id); diff.game_meeting_stone_seeker = cache .apply_diff_to_table::( "game_meeting_stone_seeker", @@ -11505,6 +11534,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_meeting_stone_party" => db_update .game_meeting_stone_party .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_meeting_stone_reminder_schedule" => db_update + .game_meeting_stone_reminder_schedule + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_meeting_stone_seeker" => db_update .game_meeting_stone_seeker .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -12370,6 +12402,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_meeting_stone_party" => db_update .game_meeting_stone_party .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_meeting_stone_reminder_schedule" => db_update + .game_meeting_stone_reminder_schedule + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_meeting_stone_seeker" => db_update .game_meeting_stone_seeker .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -12882,6 +12917,7 @@ pub struct AppliedDiff<'r> { game_map_region: __sdk::TableAppliedDiff<'r, MapRegion>, game_meeting_stone: __sdk::TableAppliedDiff<'r, MeetingStone>, game_meeting_stone_party: __sdk::TableAppliedDiff<'r, MeetingStoneParty>, + game_meeting_stone_reminder_schedule: __sdk::TableAppliedDiff<'r, MeetingStoneReminderSchedule>, game_meeting_stone_seeker: __sdk::TableAppliedDiff<'r, MeetingStoneSeeker>, game_melee_attack: __sdk::TableAppliedDiff<'r, MeleeAttack>, game_melee_schedule: __sdk::TableAppliedDiff<'r, MeleeSchedule>, @@ -13851,6 +13887,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_meeting_stone_party, event, ); + callbacks.invoke_table_row_callbacks::( + "game_meeting_stone_reminder_schedule", + &self.game_meeting_stone_reminder_schedule, + event, + ); callbacks.invoke_table_row_callbacks::( "game_meeting_stone_seeker", &self.game_meeting_stone_seeker, @@ -15195,6 +15236,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_map_region_table::register_table(client_cache); game_meeting_stone_table::register_table(client_cache); game_meeting_stone_party_table::register_table(client_cache); + game_meeting_stone_reminder_schedule_table::register_table(client_cache); game_meeting_stone_seeker_table::register_table(client_cache); game_melee_attack_table::register_table(client_cache); game_melee_schedule_table::register_table(client_cache); @@ -15481,6 +15523,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_map_region", "game_meeting_stone", "game_meeting_stone_party", + "game_meeting_stone_reminder_schedule", "game_meeting_stone_seeker", "game_melee_attack", "game_melee_schedule", diff --git a/gateway/src/stdb/bindings/remind_queued_parties_reducer.rs b/gateway/src/stdb/bindings/remind_queued_parties_reducer.rs new file mode 100644 index 000000000..6a489bb2a --- /dev/null +++ b/gateway/src/stdb/bindings/remind_queued_parties_reducer.rs @@ -0,0 +1,70 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::meeting_stone_reminder_schedule_type::MeetingStoneReminderSchedule; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct RemindQueuedPartiesArgs { + pub schedule: MeetingStoneReminderSchedule, +} + +impl From for super::Reducer { + fn from(args: RemindQueuedPartiesArgs) -> Self { + Self::RemindQueuedParties { + schedule: args.schedule, + } + } +} + +impl __sdk::InModule for RemindQueuedPartiesArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `remind_queued_parties`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait remind_queued_parties { + /// Request that the remote module invoke the reducer `remind_queued_parties` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`remind_queued_parties:remind_queued_parties_then`] to run a callback after the reducer completes. + fn remind_queued_parties(&self, schedule: MeetingStoneReminderSchedule) -> __sdk::Result<()> { + self.remind_queued_parties_then(schedule, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `remind_queued_parties` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn remind_queued_parties_then( + &self, + schedule: MeetingStoneReminderSchedule, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl remind_queued_parties for super::RemoteReducers { + fn remind_queued_parties_then( + &self, + schedule: MeetingStoneReminderSchedule, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(RemindQueuedPartiesArgs { schedule }, callback) + } +} diff --git a/module/src/debug/repair.rs b/module/src/debug/repair.rs index 653788822..d6beabe67 100644 --- a/module/src/debug/repair.rs +++ b/module/src/debug/repair.rs @@ -288,6 +288,10 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { // only interval. Absent, weather silently stops advancing. crate::weather::rearm_weather_schedule(ctx); + // Re-arm the 5 s Meeting Stone reminder tick, for the weather roll's reason. Absent, a queued + // Party never hears IN_PROGRESS. + crate::meeting_stone::rearm_reminder_schedule(ctx); + // Re-tag legacy auction mail (plain English subject, Character sender) to the vanilla // AuctionHouse sender and machine subject — see `auction::repair_legacy_auction_mail`, which // runs at most once per database on its own marker. MUST stay ordered before any Mail Timer diff --git a/module/src/group.rs b/module/src/group.rs index d5b258acc..f58157c0c 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -170,7 +170,7 @@ crate::character_owned!(not_transported, fn sweep_transfer_game_group_member_par // leader-transfer/disband logic a voluntary leave uses — never a bare row delete, which would // orphan leadership or leave a 1-member group alive. crate::character_owned!(delete, fn sweep_delete_game_group_member(ctx, character_guid) { - remove_member(ctx, character_guid); + remove_member(ctx, character_guid, Departure::Left); }); crate::character_owned!(restamp, fn sweep_restamp_game_group_member(ctx, character_guid, identity) { let members = ctx.db.game_group_member(); @@ -1373,13 +1373,13 @@ fn invite_core_on( /// The identity-free accept core: shared by `gw::gw_accept_group_invite` and any server-driven /// acceptor (a playerbot's auto-accept hook calls this with the bot's guid). pub(crate) fn accept_invite_for(ctx: &ReducerContext, acceptor_guid: u64) -> Result<(), String> { - if crate::helpers::character_by_guid(ctx, acceptor_guid) - .is_some_and(|character| !character.online) - { + let acceptor = crate::helpers::character_by_guid(ctx, acceptor_guid); + if acceptor.as_ref().is_some_and(|character| !character.online) { crate::sessionless::group_action_gate(ctx, acceptor_guid) .map_err(|refusal| refused(refusal, "session-less group acceptance"))?; } - accept_invite_on(ctx, Plane::Shard, acceptor_guid).map_err(|error| { + let class = acceptor.map_or(0, |character| character.class); + accept_invite_on(ctx, acceptor_guid, class).map_err(|error| { group_op_error( error, &format!("{acceptor_guid} could not accept its invite"), @@ -1397,10 +1397,12 @@ fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool .any(|invite| invite.inviter_guid == character_guid && invite.group_id == 0) } +/// Consume `acceptor_guid`'s pending invite and join its Group through [`join_group`]. `class` is +/// the acceptor's, for the Seeker row a queued Party gains; 0 when unknown. fn accept_invite_on( ctx: &ReducerContext, - plane: Plane, acceptor_guid: u64, + class: u8, ) -> Result<(), GroupOpError> { let invites = ctx.db.game_group_invite(); let invite = invites @@ -1409,110 +1411,148 @@ fn accept_invite_on( .next() .ok_or(GroupRefusal::NoPendingInvite)?; invites.id().delete(invite.id); - if checked_group_membership(ctx, acceptor_guid)?.is_some() { + // cmangos joins the invite's Group whatever the inviter did since: left, removed or demoted + // (cm:GroupHandler.cpp:185-227). An invite without a Group forms the inviter's. + let target = match invite.group_id { + 0 => JoinTarget::NewParty { + leader_guid: invite.inviter_guid, + }, + group_id => JoinTarget::Group(group_id), + }; + let joined = join_group(ctx, acceptor_guid, target)?; + if joined.formed { + crate::meeting_stone::party_joined(ctx, joined.group_id, invite.inviter_guid, 0) + .map_err(GroupOpError::Invariant)?; + } + crate::meeting_stone::party_joined(ctx, joined.group_id, acceptor_guid, class) + .map_err(GroupOpError::Invariant) +} + +/// Which Group [`join_group`] adds a Character to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JoinTarget { + /// An existing Group. + Group(u64), + /// A Party this join forms. The leader joins it first. + NewParty { leader_guid: u64 }, +} + +/// The Group [`join_group`] added a Character to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct JoinedGroup { + pub(crate) group_id: u64, + /// The join formed this Party. + pub(crate) formed: bool, +} + +/// The one way a Character joins a Group: an accepted invite and a Stone Add both come here. +/// +/// An existing Group must exist and have room for its kind; a Raid joiner takes a free Raid Slot. +/// A new Party starts with Vanilla's defaults, group loot at Uncommon, and its leader's other pending +/// invites now join it. Every member gets the roster list. The joiner, and a new Party's leader, get +/// their Instance Removal reconciled, so joining the Group that owns the instance cancels the +/// countdown (cm:Group.cpp:880-885). +/// +/// A member row's `owner_identity` is the local Character row's identity when this database holds +/// one, else zero: Realm-core holds no Character rows, and each World Shard mirror re-derives it. +pub(crate) fn join_group( + ctx: &ReducerContext, + joiner_guid: u64, + target: JoinTarget, +) -> Result { + if checked_group_membership(ctx, joiner_guid)?.is_some() { return Err(GroupRefusal::AlreadyInGroup.into()); } - let inviter_guid = invite.inviter_guid; let members = ctx.db.game_group_member(); - let (group_id, slot) = if invite.group_id != 0 { - // cmangos joins the invite's Group whatever the inviter did since: left, removed or - // demoted (cm:GroupHandler.cpp:185-227). A Group that is gone ends the invite. Member - // rows that still name a missing Group are broken state, not a lapsed invite. - let current = members_of(ctx, invite.group_id); - let Some(group) = ctx.db.game_group().group_id().find(invite.group_id) else { - if current.is_empty() { - return Err(GroupRefusal::NoPendingInvite.into()); + let (joined, slot) = match target { + JoinTarget::Group(group_id) => { + let current = members_of(ctx, group_id); + let Some(group) = ctx.db.game_group().group_id().find(group_id) else { + // A Group that is gone ends the invite. Member rows that still name a missing + // Group are broken state, not a lapsed invite. + if current.is_empty() { + return Err(GroupRefusal::NoPendingInvite.into()); + } + return Err(GroupOpError::Invariant(format!( + "members point to missing group {group_id}" + ))); + }; + let kind = group_kind_of(&group); + if !has_room(kind, current.len()) { + return Err(GroupRefusal::GroupFull.into()); } - return Err(GroupOpError::Invariant(format!( - "members point to missing group {}", - invite.group_id - ))); - }; - let kind = group_kind_of(&group); - if !has_room(kind, current.len()) { - return Err(GroupRefusal::GroupFull.into()); - } - let slot = match kind { - GroupKind::Party => RaidSlot::default(), - GroupKind::Raid => RaidSlot::for_raid_joiner(current.iter().map(raid_slot_of)) - .ok_or(GroupRefusal::GroupFull)?, - }; - (group.group_id, slot) - } else { - // The inviter had no Group. If it has joined one since, this invite speaks for nobody. - if checked_group_membership(ctx, inviter_guid)?.is_some() { - return Err(GroupRefusal::InviterUnavailable.into()); + let slot = match kind { + GroupKind::Party => RaidSlot::default(), + GroupKind::Raid => RaidSlot::for_raid_joiner(current.iter().map(raid_slot_of)) + .ok_or(GroupRefusal::GroupFull)?, + }; + let joined = JoinedGroup { + group_id, + formed: false, + }; + (joined, slot) } - // The member row's `owner_identity` is the SHARD's binding for that character. On - // realm-core there is no such binding to read (identities are per-database), so the - // directory plane stores ZERO and each shard's mirror re-derives its own, which is what - // `player_login`'s restamp does for every other character-owned row. - let inviter_identity = match plane { - Plane::Shard => Some( - ctx.db - .game_character() - .guid() - .find(inviter_guid) - .ok_or(GroupRefusal::InviterUnavailable)? - .owner_identity, - ), - Plane::RealmCore => None, - }; - // First acceptance forms the group: the inviter leads and joins it here. - let group = ctx.db.game_group().insert(Group { - group_id: 0, - leader_guid: inviter_guid, - // Vanilla's real default for a freshly-formed party (work-item 187): GROUP LOOT at - // Uncommon threshold, no master, cursor at 0. - loot_method: loot_method::GROUP, - loot_threshold: 2, - rr_cursor: 0, - master_looter_guid: 0, - group_type: GroupKind::Party.wire(), - }); - members.insert(GroupMember { - id: 0, - group_id: group.group_id, - character_guid: inviter_guid, - owner_identity: crate::helpers::event_recipient_identity(inviter_identity), - raid_slot: RaidSlot::default().wire(), - }); - // The inviter's other pending invites now join the Group this accept formed. - let pending: Vec = invites - .iter() - .filter(|other| other.inviter_guid == inviter_guid && other.group_id == 0) - .collect(); - for other in pending { - invites.id().update(GroupInvite { + JoinTarget::NewParty { leader_guid } => { + // A leader that has joined a Group since speaks for nobody. + if checked_group_membership(ctx, leader_guid)?.is_some() { + return Err(GroupRefusal::InviterUnavailable.into()); + } + let group = ctx.db.game_group().insert(Group { + group_id: 0, + leader_guid, + loot_method: loot_method::GROUP, + loot_threshold: 2, + rr_cursor: 0, + master_looter_guid: 0, + group_type: GroupKind::Party.wire(), + }); + members.insert(GroupMember { + id: 0, group_id: group.group_id, - ..other + character_guid: leader_guid, + owner_identity: member_identity(ctx, leader_guid), + raid_slot: RaidSlot::default().wire(), }); + let invites = ctx.db.game_group_invite(); + let pending: Vec = invites + .iter() + .filter(|other| other.inviter_guid == leader_guid && other.group_id == 0) + .collect(); + for other in pending { + invites.id().update(GroupInvite { + group_id: group.group_id, + ..other + }); + } + let joined = JoinedGroup { + group_id: group.group_id, + formed: true, + }; + (joined, RaidSlot::default()) } - (group.group_id, RaidSlot::default()) - }; - let acceptor_identity = match plane { - Plane::Shard => ctx - .db - .game_character() - .guid() - .find(acceptor_guid) - .map(|c| c.owner_identity) - .ok_or(GroupRefusal::NoSuchPlayer)?, - Plane::RealmCore => Identity::ZERO, }; members.insert(GroupMember { id: 0, - group_id, - character_guid: acceptor_guid, - owner_identity: acceptor_identity, + group_id: joined.group_id, + character_guid: joiner_guid, + owner_identity: member_identity(ctx, joiner_guid), raid_slot: slot.wire(), }); - push_list_to_all(ctx, group_id); - // Rejoining the Group that owns the instance cancels the countdown (cm:Group.cpp:880-885). - // The inviter is new to a Group this accept forms. - crate::instance::reconcile_instance_removal(ctx, acceptor_guid); - crate::instance::reconcile_instance_removal(ctx, inviter_guid); - Ok(()) + push_list_to_all(ctx, joined.group_id); + crate::instance::reconcile_instance_removal(ctx, joiner_guid); + if let JoinTarget::NewParty { leader_guid } = target { + crate::instance::reconcile_instance_removal(ctx, leader_guid); + } + Ok(joined) +} + +/// A member row's `owner_identity`: this database's binding for the Character, or zero where it +/// holds no Character row. +fn member_identity(ctx: &ReducerContext, character_guid: u64) -> Identity { + crate::helpers::event_recipient_identity( + crate::helpers::character_by_guid(ctx, character_guid) + .map(|character| character.owner_identity), + ) } /// The identity-free decline core: the body `group_decline` used to inline, so the realm-core @@ -1559,7 +1599,7 @@ fn leave_group_on(ctx: &ReducerContext, leaver_guid: u64, cause: u8) -> Result<( if cause == leave_cause::CHARACTER_DELETED { delete_target_icons_on(ctx, leaver_guid); } - remove_member(ctx, leaver_guid); + remove_member(ctx, leaver_guid, Departure::Left); Ok(()) } @@ -1597,7 +1637,7 @@ fn uninvite_on( if target_guid == group.leader_guid { return Err(GroupRefusal::NotLeader.into()); } - remove_member(ctx, target_guid); + remove_member(ctx, target_guid, Departure::Kicked); Ok(()) } @@ -1677,6 +1717,13 @@ fn raid_convert_on(ctx: &ReducerContext, actor_guid: u64) -> Result { + let mut leader_changed = false; if let Some(mut group) = group { if group.leader_guid != new_leader { group.leader_guid = new_leader; ctx.db.game_group().group_id().update(group); announce_leader(ctx, group_id, new_leader); + leader_changed = true; } } + crate::meeting_stone::party_left( + ctx, + crate::meeting_stone::PartyDeparture { + group_id, + character_guid, + departure, + leader_changed, + }, + ) } - } + }; push_list_to_all(ctx, group_id); crate::instance::reconcile_instance_removal(ctx, character_guid); + // `remove_member` has no failure path: a delete sweep runs it. A broken relationship stops + // the pass and is logged; the removal itself stands. + if let Some(bucket) = rematch { + if let Err(reason) = crate::meeting_stone::match_bucket(ctx, bucket) { + spacetimedb::log::error!("meeting stone pass after {character_guid} left: {reason}"); + } + } } /// Tell every member who leads now. `SMSG_GROUP_SET_LEADER` carries only a name, which the Gateway @@ -2011,7 +2087,8 @@ pub fn realm_group_op( realm_op::INVITE => { invite_core_on(ctx, Plane::RealmCore, actor_guid, target_guid).map(|()| Unchanged) } - realm_op::ACCEPT => accept_invite_on(ctx, Plane::RealmCore, actor_guid).map(|()| Changed), + // Realm-core holds no Character rows, so the Gateway conveys the acceptor's class. + realm_op::ACCEPT => accept_invite_on(ctx, actor_guid, arg_a).map(|()| Changed), realm_op::DECLINE => decline_invite_on(ctx, actor_guid).map(|()| Unchanged), realm_op::LEAVE => leave_group_on(ctx, actor_guid, arg_a).map(|()| Changed), realm_op::UNINVITE => uninvite_on(ctx, actor_guid, target_guid).map(|()| Changed), @@ -2099,7 +2176,14 @@ fn realm_op_groups( .collect() } -fn advance_group_revision(ctx: &ReducerContext, group_id: u64, was_active: bool, active: bool) { +/// Advance `group_id`'s Roster Revision. `was_active` says whether the Group existed before this +/// transaction, which sets the first revision of a Group that has no row yet. +pub(crate) fn advance_group_revision( + ctx: &ReducerContext, + group_id: u64, + was_active: bool, + active: bool, +) { let table = ctx.db.game_group_roster_revision(); let current = table.group_id().find(group_id); let revision = next_group_revision(current.as_ref().map(|row| row.revision), was_active); @@ -3506,7 +3590,7 @@ mod tests { ), ( "realm_op::ACCEPT =>", - "accept_invite_on(ctx, Plane::RealmCore, actor_guid)", + "accept_invite_on(ctx, actor_guid, arg_a)", ), ("realm_op::DECLINE =>", "decline_invite_on(ctx, actor_guid)"), ( diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 4dc7f7cca..91d34912e 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -549,7 +549,11 @@ pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> LOOT_TAG_FIXTURE_CHARACTER_E, LiveLootTagEligibility::Foreign, )?; - crate::group::remove_member(ctx, LOOT_TAG_FIXTURE_CHARACTER_C); + crate::group::remove_member( + ctx, + LOOT_TAG_FIXTURE_CHARACTER_C, + crate::group::Departure::Left, + ); members.insert(crate::GroupMember { id: 0, group_id: group.group_id, @@ -588,15 +592,27 @@ pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> let departed_tagger = fixture_creature_guid(14); insert_fixture_entity(ctx, &origin, departed_tagger, base_x, false, 0); crate::threat::add_threat(ctx, departed_tagger, LOOT_TAG_FIXTURE_CHARACTER_A, 10); - crate::group::remove_member(ctx, LOOT_TAG_FIXTURE_CHARACTER_A); + crate::group::remove_member( + ctx, + LOOT_TAG_FIXTURE_CHARACTER_A, + crate::group::Departure::Left, + ); expect_live_loot_tag_eligibility( ctx, departed_tagger, LOOT_TAG_FIXTURE_CHARACTER_E, LiveLootTagEligibility::Available, )?; - crate::group::remove_member(ctx, LOOT_TAG_FIXTURE_CHARACTER_C); - crate::group::remove_member(ctx, LOOT_TAG_FIXTURE_CHARACTER_D); + crate::group::remove_member( + ctx, + LOOT_TAG_FIXTURE_CHARACTER_C, + crate::group::Departure::Left, + ); + crate::group::remove_member( + ctx, + LOOT_TAG_FIXTURE_CHARACTER_D, + crate::group::Departure::Left, + ); revoke_group_member(ctx, LOOT_TAG_FIXTURE_GROUP, LOOT_TAG_FIXTURE_CHARACTER_E); expect_live_loot_tag_eligibility( ctx, diff --git a/module/src/meeting_stone.rs b/module/src/meeting_stone.rs index 8f8c4094a..f1cf9fff8 100644 --- a/module/src/meeting_stone.rs +++ b/module/src/meeting_stone.rs @@ -13,8 +13,18 @@ //! //! A solo Seeker lasts as long as the Account Claim of its World Session: [`claim_ended`] runs //! where a claim is released, replaced or reaped. A party Seeker lasts as long as its membership. +//! +//! Matching runs inside the transaction that changes a bucket ([`match_bucket`]), not on a tick: +//! once cmangos's `bool` priority compare is read literally, its queue thread reduces to longest +//! wait per Open Role, so a tick would add latency and no behavior. Every Stone Add goes through +//! the party authority's join core, [`crate::group::join_group`], the same path an accepted invite +//! takes. The group cores call back here when a queued Party's roster changes. + +use std::collections::BTreeSet; -use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table, TimeDuration, Timestamp}; +use spacetimedb::{ + reducer, table, ReducerContext, ScheduleAt, SpacetimeType, Table, TimeDuration, Timestamp, +}; use lyracore_shared::constants::go_type; use lyracore_shared::faction::team_for_race; @@ -23,10 +33,16 @@ use lyracore_shared::meeting_stone::{ encode_queue, event_kind, queue_status, realm_op, MeetingStoneRefusal, }; +use crate::game_account_claim; +use crate::group::{Departure, JoinTarget}; + /// A queued party is reminded that the queue is still working every five minutes /// (cm:LFG/LFGMgr.cpp:55). const REMINDER_INTERVAL_MICROS: i64 = 5 * 60 * 1_000_000; +/// How often [`remind_queued_parties`] looks for a due reminder. +const REMINDER_TICK_MICROS: i64 = 5_000_000; + /// One Meeting Stone template's level range and dungeon area, from the dump's `data0`, `data1` and /// `data2`. Private: the Gateway reads it with the owner token. Imported with the `gameobjects` /// family; a Package does not author it. [static] @@ -74,6 +90,22 @@ pub struct MeetingStoneParty { pub next_reminder_at: Timestamp, } +/// Drives [`remind_queued_parties`]. One row; its `scheduled_at` is the cadence. +#[table(accessor = game_meeting_stone_reminder_schedule, scheduled(remind_queued_parties))] +pub struct MeetingStoneReminderSchedule { + #[primary_key] + #[auto_inc] + pub scheduled_id: u64, + pub scheduled_at: ScheduleAt, +} + +/// The Seekers that can meet: one dungeon area and one team. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Bucket { + pub(crate) area_id: u32, + pub(crate) team: u32, +} + /// What the Gateway read about one Seeker from whichever World Shard holds it. #[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub struct SeekerFacts { @@ -195,7 +227,8 @@ impl From for StoneOpError { /// /// Operator-gated because the actor is an argument: Realm-core has no live entity to derive it /// from. `area_id` and `seekers` are JOIN's: the stone's dungeon area, and the facts of the actor -/// or of every member of the party it leads. A missing member's class is 0. LEAVE reads neither. +/// or of every member of the party it leads. A missing member's class is 0. A JOIN ends with the +/// bucket pass, so it can form or fill Parties in the same transaction. LEAVE reads neither. /// More than [`GROUP_MAX_MEMBERS`] facts, facts that omit the actor, or an unknown op byte is an /// untagged error. #[reducer] @@ -262,27 +295,51 @@ fn join( .ok_or_else(|| { StoneOpError::Invalid(format!("seeker facts omit the actor {actor_guid}")) })?; - let team = team_for_race(actor.race); - let joined = encode_queue(area_id, queue_status::JOINED_QUEUE); - let Some((group, members)) = membership_of(ctx, actor_guid)? else { - upsert_seeker(ctx, actor_guid, area_id, team, actor.class, 0); - crate::group::push_event(ctx, actor_guid, event_kind::QUEUE, 0, joined); - return Ok(()); + let bucket = Bucket { + area_id, + team: team_for_race(actor.race), }; - party_join_gate(Membership { - leads: group.leader_guid == actor_guid, - kind: crate::group::group_kind_of(&group), - members: members.len(), - })?; - let group_id = group.group_id; + if let Some((group, members)) = membership_of(ctx, actor_guid)? { + party_join_gate(Membership { + leads: group.leader_guid == actor_guid, + kind: crate::group::group_kind_of(&group), + members: members.len(), + })?; + let class_of = |guid: u64| { + seekers + .iter() + .find(|facts| facts.character_guid == guid) + .map_or(0, |facts| facts.class) + }; + let members: Vec<(u64, u8)> = members + .iter() + .map(|member| (member.character_guid, class_of(member.character_guid))) + .collect(); + queue_party(ctx, group.group_id, bucket, &members).map_err(StoneOpError::Invalid)?; + } else { + upsert_seeker(ctx, actor_guid, bucket, actor.class, 0); + push_queue(ctx, actor_guid, area_id, queue_status::JOINED_QUEUE); + } + match_bucket(ctx, bucket).map_err(StoneOpError::Invalid) +} + +/// Queue `group_id` in `bucket` with `members` as its Seekers, `(guid, class)` each, and tell each +/// of them JOINED. Re-queueing a party replaces its area and wait time and drops the +/// Seeker rows of Characters that are no longer members. +fn queue_party( + ctx: &ReducerContext, + group_id: u64, + bucket: Bucket, + members: &[(u64, u8)], +) -> Result<(), String> { let next_reminder_at = ctx .timestamp .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) - .ok_or_else(|| StoneOpError::Invalid("reminder time overflow".into()))?; + .ok_or("reminder time overflow")?; let row = MeetingStoneParty { group_id, - area_id, - team, + area_id: bucket.area_id, + team: bucket.team, queued_at: ctx.timestamp, next_reminder_at, }; @@ -297,29 +354,16 @@ fn join( .by_group() .filter(group_id) .map(|seeker| seeker.character_guid) - .filter(|guid| !members.iter().any(|m| m.character_guid == *guid)) + .filter(|guid| !members.iter().any(|(member, _)| member == guid)) .collect(); for guid in departed { seeker_rows.character_guid().delete(guid); } - let class_of = |guid: u64| { - seekers - .iter() - .find(|facts| facts.character_guid == guid) - .map_or(0, |facts| facts.class) - }; - for member in &members { - let guid = member.character_guid; - upsert_seeker(ctx, guid, area_id, team, class_of(guid), group_id); + for &(guid, class) in members { + upsert_seeker(ctx, guid, bucket, class, group_id); } - for member in &members { - crate::group::push_event( - ctx, - member.character_guid, - event_kind::QUEUE, - 0, - joined.clone(), - ); + for &(guid, _) in members { + push_queue(ctx, guid, bucket.area_id, queue_status::JOINED_QUEUE); } Ok(()) } @@ -328,15 +372,14 @@ fn join( fn upsert_seeker( ctx: &ReducerContext, character_guid: u64, - area_id: u32, - team: u32, + bucket: Bucket, class: u8, group_id: u64, ) { let row = MeetingStoneSeeker { character_guid, - area_id, - team, + area_id: bucket.area_id, + team: bucket.team, class, group_id, queued_at: ctx.timestamp, @@ -349,16 +392,42 @@ fn upsert_seeker( } } +/// `SMSG_MEETINGSTONE_SETQUEUE(area_id, status)` to `recipient_guid`. +fn push_queue(ctx: &ReducerContext, recipient_guid: u64, area_id: u32, status: u8) { + crate::group::push_event( + ctx, + recipient_guid, + event_kind::QUEUE, + 0, + encode_queue(area_id, status), + ); +} + +/// An event of `kind` with no payload to each of `recipients`. +fn push_to_each(ctx: &ReducerContext, recipients: &[u64], kind: u8, other_guid: u64) { + for &recipient in recipients { + crate::group::push_event(ctx, recipient, kind, other_guid, String::new()); + } +} + +fn member_guids(ctx: &ReducerContext, group_id: u64) -> Vec { + let mut members = crate::group::members_of(ctx, group_id); + members.sort_unstable_by_key(|member| member.id); + members + .into_iter() + .map(|member| member.character_guid) + .collect() +} + /// cm:LFG/LFGHandler.cpp:86-110. A solo Seeker leaves with LEAVE_QUEUE. The leader of a queued /// party takes the whole party out, and every member gets LEAVE_QUEUE. Anyone else in a party only /// gets NONE. Nobody queued and no party: nothing happens. fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { - let left = encode_queue(0, queue_status::LEAVE_QUEUE); - let Some((group, members)) = membership_of(ctx, actor_guid)? else { + let Some((group, _)) = membership_of(ctx, actor_guid)? else { let seekers = ctx.db.game_meeting_stone_seeker(); if seekers.character_guid().find(actor_guid).is_some() { seekers.character_guid().delete(actor_guid); - crate::group::push_event(ctx, actor_guid, event_kind::QUEUE, 0, left); + push_queue(ctx, actor_guid, 0, queue_status::LEAVE_QUEUE); } return Ok(()); }; @@ -369,28 +438,31 @@ fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { .find(group.group_id) .is_some(); if group.leader_guid == actor_guid && queued { - dequeue_party_rows(ctx, group.group_id); - for member in &members { - crate::group::push_event( - ctx, - member.character_guid, - event_kind::QUEUE, - 0, - left.clone(), - ); - } + dequeue_party(ctx, group.group_id, queue_status::LEAVE_QUEUE); } else { - crate::group::push_event( - ctx, - actor_guid, - event_kind::QUEUE, - 0, - encode_queue(0, queue_status::NONE), - ); + push_queue(ctx, actor_guid, 0, queue_status::NONE); } Ok(()) } +/// Take a queued party out of the queue: every member gets `SETQUEUE(0, status)`, and the party +/// row and its Seeker rows go. Nothing happens for a party that is not queued. +pub(crate) fn dequeue_party(ctx: &ReducerContext, group_id: u64, status: u8) { + if ctx + .db + .game_meeting_stone_party() + .group_id() + .find(group_id) + .is_none() + { + return; + } + for guid in member_guids(ctx, group_id) { + push_queue(ctx, guid, 0, status); + } + dequeue_party_rows(ctx, group_id); +} + /// Delete a queued party's row and every Seeker row it holds. fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { ctx.db @@ -408,6 +480,525 @@ fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { } } +/// A queued party with five members is complete: every member gets COMPLETE, then +/// `SETQUEUE(0, NONE)`, and the party leaves the queue (cm:LFG/LFGQueue.cpp:420-454). +fn complete_party(ctx: &ReducerContext, group_id: u64) { + let members = member_guids(ctx, group_id); + push_to_each(ctx, &members, event_kind::COMPLETE, 0); + for &guid in &members { + push_queue(ctx, guid, 0, queue_status::NONE); + } + dequeue_party_rows(ctx, group_id); +} + +// ================================================================================================= +// Roles +// ================================================================================================= + +/// 1.12 class ids (ChrClasses.dbc). +mod class { + pub const WARRIOR: u8 = 1; + pub const PALADIN: u8 = 2; + pub const HUNTER: u8 = 3; + pub const ROGUE: u8 = 4; + pub const PRIEST: u8 = 5; + pub const SHAMAN: u8 = 7; + pub const MAGE: u8 = 8; + pub const WARLOCK: u8 = 9; + pub const DRUID: u8 = 11; +} + +/// A Party's dungeon roles, in the order a Party fills them (cm:Groups/Group.cpp:1594-1599). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Role { + Tank, + Healer, + Damage, +} + +const ROLE_ORDER: [Role; 3] = [Role::Tank, Role::Healer, Role::Damage]; + +/// A Party has three damage roles (cm:LFG/LFGMgr.h:51). +const DAMAGE_ROLES: u8 = 3; + +/// How well `class` fills `role`: 0 not at all, then 1 low, 2 normal, 3 high +/// (cm:LFG/LFGMgr.cpp:108-158). A class fills exactly the roles it has a priority for, which is +/// cmangos's class role table (cm:LFG/LFGMgr.cpp:91-106). An unknown class fills nothing. +fn role_priority(class: u8, role: Role) -> u8 { + use class::*; + const LOW: u8 = 1; + const NORMAL: u8 = 2; + const HIGH: u8 = 3; + match (role, class) { + (Role::Tank, WARRIOR) => HIGH, + (Role::Tank, DRUID | PALADIN) => NORMAL, + (Role::Healer, DRUID | PALADIN | PRIEST | SHAMAN) => HIGH, + (Role::Damage, HUNTER | MAGE | ROGUE | WARLOCK) => HIGH, + (Role::Damage, DRUID | PALADIN | SHAMAN | WARRIOR) => NORMAL, + (Role::Damage, PRIEST) => LOW, + _ => 0, + } +} + +fn fills(class: u8, role: Role) -> bool { + role_priority(class, role) > 0 +} + +/// A queued Party's Open Roles: which of its one tank, one healer and three damage roles no +/// member's class fills yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct OpenRoles { + tank: bool, + healer: bool, + damage: u8, +} + +impl OpenRoles { + const ALL: Self = Self { + tank: true, + healer: true, + damage: DAMAGE_ROLES, + }; + + fn is_open(self, role: Role) -> bool { + match role { + Role::Tank => self.tank, + Role::Healer => self.healer, + Role::Damage => self.damage > 0, + } + } + + fn fill(&mut self, role: Role) { + match role { + Role::Tank => self.tank = false, + Role::Healer => self.healer = false, + Role::Damage => self.damage = self.damage.saturating_sub(1), + } + } +} + +/// The Open Roles of a Party whose members have `classes`, in join order +/// (cm:Groups/Group.cpp:1589-1685). Each member takes the first of tank, healer and damage that its +/// class fills and that is still open, unless a member not yet placed has a higher priority for it. +/// A member left without a role holds a seat and fills nothing. +fn open_roles(classes: &[u8]) -> OpenRoles { + let mut open = OpenRoles::ALL; + let mut placed = vec![false; classes.len()]; + for (seat, &class) in classes.iter().enumerate() { + let outranked = |role: Role| { + classes.iter().enumerate().any(|(other, &other_class)| { + other != seat + && !placed[other] + && role_priority(other_class, role) > role_priority(class, role) + }) + }; + let taken = ROLE_ORDER + .into_iter() + .find(|&role| fills(class, role) && open.is_open(role) && !outranked(role)); + if let Some(role) = taken { + open.fill(role); + placed[seat] = true; + } + } + open +} + +/// The longest wait first, ties by guid. +fn wait_order(seeker: &MeetingStoneSeeker) -> (Timestamp, u64) { + (seeker.queued_at, seeker.character_guid) +} + +/// The index in `solos` of the next Stone Add for a Party with `open` roles: for tank, then healer, +/// then damage, if open, the longest-waiting Seeker whose class fills it. cm:LFG/LFGQueue.cpp:292-390 +/// compares class priority as a `bool`, so every capable Seeker ties on class and the wait decides. +fn next_stone_add(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { + ROLE_ORDER + .into_iter() + .filter(|&role| open.is_open(role)) + .find_map(|role| { + solos + .iter() + .enumerate() + .filter(|(_, seeker)| fills(seeker.class, role)) + .min_by_key(|(_, seeker)| wait_order(seeker)) + .map(|(index, _)| index) + }) +} + +/// Five solo Seekers in one bucket form a Party: the longest wait leads, the next joins, and the +/// bucket pass fills the rest (cm:LFG/LFGQueue.cpp:173-221). Both cores count Seekers across every +/// area and then block on the first Seeker's area; counting one bucket removes that block. +fn formation(solos: &[MeetingStoneSeeker]) -> Option<(&MeetingStoneSeeker, &MeetingStoneSeeker)> { + if solos.len() < GROUP_MAX_MEMBERS { + return None; + } + let mut waiting: Vec<&MeetingStoneSeeker> = solos.iter().collect(); + waiting.sort_unstable_by_key(|seeker| wait_order(seeker)); + Some((waiting[0], waiting[1])) +} + +// ================================================================================================= +// The bucket pass +// ================================================================================================= + +/// Match `bucket` until nothing changes. Each queued Party, oldest first, takes Stone Adds for its +/// Open Roles; then, while the bucket holds five solo Seekers, they form a Party and the Parties fill +/// again. Every Party this forms or changes advances its Roster Revision. +/// +/// A solo Seeker whose Account Claim is closed or expired leaves the queue silently when the pass +/// meets it. The Account Claim's end drops it too, but the lease reaper may take up to 15 s to close +/// an expired claim. An `Err` is a broken durable relationship. +pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), String> { + loop { + let (mut solos, parties) = read_bucket(ctx, bucket); + for party in &parties { + fill_party(ctx, party, &mut solos)?; + } + let Some((leader, member)) = formation(&solos) else { + return Ok(()); + }; + form_party(ctx, bucket, leader, member)?; + } +} + +/// The bucket's waiting solo Seekers, and its queued Parties oldest first. Reads the bucket through +/// its index. Drops a solo Seeker without a live Account Claim, and a stale solo row of a Character +/// that is in a Group. +fn read_bucket( + ctx: &ReducerContext, + bucket: Bucket, +) -> (Vec, Vec) { + let seekers = ctx.db.game_meeting_stone_seeker(); + let rows: Vec = seekers + .by_bucket() + .filter((bucket.area_id, bucket.team)) + .collect(); + let mut solos = Vec::new(); + let mut group_ids = BTreeSet::new(); + for seeker in rows { + if seeker.group_id != 0 { + group_ids.insert(seeker.group_id); + } else if has_live_claim(ctx, seeker.character_guid) + && crate::group::group_of(ctx, seeker.character_guid).is_none() + { + solos.push(seeker); + } else { + seekers.character_guid().delete(seeker.character_guid); + } + } + let mut parties: Vec = group_ids + .into_iter() + .filter_map(|group_id| ctx.db.game_meeting_stone_party().group_id().find(group_id)) + .collect(); + parties.sort_unstable_by_key(|party| (party.queued_at, party.group_id)); + (solos, parties) +} + +/// Whether `character_guid` has an open Account Claim before its deadline. +fn has_live_claim(ctx: &ReducerContext, character_guid: u64) -> bool { + let now = ctx.timestamp.to_micros_since_unix_epoch(); + ctx.db + .game_account_claim() + .by_character() + .filter(character_guid) + .any(|claim| !claim.closed && claim.expires_micros > now) +} + +/// Give `party` Stone Adds from `solos` while it has fewer than five members and a Seeker fits an +/// Open Role. Before each add the current members get MEMBER_ADDED; at five members the party is +/// complete (cm:LFG/LFGQueue.cpp:372-383). +fn fill_party( + ctx: &ReducerContext, + party: &MeetingStoneParty, + solos: &mut Vec, +) -> Result<(), String> { + let bucket = Bucket { + area_id: party.area_id, + team: party.team, + }; + loop { + let members = member_guids(ctx, party.group_id); + if members.len() >= GROUP_MAX_MEMBERS { + complete_party(ctx, party.group_id); + return Ok(()); + } + let classes: Vec = members + .iter() + .map(|&guid| party_class(ctx, guid, party.group_id)) + .collect(); + let Some(pick) = next_stone_add(open_roles(&classes), solos) else { + return Ok(()); + }; + let joiner = solos.remove(pick); + push_to_each( + ctx, + &members, + event_kind::MEMBER_ADDED, + joiner.character_guid, + ); + stone_add(ctx, &joiner, JoinTarget::Group(party.group_id))?; + upsert_seeker( + ctx, + joiner.character_guid, + bucket, + joiner.class, + party.group_id, + ); + } +} + +/// The class a queued party's Seeker row holds for `guid`, or 0. +fn party_class(ctx: &ReducerContext, guid: u64, group_id: u64) -> u8 { + ctx.db + .game_meeting_stone_seeker() + .character_guid() + .find(guid) + .filter(|seeker| seeker.group_id == group_id) + .map_or(0, |seeker| seeker.class) +} + +/// Form a Party of `leader` and `member` and queue it in `bucket`. The leader hears MEMBER_ADDED +/// before the Party exists, then both hear JOINED (cm:LFG/LFGQueue.cpp:190-218). +fn form_party( + ctx: &ReducerContext, + bucket: Bucket, + leader: &MeetingStoneSeeker, + member: &MeetingStoneSeeker, +) -> Result<(), String> { + crate::group::push_event( + ctx, + leader.character_guid, + event_kind::MEMBER_ADDED, + member.character_guid, + String::new(), + ); + ctx.db + .game_meeting_stone_seeker() + .character_guid() + .delete(leader.character_guid); + let group_id = stone_add( + ctx, + member, + JoinTarget::NewParty { + leader_guid: leader.character_guid, + }, + )?; + queue_party( + ctx, + group_id, + bucket, + &[ + (leader.character_guid, leader.class), + (member.character_guid, member.class), + ], + ) +} + +/// Take `joiner` out of the solo queue and add it to `target` through the join core, advancing the +/// Group's Roster Revision. Returns the Group id. +fn stone_add( + ctx: &ReducerContext, + joiner: &MeetingStoneSeeker, + target: JoinTarget, +) -> Result { + ctx.db + .game_meeting_stone_seeker() + .character_guid() + .delete(joiner.character_guid); + let joined = crate::group::join_group(ctx, joiner.character_guid, target).map_err(|error| { + format!( + "stone add of {} to {target:?} failed: {error:?}", + joiner.character_guid + ) + })?; + crate::group::advance_group_revision(ctx, joined.group_id, !joined.formed, true); + Ok(joined.group_id) +} + +// ================================================================================================= +// Group core hooks +// ================================================================================================= + +/// `character_guid` joined `group_id` through an accepted invite. A solo Seeker leaves the queue +/// and hears LEAVE_QUEUE unless the Party is queued for the same area (cm:Groups/Group.cpp:360-374). +/// A queued Party gains the Character as a Seeker with `class`; at five members it is complete, +/// below five the bucket pass runs, because the new member can move the Open Roles. +pub(crate) fn party_joined( + ctx: &ReducerContext, + group_id: u64, + character_guid: u64, + class: u8, +) -> Result<(), String> { + let party = ctx.db.game_meeting_stone_party().group_id().find(group_id); + let seekers = ctx.db.game_meeting_stone_seeker(); + if let Some(solo) = seekers + .character_guid() + .find(character_guid) + .filter(|seeker| seeker.group_id == 0) + { + seekers.character_guid().delete(character_guid); + if party.as_ref().map(|party| party.area_id) != Some(solo.area_id) { + push_queue(ctx, character_guid, 0, queue_status::LEAVE_QUEUE); + } + } + let Some(party) = party else { + return Ok(()); + }; + let bucket = Bucket { + area_id: party.area_id, + team: party.team, + }; + upsert_seeker(ctx, character_guid, bucket, class, group_id); + if crate::group::members_of(ctx, group_id).len() >= GROUP_MAX_MEMBERS { + complete_party(ctx, group_id); + return Ok(()); + } + match_bucket(ctx, bucket) +} + +/// A member left a Party that survives it. See [`party_left`]. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PartyDeparture { + pub(crate) group_id: u64, + pub(crate) character_guid: u64, + pub(crate) departure: Departure, + pub(crate) leader_changed: bool, +} + +/// A member left a queued Party that survives it. Runs after the member row is gone and before the +/// roster list. +/// +/// - Left, leader unchanged: the leaver hears NONE, the rest PARTY_MEMBER_LEFT_LFG, and the party +/// stays queued without the leaver (cm:Groups/Group.cpp:437-447). +/// - Left, leader changed: the leaver hears NONE and the party leaves the queue with LEAVE_QUEUE +/// (cm:Groups/Group.cpp:464-476). +/// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the party leaves the queue with +/// LEAVE_QUEUE. The kicked Character hears LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone with +/// its class and team (cm:Groups/Group.cpp:413-435). +/// +/// Returns the bucket to match when a role opened or a Character was queued. +pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Option { + let party = ctx + .db + .game_meeting_stone_party() + .group_id() + .find(departed.group_id)?; + let bucket = Bucket { + area_id: party.area_id, + team: party.team, + }; + let guid = departed.character_guid; + match departed.departure { + Departure::Kicked => { + let class = party_class(ctx, guid, departed.group_id); + for rest in member_guids(ctx, departed.group_id) { + push_queue( + ctx, + rest, + 0, + queue_status::PARTY_MEMBER_REMOVED_PARTY_REMOVED, + ); + } + dequeue_party(ctx, departed.group_id, queue_status::LEAVE_QUEUE); + push_queue( + ctx, + guid, + bucket.area_id, + queue_status::LOOKING_FOR_NEW_PARTY_IN_QUEUE, + ); + upsert_seeker(ctx, guid, bucket, class, 0); + push_queue(ctx, guid, bucket.area_id, queue_status::JOINED_QUEUE); + Some(bucket) + } + Departure::Left => { + push_queue(ctx, guid, 0, queue_status::NONE); + if departed.leader_changed { + dequeue_party(ctx, departed.group_id, queue_status::LEAVE_QUEUE); + return None; + } + for rest in member_guids(ctx, departed.group_id) { + push_queue( + ctx, + rest, + bucket.area_id, + queue_status::PARTY_MEMBER_LEFT_LFG, + ); + } + ctx.db + .game_meeting_stone_seeker() + .character_guid() + .delete(guid); + Some(bucket) + } + } +} + +/// A queued Party disbanded: every former member hears NONE and the party leaves the queue. Nobody +/// waits on alone (cm:Groups/Group.cpp:550-562). +pub(crate) fn party_disbanded(ctx: &ReducerContext, group_id: u64, former_members: &[u64]) { + if ctx + .db + .game_meeting_stone_party() + .group_id() + .find(group_id) + .is_none() + { + return; + } + for &guid in former_members { + push_queue(ctx, guid, 0, queue_status::NONE); + } + dequeue_party_rows(ctx, group_id); +} + +// ================================================================================================= +// Reminders +// ================================================================================================= + +/// Remind every queued Party whose reminder is due that the queue is still working: each member +/// gets IN_PROGRESS, and the next reminder is five minutes on (cm:LFG/LFGQueue.cpp:149-163). +/// Scheduler-only. The party table is small and holds only queued Parties, so this reads it whole. +#[reducer] +pub fn remind_queued_parties(ctx: &ReducerContext, _schedule: MeetingStoneReminderSchedule) { + if ctx.sender() != ctx.database_identity() { + return; + } + let Some(next_reminder_at) = ctx + .timestamp + .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) + else { + spacetimedb::log::error!("meeting stone reminder time overflow"); + return; + }; + let parties = ctx.db.game_meeting_stone_party(); + let due: Vec = parties + .iter() + .filter(|party| party.next_reminder_at <= ctx.timestamp) + .collect(); + for party in due { + let members = member_guids(ctx, party.group_id); + push_to_each(ctx, &members, event_kind::IN_PROGRESS, 0); + parties.group_id().update(MeetingStoneParty { + next_reminder_at, + ..party + }); + } +} + +/// Arm the reminder tick, leaving exactly one schedule row. `init` and `debug_repair_after_publish` +/// both call this, so the two paths arm one interval. +pub(crate) fn rearm_reminder_schedule(ctx: &ReducerContext) { + let schedule = ctx.db.game_meeting_stone_reminder_schedule(); + let stale: Vec = schedule.iter().map(|row| row.scheduled_id).collect(); + for scheduled_id in stale { + schedule.scheduled_id().delete(scheduled_id); + } + schedule.insert(MeetingStoneReminderSchedule { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(TimeDuration::from_micros(REMINDER_TICK_MICROS)), + }); +} + /// The Account Claim that admitted `character_guid` ended: drop its solo Seeker row, silently. The /// Character's World Session is gone, so no packet could reach it. A party Seeker row stays, because /// it ends with the party membership, not the session. @@ -436,6 +1027,140 @@ mod tests { assert!(!level_in_range(14, 15, 0)); } + /// cm:LFG/LFGMgr.cpp:91-158, row by row: the roles each class fills and its priority in each. + #[test] + fn the_role_table_is_the_cmangos_class_table() { + use class::*; + let row = |class: u8| ROLE_ORDER.map(|role| role_priority(class, role)); + assert_eq!(row(WARRIOR), [3, 0, 2]); + assert_eq!(row(PALADIN), [2, 3, 2]); + assert_eq!(row(HUNTER), [0, 0, 3]); + assert_eq!(row(ROGUE), [0, 0, 3]); + assert_eq!(row(PRIEST), [0, 3, 1]); + assert_eq!(row(SHAMAN), [0, 3, 2]); + assert_eq!(row(MAGE), [0, 0, 3]); + assert_eq!(row(WARLOCK), [0, 0, 3]); + assert_eq!(row(DRUID), [2, 3, 2]); + assert_eq!(row(0), [0, 0, 0], "an unknown class fills nothing"); + assert_eq!(row(6), [0, 0, 0], "class 6 does not exist in 1.12"); + } + + fn open(tank: bool, healer: bool, damage: u8) -> OpenRoles { + OpenRoles { + tank, + healer, + damage, + } + } + + /// The paladin outranks nobody for tank while the warrior waits, so it heals. + #[test] + fn a_paladin_then_a_warrior_leave_three_damage_roles_open() { + use class::*; + assert_eq!(open_roles(&[PALADIN, WARRIOR]), open(false, false, 3)); + } + + /// Equal priority does not outrank, so the first warrior tanks and the second deals damage. + #[test] + fn two_warriors_leave_the_healer_and_two_damage_roles_open() { + use class::*; + assert_eq!(open_roles(&[WARRIOR, WARRIOR]), open(false, true, 2)); + } + + #[test] + fn a_full_party_has_no_open_role() { + use class::*; + assert_eq!( + open_roles(&[WARRIOR, PRIEST, MAGE, ROGUE, HUNTER]), + open(false, false, 0) + ); + } + + #[test] + fn a_member_of_unknown_class_holds_a_seat_and_no_role() { + use class::*; + assert_eq!(open_roles(&[]), OpenRoles::ALL); + assert_eq!(open_roles(&[0, MAGE]), open(true, true, 2)); + } + + /// A fourth damage dealer finds every damage role taken and fills nothing. + #[test] + fn a_member_whose_roles_are_taken_fills_nothing() { + use class::*; + assert_eq!(open_roles(&[MAGE, MAGE, MAGE, MAGE]), open(true, true, 0)); + } + + fn seeker(character_guid: u64, class: u8, waited_secs: i64) -> MeetingStoneSeeker { + MeetingStoneSeeker { + character_guid, + area_id: 1581, + team: 469, + class, + group_id: 0, + queued_at: Timestamp::from_micros_since_unix_epoch( + 1_000_000_000_000 - waited_secs * 1_000_000, + ), + } + } + + fn picked(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { + next_stone_add(open, solos).map(|index| solos[index].character_guid) + } + + #[test] + fn the_next_stone_add_takes_tank_then_healer_then_damage() { + use class::*; + let solos = [ + seeker(1, MAGE, 90), + seeker(2, PRIEST, 10), + seeker(3, WARRIOR, 5), + ]; + assert_eq!(picked(OpenRoles::ALL, &solos), Some(3)); + assert_eq!(picked(open(false, true, 3), &solos), Some(2)); + assert_eq!(picked(open(false, false, 3), &solos), Some(1)); + assert_eq!(picked(open(false, false, 0), &solos), None); + } + + #[test] + fn the_longest_wait_takes_a_role_and_the_lower_guid_breaks_a_tie() { + use class::*; + let solos = [ + seeker(7, PRIEST, 30), + seeker(5, DRUID, 60), + seeker(4, SHAMAN, 60), + ]; + assert_eq!(picked(open(false, true, 0), &solos), Some(4)); + let solos = [ + seeker(7, PRIEST, 61), + seeker(5, DRUID, 60), + seeker(4, SHAMAN, 60), + ]; + assert_eq!(picked(open(false, true, 0), &solos), Some(7)); + } + + #[test] + fn a_seeker_of_unknown_class_is_never_a_stone_add() { + assert_eq!(picked(OpenRoles::ALL, &[seeker(1, 0, 600)]), None); + } + + #[test] + fn five_solo_seekers_form_a_party_led_by_the_longest_wait() { + use class::*; + let waiting = || { + vec![ + seeker(1, MAGE, 10), + seeker(2, MAGE, 50), + seeker(3, MAGE, 40), + seeker(4, MAGE, 40), + ] + }; + assert!(formation(&waiting()).is_none()); + let mut five = waiting(); + five.push(seeker(5, MAGE, 5)); + let (leader, member) = formation(&five).expect("five Seekers form a party"); + assert_eq!((leader.character_guid, member.character_guid), (2, 3)); + } + fn party(leads: bool, kind: GroupKind, members: usize) -> Membership { Membership { leads, diff --git a/module/src/seed.rs b/module/src/seed.rs index 854700bf3..fc5ec3ff9 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -19,7 +19,8 @@ //! 1-10-alpha consumable breadth, the mock-seed fixture kits (`seed/fixtures.rs`), enchant/ //! disenchant, talents, and the stacking-group starter set. //! 4. **`seed_scheduler_arming`**: the event reaper, instance reaper, creature movement/melee/aura/ -//! ground-AoE/weather ticks. Runs last so nothing fires against a half-seeded database. +//! ground-AoE/weather/Meeting Stone reminder ticks. Runs last so nothing fires against a +//! half-seeded database. //! //! Base-row constructors (`base_spell`/`base_effect`/`base_item`, `seed/fixtures.rs`) plus the //! `spell`/`effect` closures below keep the ~700 lines of `Spell`/`SpellEffect`/`ItemTemplate` @@ -1551,8 +1552,8 @@ pub(crate) fn repair_lesser_heal_target(ctx: &ReducerContext) -> u64 { } /// Stratum 4 — scheduler arming: the event reaper, instance reaper, creature movement tick, melee -/// swing tick, aura-expiry tick, ground-AoE damage tick, and weather roll. Runs last so nothing -/// fires against a half-seeded database. +/// swing tick, aura-expiry tick, ground-AoE damage tick, weather roll, and Meeting Stone reminder. +/// Runs last so nothing fires against a half-seeded database. fn seed_scheduler_arming(ctx: &ReducerContext) { // Schedule the event reaper every 1s. ctx.db @@ -1672,6 +1673,10 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { // already-migrated database where `init` does not re-run — one definition of the canonical row, // so the two paths cannot arm different intervals. crate::weather::rearm_weather_schedule(ctx); + + // Meeting Stone reminder tick every 5 s: each queued Party hears IN_PROGRESS every five minutes. + // Shared with `debug_repair_after_publish`, as the weather roll is. + crate::meeting_stone::rearm_reminder_schedule(ctx); } // Test/mock-seed fixture kits (Test PW:Shield, scenario quest/vendor/trainer, …) live in their diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index ff28a2efc..e34f94df0 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1180,7 +1180,7 @@ mod character_fence_tripwire { ("module/src/transfer/mod.rs", 4, "TRANSFER MACHINERY, all four reading the row the fence hides because that is the row they exist to move: `in_transit_instances` (the instance reaper's claim census — this call site exists BECAUSE the character is in transit, so a fenced reader would return None every time), `import_character`'s same-database re-partition, and `CtxShard`'s two sink methods — `has_character` (the plan probe, the materialise upsert AND the post-import durability PROOF that the destination copy exists before an in-row licenses deleting the source) and `with_character` (the escrow's own export read, taken after the freeze). #380 dropped this from 8 to 4: the other four were expected-shape STRING LITERALS inside exact-shape pins that quoted `CtxImportSink`'s and `helpers.rs`'s bodies verbatim. The `helpers.rs` ones are gone (all three chokepoints collapsed onto one `gate_by_guid`, whose mutants cargo-mutants CATCHES); the adapter's moved to `transfer/tests.rs`, which is why that file now has a budget of its own"), ("module/src/world.rs", 8, "player_login carries its own `is_in_transit` fence (the re-materialisation chokepoint, 2 reads); teleport_player, set_home, persist_entity and is_gm_character take a guid already resolved through a fenced entity (persist_entity is called BY begin_transfer); debug_delete_character keeps a raw existence probe so a missing character stays a no-op while an in-transit one is REFUSED, with the fence on the same expression; cascade_delete_character's raw row DELETE is the sweep that probe guards. `recall_to_home` is now FENCED (character_by_guid) — it was the one teleport_player caller needing no live entity"), // OPEN, not decided — spec puts group MEMBERSHIP state on realm-core, settled. - ("module/src/group.rs", 5, "OPEN: group_accept/group_uninvite/group_leave have a THIRD party mutate game_group_member; if #22 lands membership on realm-core these stop being a world shard's concern entirely. Guessing a verdict here is the mistake #30 exists to correct. `push_event`'s own `other_guid` lookup dropped from this count: it could never resolve INVITE's or DECLINE's name on Realm-core (no `game_character` rows there), so the gateway now resolves it from the World Shard caches at render time instead"), + ("module/src/group.rs", 3, "OPEN: group_accept/group_uninvite/group_leave have a THIRD party mutate game_group_member; if #22 lands membership on realm-core these stop being a world shard's concern entirely. Guessing a verdict here is the mistake #30 exists to correct. `push_event`'s own `other_guid` lookup dropped from this count: it could never resolve INVITE's or DECLINE's name on Realm-core (no `game_character` rows there), so the gateway now resolves it from the World Shard caches at render time instead. The join core's two member-identity reads now go through `character_by_guid`"), // REGENERATE at the destination — connection-derived state, never carried in the blob. ("module/src/auth.rs", 4, "REGENERATE: create_character's two name checks (NAME_IN_USE, pre-insert and the race-losing retry) predate any character; its guid-allocator seed scan (`legacy_guid_seed_now`, first-ever touch only) needs the whole table; delete_character keeps a raw find so NO_SUCH_CHAR/NOT_OWNER/CHAR_IN_TRANSIT stay three answers, with the fence on the next line. establish_session's owner_identity rebind and create_character's per-account cap check now route through the `by_account` index instead of a full scan (issue #390), so they no longer count here"), // READS, not writes: name/class/race/identity lookups that mutate nothing on the character. diff --git a/module/tests/meeting_stone_matching.rs b/module/tests/meeting_stone_matching.rs new file mode 100644 index 000000000..4b732c7fb --- /dev/null +++ b/module/tests/meeting_stone_matching.rs @@ -0,0 +1,1133 @@ +//! Meeting Stone matching on a private Standalone: the bucket pass forms and fills Parties by Open +//! Role, the group cores keep a queued Party's queue state in step with its roster, and the +//! reminder tick nudges a waiting Party. One database plays both the Home Shard and Realm-core, as +//! on a single-database realm. The hook tests run each case through `realm_group_op`, the +//! Realm-core plane, and through the `gw_group_*` reducers, the single-database plane. + +mod support; + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde_json::json; +use support::Standalone; + +// Fixture ids sit in 509_6000-509_6099. +/// The Deadmines (AreaTable 1581). +const DEADMINES: u32 = 1581; +/// The Stockade (AreaTable 717). +const STOCKADE: u32 = 717; +const STONE_AT: (f32, f32, f32) = (-8949.95, -132.493, 83.5312); + +const HUMAN: u8 = 1; +const ORC: u8 = 2; +const ALLIANCE: u32 = 469; +const HORDE: u32 = 67; + +const WARRIOR: u8 = 1; +const PRIEST: u8 = 5; +const MAGE: u8 = 8; + +// realm_meeting_stone_op bytes. +const JOIN: &str = "0"; + +// realm_group_op bytes. +const INVITE: &str = "0"; +const ACCEPT: &str = "1"; +const LEAVE: &str = "3"; +const UNINVITE: &str = "4"; +const RAID_CONVERT: &str = "6"; + +// SMSG_MEETINGSTONE_SETQUEUE status bytes, cm:LFG/LFGDefines.h:50-58. +const LEAVE_QUEUE: u8 = 0; +const JOINED_QUEUE: u8 = 1; +const PARTY_MEMBER_LEFT_LFG: u8 = 2; +const PARTY_MEMBER_REMOVED_PARTY_REMOVED: u8 = 3; +const LOOKING_FOR_NEW_PARTY_IN_QUEUE: u8 = 4; +const NONE: u8 = 5; + +/// `game_group_event` kinds: the roster list and the four Meeting Stone kinds. +const LIST: u8 = 1; +const QUEUE: u8 = 27; +const MEMBER_ADDED: u8 = 28; +const IN_PROGRESS: u8 = 29; +const COMPLETE: u8 = 30; + +fn start(name: &str) -> Standalone { + let mut realm = Standalone::start(name); + realm.publish_module(); + realm.assert_call("claim_operator", &[]); + realm.assert_call("install_guid_range", &["0"]); + // Group events are reaped by the shared GC. Disarming it keeps every row for the whole test, + // so each assertion reads the rows an action added by id. + realm.assert_sql("DELETE FROM game_event_reaper_schedule"); + realm +} + +/// A Character at the Deadmines stone with a live claim of its own Account. +fn stage(realm: &Standalone, guid: u64, race: u8, class: u8) { + let (x, y, z) = STONE_AT; + realm.assert_call( + "debug_stage_meeting_stone_character", + &[ + &guid.to_string(), + &guid.to_string(), + &race.to_string(), + &class.to_string(), + "18", + "0", + &x.to_string(), + &y.to_string(), + &z.to_string(), + ], + ); +} + +fn stage_group(realm: &Standalone, leader: u64, members: &[u64]) { + realm.assert_call( + "debug_stage_meeting_stone_group", + &[&leader.to_string(), &json!(members).to_string(), "false"], + ); +} + +/// The World Session actor the fixture claim admits. +fn session(guid: u64) -> String { + json!({ + "guid": guid, + "ownership": { "some": { "account_id": guid, "generation": 1, "request_nonce": guid } }, + }) + .to_string() +} + +fn stone_join(realm: &Standalone, actor: u64, area: u32, facts: &[(u64, u8, u8)]) { + let facts: Vec<_> = facts + .iter() + .map(|&(guid, race, class)| json!({ "character_guid": guid, "race": race, "class": class })) + .collect(); + realm.assert_call( + "realm_meeting_stone_op", + &[ + JOIN, + &session(actor), + &area.to_string(), + &json!(facts).to_string(), + ], + ); +} + +/// A human Character queues alone. +fn join_alone(realm: &Standalone, guid: u64, class: u8, area: u32) { + stone_join(realm, guid, area, &[(guid, HUMAN, class)]); +} + +/// A human leader queues its whole Party, `(guid, class)` per member. +fn join_party(realm: &Standalone, leader: u64, area: u32, members: &[(u64, u8)]) { + let facts: Vec<_> = members + .iter() + .map(|&(guid, class)| (guid, HUMAN, class)) + .collect(); + stone_join(realm, leader, area, &facts); +} + +fn group_op(realm: &Standalone, op: &str, actor: u64, target: u64, arg_a: u8, arg_b: u8) { + realm.assert_call( + "realm_group_op", + &[ + op, + &session(actor), + &target.to_string(), + &arg_a.to_string(), + &arg_b.to_string(), + "0", + ], + ); +} + +/// The two ways a Gateway drives the party authority. +#[derive(Clone, Copy, Debug)] +enum Plane { + /// `realm_group_op` on Realm-core. + RealmCore, + /// The `gw_group_*` reducers on a single database. + SingleDatabase, +} + +const PLANES: [Plane; 2] = [Plane::RealmCore, Plane::SingleDatabase]; + +impl Plane { + fn start(self, name: &str) -> Standalone { + start(&format!("{name}-{self:?}").to_lowercase()) + } + + fn invite(self, realm: &Standalone, actor: u64, target: u64) { + match self { + Plane::RealmCore => group_op(realm, INVITE, actor, target, 0, 0), + Plane::SingleDatabase => { + realm.assert_call("gw_group_invite", &[&session(actor), &target.to_string()]) + } + } + } + + /// The Gateway conveys class and race on Realm-core; the single database reads its own row. + fn accept(self, realm: &Standalone, guid: u64, class: u8) { + match self { + Plane::RealmCore => group_op(realm, ACCEPT, guid, 0, class, HUMAN), + Plane::SingleDatabase => realm.assert_call("gw_accept_group_invite", &[&session(guid)]), + } + } + + fn leave(self, realm: &Standalone, guid: u64) { + match self { + Plane::RealmCore => group_op(realm, LEAVE, guid, 0, 0, 0), + Plane::SingleDatabase => realm.assert_call("gw_group_leave", &[&session(guid)]), + } + } + + /// Character deletion: a CHARACTER_DELETED leave on Realm-core, the delete sweep on a single + /// database. + fn delete(self, realm: &Standalone, guid: u64) { + match self { + Plane::RealmCore => group_op(realm, LEAVE, guid, 0, 1, 0), + Plane::SingleDatabase => { + realm.assert_call("debug_delete_character", &[&guid.to_string()]) + } + } + } + + fn kick(self, realm: &Standalone, actor: u64, target: u64) { + match self { + Plane::RealmCore => group_op(realm, UNINVITE, actor, target, 0, 0), + Plane::SingleDatabase => { + realm.assert_call("gw_group_uninvite", &[&session(actor), &target.to_string()]) + } + } + } +} + +/// One `game_group_event` row the tests read. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Event { + List, + Queue(u32, u8), + MemberAdded(u64), + InProgress, + Complete, +} + +/// Every LIST and Meeting Stone event so far, as `(id, recipient, event)` in insert order. +fn events(realm: &Standalone) -> Vec<(u64, u64, Event)> { + let mut rows: Vec<_> = realm + .query_rows(&format!( + "SELECT id, recipient_guid, kind, other_guid, payload FROM game_group_event \ + WHERE kind = {LIST} OR kind >= {QUEUE}" + )) + .into_iter() + .map(|row| { + let kind: u8 = row["kind"].parse().unwrap(); + let event = match kind { + LIST => Event::List, + QUEUE => { + let payload = row["payload"].trim_matches('"'); + let (area, status) = payload.split_once(',').unwrap(); + Event::Queue(area.parse().unwrap(), status.parse().unwrap()) + } + MEMBER_ADDED => Event::MemberAdded(row["other_guid"].parse().unwrap()), + IN_PROGRESS => Event::InProgress, + COMPLETE => Event::Complete, + other => panic!("unexpected group event kind {other}"), + }; + ( + row["id"].parse().unwrap(), + row["recipient_guid"].parse().unwrap(), + event, + ) + }) + .collect(); + rows.sort_unstable_by_key(|(id, _, _)| *id); + rows +} + +/// The events `action` adds, per recipient in insert order. +struct Heard(BTreeMap>); + +impl Heard { + /// The Meeting Stone events `guid` heard. + fn stone(&self, guid: u64) -> Vec { + self.with_lists(guid) + .into_iter() + .filter(|event| *event != Event::List) + .collect() + } + + /// Every event `guid` heard, roster lists included. + fn with_lists(&self, guid: u64) -> Vec { + self.0.get(&guid).cloned().unwrap_or_default() + } +} + +fn heard(realm: &Standalone, action: impl FnOnce()) -> Heard { + let seen = events(realm).last().map_or(0, |(id, _, _)| *id); + action(); + let mut by_recipient: BTreeMap> = BTreeMap::new(); + for (_, recipient, event) in events(realm).into_iter().filter(|(id, _, _)| *id > seen) { + by_recipient.entry(recipient).or_default().push(event); + } + Heard(by_recipient) +} + +/// `(area_id, team, class, group_id)` of one Seeker row. +type SeekerRow = (u32, u32, u8, u64); + +fn seekers(realm: &Standalone) -> BTreeMap { + realm + .query_rows("SELECT * FROM game_meeting_stone_seeker") + .into_iter() + .map(|row| { + ( + row["character_guid"].parse().unwrap(), + ( + row["area_id"].parse().unwrap(), + row["team"].parse().unwrap(), + row["class"].parse().unwrap(), + row["group_id"].parse().unwrap(), + ), + ) + }) + .collect() +} + +fn queued_parties(realm: &Standalone) -> Vec { + let mut parties: Vec = realm + .query_rows("SELECT group_id FROM game_meeting_stone_party") + .into_iter() + .map(|row| row["group_id"].parse().unwrap()) + .collect(); + parties.sort_unstable(); + parties +} + +fn group_of(realm: &Standalone, guid: u64) -> Option { + realm + .query_rows(&format!( + "SELECT group_id FROM game_group_member WHERE character_guid = {guid}" + )) + .first() + .map(|row| row["group_id"].parse().unwrap()) +} + +/// `group_id`'s members in join order. +fn members(realm: &Standalone, group_id: u64) -> Vec { + let mut rows: Vec<(u64, u64)> = realm + .query_rows(&format!( + "SELECT id, character_guid FROM game_group_member WHERE group_id = {group_id}" + )) + .into_iter() + .map(|row| { + ( + row["id"].parse().unwrap(), + row["character_guid"].parse().unwrap(), + ) + }) + .collect(); + rows.sort_unstable(); + rows.into_iter().map(|(_, guid)| guid).collect() +} + +fn leader_of(realm: &Standalone, group_id: u64) -> u64 { + realm.query_rows(&format!( + "SELECT leader_guid FROM game_group WHERE group_id = {group_id}" + ))[0]["leader_guid"] + .parse() + .unwrap() +} + +fn revision(realm: &Standalone, group_id: u64) -> Option { + realm + .query_rows(&format!( + "SELECT revision FROM game_group_roster_revision WHERE group_id = {group_id}" + )) + .first() + .map(|row| row["revision"].parse().unwrap()) +} + +fn in_party(group_id: u64, class: u8) -> SeekerRow { + (DEADMINES, ALLIANCE, class, group_id) +} + +fn alone(area: u32, team: u32, class: u8) -> SeekerRow { + (area, team, class, 0) +} + +/// Criteria 1 and 4: five Seekers covering every role form one Party that completes at once. +/// Each Stone Add is announced to the members before it, never to the added Character. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn five_seekers_with_every_role_complete_a_party_at_once() { + let realm = start("stone-match-complete"); + const TANK: u64 = 509_6000; + const HEALER: u64 = 509_6001; + const DPS: [u64; 3] = [509_6002, 509_6003, 509_6004]; + let classes = [(TANK, WARRIOR), (HEALER, PRIEST)] + .into_iter() + .chain(DPS.map(|guid| (guid, MAGE))); + for (guid, class) in classes.clone() { + stage(&realm, guid, HUMAN, class); + } + for (guid, class) in classes.clone().take(4) { + join_alone(&realm, guid, class, DEADMINES); + } + assert!(queued_parties(&realm).is_empty(), "four Seekers wait"); + + let fifth = heard(&realm, || join_alone(&realm, DPS[2], MAGE, DEADMINES)); + + let group = group_of(&realm, TANK).expect("the longest wait leads a new party"); + assert_eq!(leader_of(&realm, group), TANK); + assert_eq!( + members(&realm, group), + [TANK, HEALER, DPS[0], DPS[1], DPS[2]] + ); + let done = [Event::Complete, Event::Queue(0, NONE)]; + let added = |guids: &[u64]| -> Vec { + guids.iter().map(|&guid| Event::MemberAdded(guid)).collect() + }; + let then = |mut first: Vec| { + first.extend(done.clone()); + first + }; + assert_eq!( + fifth.stone(TANK), + then( + [ + Event::MemberAdded(HEALER), + Event::Queue(DEADMINES, JOINED_QUEUE) + ] + .into_iter() + .chain(added(&DPS)) + .collect() + ) + ); + assert_eq!( + fifth.stone(HEALER), + then( + [Event::Queue(DEADMINES, JOINED_QUEUE)] + .into_iter() + .chain(added(&DPS)) + .collect() + ) + ); + assert_eq!(fifth.stone(DPS[0]), then(added(&DPS[1..]))); + assert_eq!(fifth.stone(DPS[1]), then(added(&DPS[2..]))); + assert_eq!( + fifth.stone(DPS[2]), + then(vec![Event::Queue(DEADMINES, JOINED_QUEUE)]) + ); + assert!(seekers(&realm).is_empty()); + assert!(queued_parties(&realm).is_empty()); +} + +/// Criterion 2: five mages form a party of three damage dealers that waits for a tank and a +/// healer. The other two mages keep waiting alone. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn five_mages_form_a_party_that_waits_for_a_tank_and_a_healer() { + let realm = start("stone-match-mages"); + let mages: Vec = (509_6010..509_6015).collect(); + for &guid in &mages { + stage(&realm, guid, HUMAN, MAGE); + join_alone(&realm, guid, MAGE, DEADMINES); + } + + let group = group_of(&realm, mages[0]).expect("five mages form a party"); + assert_eq!(members(&realm, group), mages[..3]); + assert_eq!(queued_parties(&realm), [group]); + assert_eq!( + seekers(&realm), + BTreeMap::from([ + (mages[0], in_party(group, MAGE)), + (mages[1], in_party(group, MAGE)), + (mages[2], in_party(group, MAGE)), + (mages[3], alone(DEADMINES, ALLIANCE, MAGE)), + (mages[4], alone(DEADMINES, ALLIANCE, MAGE)), + ]) + ); +} + +/// Criterion 3: a queued party with only its healer open takes the longest-waiting priest. The +/// second priest keeps waiting. The leader hears MEMBER_ADDED before the roster list it precedes. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_queued_party_takes_the_longest_waiting_healer() { + let realm = start("stone-match-healer"); + const LEADER: u64 = 509_6020; + const DPS: [u64; 3] = [509_6021, 509_6022, 509_6023]; + const FIRST_PRIEST: u64 = 509_6025; + const SECOND_PRIEST: u64 = 509_6024; + stage(&realm, LEADER, HUMAN, WARRIOR); + for guid in DPS { + stage(&realm, guid, HUMAN, MAGE); + } + stage_group(&realm, LEADER, &DPS); + let group = group_of(&realm, LEADER).unwrap(); + // The higher guid waits longer, so the wait decides and not the guid. + for priest in [FIRST_PRIEST, SECOND_PRIEST] { + stage(&realm, priest, HUMAN, PRIEST); + join_alone(&realm, priest, PRIEST, DEADMINES); + } + + let queued = heard(&realm, || { + join_party( + &realm, + LEADER, + DEADMINES, + &[ + (LEADER, WARRIOR), + (DPS[0], MAGE), + (DPS[1], MAGE), + (DPS[2], MAGE), + ], + ) + }); + + assert_eq!(group_of(&realm, FIRST_PRIEST), Some(group)); + assert_eq!(group_of(&realm, SECOND_PRIEST), None); + assert_eq!( + queued.with_lists(LEADER), + [ + Event::Queue(DEADMINES, JOINED_QUEUE), + Event::MemberAdded(FIRST_PRIEST), + Event::List, + Event::Complete, + Event::Queue(0, NONE), + ] + ); + assert_eq!( + queued.stone(FIRST_PRIEST), + [Event::Complete, Event::Queue(0, NONE)] + ); + assert!(queued.stone(SECOND_PRIEST).is_empty()); + assert_eq!( + seekers(&realm), + BTreeMap::from([(SECOND_PRIEST, alone(DEADMINES, ALLIANCE, PRIEST))]) + ); +} + +/// Criterion 5: a team or an area apart, Seekers never meet, neither to form a party nor to fill one. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn seekers_of_another_team_or_area_never_meet() { + let realm = start("stone-match-buckets"); + let mages: Vec = (509_6030..509_6034).collect(); + const HORDE_MAGE: u64 = 509_6034; + const STOCKADE_MAGE: u64 = 509_6035; + const FIFTH_MAGE: u64 = 509_6036; + const HORDE_PRIEST: u64 = 509_6037; + const STOCKADE_PRIEST: u64 = 509_6038; + for &guid in &mages { + stage(&realm, guid, HUMAN, MAGE); + join_alone(&realm, guid, MAGE, DEADMINES); + } + stage(&realm, HORDE_MAGE, ORC, MAGE); + stone_join(&realm, HORDE_MAGE, DEADMINES, &[(HORDE_MAGE, ORC, MAGE)]); + stage(&realm, STOCKADE_MAGE, HUMAN, MAGE); + join_alone(&realm, STOCKADE_MAGE, MAGE, STOCKADE); + assert!( + queued_parties(&realm).is_empty(), + "four Seekers per bucket form nothing" + ); + + stage(&realm, FIFTH_MAGE, HUMAN, MAGE); + join_alone(&realm, FIFTH_MAGE, MAGE, DEADMINES); + let group = group_of(&realm, mages[0]).expect("five Alliance Seekers form a party"); + assert_eq!(members(&realm, group), mages[..3]); + + // The party has its tank and healer open; a priest a team or an area away does not take one. + stage(&realm, HORDE_PRIEST, ORC, PRIEST); + stone_join( + &realm, + HORDE_PRIEST, + DEADMINES, + &[(HORDE_PRIEST, ORC, PRIEST)], + ); + stage(&realm, STOCKADE_PRIEST, HUMAN, PRIEST); + join_alone(&realm, STOCKADE_PRIEST, PRIEST, STOCKADE); + + assert_eq!(members(&realm, group), mages[..3]); + let waiting: BTreeMap = seekers(&realm) + .into_iter() + .filter(|(_, row)| row.3 == 0) + .collect(); + assert_eq!( + waiting, + BTreeMap::from([ + (mages[3], alone(DEADMINES, ALLIANCE, MAGE)), + (HORDE_MAGE, alone(DEADMINES, HORDE, MAGE)), + (STOCKADE_MAGE, alone(STOCKADE, ALLIANCE, MAGE)), + (FIFTH_MAGE, alone(DEADMINES, ALLIANCE, MAGE)), + (HORDE_PRIEST, alone(DEADMINES, HORDE, PRIEST)), + (STOCKADE_PRIEST, alone(STOCKADE, ALLIANCE, PRIEST)), + ]) + ); +} + +/// Criterion 6: a Seeker whose claim expired before the lease reaper closed it is never added, and +/// the pass that meets it drops its row without a packet. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_seeker_without_a_live_claim_is_dropped_by_the_pass() { + let realm = start("stone-match-claims"); + // Without the lease reaper only the pass can notice the expired claim. + realm.assert_sql("DELETE FROM game_gateway_lease_reaper_schedule"); + let mages: Vec = (509_6040..509_6046).collect(); + let expired = mages[1]; + for &guid in &mages { + stage(&realm, guid, HUMAN, MAGE); + } + for &guid in &mages[..4] { + join_alone(&realm, guid, MAGE, DEADMINES); + } + realm.assert_sql(&format!( + "UPDATE game_account_claim SET expires_micros = 0 WHERE account_id = {expired}" + )); + + let fifth = heard(&realm, || join_alone(&realm, mages[4], MAGE, DEADMINES)); + assert!( + queued_parties(&realm).is_empty(), + "four live Seekers form nothing" + ); + assert!(!seekers(&realm).contains_key(&expired)); + assert!(fifth.stone(expired).is_empty()); + + join_alone(&realm, mages[5], MAGE, DEADMINES); + let group = group_of(&realm, mages[0]).expect("five live Seekers form a party"); + assert_eq!(members(&realm, group), [mages[0], mages[2], mages[3]]); + assert_eq!(group_of(&realm, expired), None); +} + +/// Criterion 7, leave and deletion with the leader staying: the leaver hears NONE, the rest hear +/// PARTY_MEMBER_LEFT_LFG, and the party stays queued and fills the role the leaver freed. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_member_who_leaves_keeps_the_party_queued_and_frees_a_role() { + for plane in PLANES { + let realm = plane.start("stone-hook-leave"); + const LEADER: u64 = 509_6050; + const LEAVER: u64 = 509_6051; + const DPS: [u64; 2] = [509_6052, 509_6053]; + const WAITING: u64 = 509_6054; + stage(&realm, LEADER, HUMAN, WARRIOR); + for guid in [LEAVER, DPS[0], DPS[1], WAITING] { + stage(&realm, guid, HUMAN, MAGE); + } + stage_group(&realm, LEADER, &[LEAVER, DPS[0], DPS[1]]); + let group = group_of(&realm, LEADER).unwrap(); + // A warrior and three mages leave only the healer open. + join_party( + &realm, + LEADER, + DEADMINES, + &[ + (LEADER, WARRIOR), + (LEAVER, MAGE), + (DPS[0], MAGE), + (DPS[1], MAGE), + ], + ); + join_alone(&realm, WAITING, MAGE, DEADMINES); + assert_eq!( + group_of(&realm, WAITING), + None, + "{plane:?}: no damage role is open" + ); + + let left = heard(&realm, || plane.leave(&realm, LEAVER)); + assert_eq!(left.stone(LEAVER), [Event::Queue(0, NONE)], "{plane:?}"); + const LEFT_LFG: Event = Event::Queue(DEADMINES, PARTY_MEMBER_LEFT_LFG); + assert_eq!( + left.stone(LEADER), + [LEFT_LFG, Event::MemberAdded(WAITING)], + "{plane:?}" + ); + assert_eq!(left.stone(WAITING), [], "{plane:?}"); + assert_eq!( + members(&realm, group), + [LEADER, DPS[0], DPS[1], WAITING], + "{plane:?}" + ); + + let deleted = heard(&realm, || plane.delete(&realm, DPS[0])); + assert_eq!(deleted.stone(DPS[0]), [Event::Queue(0, NONE)], "{plane:?}"); + for rest in [LEADER, DPS[1], WAITING] { + assert_eq!(deleted.stone(rest), [LEFT_LFG], "{plane:?}"); + } + assert_eq!(queued_parties(&realm), [group], "{plane:?}"); + assert_eq!( + seekers(&realm), + BTreeMap::from([ + (LEADER, in_party(group, WARRIOR)), + (DPS[1], in_party(group, MAGE)), + (WAITING, in_party(group, MAGE)), + ]), + "{plane:?}" + ); + } +} + +/// Criterion 7, leave with the leader changing: the party leaves the queue with LEAVE_QUEUE. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_leader_who_leaves_takes_the_party_out_of_the_queue() { + for plane in PLANES { + let realm = plane.start("stone-hook-leader-leaves"); + const LEADER: u64 = 509_6060; + const REST: [u64; 2] = [509_6061, 509_6062]; + for guid in [LEADER].into_iter().chain(REST) { + stage(&realm, guid, HUMAN, MAGE); + } + stage_group(&realm, LEADER, &REST); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, MAGE), (REST[0], MAGE), (REST[1], MAGE)], + ); + + let left = heard(&realm, || plane.leave(&realm, LEADER)); + assert_eq!(left.stone(LEADER), [Event::Queue(0, NONE)], "{plane:?}"); + for rest in REST { + assert_eq!( + left.stone(rest), + [Event::Queue(0, LEAVE_QUEUE)], + "{plane:?}" + ); + } + assert!(queued_parties(&realm).is_empty(), "{plane:?}"); + assert!(seekers(&realm).is_empty(), "{plane:?}"); + } +} + +/// Criterion 7, kick from a party that survives: the party leaves the queue, and the kicked +/// Character waits alone with its class and team. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_kicked_member_waits_alone_and_the_party_leaves_the_queue() { + for plane in PLANES { + let realm = plane.start("stone-hook-kick"); + const LEADER: u64 = 509_6065; + const KICKED: u64 = 509_6066; + const STAYS: u64 = 509_6067; + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, KICKED, HUMAN, PRIEST); + stage(&realm, STAYS, HUMAN, MAGE); + stage_group(&realm, LEADER, &[KICKED, STAYS]); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (KICKED, PRIEST), (STAYS, MAGE)], + ); + + let kicked = heard(&realm, || plane.kick(&realm, LEADER, KICKED)); + for rest in [LEADER, STAYS] { + assert_eq!( + kicked.stone(rest), + [ + Event::Queue(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED), + Event::Queue(0, LEAVE_QUEUE), + ], + "{plane:?}" + ); + } + assert_eq!( + kicked.stone(KICKED), + [ + Event::Queue(DEADMINES, LOOKING_FOR_NEW_PARTY_IN_QUEUE), + Event::Queue(DEADMINES, JOINED_QUEUE), + ], + "{plane:?}" + ); + assert!(queued_parties(&realm).is_empty(), "{plane:?}"); + assert_eq!( + seekers(&realm), + BTreeMap::from([(KICKED, alone(DEADMINES, ALLIANCE, PRIEST))]), + "{plane:?}" + ); + } +} + +/// Criterion 7, disband by a leave or a kick from two members: both hear NONE once, and nobody +/// waits on alone. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_disband_answers_every_former_member_with_none() { + for plane in PLANES { + let realm = plane.start("stone-hook-disband"); + const LEFT_PAIR: [u64; 2] = [509_6070, 509_6071]; + const KICKED_PAIR: [u64; 2] = [509_6072, 509_6073]; + for [leader, member] in [LEFT_PAIR, KICKED_PAIR] { + stage(&realm, leader, HUMAN, WARRIOR); + stage(&realm, member, HUMAN, PRIEST); + stage_group(&realm, leader, &[member]); + join_party( + &realm, + leader, + DEADMINES, + &[(leader, WARRIOR), (member, PRIEST)], + ); + } + + let left = heard(&realm, || plane.leave(&realm, LEFT_PAIR[1])); + let kicked = heard(&realm, || { + plane.kick(&realm, KICKED_PAIR[0], KICKED_PAIR[1]) + }); + for (heard, pair) in [(left, LEFT_PAIR), (kicked, KICKED_PAIR)] { + for guid in pair { + assert_eq!(heard.stone(guid), [Event::Queue(0, NONE)], "{plane:?}"); + } + } + assert!(queued_parties(&realm).is_empty(), "{plane:?}"); + assert!(seekers(&realm).is_empty(), "{plane:?}"); + } +} + +/// Criterion 7, accept: a solo Seeker who joins any party leaves the solo queue, told LEAVE_QUEUE +/// unless the party is queued for its area. A queued party gains it as a Seeker with its class and +/// completes at five members. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn accepting_an_invite_moves_a_solo_seeker_into_the_party() { + for plane in PLANES { + let realm = plane.start("stone-hook-accept"); + const LEADER: u64 = 509_6075; + const HEALER: u64 = 509_6076; + const DPS: u64 = 509_6077; + /// Waits at the Stockade. + const ELSEWHERE: u64 = 509_6078; + /// Waits at the Deadmines with a class the Gateway could not read, so no role fits it. + const UNREAD: u64 = 509_6079; + /// A solo Seeker whose own invite forms a new party. + const INVITER: u64 = 509_6080; + const INVITED: u64 = 509_6081; + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, HEALER, HUMAN, PRIEST); + for guid in [DPS, ELSEWHERE, UNREAD, INVITER, INVITED] { + stage(&realm, guid, HUMAN, MAGE); + } + join_alone(&realm, ELSEWHERE, MAGE, STOCKADE); + join_alone(&realm, UNREAD, 0, DEADMINES); + join_alone(&realm, INVITER, MAGE, STOCKADE); + stage_group(&realm, LEADER, &[HEALER, DPS]); + let group = group_of(&realm, LEADER).unwrap(); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (HEALER, PRIEST), (DPS, MAGE)], + ); + + plane.invite(&realm, LEADER, ELSEWHERE); + let other_area = heard(&realm, || plane.accept(&realm, ELSEWHERE, MAGE)); + assert_eq!( + other_area.stone(ELSEWHERE), + [Event::Queue(0, LEAVE_QUEUE)], + "{plane:?}" + ); + assert_eq!( + seekers(&realm).get(&ELSEWHERE), + Some(&in_party(group, MAGE)), + "{plane:?}" + ); + + plane.invite(&realm, LEADER, UNREAD); + let same_area = heard(&realm, || plane.accept(&realm, UNREAD, MAGE)); + for guid in [LEADER, HEALER, DPS, ELSEWHERE, UNREAD] { + assert_eq!( + same_area.stone(guid), + [Event::Complete, Event::Queue(0, NONE)], + "{plane:?}: the fifth member completes the party" + ); + } + assert!(queued_parties(&realm).is_empty(), "{plane:?}"); + + plane.invite(&realm, INVITER, INVITED); + let formed = heard(&realm, || plane.accept(&realm, INVITED, MAGE)); + assert_eq!( + formed.stone(INVITER), + [Event::Queue(0, LEAVE_QUEUE)], + "{plane:?}: the inviter joins the party its invite formed" + ); + assert_eq!(formed.stone(INVITED), [], "{plane:?}"); + assert!(seekers(&realm).is_empty(), "{plane:?}"); + } +} + +/// Criterion 7, raid convert: every member hears LEAVE_QUEUE before the raid list. The convert has +/// no single-database reducer; the Gateway sends it to the party authority on both realm shapes. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_queued_party_that_converts_to_a_raid_leaves_the_queue() { + let realm = start("stone-hook-raid-convert"); + const LEADER: u64 = 509_6085; + const MEMBER: u64 = 509_6086; + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, MEMBER, HUMAN, PRIEST); + stage_group(&realm, LEADER, &[MEMBER]); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (MEMBER, PRIEST)], + ); + + let converted = heard(&realm, || group_op(&realm, RAID_CONVERT, LEADER, 0, 0, 0)); + for guid in [LEADER, MEMBER] { + assert_eq!( + converted.with_lists(guid), + [Event::Queue(0, LEAVE_QUEUE), Event::List] + ); + } + assert!(queued_parties(&realm).is_empty()); + assert!(seekers(&realm).is_empty()); +} + +/// Criterion 8: a kicked Character re-queued alone fills a second queued party's open healer in the +/// same transaction. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_kicked_member_joins_a_second_queued_party_at_once() { + let realm = start("stone-match-kick-requeue"); + const FIRST_LEADER: u64 = 509_6090; + const KICKED: u64 = 509_6091; + const FIRST_DPS: u64 = 509_6092; + const SECOND_LEADER: u64 = 509_6093; + const SECOND_DPS: u64 = 509_6094; + stage(&realm, FIRST_LEADER, HUMAN, WARRIOR); + stage(&realm, KICKED, HUMAN, PRIEST); + stage(&realm, SECOND_LEADER, HUMAN, WARRIOR); + for guid in [FIRST_DPS, SECOND_DPS] { + stage(&realm, guid, HUMAN, MAGE); + } + stage_group(&realm, FIRST_LEADER, &[KICKED, FIRST_DPS]); + stage_group(&realm, SECOND_LEADER, &[SECOND_DPS]); + let second = group_of(&realm, SECOND_LEADER).unwrap(); + join_party( + &realm, + FIRST_LEADER, + DEADMINES, + &[(FIRST_LEADER, WARRIOR), (KICKED, PRIEST), (FIRST_DPS, MAGE)], + ); + join_party( + &realm, + SECOND_LEADER, + DEADMINES, + &[(SECOND_LEADER, WARRIOR), (SECOND_DPS, MAGE)], + ); + + let kicked = heard(&realm, || { + group_op(&realm, UNINVITE, FIRST_LEADER, KICKED, 0, 0) + }); + + assert_eq!(group_of(&realm, KICKED), Some(second)); + assert_eq!( + kicked.stone(KICKED), + [ + Event::Queue(DEADMINES, LOOKING_FOR_NEW_PARTY_IN_QUEUE), + Event::Queue(DEADMINES, JOINED_QUEUE), + ] + ); + for guid in [SECOND_LEADER, SECOND_DPS] { + assert_eq!(kicked.stone(guid), [Event::MemberAdded(KICKED)]); + } + assert_eq!(queued_parties(&realm), [second]); + assert_eq!(seekers(&realm)[&KICKED], in_party(second, PRIEST)); + assert!( + revision(&realm, second) > Some(1), + "a staged party starts at the implicit revision 1" + ); +} + +/// Criterion 9: the pass advances the Roster Revision of each party it forms and of each party a +/// Stone Add changes. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn every_party_the_pass_forms_or_fills_has_a_higher_roster_revision() { + let realm = start("stone-match-revisions"); + let mages: Vec = (509_6000..509_6005).collect(); + const PRIEST_SEEKER: u64 = 509_6005; + for &guid in &mages { + stage(&realm, guid, HUMAN, MAGE); + join_alone(&realm, guid, MAGE, DEADMINES); + } + let group = group_of(&realm, mages[0]).unwrap(); + let formed = revision(&realm, group).expect("a formed party has a Roster Revision"); + assert_eq!(formed, 2, "formed at 1, then one Stone Add"); + + stage(&realm, PRIEST_SEEKER, HUMAN, PRIEST); + join_alone(&realm, PRIEST_SEEKER, PRIEST, DEADMINES); + assert_eq!(group_of(&realm, PRIEST_SEEKER), Some(group)); + assert_eq!(revision(&realm, group), Some(formed + 1)); +} + +/// Criterion 10: a due reminder sends IN_PROGRESS to every member once and is due again five +/// minutes after it fired. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_due_reminder_tells_every_member_once() { + let realm = start("stone-match-reminder"); + const LEADER: u64 = 509_6000; + const MEMBER: u64 = 509_6001; + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, MEMBER, HUMAN, PRIEST); + stage_group(&realm, LEADER, &[MEMBER]); + let group = group_of(&realm, LEADER).unwrap(); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (MEMBER, PRIEST)], + ); + let reminders = |realm: &Standalone| { + events(realm) + .into_iter() + .filter(|(_, _, event)| *event == Event::InProgress) + .map(|(_, recipient, _)| recipient) + .collect::>() + }; + let next_due = |realm: &Standalone| -> i64 { + let rows = realm.query_rows("SELECT next_reminder_at FROM game_meeting_stone_party"); + let value = &rows[0]["next_reminder_at"]; + spacetimedb::Timestamp::parse_from_rfc3339(value) + .unwrap_or_else(|error| panic!("invalid durable timestamp {value:?}: {error}")) + .to_micros_since_unix_epoch() + }; + let queued_due = next_due(&realm); + + realm.assert_call( + "debug_backdate_meeting_stone_party", + &[&group.to_string(), "300"], + ); + let backdated_due = next_due(&realm); + assert_eq!(backdated_due, queued_due - 300_000_000); + assert!( + support::poll_until(Duration::from_secs(20), || reminders(&realm).len() == 2), + "the reminder tick never fired" + ); + let first_due = next_due(&realm); + // The tick fires within its 5 s cadence of the reminder falling due. + let fired_after = first_due - 300_000_000 - backdated_due; + assert!( + (0..15_000_000).contains(&fired_after), + "the next reminder is five minutes after the one that fired, {fired_after} us late" + ); + + std::thread::sleep(Duration::from_secs(6)); + let mut recipients = reminders(&realm); + recipients.sort_unstable(); + assert_eq!(recipients, [LEADER, MEMBER], "each member hears it once"); +} + +/// Criterion 11: a stone-formed party starts with the same `game_group` defaults as an invited one. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_stone_formed_party_has_the_defaults_of_an_invited_one() { + let realm = start("stone-match-defaults"); + let mages: Vec = (509_6000..509_6005).collect(); + const INVITER: u64 = 509_6010; + const INVITED: u64 = 509_6011; + for &guid in &mages { + stage(&realm, guid, HUMAN, MAGE); + join_alone(&realm, guid, MAGE, DEADMINES); + } + stage(&realm, INVITER, HUMAN, MAGE); + stage(&realm, INVITED, HUMAN, MAGE); + group_op(&realm, INVITE, INVITER, INVITED, 0, 0); + group_op(&realm, ACCEPT, INVITED, 0, MAGE, HUMAN); + + let defaults = |guid: u64| { + let group = group_of(&realm, guid).unwrap(); + let mut row = realm.query_rows(&format!( + "SELECT * FROM game_group WHERE group_id = {group}" + ))[0] + .clone(); + row.remove("group_id"); + row.remove("leader_guid"); + let slots: Vec = realm + .query_rows(&format!( + "SELECT raid_slot FROM game_group_member WHERE group_id = {group}" + )) + .into_iter() + .map(|member| member["raid_slot"].clone()) + .collect(); + (row, slots.into_iter().all(|slot| slot == "0")) + }; + let (stone, stone_slots) = defaults(mages[0]); + assert_eq!(stone, defaults(INVITER).0); + assert!(stone_slots); + assert_eq!(stone["loot_method"], "3", "group loot"); + assert_eq!(stone["loot_threshold"], "2", "at Uncommon"); + assert_eq!(stone["group_type"], "0", "a Party"); +} + +/// Criterion 12: a Character counting down in an instance its new stone party owns stays. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_stone_add_into_the_party_that_owns_the_instance_cancels_the_countdown() { + let realm = start("stone-match-instance"); + // The Characters `debug_stage_instance_removal_fixture` stages; all three stand in its instance. + const ALPHA: u64 = 509_295_001; + const BRAVO: u64 = 509_295_002; + const CHARLIE: u64 = 509_295_003; + // Party ops without a token run before the claims exist, as in the Instance Removal tests. + let tokenless = |op: &str, actor: u64, target: u64| { + realm.assert_call( + "realm_group_op", + &[ + op, + &support::actor(&actor.to_string()), + &target.to_string(), + "0", + "0", + "0", + ], + ); + }; + for guid in [BRAVO, CHARLIE] { + tokenless(INVITE, ALPHA, guid); + tokenless(ACCEPT, guid, 0); + } + let group = group_of(&realm, ALPHA).unwrap(); + realm.assert_call( + "debug_stage_instance_removal_fixture", + &[&group.to_string()], + ); + tokenless(LEAVE, CHARLIE, 0); + let countdowns = |realm: &Standalone| { + realm.assert_call("debug_hold_instance_removals", &[]); + realm + .query_rows("SELECT character_guid FROM game_instance_removal") + .into_iter() + .map(|row| row["character_guid"].parse().unwrap()) + .collect::>() + }; + assert_eq!(countdowns(&realm), [CHARLIE], "the leaver counts down"); + + for guid in [ALPHA, CHARLIE] { + realm.assert_sql(&format!( + "INSERT INTO game_account_claim (account_id, generation, request_nonce, \ + character_guid, expires_micros, closed) VALUES ({guid}, 1, {guid}, {guid}, \ + 4102444800000000, false)" + )); + } + join_party( + &realm, + ALPHA, + DEADMINES, + &[(ALPHA, WARRIOR), (BRAVO, PRIEST)], + ); + join_alone(&realm, CHARLIE, MAGE, DEADMINES); + + assert_eq!(group_of(&realm, CHARLIE), Some(group)); + assert!( + countdowns(&realm).is_empty(), + "the Stone Add cancels the countdown" + ); +} From 3cc52a96abe39d3420e053f91af15e533ac5f22b Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 23:14:06 +0200 Subject: [PATCH 08/16] docs(tickets): correct three T3 facts A priest fills damage, so criterion 3 needs a party with only its healer open. ACCEPT's race is not read. Raid convert has no single-database reducer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/tickets/issue-532/T3-stone-matching.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.claude/tickets/issue-532/T3-stone-matching.md b/.claude/tickets/issue-532/T3-stone-matching.md index d40d47c0c..8ea15a705 100644 --- a/.claude/tickets/issue-532/T3-stone-matching.md +++ b/.claude/tickets/issue-532/T3-stone-matching.md @@ -64,8 +64,9 @@ index; never scan the whole Seeker table. `advance_group_revision`, the helper `realm_group_op` uses. That includes every party the bucket pass forms or fills during a `realm_meeting_stone_op` JOIN, and a party a kick re-queue fills. Advancing a party twice in one transaction is fine. T2's relay pushes whatever this advances. -- `realm_group_op` passes ACCEPT's `arg_a` class and `arg_b` race into the accept core. On the - single-database plane (`gw_group_accept`, `Plane::Shard`) read the acceptor's class from its local +- `realm_group_op` passes ACCEPT's `arg_a` class into the accept core. The core does not read + `arg_b` race: the Seeker row a queued party gains takes the party's team. On the single-database + plane (`gw_accept_group_invite`, `Plane::Shard`) read the acceptor's class from its local `game_character` row. - Hooks for queued parties, per the README fact table. Keep `remove_member` the one removal core; tell kick from leave with a cause argument or with work in `uninvite_on` before it calls @@ -113,8 +114,9 @@ before the roster LIST it precedes in cmangos. T4 proves the relay keeps that or Seeker or party row remains. 2. Five mage Seekers form a party of leader, first member and one more damage add. It stays queued with tank and healer open, and two mages keep waiting. -3. A queued warrior and mage party takes the longest-waiting healer-capable Seeker as healer. A - second priest waits. +3. A queued party of a warrior and three mages takes the longest-waiting healer-capable Seeker as + healer. A second priest waits. (A warrior and one mage would also take the second priest as + damage: priests fill damage at low priority, cm:LFG/LFGMgr.cpp:99,146.) 4. Existing members get `MEMBER_ADDED(guid)` for each stone add. The added Character does not. 5. Horde and Alliance Seekers of one area never meet. Different areas never meet. 6. A Seeker without a live Account Claim is never added and its row is gone after the pass. @@ -139,7 +141,8 @@ before the roster LIST it precedes in cmangos. T4 proves the relay keeps that or through T1's debug fixtures. One test per criterion 1-3 and 5-12, asserting `game_group`, `game_group_member`, `game_group_roster_revision`, the Seeker and party tables and the ordered `game_group_event` rows per recipient. Criterion 7 runs through `realm_group_op` for the - Realm-core plane and through the `gw_group_*` reducers for the single-database plane. The + Realm-core plane and through the `gw_group_*` reducers for the single-database plane. Raid + convert has no `gw_group_*` reducer, so its case runs through `realm_group_op` only. The existing `raid_convert.rs`, `instance_removal.rs` and `loot_tag.rs` durable tests must stay green after the join core extraction. - The Module wasm check and `cargo test -p lyracore-module --lib` for the tripwires. From 5f3400998f82975c5195fb7c6eaf6a9da1367b6b Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 06:55:35 +0200 Subject: [PATCH 09/16] feat(party): relay Realm-core Roster Revisions to World Shard mirrors party::run pushes only the acting Character's parties, so a roster change Realm-core makes for a party no actor belongs to never reached the mirrors. The Roster Revision Relay watches game_group_roster_revision on the Realm-core connection. Its callbacks mark the party dirty; one worker per Gateway pushes each dirty party to every World Shard whose mirror holds an older revision or none, through sync_group_mirrors_required's retries. A new held_roster_revision read tells a missing row from revision 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 5 + gateway/src/main.rs | 52 ++++++ gateway/src/stdb/reads/party.rs | 13 ++ gateway/src/stdb/subscriptions.rs | 59 +++++++ ...ubscriptions_roster_relay_durable_tests.rs | 152 +++++++++++++++++ gateway/src/stdb/world_store.rs | 4 + gateway/src/world/mod.rs | 1 + gateway/src/world/party.rs | 25 ++- gateway/src/world/party_mirror.rs | 149 +++++++++++++++++ gateway/src/world/party_mirror_tests.rs | 157 ++++++++++++++++++ gateway/src/world/store.rs | 7 + gateway/src/world/tests.rs | 28 ++++ 12 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs create mode 100644 gateway/src/world/party_mirror.rs create mode 100644 gateway/src/world/party_mirror_tests.rs diff --git a/CONTEXT.md b/CONTEXT.md index 031571dd7..6db70bda2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -344,6 +344,11 @@ Realm-core's monotonic order for one complete Group member list, leader, loot ru and every Raid Slot. A World Shard keeps the last accepted value after disband. Older snapshots cannot change its party mirror. +**Roster Revision Relay**: +The Gateway's push of a party's Realm-core roster to each World Shard whose mirror holds an older +Roster Revision or none, whatever changed it on Realm-core. +_Avoid_: mirror sync thread, roster watcher + **Member Stats**: The status, health, power, level, zone, map position, auras, and live pet a group member's frame shows for another member. The Gateway projects them from the Home Shard's cache and sends them diff --git a/gateway/src/main.rs b/gateway/src/main.rs index d3eb71b29..643966b99 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -126,6 +126,10 @@ async fn run() -> Result<()> { // party realm-core still lists it in; no client and no Shard reducer can reach realm-core. coordinator.spawn_character_gone_relay(); + // Realm-core can change a party no session acted in. Push each Roster Revision to the World + // Shard mirrors that lack it. + coordinator.spawn_roster_revision_relay(); + // Keep this gateway's lease alive. The module's lease reaper despawns every session bound to // a lease that stops heartbeating, so the heartbeat is what bounds ghost lifetime after a // gateway crash — and its ABSENCE while sessions are bound is what would despawn a healthy @@ -563,6 +567,54 @@ mod character_gone_relay_tripwires { } } +/// The Roster Revision Relay: armed at startup, re-armed on a Realm-core reconnect, and its +/// callbacks never call a reducer. +#[cfg(test)] +mod roster_revision_relay_tripwires { + use crate::test_scan::code_of; + + #[test] + fn main_arms_the_roster_revision_relay_at_startup() { + let src = include_str!("main.rs"); + let body = code_of(src, "async fn run() -> Result<()> {"); + assert!( + body.contains("coordinator.spawn_roster_revision_relay();"), + "`main` no longer calls `spawn_roster_revision_relay`; a party Realm-core changes \ + without a session op stays stale on every World Shard mirror. Body was:\n{body}" + ); + } + + #[test] + fn spawn_roster_revision_relay_installs_the_realm_core_reconnect_hook() { + let src = include_str!("stdb/subscriptions.rs"); + let body = code_of(src, "pub fn spawn_roster_revision_relay(&self) {"); + assert!( + body.contains("self.realm_core()") + && body.contains("realm.arm_roster_revision_relay(&relay);") + && body.contains("on_reconnect") + && body.contains("hook_realm.arm_roster_revision_relay(&relay);"), + "`spawn_roster_revision_relay` must arm Realm-core now and again on every Realm-core \ + reconnect, or the relay goes silent at the first reconnect. Body was:\n{body}" + ); + } + + #[test] + fn the_callbacks_only_mark_the_party_dirty() { + let src = include_str!("stdb/subscriptions.rs"); + let body = code_of(src, "fn arm_roster_revision_relay("); + let normalized: String = body.split_whitespace().collect::>().join(" "); + assert!( + normalized.contains(".on_insert(move |_ctx, row| inserted.mark_dirty(row.group_id));") + && normalized.contains( + ".on_update(move |_ctx, _old, row| updated.mark_dirty(row.group_id));" + ) + && !normalized.contains("sync_group_mirror"), + "the Realm-core callbacks must mark the party dirty and return; a reducer call there \ + blocks the Coordinator pump. Body was:\n{body}" + ); + } +} + /// The untrusted-input boundaries stay free of panic constructs. /// /// The release-boundary hardening audit found ZERO reachable panics in the three files BOUNDARIES diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index 9c44daa3a..233cbf253 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -215,6 +215,19 @@ impl Coordinator { .map_or(1, |row| row.revision) } + /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a + /// World Shard this is the mirror's revision, which outlives a disband. + pub fn held_roster_revision(&self, group_id: u64) -> Option { + self.0 + .coord() + .conn + .db + .game_group_roster_revision() + .group_id() + .find(&group_id) + .map(|row| row.revision) + } + /// Every UNRESOLVED `game_loot_roll` row on THIS handle's database, joined with its votes. /// /// A cache read, like [`group_roster`](Self::group_roster) — this table is now part of the diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 4ac3e0b14..0e0688896 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -4159,6 +4159,61 @@ impl Coordinator { }); } + /// Start the Roster Revision Relay: Realm-core's `game_group_roster_revision` callbacks mark a + /// party dirty, and one worker pushes it to every stale World Shard mirror. Called once at + /// startup. Arms nothing unless Realm-core is a distinct database. A Realm-core reconnect + /// re-arms the callbacks on the fresh connection; changes missed while it was down are the + /// reconciliation pass's, which that reconnect also starts. + pub fn spawn_roster_revision_relay(&self) { + let Ok(realm) = self.realm_core() else { + return; + }; + if self + .all_shards() + .iter() + .any(|shard| shard.shard_name() == realm.shard_name()) + { + return; + } + let relay = match crate::world::party_mirror::RosterRevisionRelay::spawn(self.clone()) { + Ok(relay) => relay, + Err(error) => { + log::error!("party: could not start the Roster Revision Relay: {error}"); + return; + } + }; + realm.arm_roster_revision_relay(&relay); + let hook_realm = realm.clone(); + realm + .0 + .on_reconnect + .lock() + .unwrap() + .push(std::sync::Arc::new(move || { + hook_realm.arm_roster_revision_relay(&relay); + })); + } + + /// Realm-core's half of [`Self::spawn_roster_revision_relay`], on the current connection. The + /// callbacks only mark the party dirty; the worker makes every reducer call. + fn arm_roster_revision_relay( + &self, + relay: &std::sync::Arc, + ) { + use spacetimedb_sdk::TableWithPrimaryKey; + let live = self.0.coord(); + let inserted = relay.clone(); + live.conn + .db + .game_group_roster_revision() + .on_insert(move |_ctx, row| inserted.mark_dirty(row.group_id)); + let updated = relay.clone(); + live.conn + .db + .game_group_roster_revision() + .on_update(move |_ctx, _old, row| updated.mark_dirty(row.group_id)); + } + /// Start one bounded Transfer Intent dispatcher per configured World Shard. Each pass reads the /// current connection, so a reconnect needs no second thread or connection-scoped callback. pub fn spawn_bot_transfer_relay(&self) { @@ -8242,5 +8297,9 @@ mod tests { #[path = "subscriptions_character_gone_durable_tests.rs"] mod character_gone_durable_tests; +#[cfg(test)] +#[path = "subscriptions_roster_relay_durable_tests.rs"] +mod roster_relay_durable_tests; + #[cfg(test)] include!(concat!(env!("OUT_DIR"), "/package-coordinator-tests.rs")); diff --git a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs new file mode 100644 index 000000000..26e73a9ba --- /dev/null +++ b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs @@ -0,0 +1,152 @@ +use super::*; +use crate::accept::BlockingTaskCapacity; +use crate::config::GatewayConfig; +use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; +use crate::world::party::PartyOutcome; +use lyracore_shared::group::realm_op; + +/// Holds the process-wide topology variables for the whole test, then restores them. +struct TopologyEnv { + shard_map: Option, + realm_core: Option, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl TopologyEnv { + fn install(shard_map: &str, realm_core: &str) -> Self { + let guard = DURABLE_TOPOLOGY_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = Self { + shard_map: std::env::var_os("LYRACORE_SHARD_MAP"), + realm_core: std::env::var_os("LYRACORE_REALM_CORE"), + _guard: guard, + }; + std::env::set_var("LYRACORE_SHARD_MAP", shard_map); + std::env::set_var("LYRACORE_REALM_CORE", realm_core); + previous + } +} + +impl Drop for TopologyEnv { + fn drop(&mut self) { + match &self.shard_map { + Some(value) => std::env::set_var("LYRACORE_SHARD_MAP", value), + None => std::env::remove_var("LYRACORE_SHARD_MAP"), + } + match &self.realm_core { + Some(value) => std::env::set_var("LYRACORE_REALM_CORE", value), + None => std::env::remove_var("LYRACORE_REALM_CORE"), + } + } +} + +/// Every World Shard holds Realm-core's Roster Revision for the party and, while it exists, its +/// members. +fn mirrors_follow_realm_core( + realm: &Coordinator, + shards: &[(String, Coordinator)], + group_id: u64, + leader_guid: u64, +) -> bool { + let Some(revision) = realm.held_roster_revision(group_id) else { + return false; + }; + let members = realm + .group_roster(leader_guid) + .map(|roster| roster.member_guids()); + shards.iter().all(|(_, shard)| { + shard.held_roster_revision(group_id) == Some(revision) + && shard + .group_roster(leader_guid) + .map(|roster| roster.member_guids()) + == members + }) +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { + const INSTANCES: &str = "roster-relay-instances"; + const REALM: &str = "roster-relay-realm"; + const LEADER: u64 = 1; + const MEMBER: u64 = 2; + + let mut standalone = Standalone::start_persistent("roster-relay-world"); + standalone.publish_module(); + for database in [INSTANCES, REALM] { + standalone.publish_named_module_bytes(database, module_bytes()); + } + standalone.assert_call("claim_operator", &[]); + for database in [INSTANCES, REALM] { + standalone.assert_call_database(database, "claim_operator", &[]); + } + + let _topology = TopologyEnv::install(&format!("36:*={INSTANCES}"), REALM); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: standalone.server().into(), + module_name: standalone.shard_name().into(), + coordinator_token: Some(standalone.owner_token()), + gateway_id: "roster-relay-test".into(), + blocking_task_capacity: BlockingTaskCapacity::new(1), + }; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let gateway = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + gateway.spawn_roster_revision_relay(); + let realm = gateway.realm_core().unwrap(); + let shards = gateway.world_shards(); + assert_eq!(shards.len(), 2, "the default World Shard and {INSTANCES}"); + + // Straight to Realm-core: no `party::run`, so only the relay can update the mirrors. + assert_eq!( + realm + .realm_group_op(realm_op::INVITE, LEADER, MEMBER, 0, 0, 0) + .unwrap(), + PartyOutcome::Ran + ); + assert_eq!( + realm + .realm_group_op(realm_op::ACCEPT, MEMBER, 0, 1, 1, 0) + .unwrap(), + PartyOutcome::Ran + ); + assert!(poll_until(POLL_TIMEOUT, || { + realm + .group_roster(LEADER) + .is_some_and(|roster| roster.member_guids() == [LEADER, MEMBER]) + })); + let group_id = realm.group_roster(LEADER).unwrap().group_id; + assert!( + poll_until(POLL_TIMEOUT, || mirrors_follow_realm_core( + &realm, &shards, group_id, LEADER + )), + "the formed party never reached every mirror: {:?}", + shards + .iter() + .map(|(name, shard)| (name, shard.held_roster_revision(group_id))) + .collect::>() + ); + + // A party of one disbands, so the mirrors get the tombstone and forget the party. + assert_eq!( + realm + .realm_group_op(realm_op::LEAVE, MEMBER, 0, 0, 0, 0) + .unwrap(), + PartyOutcome::Ran + ); + assert!(poll_until(POLL_TIMEOUT, || realm + .group_roster(LEADER) + .is_none())); + assert!( + poll_until(POLL_TIMEOUT, || mirrors_follow_realm_core( + &realm, &shards, group_id, LEADER + )), + "the disband never reached every mirror: {:?}", + shards + .iter() + .map(|(name, shard)| (name, shard.held_roster_revision(group_id))) + .collect::>() + ); +} diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index 771a43279..2047c1b12 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -1463,6 +1463,10 @@ impl WorldStore for Coordinator { Ok(self.group_roster_revision(group_id)) } + fn held_roster_revision(&self, group_id: u64) -> Result> { + Ok(self.held_roster_revision(group_id)) + } + fn realm_character_partition( &self, character_guid: u64, diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index ca0216113..979c7a2d8 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -44,6 +44,7 @@ pub mod loot; pub mod mail; pub mod packet_lint; pub mod party; +pub(crate) mod party_mirror; pub mod presence; pub(crate) mod social; mod store; diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index ec3eefd45..1e9b1b6d3 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -1073,7 +1073,13 @@ fn sync_membership_mirrors( ), } for group_id in touched { - if let Err(error) = sync_group_mirrors_required(store, realm, group_id, before.as_ref()) { + if let Err(error) = sync_group_mirrors_required( + store, + realm, + group_id, + before.as_ref(), + &store.world_stores(), + ) { log::warn!( "party: group {group_id} mirrors stay stale until the next op or world entry \ ({error:#})" @@ -1356,6 +1362,7 @@ pub(crate) fn cleanup_deleted_character( realm.as_ref(), previous.group_id, Some(&previous), + &store.world_stores(), )?; } Ok(DeletedCharacterPartyCleanup::Removed) @@ -1402,7 +1409,13 @@ pub(crate) fn reconcile_deleted_character_parties(store: &St) -> } } for group_id in group_ids { - if let Err(error) = sync_group_mirrors_required(store, realm.as_ref(), group_id, None) { + if let Err(error) = sync_group_mirrors_required( + store, + realm.as_ref(), + group_id, + None, + &store.world_stores(), + ) { failures += 1; log::warn!("party: could not reconcile group {group_id} mirrors ({error:#}); retrying"); last_error = Some(error); @@ -1424,11 +1437,15 @@ const MIRROR_PUSH_ATTEMPTS: usize = 3; /// its first attempt. A timed-out push may still commit, and a repeated push is idempotent. const MIRROR_RETRY_WINDOW: std::time::Duration = std::time::Duration::from_secs(5); -fn sync_group_mirrors_required( +/// Push Realm-core's roster or disband tombstone for one party to each of `shards`, with up to +/// [`MIRROR_PUSH_ATTEMPTS`] attempts per Shard inside [`MIRROR_RETRY_WINDOW`]. Partition +/// certification still reads every World Shard. +pub(crate) fn sync_group_mirrors_required( store: &St, realm: &dyn WorldStore, group_id: u64, previous: Option<&GroupRoster>, + shards: &[std::sync::Arc], ) -> Result<()> { let roster = match realm.party_cleanup_group_roster_by_id(group_id)? { Some(roster) => roster, @@ -1439,7 +1456,7 @@ fn sync_group_mirrors_required( let mut failures = 0usize; let mut last_error = None; let retry_until = std::time::Instant::now() + MIRROR_RETRY_WINDOW; - for shard in store.world_stores() { + for shard in shards { let mut synced = false; for attempt in 0..MIRROR_PUSH_ATTEMPTS { if attempt > 0 && std::time::Instant::now() >= retry_until { diff --git a/gateway/src/world/party_mirror.rs b/gateway/src/world/party_mirror.rs new file mode 100644 index 000000000..035f703bf --- /dev/null +++ b/gateway/src/world/party_mirror.rs @@ -0,0 +1,149 @@ +//! The Roster Revision Relay: whatever changes a party on Realm-core, push that party's roster to +//! each World Shard whose mirror holds an older Roster Revision or none. +//! +//! `party::run` pushes only the acting Character's before and after parties. Realm-core can also +//! change parties no actor of the op belongs to, so the mirror follows the revision instead. The +//! Realm-core row callbacks only mark a party dirty; one worker thread per Gateway process drains +//! the dirty set, so several revisions of one party before the worker runs cost one push, and no +//! reducer call runs on an SDK callback thread. +//! +//! A push that still fails after [`party::sync_group_mirrors_required`]'s attempts is logged and +//! dropped. The party waits for its next revision, or for the reconciliation pass a Realm-core +//! reconnect starts. + +use super::party; +use super::WorldStore; +use std::collections::BTreeSet; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; + +/// The dirty party ids and the worker that pushes them. +#[derive(Default)] +pub(crate) struct RosterRevisionRelay { + dirty: Mutex>, + wake: Condvar, +} + +impl RosterRevisionRelay { + /// Start the worker thread. `store` is any Coordinator handle: it names Realm-core and every + /// World Shard. + pub(crate) fn spawn(store: St) -> std::io::Result> { + let relay = Arc::new(Self::default()); + let worker = relay.clone(); + std::thread::Builder::new() + .name("roster-revision-relay".into()) + .spawn(move || loop { + worker.wait_for_dirty(); + worker.push_dirty(&store); + })?; + Ok(relay) + } + + /// Note that Realm-core moved this party's Roster Revision. Safe on an SDK callback thread: + /// it takes one short lock and calls nothing. + pub(crate) fn mark_dirty(&self, group_id: u64) { + self.dirty + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(group_id); + self.wake.notify_one(); + } + + fn wait_for_dirty(&self) { + let mut dirty = self.dirty.lock().unwrap_or_else(PoisonError::into_inner); + while dirty.is_empty() { + dirty = self + .wake + .wait(dirty) + .unwrap_or_else(PoisonError::into_inner); + } + } + + /// Take every dirty party and push it to the World Shards whose mirror is stale. A party marked + /// again while this runs stays for the next pass. + pub(crate) fn push_dirty(&self, store: &St) { + let parties = + std::mem::take(&mut *self.dirty.lock().unwrap_or_else(PoisonError::into_inner)); + if parties.is_empty() { + return; + } + let realm = match store.party_cleanup_realm() { + Ok(Some(realm)) => realm, + Ok(None) => return, + Err(error) => { + log::warn!( + "party: the Roster Revision Relay cannot reach Realm-core ({error:#}); {} \ + party mirror(s) wait for the reconnect pass", + parties.len() + ); + return; + } + }; + let shards = store.world_stores(); + for group_id in parties { + push_party(store, realm.as_ref(), &shards, group_id); + } + } +} + +fn push_party( + store: &St, + realm: &dyn WorldStore, + shards: &[Arc], + group_id: u64, +) { + let realm_revision = match realm.held_roster_revision(group_id) { + Ok(Some(revision)) => revision, + Ok(None) => return, + Err(error) => { + log::warn!( + "party: could not read Realm-core's Roster Revision for group {group_id} \ + ({error:#}); its mirrors wait for the next revision" + ); + return; + } + }; + for shard in shards { + // A failed read cannot prove the mirror current, and a repeated push is idempotent. + let mirrored = shard.held_roster_revision(group_id).unwrap_or(None); + if !mirror_is_stale(realm_revision, mirrored) { + continue; + } + if let Err(error) = party::sync_group_mirrors_required( + store, + realm, + group_id, + None, + std::slice::from_ref(shard), + ) { + log::warn!( + "party: group {group_id} mirror on {} stays stale until its next Roster Revision \ + or the reconnect pass ({error:#})", + shard.shard_name() + ); + } + } +} + +/// A World Shard mirror needs Realm-core's roster when it holds no row for the party or an older +/// Roster Revision. A newer one means this Gateway's Realm-core cache lags; pushing it would be +/// refused as older anyway. +fn mirror_is_stale(realm_revision: u64, mirrored: Option) -> bool { + mirrored.is_none_or(|mirrored| mirrored < realm_revision) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mirror_with_no_row_for_the_party_is_stale() { + assert!(mirror_is_stale(1, None)); + } + + #[test] + fn an_older_mirror_is_stale_and_an_equal_or_newer_one_is_current() { + assert!(mirror_is_stale(4, Some(3))); + assert!(!mirror_is_stale(4, Some(4))); + assert!(!mirror_is_stale(4, Some(5))); + } +} diff --git a/gateway/src/world/party_mirror_tests.rs b/gateway/src/world/party_mirror_tests.rs new file mode 100644 index 000000000..913d1cdb3 --- /dev/null +++ b/gateway/src/world/party_mirror_tests.rs @@ -0,0 +1,157 @@ +//! The Roster Revision Relay against the realm-wide party topology. Each test changes the Fake +//! Realm-core party directly, as a Module rule that no `party::run` drives would, then runs one +//! worker pass and reads both World Shard mirrors. + +use super::party_tests::{ + form_split_party, party_topology, party_topology_with, GINGER, TRIN, VIM, +}; +use super::*; +use crate::world::party_mirror::RosterRevisionRelay; + +/// The split party's id after `form_split_party`. +fn split_party_id(realm: &InMemoryStore) -> u64 { + realm + .group_roster(GINGER) + .unwrap() + .expect("the split party exists on Realm-core") + .group_id +} + +/// Add `guid` to `group_id` on Realm-core and advance its Roster Revision, with no party op. +fn join_on_realm(realm: &InMemoryStore, group_id: u64, guid: u64) { + let mut party = realm.party.lock().unwrap(); + party.members.push((group_id, guid)); + *party.revisions.entry(group_id).or_insert(0) += 1; +} + +fn mirror_calls(calls: &ShardCallLog) -> Vec { + calls + .lock() + .unwrap() + .iter() + .filter(|(_, call)| call == "sync_group_mirror") + .map(|(shard, _)| shard.clone()) + .collect() +} + +#[test] +fn a_roster_change_no_party_op_made_reaches_every_shard_mirror() { + let (realm, world, instances, _calls) = party_topology(); + form_split_party(&world, &instances); + let group_id = split_party_id(&realm); + join_on_realm(&realm, group_id, TRIN); + + let relay = RosterRevisionRelay::default(); + relay.mark_dirty(group_id); + relay.push_dirty(world.as_ref()); + + let realm_revision = realm.held_roster_revision(group_id).unwrap(); + for shard in [&world, &instances] { + let mirror = shard.group_roster_by_id(group_id).unwrap().unwrap(); + assert_eq!( + mirror.member_guids(), + [GINGER, VIM, TRIN], + "{}", + shard.shard + ); + assert_eq!( + shard.held_roster_revision(group_id).unwrap(), + realm_revision, + "{} holds Realm-core's Roster Revision", + shard.shard + ); + } +} + +#[test] +fn a_disband_reaches_every_shard_as_the_tombstone() { + let (realm, world, instances, _calls) = party_topology(); + form_split_party(&world, &instances); + let group_id = split_party_id(&realm); + { + let mut party = realm.party.lock().unwrap(); + party.members.retain(|(group, _)| *group != group_id); + party.groups.retain(|(group, ..)| *group != group_id); + *party.revisions.get_mut(&group_id).unwrap() += 1; + } + + let relay = RosterRevisionRelay::default(); + relay.mark_dirty(group_id); + relay.push_dirty(world.as_ref()); + + let realm_revision = realm.held_roster_revision(group_id).unwrap(); + for shard in [&world, &instances] { + assert_eq!( + shard.group_roster_by_id(group_id).unwrap(), + None, + "{} forgets the party", + shard.shard + ); + assert_eq!( + shard.held_roster_revision(group_id).unwrap(), + realm_revision, + "{} keeps the tombstone's Roster Revision", + shard.shard + ); + } +} + +#[test] +fn a_shard_already_at_the_revision_gets_no_push() { + let (realm, world, instances, calls) = party_topology(); + form_split_party(&world, &instances); + let group_id = split_party_id(&realm); + join_on_realm(&realm, group_id, TRIN); + let current = realm.group_roster_by_id(group_id).unwrap().unwrap(); + world.sync_group_mirror(¤t).unwrap(); + calls.lock().unwrap().clear(); + + let relay = RosterRevisionRelay::default(); + relay.mark_dirty(group_id); + relay.push_dirty(world.as_ref()); + + assert_eq!(mirror_calls(&calls), ["instances"]); +} + +#[test] +fn five_revisions_before_the_worker_runs_cause_one_push_per_stale_shard() { + let (realm, world, instances, calls) = party_topology(); + form_split_party(&world, &instances); + let group_id = split_party_id(&realm); + calls.lock().unwrap().clear(); + + let relay = RosterRevisionRelay::default(); + for _ in 0..5 { + *realm + .party + .lock() + .unwrap() + .revisions + .get_mut(&group_id) + .unwrap() += 1; + relay.mark_dirty(group_id); + } + relay.push_dirty(world.as_ref()); + + let mut pushed = mirror_calls(&calls); + pushed.sort(); + assert_eq!(pushed, ["instances", "world"]); +} + +#[test] +fn an_unavailable_shard_does_not_hold_back_the_other_mirror() { + let (realm, world, instances, _calls) = party_topology_with(Some("instances is down"), None); + form_split_party(&world, &instances); + let group_id = split_party_id(&realm); + join_on_realm(&realm, group_id, TRIN); + + let relay = RosterRevisionRelay::default(); + relay.mark_dirty(group_id); + relay.push_dirty(world.as_ref()); + + assert_eq!( + world.held_roster_revision(group_id).unwrap(), + realm.held_roster_revision(group_id).unwrap() + ); + assert_eq!(instances.group_roster_by_id(group_id).unwrap(), None); +} diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index 2a9c96bd8..97ddde0ac 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -504,6 +504,13 @@ pub trait WorldStore: Ok(1) } + /// The Roster Revision this database holds for one party, or `None` when it holds no row. On a + /// World Shard it is the mirror's revision, kept after a disband; on Realm-core it is the + /// authoritative one. + fn held_roster_revision(&self, _group_id: u64) -> Result> { + Ok(None) + } + /// Realm-core's ordered locator for one Character. World Shards never supply this fact. fn realm_character_partition( &self, diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 9b3ca0a7c..817ca2127 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -104,6 +104,11 @@ fn world_session_socket_pair_times_out_when_the_server_writes_nothing() { #[path = "party_tests.rs"] mod party_tests; +/// The Roster Revision Relay against the party topology. A sibling of `party_tests` so it reaches +/// `InMemoryStore` and that topology without widening anything. +#[path = "party_mirror_tests.rs"] +mod party_mirror_tests; + /// Member Stats: the Relay tick and the stats request against the party topology. A sibling of /// `party_tests` so it reaches `InMemoryStore` and that topology without widening anything. #[path = "member_stats_tests.rs"] @@ -651,6 +656,9 @@ struct InMemoryStore { /// What `sync_group_mirror` wrote onto THIS shard, latest per group. The invalidation /// story, made observable. mirror: std::sync::Mutex>, + /// The Roster Revision each `sync_group_mirror` accepted on THIS shard, kept after the disband + /// tombstone, as the Module's mirror keeps its `game_group_roster_revision` row. + mirror_revisions: std::sync::Mutex>, /// Guids with a LIVE entity on this shard — the per-guid `entity_in_world` answer a /// realm-wide party frame's online flags are built from. Empty = the single `entity_in_world` /// flag above decides, as it did before. @@ -3828,6 +3836,22 @@ impl WorldStore for InMemoryStore { .cloned()) } + fn group_roster_revision(&self, group_id: u64) -> Result { + Ok(self.held_roster_revision(group_id)?.unwrap_or(1)) + } + + fn held_roster_revision(&self, group_id: u64) -> Result> { + if self.is_realm { + return Ok(self.realm_cache(|p| p.revisions.get(&group_id).copied())); + } + Ok(self + .mirror_revisions + .lock() + .unwrap() + .get(&group_id) + .copied()) + } + fn party_member_guids(&self) -> Result> { if !self.is_realm { return Ok(Vec::new()); @@ -3881,6 +3905,10 @@ impl WorldStore for InMemoryStore { if let Some(e) = &self.mirror_error { return Err(anyhow!("{e}")); } + self.mirror_revisions + .lock() + .unwrap() + .insert(roster.group_id, roster.roster_revision); let mut mirror = self.mirror.lock().unwrap(); mirror.retain(|r| r.group_id != roster.group_id); // An empty roster is the disband tombstone — the shard forgets the party rather than From 8b5be8d7e1fc000e772c71e9143b0f190fd363cf Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 07:25:45 +0200 Subject: [PATCH 10/16] feat(meeting-stones): prove the realm-wide queue and relay stone events in order The SDK fires one transaction's group event inserts in hash order, so a filled stone party could hear SETQUEUE(0, NONE) before COMPLETE. The first callback of a transaction now relays every row of that transaction for its recipient, in id order, once per source database. A Gateway durable test stages Seekers on two World Shards, moves one into the Instance Pool, and proves one stone-formed party on Realm-core, both mirrors at Realm-core's Roster Revision, and each session's stone events in insert order. A Realm-core claim lever stages the split. The single-database ACCEPT refuses a missing inviter or acceptor Character again. ACCEPT no longer conveys race, which the Module never read. Docs: architecture, schema, glossary, changelog and the client check. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + CONTEXT.md | 5 +- crates/lyracore-shared/src/group.rs | 4 +- docs/architecture.md | 18 +- docs/meeting-stone-client-check.md | 52 +++ docs/schema.md | 14 +- ...debug_stage_meeting_stone_claim_reducer.rs | 74 ++++ gateway/src/stdb/bindings/mod.rs | 14 + gateway/src/stdb/member_stats_relay.rs | 1 + gateway/src/stdb/subscriptions.rs | 9 +- ...bscriptions_meeting_stone_durable_tests.rs | 401 ++++++++++++++++++ ...ubscriptions_roster_relay_durable_tests.rs | 8 +- gateway/src/stdb/world_view.rs | 213 ++++++++-- .../stdb/world_view/cell_audience_tests.rs | 1 + gateway/src/stdb/world_view/relay_bench.rs | 1 + gateway/src/world/mod.rs | 24 +- gateway/src/world/party.rs | 15 +- gateway/src/world/party_tests.rs | 30 +- module/src/debug/meeting_stone.rs | 21 +- module/src/group.rs | 18 +- 20 files changed, 841 insertions(+), 85 deletions(-) create mode 100644 docs/meeting-stone-client-check.md create mode 100644 gateway/src/stdb/bindings/debug_stage_meeting_stone_claim_reducer.rs create mode 100644 gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c308f609..bfa4d45d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and is not reconstructed here. under `packages/*/src/` against that list and fails on a path outside it, naming the Package, the file, the line and the path. A Package that needs a path off the surface writes `// package-api: exempt ` on the line that names it. +- **Meeting stones.** Using a dungeon's meeting stone puts a Character, or the Party it leads, in one + realm-wide queue on Realm-core. Five Seekers for one dungeon and team form a Party, and a queued + Party fills its open tank, healer and damage roles from Seekers on any World Shard. ## [0.1.0-alpha.1] — unreleased diff --git a/CONTEXT.md b/CONTEXT.md index ef0b973c8..850de3184 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -849,8 +849,9 @@ Realm-core's realm-wide list of Seekers and queued Parties, per dungeon area and _Avoid_: LFG queue, matchmaking queue **Seeker**: -One Character in the Meeting Stone Queue, alone or as a member of a queued Party. It carries the -class and team the Gateway supplied. A solo Seeker lasts as long as its Account Claim. +One Character in the Meeting Stone Queue, alone or as a member of a queued Party. It carries its +class and its team, from the class and race the Gateway supplied. A solo Seeker lasts as long as +its Account Claim. _Avoid_: queued player, LFG player **Open Role**: diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index 84f117d72..e2a8fc299 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -236,8 +236,8 @@ pub mod event_kind { /// /// Argument slots (`realm_group_op(op, actor_guid, target_guid, arg_a, arg_b, arg_c)`), per op: /// - [`INVITE`] / [`UNINVITE`]: `target_guid` is the invitee/kicked member; the rest unused. -/// - [`ACCEPT`]: `arg_a` is the acceptor's class and `arg_b` its race, 0 when the Gateway cannot -/// read them. Realm-core holds no Character rows, so the Seeker row a queued party gains needs them. +/// - [`ACCEPT`]: `arg_a` is the acceptor's class, 0 when the Gateway cannot read it. Realm-core +/// holds no Character rows, so the Seeker row a queued party gains needs it. The rest unused. /// - [`DECLINE`]: `actor_guid` alone; every other slot unused. /// - [`LEAVE`]: `actor_guid` leaves; `arg_a` = a [`super::leave_cause`] value. /// - [`LOOT_METHOD`]: `arg_a` = loot setting, `target_guid` = the master looter, `arg_b` = the diff --git a/docs/architecture.md b/docs/architecture.md index b8362fdf5..2a80d1776 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ flowchart TB W0[("lyracore
default world shard")] W1[("lyracore-world-1
world shard (map rule)")] INST[("lyracore-instances
instance pool")] - RC[("lyracore-realm: realm-core
accounts · sessions · claims ·
groups · guilds · chat channels ·
Realm Chat Lines · mail ·
auctions · loot rolls ·
load samples")] + RC[("lyracore-realm: realm-core
accounts · sessions · claims ·
groups · guilds · chat channels ·
Realm Chat Lines · mail ·
auctions · loot rolls ·
Meeting Stone Queue · load samples")] end C1 -- "raw TCP · SRP6 · header-encrypted opcodes" --> LOGON @@ -108,6 +108,7 @@ are answered by the gateway, which is the only component that can see the whole | `CMSG_NAME_QUERY` resolution | same | `presence.rs` (`character_anywhere`) | | friend/ignore add target resolution by name, realm-wide, plus the friend's race for the Enemy Gate. This is the existence check the Module no longer performs, since Realm-core holds no Character rows for it to check | same | `presence.rs` (`resolve_by_name`, `of`), `gateway/src/world/social.rs` (`resolve_add_contact`) | | Speaker Facts: the speaker's race and chat tag for every Realm Chat Line and Chat Channel op | Realm-core holds no Characters and no live entities | `gateway/src/world/handlers/chat.rs` (`speaker_facts`), `stdb/reads/chat.rs` | +| Seeker facts: the race and class of the Character that uses a Meeting Stone, or of every member of the Party it leads, and the class of a Character that accepts an invite into a queued Party. Also whether the client can name the stone's dungeon area, checked before any state changes | Realm-core holds no Characters, and only the Gateway knows which areas the client protocol encodes | `gateway/src/world/handlers/meeting_stone.rs` (`seeker_facts`, the `Area` check in `join`), `gateway/src/world/party.rs` (`AcceptorFacts`) | | channel moderation target names, realm-wide, plus whether the target ignores the actor for an invite | same | `gateway/src/world/handlers/channel.rs` (`resolve_online_character`, `ignores`) | | the per-listener ignore filter on an ignorable Realm Chat Line | the ignore list is on each listener's Home Shard; the line is on Realm-core | `gateway/src/stdb/world_view.rs` (`realm_chat_appeared`, `Viewer::ignores`) | | loot-roll promotion and settlement fan-out across shards | a kill's transaction cannot reach realm-core | `gateway/src/world/loot.rs` | @@ -204,7 +205,7 @@ The realm runs as **four SpacetimeDB databases** behind one gateway tier: | `lyracore` | default database + world shard | | `lyracore-world-1` | world shard (map 1, Kalimdor) | | `lyracore-instances` | instance pool (map 36 / Deadmines and friends) | -| `lyracore-realm` | realm-core: accounts, sessions, Account Claims, groups, guilds, Chat Channels, Realm Chat Lines, mail, auctions, loot rolls, load samples | +| `lyracore-realm` | realm-core: accounts, sessions, Account Claims, groups, guilds, Chat Channels, Realm Chat Lines, mail, auctions, loot rolls, the Meeting Stone Queue, load samples | The **local developer fixture has one database per tier above** (#108) — `lyracore`, `lyracore-kalimdor`, `lyracore-instances`, `lyracore-realm` — brought up by `./lyracore dev up`; @@ -463,6 +464,18 @@ Every relay hangs off a coordinator connection. Row-driven relays take one of tw claim that opens or closes into friend online and offline notices for same-team viewers who list the Character. A recipient with no registered viewer misses the line, and that includes one on a loading screen during a Transfer. Nothing buffers it. +- **Group events in insert order** (`group_event_appeared`). The SDK fires one transaction's + inserts in hash order, so the first `game_group_event` callback of a transaction takes every row + of that transaction for its recipient from the cache, in id order, and the later callbacks find + theirs taken (`GroupEventCursor`, one cursor per source database). A filled Meeting Stone Party's + `COMPLETE` therefore reaches the client before its `SETQUEUE(0, NONE)`. +- **Roster Revision Relay** (`world/party_mirror.rs`). Realm-core can change a party that no actor + of the op belongs to: a Stone Add moves Seekers into a Party from any World Shard. Realm-core's + `game_group_roster_revision` callbacks only mark the party dirty. One worker thread per Gateway + reads Realm-core's revision and pushes the roster to each World Shard whose mirror holds an older + revision or none, through the retries of `sync_group_mirrors_required`. It arms only when + Realm-core is a distinct database and healthy at Gateway start. A change missed while Realm-core + is disconnected is the reconnect reconciliation pass's. - **Viewer lifetime** (`subscribe_player_events`): world entry prepares relay state, registers one viewer, and performs resident-state sweeps. `PlayerSubscriptions` owns only that registration; dropping it removes the viewer. It owns no row callbacks. A world-port removes the source viewer @@ -775,6 +788,7 @@ explains why two rungs of the ladder are written down instead of automated. | [`auction-house-client-check.md`](./auction-house-client-check.md) | The auction house against a real 5875 client. Status: outstanding, needs a human. | | [`guild-client-check.md`](./guild-client-check.md) | Guilds between two real 5875 clients across a Shard Boundary: founding, invites, chat, ranks, Transfer, emblem, Charter and deletion. Status: outstanding, needs a human. | | [`mail-client-check.md`](./mail-client-check.md) | Mail against a real 5875 client. Status: outstanding, needs a human. | +| [`meeting-stone-client-check.md`](./meeting-stone-client-check.md) | Meeting stones between two real 5875 clients across a Shard Boundary: the JOIN packet, tooltip, minimap button, every status line, Transfer, logout and a stone-formed Party. Status: outstanding, needs a human. | | [`duel-client-check.md`](./duel-client-check.md) | Duel visuals against a real 5875 client, which the automated tests cannot see. Status: outstanding. | | [`chat-client-check.md`](./chat-client-check.md) | Realm-wide chat between two real 5875 clients across a Shard Boundary: party, channels and moderation, AFK and DND, whisper, friends, `/who`, ignore, say range, proximity emotes, the language Gate and the flood mute. Status: outstanding, needs a human. | | [`raid-client-check.md`](./raid-client-check.md) | Raids, Group Broadcasts, raid chat, member stats across Shards and the Instance Removal countdown against real 5875 clients. Status: outstanding, needs a human. | diff --git a/docs/meeting-stone-client-check.md b/docs/meeting-stone-client-check.md new file mode 100644 index 000000000..4898bcb23 --- /dev/null +++ b/docs/meeting-stone-client-check.md @@ -0,0 +1,52 @@ +# Meeting stone client check + +Execution status: outstanding. This checklist needs a human with an unmodified 1.12.1 build-5875 +client. The automated suite proves the queue, the Stone Adds and the event order on durable +databases. It cannot prove what the client sends or draws. + +Use only an isolated non-production LyraCore stack. Never point these steps at a production database, +and never use `spacetime publish -c`. Run the stack sharded (`./lyracore dev up`, not `--single`), +so the Deadmines instance is on the Instance Pool and Westfall is on the default World Shard. Import +the `gameobjects` family with this change, or `game_meeting_stone` is empty and every stone is +silent. Run the Gateway at debug log level. + +Prepare three Alliance Characters on two clients: Leader and Seeker inside the Deadmines stone's +level range, and Low below it. Leader needs three playerbots it can invite. + +- [ ] Hover the Deadmines meeting stone in Westfall. Confirm the tooltip shows its level range. +- [ ] On Low, right-click the stone. Confirm the client refuses with its own level message and the + Gateway log shows no meeting stone line for Low. +- [ ] On Seeker, right-click the stone. Confirm the minimap button appears, its tooltip names The + Deadmines, and chat shows the joined line. A `CMSG_GAMEOBJ_USE` on a stone does nothing on + this server, so the button proves the client sent `CMSG_MEETINGSTONE_JOIN`. +- [ ] Click the minimap button and confirm the dialog. Confirm the button goes and chat shows the + left-queue line. +- [ ] Queue Seeker again and enter the Deadmines instance portal. After the loading screen, confirm + the button is still there: the client asked `CMSG_MEETINGSTONE_INFO` from the Instance Pool. +- [ ] Log Seeker out and back in. Confirm no button and no meeting stone line. Logout drops a solo + Seeker silently. +- [ ] Put Leader in a party it does not lead and right-click the stone. Confirm the not-leader + failure line. Repeat as the leader of a Raid and of a full Party, and confirm the raid and the + full-group failure lines. +- [ ] With Seeker inside the Deadmines instance, have Leader invite Seeker and queue the Party at the + stone. Confirm both clients show the button and the joined line, although Seeker is on + another shard. +- [ ] Have Seeker leave that queued Party. Confirm Seeker's button goes, and Leader sees the + member-left line and keeps its button. +- [ ] Invite Seeker again, queue, and kick Seeker. Confirm Leader sees the member-removed line and + loses its button. Confirm Seeker sees the looking-for-a-new-party line and keeps a button. +- [ ] Queue a Party with Leader and Seeker, then pass the lead to Seeker. Confirm both keep the + button and no meeting stone line appears. +- [ ] Queue the Party again and convert it to a Raid. Confirm every member loses the button and sees + the left-queue line. Both cores keep a Raid queued; this server does not. +- [ ] Leave Leader's Party queued for 5 minutes. Confirm every member sees the in-progress line. +- [ ] Queue Seeker alone and send it into the Deadmines instance. Invite three playerbots to Leader + by ordinary invite, with classes that leave a role open that Seeker's class fills, and queue + Leader's Party in Westfall. Confirm Leader sees the member-added line for Seeker, then every + member sees the complete line and loses the button, in that order. +- [ ] In that stone-formed Party, confirm Seeker's party frame lists all five members, `/p` reaches + both clients across the two shards, and a kill near Leader and Seeker in Westfall splits its XP + between them. + +Record the server commit, client build shown on the login screen, Character names and levels, the +stone's level range, each chat line seen, and any visible failure or disconnect. diff --git a/docs/schema.md b/docs/schema.md index 23d82cea1..e2b99debc 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -54,14 +54,14 @@ publish presents as an unrelated mid-session hang, not a loud "no such table". ## 2. Inventory -**281 tables**, all of them in `module/src/**`: 128 public, 153 private. No table comes from a +**285 tables**, all of them in `module/src/**`: 128 public, 157 private. No table comes from a package in this tree; `packages/example` is the only in-tree package and it declares none. Recount rather than trust the numbers below, which drift on every schema change. The pattern matches both `#[table(...)]` and the fully qualified `#[spacetimedb::table(...)]`; missing the second form undercounts. The second command lists the tables per file, which is what the rows below add up: ```bash -grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 281 on 2026-09-25 +grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 285 on 2026-10-03 grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ':0$' # tables per file ``` @@ -81,6 +81,7 @@ grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ': | GameObject | 10 | 6 | `gameobject.rs`, `go_model.rs`, `go_collider.rs` | | Loot | 13 | 6 | `loot/*` | | Group / party | 8 | 5 | `group.rs` | +| Meeting stones | 4 | 0 | `meeting_stone.rs` | | Guild | 9 | 0 | `guild/mod.rs`, `guild/fee.rs`, `guild/membership.rs`, `guild/petition.rs` | | Instance / encounter | 8 | 1 | `instance.rs`, `encounter.rs` | | Sharding: region, transfer, load | 9 | 0 | `region.rs`, `transfer/mod.rs`, `load.rs` | @@ -588,11 +589,11 @@ Two constraints survive the removal and bind any filter added later. ## 6. Scheduled tables -**26 scheduled tables** drive every periodic and deferred effect in the game. Nothing on a gateway +**27 scheduled tables** drive every periodic and deferred effect in the game. Nothing on a gateway timer decides gameplay. Recount and re-list them with: ```bash -grep -rn 'scheduled(' module/src --include='*.rs' # 26 tables plus 4 comment lines, 2026-09-24 +grep -rn 'scheduled(' module/src --include='*.rs' # 27 tables plus 4 comment lines, 2026-10-03 ``` | Scheduled table | Reducer | Cadence | Where | @@ -608,6 +609,7 @@ grep -rn 'scheduled(' module/src --include='*.rs' # 26 tables plus 4 comment l | `game_event_reaper_schedule` | `reap_movement_events` | 1 s (`EVENT_TTL_MICROS`) | `gc.rs:24` | | `game_transfer_reaper_schedule` | `reap_transfers` | 5 s, armed lazily by `begin_transfer` | `transfer/mod.rs:250` | | `game_mail_escrow_reaper_schedule` | `reap_mail_escrows` | 5 s, armed lazily | `mail_escrow.rs:68` | +| `game_meeting_stone_reminder_schedule` | `remind_queued_parties` | 5 s; a queued Party hears `IN_PROGRESS` every 5 min | `meeting_stone.rs:94` | | `game_pet_care_schedule` | `tick_pet_care` | 7.5 s | `creatures/pet_care.rs:18` | | `game_gateway_lease_reaper_schedule` | `reap_gateway_leases` | 15 s | `gw.rs:71` | | `game_instance_reaper_schedule` | `reap_instances` | 60 s | `instance.rs:312` | @@ -631,8 +633,8 @@ externally. ⚠ Re-arming after a schema change is a real operational step: a republish can leave a schedule row stale, because `init` does not re-run on an auto-migrating publish. `debug_repair_after_publish` -re-arms the motion, creature-tick, aura, ground-area, weather, gateway-lease and instance-reaper -schedules, restores a missing Auction expiry, re-tags legacy auction mail as Auction Mail once, arms +re-arms the motion, creature-tick, aura, ground-area, weather, gateway-lease, instance-reaper and +meeting stone reminder schedules, restores a missing Auction expiry, re-tags legacy auction mail as Auction Mail once, arms a Mail Timer for each Mail that has none, and re-seeds every fixture family `init` seeds. It does not repair every scheduled table: the event reaper and melee schedules are outside this reducer. **Nothing runs it for you.** The operator calls it by hand on every shard after every publish: diff --git a/gateway/src/stdb/bindings/debug_stage_meeting_stone_claim_reducer.rs b/gateway/src/stdb/bindings/debug_stage_meeting_stone_claim_reducer.rs new file mode 100644 index 000000000..7aebf5827 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_stage_meeting_stone_claim_reducer.rs @@ -0,0 +1,74 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugStageMeetingStoneClaimArgs { + pub guid: u64, + pub account_id: u64, +} + +impl From for super::Reducer { + fn from(args: DebugStageMeetingStoneClaimArgs) -> Self { + Self::DebugStageMeetingStoneClaim { + guid: args.guid, + account_id: args.account_id, + } + } +} + +impl __sdk::InModule for DebugStageMeetingStoneClaimArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_stage_meeting_stone_claim`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_stage_meeting_stone_claim { + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_claim` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_stage_meeting_stone_claim:debug_stage_meeting_stone_claim_then`] to run a callback after the reducer completes. + fn debug_stage_meeting_stone_claim(&self, guid: u64, account_id: u64) -> __sdk::Result<()> { + self.debug_stage_meeting_stone_claim_then(guid, account_id, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_stage_meeting_stone_claim` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_stage_meeting_stone_claim_then( + &self, + guid: u64, + account_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_stage_meeting_stone_claim for super::RemoteReducers { + fn debug_stage_meeting_stone_claim_then( + &self, + guid: u64, + account_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugStageMeetingStoneClaimArgs { guid, account_id }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 574b26f1b..80010698a 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -282,6 +282,7 @@ pub mod debug_stage_mail_expiry_fixture_reducer; pub mod debug_stage_mail_legacy_fixture_reducer; pub mod debug_stage_mail_take_fixture_reducer; pub mod debug_stage_meeting_stone_character_reducer; +pub mod debug_stage_meeting_stone_claim_reducer; pub mod debug_stage_meeting_stone_group_reducer; pub mod debug_stage_meeting_stone_reducer; pub mod debug_stage_ranged_lethal_damage_floor_fixture_reducer; @@ -1446,6 +1447,7 @@ pub use debug_stage_mail_expiry_fixture_reducer::debug_stage_mail_expiry_fixture pub use debug_stage_mail_legacy_fixture_reducer::debug_stage_mail_legacy_fixture; pub use debug_stage_mail_take_fixture_reducer::debug_stage_mail_take_fixture; pub use debug_stage_meeting_stone_character_reducer::debug_stage_meeting_stone_character; +pub use debug_stage_meeting_stone_claim_reducer::debug_stage_meeting_stone_claim; pub use debug_stage_meeting_stone_group_reducer::debug_stage_meeting_stone_group; pub use debug_stage_meeting_stone_reducer::debug_stage_meeting_stone; pub use debug_stage_ranged_lethal_damage_floor_fixture_reducer::debug_stage_ranged_lethal_damage_floor_fixture; @@ -3103,6 +3105,10 @@ pub enum Reducer { y: f32, z: f32, }, + DebugStageMeetingStoneClaim { + guid: u64, + account_id: u64, + }, DebugStageMeetingStoneGroup { leader_guid: u64, member_guids: Vec, @@ -4558,6 +4564,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugStageMeetingStoneCharacter { .. } => { "debug_stage_meeting_stone_character" } + Reducer::DebugStageMeetingStoneClaim { .. } => "debug_stage_meeting_stone_claim", Reducer::DebugStageMeetingStoneGroup { .. } => "debug_stage_meeting_stone_group", Reducer::DebugStageRangedLethalDamageFloorFixture { .. } => { "debug_stage_ranged_lethal_damage_floor_fixture" @@ -6224,6 +6231,13 @@ Reducer::DebugStageMeetingStone{ x: x.clone(), y: y.clone(), z: z.clone(), +}), + Reducer::DebugStageMeetingStoneClaim{ + guid, + account_id, +} => __sats::bsatn::to_vec(&debug_stage_meeting_stone_claim_reducer::DebugStageMeetingStoneClaimArgs { + guid: guid.clone(), + account_id: account_id.clone(), }), Reducer::DebugStageMeetingStoneGroup{ leader_guid, diff --git a/gateway/src/stdb/member_stats_relay.rs b/gateway/src/stdb/member_stats_relay.rs index 958b88847..077df35ce 100644 --- a/gateway/src/stdb/member_stats_relay.rs +++ b/gateway/src/stdb/member_stats_relay.rs @@ -118,6 +118,7 @@ mod tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }) } diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 0e0688896..49545f302 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -85,6 +85,7 @@ impl PlayerSubscriptions { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }); view.add_viewer_on_shard( viewer.clone(), @@ -3963,6 +3964,7 @@ impl Coordinator { ignored: Mutex::new(ignored), friends: Mutex::new(friends), team, + group_events: Default::default(), }); view.add_viewer( self, @@ -4650,6 +4652,7 @@ mod tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), } } @@ -7153,7 +7156,7 @@ mod tests { let body = decommented(top_level_fn_body_of("world_view.rs", "arm_realm_private")); let compact: String = body.chars().filter(|c| !c.is_whitespace()).collect(); assert!( - compact.contains("wire_insert_live(db.game_group_event(),\"realm.game_group_event.insert\",&view,move|v,row|group_event_appeared(v,&coord,row));"), + compact.contains("wire_group_events(db,\"realm.game_group_event.insert\",&view,coord.clone(),GroupEventSource::RealmCore);"), "arm_realm_private no longer relays realm-core group events through \ `group_event_appeared` (which also carries the QUEST_SHARE detail JOIN through a \ WORLD handle — realm-core's cache has no quest catalogue)" @@ -8301,5 +8304,9 @@ mod character_gone_durable_tests; #[path = "subscriptions_roster_relay_durable_tests.rs"] mod roster_relay_durable_tests; +#[cfg(test)] +#[path = "subscriptions_meeting_stone_durable_tests.rs"] +mod meeting_stone_durable_tests; + #[cfg(test)] include!(concat!(env!("OUT_DIR"), "/package-coordinator-tests.rs")); diff --git a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs new file mode 100644 index 000000000..26a886be7 --- /dev/null +++ b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs @@ -0,0 +1,401 @@ +//! Meeting Stone Seekers on two World Shards meet in one queue on Realm-core. Each Seeker uses its +//! stone through the real Coordinator, the stone forms one Party, the Roster Revision Relay carries +//! it to both World Shard mirrors, and the group event relay gives each session its stone events +//! in the order the Module inserted them. + +use super::roster_relay_durable_tests::{mirrors_follow_realm_core, TopologyEnv}; +use super::*; +use crate::accept::BlockingTaskCapacity; +use crate::config::GatewayConfig; +use crate::durable_test_support::{module_bytes, poll_until, Standalone, POLL_TIMEOUT}; +use crate::world::{ + dispatch_meeting_stone_action, MeetingStoneActionOutcome, MeetingStonePlayer, Outbound, + WorldSessionToken, +}; +use lyracore_shared::meeting_stone::queue_status::{JOINED_QUEUE, NONE}; +use std::sync::mpsc::Receiver; +use wow_world_messages::vanilla::opcodes::ClientOpcodeMessage; +use wow_world_messages::vanilla::CMSG_MEETINGSTONE_JOIN; +use wow_world_messages::Guid; + +const INSTANCES: &str = "meeting-stone-instances"; +const REALM: &str = "meeting-stone-realm"; + +// Fixture ids sit in 509_6000-509_6099. +const STONE_ENTRY: &str = "5096000"; +const WORLD_STONE: u64 = 509_6001; +const INSTANCES_STONE: u64 = 509_6002; +/// The Deadmines (AreaTable 1581). +const DEADMINES: u32 = 1581; +const STONE_AT: [&str; 3] = ["-11208", "1672", "24"]; +/// The default World Shard serves map 0; the shard map sends map 36 to the Instance Pool. +const WORLD_MAP: &str = "0"; +const INSTANCES_MAP: &str = "36"; + +const HUMAN: &str = "1"; +const WARRIOR: &str = "1"; +const PRIEST: &str = "5"; +const MAGE: &str = "8"; +const ROGUE: &str = "4"; +const HUNTER: &str = "3"; + +/// In queue order. The Warrior waits longest, so it leads; the Priest heals; the rest deal damage. +const TANK: u64 = 509_6010; +const HEALER: u64 = 509_6011; +const MAGE_SEEKER: u64 = 509_6012; +const ROGUE_SEEKER: u64 = 509_6013; +const HUNTER_SEEKER: u64 = 509_6014; +const SEEKERS: [u64; 5] = [TANK, HEALER, MAGE_SEEKER, ROGUE_SEEKER, HUNTER_SEEKER]; + +/// The World Session Token the fixture claims carry: each Seeker owns the Account of its guid. +fn token(guid: u64) -> WorldSessionToken { + WorldSessionToken { + account_id: guid, + generation: 1, + request_nonce: u128::from(guid), + } +} + +/// A Seeker at the stone on `database`, with its claim on that database and on Realm-core. +fn stage_seeker(standalone: &Standalone, database: &str, map: &str, guid: u64, class: &str) { + let guid = guid.to_string(); + standalone.assert_call_database( + database, + "debug_stage_meeting_stone_character", + &[ + &guid, + &guid, + HUMAN, + class, + "18", + map, + STONE_AT[0], + STONE_AT[1], + STONE_AT[2], + ], + ); + standalone.assert_call_database(REALM, "debug_stage_meeting_stone_claim", &[&guid, &guid]); +} + +fn stage_stone(standalone: &Standalone, database: &str, map: &str, go_guid: u64) { + standalone.assert_call_database( + database, + "debug_stage_meeting_stone", + &[ + STONE_ENTRY, + &go_guid.to_string(), + "10", + "30", + &DEADMINES.to_string(), + map, + STONE_AT[0], + STONE_AT[1], + STONE_AT[2], + ], + ); +} + +fn use_stone(session: &Coordinator, guid: u64, stone: u64) -> Vec { + send( + session, + guid, + ClientOpcodeMessage::CMSG_MEETINGSTONE_JOIN(CMSG_MEETINGSTONE_JOIN { + guid: Guid::new(stone), + }), + ) +} + +fn send(session: &Coordinator, guid: u64, msg: ClientOpcodeMessage) -> Vec { + let player = MeetingStonePlayer { + account_id: guid, + self_guid: Some(guid), + }; + match dispatch_meeting_stone_action(session, player, msg).unwrap() { + MeetingStoneActionOutcome::Handled { outbound } => outbound, + MeetingStoneActionOutcome::PassThrough(_) => panic!("the meeting stone seam passed it on"), + } +} + +/// The meeting stone messages in `outbound`, running each relay job the way the session writer +/// does. +fn stone_messages(outbound: Vec) -> Vec { + let mut messages = Vec::new(); + for item in outbound { + match item { + Outbound::One(msg) => messages.push(msg), + Outbound::Batch(batch) => messages.extend(batch), + Outbound::Job(job) => messages.extend(stone_messages(job())), + Outbound::Raw { .. } => {} + } + } + messages.retain(|msg| { + matches!( + msg, + ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE(_) + | ServerOpcodeMessage::SMSG_MEETINGSTONE_MEMBER_ADDED(_) + | ServerOpcodeMessage::SMSG_MEETINGSTONE_IN_PROGRESS + | ServerOpcodeMessage::SMSG_MEETINGSTONE_COMPLETE + ) + }); + messages +} + +fn set_queue(area_id: u32, status: u8) -> ServerOpcodeMessage { + ServerOpcodeMessage::SMSG_MEETINGSTONE_SETQUEUE( + codec::build_meetingstone_setqueue(area_id, status).unwrap(), + ) +} + +fn member_added(guid: u64) -> ServerOpcodeMessage { + ServerOpcodeMessage::SMSG_MEETINGSTONE_MEMBER_ADDED(codec::build_meetingstone_member_added( + guid, + )) +} + +/// One Seeker's World Session as the relay sees it: its registration and what reached its queue. +struct Session { + guid: u64, + _registration: PlayerSubscriptions, + rx: Receiver, + heard: Vec, +} + +impl Session { + fn open(view: Arc, guid: u64) -> Self { + let (tx, rx, _) = crate::world::session_channel(); + let arrival = codec::EntityView::default(); + Self { + guid, + _registration: PlayerSubscriptions::registered_for_test(view, guid, &arrival, tx), + rx, + heard: Vec::new(), + } + } + + fn drain(&mut self) { + let queued: Vec = self.rx.try_iter().collect(); + self.heard.extend(stone_messages(queued)); + } +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +// One topology stays alive from staging to the filled Party to prove one realm-wide scenario. +#[allow(clippy::too_many_lines)] +fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { + let mut standalone = Standalone::start_persistent("meeting-stone-world"); + standalone.publish_module(); + for database in [INSTANCES, REALM] { + standalone.publish_named_module_bytes(database, module_bytes()); + } + standalone.assert_call("claim_operator", &[]); + for database in [INSTANCES, REALM] { + standalone.assert_call_database(database, "claim_operator", &[]); + } + let world_db = standalone.shard_name().to_string(); + stage_stone(&standalone, &world_db, WORLD_MAP, WORLD_STONE); + stage_stone(&standalone, INSTANCES, INSTANCES_MAP, INSTANCES_STONE); + for (database, map, guid, class) in [ + (world_db.as_str(), WORLD_MAP, TANK, WARRIOR), + (INSTANCES, INSTANCES_MAP, HEALER, PRIEST), + (world_db.as_str(), WORLD_MAP, MAGE_SEEKER, MAGE), + (world_db.as_str(), WORLD_MAP, ROGUE_SEEKER, ROGUE), + (INSTANCES, INSTANCES_MAP, HUNTER_SEEKER, HUNTER), + ] { + stage_seeker(&standalone, database, map, guid, class); + } + + let _topology = TopologyEnv::install(&format!("36:*={INSTANCES}"), REALM); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: standalone.server().into(), + module_name: standalone.shard_name().into(), + coordinator_token: Some(standalone.owner_token()), + gateway_id: "meeting-stone-test".into(), + blocking_task_capacity: BlockingTaskCapacity::new(1), + }; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let gateway = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + gateway.spawn_roster_revision_relay(); + let realm = gateway.realm_core().unwrap(); + let instances = gateway.shard_handle(INSTANCES).unwrap(); + let shards = gateway.world_shards(); + assert_eq!(shards.len(), 2, "the default World Shard and {INSTANCES}"); + assert!(poll_until(POLL_TIMEOUT, || { + gateway.meeting_stone_area(WORLD_STONE).unwrap() == Some(DEADMINES) + && instances.meeting_stone_area(INSTANCES_STONE).unwrap() == Some(DEADMINES) + && SEEKERS.iter().all(|&guid| { + realm + .0 + .coord() + .conn + .db + .game_account_claim() + .account_id() + .find(&guid) + .is_some_and(|claim| claim.character_guid == guid) + }) + })); + let bind = |home: &Coordinator, guid: u64| { + let _runtime = runtime.enter(); + home.bind_session(token(guid)).unwrap() + }; + let mut sessions: Vec = SEEKERS + .iter() + .map(|&guid| Session::open(gateway.world_view(), guid)) + .collect(); + + // The tank queues alone on the default World Shard. + assert!(use_stone(&bind(&gateway, TANK), TANK, WORLD_STONE).is_empty()); + assert!(poll_until(POLL_TIMEOUT, || realm + .queued_area(TANK) + .unwrap() + == Some(DEADMINES))); + + // It crosses into the Instance Pool, which holds it now. Its Account Claim stays on + // Realm-core, so it stays queued, and the status query on arrival says so. + stage_seeker(&standalone, INSTANCES, INSTANCES_MAP, TANK, WARRIOR); + standalone.assert_call("debug_delete_character", &[&TANK.to_string()]); + assert!(poll_until(POLL_TIMEOUT, || { + gateway.character_by_guid(TANK).unwrap().is_none() + && instances.character_by_guid(TANK).unwrap().is_some() + })); + let arrived = stone_messages(send( + &bind(&instances, TANK), + TANK, + ClientOpcodeMessage::CMSG_MEETINGSTONE_INFO, + )); + assert_eq!(arrived, [set_queue(DEADMINES, JOINED_QUEUE)]); + + // Three Seekers wait on the Instance Pool now and two on the default World Shard. The fifth + // JOIN forms the Party and fills it in the same transaction. + for (home, guid, stone) in [ + (&instances, HEALER, INSTANCES_STONE), + (&gateway, MAGE_SEEKER, WORLD_STONE), + (&gateway, ROGUE_SEEKER, WORLD_STONE), + (&instances, HUNTER_SEEKER, INSTANCES_STONE), + ] { + assert!(use_stone(&bind(home, guid), guid, stone).is_empty()); + } + + assert!( + poll_until(POLL_TIMEOUT, || realm + .group_roster(TANK) + .is_some_and(|roster| roster.member_guids() == SEEKERS)), + "Realm-core holds {:?}", + realm.group_roster(TANK).map(|roster| roster.member_guids()) + ); + let group_id = realm.group_roster(TANK).unwrap().group_id; + assert!(poll_until(POLL_TIMEOUT, || realm + .0 + .coord() + .conn + .db + .game_meeting_stone_seeker() + .count() + == 0)); + assert!(standalone + .query_database_rows(REALM, "SELECT * FROM game_meeting_stone_party") + .is_empty()); + + // No party op ran for these Characters, so only the Roster Revision Relay moved the mirrors. + assert!( + poll_until(POLL_TIMEOUT, || mirrors_follow_realm_core( + &realm, &shards, group_id, TANK + )), + "Realm-core revision {:?}, mirrors {:?}", + realm.held_roster_revision(group_id), + shards + .iter() + .map(|(name, shard)| ( + name, + shard.held_roster_revision(group_id), + shard.group_roster(TANK).map(|roster| roster.member_guids()) + )) + .collect::>() + ); + + let joined = || set_queue(DEADMINES, JOINED_QUEUE); + let closing = || { + [ + ServerOpcodeMessage::SMSG_MEETINGSTONE_COMPLETE, + set_queue(0, NONE), + ] + }; + let expected: Vec<(u64, Vec)> = vec![ + ( + TANK, + [ + joined(), + member_added(HEALER), + joined(), + member_added(MAGE_SEEKER), + member_added(ROGUE_SEEKER), + member_added(HUNTER_SEEKER), + ] + .into_iter() + .chain(closing()) + .collect(), + ), + ( + HEALER, + [ + joined(), + joined(), + member_added(MAGE_SEEKER), + member_added(ROGUE_SEEKER), + member_added(HUNTER_SEEKER), + ] + .into_iter() + .chain(closing()) + .collect(), + ), + ( + MAGE_SEEKER, + [ + joined(), + member_added(ROGUE_SEEKER), + member_added(HUNTER_SEEKER), + ] + .into_iter() + .chain(closing()) + .collect(), + ), + ( + ROGUE_SEEKER, + [joined(), member_added(HUNTER_SEEKER)] + .into_iter() + .chain(closing()) + .collect(), + ), + ( + HUNTER_SEEKER, + [joined()].into_iter().chain(closing()).collect(), + ), + ]; + let complete = poll_until(POLL_TIMEOUT, || { + sessions.iter_mut().all(|session| { + session.drain(); + let want = &expected + .iter() + .find(|(guid, _)| *guid == session.guid) + .unwrap() + .1; + session.heard.len() >= want.len() + }) + }); + for session in &sessions { + let want = &expected + .iter() + .find(|(guid, _)| *guid == session.guid) + .unwrap() + .1; + assert_eq!( + &session.heard, want, + "Seeker {} heard its stone events out of order or incomplete", + session.guid + ); + } + assert!(complete); +} diff --git a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs index 26e73a9ba..051896726 100644 --- a/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs @@ -6,14 +6,14 @@ use crate::world::party::PartyOutcome; use lyracore_shared::group::realm_op; /// Holds the process-wide topology variables for the whole test, then restores them. -struct TopologyEnv { +pub(super) struct TopologyEnv { shard_map: Option, realm_core: Option, _guard: std::sync::MutexGuard<'static, ()>, } impl TopologyEnv { - fn install(shard_map: &str, realm_core: &str) -> Self { + pub(super) fn install(shard_map: &str, realm_core: &str) -> Self { let guard = DURABLE_TOPOLOGY_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -43,7 +43,7 @@ impl Drop for TopologyEnv { /// Every World Shard holds Realm-core's Roster Revision for the party and, while it exists, its /// members. -fn mirrors_follow_realm_core( +pub(super) fn mirrors_follow_realm_core( realm: &Coordinator, shards: &[(String, Coordinator)], group_id: u64, @@ -108,7 +108,7 @@ fn a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror() { ); assert_eq!( realm - .realm_group_op(realm_op::ACCEPT, MEMBER, 0, 1, 1, 0) + .realm_group_op(realm_op::ACCEPT, MEMBER, 0, 1, 0, 0) .unwrap(), PartyOutcome::Ran ); diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index caf3a1ec5..04fcde044 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -108,6 +108,9 @@ pub(crate) struct Viewer { /// at world entry from its race. A friend online/offline notice goes only to a same-team /// viewer, the faction separation the friends pane has in vanilla. pub(crate) team: u32, + /// The group events already relayed to this viewer, so one transaction's rows go out once + /// and in order. + pub(crate) group_events: GroupEventCursor, } impl Viewer { @@ -1092,15 +1095,13 @@ fn register_shard_callbacks( &view, |v, row| mail_arrived(v, row), ); - { - let coord = coord.clone(); - wire_insert_live( - db.game_group_event(), - "game_group_event.insert", - &view, - move |v, row| group_event_appeared(v, &coord, row), - ); - } + wire_group_events( + db, + "game_group_event.insert", + &view, + coord.clone(), + GroupEventSource::Shard(shard), + ); { let insert_coord = coord.clone(); wire_insert( @@ -1269,15 +1270,13 @@ pub(crate) fn arm_realm_private(view: Arc, realm: Coordinator, coord: &view, |v, row| auction_notice_appeared(v, row), ); - { - let coord = coord.clone(); - wire_insert_live( - db.game_group_event(), - "realm.game_group_event.insert", - &view, - move |v, row| group_event_appeared(v, &coord, row), - ); - } + wire_group_events( + db, + "realm.game_group_event.insert", + &view, + coord.clone(), + GroupEventSource::RealmCore, + ); wire_insert_live( db.game_realm_chat_event(), "realm.game_realm_chat_event.insert", @@ -1479,6 +1478,26 @@ fn wire_insert_live( }); } +/// [`wire_insert_live`] for `game_group_event` from `source`. The dispatcher also gets the cached +/// table, which already holds the whole transaction when the callback runs. +fn wire_group_events( + db: &RemoteTables, + label: &'static str, + view: &Arc, + coord: Coordinator, + source: GroupEventSource, +) { + let view = view.clone(); + db.game_group_event().on_insert(move |ctx, row| { + if is_initial_apply(&ctx.event) { + return; + } + guarded(label, || { + group_event_appeared(&view, &coord, source, row, ctx.db.game_group_event().iter()); + }); + }); +} + /// Register an UPDATE callback (see [`wire_insert`]); `f` gets `(view, old, new)`. fn wire_update( table: T, @@ -2722,10 +2741,16 @@ fn duel_event_appeared(view: &WorldView, coord: &Coordinator, shard: ShardId, ro } } -/// A group/loot-roll/quest-share event landed → the kind-decoded packet to the row's RECIPIENT -/// and nobody else (`coord` feeds the QUEST_SHARE detail -/// JOIN inside the job). -fn group_event_appeared(view: &WorldView, coord: &Coordinator, row: &GroupEvent) { +/// A group/loot-roll/quest-share event landed → the kind-decoded packets to the row's RECIPIENT +/// and nobody else (`coord` feeds the QUEST_SHARE detail JOIN inside the job). The recipient gets +/// its rows of one transaction in the order the Module inserted them ([`GroupEventCursor::take`]). +fn group_event_appeared( + view: &WorldView, + coord: &Coordinator, + source: GroupEventSource, + row: &GroupEvent, + cached: impl IntoIterator, +) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; }; @@ -2735,18 +2760,74 @@ fn group_event_appeared(view: &WorldView, coord: &Coordinator, row: &GroupEvent) if !super::subscriptions::private_recipient_audience(row.recipient_guid, viewer.self_guid) { return; } - let (row, coord) = (row.clone(), coord.clone()); + let rows = viewer.group_events.take(source, row, cached); + if rows.is_empty() { + return; + } + let coord = coord.clone(); let self_guid = viewer.self_guid; enqueue(viewer.clone(), move |viewer| { - let packets = super::subscriptions::group_event_outbound(&coord, self_guid, &row); - // A party frame sets every member's online flag, so Member Stats start over behind it. - if row.kind == lyracore_shared::group::event_kind::LIST { - viewer.member_stats.forget_all(); + let mut packets = Vec::new(); + for row in &rows { + packets.extend(super::subscriptions::group_event_outbound( + &coord, self_guid, row, + )); + // A party frame sets every member's online flag, so Member Stats start over behind it. + if row.kind == lyracore_shared::group::event_kind::LIST { + viewer.member_stats.forget_all(); + } } packets }); } +/// The database a `game_group_event` row came from. Each database numbers its rows itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum GroupEventSource { + Shard(ShardId), + RealmCore, +} + +/// The newest `game_group_event` id relayed to one viewer from each source database. +#[derive(Default)] +pub(crate) struct GroupEventCursor(Mutex>); + +impl GroupEventCursor { + /// The rows to relay for the insert callback of `row`, oldest first. + /// + /// The SDK fires the inserts of one transaction in hash order, and the client must hear them + /// in insert order: a filled Meeting Stone party's `COMPLETE` comes before `SETQUEUE(0, NONE)`. + /// So the first callback of a transaction takes every row of that transaction for this + /// recipient from `cached`, which already holds the whole transaction, and the later callbacks + /// find theirs taken. Rows of one transaction share `created_at`. + fn take( + &self, + source: GroupEventSource, + row: &GroupEvent, + cached: impl IntoIterator, + ) -> Vec { + let mut cursors = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let cursor = cursors.entry(source).or_default(); + if row.id <= *cursor { + return Vec::new(); + } + let mut rows: Vec = cached + .into_iter() + .filter(|cached| { + cached.recipient_guid == row.recipient_guid + && cached.created_at == row.created_at + && cached.id > *cursor + }) + .collect(); + if !rows.iter().any(|cached| cached.id == row.id) { + rows.push(row.clone()); + } + rows.sort_unstable_by_key(|cached| cached.id); + *cursor = rows.last().map_or(row.id, |last| last.id); + rows + } +} + /// A shard mirror delta is the point at which viewer-relative Loot Tag flags can be projected from /// current membership without racing the realm event that requested the mirror write. fn group_member_mirror_changed( @@ -3318,6 +3399,7 @@ mod family_audience_tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }) } @@ -4540,6 +4622,7 @@ mod family_audience_tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }); view.add_viewer_on_shard(old.clone(), CellKey::at(0, 0, 0, 0), 3); view.add_viewer_on_shard(replacement.clone(), CellKey::at(1, 2, 0, 0), 4); @@ -5318,6 +5401,7 @@ mod realm_chat_relay_tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }); view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); (viewer, rx) @@ -5627,6 +5711,7 @@ mod account_claim_relay_tests { ignored: Mutex::default(), friends: Mutex::new(HashSet::from([FRIEND])), team, + group_events: Default::default(), }); view.add_viewer_on_shard(viewer.clone(), CellKey::at(0, 0, 0, 0), shard); (viewer, rx) @@ -5930,6 +6015,80 @@ mod account_claim_relay_tests { } } +#[cfg(test)] +mod group_event_order_tests { + use super::{GroupEvent, GroupEventCursor, GroupEventSource}; + + const RECIPIENT: u64 = 7; + + fn event(id: u64, recipient_guid: u64, created_micros: i64) -> GroupEvent { + GroupEvent { + id, + recipient_identity: spacetimedb_sdk::Identity::ZERO, + kind: 0, + other_guid: 0, + other_name: String::new(), + created_at: spacetimedb_sdk::Timestamp::from_micros_since_unix_epoch(created_micros), + payload: String::new(), + recipient_guid, + } + } + + fn ids(rows: &[GroupEvent]) -> Vec { + rows.iter().map(|row| row.id).collect() + } + + /// One transaction wrote rows 10 to 12 for the recipient and row 13 for someone else. Row 5 is + /// an older transaction's. The SDK fires the inserts in hash order; the recipient still gets + /// 10, 11, 12 once, in that order. + #[test] + fn the_first_callback_of_a_transaction_relays_its_rows_in_insert_order() { + let cached = [ + event(5, RECIPIENT, 100), + event(12, RECIPIENT, 200), + event(10, RECIPIENT, 200), + event(13, 8, 200), + event(11, RECIPIENT, 200), + ]; + let cursor = GroupEventCursor::default(); + let source = GroupEventSource::RealmCore; + + let first = cursor.take(source, &cached[1], cached.clone()); + let later: Vec = [&cached[2], &cached[4]] + .into_iter() + .flat_map(|row| cursor.take(source, row, cached.clone())) + .collect(); + + assert_eq!(ids(&first), [10, 11, 12]); + assert!(later.is_empty()); + } + + /// The next transaction starts after the cursor, even with the same timestamp, and another + /// database's ids are counted apart. + #[test] + fn each_source_database_keeps_its_own_cursor() { + let cursor = GroupEventCursor::default(); + let realm = [event(10, RECIPIENT, 200), event(11, RECIPIENT, 200)]; + cursor.take(GroupEventSource::RealmCore, &realm[0], realm.clone()); + + let next = [ + event(10, RECIPIENT, 200), + event(11, RECIPIENT, 200), + event(14, RECIPIENT, 200), + ]; + let shard = [event(3, RECIPIENT, 200)]; + + assert_eq!( + ids(&cursor.take(GroupEventSource::RealmCore, &next[2], next.clone())), + [14] + ); + assert_eq!( + ids(&cursor.take(GroupEventSource::Shard(0), &shard[0], shard.clone())), + [3] + ); + } +} + // =============================================================================================== // The pump→writer hand-off // =============================================================================================== diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index ff024f5f0..b8e496fc0 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -56,6 +56,7 @@ fn viewer(session: SessionId, self_guid: u64, tx: SessionTx) -> Arc { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }) } diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index 58d55040a..f8d347770 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -56,6 +56,7 @@ fn viewer(session: SessionId, self_guid: u64, tx: SessionTx) -> Arc { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }) } diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 979c7a2d8..a3fd10bf8 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -53,22 +53,22 @@ pub mod whisper; mod who; use coalesce::CoalesceState; pub(crate) use handlers::{ - character_facts, reconcile_deleted_guild_characters, DurableCharacterFacts, GuildCleanup, + character_facts, dispatch_meeting_stone_action, reconcile_deleted_guild_characters, + DurableCharacterFacts, GuildCleanup, MeetingStoneActionOutcome, MeetingStonePlayer, }; use handlers::{ decode_auction_browse, dispatch_auction_action, dispatch_auction_browse_action, dispatch_cast, dispatch_channel_action, dispatch_chat_action, dispatch_duel_action, dispatch_guild_action, - dispatch_item_action, dispatch_loot_window, dispatch_meeting_stone_action, - dispatch_melee_action, dispatch_member_stats, dispatch_quest_action, dispatch_taxi_action, - dispatch_vendor_action, handle_bank, handle_char, handle_combat, handle_loot, handle_mail, - handle_query, handle_trade, handle_trainer, quest_giver_menu, queue_reply_then_arm, - AuctionActionOutcome, AuctionActionPlayer, CastOutcome, CastPlayer, CastTransition, - ChannelActionOutcome, ChatActionOutcome, ChatActionPlayer, DuelActionOutcome, DuelActionPlayer, - GuildActionOutcome, GuildActionPlayer, ItemActionOutcome, ItemActionPlayer, LootWindowOutcome, - LootWindowPlayer, MeetingStoneActionOutcome, MeetingStonePlayer, MeleeActionOutcome, - MeleeActionPlayer, MemberStatsOutcome, MemberStatsPlayer, OpenLootState, QuestActionOutcome, - QuestActionPlayer, TaxiActionOutcome, TaxiActionPlayer, VendorActionOutcome, - VendorActionPlayer, CMSG_AUCTION_LIST_ITEMS_OPCODE, + dispatch_item_action, dispatch_loot_window, dispatch_melee_action, dispatch_member_stats, + dispatch_quest_action, dispatch_taxi_action, dispatch_vendor_action, handle_bank, handle_char, + handle_combat, handle_loot, handle_mail, handle_query, handle_trade, handle_trainer, + quest_giver_menu, queue_reply_then_arm, AuctionActionOutcome, AuctionActionPlayer, CastOutcome, + CastPlayer, CastTransition, ChannelActionOutcome, ChatActionOutcome, ChatActionPlayer, + DuelActionOutcome, DuelActionPlayer, GuildActionOutcome, GuildActionPlayer, ItemActionOutcome, + ItemActionPlayer, LootWindowOutcome, LootWindowPlayer, MeleeActionOutcome, MeleeActionPlayer, + MemberStatsOutcome, MemberStatsPlayer, OpenLootState, QuestActionOutcome, QuestActionPlayer, + TaxiActionOutcome, TaxiActionPlayer, VendorActionOutcome, VendorActionPlayer, + CMSG_AUCTION_LIST_ITEMS_OPCODE, }; pub(crate) use handlers::{ member_stats_tick, zone_weather_message, AuctionBrowseRequest, AuctionPage, AuctionQuery, diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 1e9b1b6d3..bd4da30b8 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -399,30 +399,27 @@ impl GroupBroadcastCooldowns { /// `realm_group_op`'s argument slots after the actor: `(op, target_guid, arg_a, arg_b, arg_c)`. type RealmOpArgs = (u8, u64, u8, u8, u64); -/// The class and race of a Character accepting an invite. Realm-core holds no Character rows, so an -/// ACCEPT conveys them for the Seeker row a queued party gains. 0 when no World Shard names the -/// Character. +/// The class of a Character accepting an invite. Realm-core holds no Character rows, so an ACCEPT +/// conveys it for the Seeker row a queued party gains. 0 when no World Shard names the Character. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct AcceptorFacts { class: u8, - race: u8, } impl AcceptorFacts { /// What `realm_group_op` receives for every op but ACCEPT. - const NONE: Self = Self { class: 0, race: 0 }; + const NONE: Self = Self { class: 0 }; - /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0s rather than losing + /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0 rather than losing /// the accept: the Module treats an unknown class as a seat with no role. fn of(store: &St, guid: u64) -> Self { match presence::character_anywhere(store, guid) { Ok(Some(character)) => Self { class: character.class, - race: character.race, }, Ok(None) => Self::NONE, Err(error) => { - log::warn!("party: class and race of acceptor {guid} unread: {error:#}"); + log::warn!("party: class of acceptor {guid} unread: {error:#}"); Self::NONE } } @@ -442,7 +439,7 @@ impl Op { fn realm_args(self, acceptor: AcceptorFacts) -> RealmOpArgs { match self { Op::Invite(target) => (realm_op::INVITE, target, 0, 0, 0), - Op::Accept => (realm_op::ACCEPT, 0, acceptor.class, acceptor.race, 0), + Op::Accept => (realm_op::ACCEPT, 0, acceptor.class, 0, 0), Op::Decline => (realm_op::DECLINE, 0, 0, 0, 0), Op::Leave => (realm_op::LEAVE, 0, 0, 0, 0), Op::Uninvite(target) => (realm_op::UNINVITE, target, 0, 0, 0), diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index d56f58348..6e1c09416 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1473,7 +1473,7 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { party_state.ops.clone(), vec![ (realm_op::INVITE, GINGER, BOT, 0, 0, 0), - (realm_op::ACCEPT, BOT, 0, 1, 1, 0) + (realm_op::ACCEPT, BOT, 0, 1, 0, 0) ], "the accept must run on realm-core with the BOT as the actor — never the inviter, and never 0" ); @@ -1654,7 +1654,7 @@ fn a_bot_that_cannot_join_declines_out_loud_instead_of_leaving_the_dialog_hangin state.ops.clone(), vec![ (realm_op::INVITE, GINGER, BOT, 0, 0, 0), - (realm_op::ACCEPT, BOT, 0, 1, 1, 0), + (realm_op::ACCEPT, BOT, 0, 1, 0, 0), // …and the decline is the bot's own too, not the inviter's. (realm_op::DECLINE, BOT, 0, 0, 0, 0), ], @@ -1724,8 +1724,8 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { vec![ // INVITE: the target rides `target_guid`, nothing else is used. (realm_op::INVITE, GINGER, VIM, 0, 0, 0), - // ACCEPT: the acceptor's class in arg_a and race in arg_b, the fixture's Human Warrior. - (realm_op::ACCEPT, VIM, 0, 1, 1, 0), + // ACCEPT: the acceptor's class in arg_a, the fixture's Warrior. + (realm_op::ACCEPT, VIM, 0, 1, 0, 0), // LOOT_METHOD: setting in arg_a, MASTER in target_guid, threshold in arg_b — // CMSG_LOOT_METHOD's own field order. (realm_op::LOOT_METHOD, GINGER, VIM, 2, 4, 0), @@ -1901,7 +1901,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() // World entry asks for the Party's Target Icons after the list. (realm_op::TARGET_ICON, GINGER, 0, 0xFF, 0, 0), (realm_op::INVITE, GINGER, VIM, 0, 0, 0), - (realm_op::ACCEPT, GINGER, 0, 1, 1, 0), + (realm_op::ACCEPT, GINGER, 0, 1, 0, 0), (realm_op::DECLINE, GINGER, 0, 0, 0, 0), // CMSG_LOOT_METHOD's own field order: setting in arg_a, MASTER in target_guid, // threshold in arg_b. @@ -1977,7 +1977,7 @@ fn a_bot_invite_forms_a_party_on_realm_core_across_a_shard_boundary() { party_state.ops.clone(), vec![ (realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0), - (realm_op::ACCEPT, FAR_BOT, 0, 1, 1, 0) + (realm_op::ACCEPT, FAR_BOT, 0, 1, 0, 0) ], "both halves must run on realm-core, attributed to the right actor each time — the bot as \ itself for both the invite and (through the session-less answer) the accept" @@ -2094,7 +2094,7 @@ fn the_intent_op_byte_picks_the_party_op_that_runs() { party_state.ops.clone(), vec![ (realm_op::INVITE, BOT, FAR_BOT, 0, 0, 0), - (realm_op::ACCEPT, FAR_BOT, 0, 1, 1, 0), + (realm_op::ACCEPT, FAR_BOT, 0, 1, 0, 0), (realm_op::LEAVE, BOT, 0, 0, 0, 0), ], "the invite runs INVITE (plus the session-less answer) and the leave runs LEAVE, each \ @@ -4302,32 +4302,32 @@ fn accepts(realm: &InMemoryStore) -> Vec<(u8, u64, u64, u8, u8, u64)> { .collect() } -/// A client's Realm-core ACCEPT conveys the acceptor's class in `arg_a` and race in `arg_b`, so a -/// queued party can give the joiner a Seeker row. +/// A client's Realm-core ACCEPT conveys the acceptor's class in `arg_a`, so a queued party can give +/// the joiner a Seeker row. #[test] -fn a_client_accept_carries_the_acceptors_class_and_race() { +fn a_client_accept_carries_the_acceptors_class() { let (realm, world, instances) = dwarf_priest_topology(); party::run(world.as_ref(), 7, GINGER, party::Op::Invite(VIM)).unwrap(); party::run(instances.as_ref(), 8, VIM, party::Op::Accept).unwrap(); - assert_eq!(accepts(&realm), [(realm_op::ACCEPT, VIM, 0, 5, 3, 0)]); + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, VIM, 0, 5, 0, 0)]); } /// A playerbot's automatic accept reads its facts from the Shard that holds it, here not the /// inviter's. #[test] -fn a_playerbot_accept_carries_its_class_and_race_from_its_own_shard() { +fn a_playerbot_accept_carries_its_class_from_its_own_shard() { let (realm, world, _instances) = dwarf_priest_topology(); party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); - assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 3, 0)]); + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 0, 0)]); } -/// An acceptor no World Shard names conveys 0s, and the accept still runs. +/// An acceptor no World Shard names conveys class 0, and the accept still runs. #[test] -fn an_accept_nobody_can_name_conveys_zero_class_and_race() { +fn an_accept_nobody_can_name_conveys_zero_class() { let (realm, world, _instances, _calls) = party_topology(); realm.party.lock().unwrap().invites.push((99, GINGER)); diff --git a/module/src/debug/meeting_stone.rs b/module/src/debug/meeting_stone.rs index 1d36f7a8e..e5d29bb99 100644 --- a/module/src/debug/meeting_stone.rs +++ b/module/src/debug/meeting_stone.rs @@ -1,8 +1,8 @@ //! Meeting Stone Queue fixtures for the durable tests. Fixture ids sit in `509_6000`-`509_6099`. //! //! The levers stage the world a Meeting Stone needs: a stone and its spawned GameObject, Characters -//! with a chosen race, class and level, each with a live entity and a live Account Claim, and a -//! Party or Raid led by one of them. Two more move a Seeker's or a queued party's time back, so a +//! with a chosen race, class and level, each with a live entity and a live Account Claim, the same +//! claim alone on Realm-core, and a Party or Raid led by one of them. Two more move a Seeker's or a queued party's time back, so a //! test never waits on the clock. use spacetimedb::{reducer, ReducerContext, Table, TimeDuration}; @@ -126,6 +126,23 @@ pub fn debug_stage_meeting_stone_character( ctx.db.game_character().insert(character); } super::debug_spawn_player_entity(ctx, guid)?; + stage_claim(ctx, guid, account_id) +} + +/// Stage only the live Account Claim [`debug_stage_meeting_stone_character`] writes, with the same +/// World Session Token. On a sharded realm the Character lives on a World Shard and its claim on +/// Realm-core, which holds no Character rows. A second call replaces the claim. +#[reducer] +pub fn debug_stage_meeting_stone_claim( + ctx: &ReducerContext, + guid: u64, + account_id: u64, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + stage_claim(ctx, guid, account_id) +} + +fn stage_claim(ctx: &ReducerContext, guid: u64, account_id: u64) -> Result<(), String> { let expires_micros = ctx .timestamp .to_micros_since_unix_epoch() diff --git a/module/src/group.rs b/module/src/group.rs index f58157c0c..14d3affeb 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1379,7 +1379,7 @@ pub(crate) fn accept_invite_for(ctx: &ReducerContext, acceptor_guid: u64) -> Res .map_err(|refusal| refused(refusal, "session-less group acceptance"))?; } let class = acceptor.map_or(0, |character| character.class); - accept_invite_on(ctx, acceptor_guid, class).map_err(|error| { + accept_invite_on(ctx, Plane::Shard, acceptor_guid, class).map_err(|error| { group_op_error( error, &format!("{acceptor_guid} could not accept its invite"), @@ -1401,6 +1401,7 @@ fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool /// the acceptor's, for the Seeker row a queued Party gains; 0 when unknown. fn accept_invite_on( ctx: &ReducerContext, + plane: Plane, acceptor_guid: u64, class: u8, ) -> Result<(), GroupOpError> { @@ -1419,6 +1420,15 @@ fn accept_invite_on( }, group_id => JoinTarget::Group(group_id), }; + // A World Shard holds every Character it groups, so a missing row is a Character that is gone. + // Realm-core holds none and takes the Gateway's word. + if plane == Plane::Shard { + if let JoinTarget::NewParty { leader_guid } = target { + crate::helpers::character_by_guid(ctx, leader_guid) + .ok_or(GroupRefusal::InviterUnavailable)?; + } + crate::helpers::character_by_guid(ctx, acceptor_guid).ok_or(GroupRefusal::NoSuchPlayer)?; + } let joined = join_group(ctx, acceptor_guid, target)?; if joined.formed { crate::meeting_stone::party_joined(ctx, joined.group_id, invite.inviter_guid, 0) @@ -2088,7 +2098,9 @@ pub fn realm_group_op( invite_core_on(ctx, Plane::RealmCore, actor_guid, target_guid).map(|()| Unchanged) } // Realm-core holds no Character rows, so the Gateway conveys the acceptor's class. - realm_op::ACCEPT => accept_invite_on(ctx, actor_guid, arg_a).map(|()| Changed), + realm_op::ACCEPT => { + accept_invite_on(ctx, Plane::RealmCore, actor_guid, arg_a).map(|()| Changed) + } realm_op::DECLINE => decline_invite_on(ctx, actor_guid).map(|()| Unchanged), realm_op::LEAVE => leave_group_on(ctx, actor_guid, arg_a).map(|()| Changed), realm_op::UNINVITE => uninvite_on(ctx, actor_guid, target_guid).map(|()| Changed), @@ -3590,7 +3602,7 @@ mod tests { ), ( "realm_op::ACCEPT =>", - "accept_invite_on(ctx, actor_guid, arg_a)", + "accept_invite_on(ctx, Plane::RealmCore, actor_guid, arg_a)", ), ("realm_op::DECLINE =>", "decline_invite_on(ctx, actor_guid)"), ( From 702f090cd297020dbf6cd186d4cc186322de8da1 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 07:25:53 +0200 Subject: [PATCH 11/16] chore(tickets): drop the meeting stone planning tickets Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/tickets/issue-532/README.md | 390 ------------------ .../issue-532/T1-meeting-stone-queue.md | 214 ---------- .../issue-532/T2-roster-revision-relay.md | 127 ------ .../tickets/issue-532/T3-stone-matching.md | 184 --------- .../issue-532/T4-integrate-verify-pr.md | 84 ---- 5 files changed, 999 deletions(-) delete mode 100644 .claude/tickets/issue-532/README.md delete mode 100644 .claude/tickets/issue-532/T1-meeting-stone-queue.md delete mode 100644 .claude/tickets/issue-532/T2-roster-revision-relay.md delete mode 100644 .claude/tickets/issue-532/T3-stone-matching.md delete mode 100644 .claude/tickets/issue-532/T4-integrate-verify-pr.md diff --git a/.claude/tickets/issue-532/README.md b/.claude/tickets/issue-532/README.md deleted file mode 100644 index 8a4cfa02f..000000000 --- a/.claude/tickets/issue-532/README.md +++ /dev/null @@ -1,390 +0,0 @@ -# Issue #532 meeting stones: the 1.12.1 dungeon queue on Realm-core, split into tickets - -Issue #532 schedules this work. The maintainer deferred it from the issue #3 push on 2026-09-23 and -filed #532 to carry it. - -Closed spec #2 set the partition rule: state not coupled to space has one authoritative copy on -Realm-core, and party membership is authoritative there. - -## Settled maintainer decisions (#532) - -1. **Meeting stones only.** 1.12.1 has no LFG/LFM browse window, so none is built. No Package - addon window either. -2. **Humans only.** A Seeker needs a World Session and a live Account Claim. Playerbots do not - queue; a leader invites them into a stone party by ordinary invite, and the party counts their - class like any member's. - -## Sources and citation keys - -| Key | Source | Pin | -|---|---|---| -| `cm:` | cmangos mangos-classic `src/game/` | `ca0775fe352fb67a0e82dc6051f0563187d569a5` | -| `vm:` | vmangos core `src/game/` | `4b350a09fca8b5797975e343ae6300fbb5f9937b` | -| `fx:` | stock 1.12 FrameXML (`## Interface: 11200`), `AtheneGenesis/Vanilla_enUS_FrameXML` | `64ba0a1fa7cf42679284b608562bde094bbee75f` | -| `wwm:` | `wow_world_messages` 0.3.0 and `wow_world_base` 0.3.0 | cargo registry | - -cmangos LFG code is `LFG/{LFGHandler,LFGMgr,LFGQueue}.cpp` and `LFG/LFGDefines.h`. vmangos has the -same four files. The two queue implementations are the same algorithm line for line; vmangos only -swaps packet builders and sleeps 1 s instead of 500 ms (`vm:LFG/LFGQueue.cpp:66-224`, -`cm:LFG/LFGQueue.cpp:63-225`). - -## What 1.12.1 ships - -**No LFG/LFM window.** The 1.12 FrameXML has no LFG frame. The only LFG widget, a "I would like to -join a group" checkbox on the Who tab that calls `SetLookingForGroup`, sits inside an XML comment -(`fx:FriendsFrame.xml:1212-1301`, string `LFG_LABEL` at `fx:GlobalStrings.lua:2581`). Neither core -handles `CMSG_SET_LOOKING_FOR_GROUP` 0x200 (`cm:Server/Opcodes.cpp:540` `STATUS_NEVER`, -`vm:Server/Protocol/Opcodes.cpp:603` unhandled), and `wwm:` has no vanilla parser for it. The -LFG/LFM tool with dungeon, zone and quest entries, comments and browse results is the TBC tool: -`CMSG_SET_LFG_COMMENT` 0x366 and `SMSG_LFG_DISABLED` exist in `wwm:` only as `_tbc_wrath` types. - -`MSG_LOOKING_FOR_GROUP` 0x1FF exists. The client message is empty, the server message is one `u32`. -vmangos answers it with `0` (`vm:Handlers/MiscHandler.cpp:277-282`); cmangos ignores it -(`cm:Server/Opcodes.cpp:539`). The stock UI never reads the answer. - -**Meeting stones with a server queue.** This is the whole 1.12 LFG feature. - -| Fact | Source | -|---|---| -| A meeting stone is GameObject type 23. `data0` min level, `data1` max level, `data2` AreaTable id of the dungeon | `cm:Entities/GameObject.h:266-272`, `vm:Objects/GameObjectDefines.h:437-443` | -| The client sends `CMSG_MEETINGSTONE_JOIN(go guid)` on use, not `CMSG_GAMEOBJ_USE`. vmangos says so and returns at once from `Use` for type 23. T4's client check confirms it | `vm:Objects/GameObject.cpp:1836-1841` | -| Join gates: interactable GO of type 23, else silent. In a party, in this order: not leader → `JOINFAILED(1)`, raid → `JOINFAILED(3)`, full → `JOINFAILED(2)`. No level check on the server | `cm:LFG/LFGHandler.cpp:34-84`, `vm:LFG/LFGHandler.cpp:31-75` | -| Queue key is dungeon area and team. A leader queues the whole party; anyone else queues alone. Re-joining overwrites the area and the wait time | `cm:LFG/LFGMgr.cpp:37-89`, `cm:LFG/LFGQueue.cpp:252-277` | -| Status bytes: 0 LEAVE_QUEUE, 1 JOINED_QUEUE, 2 PARTY_MEMBER_LEFT_LFG, 3 PARTY_MEMBER_REMOVED_PARTY_REMOVED, 4 LOOKING_FOR_NEW_PARTY_IN_QUEUE, 5 NONE. Failure bytes 1 PARTYLEADER, 2 FULL_GROUP, 3 RAID_GROUP | `cm:LFG/LFGDefines.h:50-67`, `wwm:` `MeetingStoneStatus`, `MeetingStoneFailure` | -| `SMSG_MEETINGSTONE_SETQUEUE` is `u32 area, u8 status`. `JOINFAILED` is `u8`. `MEMBER_ADDED` is a guid. `COMPLETE` and `IN_PROGRESS` are empty | `cm:LFG/LFGHandler.cpp:139-152`, `cm:LFG/LFGMgr.cpp:288-309` | -| Leave: alone → `SETQUEUE(0, LEAVE_QUEUE)`. Leader of a queued party → the party leaves, every member gets `SETQUEUE(0, LEAVE_QUEUE)`. Anyone else in a party → `SETQUEUE(0, NONE)` to self | `cm:LFG/LFGHandler.cpp:86-110`, `cm:LFG/LFGQueue.cpp:392-454` | -| `CMSG_MEETINGSTONE_INFO` arrives at world entry. Queued party → `SETQUEUE(area, JOINED)`, else `SETQUEUE(0, NONE)` | `cm:LFG/LFGHandler.cpp:112-137`, `wwm:` `CMSG_MEETINGSTONE_INFO` doc | -| Roles by class: Druid and Paladin T/H/D, Priest and Shaman H/D, Warrior T/D, Hunter, Mage, Rogue, Warlock D. A party has 1 tank, 1 healer, 3 damage roles | `cm:LFG/LFGMgr.cpp:91-106`, `cm:LFG/LFGMgr.h:51` | -| A queued party's Open Roles come from its members' classes, with a class priority table breaking ties | `cm:Groups/Group.cpp:1589-1685`, `cm:LFG/LFGMgr.cpp:108-158` | -| Fill: a solo Seeker of the same area and team takes an Open Role. The priority checks compare `bool`s, so in practice the longest-waiting capable Seeker wins each role | `cm:LFG/LFGQueue.cpp:99-171,292-390` (the `bool` at 300 and 316) | -| Formation: with 5 or more solo Seekers, the first becomes leader and one more joins; the fill pass adds the rest | `cm:LFG/LFGQueue.cpp:173-221` | -| Stone adds: existing members get `MEMBER_ADDED(guid)` before the add. At 5 members every member gets `COMPLETE` then `SETQUEUE(0, NONE)` | `cm:LFG/LFGQueue.cpp:372-383,420-454` | -| A queued party gets `IN_PROGRESS` every 5 minutes | `cm:LFG/LFGMgr.cpp:55`, `cm:LFG/LFGQueue.cpp:149-163` | -| Member leaves a queued party: leaver `SETQUEUE(0, NONE)`; if the leader stayed, the rest get `SETQUEUE(area, PARTY_MEMBER_LEFT_LFG)` and the party stays queued; if the leader left, the party leaves the queue with `LEAVE_QUEUE` | `cm:Groups/Group.cpp:437-447,464-479`, `vm:Group/Group.cpp:477-518` | -| Kick from a queued party: the rest get `SETQUEUE(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED)` and the party leaves the queue. The kicked Character gets `SETQUEUE(area, LOOKING_FOR_NEW_PARTY_IN_QUEUE)` and is queued alone | `cm:Groups/Group.cpp:413-435`, `vm:Group/Group.cpp:449-474` | -| Disband: every member `SETQUEUE(0, NONE)`, party leaves the queue | `cm:Groups/Group.cpp:550-562`, `vm:Group/Group.cpp:586-602` | -| A manual invite into a queued party recomputes Open Roles; reaching 5 completes it | `cm:Groups/Group.cpp:376-382`, `cm:LFG/LFGQueue.cpp:237-250` | -| Converting to a raid and passing the lead touch no LFG state in either core | `cm:Groups/Group.cpp:208-222`, `vm:Group/Group.cpp:236-257` | -| Logout drops a solo Seeker silently | `cm:Server/WorldSession.cpp:662-666`, `vm:Server/WorldSession.cpp:721-725` | -| Client UI: a minimap button while queued, click → confirm → `CancelMeetingStoneRequest()` → `CMSG_MEETINGSTONE_LEAVE`. The client renders every status as its own `ERR_MEETING_STONE_*` line | `fx:Minimap.xml:175-235`, `fx:StaticPopup.lua:170-179`, `fx:GlobalStrings.lua:847,1698-1710,2678-2679,4354` | -| The queue exists from patch 1.3 | `vm:World.cpp:1882-1885` | - -**Not 1.12.** cmangos `GameObject::Use` casts Meeting Stone Summon 23598 for type 23 -(`cm:Entities/GameObject.cpp:1925-1954`). That is the 2.0 summoning stone, shared code in the -classic core. vmangos returns (`vm:Objects/GameObject.cpp:1836-1841`) and the 1.12 FrameXML has no -summon UI. The talent-based roles behind `LFG.Matchmaking` default off -(`cm:World/World.cpp:740-741`) and are a cmangos addition. - -**No coupling to the LookingForGroup channel.** It is a Chat Channel on Realm-core now -(`lyracore_shared::channel`, prefix `LookingForGroup`), with the 1.12.1 dbc flags 0x0. No LFG code -in either core joins, leaves or restricts it: `Channel.RestrictedLfg` is read by nothing -(`cm:World/World.cpp:678`), and `Player::LeaveLFGChannel` has no caller -(`cm:Entities/Player.cpp:4796-4806`). Nothing here touches channels. - -### Where the cores disagree or are buggy - -| Behavior | cmangos | vmangos | We do | -|---|---|---|---| -| `MSG_LOOKING_FOR_GROUP` | ignored | replies `u32 0` | vmangos. It targets 1.12 and the reply is harmless | -| Solo Seeker accepts a manual invite | dropped from the queue, `LEAVE_QUEUE` unless the party is queued for the same area, playerbots builds only (`cm:Groups/Group.cpp:360-374`) | stays in the solo queue while grouped | cmangos. A grouped Character in the solo queue can be matched into a second party | -| `INFO` for a solo Seeker | `NONE`, because `m_offlinePlayers` is never written | same | the real state. Both cores read a map nothing fills | -| Formation trigger | 5 solos across all areas, then 4 more in the first Seeker's area; head-of-line blocks | same | 5 solos in one area and team | -| Leader of a new party | lowest guid | same | longest wait | -| A queued party converts to a raid | stays queued and keeps filling | same | leaves the queue with `LEAVE_QUEUE`. JOIN refuses raids, so a queued raid is the state the join gate exists to prevent | - -## State of the world (verified on `main` at `da11dcfc`, 2026-10-02) - -- Nothing meeting-stone related exists. `grep -rliE 'lfg|meetingstone|meeting_stone'` over - `gateway/ module/ importer/ crates/` finds a chat string in - `gateway/src/stdb/world_view/relay_bench.rs`, the LookingForGroup channel flags in - `lyracore_shared::channel`, and the reservation comments in `lyracore_shared::group`. -- `lyracore_shared::constants::go_type` has `QUESTGIVER = 2` and `CHEST = 3` only - (`constants.rs:105-113`). -- The importer imports every spawned GO type. Type 23 falls into the `other` arm of - `go_template_row` (`importer/src/main.rs:703-706`), which writes `data0 = data1 = 0`. The stone's - level range and area are lost. `got` names `DATA0`, `DATA1`, `DATA3` and `DATA5`, not `DATA2` - (`main.rs:874-894`). The per-type side table precedent is `game_gameobject_trap` - (`module/src/gameobject.rs:117-127`): the importer captures its fields into `GoMeta` - (`main.rs:4940-4964`), emits rows in `gameobject_template_rows` (`main.rs:5083-5088`), clears - and reloads them with the `gameobjects` family (`main.rs:5549-5559`), and counts them in that - family's provenance stamp (`main.rs:5765-5770`). The trap table also rides the Package Delta - `gameobjects` Import Family (`crates/lyracore-package-delta/src/schema.rs`, - `module/src/package_import/gameobjects.rs`). -- `use_gameobject` treats unknown types as a benign no-op (`module/src/gameobject.rs:980-983`), so - a type-23 `CMSG_GAMEOBJ_USE` does nothing today. The shared GO Gate is the private `usable_go` - (`gameobject.rs:672-715`): GO present, live Character, same map and instance, - `USE_RANGE_SQ = LOOT_RANGE_SQ = 100` (10 yd), template present. Its `ActionRefusalKind`s are - `MissingTarget`, `MissingActor`, `OtherPartition` and `OutOfRange`. `taxi::is_in_flight` - (`module/src/taxi.rs:789`) answers the flight check. -- `SMSG_GAMEOBJECT_QUERY_RESPONSE` sends `raw_data: [data0, data1, 0, 0, 0, 0]` - (`gateway/src/codec/gameobject.rs:130-151`), read through `GameObjectTemplateView` - (`codec/gameobject.rs:44-50`) and `stdb/reads/templates.rs:34-52`. -- Raids landed. `Group.group_type` holds a `GroupKind` byte (`module/src/group.rs:94-96`); - `raid_convert_on` (`group.rs:1673-1683`) flips it and has no queue hook. `has_room` follows the - kind (`group.rs:610-612`), and `accept_invite_on` (`group.rs:1400-1517`) assigns a `RaidSlot` and - reconciles Instance Removal for the acceptor and a new party's leader. `remove_member` - (`group.rs:1845-1918`) revokes Loot Tag membership, resolves rolls on disband and reconciles - Instance Removal. `leave_group_on(.., cause)` and `uninvite_on` both end in `remove_member`. - `SET_LEADER` exists as `set_leader_on`. -- Party authority: `realm_group_op(op, request_actor: SessionActor, target_guid, arg_a, arg_b, - arg_c)` (`group.rs:1993-2062`) runs the group cores with `Plane::RealmCore` and advances - `game_group_roster_revision` through `advance_group_revision` (`group.rs:2102-2116`) for every - group `realm_op_groups` names before and after the op. Realm ops 0-15 are taken - (`lyracore_shared::group::realm_op`). ACCEPT's `arg_a`/`arg_b` are documented as kept free for - meeting stones (`crates/lyracore-shared/src/group.rs:239-240`). -- Plane: a member row's `owner_identity` is the local Character's identity on `Plane::Shard` and - zero on `Plane::RealmCore` (`group.rs:1490-1502`). On a single-database realm the Gateway calls - `gw_group_*` reducers (`Plane::Shard`) for membership ops. -- Gateway party flow: `party::run` (`gateway/src/world/party.rs:912-1009`) calls Realm-core through - `run_on_authority_visible`, then pushes mirrors. Accept, leave and kick use - `sync_membership_mirrors` (`party.rs:1016-1039`), which goes through - `sync_group_mirrors_required` (`party.rs:1380-1428`, three attempts within a 5 s window per - shard). Other ops use the best-effort `sync_mirrors` (`party.rs:1435-1495`). Both push only the - actor's before and after groups, to every World Shard. `Op::realm_args` (`party.rs:403`) packs - ACCEPT as `(ACCEPT, 0, 0, 0, 0)`. A playerbot's automatic accept is `answer_for_session_less` - (`party.rs:873-905`). -- **No Roster Revision Relay exists.** Nothing watches `game_group_roster_revision`. Mirrors move - only through `party::run`, world entry (`on_world_entry`, `party.rs:1505`), deleted-Character - cleanup, and `reconcile_deleted_character_parties` (`party.rs:1327-1371`), which pushes every - party known to Realm-core or any shard. That pass already runs at startup and on every Realm-core - reconnect (`spawn_character_gone_relay`, `gateway/src/stdb/subscriptions.rs:4223-4252`). The - Coordinator caches `game_group_roster_revision` on every connection of a sharded realm - (`stdb/connection.rs`, `sharded_tables` block), but `reads/party.rs:207-216` - `group_roster_revision` answers 1 for a missing row, so it cannot tell "no mirror" from - revision 1. -- `game_group_event` is the per-recipient relay. The Gateway routes a row by `recipient_guid` - (`gateway/src/stdb/world_view.rs:2728-2747` `group_event_appeared`, wired at `world_view.rs:1098` - for a shard and `:1275` for Realm-core) and decodes it in `group_event_outbound` - (`subscriptions.rs:2193`). Kinds: 0-3 membership, 4-8 loot rolls, 9 retired (party chat is a - Realm Chat Line now), 10-11 quest sharing, 12-19 unassigned, 20 leader announcement, 21-26 Group - Broadcasts, **27-30 reserved for meeting stones** (`crates/lyracore-shared/src/group.rs:173-182`). - No test covers every kind at once; `quest.rs:247` covers 0-11. -- Account Claims live on Realm-core, one per Account (`module/src/account_ownership.rs:27-35`), - 60 s without renewal (`CLAIM_MICROS`). A claim ends in three places, and each one already calls - `channel::leave_all` for its Character: `claim_account` replacing a dead generation - (`account_ownership.rs:186-190`), `release_account_claim` (`:224-239`), and - `reap_account_claims` (`:549-566`), which the Gateway lease reaper runs every 15 s - (`module/src/gw.rs:118-127`, `LEASE_REAP_MICROS`). `require_actor` (`:475-524`) accepts a - tokenless actor whose Character has no live claim, so JOIN must refuse a tokenless actor itself. -- Chat Channel membership is the precedent for Realm-core state tied to a claim: its tables sit in - the `NOT_CHARACTER_OWNED` list of `module/src/tripwires.rs` ("membership ends with the Account - Claim that admitted it"), not behind `character_owned!` markers. -- The Gateway's newer protocol families use a dispatch seam in `gateway/src/world/handlers/`: - `dispatch_channel_action` with a narrow `ChannelActionStore` trait implemented for `Coordinator` - and a Fake, wired into the chain in `gateway/src/world/mod.rs:1559`. The Coordinator picks the - database; handlers never do. Party ops still use `world/party.rs` and `world/social.rs`. -- Realm-core tables the Gateway reads sit in the base list of `coordinator_queries` - (`stdb/connection.rs:1011`), for example the Chat Channel tables at `connection.rs:1209-1212`. -- An opcode `wwm:` cannot parse ends the World Session (`gateway/src/world/framing_tests.rs:199`). - The three meeting-stone client messages and `MSG_LOOKING_FOR_GROUP` all parse. -- `lyracore_shared::faction::team_for_race` maps race to team (469 Alliance, 67 Horde). -- Fixture ids: `docs/danger-zones.md` reserves `509_0000`-`509_9999`. The auction, mail and - creature-cycle fixtures use `509_00xx`-`509_05xx` and `509_36xx`. `509_6000`-`509_6099` is free. -- Bindings: `docs/danger-zones.md` §1 item 2 holds the regeneration command - (`spacetime generate --lang rust --out-dir gateway/src/stdb/bindings --module-path module - --include-private --build-options='--features=debug_reducers' -y`), its hand-patches, and - `scripts/check-gateway-bindings.py`. -- CI: `.github/workflows/module-durable.yml` discovers every `module/tests/*.rs` target by itself, - but runs Gateway durable tests by exact name. A new Gateway durable test needs a workflow edit, - and the agents' token cannot push `.github/workflows/*`. - -## Design - -### Placement - -| Question | Answer | Why | -|---|---|---| -| Is the Character at a stone, in range, in its level range? | Module on the Home Shard, `gw_admit_meeting_stone` | Only that shard holds the GO and the Character | -| Queue state and matching | Module on Realm-core, `realm_meeting_stone_op` | Seekers on different shards meet in one queue. Matching changes party membership, which lives there | -| Party formation | the group cores in `module/src/group.rs`, on Realm-core | One group path | -| Class and race of Seekers | Gateway reads them from the shard caches and passes them in | Realm-core has no Character rows. Same shape as `invite_gate` and the channel `SpeakerFacts` | -| Client packets | Gateway, `world/handlers/meeting_stone.rs`, a `dispatch_meeting_stone_action` seam | The house shape for a protocol family on Realm-core | -| Notifications | `game_group_event` kinds 27-30, existing relay | One recipient, a kind byte, a small payload | -| Solo Seeker lifetime | the three Account Claim end points | The claim already ends Chat Channel membership there | -| Mirrors of stone-formed parties | Gateway Roster Revision Relay | The matcher changes parties the acting Character is not in | - -On a single-database realm every call goes to the one database, as `run_bot_invite` already does. - -### Durable state, all in `module/src/meeting_stone.rs` - -- `game_meeting_stone` [static, private]: `entry` (template) PK, `min_level`, `max_level`, - `area_id`. Imported with the `gameobjects` family. `max_level = 0` means no upper bound. The - Gateway reads it with the owner token, like `game_mail_escrow`. -- `game_meeting_stone_seeker` [entity, private]: one row per queued Character. `character_guid` - PK, `area_id`, `team`, `class`, `group_id` (0 = alone), `queued_at`. Index on - `(area_id, team)` and on `group_id`. The Gateway subscribes it to answer `INFO`. -- `game_meeting_stone_party` [entity, private]: one row per queued party. `group_id` PK, - `area_id`, `team`, `queued_at`, `next_reminder_at`. Module only. -- `game_meeting_stone_reminder_schedule` [scheduled], every 5 s, added by T3: sends `IN_PROGRESS` - reminders. Nothing else needs a tick. - -Invariant: a party row exists if and only if the party is queued, and its Seeker rows are exactly -its current members. The group cores keep it in the same transaction that changes membership. - -### Flows - -```text -CMSG_MEETINGSTONE_JOIN(go) - Gateway: Home Shard gw_admit_meeting_stone(actor, go) refused → silent - Gateway: stone area from the Home Shard cache; area not in wwm Area → silent - Gateway: facts = actor, or every party member: (guid, race, class) - Gateway: authority realm_meeting_stone_op(JOIN, actor, area, facts) - refused NotLeader/RaidGroup/PartyFull → SMSG_MEETINGSTONE_JOINFAILED(1/3/2) - Realm-core, one transaction: enqueue, SETQUEUE(area, JOINED) events, match the bucket, - form or fill parties through the group cores, advance Roster Revisions - Gateway: relay events; Roster Revision Relay pushes changed parties to World Shards - -CMSG_MEETINGSTONE_LEAVE → realm_meeting_stone_op(LEAVE) → events -CMSG_MEETINGSTONE_INFO → Durable Read of the Seeker row → SETQUEUE(area, JOINED) or (0, NONE) -MSG_LOOKING_FOR_GROUP → MSG_LOOKING_FOR_GROUP(0) -CMSG_GROUP_ACCEPT → realm_group_op(ACCEPT, arg_a = class, arg_b = race) -Account Claim ends → the claim's Character loses its solo Seeker row, no packet -``` - -| `lyracore_shared::meeting_stone::event_kind` | Value | SMSG | Payload | -|---|---:|---|---| -| `QUEUE` | 27 | `SETQUEUE` | `lyracore_shared::meeting_stone` grammar for area and status | -| `MEMBER_ADDED` | 28 | `MEMBER_ADDED` | `other_guid` = added Character | -| `IN_PROGRESS` | 29 | `IN_PROGRESS` | none | -| `COMPLETE` | 30 | `COMPLETE` | none | - -## Decisions - -1. Ship meeting stones only. 1.12.1 has no LFG/LFM window (`fx:FriendsFrame.xml:1212-1301`). - Maintainer decision in #532. -2. Answer `MSG_LOOKING_FOR_GROUP` with `0`, as vmangos does. Leave 0x200 unparsed; the stock UI - never sends it. -3. No summoning. 23598 on use is 2.0 behavior. -4. Queue on Realm-core, one Seeker row per Character plus one row per queued party. INFO is then - one indexed read. -5. Match inside the transaction that changes a bucket, not on a 1 s tick. The core's priority - checks reduce to longest wait per role, so a tick adds latency and no behavior. -6. Class roles and 1/1/3 roles from cmangos. No talent roles; `LFG.Matchmaking` defaults off. -7. Form a party when one area and team holds 5 solo Seekers. Leader is the longest wait. This - removes the cores' head-of-line bug. -8. Joining any party drops the solo Seeker, with cmangos's notice rule. -9. `INFO` reports the real state. The cores' restore map is dead code. -10. A solo Seeker lives as long as its Account Claim. The three places that end a claim and call - `channel::leave_all` also drop the solo Seeker, through one shared call. A Transfer keeps the - claim, so the Seeker survives crossing shards. No claim sweep tick: the lease reaper already - closes an expired claim within 15 s. -11. Humans only. JOIN refuses a tokenless actor. Maintainer decision in #532. -12. The Module enforces the stone's level range for the using Character, silently. Neither core - does, the client pre-checks with `ERR_MEETING_STONE_INVALID_LEVEL`, and 1.12 has no failure - code for it. -13. Reuse `usable_go` for range and partition. One GO interaction Gate, 10 yd, not the cores' 5 yd. -14. Stone facts in a new `game_meeting_stone` table, the trap precedent. A new template column - would ripple through the Package Delta schema, every template literal and the Datascript - typings. -15. The query response for type 23 fills `raw_data[0..3]` from the stone row so the client tooltip - shows the level range. -16. Notifications reuse `game_group_event`, kinds 27-30. Verified free on `da11dcfc`; - `lyracore_shared::group` reserves them. `JOINFAILED` goes back synchronously from the Refusal. -17. Realm-core learns class and race from the Gateway: JOIN facts, and ACCEPT's reserved - `arg_a`/`arg_b`. An unknown class holds a seat and no role. -18. A Roster Revision Relay pushes any Realm-core roster change to stale World Shard mirrors. The - matcher changes parties the actor is not in, so `party::run`'s actor-scoped push misses them. - Main has no such relay, so T2 stays. It shrinks: it reuses `sync_group_mirrors_required`'s - retries, and the existing Realm-core reconnect pass already covers changes missed while - disconnected. -19. Only the Gateway can say whether a stone's area encodes. If `wwm:` `Area::try_from` refuses it, - the Gateway drops the JOIN before any state changes. -20. `IN_PROGRESS` every 5 minutes from a 5 s Realm-core reminder tick (T3). -21. Raids exist, so T1 refuses a raid at JOIN with `JOINFAILED(3)` in the core's order: not - leader, raid, full. No marker is left for later. -22. A queued party that converts to a raid leaves the queue with `SETQUEUE(0, LEAVE_QUEUE)` to every - member. Both cores leave it queued, but they refuse a raid at JOIN, and a queued raid would - keep taking stone adds past the 1/1/3 role model. T3 calls `dequeue_party` from - `raid_convert_on`. -23. Passing the lead keeps the party queued with no event. Both cores do this, and the party row - is keyed by `group_id`, so nothing changes. -24. The Seeker table goes in `tripwires.rs`'s `NOT_CHARACTER_OWNED` list, the Chat Channel - precedent. A solo Seeker ends with its claim. A party Seeker ends with its membership, and a - deleted Character leaves its party through `remove_member` on both planes, where T3's hook - drops the row. The party table has no Character guid column, so the census test rejects an - entry for it as stale. -25. Packages cannot author meeting stones in this work. `game_meeting_stone` does not join the - Package Delta `gameobjects` Import Family. A Package type-23 template has no stone row and - is refused as `NotAMeetingStone`. No Package ships a stone today; adding the table to the - family later is additive. -26. The Gateway side is a `dispatch_meeting_stone_action` seam in `world/handlers/`, with a narrow - `MeetingStoneActionStore` trait for the Coordinator and a Fake. Party code stays where it is. -27. A stone add writes the member's `owner_identity` the way `push_event` resolves a recipient: - the local Character row's identity when the database holds one (a single-database realm), - else zero (Realm-core, where each mirror re-derives it). -28. Debug fixtures use ids `509_6000`-`509_6099`. - -## Maintainer judgment - -1. **CI for the two new Gateway durable tests (T2, T4).** `module-durable.yml` names each Gateway - durable test, and the agents' token cannot push a workflow change. - a. T4 puts the two exact `cargo test ... --ignored --exact` lines in the PR description, and the - maintainer pushes the workflow edit to the feature branch over SSH before merge. - *Recommended.* The cross-shard proof is the only test of the realm-wide claim, so it should - run in CI. - b. Leave both tests local-only and run them by hand before each release. - c. Grant the agents' token the `workflow` scope so T4 can push the edit itself. - -## Coordination - -This section describes merged reality on `da11dcfc`. No sibling ticket set is open against these -files. - -- **Raids are on main** (#543 convert, #550 leadership, #551 Subgroup moves, #552 Group - Broadcasts, #557 quest credit and loot scale, #558 raid chat, #566 Instance Removal timer, #574 - integration). They use realm ops 6-15 and event kinds 20-26, and left ACCEPT's `arg_a`/`arg_b` - and kinds 27-30 alone. Their accept and removal cores (`RaidSlot` assignment, Instance Removal - reconciliation, Loot Tag revocation) are what T3's join core extraction must keep intact. -- **Realm Chat is on main** (#539, #542, #546, #555, #558, #565, #568, #575, #576). Party and raid - chat are Realm Chat Lines on `game_realm_chat_event`; kind 9 is retired and 12-19 are unassigned. - Chat Channels live on Realm-core and end membership with the Account Claim, which is the hook - T1 reuses. Nothing here touches channels or chat. -- **Guilds, mail and auctions are on main.** They add Realm-core tables, `realm_*` and `gw_*` - reducers and Gateway dispatch seams, and share no file region with this work beyond the - regenerated bindings and the base subscription list. -- **Playerbots tests moved to the Packages repository** (#595). The party-command durable tests and - the playerbots Headless Client acceptance harness are no longer in Core. The sharded Gateway - durable shape left in Core is `gateway/src/stdb/subscriptions_character_gone_durable_tests.rs`. -- **Perf work** (#578 playerbots capacity, #587 creature movement writes) does not touch the party, - group event or GameObject paths used here. - -## Execution order - -```text -T1 meeting stone queue tracer (alone) - ├── T2 Roster Revision Relay (Gateway) ──┐ - └── T3 stone matching (Module) ──────────┤ parallel worktrees - └── T4 integrate, verify, PR -``` - -| # | Ticket | Model | Est. tokens | File ownership | -|---|---|---|---:|---| -| T1 | Queue and leave at a meeting stone, alone or as a party | Opus | ~200k | everything it touches; see ticket | -| T2 | Roster Revision Relay | Opus | ~110k | Gateway only: `world/party_mirror.rs` (new), `sync_group_mirrors_required`'s signature in `world/party.rs`, the mirrored-revision read, the relay region of `stdb/subscriptions.rs`, `main.rs`, a new durable test file | -| T3 | Stone matching and party-queue rules | Opus | ~200k | Module only: `module/src/meeting_stone.rs`, `module/src/group.rs` cores, `module/src/debug/meeting_stone.rs`, `module/tests/meeting_stone_matching.rs` | -| T4 | Integrate, verify cross-shard, docs, PR | Opus | ~150k | union, docs, verification doc, PR | - -T2 touches no Module file and no bindings. T3 touches no Gateway file except regenerated bindings. - -## Shared rules - -- Read `AGENTS.md`, `CODING_STANDARDS.md`, `CONTEXT.md`, `docs/architecture.md` §2.3 and §5.3, - `docs/danger-zones.md` §1, and this file before editing. -- Apply `.claude/skills/unslop/SKILL.md` to prose. No issue numbers in code comments. No em dashes. -- Do not touch a production or development realm. Live wire runs are operator-gated. -- Prefix every cargo command with `nice -n 10 env CARGO_BUILD_JOBS=4`. One cargo command at a time. -- Schema: new tables only, END-append with typed `#[default(...)]` if a column is ever added. Never - drop a table. A new table or reducer means regenerating bindings with the `danger-zones.md` §1 - item 2 command and its hand-patches, then `scripts/check-gateway-bindings.py`. Never hand-merge - generated bindings; regenerate from the merged module. -- A new reducer argument changes the module reducer, the Gateway call and every durable test that - calls it, in one commit. -- Wire values that cross crates live in `lyracore-shared` and are pinned by tests. -- A Refusal never ends a session. Transport loss and untagged errors stay `Err`. -- Stay inside your file ownership. Report a cross-ticket gap instead of editing a sibling's region. -- Format touched files individually. Run `cargo clippy` and `cargo test` for each touched crate, the - wasm check for Module changes, and `lyracore preflight` after schema changes. Before excusing a - failure, prove it fails on the unmodified base. -- Commit unsigned (`git -c commit.gpgsign=false commit`) on your worktree branch with a - conventional-commit message. Do not push, open a PR or comment on GitHub unless the ticket - assigns it. Return the branch, exact test commands and results, and handoff notes. diff --git a/.claude/tickets/issue-532/T1-meeting-stone-queue.md b/.claude/tickets/issue-532/T1-meeting-stone-queue.md deleted file mode 100644 index b2d60d53b..000000000 --- a/.claude/tickets/issue-532/T1-meeting-stone-queue.md +++ /dev/null @@ -1,214 +0,0 @@ -# T1: Queue and leave at a meeting stone, alone or as a party - -Parent: issue #532, meeting stones. **Tracer. Runs alone. Blocks T2, T3, T4.** -Model: Opus. Estimated size: ~200k tokens. -Base: `main` at `da11dcfc` or later, branch `feat/meeting-stones`. - -## Problem - -A 1.12.1 client can right-click a meeting stone, and nothing answers. The importer drops the stone's -level range and dungeon area (`importer/src/main.rs:703-706`). No table holds a queue, no reducer -accepts one, and the Gateway has no arm for `CMSG_MEETINGSTONE_JOIN`, `_LEAVE` or `_INFO`. The -queue must be realm-wide: a Character who queued in Westfall and then walked into the Deadmines -instance is on another shard and still waiting. - -This ticket fixes every contract the other tickets build on: schema, reducer signatures, shared -bytes, event kinds and the Gateway flow. T2 and T3 then change no signature and share no file. - -## Delivery - -### Shared contract, `crates/lyracore-shared` - -- `constants.rs` `go_type`: add `MEETINGSTONE = 23` with a pin test, like `QUESTGIVER`. -- New `meeting_stone.rs`, exported from `lib.rs`: - - `realm_op`: `JOIN = 0`, `LEAVE = 1`. Its own byte space; `realm_meeting_stone_op` is a separate - reducer from `realm_group_op`. - - `queue_status`: 0 `LEAVE_QUEUE` .. 5 `NONE`, and `join_failure`: 1 `PARTY_LEADER`, - 2 `FULL_GROUP`, 3 `RAID_GROUP` (`cm:LFG/LFGDefines.h:50-67`). - - `MeetingStoneRefusal` with stable `meeting_stone:*` tags and `ALL`, the `GroupRefusal` shape: - `NotLeader`, `RaidGroup`, `PartyFull`, `ActorUnavailable`, `NotAMeetingStone`, `OutOfRange`, - `OtherPartition`, `LevelOutOfRange`, `UnknownArea`. - - `join_failure_for(refusal) -> Option`: `NotLeader` 1, `PartyFull` 2, `RaidGroup` 3. Every - other Refusal is silent, as in both cores. - - `event_kind`: `QUEUE = 27`, `MEMBER_ADDED = 28`, `IN_PROGRESS = 29`, `COMPLETE = 30`. - - `encode_queue(area_id, status)` and `decode_queue`, which fails closed on a status above 5. -- `group.rs`: in the `event_kind` doc table, replace "27-30: reserved for meeting stones" with a - pointer to `crate::meeting_stone::event_kind`. In the `realm_op` doc, ACCEPT reads `arg_a` as the - acceptor's class and `arg_b` as its race, 0 for unknown. -- One unit test that every `game_group_event` kind is distinct: `group` 0-3 and 20-26, `loot_roll` - 4-8, `quest::share_event_kind` 10-11, `meeting_stone` 27-30, and that none is 9. - -### Importer, `importer/src/main.rs` - -- Name `got::DATA2` (dump column 10). -- Capture `data2` in `GoMeta` for raw type 23, beside the trap fields (`main.rs:4940-4964`). -- In `gameobject_template_rows`, emit a `game_meeting_stone` row `(entry, data0, data1, data2)` for - every used template with raw type 23. Return it beside the trap rows, clear and reload it with the - `gameobjects` family exactly as `game_gameobject_trap` is (`main.rs:5549-5559`), and add its - count to that family's provenance stamp (`main.rs:5765-5770`). -- The type census prints `type 23 MEETINGSTONE (meeting stone flow, not use-dispatched)`, - the `QUESTGIVER` line's shape. -- The template row keeps its `other`-arm shape. The stone row is the only copy of the three values. -- The Package Delta `gameobjects` family does not change (README decision 25). - -### Module, `module/src/meeting_stone.rs` (new) - -Tables as in the README's durable-state list, with `SeekerFacts { character_guid, race, class }` -as a `SpacetimeType`. All three tables private. No schedule table in this ticket. - -- `gw_admit_meeting_stone(request_actor: SessionActor, go_guid)` on the Home Shard. - `require_operator`, then `require_actor`; a tokenless actor (`ownership: None`) is - `ActorUnavailable`. `taxi::is_in_flight` is `ActorUnavailable`. Call `gameobject::usable_go` and - map `MissingActor`, `OtherPartition`, `OutOfRange` and `MissingTarget` to `ActorUnavailable`, - `OtherPartition`, `OutOfRange` and `NotAMeetingStone`. A template that is not type 23 or has no - stone row is `NotAMeetingStone`. A level outside `min_level..=max_level` is `LevelOutOfRange`; - `max_level = 0` has no upper bound. Writes nothing. -- `realm_meeting_stone_op(op, request_actor: SessionActor, area_id, seekers: Vec)`, - the authority op. `require_operator`, `require_actor`, tokenless is `ActorUnavailable`, area 0 is - `UnknownArea`, more than `GROUP_MAX_MEMBERS` facts is an untagged error. Team comes from the - leader's or actor's race through `lyracore_shared::faction::team_for_race`. A missing fact means - class 0. - - JOIN, not in a party: upsert the solo Seeker with `queued_at = now`. Push `QUEUE(area, JOINED)` - to the actor. Re-joining replaces area and wait time. - - JOIN, in a party, checked in the core's order (`cm:LFG/LFGHandler.cpp:58-77`): not its leader - is `NotLeader`; `GroupKind::Raid` is `RaidGroup`; five members is `PartyFull`. - - JOIN, leader of a Party with room: upsert the party row with `next_reminder_at = now + 5 min`, - replace its Seeker rows with one per current member, push `QUEUE(area, JOINED)` to every member. - - LEAVE: a solo Seeker is deleted and gets `QUEUE(0, LEAVE_QUEUE)`. A leader of a queued party - deletes the party and its Seekers, and every member gets `QUEUE(0, LEAVE_QUEUE)`. Anyone else in - a party gets `QUEUE(0, NONE)`. Nobody queued and no party: `Ok`, no event. - - Membership reads go through `group::checked_group_membership`, `group_kind_of` and - `members_of`. Events go through `group::push_event`. Widen `checked_group_membership`, its - error type `GroupOpError`, and `gameobject::usable_go` to `pub(crate)`; change nothing else in - `group.rs` or `gameobject.rs`. -- `meeting_stone::claim_ended(ctx, character_guid)` deletes that Character's solo Seeker row, no - event. A party Seeker row stays. Call it at the three points in `account_ownership.rs` that call - `channel::leave_all` (`claim_account` replacing a dead generation, `release_account_claim`, - `reap_account_claims`). If the two calls then always travel together, fold them into one - `account_ownership` helper so the next claim-scoped feature has one place to hook. -- `tripwires.rs`: add `game_meeting_stone_seeker` to `NOT_CHARACTER_OWNED` with the reason - "Realm-core Meeting Stone Queue; a solo Seeker ends with its Account Claim, a party Seeker with - its party membership". `game_meeting_stone_party` has no Character guid column, so it needs no - entry, and the census test fails on one. No `character_owned!` markers. -- `debug/meeting_stone.rs`, behind `debug_reducers`, ids `509_6000`-`509_6099` (grep first): stage - a stone template, stone row and spawned GO; stage Characters with chosen race, class and level - beside it, each with a live Account Claim; stage a Party or Raid led by one of them; backdate a - Seeker's `queued_at` and a party's `next_reminder_at`. T3's durable tests use these, so make them - general enough for five Characters. - -T1 does no matching. A queued party waits until T3. - -### Gateway - -- Regenerate bindings once, with the hand-patches, and run `scripts/check-gateway-bindings.py`. -- `stdb/connection.rs`: add `game_meeting_stone` and `game_meeting_stone_seeker` to the base list - of `coordinator_queries`, beside the Chat Channel tables. Both are small, and a single-database - realm needs them. -- `stdb/reads/templates.rs` and `codec/gameobject.rs`: add `data2` to `GameObjectTemplateView`. - For a type-23 template fill `data0..data2` from its stone row; send - `raw_data: [data0, data1, data2, 0, 0, 0]`. Other types keep `data2 = 0`. -- Reads: the stone area for a spawned GO guid on this shard, and the Seeker's area for a Character. -- `stdb/reducers.rs`: wrappers for both reducers returning `Ran` or `Refused(MeetingStoneRefusal)`. - An untagged error stays `Err`. -- `world/handlers/meeting_stone.rs` (new), the `dispatch_channel_action` shape: - `dispatch_meeting_stone_action` over a narrow `MeetingStoneActionStore` trait, implemented for - `Coordinator` and for a Fake. The Coordinator picks the database: admission on the actor's Home - Shard, the op on `realm_store()` or the one database. Wire it into the chain in - `world/mod.rs` beside `dispatch_channel_action`. Opcodes: - - `CMSG_MEETINGSTONE_JOIN`: admission; stone area; `Area::try_from` refuses → debug log, no - further call; facts for the actor, or for every member of the actor's Realm-core roster via - `presence::character_anywhere`; then the op. `join_failure_for` decides whether to answer - `SMSG_MEETINGSTONE_JOINFAILED`. - - `CMSG_MEETINGSTONE_LEAVE`: the op, nothing else. - - `CMSG_MEETINGSTONE_INFO`: `SETQUEUE(area, JOINED)` when the Character has a Seeker row, else - `SETQUEUE(0, NONE)`. - - `MSG_LOOKING_FOR_GROUP`: answer `MSG_LOOKING_FOR_GROUP { unknown1: 0 }`. - - All four drop silently outside the world. -- `world/party.rs`: ACCEPT carries the acceptor's class and race. Pack them in `Op::realm_args` - only, so one place fills `arg_a`/`arg_b`. Both Realm-core accept paths supply them: the client - accept through `run` and the playerbot accept in `answer_for_session_less`. Read them through - `presence::character_anywhere`, 0 when unknown. The single-database `group_accept` path is - unchanged; T3 reads the local Character row there. -- Codec builders for `SETQUEUE`, `JOINFAILED`, `MEMBER_ADDED`, `IN_PROGRESS`, `COMPLETE` using - `wwm:` types. -- `stdb/subscriptions.rs` `group_event_outbound`: arms for kinds 27-30. A bad payload logs a warning - and sends nothing. - -## Acceptance criteria - -1. An imported world has one `game_meeting_stone` row per spawned type-23 template, carrying the - dump's `data0`, `data1`, `data2`. -2. `CMSG_GAMEOBJECT_QUERY` for a stone answers `raw_data[0..3] = [min, max, area]`. Other GO types - answer byte-identically to today. -3. A lone Character in range and level range who joins gets exactly one `SETQUEUE(area, JOINED)` - and one Seeker row on the authority. Joining again at another stone replaces the row. -4. Out of range, another map or instance, wrong GO type, outside the level range, taxi flight, or a - tokenless actor: no durable change, no packet. -5. A party member who is not the leader gets `JOINFAILED(1)`. The leader of a Raid gets - `JOINFAILED(3)`. The leader of a full Party gets `JOINFAILED(2)`. None changes durable state. -6. A Party leader's join queues the party: one party row, one Seeker row per member with the class - the Gateway supplied, `SETQUEUE(area, JOINED)` to every member on any shard. -7. `LEAVE` sends `LEAVE_QUEUE` to a solo Seeker, `LEAVE_QUEUE` to every member when the leader of a - queued party leaves, and `NONE` to a non-leader in a party, per the README table. -8. `INFO` answers `SETQUEUE(area, JOINED)` for any Character with a Seeker row, and `SETQUEUE(0, NONE)` - otherwise, on world entry and after a Transfer. -9. Logout drops the solo Seeker with no packet. A crashed Gateway's solo Seekers are gone after one - lease reaper pass past their claims' expiry. A new claim on the same Account drops the previous - Character's solo Seeker. Party Seeker rows survive all three. -10. `MSG_LOOKING_FOR_GROUP` gets `MSG_LOOKING_FOR_GROUP(0)`. -11. On a sharded Gateway, `realm_meeting_stone_op` runs on Realm-core only; the admission runs on - the Character's Home Shard only. On a single database both run on that database. -12. The Realm-core ACCEPT carries the acceptor's class and race, for a client and for a playerbot. -13. A Refusal never ends the World Session. Transport loss does. - -## Verification rungs - -- Unit: shared tags, `ALL`, kind distinctness, payload round trip and fail-closed decode; the level - range rule with inclusive bounds and `max_level = 0`; the JOIN classification given membership - and kind, in the core's order; importer stone rows from a fixture dump, including an unspawned - stone that yields none. -- Codec: `SETQUEUE` bytes equal `u32 area, u8 status` (`cm:LFG/LFGHandler.cpp:146-152`); the - status and failure bytes equal `wwm:` `MeetingStoneStatus::as_int` and - `MeetingStoneFailure::as_int`; the stone query response. -- Gateway Store Fake: an in-file `mod tests` in `world/handlers/meeting_stone.rs` with a Fake - `MeetingStoneActionStore`, the `channel.rs` shape. One test per routing branch: admission refused - stops before Realm-core; each join failure byte; unknown `Area` stops before Realm-core; party - facts come from every member across shards; leave; `INFO` both answers; `MSG_LOOKING_FOR_GROUP`; - unsharded runs everything on the one store. ACCEPT class and race go in `party_tests.rs`. Relay - arms for kinds 27-30 go beside the existing `group_event_outbound` tests. -- Durable Module: `module/tests/meeting_stone_queue.rs` with `support::Standalone`, the - `auction_buyout.rs` shape. Stage through the debug fixtures and drive the real reducers. Cover - criteria 3 to 7 and 9 against `game_meeting_stone_seeker`, `game_meeting_stone_party` and - `game_group_event` rows. CI picks the new target up by itself. -- `lyracore preflight` after the schema change. - -## CONTEXT.md terms - -Add a `### Meeting stones` section: - -- **Meeting Stone**: a type-23 GameObject at a dungeon entrance. Using it puts the Character, or - the Party it leads, in the Meeting Stone Queue for the stone's dungeon area, inside the stone's - level range. _Avoid_: summoning stone, LFG tool. -- **Meeting Stone Queue**: Realm-core's realm-wide list of Seekers and queued Parties, per dungeon - area and team. _Avoid_: LFG queue, matchmaking queue. -- **Seeker**: one Character in the Meeting Stone Queue, alone or as a member of a queued Party. It - carries the class and team the Gateway supplied. A solo Seeker lasts as long as its Account - Claim. _Avoid_: queued player, LFG player. - -## File ownership - -Everything this ticket touches. T2 and T3 start from its merged branch. - -## Non-goals - -- No matching, no stone adds, no reminders, no group-core hooks, no raid-convert dequeue. T3 owns - them. -- No mirror push for parties the actor is not in. T2 owns it. -- No summoning, no LFG window, no channel change, no playerbot queueing, no Package Delta change. - -## Definition of done - -Touched files formatted. `cargo clippy` and `cargo test` clean for `lyracore-shared`, -`lyracore-importer`, `lyracore-module` (plus the wasm check) and `lyracore-gateway`. Binding check -clean. Preflight clean. The durable test passes locally. Commit on the feature branch and report -the exact commands, the fixture reducer names and anything T3 must know. diff --git a/.claude/tickets/issue-532/T2-roster-revision-relay.md b/.claude/tickets/issue-532/T2-roster-revision-relay.md deleted file mode 100644 index 82c98b66c..000000000 --- a/.claude/tickets/issue-532/T2-roster-revision-relay.md +++ /dev/null @@ -1,127 +0,0 @@ -# T2: Roster Revision Relay - -Parent: issue #532, meeting stones. Depends on T1. **Runs in parallel with T3.** -Model: Opus. Estimated size: ~110k tokens. - -## Problem - -World Shard party mirrors change only when this Gateway's own `party::run` pushes the acting -Character's before and after groups (`gateway/src/world/party.rs:1003-1007`), at world entry, on -deleted-Character cleanup, and in `reconcile_deleted_character_parties` (`party.rs:1327-1371`). A -roster change the Module makes on Realm-core for a party the actor is not in never reaches the -mirrors. Main has no relay for it; verified on `da11dcfc`. - -T3 makes that normal. A Seeker joining a queue can form a party from five other Seekers. A kick can -put the kicked Character into a second party. Those parties' mirrors stay stale until someone's next -op or world entry, so `/p`, the XP split, loot rules and the dungeon binding read the wrong roster -on every shard. - -Realm-core already advances `game_group_roster_revision` for every roster change, and each Gateway -connection of a sharded realm caches that table. The mirror can follow the revision. - -## What already exists, and is reused - -- `sync_group_mirrors_required` (`party.rs:1380-1428`) reads the Realm-core roster or its disband - tombstone, certifies partitions, and pushes to every World Shard with three attempts inside a - 5 s retry window. The relay calls it; it writes no retry logic of its own. -- A Realm-core reconnect already starts `reconcile_deleted_character_parties`, which pushes every - known party (`gateway/src/stdb/subscriptions.rs:4237-4248`). Changes missed while disconnected - need no new pass. -- `spawn_bot_invite_relay` and `arm_bot_invite_relay` (`subscriptions.rs:4084-4140`) are the shape - for a callback relay that re-arms on reconnect and runs its work off the SDK callback thread. - -## Delivery - -**Roster Revision Relay**: when a Realm-core `game_group_roster_revision` row is inserted or -updated, push that party's authoritative roster to every World Shard whose mirrored revision is -older or missing. - -- `gateway/src/world/party_mirror.rs` (new): - - A pure selection of stale shards from the Realm-core revision and each shard's mirrored - revision. A shard with no row for the party is stale. - - A coalescing set of dirty party ids with one worker thread per Gateway process. The worker - drains the set, selects stale shards from the caches, and calls `sync_group_mirrors_required` - for those shards only. Several revisions of one party before the worker runs cause one push. - - A push that still fails logs a warning naming the party and shard. The party then waits for - its next revision or the reconnect pass. Never panic the process. -- `gateway/src/world/party.rs`: give `sync_group_mirrors_required` the target shards as an - argument; its three existing callers pass `store.world_stores()`. Change nothing else in - `party.rs`. -- Mirrored revision read: `stdb/reads/party.rs:207-216` `group_roster_revision` answers 1 for a - missing row. Add a read that returns `Option` (the shard's row, or `None`), a matching - `WorldStore` method in `world/store.rs` with a default, its `stdb/world_store.rs` impl, and the - `InMemoryStore` Fake in `world/tests.rs`. Leave `group_roster_revision` as it is. -- `gateway/src/stdb/subscriptions.rs`: `spawn_roster_revision_relay` and `arm_roster_revision_relay` - after `arm_bot_invite_relay`. Register `on_insert` and `on_update` for `game_group_roster_revision` - on the Realm-core connection only. Callbacks enqueue a party id and return; they never call a - reducer. Re-arm through Realm-core's `on_reconnect`. The initial subscription apply may enqueue - every row; the stale check finds them current and pushes nothing, so it needs no special case. -- `gateway/src/main.rs`: call `coordinator.spawn_roster_revision_relay()` beside the other relays, - and add an Architecture Test beside the bot relay ones proving `main` calls it and the re-arm is - installed. -- Arm nothing when Realm-core is not a distinct database. - -Keep `party::run`'s inline push. It gives the acting session its own party on its next packet. The -relay then finds those shards current and skips them. - -## Acceptance criteria - -1. A roster change committed by calling `realm_group_op` directly on Realm-core, with no - `party::run`, reaches every World Shard mirror. Each shard's `game_group_roster_revision` then - equals Realm-core's. -2. A disband reaches every shard as the tombstone and the mirror forgets the party. -3. A shard already holding the revision gets no `sync_group_mirror` call. -4. Five revisions of one party before the worker runs cause one push per stale shard. -5. No reducer call runs on an SDK callback thread. A slow or unavailable shard delays only the - worker. -6. After a Realm-core reconnect the relay is armed again. -7. A single-database Gateway spawns no relay. -8. A failed push is logged with party and shard after `sync_group_mirrors_required`'s attempts. - -## Verification rungs - -- Unit: stale-shard selection, including a missing row and an equal revision; coalescing. -- Gateway Store Fake: tests in `gateway/src/world/party_mirror.rs` using - `party_tests::party_topology`. Change the `FakeParty` roster directly, as the matcher would, run - the worker once, assert both shard mirrors. Cover the tombstone and the skip. -- Durable Gateway: `gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs`, the - `subscriptions_character_gone_durable_tests.rs` shape: a real Realm-core plus two World Shards, a - Coordinator with the relay spawned, `realm_group_op` INVITE and ACCEPT sent straight to Realm-core - with the private CLI, then poll both shards' `game_group_member` and - `game_group_roster_revision`. Run it locally; CI wiring is a README maintainer question. -- Architecture Test in `main.rs`. - -## CONTEXT.md terms - -Under "Sharding and transfer", after **Roster Revision**: - -- **Roster Revision Relay**: the Gateway's push of a party's Realm-core roster to each World Shard - whose mirror holds an older Roster Revision or none, whatever changed it on Realm-core. _Avoid_: - mirror sync thread, roster watcher. - -## File ownership - -- `gateway/src/world/party_mirror.rs` (new) and its tests. -- `gateway/src/world/mod.rs`: one module declaration line. -- `gateway/src/world/party.rs`: `sync_group_mirrors_required`'s signature and its three call sites. -- `gateway/src/stdb/reads/party.rs`, `gateway/src/world/store.rs`, `gateway/src/stdb/world_store.rs`, - `gateway/src/world/tests.rs`: the mirrored-revision read and its Fake, nothing else. -- `gateway/src/stdb/subscriptions.rs`: the new spawn and arm functions, placed after - `arm_bot_invite_relay`. Do not touch `group_event_outbound`. -- `gateway/src/main.rs`: the call and its Architecture Test. -- `gateway/src/stdb/subscriptions_roster_relay_durable_tests.rs` (new) and its module line. -- `CONTEXT.md`: the one entry above. - -No Module files, no bindings. T3 owns the Module and may regenerate bindings. - -## Non-goals - -- No meeting-stone logic. The relay does not know why a revision moved. -- No change to `sync_group_mirror`, `party::run`, or the reconciliation passes. -- No new retry policy and no new full reconciliation pass. - -## Definition of done - -Touched files formatted. `cargo clippy -p lyracore-gateway` and `cargo test -p lyracore-gateway` -clean, including the new durable test where the standalone toolchain exists. Commit on your -worktree branch and report the exact commands and results. diff --git a/.claude/tickets/issue-532/T3-stone-matching.md b/.claude/tickets/issue-532/T3-stone-matching.md deleted file mode 100644 index 8ea15a705..000000000 --- a/.claude/tickets/issue-532/T3-stone-matching.md +++ /dev/null @@ -1,184 +0,0 @@ -# T3: Stone matching and party-queue rules - -Parent: issue #532, meeting stones. Depends on T1. **Runs in parallel with T2.** -Model: Opus. Estimated size: ~200k tokens. - -## Problem - -After T1, Seekers wait forever. Nobody is grouped, a queued party never fills, and a queued party -that loses or kicks a member keeps stale Seeker rows. Vanilla groups Seekers by class role, -announces each stone add, completes the party at five, and reminds a waiting party every five -minutes. It also reacts to every membership change of a queued party. All of it is gameplay, so it -belongs in the Module on Realm-core, and every party it forms or changes must go through the same -group cores a player's invite uses. - -## Delivery - -All in the Module. No Gateway file. Keep T1's table shapes and reducer signatures. - -### Roles, pure and unit-tested, in `module/src/meeting_stone.rs` - -- Class roles (`cm:LFG/LFGMgr.cpp:91-106`): Druid and Paladin tank, healer, damage. Priest and - Shaman healer, damage. Warrior tank, damage. Hunter, Mage, Rogue, Warlock damage. Class 0 fills - nothing. -- Class priority per role (`cm:LFG/LFGMgr.cpp:108-158`). -- A party's **Open Roles** from its members' classes in join order: one tank, one healer, three - damage. Each member takes the first of tank, healer, damage its class fills and that is still - open, unless another not-yet-placed member has a higher priority for it - (`cm:Groups/Group.cpp:1589-1685`). Worked example: paladin then warrior leaves three damage open. -- The next stone add: for tank, then healer, then damage, if open, the longest-waiting Seeker whose - class fills it; ties by guid. This is what `cm:LFG/LFGQueue.cpp:292-390` does once its `bool` - priority compare is read literally. - -### The bucket pass, `match_bucket(ctx, area_id, team)` - -Run it at the end of every transaction that can create a match: T1's JOIN, and the hooks below that -free an Open Role or queue a Character. Loop until nothing changes: - -1. Each queued party in the bucket, oldest `queued_at` first, takes stone adds while it has fewer - than five members. Before an add, push `MEMBER_ADDED(guid)` to the current members, then - delete the solo Seeker, add the Character through the shared join core, and write its party - Seeker row. At five members push `COMPLETE` then `QUEUE(0, NONE)` to every member and delete - the party and its Seeker rows (`cm:LFG/LFGQueue.cpp:372-383,420-454`). -2. If the bucket still holds five or more solo Seekers, form a party: the longest wait leads, the - next joins. Push `MEMBER_ADDED(member)` to the leader, create the party through the join core, - queue it with `QUEUE(area, JOINED)` to both (`cm:LFG/LFGQueue.cpp:173-221`), then go to 1. - -A solo Seeker whose Account Claim is closed or past `expires_micros` is deleted silently when the -pass meets it and is never added. T1 drops most of them when the claim ends; this check covers the -up to 15 s between expiry and the lease reaper. Read the bucket through the `(area_id, team)` -index; never scan the whole Seeker table. - -### Group cores, `module/src/group.rs` - -- Extract the part of `accept_invite_on` (`group.rs:1400-1517`) that creates a party for a leader - when needed, inserts the member and pushes the roster into one join core. ACCEPT and the bucket - pass both call it. There is no second way to add a member. The core keeps everything the accept - does today: the `has_room` and `RaidSlot` rules, Vanilla's GROUP loot at Uncommon for a new - party, folding the inviter's other pending invites into a new party, `push_list_to_all`, and - `instance::reconcile_instance_removal` for the joiner and a new party's leader. -- Member identity in the join core (README decision 27): the local Character row's - `owner_identity` when this database holds the row, else zero. Use the existing Character lookup - chokepoint; `character_fence_tripwire` forbids a raw `game_character` lookup. -- Every transaction that changes a party roster advances its Roster Revision through - `advance_group_revision`, the helper `realm_group_op` uses. That includes every party the bucket - pass forms or fills during a `realm_meeting_stone_op` JOIN, and a party a kick re-queue fills. - Advancing a party twice in one transaction is fine. T2's relay pushes whatever this advances. -- `realm_group_op` passes ACCEPT's `arg_a` class into the accept core. The core does not read - `arg_b` race: the Seeker row a queued party gains takes the party's team. On the single-database - plane (`gw_accept_group_invite`, `Plane::Shard`) read the acceptor's class from its local - `game_character` row. -- Hooks for queued parties, per the README fact table. Keep `remove_member` the one removal core; - tell kick from leave with a cause argument or with work in `uninvite_on` before it calls - `remove_member`. Deletion already arrives as `leave_group_on(.., CHARACTER_DELETED)` on - Realm-core and as the `sweep_delete_game_group_member` marker on a single database; both end in - `remove_member`. - - Accept: a solo Seeker who joins any party loses its solo row, and gets `QUEUE(0, LEAVE_QUEUE)` - unless the party is queued for the same area (`cm:Groups/Group.cpp:360-374`). A queued party - gains the joiner's Seeker row with its class; five members completes it. A playerbot invited - into a queued party gets a Seeker row the same way. - - Leave or Character deletion, leader unchanged: leaver `QUEUE(0, NONE)`, the rest - `QUEUE(area, PARTY_MEMBER_LEFT_LFG)`, the leaver's Seeker row goes, the party stays queued, then - the bucket pass (`cm:Groups/Group.cpp:437-447,478-479`). - - Leave, leader changed: leaver `QUEUE(0, NONE)`, the rest `QUEUE(0, LEAVE_QUEUE)`, the party - leaves the queue (`cm:Groups/Group.cpp:464-476`). - - Kick from a party that survives: the rest get `QUEUE(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED)` then - `QUEUE(0, LEAVE_QUEUE)`, the party leaves the queue. The kicked Character gets - `QUEUE(area, LOOKING_FOR_NEW_PARTY_IN_QUEUE)`, becomes a solo Seeker with its stored class and - team, gets `QUEUE(area, JOINED)`, then the bucket pass (`cm:Groups/Group.cpp:413-435`). - - Disband, including a kick or leave from two members: every former member `QUEUE(0, NONE)`, the - party leaves the queue, nobody is re-queued (`cm:Groups/Group.cpp:550-562`). - - Raid convert (README decision 22): `raid_convert_on` calls - `meeting_stone::dequeue_party(ctx, group_id, LEAVE_QUEUE)` when the party is queued. Every - member gets `QUEUE(0, LEAVE_QUEUE)` before the raid list. - - Set leader: nothing (README decision 23). - -### Reminders - -Add `game_meeting_stone_reminder_schedule`, a 5 s scheduled reducer with the scheduler-only gate. -Seed it in `seed.rs::seed_scheduler_arming` and re-arm it in -`debug/repair.rs::debug_repair_after_publish`, the weather precedent (`rearm_weather_schedule`). -Every party row with `next_reminder_at <= now` pushes `IN_PROGRESS` to each member and moves -`next_reminder_at` on by five minutes (`cm:LFG/LFGMgr.cpp:55`, `cm:LFG/LFGQueue.cpp:149-163`). -The party table is small, private and not spatial. - -### Event order - -Insert each recipient's events in wire order inside one transaction, for example `MEMBER_ADDED` -before the roster LIST it precedes in cmangos. T4 proves the relay keeps that order. - -## Acceptance criteria - -1. Five solo Seekers of one area and team form one party. With a tank, a healer and three damage - classes it completes at once: every member ends with `COMPLETE` then `QUEUE(0, NONE)`, and no - Seeker or party row remains. -2. Five mage Seekers form a party of leader, first member and one more damage add. It stays queued - with tank and healer open, and two mages keep waiting. -3. A queued party of a warrior and three mages takes the longest-waiting healer-capable Seeker as - healer. A second priest waits. (A warrior and one mage would also take the second priest as - damage: priests fill damage at low priority, cm:LFG/LFGMgr.cpp:99,146.) -4. Existing members get `MEMBER_ADDED(guid)` for each stone add. The added Character does not. -5. Horde and Alliance Seekers of one area never meet. Different areas never meet. -6. A Seeker without a live Account Claim is never added and its row is gone after the pass. -7. Each leave, kick, disband, accept and raid-convert case in the hooks list produces exactly the - listed events and queue rows, on both planes. -8. A kicked Character re-queued alone joins a second queued party in the same transaction when a - role fits. -9. Every party the Module forms or changes has a higher Roster Revision after the transaction. -10. A party row whose reminder is due sends `IN_PROGRESS` to every member once and schedules the - next five minutes later. -11. A stone-formed party has the same `game_group` defaults as an invited one, group loot at - Uncommon, because both go through the one join core. -12. A Character who joins a stone party inside a dungeon instance its new party owns has its - Instance Removal cancelled, as an invited one does. - -## Verification rungs - -- Unit: the role table and priorities against the cmangos rows cited above; Open Roles for the - paladin-then-warrior case, a two-warrior party and a full party; the stone-add pick order with ties; - the formation threshold at four and five Seekers. -- Durable Module: `module/tests/meeting_stone_matching.rs` with `support::Standalone`, staged - through T1's debug fixtures. One test per criterion 1-3 and 5-12, asserting `game_group`, - `game_group_member`, `game_group_roster_revision`, the Seeker and party tables and the ordered - `game_group_event` rows per recipient. Criterion 7 runs through `realm_group_op` for the - Realm-core plane and through the `gw_group_*` reducers for the single-database plane. Raid - convert has no `gw_group_*` reducer, so its case runs through `realm_group_op` only. The - existing `raid_convert.rs`, `instance_removal.rs` and `loot_tag.rs` durable tests must stay green - after the join core extraction. -- The Module wasm check and `cargo test -p lyracore-module --lib` for the tripwires. - -## CONTEXT.md terms - -In the `### Meeting stones` section T1 created: - -- **Open Role**: one of a queued Party's one tank, one healer and three damage roles that no - member's class fills yet. _Avoid_: vacancy, role slot. -- **Stone Add**: the Module adding a Seeker to a queued Party, or forming a Party from five waiting - Seekers, through the party authority's join core. _Avoid_: matchmaking, auto-invite. - -## File ownership - -- `module/src/meeting_stone.rs`: everything except T1's table shapes and reducer signatures. -- `module/src/group.rs`: the join core, the accept, leave, uninvite, raid convert and - `remove_member` cores, `realm_group_op`'s ACCEPT arm and revision advances. -- `module/src/seed.rs` and `module/src/debug/repair.rs`: the reminder schedule's seed and re-arm. -- `module/src/debug/meeting_stone.rs`: may add fixtures. A new table or reducer means regenerating - bindings and saying so in the handoff. T2 touches no bindings. -- `module/tests/meeting_stone_matching.rs` (new). -- `CONTEXT.md`: the two entries above. - -No Gateway files except regenerated bindings. No importer. - -## Non-goals - -- No talent roles, no summoning, no playerbot queueing. -- No mirror push. T2 owns it. -- No change to T1's Gateway flow. - -## Definition of done - -Touched files formatted. Module wasm check, `cargo clippy -p lyracore-module`, -`cargo test -p lyracore-module` and the new durable test clean. The reminder schedule table means -bindings are regenerated; the binding check and `cargo test -p lyracore-gateway` are clean too. -Commit on your worktree branch and report the exact commands, results and any schema or fixture -change T4 must know. diff --git a/.claude/tickets/issue-532/T4-integrate-verify-pr.md b/.claude/tickets/issue-532/T4-integrate-verify-pr.md deleted file mode 100644 index cd9a5346a..000000000 --- a/.claude/tickets/issue-532/T4-integrate-verify-pr.md +++ /dev/null @@ -1,84 +0,0 @@ -# T4: Integrate, verify across shards, document, open the PR - -Parent: issue #532, meeting stones. **Runs last, after T1, T2 and T3.** -Model: Opus. Estimated size: ~150k tokens. - -## Problem - -T1, T2 and T3 each prove their own rung. Nothing yet proves the realm-wide claim: Seekers on -different World Shards meet in one queue, the stone forms one party on Realm-core, and every World -Shard mirror carries it. The relay's event order inside one transaction is also unproven, and the -docs do not know meeting stones exist. - -## Delivery - -1. Merge T1, then T2, then T3 on the feature branch. T3 adds a schedule table, so regenerate - bindings once more from the merged module with the `danger-zones.md` §1 item 2 command and - hand-patches. Never hand-merge generated files. -2. Cross-shard durable proof, `gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs`, the - `subscriptions_character_gone_durable_tests.rs` shape: Realm-core plus two World Shards, a - Coordinator with the Roster Revision Relay spawned. Stage a Deadmines stone and five Characters, - three on one shard and two on the other, with their Account Claims on Realm-core and classes that - fill tank, healer and three damage. Drive `dispatch_meeting_stone_action` for each through the - real Coordinator. T1's fixtures stage one database; extend them for the split if needed. -3. Event order. Assert that one recipient's events from one transaction reach its session in row-id - order. If the relay can reorder them, sort per recipient before enqueueing in - `world_view::group_event_appeared` and say so in the PR. -4. CI. Write the exact `cargo test -p lyracore-gateway --bin lyracore-gateway -- --ignored - --exact` lines for this test and T2's durable test. The agents' token cannot push - `.github/workflows/*`; follow the maintainer's answer to the README question. Until then, put the - lines in the PR description. -5. `docs/meeting-stone-client-check.md`, the `auction-house-client-check.md` shape, status - outstanding, for a human with a real 5875 client. Check: right-click sends `JOIN`, not - `GAMEOBJ_USE`; the tooltip shows the level range; the client refuses an out-of-range level itself; - the minimap button appears and its tooltip names the dungeon; each `ERR_MEETING_STONE_*` line for - each status; `INFO` after a world port keeps the button; leaving through the minimap confirm; - converting a queued party to a raid drops the button; a stone-formed party's frame, `/p` and XP - split across two shards. -6. Docs: - - `docs/architecture.md`: add the Meeting Stone Queue to Realm-core's holdings in §1 and §3.1; - add the class and race facts to the §2.3 Gateway read list beside presence; add the Roster - Revision Relay to §5.3. - - `docs/schema.md`: the new tables in the §2 inventory and the recount. - - `CONTEXT.md`: check the T1, T2 and T3 entries read as one section and use the `_Avoid_` words - nowhere in new code or prose. - - `CHANGELOG.md`: one line under `[Unreleased]` → Added. -7. Open the PR with the `file-pr` skill after rebasing on `main`. Title in the repo's style, for - example `feat(meeting-stones): queue and group Seekers realm-wide`. The body states the problem, - the solution, that 1.12.1 has no LFG window, the two settled maintainer decisions (stones only, - humans only), the raid-convert dequeue that departs from both cores, the CI state, and the model - and harness blurb. Use `Refs #532`, not `Closes`, while the client check is outstanding. - -## Acceptance criteria - -1. The five staged Characters end in one party on Realm-core with five members, the tank, healer and - damage classes placed, and no Seeker or party row left. -2. Both World Shards' `game_group_member` mirrors hold the five members, and each shard's - `game_group_roster_revision` equals Realm-core's, without any party op from those Characters. -3. Each Character's session receives its stone events in the order the Module inserted them, from - its first `SETQUEUE(area, JOINED)` to the closing `COMPLETE` then `SETQUEUE(0, NONE)`. -4. A Seeker who crosses into the Instance Pool after queueing stays queued and gets - `SETQUEUE(area, JOINED)` for `INFO` on arrival. -5. Every workspace check below is clean, or each exception is proven to fail on the base commit. -6. The client check document exists with outstanding status and exact steps. - -## Verification rungs - -- Format touched files individually, `cargo clippy` and `cargo test` for `lyracore-shared`, - `lyracore-importer`, `lyracore-module`, `lyracore-gateway` and `lyracore-package-delta`. -- The Module wasm check, `scripts/check-gateway-bindings.py`, `lyracore preflight`. -- The durable Module tests from T1 and T3, the durable Gateway tests from T2 and this ticket. -- The real-client check is the named human rung. A Headless Client run is optional. - -## CONTEXT.md terms - -None new. Consolidate. - -## File ownership - -The union after merge, the new durable test, the docs listed above and the PR. - -## Definition of done - -The PR is open against `main`, rebased, green, and links the client check document as the one -outstanding human step. From 1c36b3a1112fe74369b038066051670b975a25b8 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 07:48:44 +0200 Subject: [PATCH 12/16] fix(meeting-stones): never commit a half-done Stone Add A bucket pass that failed inside remove_member is logged and its writes commit, so a MEMBER_ADDED could go out for a member who never joined. The join core now splits into plan_join, which checks and writes nothing, and JoinPlan::apply, which cannot fail. A Stone Add writes its events and drops the Seeker row only after its plan holds, and the pass drops a party row whose Group is gone or is a Raid, so one stale row no longer blocks every JOIN in its bucket. ACCEPT keeps main's refusal order: the restored missing-Character checks run after the join checks. A kicked member without a live Account Claim only leaves the queue. The group event relay reads the cache only for a recipient with a session, once per transaction. The Roster Revision Relay logs when it cannot arm and survives a panicking pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/stdb/subscriptions.rs | 12 +- gateway/src/stdb/world_view.rs | 35 ++++-- gateway/src/world/party_mirror.rs | 11 +- module/src/group.rs | 165 +++++++++++++++---------- module/src/meeting_stone.rs | 109 ++++++++++------ module/tests/meeting_stone_matching.rs | 81 ++++++++++++ 6 files changed, 295 insertions(+), 118 deletions(-) diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index 49545f302..2e81fbf0e 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -4167,8 +4167,16 @@ impl Coordinator { /// re-arms the callbacks on the fresh connection; changes missed while it was down are the /// reconciliation pass's, which that reconnect also starts. pub fn spawn_roster_revision_relay(&self) { - let Ok(realm) = self.realm_core() else { - return; + let realm = match self.realm_core() { + Ok(realm) => realm, + Err(error) => { + log::error!( + "party: the Roster Revision Relay is not running; Realm-core is unavailable at \ + startup ({error:#}). World Shard mirrors follow only party ops and the \ + reconnect pass until the Gateway restarts" + ); + return; + } }; if self .all_shards() diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 04fcde044..d6c489a3e 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -1478,8 +1478,10 @@ fn wire_insert_live( }); } -/// [`wire_insert_live`] for `game_group_event` from `source`. The dispatcher also gets the cached -/// table, which already holds the whole transaction when the callback runs. +/// [`wire_insert_live`] for `game_group_event` from `source`. The dispatcher also gets a read of +/// the cached table, which already holds the whole transaction when the callback runs. It runs +/// the read only for a recipient with a session, once per transaction, because the SDK copies +/// every cached row to iterate. fn wire_group_events( db: &RemoteTables, label: &'static str, @@ -1493,7 +1495,9 @@ fn wire_group_events( return; } guarded(label, || { - group_event_appeared(&view, &coord, source, row, ctx.db.game_group_event().iter()); + group_event_appeared(&view, &coord, source, row, || { + ctx.db.game_group_event().iter().collect() + }); }); }); } @@ -2749,7 +2753,7 @@ fn group_event_appeared( coord: &Coordinator, source: GroupEventSource, row: &GroupEvent, - cached: impl IntoIterator, + cached: impl FnOnce() -> Vec, ) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; @@ -2798,20 +2802,21 @@ impl GroupEventCursor { /// The SDK fires the inserts of one transaction in hash order, and the client must hear them /// in insert order: a filled Meeting Stone party's `COMPLETE` comes before `SETQUEUE(0, NONE)`. /// So the first callback of a transaction takes every row of that transaction for this - /// recipient from `cached`, which already holds the whole transaction, and the later callbacks - /// find theirs taken. Rows of one transaction share `created_at`. + /// recipient from `cached`, which reads the table that already holds the whole transaction, and + /// the later callbacks find theirs taken without reading it. Rows of one transaction share + /// `created_at`. fn take( &self, source: GroupEventSource, row: &GroupEvent, - cached: impl IntoIterator, + cached: impl FnOnce() -> Vec, ) -> Vec { let mut cursors = self.0.lock().unwrap_or_else(|p| p.into_inner()); let cursor = cursors.entry(source).or_default(); if row.id <= *cursor { return Vec::new(); } - let mut rows: Vec = cached + let mut rows: Vec = cached() .into_iter() .filter(|cached| { cached.recipient_guid == row.recipient_guid @@ -6053,10 +6058,14 @@ mod group_event_order_tests { let cursor = GroupEventCursor::default(); let source = GroupEventSource::RealmCore; - let first = cursor.take(source, &cached[1], cached.clone()); + let first = cursor.take(source, &cached[1], || cached.to_vec()); let later: Vec = [&cached[2], &cached[4]] .into_iter() - .flat_map(|row| cursor.take(source, row, cached.clone())) + .flat_map(|row| { + cursor.take(source, row, || -> Vec { + panic!("a row already taken must not read the table again") + }) + }) .collect(); assert_eq!(ids(&first), [10, 11, 12]); @@ -6069,7 +6078,7 @@ mod group_event_order_tests { fn each_source_database_keeps_its_own_cursor() { let cursor = GroupEventCursor::default(); let realm = [event(10, RECIPIENT, 200), event(11, RECIPIENT, 200)]; - cursor.take(GroupEventSource::RealmCore, &realm[0], realm.clone()); + cursor.take(GroupEventSource::RealmCore, &realm[0], || realm.to_vec()); let next = [ event(10, RECIPIENT, 200), @@ -6079,11 +6088,11 @@ mod group_event_order_tests { let shard = [event(3, RECIPIENT, 200)]; assert_eq!( - ids(&cursor.take(GroupEventSource::RealmCore, &next[2], next.clone())), + ids(&cursor.take(GroupEventSource::RealmCore, &next[2], || next.to_vec())), [14] ); assert_eq!( - ids(&cursor.take(GroupEventSource::Shard(0), &shard[0], shard.clone())), + ids(&cursor.take(GroupEventSource::Shard(0), &shard[0], || shard.to_vec())), [3] ); } diff --git a/gateway/src/world/party_mirror.rs b/gateway/src/world/party_mirror.rs index 035f703bf..b1a6104a4 100644 --- a/gateway/src/world/party_mirror.rs +++ b/gateway/src/world/party_mirror.rs @@ -33,7 +33,16 @@ impl RosterRevisionRelay { .name("roster-revision-relay".into()) .spawn(move || loop { worker.wait_for_dirty(); - worker.push_dirty(&store); + // A panic in one pass must not end the relay for the life of the process. + let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + worker.push_dirty(&store); + })); + if pass.is_err() { + log::error!( + "party: a Roster Revision Relay pass panicked; its parties wait for their \ + next revision or the reconnect pass" + ); + } })?; Ok(relay) } diff --git a/module/src/group.rs b/module/src/group.rs index 14d3affeb..ffa68d65b 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1397,7 +1397,7 @@ fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool .any(|invite| invite.inviter_guid == character_guid && invite.group_id == 0) } -/// Consume `acceptor_guid`'s pending invite and join its Group through [`join_group`]. `class` is +/// Consume `acceptor_guid`'s pending invite and join its Group through [`plan_join`]. `class` is /// the acceptor's, for the Seeker row a queued Party gains; 0 when unknown. fn accept_invite_on( ctx: &ReducerContext, @@ -1420,8 +1420,9 @@ fn accept_invite_on( }, group_id => JoinTarget::Group(group_id), }; - // A World Shard holds every Character it groups, so a missing row is a Character that is gone. - // Realm-core holds none and takes the Gateway's word. + let plan = plan_join(ctx, acceptor_guid, target)?; + // A World Shard holds every Character it groups, so a missing row is a Character that is gone + // or in transit. Realm-core holds none and takes the Gateway's word. if plane == Plane::Shard { if let JoinTarget::NewParty { leader_guid } = target { crate::helpers::character_by_guid(ctx, leader_guid) @@ -1429,7 +1430,7 @@ fn accept_invite_on( } crate::helpers::character_by_guid(ctx, acceptor_guid).ok_or(GroupRefusal::NoSuchPlayer)?; } - let joined = join_group(ctx, acceptor_guid, target)?; + let joined = plan.apply(ctx); if joined.formed { crate::meeting_stone::party_joined(ctx, joined.group_id, invite.inviter_guid, 0) .map_err(GroupOpError::Invariant)?; @@ -1438,7 +1439,7 @@ fn accept_invite_on( .map_err(GroupOpError::Invariant) } -/// Which Group [`join_group`] adds a Character to. +/// Which Group [`plan_join`] adds a Character to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum JoinTarget { /// An existing Group. @@ -1447,7 +1448,7 @@ pub(crate) enum JoinTarget { NewParty { leader_guid: u64 }, } -/// The Group [`join_group`] added a Character to. +/// The Group [`JoinPlan::apply`] added a Character to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct JoinedGroup { pub(crate) group_id: u64, @@ -1455,26 +1456,29 @@ pub(crate) struct JoinedGroup { pub(crate) formed: bool, } +/// A join that passed every check and has written nothing yet. [`JoinPlan::apply`] cannot fail, so +/// a caller may write its own rows between the two and never leave half a join behind. The plan +/// holds while nothing between the two changes group membership. +#[derive(Clone, Copy, Debug)] +pub(crate) struct JoinPlan { + joiner_guid: u64, + target: JoinTarget, + slot: RaidSlot, +} + /// The one way a Character joins a Group: an accepted invite and a Stone Add both come here. /// -/// An existing Group must exist and have room for its kind; a Raid joiner takes a free Raid Slot. -/// A new Party starts with Vanilla's defaults, group loot at Uncommon, and its leader's other pending -/// invites now join it. Every member gets the roster list. The joiner, and a new Party's leader, get -/// their Instance Removal reconciled, so joining the Group that owns the instance cancels the -/// countdown (cm:Group.cpp:880-885). -/// -/// A member row's `owner_identity` is the local Character row's identity when this database holds -/// one, else zero: Realm-core holds no Character rows, and each World Shard mirror re-derives it. -pub(crate) fn join_group( +/// The joiner must be in no Group. An existing Group must exist and have room for its kind; a Raid +/// joiner takes a free Raid Slot. A new Party's leader must be in no Group either. Writes nothing. +pub(crate) fn plan_join( ctx: &ReducerContext, joiner_guid: u64, target: JoinTarget, -) -> Result { +) -> Result { if checked_group_membership(ctx, joiner_guid)?.is_some() { return Err(GroupRefusal::AlreadyInGroup.into()); } - let members = ctx.db.game_group_member(); - let (joined, slot) = match target { + let slot = match target { JoinTarget::Group(group_id) => { let current = members_of(ctx, group_id); let Some(group) = ctx.db.game_group().group_id().find(group_id) else { @@ -1491,69 +1495,96 @@ pub(crate) fn join_group( if !has_room(kind, current.len()) { return Err(GroupRefusal::GroupFull.into()); } - let slot = match kind { + match kind { GroupKind::Party => RaidSlot::default(), GroupKind::Raid => RaidSlot::for_raid_joiner(current.iter().map(raid_slot_of)) .ok_or(GroupRefusal::GroupFull)?, - }; - let joined = JoinedGroup { - group_id, - formed: false, - }; - (joined, slot) + } } JoinTarget::NewParty { leader_guid } => { // A leader that has joined a Group since speaks for nobody. if checked_group_membership(ctx, leader_guid)?.is_some() { return Err(GroupRefusal::InviterUnavailable.into()); } - let group = ctx.db.game_group().insert(Group { - group_id: 0, - leader_guid, - loot_method: loot_method::GROUP, - loot_threshold: 2, - rr_cursor: 0, - master_looter_guid: 0, - group_type: GroupKind::Party.wire(), - }); - members.insert(GroupMember { - id: 0, - group_id: group.group_id, - character_guid: leader_guid, - owner_identity: member_identity(ctx, leader_guid), - raid_slot: RaidSlot::default().wire(), - }); - let invites = ctx.db.game_group_invite(); - let pending: Vec = invites - .iter() - .filter(|other| other.inviter_guid == leader_guid && other.group_id == 0) - .collect(); - for other in pending { - invites.id().update(GroupInvite { + RaidSlot::default() + } + }; + Ok(JoinPlan { + joiner_guid, + target, + slot, + }) +} + +impl JoinPlan { + /// Add the joiner. A new Party starts with Vanilla's defaults, group loot at Uncommon, and its + /// leader's other pending invites now join it. Every member gets the roster list. The joiner, + /// and a new Party's leader, get their Instance Removal reconciled, so joining the Group that + /// owns the instance cancels the countdown (cm:Group.cpp:880-885). + /// + /// A member row's `owner_identity` is the local Character row's identity when this database + /// holds one, else zero: Realm-core holds no Character rows, and each World Shard mirror + /// re-derives it. + pub(crate) fn apply(self, ctx: &ReducerContext) -> JoinedGroup { + let Self { + joiner_guid, + target, + slot, + } = self; + let members = ctx.db.game_group_member(); + let joined = match target { + JoinTarget::Group(group_id) => JoinedGroup { + group_id, + formed: false, + }, + JoinTarget::NewParty { leader_guid } => { + let group = ctx.db.game_group().insert(Group { + group_id: 0, + leader_guid, + loot_method: loot_method::GROUP, + loot_threshold: 2, + rr_cursor: 0, + master_looter_guid: 0, + group_type: GroupKind::Party.wire(), + }); + members.insert(GroupMember { + id: 0, group_id: group.group_id, - ..other + character_guid: leader_guid, + owner_identity: member_identity(ctx, leader_guid), + raid_slot: RaidSlot::default().wire(), }); + let invites = ctx.db.game_group_invite(); + let pending: Vec = invites + .iter() + .filter(|other| other.inviter_guid == leader_guid && other.group_id == 0) + .collect(); + for other in pending { + invites.id().update(GroupInvite { + group_id: group.group_id, + ..other + }); + } + JoinedGroup { + group_id: group.group_id, + formed: true, + } } - let joined = JoinedGroup { - group_id: group.group_id, - formed: true, - }; - (joined, RaidSlot::default()) + }; + members.insert(GroupMember { + id: 0, + group_id: joined.group_id, + character_guid: joiner_guid, + owner_identity: member_identity(ctx, joiner_guid), + raid_slot: slot.wire(), + }); + push_list_to_all(ctx, joined.group_id); + crate::instance::reconcile_instance_removal(ctx, joiner_guid); + if let JoinTarget::NewParty { leader_guid } = target { + crate::instance::reconcile_instance_removal(ctx, leader_guid); } - }; - members.insert(GroupMember { - id: 0, - group_id: joined.group_id, - character_guid: joiner_guid, - owner_identity: member_identity(ctx, joiner_guid), - raid_slot: slot.wire(), - }); - push_list_to_all(ctx, joined.group_id); - crate::instance::reconcile_instance_removal(ctx, joiner_guid); - if let JoinTarget::NewParty { leader_guid } = target { - crate::instance::reconcile_instance_removal(ctx, leader_guid); + joined } - Ok(joined) } /// A member row's `owner_identity`: this database's binding for the Character, or zero where it diff --git a/module/src/meeting_stone.rs b/module/src/meeting_stone.rs index f1cf9fff8..901e46008 100644 --- a/module/src/meeting_stone.rs +++ b/module/src/meeting_stone.rs @@ -17,7 +17,7 @@ //! Matching runs inside the transaction that changes a bucket ([`match_bucket`]), not on a tick: //! once cmangos's `bool` priority compare is read literally, its queue thread reduces to longest //! wait per Open Role, so a tick would add latency and no behavior. Every Stone Add goes through -//! the party authority's join core, [`crate::group::join_group`], the same path an accepted invite +//! the party authority's join core, [`crate::group::plan_join`], the same path an accepted invite //! takes. The group cores call back here when a queued Party's roster changes. use std::collections::BTreeSet; @@ -33,8 +33,8 @@ use lyracore_shared::meeting_stone::{ encode_queue, event_kind, queue_status, realm_op, MeetingStoneRefusal, }; -use crate::game_account_claim; -use crate::group::{Departure, JoinTarget}; +use crate::group::{Departure, JoinPlan, JoinTarget}; +use crate::{game_account_claim, game_group}; /// A queued party is reminded that the queue is still working every five minutes /// (cm:LFG/LFGMgr.cpp:55). @@ -315,7 +315,8 @@ fn join( .iter() .map(|member| (member.character_guid, class_of(member.character_guid))) .collect(); - queue_party(ctx, group.group_id, bucket, &members).map_err(StoneOpError::Invalid)?; + let next_reminder_at = first_reminder_at(ctx).map_err(StoneOpError::Invalid)?; + queue_party(ctx, group.group_id, bucket, &members, next_reminder_at); } else { upsert_seeker(ctx, actor_guid, bucket, actor.class, 0); push_queue(ctx, actor_guid, area_id, queue_status::JOINED_QUEUE); @@ -323,6 +324,13 @@ fn join( match_bucket(ctx, bucket).map_err(StoneOpError::Invalid) } +/// When a Party queued now first hears IN_PROGRESS. +fn first_reminder_at(ctx: &ReducerContext) -> Result { + ctx.timestamp + .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) + .ok_or_else(|| "reminder time overflow".to_string()) +} + /// Queue `group_id` in `bucket` with `members` as its Seekers, `(guid, class)` each, and tell each /// of them JOINED. Re-queueing a party replaces its area and wait time and drops the /// Seeker rows of Characters that are no longer members. @@ -331,11 +339,8 @@ fn queue_party( group_id: u64, bucket: Bucket, members: &[(u64, u8)], -) -> Result<(), String> { - let next_reminder_at = ctx - .timestamp - .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) - .ok_or("reminder time overflow")?; + next_reminder_at: Timestamp, +) { let row = MeetingStoneParty { group_id, area_id: bucket.area_id, @@ -365,7 +370,6 @@ fn queue_party( for &(guid, _) in members { push_queue(ctx, guid, bucket.area_id, queue_status::JOINED_QUEUE); } - Ok(()) } /// Queue `character_guid`, replacing any earlier row and its wait time. @@ -662,8 +666,9 @@ pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), S } /// The bucket's waiting solo Seekers, and its queued Parties oldest first. Reads the bucket through -/// its index. Drops a solo Seeker without a live Account Claim, and a stale solo row of a Character -/// that is in a Group. +/// its index. Drops a solo Seeker without a live Account Claim, a stale solo row of a Character +/// that is in a Group, and a party row whose Group is gone or is a Raid. What is left cannot make +/// a Stone Add fail, so a pass that runs where it cannot roll back leaves no half-done add. fn read_bucket( ctx: &ReducerContext, bucket: Bucket, @@ -689,6 +694,23 @@ fn read_bucket( let mut parties: Vec = group_ids .into_iter() .filter_map(|group_id| ctx.db.game_meeting_stone_party().group_id().find(group_id)) + .filter(|party| { + let kind = ctx + .db + .game_group() + .group_id() + .find(party.group_id) + .map(|group| crate::group::group_kind_of(&group)); + let queueable = kind == Some(GroupKind::Party); + if !queueable { + spacetimedb::log::warn!( + "meeting stone party {} left the queue: its Group is {kind:?}", + party.group_id + ); + dequeue_party_rows(ctx, party.group_id); + } + queueable + }) .collect(); parties.sort_unstable_by_key(|party| (party.queued_at, party.group_id)); (solos, parties) @@ -706,7 +728,8 @@ fn has_live_claim(ctx: &ReducerContext, character_guid: u64) -> bool { /// Give `party` Stone Adds from `solos` while it has fewer than five members and a Seeker fits an /// Open Role. Before each add the current members get MEMBER_ADDED; at five members the party is -/// complete (cm:LFG/LFGQueue.cpp:372-383). +/// complete (cm:LFG/LFGQueue.cpp:372-383). A full Party is completed, never joined, so a Stone Add +/// never meets a full Group. fn fill_party( ctx: &ReducerContext, party: &MeetingStoneParty, @@ -730,13 +753,14 @@ fn fill_party( return Ok(()); }; let joiner = solos.remove(pick); + let plan = plan_stone_add(ctx, &joiner, JoinTarget::Group(party.group_id))?; push_to_each( ctx, &members, event_kind::MEMBER_ADDED, joiner.character_guid, ); - stone_add(ctx, &joiner, JoinTarget::Group(party.group_id))?; + stone_add(ctx, &joiner, plan); upsert_seeker( ctx, joiner.character_guid, @@ -765,6 +789,14 @@ fn form_party( leader: &MeetingStoneSeeker, member: &MeetingStoneSeeker, ) -> Result<(), String> { + let plan = plan_stone_add( + ctx, + member, + JoinTarget::NewParty { + leader_guid: leader.character_guid, + }, + )?; + let next_reminder_at = first_reminder_at(ctx)?; crate::group::push_event( ctx, leader.character_guid, @@ -776,13 +808,7 @@ fn form_party( .game_meeting_stone_seeker() .character_guid() .delete(leader.character_guid); - let group_id = stone_add( - ctx, - member, - JoinTarget::NewParty { - leader_guid: leader.character_guid, - }, - )?; + let group_id = stone_add(ctx, member, plan); queue_party( ctx, group_id, @@ -791,28 +817,35 @@ fn form_party( (leader.character_guid, leader.class), (member.character_guid, member.class), ], - ) + next_reminder_at, + ); + Ok(()) } -/// Take `joiner` out of the solo queue and add it to `target` through the join core, advancing the -/// Group's Roster Revision. Returns the Group id. -fn stone_add( +/// Check that `joiner` can join `target`, before the Stone Add writes anything. +fn plan_stone_add( ctx: &ReducerContext, joiner: &MeetingStoneSeeker, target: JoinTarget, -) -> Result { - ctx.db - .game_meeting_stone_seeker() - .character_guid() - .delete(joiner.character_guid); - let joined = crate::group::join_group(ctx, joiner.character_guid, target).map_err(|error| { +) -> Result { + crate::group::plan_join(ctx, joiner.character_guid, target).map_err(|error| { format!( "stone add of {} to {target:?} failed: {error:?}", joiner.character_guid ) - })?; + }) +} + +/// Take `joiner` out of the solo queue and add it through `plan`, advancing the Group's Roster +/// Revision. Returns the Group id. +fn stone_add(ctx: &ReducerContext, joiner: &MeetingStoneSeeker, plan: JoinPlan) -> u64 { + ctx.db + .game_meeting_stone_seeker() + .character_guid() + .delete(joiner.character_guid); + let joined = plan.apply(ctx); crate::group::advance_group_revision(ctx, joined.group_id, !joined.formed, true); - Ok(joined.group_id) + joined.group_id } // ================================================================================================= @@ -873,8 +906,9 @@ pub(crate) struct PartyDeparture { /// - Left, leader changed: the leaver hears NONE and the party leaves the queue with LEAVE_QUEUE /// (cm:Groups/Group.cpp:464-476). /// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the party leaves the queue with -/// LEAVE_QUEUE. The kicked Character hears LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone with -/// its class and team (cm:Groups/Group.cpp:413-435). +/// LEAVE_QUEUE. A kicked Character with a live Account Claim hears +/// LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone with its class and team +/// (cm:Groups/Group.cpp:413-435). A kicked playerbot or offline member only leaves. /// /// Returns the bucket to match when a role opened or a Character was queued. pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Option { @@ -900,6 +934,11 @@ pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Opti ); } dequeue_party(ctx, departed.group_id, queue_status::LEAVE_QUEUE); + // Only a Character with a World Session waits alone: a kicked playerbot or offline + // member just leaves, as in cmangos (`if (player)`). + if !has_live_claim(ctx, guid) { + return None; + } push_queue( ctx, guid, diff --git a/module/tests/meeting_stone_matching.rs b/module/tests/meeting_stone_matching.rs index 4b732c7fb..e2eb1d15f 100644 --- a/module/tests/meeting_stone_matching.rs +++ b/module/tests/meeting_stone_matching.rs @@ -754,6 +754,87 @@ fn a_kicked_member_waits_alone_and_the_party_leaves_the_queue() { } } +/// A kicked member without a live Account Claim, a playerbot or an offline Character, only +/// leaves. The party still leaves the queue, and nobody waits alone. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_kicked_member_without_a_claim_does_not_wait_alone() { + let realm = start("stone-hook-kick-unclaimed"); + const LEADER: u64 = 509_6065; + const KICKED: u64 = 509_6066; + const STAYS: u64 = 509_6067; + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, KICKED, HUMAN, PRIEST); + stage(&realm, STAYS, HUMAN, MAGE); + stage_group(&realm, LEADER, &[KICKED, STAYS]); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (KICKED, PRIEST), (STAYS, MAGE)], + ); + realm.assert_sql(&format!( + "UPDATE game_account_claim SET expires_micros = 0 WHERE account_id = {KICKED}" + )); + + let kicked = heard(&realm, || group_op(&realm, UNINVITE, LEADER, KICKED, 0, 0)); + + for rest in [LEADER, STAYS] { + assert_eq!( + kicked.stone(rest), + [ + Event::Queue(0, PARTY_MEMBER_REMOVED_PARTY_REMOVED), + Event::Queue(0, LEAVE_QUEUE), + ] + ); + } + assert!(kicked.stone(KICKED).is_empty()); + assert!(queued_parties(&realm).is_empty()); + assert!(seekers(&realm).is_empty()); +} + +/// A queued party row whose Group is gone is broken state. The next pass drops it, and the JOIN +/// that runs the pass still succeeds and forms its own Party. +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn a_stale_party_row_leaves_the_queue_without_blocking_a_join() { + let realm = start("stone-match-stale-party"); + const LEADER: u64 = 509_6090; + const MEMBER: u64 = 509_6091; + let mages: Vec = (509_6092..509_6097).collect(); + stage(&realm, LEADER, HUMAN, WARRIOR); + stage(&realm, MEMBER, HUMAN, PRIEST); + stage_group(&realm, LEADER, &[MEMBER]); + join_party( + &realm, + LEADER, + DEADMINES, + &[(LEADER, WARRIOR), (MEMBER, PRIEST)], + ); + let stale = group_of(&realm, LEADER).unwrap(); + realm.assert_sql(&format!( + "DELETE FROM game_group_member WHERE group_id = {stale}" + )); + realm.assert_sql(&format!("DELETE FROM game_group WHERE group_id = {stale}")); + assert_eq!(queued_parties(&realm), [stale]); + + stage(&realm, mages[0], HUMAN, MAGE); + join_alone(&realm, mages[0], MAGE, DEADMINES); + assert!(queued_parties(&realm).is_empty()); + assert_eq!( + seekers(&realm), + BTreeMap::from([(mages[0], alone(DEADMINES, ALLIANCE, MAGE))]) + ); + + for &guid in &mages[1..] { + stage(&realm, guid, HUMAN, MAGE); + join_alone(&realm, guid, MAGE, DEADMINES); + } + let group = group_of(&realm, mages[0]).expect("five mages form a party"); + assert_eq!(members(&realm, group), mages[..3]); + assert_eq!(queued_parties(&realm), [group]); +} + /// Criterion 7, disband by a leave or a kick from two members: both hear NONE once, and nobody /// waits on alone. #[test] From 67e72212993c9484b59c193c717cd10ed2f21bf7 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 07:53:47 +0200 Subject: [PATCH 13/16] test(meeting-stones): name a timed-out wait in the stone event assertion Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/stdb/subscriptions_meeting_stone_durable_tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs index 26a886be7..2862d6f26 100644 --- a/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs +++ b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs @@ -393,9 +393,8 @@ fn seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror() { .1; assert_eq!( &session.heard, want, - "Seeker {} heard its stone events out of order or incomplete", - session.guid + "Seeker {} heard its stone events out of order or incomplete (wait timed out: {})", + session.guid, !complete ); } - assert!(complete); } From 4bf6bc29d78eccda43e4dd6afb0438d6ac50998f Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 08:03:17 +0200 Subject: [PATCH 14/16] refactor(meeting-stone): trim comments and split matching Move the pure role and matching code into meeting_stone/matching.rs with its unit tests, unchanged. The group read the JOIN and LEAVE ops need is now group::group_with_members. The Gateway's party-wide fact read is facts_for_join, so it no longer shares a name with the Store method. Comments on new code keep only the constraint they explain and one source pointer per concept. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/schema.md | 4 +- gateway/src/codec/meeting_stone.rs | 9 +- gateway/src/stdb/reads/meeting_stone.rs | 9 +- gateway/src/world/handlers/meeting_stone.rs | 63 +-- gateway/src/world/party_mirror.rs | 33 +- importer/src/main.rs | 12 +- module/src/group.rs | 13 + module/src/meeting_stone/matching.rs | 286 ++++++++++ .../mod.rs} | 492 +++--------------- 10 files changed, 424 insertions(+), 499 deletions(-) create mode 100644 module/src/meeting_stone/matching.rs rename module/src/{meeting_stone.rs => meeting_stone/mod.rs} (56%) diff --git a/docs/architecture.md b/docs/architecture.md index 2a80d1776..29409318e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ are answered by the gateway, which is the only component that can see the whole | `CMSG_NAME_QUERY` resolution | same | `presence.rs` (`character_anywhere`) | | friend/ignore add target resolution by name, realm-wide, plus the friend's race for the Enemy Gate. This is the existence check the Module no longer performs, since Realm-core holds no Character rows for it to check | same | `presence.rs` (`resolve_by_name`, `of`), `gateway/src/world/social.rs` (`resolve_add_contact`) | | Speaker Facts: the speaker's race and chat tag for every Realm Chat Line and Chat Channel op | Realm-core holds no Characters and no live entities | `gateway/src/world/handlers/chat.rs` (`speaker_facts`), `stdb/reads/chat.rs` | -| Seeker facts: the race and class of the Character that uses a Meeting Stone, or of every member of the Party it leads, and the class of a Character that accepts an invite into a queued Party. Also whether the client can name the stone's dungeon area, checked before any state changes | Realm-core holds no Characters, and only the Gateway knows which areas the client protocol encodes | `gateway/src/world/handlers/meeting_stone.rs` (`seeker_facts`, the `Area` check in `join`), `gateway/src/world/party.rs` (`AcceptorFacts`) | +| Seeker facts: the race and class of the Character that uses a Meeting Stone, or of every member of the Party it leads, and the class of a Character that accepts an invite into a queued Party. Also whether the client can name the stone's dungeon area, checked before any state changes | Realm-core holds no Characters, and only the Gateway knows which areas the client protocol encodes | `gateway/src/world/handlers/meeting_stone.rs` (`facts_for_join`, the `Area` check in `join`), `gateway/src/world/party.rs` (`AcceptorFacts`) | | channel moderation target names, realm-wide, plus whether the target ignores the actor for an invite | same | `gateway/src/world/handlers/channel.rs` (`resolve_online_character`, `ignores`) | | the per-listener ignore filter on an ignorable Realm Chat Line | the ignore list is on each listener's Home Shard; the line is on Realm-core | `gateway/src/stdb/world_view.rs` (`realm_chat_appeared`, `Viewer::ignores`) | | loot-roll promotion and settlement fan-out across shards | a kill's transaction cannot reach realm-core | `gateway/src/world/loot.rs` | diff --git a/docs/schema.md b/docs/schema.md index e2b99debc..f1169fd6d 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -81,7 +81,7 @@ grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ': | GameObject | 10 | 6 | `gameobject.rs`, `go_model.rs`, `go_collider.rs` | | Loot | 13 | 6 | `loot/*` | | Group / party | 8 | 5 | `group.rs` | -| Meeting stones | 4 | 0 | `meeting_stone.rs` | +| Meeting stones | 4 | 0 | `meeting_stone/mod.rs` | | Guild | 9 | 0 | `guild/mod.rs`, `guild/fee.rs`, `guild/membership.rs`, `guild/petition.rs` | | Instance / encounter | 8 | 1 | `instance.rs`, `encounter.rs` | | Sharding: region, transfer, load | 9 | 0 | `region.rs`, `transfer/mod.rs`, `load.rs` | @@ -609,7 +609,7 @@ grep -rn 'scheduled(' module/src --include='*.rs' # 27 tables plus 4 comment l | `game_event_reaper_schedule` | `reap_movement_events` | 1 s (`EVENT_TTL_MICROS`) | `gc.rs:24` | | `game_transfer_reaper_schedule` | `reap_transfers` | 5 s, armed lazily by `begin_transfer` | `transfer/mod.rs:250` | | `game_mail_escrow_reaper_schedule` | `reap_mail_escrows` | 5 s, armed lazily | `mail_escrow.rs:68` | -| `game_meeting_stone_reminder_schedule` | `remind_queued_parties` | 5 s; a queued Party hears `IN_PROGRESS` every 5 min | `meeting_stone.rs:94` | +| `game_meeting_stone_reminder_schedule` | `remind_queued_parties` | 5 s; a queued Party hears `IN_PROGRESS` every 5 min | `meeting_stone/mod.rs:77` | | `game_pet_care_schedule` | `tick_pet_care` | 7.5 s | `creatures/pet_care.rs:18` | | `game_gateway_lease_reaper_schedule` | `reap_gateway_leases` | 15 s | `gw.rs:71` | | `game_instance_reaper_schedule` | `reap_instances` | 60 s | `instance.rs:312` | diff --git a/gateway/src/codec/meeting_stone.rs b/gateway/src/codec/meeting_stone.rs index 6e9cdf927..f53541d44 100644 --- a/gateway/src/codec/meeting_stone.rs +++ b/gateway/src/codec/meeting_stone.rs @@ -7,8 +7,7 @@ use wow_world_messages::vanilla::{ }; use wow_world_messages::Guid; -/// `u32 area, u8 status` (cm:LFG/LFGHandler.cpp:146-152). `None` when `wow_world_messages` has no -/// `Area` for the id or the status byte is unknown, since neither could be encoded. +/// `None` when `wow_world_messages` has no `Area` for the id or knows no such status. pub fn build_meetingstone_setqueue(area_id: u32, status: u8) -> Option { Some(SMSG_MEETINGSTONE_SETQUEUE { area: Area::try_from(area_id).ok()?, @@ -23,15 +22,15 @@ pub fn build_meetingstone_joinfailed(reason: u8) -> Option SMSG_MEETINGSTONE_MEMBER_ADDED { SMSG_MEETINGSTONE_MEMBER_ADDED { guid: Guid::new(guid), } } -/// The answer to `MSG_LOOKING_FOR_GROUP`. vmangos sends 0 (vm:Handlers/MiscHandler.cpp:277-282); -/// the stock UI never reads it. +/// The answer to `MSG_LOOKING_FOR_GROUP`. vmangos sends 0 (vm:Handlers/MiscHandler.cpp:277-282), +/// and the stock UI never reads it. pub fn build_looking_for_group() -> MSG_LOOKING_FOR_GROUP_Server { MSG_LOOKING_FOR_GROUP_Server { unknown1: 0 } } diff --git a/gateway/src/stdb/reads/meeting_stone.rs b/gateway/src/stdb/reads/meeting_stone.rs index 6a0c5c442..be03afaac 100644 --- a/gateway/src/stdb/reads/meeting_stone.rs +++ b/gateway/src/stdb/reads/meeting_stone.rs @@ -1,5 +1,4 @@ -//! Meeting Stone reads. A stone row is static content on every database; a Seeker row lives on -//! the party authority. Both tables are keyed by primary key, so each read is one unique find. +//! Meeting Stone reads: stone rows on the Home Shard, Seeker rows on the party authority. use anyhow::Result; use lyracore_shared::constants::go_type; @@ -8,8 +7,7 @@ use super::super::connection::Coordinator; use super::*; impl Coordinator { - /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database. `None` when no - /// such GameObject is cached here, or it is not a stone with an imported stone row. + /// The dungeon area of the Meeting Stone spawned as `go_guid` on this database, if any. pub(crate) fn meeting_stone_area(&self, go_guid: u64) -> Result> { let guard = self.0.coord(); let db = &guard.conn.db; @@ -27,8 +25,7 @@ impl Coordinator { .map(|stone| stone.area_id)) } - /// The area `character_guid` waits for in the Meeting Stone Queue, read from the party - /// authority: Realm-core, or the one database of an unsharded Realm. + /// The area `character_guid` is queued for, read from the party authority. pub(crate) fn queued_area(&self, character_guid: u64) -> Result> { let realm = self.realm_core()?; let guard = realm.0.coord(); diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index c82d2110e..8fe892b67 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -1,10 +1,8 @@ -//! Meeting Stone dispatcher: join, leave and the queue status query, plus the vanilla -//! `MSG_LOOKING_FOR_GROUP` answer. The client sends `CMSG_MEETINGSTONE_JOIN` when it uses a stone. +//! Meeting Stone dispatcher: join, leave, the queue status query and `MSG_LOOKING_FOR_GROUP`. //! -//! A JOIN takes two Durable Requests. The actor's Home Shard admits it at the stone, then the party -//! authority queues it. Realm-core holds no Characters, so this file reads each Seeker's race and -//! class realm-wide and conveys them. Queue notices return on the group event relay -//! (`stdb::subscriptions::group_event_outbound`); only a refused party JOIN is answered here. +//! A JOIN is two Durable Requests: the Home Shard admits the actor at the stone, then the party +//! authority queues it with the Seekers' race and class, read realm-wide. Queue notices arrive on +//! the group event relay; only a refused party JOIN is answered here. use super::super::*; use super::chat::is_transport_failure; @@ -14,7 +12,6 @@ use lyracore_shared::meeting_stone::{ }; use wow_world_messages::vanilla::Area; -/// One Seeker's facts as the party authority needs them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct SeekerFacts { pub(crate) character_guid: u64, @@ -22,8 +19,7 @@ pub(crate) struct SeekerFacts { pub(crate) class: u8, } -/// How the Module answered one Meeting Stone Durable Request. A timeout, transport or SDK failure -/// stays an `Err` with an unknown durable outcome. +/// A timeout, transport or SDK failure stays an `Err` with an unknown outcome. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MeetingStoneOutcome { Ran, @@ -31,16 +27,15 @@ pub(crate) enum MeetingStoneOutcome { } pub(crate) trait MeetingStoneActionStore { - /// Durable Request on the actor's Home Shard: may it use the stone `go_guid`? Writes nothing. + /// Durable Request on the actor's Home Shard. Writes nothing. fn admit_meeting_stone(&self, actor_guid: u64, go_guid: u64) -> Result; - /// Durable Read of the Home Shard cache: the dungeon area of the stone spawned as `go_guid`. + /// Durable Read of the Home Shard cache. fn meeting_stone_area(&self, go_guid: u64) -> Result>; - /// Durable Read of the party authority: the members of `actor_guid`'s party in join order, or - /// `None` outside a party. + /// Durable Read of the party authority, in join order. `None` outside a party. fn party_members(&self, actor_guid: u64) -> Result>>; - /// Race and class of `character_guid` from whichever World Shard holds it. + /// One Seeker's race and class, from whichever World Shard holds it. fn seeker_facts(&self, character_guid: u64) -> Result>; - /// Durable Request on the party authority. The Coordinator picks the database. + /// Durable Request on the party authority. fn meeting_stone_op( &self, actor_guid: u64, @@ -48,7 +43,7 @@ pub(crate) trait MeetingStoneActionStore { area_id: u32, seekers: Vec, ) -> Result; - /// Durable Read of the party authority: the area `character_guid` is queued for. + /// Durable Read of the party authority. fn queued_area(&self, character_guid: u64) -> Result>; } @@ -106,8 +101,8 @@ pub(crate) enum MeetingStoneActionOutcome { PassThrough(ClientOpcodeMessage), } -/// Consume the three meeting stone opcodes and `MSG_LOOKING_FOR_GROUP`, and pass everything else -/// on. All four are dropped silently outside the world. +/// Consume the meeting stone opcodes and `MSG_LOOKING_FOR_GROUP`, silently outside the world, and +/// pass everything else on. pub(crate) fn dispatch_meeting_stone_action( store: &St, player: MeetingStonePlayer, @@ -153,9 +148,8 @@ enum Action { LookingForGroup, } -/// A Store call that ends the World Session only on transport loss. Anything else is logged at -/// debug and answered with nothing: the client has no message for it, and a Refusal or a peer -/// Shard read this Gateway cannot finish must not close the session. +/// Only a transport loss ends the World Session. Anything else is logged and answered with nothing, +/// because the client has no message for it. fn recoverable(what: &str, account_id: u64, result: Result) -> Result> { match result { Ok(value) => Ok(Some(value)), @@ -167,8 +161,8 @@ fn recoverable(what: &str, account_id: u64, result: Result) -> Result( store: &St, account_id: u64, @@ -198,8 +192,7 @@ fn join( else { return Ok(Vec::new()); }; - // Only the Gateway can tell whether the client can be told about this area, so it drops a - // stone it could never answer for before any state changes. + // Only the Gateway knows which areas the client protocol can name. if Area::try_from(area_id).is_err() { log::debug!("world: meeting stone {go_guid} names unknown area {area_id}"); return Ok(Vec::new()); @@ -207,7 +200,7 @@ fn join( let Some(seekers) = recoverable( "seeker facts read", account_id, - seeker_facts(store, actor_guid), + facts_for_join(store, actor_guid), )? else { return Ok(Vec::new()); @@ -231,9 +224,9 @@ fn join( .collect()) } -/// The facts of the actor, or of every member of the party it is in. Only a Raid holds more than -/// a Party's five, and the queue refuses a Raid, so the actor's own facts are enough to hear that. -fn seeker_facts( +/// The Seeker facts a JOIN conveys: the actor's, or every member's of its Party. The queue refuses +/// a Raid, so a Raid's actor conveys only its own. +fn facts_for_join( store: &St, actor_guid: u64, ) -> Result> { @@ -248,7 +241,7 @@ fn seeker_facts( Ok(facts) } -/// One queue op. `None` when it failed short of a transport loss. +/// `None` when the op failed short of a transport loss. fn run_op( store: &St, account_id: u64, @@ -271,10 +264,8 @@ fn run_op( Ok(outcome) } -/// `CMSG_MEETINGSTONE_INFO`: the queue status the client shows after a loading screen. A Seeker -/// row answers JOINED for its area, alone or in a party; anything else answers NONE. Both cores -/// read a restore map nothing fills, so they never report a solo Seeker; this reports the real -/// state. +/// `CMSG_MEETINGSTONE_INFO`, sent after a loading screen: JOINED for a Seeker's area, else NONE. +/// Both cores read a restore map nothing fills; this reports the real state. fn info( store: &St, account_id: u64, @@ -491,7 +482,7 @@ mod tests { ); } - /// Each party Refusal answers with cmangos's byte (cm:LFG/LFGHandler.cpp:58-77). + /// Each party Refusal answers with cmangos's failure byte. #[test] fn each_party_refusal_answers_its_join_failure_byte() { for (refusal, reason) in [ @@ -602,7 +593,7 @@ mod tests { ); } - /// vmangos answers 0 (vm:Handlers/MiscHandler.cpp:277-282). + /// vmangos answers 0. #[test] fn looking_for_group_answers_zero() { let store = FakeMeetingStones::default(); diff --git a/gateway/src/world/party_mirror.rs b/gateway/src/world/party_mirror.rs index b1a6104a4..d3603f88c 100644 --- a/gateway/src/world/party_mirror.rs +++ b/gateway/src/world/party_mirror.rs @@ -1,22 +1,16 @@ -//! The Roster Revision Relay: whatever changes a party on Realm-core, push that party's roster to -//! each World Shard whose mirror holds an older Roster Revision or none. +//! The Roster Revision Relay: push a party's Realm-core roster to each World Shard whose mirror +//! holds an older Roster Revision or none, whatever changed it. `party::run` pushes only the +//! actor's parties, and a Stone Add changes parties the actor is not in. //! -//! `party::run` pushes only the acting Character's before and after parties. Realm-core can also -//! change parties no actor of the op belongs to, so the mirror follows the revision instead. The -//! Realm-core row callbacks only mark a party dirty; one worker thread per Gateway process drains -//! the dirty set, so several revisions of one party before the worker runs cost one push, and no -//! reducer call runs on an SDK callback thread. -//! -//! A push that still fails after [`party::sync_group_mirrors_required`]'s attempts is logged and -//! dropped. The party waits for its next revision, or for the reconciliation pass a Realm-core -//! reconnect starts. +//! Row callbacks only mark a party dirty, and one worker thread drains the set, so no reducer call +//! runs on an SDK callback thread. A push that fails every attempt is logged and dropped; the party +//! waits for its next revision or the reconnect pass. use super::party; use super::WorldStore; use std::collections::BTreeSet; use std::sync::{Arc, Condvar, Mutex, PoisonError}; -/// The dirty party ids and the worker that pushes them. #[derive(Default)] pub(crate) struct RosterRevisionRelay { dirty: Mutex>, @@ -24,8 +18,7 @@ pub(crate) struct RosterRevisionRelay { } impl RosterRevisionRelay { - /// Start the worker thread. `store` is any Coordinator handle: it names Realm-core and every - /// World Shard. + /// Start the worker thread. `store` is any Coordinator handle. pub(crate) fn spawn(store: St) -> std::io::Result> { let relay = Arc::new(Self::default()); let worker = relay.clone(); @@ -47,8 +40,7 @@ impl RosterRevisionRelay { Ok(relay) } - /// Note that Realm-core moved this party's Roster Revision. Safe on an SDK callback thread: - /// it takes one short lock and calls nothing. + /// Safe on an SDK callback thread: one short lock, no calls. pub(crate) fn mark_dirty(&self, group_id: u64) { self.dirty .lock() @@ -67,8 +59,8 @@ impl RosterRevisionRelay { } } - /// Take every dirty party and push it to the World Shards whose mirror is stale. A party marked - /// again while this runs stays for the next pass. + /// Push every dirty party to the World Shards whose mirror is stale. A party marked during the + /// pass waits for the next one. pub(crate) fn push_dirty(&self, store: &St) { let parties = std::mem::take(&mut *self.dirty.lock().unwrap_or_else(PoisonError::into_inner)); @@ -133,9 +125,8 @@ fn push_party( } } -/// A World Shard mirror needs Realm-core's roster when it holds no row for the party or an older -/// Roster Revision. A newer one means this Gateway's Realm-core cache lags; pushing it would be -/// refused as older anyway. +/// A newer mirrored revision means this Gateway's Realm-core cache lags, and the shard would refuse +/// the older push anyway. fn mirror_is_stale(realm_revision: u64, mirrored: Option) -> bool { mirrored.is_none_or(|mirrored| mirrored < realm_revision) } diff --git a/importer/src/main.rs b/importer/src/main.rs index 2e9424941..30b89c705 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -890,7 +890,7 @@ pub(crate) mod got { // raw dump columns. pub const DATA0: usize = 8; pub const DATA1: usize = 9; - /// MEETINGSTONE: the dungeon's AreaTable id (cm:Entities/GameObject.h:266-272). + /// MEETINGSTONE: the dungeon's AreaTable id. pub const DATA2: usize = 10; pub const DATA3: usize = 11; pub const DATA5: usize = 13; @@ -4271,7 +4271,7 @@ struct GoMeta { data1: u32, trap_spell_id: u32, trap_cooldown_secs: u32, - /// MEETINGSTONE only: the dump's `data2`, the dungeon area. 0 for every other type. + /// 0 for every type but MEETINGSTONE. meeting_stone_area_id: u32, size: f32, } @@ -5062,8 +5062,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject struct GameobjectTemplateRows { templates: Vec, traps: Vec, - /// One `game_meeting_stone` row per spawned MEETINGSTONE template. The template row keeps its - /// inert shape, so this is the only copy of the stone's level range and dungeon area. + /// The only copy of a spawned stone's level range and dungeon area. meeting_stones: Vec, chest_loot_ids: Vec, } @@ -5145,7 +5144,6 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows continue; } if type_id == GO_MEETINGSTONE { - // The client sends CMSG_MEETINGSTONE_JOIN, never CMSG_GAMEOBJ_USE, for a stone. eprintln!( " type {type_id:>2} MEETINGSTONE {count} (meeting stone flow, not use-dispatched)" ); @@ -6251,8 +6249,7 @@ mod tests { assert_eq!(go_initial_state(GO_QUESTGIVER, 1), 0); } - /// Only a spawned stone gets a row, and the row is the dump's `data0`, `data1`, `data2`. The - /// Deadmines stone (Westfall) is level 15 to 20 for area 1581. + /// The Deadmines stone is level 15 to 20 for area 1581. #[test] fn gameobject_family_emits_one_meeting_stone_row_per_spawned_stone() { let dump = "INSERT INTO `gameobject_template` VALUES \ @@ -6281,7 +6278,6 @@ mod tests { statement.starts_with("INSERT INTO game_gameobject_template") && statement.contains("(179584,23,6973,'Meeting Stone',0,0,0,0,0,0,1)") })); - // One template row and one stone row. assert_eq!(plan.stamps, vec![("gameobjects", 2)]); } diff --git a/module/src/group.rs b/module/src/group.rs index 6e41e3f7e..a8806d76d 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1109,6 +1109,19 @@ pub(crate) fn checked_group_membership( Ok(Some((member, group))) } +/// `character_guid`'s Group and its members, through [`checked_group_membership`]. +pub(crate) fn group_with_members( + ctx: &ReducerContext, + character_guid: u64, +) -> Result)>, GroupOpError> { + Ok( + checked_group_membership(ctx, character_guid)?.map(|(_, group)| { + let members = members_of(ctx, group.group_id); + (group, members) + }), + ) +} + /// The leader-authorization sequence of the ops only the leader may run (loot rules, convert, set /// leader, set Assistant): resolve `guid`'s group membership, its `Group` row, and confirm `guid` /// actually IS that group's leader. [`GroupRefusal::NotInGroup`] if `guid` has no group at all; diff --git a/module/src/meeting_stone/matching.rs b/module/src/meeting_stone/matching.rs new file mode 100644 index 000000000..5de433a8f --- /dev/null +++ b/module/src/meeting_stone/matching.rs @@ -0,0 +1,286 @@ +//! Pure Meeting Stone matching: class roles, a Party's Open Roles, the next Stone Add and Party +//! formation. The role table and fill order are cmangos's (cm:LFG/LFGMgr.cpp:91-158). + +use spacetimedb::Timestamp; + +use lyracore_shared::group::GROUP_MAX_MEMBERS; + +use super::MeetingStoneSeeker; + +/// 1.12 class ids (ChrClasses.dbc). +mod class { + pub const WARRIOR: u8 = 1; + pub const PALADIN: u8 = 2; + pub const HUNTER: u8 = 3; + pub const ROGUE: u8 = 4; + pub const PRIEST: u8 = 5; + pub const SHAMAN: u8 = 7; + pub const MAGE: u8 = 8; + pub const WARLOCK: u8 = 9; + pub const DRUID: u8 = 11; +} + +/// A Party's dungeon roles, in the order a Party fills them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Role { + Tank, + Healer, + Damage, +} + +const ROLE_ORDER: [Role; 3] = [Role::Tank, Role::Healer, Role::Damage]; + +const DAMAGE_ROLES: u8 = 3; + +/// How well `class` fills `role`: 0 not at all, then 1 low, 2 normal, 3 high. An unknown class +/// fills nothing. +fn role_priority(class: u8, role: Role) -> u8 { + use class::*; + const LOW: u8 = 1; + const NORMAL: u8 = 2; + const HIGH: u8 = 3; + match (role, class) { + (Role::Tank, WARRIOR) => HIGH, + (Role::Tank, DRUID | PALADIN) => NORMAL, + (Role::Healer, DRUID | PALADIN | PRIEST | SHAMAN) => HIGH, + (Role::Damage, HUNTER | MAGE | ROGUE | WARLOCK) => HIGH, + (Role::Damage, DRUID | PALADIN | SHAMAN | WARRIOR) => NORMAL, + (Role::Damage, PRIEST) => LOW, + _ => 0, + } +} + +fn fills(class: u8, role: Role) -> bool { + role_priority(class, role) > 0 +} + +/// Which of a Party's one tank, one healer and three damage roles no member fills yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct OpenRoles { + tank: bool, + healer: bool, + damage: u8, +} + +impl OpenRoles { + const ALL: Self = Self { + tank: true, + healer: true, + damage: DAMAGE_ROLES, + }; + + fn is_open(self, role: Role) -> bool { + match role { + Role::Tank => self.tank, + Role::Healer => self.healer, + Role::Damage => self.damage > 0, + } + } + + fn fill(&mut self, role: Role) { + match role { + Role::Tank => self.tank = false, + Role::Healer => self.healer = false, + Role::Damage => self.damage = self.damage.saturating_sub(1), + } + } +} + +/// The Open Roles of a Party whose members have `classes`, in join order. Each member takes the +/// first open role its class fills, unless a member not yet placed has a higher priority for it +/// (cm:Groups/Group.cpp:1589-1685). A member left without a role holds a seat. +pub(super) fn open_roles(classes: &[u8]) -> OpenRoles { + let mut open = OpenRoles::ALL; + let mut placed = vec![false; classes.len()]; + for (seat, &class) in classes.iter().enumerate() { + let outranked = |role: Role| { + classes.iter().enumerate().any(|(other, &other_class)| { + other != seat + && !placed[other] + && role_priority(other_class, role) > role_priority(class, role) + }) + }; + let taken = ROLE_ORDER + .into_iter() + .find(|&role| fills(class, role) && open.is_open(role) && !outranked(role)); + if let Some(role) = taken { + open.fill(role); + placed[seat] = true; + } + } + open +} + +/// The longest wait first, ties by guid. +fn wait_order(seeker: &MeetingStoneSeeker) -> (Timestamp, u64) { + (seeker.queued_at, seeker.character_guid) +} + +/// The index in `solos` of the next Stone Add: for the first open role in fill order, the +/// longest-waiting Seeker whose class fills it. cmangos compares class priority as a `bool` +/// (cm:LFG/LFGQueue.cpp:300,316), so the wait decides. +pub(super) fn next_stone_add(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { + ROLE_ORDER + .into_iter() + .filter(|&role| open.is_open(role)) + .find_map(|role| { + solos + .iter() + .enumerate() + .filter(|(_, seeker)| fills(seeker.class, role)) + .min_by_key(|(_, seeker)| wait_order(seeker)) + .map(|(index, _)| index) + }) +} + +/// With five solo Seekers in one bucket, the longest wait leads a new Party and the next joins it. +/// Both cores count Seekers across every area and block on the first one's; one bucket does not. +pub(super) fn formation( + solos: &[MeetingStoneSeeker], +) -> Option<(&MeetingStoneSeeker, &MeetingStoneSeeker)> { + if solos.len() < GROUP_MAX_MEMBERS { + return None; + } + let mut waiting: Vec<&MeetingStoneSeeker> = solos.iter().collect(); + waiting.sort_unstable_by_key(|seeker| wait_order(seeker)); + Some((waiting[0], waiting[1])) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The cmangos class table, row by row: the roles each class fills and its priority in each. + #[test] + fn the_role_table_is_the_cmangos_class_table() { + use class::*; + let row = |class: u8| ROLE_ORDER.map(|role| role_priority(class, role)); + assert_eq!(row(WARRIOR), [3, 0, 2]); + assert_eq!(row(PALADIN), [2, 3, 2]); + assert_eq!(row(HUNTER), [0, 0, 3]); + assert_eq!(row(ROGUE), [0, 0, 3]); + assert_eq!(row(PRIEST), [0, 3, 1]); + assert_eq!(row(SHAMAN), [0, 3, 2]); + assert_eq!(row(MAGE), [0, 0, 3]); + assert_eq!(row(WARLOCK), [0, 0, 3]); + assert_eq!(row(DRUID), [2, 3, 2]); + assert_eq!(row(0), [0, 0, 0], "an unknown class fills nothing"); + assert_eq!(row(6), [0, 0, 0], "class 6 does not exist in 1.12"); + } + + fn open(tank: bool, healer: bool, damage: u8) -> OpenRoles { + OpenRoles { + tank, + healer, + damage, + } + } + + /// The paladin outranks nobody for tank while the warrior waits, so it heals. + #[test] + fn a_paladin_then_a_warrior_leave_three_damage_roles_open() { + use class::*; + assert_eq!(open_roles(&[PALADIN, WARRIOR]), open(false, false, 3)); + } + + /// Equal priority does not outrank, so the first warrior tanks and the second deals damage. + #[test] + fn two_warriors_leave_the_healer_and_two_damage_roles_open() { + use class::*; + assert_eq!(open_roles(&[WARRIOR, WARRIOR]), open(false, true, 2)); + } + + #[test] + fn a_full_party_has_no_open_role() { + use class::*; + assert_eq!( + open_roles(&[WARRIOR, PRIEST, MAGE, ROGUE, HUNTER]), + open(false, false, 0) + ); + } + + #[test] + fn a_member_of_unknown_class_holds_a_seat_and_no_role() { + use class::*; + assert_eq!(open_roles(&[]), OpenRoles::ALL); + assert_eq!(open_roles(&[0, MAGE]), open(true, true, 2)); + } + + /// A fourth damage dealer finds every damage role taken and fills nothing. + #[test] + fn a_member_whose_roles_are_taken_fills_nothing() { + use class::*; + assert_eq!(open_roles(&[MAGE, MAGE, MAGE, MAGE]), open(true, true, 0)); + } + + fn seeker(character_guid: u64, class: u8, waited_secs: i64) -> MeetingStoneSeeker { + MeetingStoneSeeker { + character_guid, + area_id: 1581, + team: 469, + class, + group_id: 0, + queued_at: Timestamp::from_micros_since_unix_epoch( + 1_000_000_000_000 - waited_secs * 1_000_000, + ), + } + } + + fn picked(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { + next_stone_add(open, solos).map(|index| solos[index].character_guid) + } + + #[test] + fn the_next_stone_add_takes_tank_then_healer_then_damage() { + use class::*; + let solos = [ + seeker(1, MAGE, 90), + seeker(2, PRIEST, 10), + seeker(3, WARRIOR, 5), + ]; + assert_eq!(picked(OpenRoles::ALL, &solos), Some(3)); + assert_eq!(picked(open(false, true, 3), &solos), Some(2)); + assert_eq!(picked(open(false, false, 3), &solos), Some(1)); + assert_eq!(picked(open(false, false, 0), &solos), None); + } + + #[test] + fn the_longest_wait_takes_a_role_and_the_lower_guid_breaks_a_tie() { + use class::*; + let solos = [ + seeker(7, PRIEST, 30), + seeker(5, DRUID, 60), + seeker(4, SHAMAN, 60), + ]; + assert_eq!(picked(open(false, true, 0), &solos), Some(4)); + let solos = [ + seeker(7, PRIEST, 61), + seeker(5, DRUID, 60), + seeker(4, SHAMAN, 60), + ]; + assert_eq!(picked(open(false, true, 0), &solos), Some(7)); + } + + #[test] + fn a_seeker_of_unknown_class_is_never_a_stone_add() { + assert_eq!(picked(OpenRoles::ALL, &[seeker(1, 0, 600)]), None); + } + + #[test] + fn five_solo_seekers_form_a_party_led_by_the_longest_wait() { + use class::*; + let waiting = || { + vec![ + seeker(1, MAGE, 10), + seeker(2, MAGE, 50), + seeker(3, MAGE, 40), + seeker(4, MAGE, 40), + ] + }; + assert!(formation(&waiting()).is_none()); + let mut five = waiting(); + five.push(seeker(5, MAGE, 5)); + let (leader, member) = formation(&five).expect("five Seekers form a party"); + assert_eq!((leader.character_guid, member.character_guid), (2, 3)); + } +} diff --git a/module/src/meeting_stone.rs b/module/src/meeting_stone/mod.rs similarity index 56% rename from module/src/meeting_stone.rs rename to module/src/meeting_stone/mod.rs index 901e46008..b921c0d38 100644 --- a/module/src/meeting_stone.rs +++ b/module/src/meeting_stone/mod.rs @@ -1,24 +1,11 @@ -//! The Meeting Stone Queue: a Character, or the Party it leads, waits at a dungeon's Meeting Stone -//! for other Seekers of the same dungeon area and team. +//! The Meeting Stone Queue. [`gw_admit_meeting_stone`] admits a Character at the stone on its Home +//! Shard and writes nothing. [`realm_meeting_stone_op`] queues it on Realm-core, where party +//! membership is authoritative, so Seekers on every World Shard share one queue. Realm-core holds +//! no Character rows, so the Gateway conveys race and class as [`SeekerFacts`]. //! -//! Two Durable Requests split the work. [`gw_admit_meeting_stone`] runs on the Character's Home -//! Shard, the only database that holds the stone and the live entity, and writes nothing. -//! [`realm_meeting_stone_op`] runs on Realm-core, where party membership is authoritative, so -//! Seekers on different World Shards wait in one queue. Realm-core holds no Character rows, so the -//! Gateway conveys each Seeker's race and class as [`SeekerFacts`]. -//! -//! Notifications are `game_group_event` rows of the -//! [`lyracore_shared::meeting_stone::event_kind`] kinds. A Refusal rolls its transaction back and -//! returns its stable tag; the Gateway answers the party Refusals with `SMSG_MEETINGSTONE_JOINFAILED`. -//! -//! A solo Seeker lasts as long as the Account Claim of its World Session: [`claim_ended`] runs -//! where a claim is released, replaced or reaped. A party Seeker lasts as long as its membership. -//! -//! Matching runs inside the transaction that changes a bucket ([`match_bucket`]), not on a tick: -//! once cmangos's `bool` priority compare is read literally, its queue thread reduces to longest -//! wait per Open Role, so a tick would add latency and no behavior. Every Stone Add goes through -//! the party authority's join core, [`crate::group::plan_join`], the same path an accepted invite -//! takes. The group cores call back here when a queued Party's roster changes. +//! Matching runs in the transaction that changes a bucket ([`match_bucket`]): the cores' queue +//! thread reduces to longest wait per Open Role, so a tick would only add latency. A solo Seeker +//! lasts as long as its Account Claim ([`claim_ended`]); a party Seeker as long as its membership. use std::collections::BTreeSet; @@ -33,19 +20,20 @@ use lyracore_shared::meeting_stone::{ encode_queue, event_kind, queue_status, realm_op, MeetingStoneRefusal, }; +mod matching; + +use matching::{formation, next_stone_add, open_roles}; + use crate::group::{Departure, JoinPlan, JoinTarget}; use crate::{game_account_claim, game_group}; -/// A queued party is reminded that the queue is still working every five minutes -/// (cm:LFG/LFGMgr.cpp:55). +/// A queued Party hears IN_PROGRESS this often (cm:LFG/LFGMgr.cpp:55). const REMINDER_INTERVAL_MICROS: i64 = 5 * 60 * 1_000_000; -/// How often [`remind_queued_parties`] looks for a due reminder. const REMINDER_TICK_MICROS: i64 = 5_000_000; -/// One Meeting Stone template's level range and dungeon area, from the dump's `data0`, `data1` and -/// `data2`. Private: the Gateway reads it with the owner token. Imported with the `gameobjects` -/// family; a Package does not author it. [static] +/// One Meeting Stone template's level range and dungeon area, imported from `data0` to `data2`. +/// Packages do not author it. [static] #[table(accessor = game_meeting_stone)] pub struct MeetingStone { #[primary_key] @@ -53,12 +41,10 @@ pub struct MeetingStone { pub min_level: u32, /// 0 means no upper bound. pub max_level: u32, - /// The dungeon's AreaTable id. pub area_id: u32, } -/// One Seeker: a Character in the Meeting Stone Queue, alone or as a member of a queued Party. -/// Private: the Gateway reads it with the owner token to answer `CMSG_MEETINGSTONE_INFO`. [entity] +/// One Seeker. The Gateway reads it to answer `CMSG_MEETINGSTONE_INFO`. [entity] #[table( accessor = game_meeting_stone_seeker, index(accessor = by_bucket, btree(columns = [area_id, team])), @@ -68,17 +54,16 @@ pub struct MeetingStoneSeeker { #[primary_key] pub character_guid: u64, pub area_id: u32, - /// `lyracore_shared::faction` team id. Seekers of different teams never meet. pub team: u32, - /// The class the Gateway conveyed. 0 when it could not read one. + /// 0 when the Gateway could not read it. pub class: u8, - /// The queued party. 0 for a solo Seeker. + /// 0 for a solo Seeker. pub group_id: u64, pub queued_at: Timestamp, } -/// One queued Party. A row exists exactly while the party is queued, and its Seeker rows are its -/// current members. Private and Module only. [entity] +/// One queued Party. It exists exactly while the Party is queued, and its Seeker rows are its +/// current members. [entity] #[table(accessor = game_meeting_stone_party)] pub struct MeetingStoneParty { #[primary_key] @@ -86,11 +71,9 @@ pub struct MeetingStoneParty { pub area_id: u32, pub team: u32, pub queued_at: Timestamp, - /// When the members next get `SMSG_MEETINGSTONE_IN_PROGRESS`. pub next_reminder_at: Timestamp, } -/// Drives [`remind_queued_parties`]. One row; its `scheduled_at` is the cadence. #[table(accessor = game_meeting_stone_reminder_schedule, scheduled(remind_queued_parties))] pub struct MeetingStoneReminderSchedule { #[primary_key] @@ -106,7 +89,6 @@ pub(crate) struct Bucket { pub(crate) team: u32, } -/// What the Gateway read about one Seeker from whichever World Shard holds it. #[derive(SpacetimeType, Clone, Copy, Debug, PartialEq, Eq)] pub struct SeekerFacts { pub character_guid: u64, @@ -120,10 +102,8 @@ fn refused(refusal: MeetingStoneRefusal, detail: &str) -> String { tag.to_string() } -/// May `actor_guid` use the Meeting Stone `go_guid`? Runs on the actor's Home Shard and writes -/// nothing. Refuses an actor without a World Session, one on a taxi flight, a GameObject out of -/// reach or not a stone, and a level outside the stone's range. Neither core checks the level; -/// the client does, and 1.12 has no failure code for it, so the Gateway drops this Refusal. +/// May `actor_guid` use the Meeting Stone `go_guid`? Writes nothing. The level check has no 1.12 +/// failure code, so the Gateway drops that Refusal silently, as the client checks it first. #[reducer] pub fn gw_admit_meeting_stone( ctx: &ReducerContext, @@ -146,7 +126,7 @@ fn admit( actor_guid: u64, go_guid: u64, ) -> Result<(), MeetingStoneRefusal> { - // Humans only. An Operator-driven Character without a World Session never queues. + // Humans only: no World Session, no queue. if request_actor.ownership.is_none() || crate::taxi::is_in_flight(ctx, actor_guid) { return Err(MeetingStoneRefusal::ActorUnavailable); } @@ -170,8 +150,6 @@ fn admit( Ok(()) } -/// The shared GameObject Gate's answer as a Meeting Stone Refusal. `usable_go` produces only the -/// four kinds named here. fn gate_refusal(kind: crate::actor::ActionRefusalKind) -> MeetingStoneRefusal { use crate::actor::ActionRefusalKind; match kind { @@ -182,12 +160,11 @@ fn gate_refusal(kind: crate::actor::ActionRefusalKind) -> MeetingStoneRefusal { } } -/// Inclusive on both ends. A `max_level` of 0 has no upper bound. +/// A `max_level` of 0 has no upper bound. fn level_in_range(level: u32, min_level: u32, max_level: u32) -> bool { level >= min_level && (max_level == 0 || level <= max_level) } -/// The actor's party, as the JOIN Gate needs it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Membership { leads: bool, @@ -195,8 +172,8 @@ struct Membership { members: usize, } -/// A grouped actor queues only as the leader of a Party with room, checked in cmangos's order: -/// not the leader, then a raid, then a full party (cm:LFG/LFGHandler.cpp:58-77). +/// A grouped actor queues only as the leader of a Party with room. The order of the checks picks +/// the failure code (cm:LFG/LFGHandler.cpp:58-77). fn party_join_gate(membership: Membership) -> Result<(), MeetingStoneRefusal> { if !membership.leads { return Err(MeetingStoneRefusal::NotLeader); @@ -212,8 +189,7 @@ fn party_join_gate(membership: Membership) -> Result<(), MeetingStoneRefusal> { enum StoneOpError { Refused(MeetingStoneRefusal), - /// A broken durable relationship or a malformed request. Untagged, so the Gateway treats it - /// as a failure with an unknown outcome. + /// Untagged, so the Gateway treats the outcome as unknown. Invalid(String), } @@ -223,14 +199,15 @@ impl From for StoneOpError { } } -/// Run one Meeting Stone Queue op for `request_actor` on the party authority. -/// -/// Operator-gated because the actor is an argument: Realm-core has no live entity to derive it -/// from. `area_id` and `seekers` are JOIN's: the stone's dungeon area, and the facts of the actor -/// or of every member of the party it leads. A missing member's class is 0. A JOIN ends with the -/// bucket pass, so it can form or fill Parties in the same transaction. LEAVE reads neither. -/// More than [`GROUP_MAX_MEMBERS`] facts, facts that omit the actor, or an unknown op byte is an -/// untagged error. +impl From for StoneOpError { + fn from(error: crate::group::GroupOpError) -> Self { + Self::Invalid(format!("{error:?}")) + } +} + +/// One Meeting Stone Queue op on the party authority. Operator-gated because the actor is an +/// argument. JOIN reads `area_id` and the `seekers` of the actor or its whole Party; LEAVE reads +/// neither. Too many facts, facts without the actor, or an unknown op is an untagged error. #[reducer] pub fn realm_meeting_stone_op( ctx: &ReducerContext, @@ -268,18 +245,6 @@ pub fn realm_meeting_stone_op( }) } -fn membership_of( - ctx: &ReducerContext, - character_guid: u64, -) -> Result)>, StoneOpError> { - let membership = crate::group::checked_group_membership(ctx, character_guid) - .map_err(|error| StoneOpError::Invalid(format!("{error:?}")))?; - Ok(membership.map(|(_, group)| { - let members = crate::group::members_of(ctx, group.group_id); - (group, members) - })) -} - fn join( ctx: &ReducerContext, actor_guid: u64, @@ -299,7 +264,7 @@ fn join( area_id, team: team_for_race(actor.race), }; - if let Some((group, members)) = membership_of(ctx, actor_guid)? { + if let Some((group, members)) = crate::group::group_with_members(ctx, actor_guid)? { party_join_gate(Membership { leads: group.leader_guid == actor_guid, kind: crate::group::group_kind_of(&group), @@ -324,16 +289,14 @@ fn join( match_bucket(ctx, bucket).map_err(StoneOpError::Invalid) } -/// When a Party queued now first hears IN_PROGRESS. fn first_reminder_at(ctx: &ReducerContext) -> Result { ctx.timestamp .checked_add(TimeDuration::from_micros(REMINDER_INTERVAL_MICROS)) .ok_or_else(|| "reminder time overflow".to_string()) } -/// Queue `group_id` in `bucket` with `members` as its Seekers, `(guid, class)` each, and tell each -/// of them JOINED. Re-queueing a party replaces its area and wait time and drops the -/// Seeker rows of Characters that are no longer members. +/// Queue `group_id` with `members`, `(guid, class)` each, and tell each JOINED. Re-queueing resets +/// the area and wait time and drops Seeker rows of former members. fn queue_party( ctx: &ReducerContext, group_id: u64, @@ -372,7 +335,6 @@ fn queue_party( } } -/// Queue `character_guid`, replacing any earlier row and its wait time. fn upsert_seeker( ctx: &ReducerContext, character_guid: u64, @@ -396,7 +358,6 @@ fn upsert_seeker( } } -/// `SMSG_MEETINGSTONE_SETQUEUE(area_id, status)` to `recipient_guid`. fn push_queue(ctx: &ReducerContext, recipient_guid: u64, area_id: u32, status: u8) { crate::group::push_event( ctx, @@ -407,7 +368,6 @@ fn push_queue(ctx: &ReducerContext, recipient_guid: u64, area_id: u32, status: u ); } -/// An event of `kind` with no payload to each of `recipients`. fn push_to_each(ctx: &ReducerContext, recipients: &[u64], kind: u8, other_guid: u64) { for &recipient in recipients { crate::group::push_event(ctx, recipient, kind, other_guid, String::new()); @@ -423,11 +383,10 @@ fn member_guids(ctx: &ReducerContext, group_id: u64) -> Vec { .collect() } -/// cm:LFG/LFGHandler.cpp:86-110. A solo Seeker leaves with LEAVE_QUEUE. The leader of a queued -/// party takes the whole party out, and every member gets LEAVE_QUEUE. Anyone else in a party only -/// gets NONE. Nobody queued and no party: nothing happens. +/// A solo Seeker leaves with LEAVE_QUEUE, and a queued Party's leader takes the whole Party out. +/// Any other member only hears NONE. fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { - let Some((group, _)) = membership_of(ctx, actor_guid)? else { + let Some((group, _)) = crate::group::group_with_members(ctx, actor_guid)? else { let seekers = ctx.db.game_meeting_stone_seeker(); if seekers.character_guid().find(actor_guid).is_some() { seekers.character_guid().delete(actor_guid); @@ -449,8 +408,7 @@ fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { Ok(()) } -/// Take a queued party out of the queue: every member gets `SETQUEUE(0, status)`, and the party -/// row and its Seeker rows go. Nothing happens for a party that is not queued. +/// Take a queued Party out of the queue. Every member hears `SETQUEUE(0, status)`. pub(crate) fn dequeue_party(ctx: &ReducerContext, group_id: u64, status: u8) { if ctx .db @@ -467,7 +425,6 @@ pub(crate) fn dequeue_party(ctx: &ReducerContext, group_id: u64, status: u8) { dequeue_party_rows(ctx, group_id); } -/// Delete a queued party's row and every Seeker row it holds. fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { ctx.db .game_meeting_stone_party() @@ -484,8 +441,7 @@ fn dequeue_party_rows(ctx: &ReducerContext, group_id: u64) { } } -/// A queued party with five members is complete: every member gets COMPLETE, then -/// `SETQUEUE(0, NONE)`, and the party leaves the queue (cm:LFG/LFGQueue.cpp:420-454). +/// Every member hears COMPLETE, then `SETQUEUE(0, NONE)`, and the Party leaves the queue. fn complete_party(ctx: &ReducerContext, group_id: u64) { let members = member_guids(ctx, group_id); push_to_each(ctx, &members, event_kind::COMPLETE, 0); @@ -495,163 +451,12 @@ fn complete_party(ctx: &ReducerContext, group_id: u64) { dequeue_party_rows(ctx, group_id); } -// ================================================================================================= -// Roles -// ================================================================================================= - -/// 1.12 class ids (ChrClasses.dbc). -mod class { - pub const WARRIOR: u8 = 1; - pub const PALADIN: u8 = 2; - pub const HUNTER: u8 = 3; - pub const ROGUE: u8 = 4; - pub const PRIEST: u8 = 5; - pub const SHAMAN: u8 = 7; - pub const MAGE: u8 = 8; - pub const WARLOCK: u8 = 9; - pub const DRUID: u8 = 11; -} - -/// A Party's dungeon roles, in the order a Party fills them (cm:Groups/Group.cpp:1594-1599). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Role { - Tank, - Healer, - Damage, -} - -const ROLE_ORDER: [Role; 3] = [Role::Tank, Role::Healer, Role::Damage]; - -/// A Party has three damage roles (cm:LFG/LFGMgr.h:51). -const DAMAGE_ROLES: u8 = 3; - -/// How well `class` fills `role`: 0 not at all, then 1 low, 2 normal, 3 high -/// (cm:LFG/LFGMgr.cpp:108-158). A class fills exactly the roles it has a priority for, which is -/// cmangos's class role table (cm:LFG/LFGMgr.cpp:91-106). An unknown class fills nothing. -fn role_priority(class: u8, role: Role) -> u8 { - use class::*; - const LOW: u8 = 1; - const NORMAL: u8 = 2; - const HIGH: u8 = 3; - match (role, class) { - (Role::Tank, WARRIOR) => HIGH, - (Role::Tank, DRUID | PALADIN) => NORMAL, - (Role::Healer, DRUID | PALADIN | PRIEST | SHAMAN) => HIGH, - (Role::Damage, HUNTER | MAGE | ROGUE | WARLOCK) => HIGH, - (Role::Damage, DRUID | PALADIN | SHAMAN | WARRIOR) => NORMAL, - (Role::Damage, PRIEST) => LOW, - _ => 0, - } -} - -fn fills(class: u8, role: Role) -> bool { - role_priority(class, role) > 0 -} - -/// A queued Party's Open Roles: which of its one tank, one healer and three damage roles no -/// member's class fills yet. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct OpenRoles { - tank: bool, - healer: bool, - damage: u8, -} - -impl OpenRoles { - const ALL: Self = Self { - tank: true, - healer: true, - damage: DAMAGE_ROLES, - }; - - fn is_open(self, role: Role) -> bool { - match role { - Role::Tank => self.tank, - Role::Healer => self.healer, - Role::Damage => self.damage > 0, - } - } - - fn fill(&mut self, role: Role) { - match role { - Role::Tank => self.tank = false, - Role::Healer => self.healer = false, - Role::Damage => self.damage = self.damage.saturating_sub(1), - } - } -} - -/// The Open Roles of a Party whose members have `classes`, in join order -/// (cm:Groups/Group.cpp:1589-1685). Each member takes the first of tank, healer and damage that its -/// class fills and that is still open, unless a member not yet placed has a higher priority for it. -/// A member left without a role holds a seat and fills nothing. -fn open_roles(classes: &[u8]) -> OpenRoles { - let mut open = OpenRoles::ALL; - let mut placed = vec![false; classes.len()]; - for (seat, &class) in classes.iter().enumerate() { - let outranked = |role: Role| { - classes.iter().enumerate().any(|(other, &other_class)| { - other != seat - && !placed[other] - && role_priority(other_class, role) > role_priority(class, role) - }) - }; - let taken = ROLE_ORDER - .into_iter() - .find(|&role| fills(class, role) && open.is_open(role) && !outranked(role)); - if let Some(role) = taken { - open.fill(role); - placed[seat] = true; - } - } - open -} - -/// The longest wait first, ties by guid. -fn wait_order(seeker: &MeetingStoneSeeker) -> (Timestamp, u64) { - (seeker.queued_at, seeker.character_guid) -} - -/// The index in `solos` of the next Stone Add for a Party with `open` roles: for tank, then healer, -/// then damage, if open, the longest-waiting Seeker whose class fills it. cm:LFG/LFGQueue.cpp:292-390 -/// compares class priority as a `bool`, so every capable Seeker ties on class and the wait decides. -fn next_stone_add(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { - ROLE_ORDER - .into_iter() - .filter(|&role| open.is_open(role)) - .find_map(|role| { - solos - .iter() - .enumerate() - .filter(|(_, seeker)| fills(seeker.class, role)) - .min_by_key(|(_, seeker)| wait_order(seeker)) - .map(|(index, _)| index) - }) -} - -/// Five solo Seekers in one bucket form a Party: the longest wait leads, the next joins, and the -/// bucket pass fills the rest (cm:LFG/LFGQueue.cpp:173-221). Both cores count Seekers across every -/// area and then block on the first Seeker's area; counting one bucket removes that block. -fn formation(solos: &[MeetingStoneSeeker]) -> Option<(&MeetingStoneSeeker, &MeetingStoneSeeker)> { - if solos.len() < GROUP_MAX_MEMBERS { - return None; - } - let mut waiting: Vec<&MeetingStoneSeeker> = solos.iter().collect(); - waiting.sort_unstable_by_key(|seeker| wait_order(seeker)); - Some((waiting[0], waiting[1])) -} - // ================================================================================================= // The bucket pass // ================================================================================================= -/// Match `bucket` until nothing changes. Each queued Party, oldest first, takes Stone Adds for its -/// Open Roles; then, while the bucket holds five solo Seekers, they form a Party and the Parties fill -/// again. Every Party this forms or changes advances its Roster Revision. -/// -/// A solo Seeker whose Account Claim is closed or expired leaves the queue silently when the pass -/// meets it. The Account Claim's end drops it too, but the lease reaper may take up to 15 s to close -/// an expired claim. An `Err` is a broken durable relationship. +/// Match `bucket` until nothing changes: queued Parties fill oldest first, then five solo Seekers +/// form a Party and the Parties fill again. An `Err` is a broken durable relationship. pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), String> { loop { let (mut solos, parties) = read_bucket(ctx, bucket); @@ -665,10 +470,9 @@ pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), S } } -/// The bucket's waiting solo Seekers, and its queued Parties oldest first. Reads the bucket through -/// its index. Drops a solo Seeker without a live Account Claim, a stale solo row of a Character -/// that is in a Group, and a party row whose Group is gone or is a Raid. What is left cannot make -/// a Stone Add fail, so a pass that runs where it cannot roll back leaves no half-done add. +/// The bucket's solo Seekers, and its queued Parties oldest first. Drops what could make a Stone +/// Add fail: a solo Seeker without a live claim (the lease reaper may take 15 s) or in a Group, and +/// a party row whose Group is gone or is a Raid. `remove_member` cannot roll a pass back. fn read_bucket( ctx: &ReducerContext, bucket: Bucket, @@ -716,7 +520,6 @@ fn read_bucket( (solos, parties) } -/// Whether `character_guid` has an open Account Claim before its deadline. fn has_live_claim(ctx: &ReducerContext, character_guid: u64) -> bool { let now = ctx.timestamp.to_micros_since_unix_epoch(); ctx.db @@ -726,10 +529,8 @@ fn has_live_claim(ctx: &ReducerContext, character_guid: u64) -> bool { .any(|claim| !claim.closed && claim.expires_micros > now) } -/// Give `party` Stone Adds from `solos` while it has fewer than five members and a Seeker fits an -/// Open Role. Before each add the current members get MEMBER_ADDED; at five members the party is -/// complete (cm:LFG/LFGQueue.cpp:372-383). A full Party is completed, never joined, so a Stone Add -/// never meets a full Group. +/// Give `party` Stone Adds while a Seeker fits an Open Role. The current members hear MEMBER_ADDED +/// before each add. A full Party is completed, never joined. fn fill_party( ctx: &ReducerContext, party: &MeetingStoneParty, @@ -771,7 +572,6 @@ fn fill_party( } } -/// The class a queued party's Seeker row holds for `guid`, or 0. fn party_class(ctx: &ReducerContext, guid: u64, group_id: u64) -> u8 { ctx.db .game_meeting_stone_seeker() @@ -781,8 +581,8 @@ fn party_class(ctx: &ReducerContext, guid: u64, group_id: u64) -> u8 { .map_or(0, |seeker| seeker.class) } -/// Form a Party of `leader` and `member` and queue it in `bucket`. The leader hears MEMBER_ADDED -/// before the Party exists, then both hear JOINED (cm:LFG/LFGQueue.cpp:190-218). +/// Form a Party of `leader` and `member` and queue it. The leader hears MEMBER_ADDED before the +/// Party exists, then both hear JOINED. fn form_party( ctx: &ReducerContext, bucket: Bucket, @@ -822,7 +622,6 @@ fn form_party( Ok(()) } -/// Check that `joiner` can join `target`, before the Stone Add writes anything. fn plan_stone_add( ctx: &ReducerContext, joiner: &MeetingStoneSeeker, @@ -836,8 +635,7 @@ fn plan_stone_add( }) } -/// Take `joiner` out of the solo queue and add it through `plan`, advancing the Group's Roster -/// Revision. Returns the Group id. +/// Add `joiner` through `plan` and advance the Group's Roster Revision. Returns the Group id. fn stone_add(ctx: &ReducerContext, joiner: &MeetingStoneSeeker, plan: JoinPlan) -> u64 { ctx.db .game_meeting_stone_seeker() @@ -852,10 +650,9 @@ fn stone_add(ctx: &ReducerContext, joiner: &MeetingStoneSeeker, plan: JoinPlan) // Group core hooks // ================================================================================================= -/// `character_guid` joined `group_id` through an accepted invite. A solo Seeker leaves the queue -/// and hears LEAVE_QUEUE unless the Party is queued for the same area (cm:Groups/Group.cpp:360-374). -/// A queued Party gains the Character as a Seeker with `class`; at five members it is complete, -/// below five the bucket pass runs, because the new member can move the Open Roles. +/// `character_guid` joined `group_id` through an accepted invite. A solo Seeker leaves the queue, +/// with LEAVE_QUEUE unless the Party is queued for the same area. A queued Party gains a Seeker, +/// completes at five, and otherwise rematches, because the new member moves the Open Roles. pub(crate) fn party_joined( ctx: &ReducerContext, group_id: u64, @@ -889,7 +686,6 @@ pub(crate) fn party_joined( match_bucket(ctx, bucket) } -/// A member left a Party that survives it. See [`party_left`]. #[derive(Clone, Copy, Debug)] pub(crate) struct PartyDeparture { pub(crate) group_id: u64, @@ -898,19 +694,14 @@ pub(crate) struct PartyDeparture { pub(crate) leader_changed: bool, } -/// A member left a queued Party that survives it. Runs after the member row is gone and before the -/// roster list. +/// A member left a queued Party that survives it, after the member row is gone and before the +/// roster list (cm:Groups/Group.cpp:413-476). Returns the bucket to rematch, if any. /// -/// - Left, leader unchanged: the leaver hears NONE, the rest PARTY_MEMBER_LEFT_LFG, and the party -/// stays queued without the leaver (cm:Groups/Group.cpp:437-447). -/// - Left, leader changed: the leaver hears NONE and the party leaves the queue with LEAVE_QUEUE -/// (cm:Groups/Group.cpp:464-476). -/// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the party leaves the queue with -/// LEAVE_QUEUE. A kicked Character with a live Account Claim hears -/// LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone with its class and team -/// (cm:Groups/Group.cpp:413-435). A kicked playerbot or offline member only leaves. -/// -/// Returns the bucket to match when a role opened or a Character was queued. +/// - Left, same leader: the leaver hears NONE, the rest PARTY_MEMBER_LEFT_LFG, the Party stays. +/// - Left, new leader: the leaver hears NONE and the Party leaves with LEAVE_QUEUE. +/// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the Party leaves with +/// LEAVE_QUEUE. The kicked Character, if it has a World Session, hears +/// LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone. pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Option { let party = ctx .db @@ -934,8 +725,7 @@ pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Opti ); } dequeue_party(ctx, departed.group_id, queue_status::LEAVE_QUEUE); - // Only a Character with a World Session waits alone: a kicked playerbot or offline - // member just leaves, as in cmangos (`if (player)`). + // A kicked playerbot or offline member only leaves, as in cmangos (`if (player)`). if !has_live_claim(ctx, guid) { return None; } @@ -972,8 +762,7 @@ pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Opti } } -/// A queued Party disbanded: every former member hears NONE and the party leaves the queue. Nobody -/// waits on alone (cm:Groups/Group.cpp:550-562). +/// A queued Party disbanded: every former member hears NONE, and nobody waits on alone. pub(crate) fn party_disbanded(ctx: &ReducerContext, group_id: u64, former_members: &[u64]) { if ctx .db @@ -994,9 +783,8 @@ pub(crate) fn party_disbanded(ctx: &ReducerContext, group_id: u64, former_member // Reminders // ================================================================================================= -/// Remind every queued Party whose reminder is due that the queue is still working: each member -/// gets IN_PROGRESS, and the next reminder is five minutes on (cm:LFG/LFGQueue.cpp:149-163). -/// Scheduler-only. The party table is small and holds only queued Parties, so this reads it whole. +/// Every member of a queued Party whose reminder is due hears IN_PROGRESS. Scheduler-only. The +/// party table holds only queued Parties, so this reads it whole. #[reducer] pub fn remind_queued_parties(ctx: &ReducerContext, _schedule: MeetingStoneReminderSchedule) { if ctx.sender() != ctx.database_identity() { @@ -1024,8 +812,8 @@ pub fn remind_queued_parties(ctx: &ReducerContext, _schedule: MeetingStoneRemind } } -/// Arm the reminder tick, leaving exactly one schedule row. `init` and `debug_repair_after_publish` -/// both call this, so the two paths arm one interval. +/// Arm the reminder tick with exactly one schedule row. `init` and `debug_repair_after_publish` +/// both call this. pub(crate) fn rearm_reminder_schedule(ctx: &ReducerContext) { let schedule = ctx.db.game_meeting_stone_reminder_schedule(); let stale: Vec = schedule.iter().map(|row| row.scheduled_id).collect(); @@ -1038,9 +826,8 @@ pub(crate) fn rearm_reminder_schedule(ctx: &ReducerContext) { }); } -/// The Account Claim that admitted `character_guid` ended: drop its solo Seeker row, silently. The -/// Character's World Session is gone, so no packet could reach it. A party Seeker row stays, because -/// it ends with the party membership, not the session. +/// `character_guid`'s Account Claim ended: drop its solo Seeker row without a packet, since no +/// session is left to hear one. A party Seeker row ends with the membership instead. pub(crate) fn claim_ended(ctx: &ReducerContext, character_guid: u64) { let seekers = ctx.db.game_meeting_stone_seeker(); if seekers @@ -1066,140 +853,6 @@ mod tests { assert!(!level_in_range(14, 15, 0)); } - /// cm:LFG/LFGMgr.cpp:91-158, row by row: the roles each class fills and its priority in each. - #[test] - fn the_role_table_is_the_cmangos_class_table() { - use class::*; - let row = |class: u8| ROLE_ORDER.map(|role| role_priority(class, role)); - assert_eq!(row(WARRIOR), [3, 0, 2]); - assert_eq!(row(PALADIN), [2, 3, 2]); - assert_eq!(row(HUNTER), [0, 0, 3]); - assert_eq!(row(ROGUE), [0, 0, 3]); - assert_eq!(row(PRIEST), [0, 3, 1]); - assert_eq!(row(SHAMAN), [0, 3, 2]); - assert_eq!(row(MAGE), [0, 0, 3]); - assert_eq!(row(WARLOCK), [0, 0, 3]); - assert_eq!(row(DRUID), [2, 3, 2]); - assert_eq!(row(0), [0, 0, 0], "an unknown class fills nothing"); - assert_eq!(row(6), [0, 0, 0], "class 6 does not exist in 1.12"); - } - - fn open(tank: bool, healer: bool, damage: u8) -> OpenRoles { - OpenRoles { - tank, - healer, - damage, - } - } - - /// The paladin outranks nobody for tank while the warrior waits, so it heals. - #[test] - fn a_paladin_then_a_warrior_leave_three_damage_roles_open() { - use class::*; - assert_eq!(open_roles(&[PALADIN, WARRIOR]), open(false, false, 3)); - } - - /// Equal priority does not outrank, so the first warrior tanks and the second deals damage. - #[test] - fn two_warriors_leave_the_healer_and_two_damage_roles_open() { - use class::*; - assert_eq!(open_roles(&[WARRIOR, WARRIOR]), open(false, true, 2)); - } - - #[test] - fn a_full_party_has_no_open_role() { - use class::*; - assert_eq!( - open_roles(&[WARRIOR, PRIEST, MAGE, ROGUE, HUNTER]), - open(false, false, 0) - ); - } - - #[test] - fn a_member_of_unknown_class_holds_a_seat_and_no_role() { - use class::*; - assert_eq!(open_roles(&[]), OpenRoles::ALL); - assert_eq!(open_roles(&[0, MAGE]), open(true, true, 2)); - } - - /// A fourth damage dealer finds every damage role taken and fills nothing. - #[test] - fn a_member_whose_roles_are_taken_fills_nothing() { - use class::*; - assert_eq!(open_roles(&[MAGE, MAGE, MAGE, MAGE]), open(true, true, 0)); - } - - fn seeker(character_guid: u64, class: u8, waited_secs: i64) -> MeetingStoneSeeker { - MeetingStoneSeeker { - character_guid, - area_id: 1581, - team: 469, - class, - group_id: 0, - queued_at: Timestamp::from_micros_since_unix_epoch( - 1_000_000_000_000 - waited_secs * 1_000_000, - ), - } - } - - fn picked(open: OpenRoles, solos: &[MeetingStoneSeeker]) -> Option { - next_stone_add(open, solos).map(|index| solos[index].character_guid) - } - - #[test] - fn the_next_stone_add_takes_tank_then_healer_then_damage() { - use class::*; - let solos = [ - seeker(1, MAGE, 90), - seeker(2, PRIEST, 10), - seeker(3, WARRIOR, 5), - ]; - assert_eq!(picked(OpenRoles::ALL, &solos), Some(3)); - assert_eq!(picked(open(false, true, 3), &solos), Some(2)); - assert_eq!(picked(open(false, false, 3), &solos), Some(1)); - assert_eq!(picked(open(false, false, 0), &solos), None); - } - - #[test] - fn the_longest_wait_takes_a_role_and_the_lower_guid_breaks_a_tie() { - use class::*; - let solos = [ - seeker(7, PRIEST, 30), - seeker(5, DRUID, 60), - seeker(4, SHAMAN, 60), - ]; - assert_eq!(picked(open(false, true, 0), &solos), Some(4)); - let solos = [ - seeker(7, PRIEST, 61), - seeker(5, DRUID, 60), - seeker(4, SHAMAN, 60), - ]; - assert_eq!(picked(open(false, true, 0), &solos), Some(7)); - } - - #[test] - fn a_seeker_of_unknown_class_is_never_a_stone_add() { - assert_eq!(picked(OpenRoles::ALL, &[seeker(1, 0, 600)]), None); - } - - #[test] - fn five_solo_seekers_form_a_party_led_by_the_longest_wait() { - use class::*; - let waiting = || { - vec![ - seeker(1, MAGE, 10), - seeker(2, MAGE, 50), - seeker(3, MAGE, 40), - seeker(4, MAGE, 40), - ] - }; - assert!(formation(&waiting()).is_none()); - let mut five = waiting(); - five.push(seeker(5, MAGE, 5)); - let (leader, member) = formation(&five).expect("five Seekers form a party"); - assert_eq!((leader.character_guid, member.character_guid), (2, 3)); - } - fn party(leads: bool, kind: GroupKind, members: usize) -> Membership { Membership { leads, @@ -1218,8 +871,7 @@ mod tests { } } - /// cm:LFG/LFGHandler.cpp:58-77 checks the leader first, then the raid, then the size, so a - /// member of a full raid hears only that it does not lead. + /// A member of a full raid hears only that it does not lead. #[test] fn the_join_gate_checks_leader_then_raid_then_size() { use MeetingStoneRefusal::*; From 9d9cf7bb0796a902816600854d570518ad8b5a0f Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 08:08:06 +0200 Subject: [PATCH 15/16] refactor(group): let a join plan apply only once Co-Authored-By: Claude Opus 5.5 (1M context) --- module/src/group.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/src/group.rs b/module/src/group.rs index a8806d76d..79c8210ee 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -1470,7 +1470,7 @@ pub(crate) struct JoinedGroup { /// A join that passed every check and has written nothing yet. [`JoinPlan::apply`] cannot fail, so /// a caller may write its own rows between the two and never leave half a join behind. The plan /// holds while nothing between the two changes group membership. -#[derive(Clone, Copy, Debug)] +#[derive(Debug)] pub(crate) struct JoinPlan { joiner_guid: u64, target: JoinTarget, From 1e70e33a5af67af1edd74034a5601a27433d6477 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Sat, 3 Oct 2026 18:14:54 +0200 Subject: [PATCH 16/16] ci(durable): run the meeting stone and roster relay Gateway tests Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/module-durable.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 572fae94d..402d0c434 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -97,6 +97,8 @@ jobs: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::account_sessions::tests::independent_coordinators_preserve_the_winner_after_delayed_cleanup -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway world::tests::competing_world_sessions_close_the_old_socket_without_removing_the_winner -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::character_gone_durable_tests::another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character -- --ignored --exact + cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::roster_relay_durable_tests::a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror -- --ignored --exact + cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::meeting_stone_durable_tests::seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror -- --ignored --exact 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 - name: Upload the standalone logs