Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d9d7198
docs(tickets): refresh the meeting stone tickets on current main
Mathih13 Oct 2, 2026
8c2684e
feat(shared): add the meeting stone wire contract
Mathih13 Oct 2, 2026
6f9a862
feat(importer): keep a meeting stone's level range and dungeon area
Mathih13 Oct 2, 2026
0ec6508
feat(module): queue and leave at a meeting stone
Mathih13 Oct 2, 2026
97ebb05
feat(gateway): answer meeting stone join, leave and status
Mathih13 Oct 2, 2026
66a97ca
docs(tickets): correct the T1 tripwire and visibility notes
Mathih13 Oct 2, 2026
a725364
feat(module): match meeting stone Seekers by Open Role
Mathih13 Oct 2, 2026
3cc52a9
docs(tickets): correct three T3 facts
Mathih13 Oct 2, 2026
5f34009
feat(party): relay Realm-core Roster Revisions to World Shard mirrors
Mathih13 Oct 3, 2026
8bcd593
Merge remote-tracking branch 'origin/feat/meeting-stones-t3' into mee…
Mathih13 Oct 3, 2026
5da4949
Merge remote-tracking branch 'origin/feat/meeting-stones-t2' into mee…
Mathih13 Oct 3, 2026
042833b
Merge remote-tracking branch 'origin/main' into meeting-stones-int
Mathih13 Oct 3, 2026
8b5be8d
feat(meeting-stones): prove the realm-wide queue and relay stone even…
Mathih13 Oct 3, 2026
702f090
chore(tickets): drop the meeting stone planning tickets
Mathih13 Oct 3, 2026
1c36b3a
fix(meeting-stones): never commit a half-done Stone Add
Mathih13 Oct 3, 2026
9413cd2
Merge remote-tracking branch 'origin/main' into meeting-stones-int
Mathih13 Oct 3, 2026
67e7221
test(meeting-stones): name a timed-out wait in the stone event assertion
Mathih13 Oct 3, 2026
4bf6bc2
refactor(meeting-stone): trim comments and split matching
Mathih13 Oct 3, 2026
9d9cf7b
refactor(group): let a join plan apply only once
Mathih13 Oct 3, 2026
1e70e33
ci(durable): run the meeting stone and roster relay Gateway tests
Mathih13 Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/module-durable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reason>` 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

Expand Down
32 changes: 32 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**:
Expand Down
10 changes: 10 additions & 0 deletions crates/lyracore-shared/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
46 changes: 43 additions & 3 deletions crates/lyracore-shared/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/lyracore-shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
191 changes: 191 additions & 0 deletions crates/lyracore-shared/src/meeting_stone.rs
Original file line number Diff line number Diff line change
@@ -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> {
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<u8> {
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:?}");
}
}
}
Loading
Loading