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 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 4a99c96cf..2ec133798 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -345,6 +345,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 @@ -833,6 +838,33 @@ 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 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**: +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/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..e2a8fc299 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, 0 when the Gateway cannot read it. Realm-core +/// holds no Character rows, so the Seeker row a queued party gains needs it. The rest unused. +/// - [`DECLINE`]: `actor_guid` alone; every other slot unused. /// - [`LEAVE`]: `actor_guid` leaves; `arg_a` = a [`super::leave_cause`] value. /// - [`LOOT_METHOD`]: `arg_a` = loot setting, `target_guid` = the master looter, `arg_b` = the /// quality threshold. (That is `CMSG_LOOT_METHOD`'s own field order, kept so the gateway hands the @@ -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:?}"); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b8362fdf5..29409318e 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` (`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` | @@ -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..f1169fd6d 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/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` | @@ -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/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` | @@ -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/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..f53541d44 --- /dev/null +++ b/gateway/src/codec/meeting_stone.rs @@ -0,0 +1,111 @@ +//! Meeting Stone Queue messages. `SMSG_MEETINGSTONE_COMPLETE` and `_IN_PROGRESS` are empty, so +//! they need no builder. + +use wow_world_messages::vanilla::{ + Area, MSG_LOOKING_FOR_GROUP_Server, MeetingStoneFailure, MeetingStoneStatus, + SMSG_MEETINGSTONE_JOINFAILED, SMSG_MEETINGSTONE_MEMBER_ADDED, SMSG_MEETINGSTONE_SETQUEUE, +}; +use wow_world_messages::Guid; + +/// `None` when `wow_world_messages` has no `Area` for the id or knows no such status. +pub fn build_meetingstone_setqueue(area_id: u32, status: u8) -> Option { + Some(SMSG_MEETINGSTONE_SETQUEUE { + area: Area::try_from(area_id).ok()?, + 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. +pub fn build_meetingstone_member_added(guid: u64) -> SMSG_MEETINGSTONE_MEMBER_ADDED { + SMSG_MEETINGSTONE_MEMBER_ADDED { + guid: Guid::new(guid), + } +} + +/// The answer to `MSG_LOOKING_FOR_GROUP`. vmangos sends 0 (vm:Handlers/MiscHandler.cpp:277-282), +/// and the stock UI never reads it. +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/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/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_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/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_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/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_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/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..80010698a 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,10 @@ 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_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; pub mod debug_stage_reward_letter_fixture_reducer; pub mod debug_stress_relay_reducer; @@ -546,6 +552,10 @@ 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_reminder_schedule_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 +731,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 +882,10 @@ 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_reminder_schedule_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 +990,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; @@ -1036,6 +1052,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; @@ -1051,6 +1068,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 +1312,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 +1446,10 @@ 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_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; pub use debug_stage_reward_letter_fixture_reducer::debug_stage_reward_letter_fixture; pub use debug_stress_relay_reducer::debug_stress_relay; @@ -1693,6 +1717,10 @@ 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_reminder_schedule_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 +1896,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 +2047,10 @@ 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_reminder_schedule_type::MeetingStoneReminderSchedule; +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 +2155,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; @@ -2183,6 +2217,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; @@ -2198,6 +2233,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 +2527,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 +3083,37 @@ 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, + }, + DebugStageMeetingStoneClaim { + guid: u64, + account_id: u64, + }, + DebugStageMeetingStoneGroup { + leader_guid: u64, + member_guids: Vec, + raid: bool, + }, DebugStageRangedLethalDamageFloorFixture { attacker_guid: u64, target_guid: u64, @@ -3292,6 +3367,10 @@ pub enum Reducer { request_actor: SessionActor, target_guid: u64, }, + GwAdmitMeetingStone { + request_actor: SessionActor, + go_guid: u64, + }, GwArmTaxiFlight { request_actor: SessionActor, }, @@ -4090,6 +4169,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, @@ -4150,6 +4235,9 @@ pub enum Reducer { transfer_id: u64, request_actor: SessionActor, }, + RemindQueuedParties { + schedule: MeetingStoneReminderSchedule, + }, RenewAccountClaim { token: WorldSessionToken, }, @@ -4311,6 +4399,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 +4560,12 @@ 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::DebugStageMeetingStoneClaim { .. } => "debug_stage_meeting_stone_claim", + Reducer::DebugStageMeetingStoneGroup { .. } => "debug_stage_meeting_stone_group", Reducer::DebugStageRangedLethalDamageFloorFixture { .. } => { "debug_stage_ranged_lethal_damage_floor_fixture" } @@ -4548,6 +4646,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 +4814,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", @@ -4729,6 +4829,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", @@ -5092,6 +5193,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 +6190,65 @@ 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::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, + 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 +6703,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 +8151,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, @@ -8078,6 +8269,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, @@ -8459,6 +8655,10 @@ 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_reminder_schedule: __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 +9360,22 @@ 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_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)?, + ), "game_melee_attack" => db_update .game_melee_attack .append(game_melee_attack_table::parse_table_update(table_update)?), @@ -10320,6 +10536,27 @@ 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_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", + &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 +11542,18 @@ 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_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)?), "game_melee_attack" => db_update .game_melee_attack .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -12161,6 +12410,18 @@ 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_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)?), "game_melee_attack" => db_update .game_melee_attack .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -12668,6 +12929,10 @@ 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_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>, game_motion_publish_schedule: __sdk::TableAppliedDiff<'r, MotionPublishSchedule>, @@ -13626,6 +13891,26 @@ 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_reminder_schedule", + &self.game_meeting_stone_reminder_schedule, + 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 +15248,10 @@ 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_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); game_motion_publish_schedule_table::register_table(client_cache); @@ -15246,6 +15535,10 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_mail_template", "game_mail_timer", "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", "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/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/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/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/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/reads/meeting_stone.rs b/gateway/src/stdb/reads/meeting_stone.rs new file mode 100644 index 000000000..be03afaac --- /dev/null +++ b/gateway/src/stdb/reads/meeting_stone.rs @@ -0,0 +1,40 @@ +//! Meeting Stone reads: stone rows on the Home Shard, Seeker rows on the party authority. + +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, if any. + 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` 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(); + 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/party.rs b/gateway/src/stdb/reads/party.rs index 1555d95d6..a63c86ecd 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/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..2e81fbf0e 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(), @@ -2197,6 +2198,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 +2443,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 @@ -3945,6 +3964,7 @@ impl Coordinator { ignored: Mutex::new(ignored), friends: Mutex::new(friends), team, + group_events: Default::default(), }); view.add_viewer( self, @@ -4141,6 +4161,69 @@ impl Coordinator { }); } + /// Start the Roster Revision Relay: Realm-core's `game_group_roster_revision` callbacks mark a + /// party dirty, and one worker pushes it to every stale World Shard mirror. Called once at + /// startup. Arms nothing unless Realm-core is a distinct database. A Realm-core reconnect + /// re-arms the callbacks on the fresh connection; changes missed while it was down are the + /// reconciliation pass's, which that reconnect also starts. + pub fn spawn_roster_revision_relay(&self) { + let realm = match self.realm_core() { + Ok(realm) => realm, + 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() + .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) { @@ -4577,6 +4660,7 @@ mod tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), } } @@ -4746,6 +4830,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)); @@ -7079,7 +7164,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)" @@ -8223,5 +8308,13 @@ 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)] +#[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..2862d6f26 --- /dev/null +++ b/gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs @@ -0,0 +1,400 @@ +//! 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 (wait timed out: {})", + session.guid, !complete + ); + } +} 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..051896726 --- /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. +pub(super) struct TopologyEnv { + shard_map: Option, + realm_core: Option, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl TopologyEnv { + 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); + 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. +pub(super) 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, 0, 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/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index caf3a1ec5..d6c489a3e 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,30 @@ fn wire_insert_live( }); } +/// [`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, + 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().collect() + }); + }); + }); +} + /// Register an UPDATE callback (see [`wire_insert`]); `f` gets `(view, old, new)`. fn wire_update( table: T, @@ -2722,10 +2745,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 FnOnce() -> Vec, +) { let Some(session) = view.session_of_owner(row.recipient_guid) else { return; }; @@ -2735,18 +2764,75 @@ 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 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 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() + .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 +3404,7 @@ mod family_audience_tests { ignored: Mutex::default(), friends: Mutex::default(), team: lyracore_shared::faction::TEAM_ALLIANCE, + group_events: Default::default(), }) } @@ -4540,6 +4627,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 +5406,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 +5716,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 +6020,84 @@ 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.to_vec()); + let later: Vec = [&cached[2], &cached[4]] + .into_iter() + .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]); + 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.to_vec()); + + 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.to_vec())), + [14] + ); + assert_eq!( + ids(&cursor.take(GroupEventSource::Shard(0), &shard[0], || shard.to_vec())), + [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/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs new file mode 100644 index 000000000..8fe892b67 --- /dev/null +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -0,0 +1,670 @@ +//! Meeting Stone dispatcher: join, leave, the queue status query and `MSG_LOOKING_FOR_GROUP`. +//! +//! A JOIN is two Durable Requests: the Home Shard admits the actor at the stone, then the party +//! authority queues it with the Seekers' race and class, read realm-wide. Queue notices arrive on +//! the group event relay; only a refused party JOIN is answered here. + +use super::super::*; +use 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; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SeekerFacts { + pub(crate) character_guid: u64, + pub(crate) race: u8, + pub(crate) class: u8, +} + +/// A timeout, transport or SDK failure stays an `Err` with an unknown outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MeetingStoneOutcome { + Ran, + Refused(MeetingStoneRefusal), +} + +pub(crate) trait MeetingStoneActionStore { + /// 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. + fn meeting_stone_area(&self, go_guid: u64) -> Result>; + /// Durable Read of the party authority, in join order. `None` outside a party. + fn party_members(&self, actor_guid: u64) -> Result>>; + /// One Seeker's race and class, from whichever World Shard holds it. + fn seeker_facts(&self, character_guid: u64) -> Result>; + /// Durable Request on the party authority. + fn meeting_stone_op( + &self, + actor_guid: u64, + op: u8, + area_id: u32, + seekers: Vec, + ) -> Result; + /// Durable Read of the party authority. + 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 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, + 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, +} + +/// 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)), + Err(error) if is_transport_failure(&error) => Err(error), + Err(error) => { + log::debug!("world: meeting stone {what} dropped (account {account_id}): {error:#}"); + Ok(None) + } + } +} + +/// 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 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()); + } + let Some(seekers) = recoverable( + "seeker facts read", + account_id, + facts_for_join(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 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> { + 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) +} + +/// `None` when the op 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`, 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, + 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 failure byte. + #[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. + #[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..a3fd10bf8 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; @@ -52,7 +53,8 @@ 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, @@ -73,9 +75,9 @@ pub(crate) use handlers::{ 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 +1574,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 17a414776..ef94cecb8 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -399,11 +399,47 @@ impl GroupBroadcastCooldowns { /// `realm_group_op`'s argument slots after the actor: `(op, target_guid, arg_a, arg_b, arg_c)`. type RealmOpArgs = (u8, u64, u8, u8, u64); +/// The class of a Character accepting an invite. Realm-core holds no Character rows, so an ACCEPT +/// conveys it for the Seeker row a queued party gains. 0 when no World Shard names the Character. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct AcceptorFacts { + class: u8, +} + +impl AcceptorFacts { + /// What `realm_group_op` receives for every op but ACCEPT. + const NONE: Self = Self { class: 0 }; + + /// The facts an ACCEPT conveys, read realm-wide. A failed read conveys 0 rather than losing + /// the accept: the Module treats an unknown class as a seat with no role. + fn of(store: &St, guid: u64) -> Self { + match presence::character_anywhere(store, guid) { + Ok(Some(character)) => Self { + class: character.class, + }, + Ok(None) => Self::NONE, + Err(error) => { + log::warn!("party: class 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, 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), @@ -469,26 +505,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) } @@ -877,7 +915,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; @@ -984,7 +1023,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)); } @@ -1026,7 +1067,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:#})" @@ -1162,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); @@ -1196,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); @@ -1306,6 +1356,7 @@ pub(crate) fn cleanup_deleted_character( realm.as_ref(), previous.group_id, Some(&previous), + &store.world_stores(), )?; } Ok(DeletedCharacterPartyCleanup::Removed) @@ -1352,7 +1403,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); @@ -1374,11 +1431,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, @@ -1389,7 +1450,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..d3603f88c --- /dev/null +++ b/gateway/src/world/party_mirror.rs @@ -0,0 +1,149 @@ +//! 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. +//! +//! 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}; + +#[derive(Default)] +pub(crate) struct RosterRevisionRelay { + dirty: Mutex>, + wake: Condvar, +} + +impl RosterRevisionRelay { + /// 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(); + std::thread::Builder::new() + .name("roster-revision-relay".into()) + .spawn(move || loop { + worker.wait_for_dirty(); + // A panic in one pass must not end the relay for the life of the process. + let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + worker.push_dirty(&store); + })); + 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) + } + + /// Safe on an SDK callback thread: one short lock, no calls. + 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); + } + } + + /// 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)); + 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 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) +} + +#[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/party_tests.rs b/gateway/src/world/party_tests.rs index 73e848e95..3227445e0 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1139,7 +1139,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(")); @@ -1504,7 +1504,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, 0, 0) ], "the accept must run on realm-core with the BOT as the actor — never the inviter, and never 0" ); @@ -1685,7 +1685,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, 0, 0), // …and the decline is the bot's own too, not the inviter's. (realm_op::DECLINE, BOT, 0, 0, 0, 0), ], @@ -1755,8 +1755,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, 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), @@ -1932,7 +1932,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, 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. @@ -2008,7 +2008,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, 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" @@ -2125,7 +2125,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, 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 \ @@ -4232,6 +4232,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`, so a queued party can give +/// the joiner a Seeker row. +#[test] +fn a_client_accept_carries_the_acceptors_class() { + let (realm, world, instances) = dwarf_priest_topology(); + 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, 0, 0)]); +} + +/// A playerbot's automatic accept reads its facts from the Shard that holds it, here not the +/// inviter's. +#[test] +fn a_playerbot_accept_carries_its_class_from_its_own_shard() { + let (realm, world, _instances) = dwarf_priest_topology(); + + party::run(world.as_ref(), 7, GINGER, party::Op::Invite(FAR_BOT)).unwrap(); + + assert_eq!(accepts(&realm), [(realm_op::ACCEPT, FAR_BOT, 0, 5, 0, 0)]); +} + +/// An acceptor no World Shard names conveys class 0, and the accept still runs. +#[test] +fn an_accept_nobody_can_name_conveys_zero_class() { + let (realm, world, _instances, _calls) = party_topology(); + 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 381f4f1a0..4d5df25cf 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 @@ -502,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 1aec1ab64..817ca2127 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::*; @@ -103,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"] @@ -650,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. @@ -3827,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()); @@ -3880,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 @@ -4375,6 +4404,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", diff --git a/importer/src/main.rs b/importer/src/main.rs index eec4191ad..30b89c705 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. + 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, + /// 0 for every type but MEETINGSTONE. + 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,17 @@ 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, + /// The only copy of a spawned 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 +5081,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 +5112,12 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> (Vec, Vec (Vec, Vec2} QUESTGIVER {count} (quest flow, not use-dispatched)"); continue; } + if type_id == GO_MEETINGSTONE { + 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 +5162,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 +5602,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 +5775,7 @@ fn family_stamps( quests, go_template_rows, go_trap_rows, + go_meeting_stone_rows, trainer_rows, creature_casts, creature_rotation_rows, @@ -5766,7 +5818,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 +6249,38 @@ mod tests { assert_eq!(go_initial_state(GO_QUESTGIVER, 1), 0); } + /// The Deadmines stone is level 15 to 20 for area 1581. + #[test] + fn gameobject_family_emits_one_meeting_stone_row_per_spawned_stone() { + let dump = "INSERT INTO `gameobject_template` VALUES \ + (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)") + })); + assert_eq!(plan.stamps, vec![("gameobjects", 2)]); + } + #[test] fn gameobject_family_emits_the_reachable_relay_trap_profile() { let dump = "INSERT INTO `gameobject_template` VALUES \ 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..e5d29bb99 --- /dev/null +++ b/module/src/debug/meeting_stone.rs @@ -0,0 +1,271 @@ +//! Meeting Stone Queue fixtures for the durable tests. Fixture ids sit in `509_6000`-`509_6099`. +//! +//! The levers stage the world a Meeting Stone needs: a stone and its spawned GameObject, Characters +//! with a chosen race, class and level, each with a live entity and a live Account Claim, the same +//! claim alone on Realm-core, and a Party or Raid led by one of them. Two more move a Seeker's or a queued party's time back, so a +//! test never waits on the clock. + +use spacetimedb::{reducer, ReducerContext, Table, TimeDuration}; + +use lyracore_shared::constants::go_type; +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)?; + 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() + .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/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/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 3e7bc720a..79c8210ee 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(); @@ -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), } @@ -1088,7 +1088,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> { @@ -1109,6 +1109,19 @@ 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; @@ -1371,13 +1384,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, Plane::Shard, acceptor_guid, class).map_err(|error| { group_op_error( error, &format!("{acceptor_guid} could not accept its invite"), @@ -1395,10 +1408,13 @@ fn has_pending_leader_invite(ctx: &ReducerContext, character_guid: u64) -> bool .any(|invite| invite.inviter_guid == character_guid && invite.group_id == 0) } +/// Consume `acceptor_guid`'s pending invite and join its Group through [`plan_join`]. `class` is +/// the acceptor's, for the Seeker row a queued Party gains; 0 when unknown. fn accept_invite_on( ctx: &ReducerContext, plane: Plane, acceptor_guid: u64, + class: u8, ) -> Result<(), GroupOpError> { let invites = ctx.db.game_group_invite(); let invite = invites @@ -1407,110 +1423,188 @@ 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 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) + .ok_or(GroupRefusal::InviterUnavailable)?; + } + crate::helpers::character_by_guid(ctx, acceptor_guid).ok_or(GroupRefusal::NoSuchPlayer)?; + } + 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)?; + } + crate::meeting_stone::party_joined(ctx, joined.group_id, acceptor_guid, class) + .map_err(GroupOpError::Invariant) +} + +/// Which Group [`plan_join`] adds a Character to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JoinTarget { + /// An existing Group. + Group(u64), + /// A Party this join forms. The leader joins it first. + NewParty { leader_guid: u64 }, +} + +/// The Group [`JoinPlan::apply`] added a Character to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct JoinedGroup { + pub(crate) group_id: u64, + /// The join formed this Party. + pub(crate) formed: bool, +} + +/// A join that passed every check and has written nothing yet. [`JoinPlan::apply`] cannot fail, so +/// a caller may write its own rows between the two and never leave half a join behind. The plan +/// holds while nothing between the two changes group membership. +#[derive(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. +/// +/// 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 { + 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 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()); + } + match kind { + GroupKind::Party => RaidSlot::default(), + GroupKind::Raid => RaidSlot::for_raid_joiner(current.iter().map(raid_slot_of)) + .ok_or(GroupRefusal::GroupFull)?, } - 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()); + 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()); + } + RaidSlot::default() } - // 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, + }; + 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, + 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, + } + } }; - // 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(), + group_id: joined.group_id, + character_guid: joiner_guid, + owner_identity: member_identity(ctx, joiner_guid), + raid_slot: slot.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 { - group_id: group.group_id, - ..other - }); + 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); } - (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, - 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(()) + 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 @@ -1557,7 +1651,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(()) } @@ -1595,7 +1689,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(()) } @@ -1675,6 +1769,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 @@ -2009,7 +2139,10 @@ 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, 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), @@ -2097,7 +2230,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); @@ -3505,7 +3645,7 @@ mod tests { ), ( "realm_op::ACCEPT =>", - "accept_invite_on(ctx, Plane::RealmCore, actor_guid)", + "accept_invite_on(ctx, Plane::RealmCore, actor_guid, arg_a)", ), ("realm_op::DECLINE =>", "decline_invite_on(ctx, actor_guid)"), ( 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/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/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/mod.rs b/module/src/meeting_stone/mod.rs new file mode 100644 index 000000000..b921c0d38 --- /dev/null +++ b/module/src/meeting_stone/mod.rs @@ -0,0 +1,899 @@ +//! The Meeting Stone Queue. [`gw_admit_meeting_stone`] admits a Character at the stone on its Home +//! Shard and writes nothing. [`realm_meeting_stone_op`] queues it on Realm-core, where party +//! membership is authoritative, so Seekers on every World Shard share one queue. Realm-core holds +//! no Character rows, so the Gateway conveys race and class as [`SeekerFacts`]. +//! +//! Matching runs in the transaction that changes a bucket ([`match_bucket`]): the cores' queue +//! thread reduces to longest wait per Open Role, so a tick would only add latency. A solo Seeker +//! lasts as long as its Account Claim ([`claim_ended`]); a party Seeker as long as its membership. + +use std::collections::BTreeSet; + +use spacetimedb::{ + reducer, table, ReducerContext, ScheduleAt, 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, +}; + +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 hears IN_PROGRESS this often (cm:LFG/LFGMgr.cpp:55). +const REMINDER_INTERVAL_MICROS: i64 = 5 * 60 * 1_000_000; + +const REMINDER_TICK_MICROS: i64 = 5_000_000; + +/// One Meeting Stone template's level range and dungeon area, imported from `data0` to `data2`. +/// Packages do not author it. [static] +#[table(accessor = game_meeting_stone)] +pub struct MeetingStone { + #[primary_key] + pub entry: u32, + pub min_level: u32, + /// 0 means no upper bound. + pub max_level: u32, + pub area_id: u32, +} + +/// One Seeker. The Gateway reads it to answer `CMSG_MEETINGSTONE_INFO`. [entity] +#[table( + accessor = game_meeting_stone_seeker, + index(accessor = by_bucket, btree(columns = [area_id, team])), + index(accessor = by_group, btree(columns = [group_id])) +)] +pub struct MeetingStoneSeeker { + #[primary_key] + pub character_guid: u64, + pub area_id: u32, + pub team: u32, + /// 0 when the Gateway could not read it. + pub class: u8, + /// 0 for a solo Seeker. + pub group_id: u64, + pub queued_at: Timestamp, +} + +/// One queued Party. It exists exactly while the Party is queued, and its Seeker rows are its +/// current members. [entity] +#[table(accessor = game_meeting_stone_party)] +pub struct MeetingStoneParty { + #[primary_key] + pub group_id: u64, + pub area_id: u32, + pub team: u32, + pub queued_at: Timestamp, + pub next_reminder_at: Timestamp, +} + +#[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, +} + +#[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`? 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, + 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: no World Session, no queue. + 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(()) +} + +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, + } +} + +/// 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) +} + +#[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. 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); + } + if membership.kind == GroupKind::Raid { + return Err(MeetingStoneRefusal::RaidGroup); + } + if membership.members >= GROUP_MAX_MEMBERS { + return Err(MeetingStoneRefusal::PartyFull); + } + Ok(()) +} + +enum StoneOpError { + Refused(MeetingStoneRefusal), + /// Untagged, so the Gateway treats the outcome as unknown. + Invalid(String), +} + +impl From for StoneOpError { + fn from(refusal: MeetingStoneRefusal) -> Self { + Self::Refused(refusal) + } +} + +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, + 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 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 bucket = Bucket { + area_id, + team: team_for_race(actor.race), + }; + 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), + 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(); + 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); + } + match_bucket(ctx, bucket).map_err(StoneOpError::Invalid) +} + +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` 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, + bucket: Bucket, + members: &[(u64, u8)], + next_reminder_at: Timestamp, +) { + let row = MeetingStoneParty { + group_id, + area_id: bucket.area_id, + team: bucket.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(|(member, _)| member == guid)) + .collect(); + for guid in departed { + seeker_rows.character_guid().delete(guid); + } + for &(guid, class) in members { + upsert_seeker(ctx, guid, bucket, class, group_id); + } + for &(guid, _) in members { + push_queue(ctx, guid, bucket.area_id, queue_status::JOINED_QUEUE); + } +} + +fn upsert_seeker( + ctx: &ReducerContext, + character_guid: u64, + bucket: Bucket, + class: u8, + group_id: u64, +) { + let row = MeetingStoneSeeker { + character_guid, + area_id: bucket.area_id, + team: bucket.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); + } +} + +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), + ); +} + +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() +} + +/// A solo Seeker leaves with LEAVE_QUEUE, and a queued Party's leader takes the whole Party out. +/// Any other member only hears NONE. +fn leave(ctx: &ReducerContext, actor_guid: u64) -> Result<(), StoneOpError> { + let Some((group, _)) = crate::group::group_with_members(ctx, actor_guid)? else { + let seekers = ctx.db.game_meeting_stone_seeker(); + if seekers.character_guid().find(actor_guid).is_some() { + seekers.character_guid().delete(actor_guid); + push_queue(ctx, actor_guid, 0, queue_status::LEAVE_QUEUE); + } + 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(ctx, group.group_id, queue_status::LEAVE_QUEUE); + } else { + push_queue(ctx, actor_guid, 0, queue_status::NONE); + } + Ok(()) +} + +/// Take a queued Party out of the queue. Every member hears `SETQUEUE(0, status)`. +pub(crate) fn dequeue_party(ctx: &ReducerContext, group_id: u64, status: u8) { + if ctx + .db + .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); +} + +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); + } +} + +/// 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); + for &guid in &members { + push_queue(ctx, guid, 0, queue_status::NONE); + } + dequeue_party_rows(ctx, group_id); +} + +// ================================================================================================= +// The bucket pass +// ================================================================================================= + +/// Match `bucket` until nothing changes: queued Parties fill oldest first, then five solo Seekers +/// form a Party and the Parties fill again. An `Err` is a broken durable relationship. +pub(crate) fn match_bucket(ctx: &ReducerContext, bucket: Bucket) -> Result<(), String> { + loop { + let (mut solos, parties) = read_bucket(ctx, bucket); + 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 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, +) -> (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)) + .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) +} + +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 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, + 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); + 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, plan); + upsert_seeker( + ctx, + joiner.character_guid, + bucket, + joiner.class, + party.group_id, + ); + } +} + +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. The leader hears MEMBER_ADDED before the +/// Party exists, then both hear JOINED. +fn form_party( + ctx: &ReducerContext, + bucket: Bucket, + 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, + 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, plan); + queue_party( + ctx, + group_id, + bucket, + &[ + (leader.character_guid, leader.class), + (member.character_guid, member.class), + ], + next_reminder_at, + ); + Ok(()) +} + +fn plan_stone_add( + ctx: &ReducerContext, + joiner: &MeetingStoneSeeker, + target: JoinTarget, +) -> Result { + crate::group::plan_join(ctx, joiner.character_guid, target).map_err(|error| { + format!( + "stone add of {} to {target:?} failed: {error:?}", + joiner.character_guid + ) + }) +} + +/// 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() + .character_guid() + .delete(joiner.character_guid); + let joined = plan.apply(ctx); + crate::group::advance_group_revision(ctx, joined.group_id, !joined.formed, true); + joined.group_id +} + +// ================================================================================================= +// Group core hooks +// ================================================================================================= + +/// `character_guid` joined `group_id` through an accepted invite. A solo Seeker leaves the queue, +/// with LEAVE_QUEUE unless the Party is queued for the same area. A queued Party gains a Seeker, +/// completes at five, and otherwise rematches, because the new member moves the Open Roles. +pub(crate) fn party_joined( + ctx: &ReducerContext, + group_id: u64, + 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) +} + +#[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, after the member row is gone and before the +/// roster list (cm:Groups/Group.cpp:413-476). Returns the bucket to rematch, if any. +/// +/// - Left, same leader: the leaver hears NONE, the rest PARTY_MEMBER_LEFT_LFG, the Party stays. +/// - Left, new leader: the leaver hears NONE and the Party leaves with LEAVE_QUEUE. +/// - Kicked: the rest hear PARTY_MEMBER_REMOVED_PARTY_REMOVED and the Party leaves with +/// LEAVE_QUEUE. The kicked Character, if it has a World Session, hears +/// LOOKING_FOR_NEW_PARTY_IN_QUEUE and waits alone. +pub(crate) fn party_left(ctx: &ReducerContext, departed: PartyDeparture) -> Option { + let party = ctx + .db + .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); + // A kicked playerbot or offline member only leaves, as in cmangos (`if (player)`). + if !has_live_claim(ctx, guid) { + return None; + } + 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 nobody waits on alone. +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 +// ================================================================================================= + +/// 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() { + 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 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(); + 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)), + }); +} + +/// `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 + .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(()) + ); + } + } + + /// 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/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 41adbf9f2..e34f94df0 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"], @@ -1176,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..e2eb1d15f --- /dev/null +++ b/module/tests/meeting_stone_matching.rs @@ -0,0 +1,1214 @@ +//! 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:?}" + ); + } +} + +/// 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] +#[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" + ); +} 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" + ); +}