Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1254,7 +1254,7 @@ _Avoid_: client patch, distribution, release bundle, player package
The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait.

**Protocol Family**:
A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store.
A group of client opcodes one Gateway handler owns, with the Store traits that handler calls. The opcode ownership table selects that handler once. Each handler implements `ProtocolFamily`, reads or changes shared protocol state, and returns its outbound messages. `WorldStore` holds all Store traits for the World Session; each handler names only the Stores it needs.
_Avoid_: family (unqualified), domain

**Store**:
Expand Down
2 changes: 1 addition & 1 deletion CORE_TERMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ _Avoid_: SDK, plugin API, public API, allowlist
The interface where session or protocol handling hands work to durable state, expressed as a trait so tests can substitute the far side. Not the Shard Boundary, not any arbitrary trait.

**Protocol Family**:
A group of client opcodes one Gateway handler owns, with the Store trait that handler calls (for example the vendor family and `VendorActionStore`). `WorldStore` is the umbrella over every family; a handler takes only its family's Store.
A group of client opcodes one Gateway handler owns, with the Store traits that handler calls. The opcode ownership table selects that handler once. Each handler implements `ProtocolFamily`, reads or changes shared protocol state, and returns its outbound messages. `WorldStore` holds all Store traits for the World Session; each handler names only the Stores it needs.
_Avoid_: family (unqualified), domain

**Store**:
Expand Down
56 changes: 30 additions & 26 deletions docs/recipes/add-an-opcode.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,25 +86,25 @@ or another family also calls is an `impl Coordinator` method in `gateway/src/std
in the Coordinator cache by a unique index. The SDK cache has no other index, and a whole-table
`iter()` holds the lock the pump needs.

## 7. Write the family dispatcher

File: `gateway/src/world/handlers/<family>.rs`, declared in `gateway/src/world/handlers/mod.rs` with
a `pub(crate) use` of its public items. For an opcode in an existing family, add an arm to that
family's dispatcher and skip to step 10.

- A Store trait, `<Family>ActionStore`. Each method's doc says whether it is a Durable Request or a
Durable Read and on which Shard. A method that acts for a Character takes `actor: Actor`. Steps 5
and 6 implement it.
- The dispatcher resolves the session's Character to `Option<Actor>` once, with `Actor::new`. When
it is `None`, make no Durable Request. The client usually gets no answer.
- When a Store call fails, match on `classify(&error)`. A `DurableFailure::Refusal { reason }` gets
the family's answer to the client and the session continues. A `DurableFailure::TransportLoss`
returns the `Err`, which ends the World Session.
- A player struct with the session facts the family needs, and an outcome enum with
`Handled { outbound }` and `PassThrough(msg)`.
- `dispatch_<family>_action(store, player, msg)` matches the family's `ClientOpcodeMessage` variants
and passes every other message through.
- A local Fake of the Store and the family's tests in the same file.
## 7. Write the Protocol Family handler

File: `gateway/src/world/handlers/<family>.rs`, declared and re-exported in
`gateway/src/world/handlers/mod.rs`. For an existing Protocol Family, add its request arm and
ownership entry, then continue with the packet and tests.

- Keep Durable Reads and Durable Requests on the Protocol Family's Store trait. A method that acts for a
Character takes `actor: Actor`. Steps 5 and 6 implement it.
- Implement `ProtocolFamily<St>` on the handler. Its `handle` method takes the Store, shared
`ProtocolSession`, and `ProtocolRequest`, and returns `ProtocolReply`.
- Resolve the Character with `session.actor()`. Before world entry it is `None`; preserve the
opcode's specified reply without inventing an Actor.
- Classify Store errors with `classify`. A Refusal gets the Protocol Family's reply. A Transport Loss
returns `Err`, which ends the World Session.
- Update local protocol state after the Durable Request succeeds and return outbound messages in
their required order. The World Session queues them. Existing operations that must follow that
queue step, such as taxi arming, use `WorldSessionAction`.
- Keep a local Fake and test through `handle`. The shared `ProtocolSession` needs no socket or
routed Store. Do not add a separate context for each Protocol Family, dispatch outcome, or pass-through result.

## 8. Join the family to `WorldStore`

Expand All @@ -113,15 +113,19 @@ family's dispatcher and skip to step 10.
- File: `gateway/src/world/test_support/world_fake/<family>.rs`. Implement the trait for `WorldFake`,
the shared Fake that implements every family, and keep the family's state in its own struct there.

## 9. Link the dispatcher into the chain
## 9. Declare opcode ownership

File: `gateway/src/world/mod.rs`. In `dispatch`, add a `match dispatch_<family>_action(...)` link
that sends `Handled` messages and returns, and passes `PassThrough` on. Re-export the family's items
in the `pub(crate) use handlers::{...}` list.
File: `gateway/src/world/routing.rs`. Add the opcode type to its owner's entry in
`protocol_families!`. The declaration supplies both the ownership table and the handler call.
A new Protocol Family needs one entry with its handler type and opcodes.

The order of the links matters. The first dispatcher that matches an opcode consumes it, and the
links before yours must not claim your opcodes. A message no link takes reaches the debug log
`world: ignoring`.
The ownership test refuses duplicate entries. Declaration order has no routing meaning. A handler
receives only its owned requests; an unexpected opcode is a routing defect. If one opcode serves
several interactions, its owner selects the operation from the payload or Durable Read. GameObject
use and chat are examples.

The reader supplies the numeric opcode, including requests whose packet layout needs a custom
reader. Movement coalescing and the Chat Flood Limiter run before handler dispatch.

## 10. Build the packet

Expand Down
19 changes: 9 additions & 10 deletions gateway/src/stdb/subscriptions_meeting_stone_durable_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ 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, MeetingStoneActionStore,
MeetingStonePlayer, Outbound, WorldSessionToken,
MeetingStone, MeetingStoneActionStore, Outbound, ProtocolFamily, ProtocolSession,
WorldSessionToken,
};
use lyracore_shared::meeting_stone::queue_status::{JOINED_QUEUE, NONE};
use std::sync::mpsc::Receiver;
Expand Down Expand Up @@ -106,14 +106,13 @@ fn use_stone(session: &Coordinator, guid: u64, stone: u64) -> Vec<Outbound> {
}

fn send(session: &Coordinator, guid: u64, msg: ClientOpcodeMessage) -> Vec<Outbound> {
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"),
}
MeetingStone::handle(
session,
&mut ProtocolSession::in_world(guid, guid),
msg.into(),
)
.unwrap()
.outbound
}

/// The meeting stone messages in `outbound`, running each relay job the way the session writer
Expand Down
21 changes: 9 additions & 12 deletions gateway/src/world/auction_tests.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
//! The auction house: the views through their dispatcher, and the raw browse path over an
//! encrypted World Session.
//! Auction replies through the Protocol Family and encrypted World Session.

use super::handlers::{store_with, InMemoryAuctionActions};
use super::handlers::{store_with, Auction, InMemoryAuctionActions};
use super::*;

const PLAYER: AuctionActionPlayer = AuctionActionPlayer { self_guid: Some(1) };

/// Dispatch one auction message and return the packets the session would send for it.
fn run(actions: &InMemoryAuctionActions, msg: impl Into<ClientOpcodeMessage>) -> Vec<Outbound> {
match dispatch_auction_action(actions, PLAYER, msg.into()).unwrap() {
AuctionActionOutcome::Handled { outbound } => outbound,
AuctionActionOutcome::PassThrough(_) => {
panic!("the auction dispatcher passed the message on")
}
}
Auction::handle(
actions,
&mut ProtocolSession::in_world(7, 1),
ProtocolRequest::Message(msg.into()),
)
.unwrap()
.outbound
}

/// The one typed packet a request answered with.
Expand Down
25 changes: 12 additions & 13 deletions gateway/src/world/bank_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! Bank opcodes, run through `handle_bank` against a Fake that holds only the Bank and Npc Stores
//! the handler is bounded on.
//! Bank replies and durable outcomes through the Bank and Npc Stores.

use super::handlers::{handle_bank, BankStore, NpcStore};
use super::handlers::{Bank, BankStore, NpcStore};
use super::*;
use crate::stdb::ReducerCallError;
use std::collections::BTreeSet;
Expand Down Expand Up @@ -86,24 +85,24 @@ impl BankStore for BankFake {
}
}

/// Send `msg` as Character 1 of account 7 and return what the handler sent, in order.
/// Phase 5 retargets only this helper.
/// Handle `msg` as Character 1 of Account 7 and return the replies in order.
fn run(store: &BankFake, msg: impl Into<ClientOpcodeMessage>) -> Vec<ServerOpcodeMessage> {
let (tx, rx) = SessionTx::with_depth(0);
let mut conn = in_world_conn(7, 1);
let passed_on = handle_bank(&tx, store, &mut conn, msg.into()).unwrap();
assert!(passed_on.is_none(), "the bank family owns this opcode");
drain_outbound(&rx)
let mut session = ProtocolSession::in_world(7, 1);
let reply = Bank::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap();
outbound_messages(reply.outbound)
}

/// What the handler returns when the Store loses the transport.
fn run_transport_lost(msg: impl Into<ClientOpcodeMessage>) -> Result<Option<ClientOpcodeMessage>> {
fn run_transport_lost(msg: impl Into<ClientOpcodeMessage>) -> Result<ProtocolReply> {
let store = BankFake {
transport_lost: true,
..Default::default()
};
let (tx, _rx) = SessionTx::with_depth(0);
handle_bank(&tx, &store, &mut in_world_conn(7, 1), msg.into())
Bank::handle(
&store,
&mut ProtocolSession::in_world(7, 1),
ProtocolRequest::Message(msg.into()),
)
}

fn kinds(sent: &[ServerOpcodeMessage]) -> String {
Expand Down
67 changes: 17 additions & 50 deletions gateway/src/world/cast_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! session sees: the ordered batch, the ranged auto-repeat state it carries between casts, and
//! the effect of each cancellation.

use super::family::{ProtocolFamily, ProtocolSession};
use super::handlers::InMemoryCasts;
use super::*;
use std::sync::Mutex;
Expand All @@ -11,52 +12,18 @@ use std::sync::Mutex;
/// contract under test.
const OP_CAST_RESULT: u16 = 0x0130;

/// What the session keeps between two casts.
struct Caster {
self_guid: Option<u64>,
ranged_repeat: bool,
}

impl Caster {
fn in_world() -> Self {
Self {
self_guid: Some(1),
ranged_repeat: false,
}
}

fn at_character_select() -> Self {
Self {
self_guid: None,
ranged_repeat: false,
}
}
}

/// Dispatch one cast message, apply the session transition it asks for, and return the packets
/// the session would send for it.
fn run(
store: &InMemoryCasts,
caster: &mut Caster,
session: &mut ProtocolSession,
msg: impl Into<ClientOpcodeMessage>,
) -> Vec<Outbound> {
let player = CastPlayer {
account_id: 7,
self_guid: caster.self_guid,
ranged_repeat: caster.ranged_repeat,
};
match dispatch_cast(store, player, msg.into()).unwrap() {
CastOutcome::Handled {
transition,
outbound,
} => {
if let Some(armed) = transition.ranged_repeat {
caster.ranged_repeat = armed;
}
outbound
}
CastOutcome::PassThrough(_) => panic!("the cast dispatcher passed the message on"),
}
handlers::Cast::handle(store, session, msg.into().into())
.unwrap()
.outbound
}

fn repeating(session: &ProtocolSession) -> bool {
matches!(&session.state, WorldState::InWorld(world) if world.ranged_repeat)
}

/// `SpellCastTargets` carrying a UNIT target (the client's selected mob).
Expand All @@ -79,7 +46,7 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ
let store = InMemoryCasts::instant();
let sent = run(
&store,
&mut Caster::in_world(),
&mut ProtocolSession::in_world(7, 1),
CMSG_CAST_SPELL {
spell: 100,
targets: unit_targets(77),
Expand Down Expand Up @@ -119,7 +86,7 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() {
ranged_auto_repeat: vec![75, 5019],
..InMemoryCasts::instant()
};
let mut caster = Caster::in_world();
let mut caster = ProtocolSession::in_world(7, 1);
let sent = run(
&store,
&mut caster,
Expand Down Expand Up @@ -152,7 +119,7 @@ fn auto_shot_intercept_starts_the_ranged_attack_instead_of_casting() {
),
}
assert!(
caster.ranged_repeat,
repeating(&caster),
"spell {spell}: the session arms the loop"
);
assert_eq!(
Expand All @@ -171,7 +138,7 @@ fn a_cast_before_entering_the_world_makes_no_request_and_does_not_fail() {
let store = InMemoryCasts::instant();
let sent = run(
&store,
&mut Caster::at_character_select(),
&mut ProtocolSession::new(7, "TESTER".into()),
CMSG_CAST_SPELL {
spell: 100,
targets: unit_targets(77),
Expand All @@ -195,7 +162,7 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack()
ranged_auto_repeat: vec![75],
..InMemoryCasts::instant()
};
let mut caster = Caster::in_world();
let mut caster = ProtocolSession::in_world(7, 1);
run(
&store,
&mut caster,
Expand All @@ -209,7 +176,7 @@ fn cancelling_auto_repeat_still_tears_the_ranged_loop_down_through_stop_attack()
// The cancel sends no ack of its own (the on_delete relay does).
let sent = run(&store, &mut caster, CMSG_CANCEL_AUTO_REPEAT_SPELL {});
assert!(sent.is_empty());
assert!(!caster.ranged_repeat);
assert!(!repeating(&caster));
assert!(store.engaged.lock().unwrap().is_empty());
}

Expand All @@ -221,7 +188,7 @@ fn cancel_aura_removes_the_aura_the_wire_spell_id_names() {
};
let sent = run(
&store,
&mut Caster::in_world(),
&mut ProtocolSession::in_world(7, 1),
CMSG_CANCEL_AURA { id: 5555 },
);
assert!(sent.is_empty());
Expand All @@ -236,7 +203,7 @@ fn cancel_cast_drops_the_pending_cast_of_the_caller_only() {
};
let sent = run(
&store,
&mut Caster::in_world(),
&mut ProtocolSession::in_world(7, 1),
CMSG_CANCEL_CAST { id: 133 },
);
assert!(sent.is_empty());
Expand Down
36 changes: 21 additions & 15 deletions gateway/src/world/combat_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! Targeting and sheathing, run through `handle_combat` against a Fake that holds only the Combat
//! Store the handler is bounded on.
//! Targeting and sheathing through the Combat Store.

use super::handlers::{handle_combat, CombatStore};
use super::handlers::{Combat, CombatStore};
use super::*;
use crate::stdb::ReducerCallError;
use std::collections::BTreeMap;
Expand Down Expand Up @@ -61,11 +60,11 @@ impl CombatStore for CombatFake {
}
}

/// Send `msg` as Character 1 of account 7. Phase 5 retargets only this helper.
/// Handle `msg` as Character 1 of Account 7.
fn run(store: &CombatFake, msg: impl Into<ClientOpcodeMessage>) {
let mut conn = in_world_conn(7, 1);
let passed_on = handle_combat(store, &mut conn, msg.into()).unwrap();
assert!(passed_on.is_none(), "the combat family owns this opcode");
let mut session = ProtocolSession::in_world(7, 1);
let reply = Combat::handle(store, &mut session, ProtocolRequest::Message(msg.into())).unwrap();
assert!(reply.outbound.is_empty());
}

#[test]
Expand Down Expand Up @@ -106,18 +105,25 @@ fn sheathe() -> CMSG_SETSHEATHED {
#[test]
fn transport_loss_ends_the_world_session() {
let store = CombatFake::failing(ReducerCallError::transport_lost("gw_set_sheathed"));
let mut conn = in_world_conn(7, 1);
assert!(handle_combat(&store, &mut conn, sheathe().into()).is_err());
let mut session = ProtocolSession::in_world(7, 1);
assert!(Combat::handle(
&store,
&mut session,
ProtocolRequest::Message(sheathe().into())
)
.is_err());
}

#[test]
fn refusal_is_ignored_and_the_world_session_continues() {
let store = CombatFake::failing(ReducerCallError::refused("gw_set_sheathed", "not in world"));
let mut conn = in_world_conn(7, 1);
let passed_on = handle_combat(&store, &mut conn, sheathe().into()).unwrap();
assert!(
passed_on.is_none(),
"the combat family still owns the opcode"
);
let mut session = ProtocolSession::in_world(7, 1);
let reply = Combat::handle(
&store,
&mut session,
ProtocolRequest::Message(sheathe().into()),
)
.unwrap();
assert!(reply.outbound.is_empty());
assert_eq!(store.sheath_of(1), None);
}
Loading
Loading