From c79e2e7ca33ba057a04ce85bad5b5808bf9e01c7 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 19 Sep 2026 17:09:09 +0700 Subject: [PATCH] wire-order dispatch, and eleven windows register their own handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the net drain dispatches a frame's packets in wire order across the migration seam: a claimed packet lands the run before it first, so two red lines from different families print in the order the server sent them (2306) - the mailbox and the player trade register their own packet handlers and leave the dispatch match, each with a session-end listener that clears what dies with the socket (2306) - nine windows — binder, duel, GM ticket, guild, instance lockouts, petition, social, summon, tabard vendor — register theirs: 41 kinds leave the match, and the table gains two checks the match gave for free (2312) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/cvars.rs | 2 +- crates/benilla-app/src/net/apply.rs | 293 +----------------- crates/benilla-app/src/net/apply/params.rs | 40 +-- .../benilla-app/src/net/apply/seam_tests.rs | 132 ++++++++ crates/benilla-app/src/net/apply/session.rs | 40 +-- crates/benilla-app/src/net/handlers.rs | 240 ++++++++++++-- crates/benilla-app/src/ui_binder.rs | 42 ++- crates/benilla-app/src/ui_duel.rs | 93 +++++- crates/benilla-app/src/ui_gm_ticket.rs | 91 +++++- crates/benilla-app/src/ui_guild/mod.rs | 127 +++++++- crates/benilla-app/src/ui_instance.rs | 60 +++- crates/benilla-app/src/ui_mail/mod.rs | 8 +- .../src/{net/apply/mail.rs => ui_mail/net.rs} | 157 ++++++++-- crates/benilla-app/src/ui_petition/mod.rs | 113 ++++++- crates/benilla-app/src/ui_social/mod.rs | 58 +++- crates/benilla-app/src/ui_summon.rs | 65 +++- crates/benilla-app/src/ui_tabard.rs | 39 +++ .../src/{ui_trade.rs => ui_trade/mod.rs} | 41 +-- .../{net/apply/trade.rs => ui_trade/net.rs} | 94 +++++- 19 files changed, 1238 insertions(+), 497 deletions(-) create mode 100644 crates/benilla-app/src/net/apply/seam_tests.rs rename crates/benilla-app/src/{net/apply/mail.rs => ui_mail/net.rs} (61%) rename crates/benilla-app/src/{ui_trade.rs => ui_trade/mod.rs} (98%) rename crates/benilla-app/src/{net/apply/trade.rs => ui_trade/net.rs} (87%) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 0a02335d..636f9106 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -845,7 +845,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // silent when a guildmate logs in, and a whole-image census of the record global `0xc4d3c4` // finds exactly two readers, both inside `SMSG_GUILD_EVENT`'s handler. The knob is // [`crate::ui_guild::GuildMemberNotify`]; the other three conjuncts of the line's display - // condition live on `ui_guild::apply::event`. + // condition live on `ui_guild::net::event`. same("guildMemberNotify", "0"), // The minimap's two zoom indices (1131). Byte-verified 1.12 CVars, both registered `"3"` // (wow-re, at the `RegisterCVar 0x63db90` argument slot). No options row drives these — the diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 252cecb9..1fe60eb6 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -21,7 +21,6 @@ mod combat_log; mod death; mod group; mod loot; -mod mail; mod mount; mod names; mod npc; @@ -29,9 +28,10 @@ mod objects; mod params; mod pet; mod quests; +#[cfg(test)] +mod seam_tests; mod session; mod spells; -mod trade; mod world; use params::{ActionStores, AnimWriters, Catalogs, Clocks, ObjectQueries, Session, WindowStores}; @@ -85,12 +85,12 @@ fn addressed_store<'a>( // ── The per-frame bridge systems ───────────────────────────────────────────────────────────────── -/// The drain: take this frame's events off the channel and run them, **in packet order**, through -/// the two halves of the dispatch (decision 2305) — first the kinds the `match` below still owns, -/// through [`apply_unpeeled`], then the kinds a subsystem has claimed in the handler table -/// ([`super::handlers`]), each handler a one-shot system over this exclusive world. One frame, -/// packet order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the -/// property 0006 built and 2265 said every split must keep. +/// The drain: take this frame's events off the channel and run them **in wire order** — a run of +/// kinds the `match` below still owns goes through [`apply_unpeeled`] as one batch, a kind a +/// subsystem has claimed in the handler table ([`super::handlers`]) runs its handlers in place, +/// each a one-shot system over this exclusive world (decisions 2305, 2306). One frame, packet +/// order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the property +/// 0006 built and 2265 said every split must keep. pub(crate) fn apply_net_updates(world: &mut World) { let events: Vec = world.resource::().0.try_iter().collect(); super::handlers::dispatch(world, events, |world, unclaimed| { @@ -194,7 +194,6 @@ fn apply_unpeeled( area_table, exploration_sounds, current_map, - guild_notify, } = catalogs; // A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp // it *conditionally* can take it by reference and only advance it when they emit. @@ -220,22 +219,16 @@ fn apply_unpeeled( mut death_net, mut group, mut taxi, - mut mail_open, - mut mail_pending, - mut trade_session, mut bank_open, mut bank_errors, mut world_states, - mut duel, - mut social, + social, mut logout, mut mirror_timers, mut pet_bar, mut ui_error_keys, mut page_texts, mut played_time_answer, - mut guild, - mut binder, mut talent_wipe, mut pet_unlearn, mut instance_boot, @@ -246,15 +239,9 @@ fn apply_unpeeled( mut battlefield, mut tutorials, mut battlefield_positions, - mut tabard, mut poi_marker, mut inspect_honor, mut ping, - mut gm_ticket, - mut registrar, - mut petition, - mut summon, - mut instances, } = windows; let Session { mut teleports, @@ -387,14 +374,7 @@ fn apply_unpeeled( &mut death_net, &mut group, &mut taxi, - &mut mail_open, - &mut mail_pending, - &mut trade_session, &mut bank_open, - &mut duel, - &mut social, - &mut guild, - &mut gm_ticket, &mut cooldown_store, &mut pending_transfer, &mut disconnects, @@ -662,62 +642,6 @@ fn apply_unpeeled( extra: None, }); } - SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc), - // Someone is asking to pull us to them (decision 1747). The reference gates this in - // the HANDLER, not the dialog: a dead or ghost player's request is dropped before the - // latch, so it cannot disturb a live question either (`0x5e6194`). The predicate is - // `0x605f30` — **health ≤ 0 OR (is-player AND `PLAYER_FLAGS` ghost bit)**, which is - // both of these accessors and not the one `unit_is_dead` alone would give (a ghost's - // wire health is 1). A self object we have not streamed yet reads as alive: the - // reference's own default (`0x5e6189` sends a NULL object through to the latch). - SessionEvent::SummonRequest { - summoner, - zone, - delay_ms, - } => { - let dead_or_ghost = self_guid - .0 - .and_then(|g| index.0.get(&g)) - .and_then(|e| stores.get(*e).ok()) - .is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost()); - crate::ui_summon::apply::request( - summoner, - zone, - delay_ms, - dead_or_ghost, - real_clock.elapsed_secs_f64(), - &mut summon, - ); - } - // The GM ticket answers (decision 1673). The GETTICKET arm takes EVERY answer, - // including `None` ("you have no ticket") and including an unsolicited one pushed by a - // GM's `.ticket view`/`escalate`/`complete` — they are indistinguishable on the wire - // and want identical handling. - SessionEvent::GmTicket { ticket } => gm_ticket.answer(ticket), - SessionEvent::GmTicketSystemStatus { status } => gm_ticket.answer_queue(status), - // A GM touched the ticket. Value 1 makes the reference re-ask (`0x5e7932`), the same - // leg the create/update success codes take; 2 (closed) and 3 (survey offered) are - // recorded and not acted on — 3 is the survey trigger and that window is deferred. - // vmangos never sends this packet at all, so on our server the arm is dead; cmangos - // makes it the whole notification model, which is why it is parsed rather than dropped. - SessionEvent::GmTicketStatusUpdate { status } => { - crate::ui_gm_ticket::apply::status_update(status, &mut gm_ticket) - } - // The three response codes have no consumer in the shipped 1.12 UI — no event, no - // handler. Logged so a refusal is visible in a session log rather than silent; the - // `ERR_TICKET_*` display path is still unpinned (see `ui_gm_ticket::apply`). - // Create-ok (2) and update-ok (4) make the ENGINE re-ask for the ticket — the - // reference's own `0x5e4479` arm, and the reason the shipped UI needs no handler for - // either opcode. Without it a filed ticket goes unseen until the 10-minute poll. - SessionEvent::GmTicketCreated { response } => { - crate::ui_gm_ticket::apply::write_response("create", response, 2, &mut gm_ticket) - } - SessionEvent::GmTicketUpdated { response } => { - crate::ui_gm_ticket::apply::write_response("update", response, 4, &mut gm_ticket) - } - SessionEvent::GmTicketDeleted { response } => { - crate::ui_gm_ticket::apply::response("delete", response) - } // A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a // question — there is nothing to ask about, so nothing goes on screen (decision 1580; // `crate::ui_talent_wipe`'s header carries why the reference instead re-sends here). @@ -761,24 +685,6 @@ fn apply_unpeeled( SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice), SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes), SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet), - SessionEvent::TabardVendorActivate(vendor) => tabard.open(vendor), - // A saved emblem evicts our guild's cached record (`0x5e715f`): the next query - // anywhere re-fetches it — no event, no packet. - SessionEvent::SaveGuildEmblemResult(result) => { - if tabard.apply_result(result) { - guild.evict_own_identity(); - } - } - SessionEvent::PlayerBound { binder: npc, area } => { - debug!("net: bound to area {area} by {npc:#x}"); - crate::ui_binder::apply::bound( - area, - &mut binder, - &mut ui_error_keys, - area_table.as_deref(), - &mut server_sounds, - ) - } SessionEvent::Proficiency { item_class, subclass_mask, @@ -1054,56 +960,6 @@ fn apply_unpeeled( SessionEvent::ReadyCheckAnswer { guid, ready } => { group.apply_ready_check_answer(guid, ready != 0) } - // ── The duel family (decision 0633): the session mirror + the two DisplayError - // lines the handlers emit inline; the Era events fire off the mirror's edges in - // `ui_duel::feed_duel`, and the countdown ticks in its own system ── - SessionEvent::DuelRequested { - arbiter, - challenger, - } => crate::ui_duel::apply::requested( - &mut duel, - &mut ui_error_keys, - &net_commands, - arbiter, - challenger, - self_guid.0, - social.is_ignored(challenger), - ), - // ── The instance/raid lockout family (decision 1748): four lines the client - // composes itself out of GlobalStrings, and the two-packet latch behind the SELF - // menu's reset row. The lines are QUEUED — resolving them needs the VM, which this - // drain has no access to (decision 0669's split) ── - SessionEvent::RaidInstanceMessage { message } => { - crate::ui_instance::apply::raid_instance_message(&mut instances, message); - } - SessionEvent::InstanceSaveCreated { flag } => { - crate::ui_instance::apply::instance_save_created(&mut instances, flag); - } - SessionEvent::InstanceReset { map } => { - crate::ui_instance::apply::instance_reset(&mut instances, map); - } - SessionEvent::InstanceResetFailed { failure } => { - crate::ui_instance::apply::instance_reset_failed(&mut instances, failure); - } - SessionEvent::UpdateLastInstance { map } => { - crate::ui_instance::apply::update_last_instance(&mut instances, map); - } - SessionEvent::UpdateInstanceOwnership { owns } => { - crate::ui_instance::apply::update_instance_ownership(&mut instances, owns); - } - SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true), - SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false), - SessionEvent::DuelComplete { started } => { - crate::ui_duel::apply::complete(&mut duel, &mut ui_error_keys, started); - } - SessionEvent::DuelWinner { - fled, - winner, - loser, - } => crate::ui_duel::apply::winner(&mut duel, fled, &winner, &loser), - SessionEvent::DuelCountdown { seconds } => { - crate::ui_duel::apply::countdown(&mut duel, seconds); - } // ── The mirror timers (decision 0874): breath / fatigue / feign-death. Pure queue // arms — every meaning (which bar, what colour, what caption, how fast it drains) // is resolved at the UI seam in `ui_mirror`, and the countdown itself is the @@ -1117,94 +973,6 @@ fn apply_unpeeled( SessionEvent::MirrorTimerStop { kind } => mirror_timers .0 .push(crate::ui_mirror::MirrorTimerEdge::Stop { kind }), - // ── The social family (decision 0668): the friend/ignore lists, the `/who` - // answer, and the result codes that print their own chat lines. The lines and the - // Era events fire off the mirror in `ui_social::feed_social` — every one of them - // needs a NAME the drain has no cache handle for. - SessionEvent::FriendList { friends } => { - crate::ui_social::apply::friend_list(&mut social, friends) - } - SessionEvent::IgnoreList { guids } => { - crate::ui_social::apply::ignore_list(&mut social, guids) - } - SessionEvent::FriendStatus(update) => { - crate::ui_social::apply::friend_status(&mut social, update) - } - SessionEvent::WhoResults(results) => crate::ui_social::apply::who(&mut social, results), - // ── The guild family (decision 1257): the identity cache, the roster, and the - // `ERR_GUILD_*` lines the engine composes. Every arm's law lives in - // `ui_guild::apply` beside the state it drives; the guild EVENTS fire off the mirror - // in `ui_guild::feed_guild`, on their edges. - SessionEvent::GuildQueryResponse(response) => { - crate::ui_guild::apply::query_response(&mut guild, response) - } - SessionEvent::GuildRoster(roster) => crate::ui_guild::apply::roster(&mut guild, roster), - // The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the - // four-conjunct display condition on their line — which is why this arm reads - // `social`, the notify knob and our own guid (decision 1589; the condition and its - // byte addresses are on `ui_guild::apply::event`). - SessionEvent::GuildEvent(notice) => crate::ui_guild::apply::event( - &mut guild, - &mut ui_error_keys, - &social, - &guild_notify, - self_guid.0, - notice, - ), - SessionEvent::GuildCommandResult(result) => { - crate::ui_guild::apply::command_result(&mut guild, &mut ui_error_keys, result) - } - SessionEvent::GuildInvite { inviter, guild: g } => { - crate::ui_guild::apply::invite(&mut guild, &mut ui_error_keys, inviter, g) - } - SessionEvent::GuildDecline { name } => { - crate::ui_guild::apply::decline(&mut ui_error_keys, &name) - } - SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut guild, info), - // ── The petition family (decision 1672): founding a guild. The registrar half is an - // NPC window, the charter half is item-bound, and they are two resources for that - // reason — see `ui_petition`'s module doc. - SessionEvent::PetitionShowList(list) => { - // The registrar's two `UNIT_NPC_FLAGS` gates are on LIVE NPC state rather than on - // the packet, so the flags are read here — this pass holds the store. An unstreamed - // guid reads `None` and fails the gate, as the client's own resolve does. - let flags = index - .0 - .get(&list.npc) - .and_then(|e| stores.get(*e).ok()) - .map(|s| s.0.unit_npc_flags()); - crate::ui_petition::apply::show_list(&mut registrar, list, flags) - } - SessionEvent::PetitionShowSignatures(sigs) => { - // An ignored owner suppresses the ENTIRE update — no record fetch, no list, no - // event, no error line (`0x5eeefe`). Consulted before anything else happens. - let ignored = social.is_ignored(sigs.owner); - crate::ui_petition::apply::show_signatures( - &mut petition, - sigs, - ignored, - &net_commands, - ) - } - SessionEvent::PetitionQueryResponse(response) => { - crate::ui_petition::apply::query_response(&mut petition, response) - } - SessionEvent::PetitionSignResults(results) => crate::ui_petition::apply::sign_results( - &mut petition, - &names, - self_guid.0.unwrap_or(0), - results, - &net_commands, - ), - SessionEvent::TurnInPetitionResults { result } => { - crate::ui_petition::apply::turn_in_results(&mut petition, &mut registrar, result) - } - SessionEvent::PetitionDeclined { player } => { - crate::ui_petition::apply::declined(&mut petition, &names, player) - } - SessionEvent::PetitionRenamed(rename) => { - crate::ui_petition::apply::renamed(&mut petition, rename) - } SessionEvent::LootResponse { guid, loot_type, @@ -1865,31 +1633,6 @@ fn apply_unpeeled( opcode, unparseable, } => session::packet_dropped(opcode, unparseable, &mut dropped), - // The mail arc (decision 0544 P1/P2/P3): the inbox/body/send-result arms fill the - // mailbox session the feed reads (`crate::ui_mail`); the arrival pair feeds - // `MailPending` (`HasNewMail()`/the minimap icon). - SessionEvent::MailList { mails } => { - mail::mail_list(mails, &mut mail_open, &net_commands) - } - SessionEvent::SendMailResult { - mail_id, - action, - error, - equip_error, - item, - } => mail::send_mail_result( - mail_id, - action, - error, - equip_error, - item, - &mut mail_open, - &net_commands, - &mut equip_errors, - ), - SessionEvent::MailItemText { text_id, text } => { - mail::mail_item_text(text_id, text, &mut mail_open) - } // The book-page cache (decision 1105) — one page per packet, the whole chain in // answer to the first ask; the reader repaints off it on the next feed. SessionEvent::PageText { @@ -1897,24 +1640,6 @@ fn apply_unpeeled( text, next_page_id, } => page_texts.insert(page_id, text, next_page_id), - SessionEvent::ReceivedMail { seconds } => { - mail::received_mail(seconds, &mut mail_pending, &mail_open, &net_commands) - } - SessionEvent::NextMailTime { seconds } => { - mail::next_mail_time(seconds, &mut mail_pending) - } - // The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close - // state machine, the extended snapshot replaces one side's item/gold — both into the - // `TradeSession` the trade feed (`crate::ui_trade`) reads. - SessionEvent::TradeStatus { status } => trade::trade_status( - status, - &mut trade_session, - &mut ui_error_keys, - &net_commands, - ), - SessionEvent::TradeStatusExtended { state } => { - trade::trade_status_extended(&state, &mut trade_session) - } SessionEvent::WorldStates { scope, states } => { world::world_states(scope, states, &mut world_states) } diff --git a/crates/benilla-app/src/net/apply/params.rs b/crates/benilla-app/src/net/apply/params.rs index 0396037d..ad29e58c 100644 --- a/crates/benilla-app/src/net/apply/params.rs +++ b/crates/benilla-app/src/net/apply/params.rs @@ -160,19 +160,14 @@ pub(crate) struct WindowStores<'w> { pub group: ResMut<'w, crate::ui_party::GroupState>, /// The taxi-map session (decision 0484 phase 1). pub taxi: ResMut<'w, crate::ui_taxi::TaxiState>, - /// The mailbox session + its login-scoped arrival countdown (decision 0544). - pub mail_open: ResMut<'w, crate::ui_mail::MailOpen>, - pub mail_pending: ResMut<'w, crate::ui_mail::MailPending>, - /// The player-trade session (decision 0592). - pub trade_session: ResMut<'w, crate::ui_trade::TradeSession>, /// The bank session and its purchase-refusal queue (decision 0604). pub bank_open: ResMut<'w, crate::ui_bank::BankOpen>, pub bank_errors: ResMut<'w, crate::ui_bank::BankErrors>, /// The world-state table the NPC-text `$w` tokens read. pub world_states: ResMut<'w, crate::world_state::WorldStates>, - /// The duel session (decision 0633). - pub duel: ResMut<'w, crate::ui_duel::DuelState>, - pub social: ResMut<'w, crate::ui_social::SocialState>, + /// Read-only here since 2312 — the social handlers own it (`ui_social::net`); the chat arm + /// still reads the ignore list. + pub social: Res<'w, crate::ui_social::SocialState>, /// The pending logout/quit (decision 0674): the server's response and cancel-ack land here, /// and `crate::ui_logout` turns them into the countdown dialog. pub logout: ResMut<'w, crate::ui_logout::LogoutState>, @@ -189,13 +184,6 @@ pub(crate) struct WindowStores<'w> { /// id; the reader session repaints off it. pub page_texts: ResMut<'w, crate::ui_item_text::PageTexts>, pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>, - /// The guild session (decision 1257) — the identity/roster mirror the seven - /// `SessionEvent::Guild*` arms drive. - pub guild: ResMut<'w, crate::ui_guild::GuildState>, - /// The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM` parks the - /// innkeeper's guid here and `crate::ui_binder` turns it into the CONFIRM_BINDER dialog, - /// whose Accept is the only thing that binds anything. - pub binder: ResMut<'w, crate::ui_binder::BinderState>, /// The class trainer's pending respec question (decision 1580) — the inbound /// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and /// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose Accept is the @@ -213,7 +201,6 @@ pub(crate) struct WindowStores<'w> { pub battlefield: ResMut<'w, crate::ui_battlefield::Battlefield>, pub tutorials: ResMut<'w, crate::tutorial::Tutorials>, pub battlefield_positions: ResMut<'w, crate::ui_battlefield_positions::BattlefieldPositions>, - pub tabard: ResMut<'w, crate::ui_tabard::TabardOpen>, /// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so /// "where you were standing when the guard told you" is the client's to remember /// (`crate::poi_marker`). @@ -225,24 +212,6 @@ pub(crate) struct WindowStores<'w> { /// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world /// point here and the minimap renderer derives everything else from it. pub ping: ResMut<'w, crate::minimap::MinimapPing>, - /// The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held ticket here - /// and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket` diffs on: the Help window - /// re-polls every 10 minutes and an unchanged answer still has to re-fire `UPDATE_TICKET`. - pub gm_ticket: ResMut<'w, crate::ui_gm_ticket::GmTicketState>, - /// The guild-charter session (decision 1672) — two resources because only the registrar - /// half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and - /// `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must survive - /// walking away from the registrar. - pub registrar: ResMut<'w, crate::ui_petition::GuildRegistrarState>, - pub petition: ResMut<'w, crate::ui_petition::PetitionState>, - /// The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the summoner's - /// guid, zone and expiry here and `crate::ui_summon` turns it into the CONFIRM_SUMMON dialog, - /// whose Accept is the only packet in the flow. - pub summon: ResMut<'w, crate::ui_summon::SummonState>, - /// The instance-lockout bookkeeping (decision 1748) — four of its six packets queue a - /// GlobalStrings-templated chat line here for `crate::ui_instance` to resolve against the - /// VM, and two write the latch behind `CanShowResetInstances()`. - pub instances: ResMut<'w, crate::ui_instance::InstanceState>, } /// The action bar's family: the cast/cooldown state and every error queue the red line drains @@ -369,7 +338,4 @@ pub(crate) struct Catalogs<'w> { pub exploration_sounds: Option>, /// The map the guard's directions marker is stamped with. pub current_map: Option>, - /// Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's display - /// condition reads; see `ui_guild::apply::event` for the four conjuncts. - pub guild_notify: Res<'w, crate::ui_guild::GuildMemberNotify>, } diff --git a/crates/benilla-app/src/net/apply/seam_tests.rs b/crates/benilla-app/src/net/apply/seam_tests.rs new file mode 100644 index 00000000..b0fde6f1 --- /dev/null +++ b/crates/benilla-app/src/net/apply/seam_tests.rs @@ -0,0 +1,132 @@ +//! **A run boundary lands exactly what one run lands** (decision 2306). The drain dispatches in +//! wire order across the migration's seam, so a claimed packet arriving between two of the +//! match's own ends the match's run — a command flush — mid-frame. The match keeps three +//! intra-drain accumulators (`pending`, `StagedModes`, `SpeedStage`) precisely because its spawns +//! are deferred; each is *staged, else live*, so what a boundary flushes the next run reads off +//! the component. This drives the real drain on the built client both ways and compares. + +use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH}; +use benilla_protocol::messages::{ObjectType, SpeedKind, SplineMode}; +use benilla_protocol::{EntityKind, MoveSpeeds, ObjectFields, SessionEvent}; +use bevy::prelude::*; + +use crate::net::{GuidIndex, NetEvents, ObjectStore, UnitMoveModes, UnitSpeeds}; + +const GUID: u64 = 0xF130_0000_1234_0001; + +fn create() -> SessionEvent { + SessionEvent::ObjectCreate { + guid: GUID, + kind: EntityKind::Unit, + display_id: None, + position: [1.0, 2.0, 3.0], + orientation: 0.0, + scale: 1.0, + speeds: Some(MoveSpeeds { + walk: 2.5, + run: 7.0, + run_back: 4.5, + swim: 4.7, + swim_back: 2.5, + turn_rate: std::f32::consts::PI, + }), + mover: None, + transport_progress: None, + transport: None, + spline: None, + fields: ObjectFields::from_pairs(&[ + (FIELD_UNIT_HEALTH, 100), + (FIELD_UNIT_MAXHEALTH, 100), + (FIELD_UNIT_LEVEL, 9), + ]) + .into_created(ObjectType::Unit), + } +} + +/// What the object layer does to a unit born this frame: a values delta, a speed change, a +/// granted mode, a second delta — one of each accumulator, and `pending` twice. +fn updates() -> Vec { + vec![ + SessionEvent::ObjectValues { + guid: GUID, + fields: ObjectFields::from_pairs(&[(FIELD_UNIT_HEALTH, 60)]), + }, + SessionEvent::SpeedChanged { + guid: GUID, + kind: SpeedKind::Run, + speed: 14.0, + }, + SessionEvent::SplineMoveMode { + guid: GUID, + mode: SplineMode::Root, + apply: true, + }, + SessionEvent::ObjectValues { + guid: GUID, + fields: ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 10)]), + }, + ] +} + +/// A kind the mailbox has claimed — any table kind would do; this one needs no open window. +fn claimed() -> SessionEvent { + SessionEvent::NextMailTime { seconds: -86400.0 } +} + +/// One frame of the real drain over `events`; what the unit ended up as. +fn drained(events: Vec) -> (Vec<(u16, u32)>, MoveSpeeds, UnitMoveModes) { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetEvents(rx)); + for ev in events { + tx.send(ev).unwrap(); + } + let world = app.world_mut(); + super::apply_net_updates(world); + let e = *world + .resource::() + .0 + .get(&GUID) + .expect("the create indexed the unit"); + let fields = world + .get::(e) + .expect("the store landed") + .0 + .raw_fields() + .collect(); + let speeds = world.get::(e).expect("the speeds landed").0; + let modes = *world.get::(e).expect("the modes landed"); + (fields, speeds, modes) +} + +#[test] +fn a_claimed_packet_between_a_create_and_its_updates_changes_nothing() { + // One run: the create and everything after it in one batch of the match, the claimed packet + // last — what the two-halves dispatch 2305 landed would have made of either frame. + let mut one_run = vec![create()]; + one_run.extend(updates()); + one_run.push(claimed()); + // The seam at its worst: a claimed packet after the create and between every update, so + // each accumulator is flushed and re-seeded from the live component. + let mut split = vec![create()]; + for ev in updates() { + split.push(claimed()); + split.push(ev); + } + + let (fields, speeds, modes) = drained(one_run); + let (split_fields, split_speeds, split_modes) = drained(split); + + // The frame's own meaning, so the comparison below cannot pass on two empty results. + assert!(fields.contains(&(FIELD_UNIT_HEALTH, 60)), "{fields:?}"); + assert!(fields.contains(&(FIELD_UNIT_LEVEL, 10)), "{fields:?}"); + assert!(fields.contains(&(FIELD_UNIT_MAXHEALTH, 100)), "{fields:?}"); + assert_eq!(speeds.run, 14.0); + assert_eq!(speeds.walk, 2.5); + assert!(modes.rooted()); + + assert_eq!(split_fields, fields); + assert_eq!(split_speeds.run, speeds.run); + assert_eq!(split_speeds.walk, speeds.walk); + assert_eq!(split_modes, modes); +} diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index 1544b7f6..f8b1833c 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -13,7 +13,6 @@ use crate::names::NameCache; use crate::ui_chat::ChatLog; use crate::ui_gossip::GossipState; use crate::ui_loot::LootState; -use crate::ui_mail::MailOpen; use crate::ui_merchant::MerchantOpen; use crate::ui_quest::QuestGiver; use crate::ui_quest_log::QuestLog; @@ -191,14 +190,7 @@ pub(super) fn disconnected( death_net: &mut crate::death::DeathNet, group: &mut crate::ui_party::GroupState, taxi: &mut TaxiState, - mail: &mut MailOpen, - mail_pending: &mut crate::ui_mail::MailPending, - trade: &mut crate::ui_trade::TradeSession, bank: &mut crate::ui_bank::BankOpen, - duel: &mut crate::ui_duel::DuelState, - social: &mut crate::ui_social::SocialState, - guild: &mut crate::ui_guild::GuildState, - gm_ticket: &mut crate::ui_gm_ticket::GmTicketState, cooldowns: &mut crate::cooldowns::Cooldowns, pending_transfer: &mut PendingTransfer, disconnects: &mut MessageWriter, @@ -258,38 +250,10 @@ pub(super) fn disconnected( quest_share.clear_session(); group.clear_session(); taxi.clear_session(); - mail.clear_session(); - // The arrival countdown is login-scoped (decision 0544 P3): a fresh login re-queries - // `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing carries over across a reconnect. - *mail_pending = crate::ui_mail::MailPending::default(); - // An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade. - trade.clear_session(); // The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker. bank.clear_session(); - // A pending challenge, a running duel, and its countdown all die with the socket - // (decision 0633) — the server drops the duel too (`Player::DuelComplete(DUEL_FLED)` on - // logout), and a stale arbiter guid would make the next AcceptDuel echo a dead object. - *duel = crate::ui_duel::DuelState::default(); - // The friend/ignore lists and the last `/who` are session state too (decision 0668): the - // server re-pushes both lists at the next login, and a stale ignore list would silence the - // wrong guids after a reconnect renumbers nothing but re-streams everything. The `/who` sort - // chain is the one thing that survives — it is per-PROCESS in the reference, not per-login - // (decision 2030), which is why this is a `clear_session` and not a `default()`. - social.clear_session(); - // The guild session is login-scoped the same way (decision 1257) — and more strictly, because - // the next login may be a *different character*, whose guild id, rank, rights and roster share - // nothing with this one's. The identity cache goes too: it is keyed by guild id, so it would - // survive correctly, but the reference's own is backed by `guildcache.wdb` and re-primed - // lazily, and keeping a cache alive across a socket only to save one query is not worth the - // one wrong name a renamed guild would show. - *guild = crate::ui_guild::GuildState::default(); - // The GM ticket is login-scoped too (decision 1673), and for a sharper reason than most: the - // ticket belongs to the CHARACTER, and the next login may be a different one. Its answer - // counters go with it, so the first `SMSG_GMTICKET_GETTICKET` of the new session re-fires - // `UPDATE_TICKET` rather than being diffed away against the old character's answer count. - gm_ticket.clear_session(); - // The cooldown list is session-scoped for the same reason and had been missing from this - // sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown + // The cooldown list is session-scoped — the next login may be a different character — and + // had been missing from this sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown // still running at every world entry and `seed_initial` APPENDS, so a list that outlives the // socket answers the old session's records: a second login on the same character reads its // own stale copy over the wire's fresh remainder, and a login on a different character diff --git a/crates/benilla-app/src/net/handlers.rs b/crates/benilla-app/src/net/handlers.rs index 96b54742..605c1a3f 100644 --- a/crates/benilla-app/src/net/handlers.rs +++ b/crates/benilla-app/src/net/handlers.rs @@ -17,13 +17,16 @@ //! any split: one frame, packet order, before anything else runs. //! //! **The migration.** The dispatch `match` in `apply.rs` still owns every kind no subsystem has -//! claimed. One drain runs in two halves: the unclaimed events through the match first, in packet -//! order, then the claimed ones through the table, in packet order — so a window handler always -//! sees this frame's object updates, whatever the interleaving. A kind is owned by exactly one of -//! the two, checked on the built app by `every_session_event_kind_has_one_owner`; the one -//! exception is [`BROADCAST`], a session-end the match still handles and peeled windows also listen -//! to, which reaches both. When the last family leaves the match, the match, the halves and the -//! broadcast list go with it. +//! claimed, and one drain still runs in **wire order across the seam** (decision 2306): the frame's +//! events are walked once, a run of consecutive unclaimed events goes through the match as one +//! batch, and a claimed event runs its handlers in place — after the run before it has landed, +//! before the run after it starts. A run boundary is a command flush, which is safe because every +//! intra-drain accumulator the match keeps (`pending`, `StagedModes`, `SpeedStage`) is *staged, +//! else live*: what a flush lands, the next run reads off the component. A kind is owned by +//! exactly one of the two, checked on the built app by `every_session_event_kind_has_one_owner`; +//! the one exception is [`BROADCAST`], a session-end the match still handles and peeled windows +//! also listen to, which reaches both — the match first. When the last family leaves the match, +//! the match, the runs and the broadcast list go with it. use std::collections::HashMap; @@ -41,8 +44,8 @@ pub(crate) struct NetHandlers { } /// The kinds the dispatch match still owns **and** peeled subsystems listen to — a session end, -/// which every window that dies with the socket answers for itself. Cloned to the table after the -/// match has run. Empty once the session family itself is peeled. +/// which every window that dies with the socket answers for itself. It closes the match's run and +/// then runs its listeners, in place. Empty once the session family itself is peeled. pub(crate) const BROADCAST: &[SessionEventKind] = &[SessionEventKind::Disconnected]; impl NetHandlers { @@ -61,6 +64,24 @@ impl NetHandlers { .collect() } + /// Run **every** registered handler once with `ev` — whatever kind it registered for — and + /// name the ones that could not run. A handler matches on its own kind and ignores the rest, + /// so what this exercises is the part a wrong-kind event still reaches: the system's + /// parameters being fetched from this world. + #[cfg(test)] + pub(crate) fn probe(&self, world: &mut World, ev: &SessionEvent) -> Vec { + let mut failed = Vec::new(); + for (kind, list) in &self.by_kind { + for (id, name) in list { + if let Err(e) = world.run_system_with(*id, ev.clone()) { + failed.push(format!("{kind:?}: `{name}` — {e}")); + } + } + } + failed.sort(); + failed + } + fn push(&mut self, kind: SessionEventKind, handler: Handler) { self.by_kind.entry(kind).or_default().push(handler); } @@ -115,32 +136,34 @@ impl NetHandlerApp for App { } } -/// One drain's dispatch: the events the table does not own go through `unclaimed` first — the -/// dispatch match, in packet order — then the owned ones run through the table, in packet order. -/// A [`BROADCAST`] kind reaches both. +/// One drain's dispatch, **in wire order**: a run of consecutive events the table does not own +/// goes through `unclaimed` — the dispatch match — as one batch; an owned event first lands the +/// run before it, then runs its handlers. A [`BROADCAST`] kind is the last event of the match's +/// run *and* a table event. `unclaimed` is never called with nothing. pub(crate) fn dispatch( world: &mut World, events: Vec, - unclaimed: impl FnOnce(&mut World, Vec), + mut unclaimed: impl FnMut(&mut World, Vec), ) { let handlers = world.remove_resource::().unwrap_or_default(); - let mut to_match = Vec::with_capacity(events.len()); - let mut to_table = Vec::new(); + let mut run = Vec::new(); for ev in events { let kind = SessionEventKind::from(&ev); if !handlers.handles(kind) { - to_match.push(ev); - } else if BROADCAST.contains(&kind) { - to_table.push(ev.clone()); - to_match.push(ev); - } else { - to_table.push(ev); + run.push(ev); + continue; + } + if BROADCAST.contains(&kind) { + run.push(ev.clone()); + } + if !run.is_empty() { + unclaimed(world, std::mem::take(&mut run)); } - } - unclaimed(world, to_match); - for ev in to_table { handlers.run(world, ev); } + if !run.is_empty() { + unclaimed(world, run); + } world.insert_resource(handlers); } @@ -200,27 +223,65 @@ mod tests { } #[test] - fn owned_kinds_run_in_packet_order_after_the_match_has_run_the_rest() { + fn the_match_and_the_table_interleave_in_wire_order() { let mut app = app(); app.net_handler(SessionEventKind::LoginQueued, on_queued) .net_handler(SessionEventKind::LoggedOut, on_logged_out); + let stage = || SessionEvent::LoginStage { + stage: benilla_protocol::LoginStage::Connecting, + }; let log = run( &mut app, vec![ queued(1), - SessionEvent::LoginStage { - stage: benilla_protocol::LoginStage::Connecting, - }, + stage(), + stage(), SessionEvent::LoggedOut, queued(2), + stage(), ], ); assert_eq!( log, - vec!["match:LoginStage", "queued:1", "logged_out", "queued:2"] + vec![ + "queued:1", + "match:LoginStage", + "match:LoginStage", + "logged_out", + "queued:2", + "match:LoginStage" + ] ); } + /// Consecutive unclaimed events are one batch — one run of the match, one command flush — + /// and an empty frame never runs the match at all. + #[test] + fn a_run_of_unclaimed_events_is_one_batch_and_nothing_is_no_batch() { + let mut app = app(); + app.net_handler(SessionEventKind::LoggedOut, on_logged_out); + let stage = || SessionEvent::LoginStage { + stage: benilla_protocol::LoginStage::Connecting, + }; + let mut batches = Vec::new(); + dispatch( + app.world_mut(), + vec![stage(), stage(), SessionEvent::LoggedOut, stage()], + |_, batch| batches.push(batch.len()), + ); + assert_eq!(batches, vec![2, 1]); + batches.clear(); + dispatch(app.world_mut(), vec![], |_, batch| { + batches.push(batch.len()) + }); + dispatch( + app.world_mut(), + vec![SessionEvent::LoggedOut], + |_, batch| batches.push(batch.len()), + ); + assert!(batches.is_empty()); + } + #[test] fn several_handlers_on_one_kind_run_in_registration_order_each_with_the_event() { let mut app = app(); @@ -316,6 +377,127 @@ mod tests { assert!(problems.is_empty(), "{}", problems.join("\n")); } + /// **Every registered handler can run on the built client.** The dispatch match's parameters + /// were fetched every frame from the first, so a resource nobody inserted failed at boot; a + /// handler's are fetched when its packet arrives, which for a rare packet is never in a + /// smoke run. This fetches them all, once, on the headless client — every plugin built and + /// finished — by running each handler with an event of a kind it ignores. + #[test] + fn every_registered_handler_can_run_on_the_built_client() { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let world = app.world_mut(); + let handlers = world + .remove_resource::() + .expect("the built client registers handlers"); + let failed = handlers.probe(world, &SessionEvent::LoggedOut); + let count: usize = handlers.census().values().map(Vec::len).sum(); + eprintln!( + "net handlers probed on the built client: {count}, {} failed", + failed.len() + ); + assert!(failed.is_empty(), "{}", failed.join("\n")); + } + + /// **A handler matches the kind it registered for.** `net_handler(K::X, on_y)` routes X to + /// `on_y`, and `on_y` opens with `if let SessionEvent::X` — two spellings of one kind, which + /// nothing else ties together: a handler registered under the wrong kind compiles, runs, and + /// silently ignores its packet. Read off the source: every registration's handler names its + /// kind in its body. A session-end listener takes `In(_)` and is exempt. + #[test] + fn every_handler_names_the_kind_it_registered_for() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + let mut dirs = vec![src]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("src") { + let path = entry.expect("entry").path(); + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let (mut seen, mut problems) = (0, Vec::new()); + for path in files { + if path.ends_with("net/handlers.rs") { + continue; // this file's own fixtures and prose + } + let text = regex_lite_strip_comments(&std::fs::read_to_string(&path).expect("source")); + for call in text.split("net_handler(").skip(1) { + let Some((args, _)) = call.split_once(')') else { + continue; + }; + let Some((kind, handler)) = args.split_once(',') else { + continue; + }; + let kind = kind.trim().rsplit("::").next().unwrap_or_default(); + let handler = handler.trim(); + seen += 1; + if kind == "Disconnected" { + continue; + } + let Some(at) = text.find(&format!("fn {handler}(")) else { + problems.push(format!( + "{}: `{handler}` is not in the file that registers it", + path.display() + )); + continue; + }; + // The handler's own lines: from its `fn` to the next item's. + let body: Vec<&str> = text[at..] + .lines() + .enumerate() + .take_while(|(i, l)| { + let l = l.trim_start(); + *i == 0 + || !(l.starts_with("fn ") + || l.starts_with("pub") + || l.starts_with("#[")) + }) + .map(|(_, l)| l) + .collect(); + if !body.join("\n").contains(&format!("SessionEvent::{kind}")) { + problems.push(format!( + "{}: `{handler}` is registered for {kind} and never names it", + path.display() + )); + } + } + } + let registered: usize = crate::game_plugins::schedule_tests::headless_client() + .world() + .resource::() + .census() + .values() + .map(Vec::len) + .sum(); + assert_eq!( + seen, registered, + "a registration this scan cannot read — spell it `net_handler(K::Kind, handler)`" + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); + } + + #[derive(Resource)] + struct Absent; + + fn needs_what_nobody_inserted(In(_): In, _absent: Res) {} + + #[test] + fn the_probe_names_a_handler_whose_resource_is_missing() { + let mut app = app(); + app.net_handler(SessionEventKind::LoggedOut, on_logged_out) + .net_handler(SessionEventKind::LoginQueued, needs_what_nobody_inserted); + let handlers = app.world_mut().remove_resource::().unwrap(); + let failed = handlers.probe(app.world_mut(), &SessionEvent::LoggedOut); + assert_eq!(failed.len(), 1, "{failed:?}"); + assert!( + failed[0].contains("needs_what_nobody_inserted"), + "{failed:?}" + ); + } + /// `//` comments out, so a variant named in prose does not count as an arm. fn regex_lite_strip_comments(s: &str) -> String { s.lines() diff --git a/crates/benilla-app/src/ui_binder.rs b/crates/benilla-app/src/ui_binder.rs index 22a0feec..7f9f9114 100644 --- a/crates/benilla-app/src/ui_binder.rs +++ b/crates/benilla-app/src/ui_binder.rs @@ -207,14 +207,49 @@ fn drain_binder( /// silent but still audible, which is the reference's own ordering rather than an accident of ours. const SOUND_PLAYERBOUND: u32 = 1141; -/// The net drain's `SessionEvent::PlayerBound` arm, factored here so the wire law lives beside the -/// state it drives. -pub(crate) mod apply { +/// The innkeeper's packet handlers (in the net handler table since 2312), beside the state they +/// drive. +pub(crate) mod net { use super::*; use bevy::ecs::message::MessageWriter; use crate::net::{ServerSoundKind, ServerSoundMessage}; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + + /// Register the binder's handlers — called from [`UiBinderPlugin`]. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::BinderConfirm, on_confirm) + .net_handler(K::PlayerBound, on_bound); + } + + fn on_confirm(In(ev): In, mut binder: ResMut) { + if let SessionEvent::BinderConfirm { binder: npc } = ev { + binder.ask(npc); + } + } + + fn on_bound( + In(ev): In, + mut binder: ResMut, + mut errors: ResMut, + areas: Option>, + mut sounds: MessageWriter, + ) { + if let SessionEvent::PlayerBound { binder: npc, area } = ev { + debug!("net: bound to area {area} by {npc:#x}"); + bound( + area, + &mut binder, + &mut errors, + areas.as_deref(), + &mut sounds, + ); + } + } /// `SMSG_PLAYERBOUND` — the bind took. Retract the question, play the sound, and queue /// `DisplayError(0x138)` = `ERR_DEATHBIND_SUCCESS_S` (catalog row 312, `kind 0` — a system @@ -260,6 +295,7 @@ pub(crate) struct UiBinderPlugin; impl Plugin for UiBinderPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 9553505a..840bf295 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -184,10 +184,96 @@ fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option) -> Option, + mut duel: ResMut, + mut errors: ResMut, + commands: Res, + self_guid: Res, + social: Res, + ) { + if let SessionEvent::DuelRequested { + arbiter, + challenger, + } = ev + { + requested( + &mut duel, + &mut errors, + &commands, + arbiter, + challenger, + self_guid.0, + social.is_ignored(challenger), + ); + } + } + + fn on_bounds(In(ev): In, mut duel: ResMut) { + match ev { + SessionEvent::DuelOutOfBounds => bounds(&mut duel, true), + SessionEvent::DuelInBounds => bounds(&mut duel, false), + _ => {} + } + } + + fn on_complete( + In(ev): In, + mut duel: ResMut, + mut errors: ResMut, + ) { + if let SessionEvent::DuelComplete { started } = ev { + complete(&mut duel, &mut errors, started); + } + } + + fn on_winner(In(ev): In, mut duel: ResMut) { + if let SessionEvent::DuelWinner { + fled, + winner: won, + loser, + } = ev + { + winner(&mut duel, fled, &won, &loser); + } + } + + fn on_countdown(In(ev): In, mut duel: ResMut) { + if let SessionEvent::DuelCountdown { seconds } = ev { + countdown(&mut duel, seconds); + } + } + + /// A pending challenge, a running duel, and its countdown all die with the socket (decision + /// 0633) — the server drops the duel too (`Player::DuelComplete(DUEL_FLED)` on logout), and a + /// stale arbiter guid would make the next AcceptDuel echo a dead object. A listener on the + /// session end ([`crate::net::handlers::BROADCAST`]). + fn on_session_end(In(_): In, mut duel: ResMut) { + *duel = DuelState::default(); + } /// `SMSG_DUEL_REQUESTED` — store the arbiter, and either own the challenge (error line + /// immediate accept) or hand the popup to the feed. @@ -461,6 +547,7 @@ pub(crate) struct UiDuelPlugin; impl Plugin for UiDuelPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( diff --git a/crates/benilla-app/src/ui_gm_ticket.rs b/crates/benilla-app/src/ui_gm_ticket.rs index a7fddcf6..f7aba93b 100644 --- a/crates/benilla-app/src/ui_gm_ticket.rs +++ b/crates/benilla-app/src/ui_gm_ticket.rs @@ -377,10 +377,88 @@ fn load_gm_ticket_dbc( } } -/// The net drain's arms, beside the state they drive. -pub(crate) mod apply { +/// The GM ticket's packet handlers (decision 1673; in the net handler table since 2312), beside +/// the state they drive. +pub(crate) mod net { + use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; + use crate::net::NetHandlerApp; + + use super::GmTicketState; + + /// Register the ticket's handlers — called from [`super::UiGmTicketPlugin`]. One per kind, + /// plus the session-end listener. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::GmTicket, on_ticket) + .net_handler(K::GmTicketSystemStatus, on_system_status) + .net_handler(K::GmTicketStatusUpdate, on_status_update) + .net_handler(K::GmTicketCreated, on_written) + .net_handler(K::GmTicketUpdated, on_written) + .net_handler(K::GmTicketDeleted, on_deleted) + .net_handler(K::Disconnected, on_session_end); + } + + /// The GETTICKET answer — EVERY one, including `None` ("you have no ticket") and including + /// an unsolicited one pushed by a GM's `.ticket view`/`escalate`/`complete`: they are + /// indistinguishable on the wire and want identical handling. + fn on_ticket(In(ev): In, mut ticket: ResMut) { + if let SessionEvent::GmTicket { ticket: answer } = ev { + ticket.answer(answer); + } + } + + fn on_system_status(In(ev): In, mut ticket: ResMut) { + if let SessionEvent::GmTicketSystemStatus { status } = ev { + ticket.answer_queue(status); + } + } + + /// A GM touched the ticket. Value 1 makes the reference re-ask (`0x5e7932`), the same leg + /// the create/update success codes take; 2 (closed) and 3 (survey offered) are recorded and + /// not acted on — 3 is the survey trigger and that window is deferred. vmangos never sends + /// this packet at all, so on our server the handler is dead; cmangos makes it the whole + /// notification model, which is why it is parsed rather than dropped. + fn on_status_update(In(ev): In, mut ticket: ResMut) { + if let SessionEvent::GmTicketStatusUpdate { status } = ev { + status_update(status, &mut ticket); + } + } + + /// The three response codes have no consumer in the shipped 1.12 UI — no event, no handler. + /// Logged so a refusal is visible in a session log rather than silent; the `ERR_TICKET_*` + /// display path is still unpinned. Create-ok (2) and update-ok (4) make the ENGINE re-ask + /// for the ticket — the reference's own `0x5e4479` arm, and the reason the shipped UI needs + /// no handler for either opcode. Without it a filed ticket goes unseen until the 10-minute + /// poll. + fn on_written(In(ev): In, mut ticket: ResMut) { + match ev { + SessionEvent::GmTicketCreated { response } => { + write_response("create", response, 2, &mut ticket) + } + SessionEvent::GmTicketUpdated { response } => { + write_response("update", response, 4, &mut ticket) + } + _ => {} + } + } + + fn on_deleted(In(ev): In) { + if let SessionEvent::GmTicketDeleted { response: code } = ev { + response("delete", code); + } + } + + /// The GM ticket is login-scoped (decision 1673), and for a sharper reason than most: the + /// ticket belongs to the CHARACTER, and the next login may be a different one. Its answer + /// counters go with it, so the first `SMSG_GMTICKET_GETTICKET` of the new session re-fires + /// `UPDATE_TICKET` rather than being diffed away against the old character's answer count. + /// A listener on the session end ([`crate::net::handlers::BROADCAST`]). + fn on_session_end(In(_): In, mut ticket: ResMut) { + ticket.clear_session(); + } + /// `SMSG_GMTICKET_CREATE` (2 = created) and `SMSG_GMTICKET_UPDATETEXT` (4 = saved) — the two /// codes the reference engine answers by **re-asking for the ticket**, at `0x5e4479`. /// @@ -440,6 +518,7 @@ pub(crate) struct UiGmTicketPlugin; impl Plugin for UiGmTicketPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_systems( @@ -535,13 +614,13 @@ mod tests { #[test] fn a_landed_write_makes_the_engine_reask_and_a_refused_one_does_not() { let mut state = GmTicketState::default(); - apply::write_response("create", 2, 2, &mut state); - apply::write_response("update", 4, 4, &mut state); + net::write_response("create", 2, 2, &mut state); + net::write_response("update", 4, 4, &mut state); assert_eq!(state.take_reasks(), 2, "one re-ask per landed write"); assert_eq!(state.take_reasks(), 0, "drained means drained"); - apply::write_response("create", 3, 2, &mut state); // CREATE_ERROR - apply::write_response("update", 5, 4, &mut state); // UPDATE_ERROR + net::write_response("create", 3, 2, &mut state); // CREATE_ERROR + net::write_response("update", 5, 4, &mut state); // UPDATE_ERROR assert_eq!(state.take_reasks(), 0, "a refusal has nothing to fetch"); } diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs index cf1e5b56..79fc3898 100644 --- a/crates/benilla-app/src/ui_guild/mod.rs +++ b/crates/benilla-app/src/ui_guild/mod.rs @@ -648,14 +648,112 @@ fn guild_emblem( emblem.is_designed().then_some(emblem) } -/// The net drain's `SessionEvent::Guild*` arms, factored here so the wire laws live beside the -/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe a line queue the -/// **message id** [`lines`] named, the way `crate::net::apply`'s group shims do — the surface and -/// the sound come off the catalog at the drain, not from here (decision 2054). -pub(crate) mod apply { +/// The guild family's packet handlers (decision 1257; in the net handler table since 2312), +/// beside the state they drive ([`crate::ui_social::net`]'s shape): the identity cache, the +/// roster, and the `ERR_GUILD_*` lines the engine composes; the guild EVENTS fire off the mirror +/// in [`feed_guild`], on their edges. The ones that owe a line queue the **message id** [`lines`] +/// named, the way `crate::net::apply`'s group shims do — the surface and the sound come off the +/// catalog at the error frame's drain, not from here (decision 2054). +pub(crate) mod net { use super::*; use crate::ui_action::{UiError, UiErrorKeys}; use crate::ui_social::SocialState; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + + /// Register the family's handlers — called from [`UiGuildPlugin`]. One per kind, plus the + /// session-end listener. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::GuildQueryResponse, on_query_response) + .net_handler(K::GuildRoster, on_roster) + .net_handler(K::GuildEvent, on_event) + .net_handler(K::GuildCommandResult, on_command_result) + .net_handler(K::GuildInvite, on_invite) + .net_handler(K::GuildDecline, on_decline) + .net_handler(K::GuildInfo, on_info) + .net_handler(K::Disconnected, on_session_end); + } + + fn on_query_response(In(ev): In, mut guild: ResMut) { + if let SessionEvent::GuildQueryResponse(response) = ev { + query_response(&mut guild, response); + } + } + + fn on_roster(In(ev): In, mut guild: ResMut) { + if let SessionEvent::GuildRoster(r) = ev { + roster(&mut guild, r); + } + } + + /// The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the + /// four-conjunct display condition on their line — which is why this handler reads the + /// social lists, the notify knob and our own guid (decision 1589; the condition and its byte + /// addresses are on [`event`]). + fn on_event( + In(ev): In, + mut guild: ResMut, + mut errors: ResMut, + social: Res, + notify: Res, + self_guid: Res, + ) { + if let SessionEvent::GuildEvent(notice) = ev { + event( + &mut guild, + &mut errors, + &social, + ¬ify, + self_guid.0, + notice, + ); + } + } + + fn on_command_result( + In(ev): In, + mut guild: ResMut, + mut errors: ResMut, + ) { + if let SessionEvent::GuildCommandResult(result) = ev { + command_result(&mut guild, &mut errors, result); + } + } + + fn on_invite( + In(ev): In, + mut guild: ResMut, + mut errors: ResMut, + ) { + if let SessionEvent::GuildInvite { inviter, guild: g } = ev { + invite(&mut guild, &mut errors, inviter, g); + } + } + + fn on_decline(In(ev): In, mut errors: ResMut) { + if let SessionEvent::GuildDecline { name } = ev { + decline(&mut errors, &name); + } + } + + fn on_info(In(ev): In, mut guild: ResMut) { + if let SessionEvent::GuildInfo(i) = ev { + info(&mut guild, i); + } + } + + /// The guild session is login-scoped (decision 1257) — and strictly, because the next login + /// may be a *different character*, whose guild id, rank, rights and roster share nothing + /// with this one's. The identity cache goes too: it is keyed by guild id, so it would + /// survive correctly, but the reference's own is backed by `guildcache.wdb` and re-primed + /// lazily, and keeping a cache alive across a socket only to save one query is not worth the + /// one wrong name a renamed guild would show. A listener on the session end + /// ([`crate::net::handlers::BROADCAST`]). + fn on_session_end(In(_): In, mut guild: ResMut) { + *guild = GuildState::default(); + } /// Queue what [`lines`] named. The key IS the lookup and the catalog row behind it names the /// surface and the sound, so nothing here decides either — which is the difference decision @@ -790,6 +888,7 @@ pub(crate) fn on_cvar(ev: On, mut notify: ResMut(app); app.init_resource::() @@ -853,37 +952,37 @@ mod tests { #[test] fn the_signon_condition_is_all_four_conjuncts() { let mut social = SocialState::default(); - crate::ui_social::apply::friend_list( + crate::ui_social::net::friend_list( &mut social, vec![benilla_protocol::messages::FriendEntry { guid: 7, ..Default::default() }], ); - crate::ui_social::apply::ignore_list(&mut social, vec![9]); + crate::ui_social::net::ignore_list(&mut social, vec![9]); let on = GuildMemberNotify(true); let off = GuildMemberNotify(false); let me = Some(1); // 2 · the CVar, which ships OFF — so the default client says nothing at all. assert!( - !apply::announce_signon(&social, &off, me, Some(5)), + !net::announce_signon(&social, &off, me, Some(5)), "guildMemberNotify off silences the whole family" ); - assert!(apply::announce_signon(&social, &on, me, Some(5))); + assert!(net::announce_signon(&social, &on, me, Some(5))); // 3 · not you. vmangos broadcasts the sign-on to EVERY member including the signer // (`Guild::BroadcastPacket`, Guild.cpp:651-656), so without this you announce yourself at // every login. assert!( - !apply::announce_signon(&social, &on, me, Some(1)), + !net::announce_signon(&social, &on, me, Some(1)), "your own sign-on is not announced to you" ); // 4 · not a friend — de-duplication against SMSG_FRIEND_STATUS, which says the same thing // with no CVar gate of its own. THIS is the conjunct benilla read as an ignore check. assert!( - !apply::announce_signon(&social, &on, me, Some(7)), + !net::announce_signon(&social, &on, me, Some(7)), "a guildmate who is also a friend is announced by the friend path, not twice" ); @@ -894,14 +993,14 @@ mod tests { "the fixture's ignore really is an ignore" ); assert!( - apply::announce_signon(&social, &on, me, Some(9)), + net::announce_signon(&social, &on, me, Some(9)), "the reference announces an ignored guildmate — 0x5ae810 is not the ignore check" ); // 1 · a local player, and the pair's own guid: either missing leaves the condition // unanswerable, and the reference's silent exit is the answer. - assert!(!apply::announce_signon(&social, &on, None, Some(5))); - assert!(!apply::announce_signon(&social, &on, me, None)); + assert!(!net::announce_signon(&social, &on, None, Some(5))); + assert!(!net::announce_signon(&social, &on, me, None)); } /// `show_offline` changes what `GetNumGuildMembers` counts and how the rows are ordered — diff --git a/crates/benilla-app/src/ui_instance.rs b/crates/benilla-app/src/ui_instance.rs index 49cf12ca..3af80296 100644 --- a/crates/benilla-app/src/ui_instance.rs +++ b/crates/benilla-app/src/ui_instance.rs @@ -97,7 +97,7 @@ //! maps it ever names are raids, and a raid is `InstanceType == 2`, which `0x495d33`'s //! `cmp [rec+8],1` rejects. (1) is the only live writer here, which is why it is also the one //! that answers term 1. (1) is [`track_instance_state`]'s `CurrentMap` flip; (2) is -//! [`apply::update_last_instance`]'s queue, drained through the same helper so the two cannot +//! [`net::update_last_instance`]'s queue, drained through the same helper so the two cannot //! drift — [`LatchWriter`] is which of them is speaking. //! //! **(2) is therefore unreachable on our server by construction, and no live probe can cover it** @@ -382,11 +382,48 @@ fn raid_instance_line(message: &RaidInstanceMessage) -> Option { }) } -/// The net drain's six arms, factored here so the wire law lives beside the state it drives. -pub(crate) mod apply { +/// The instance/raid lockout family's six packet handlers (decision 1748; in the net handler +/// table since 2312), beside the state they drive: four lines the client composes itself out of +/// GlobalStrings, and the two-packet latch behind the SELF menu's reset row. The lines are +/// QUEUED — resolving them needs the VM, which a packet handler leaves to the feed (decision +/// 0669's split). +pub(crate) mod net { use super::*; use benilla_protocol::messages::InstanceResetFailed; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + + /// Register the six handlers — called from [`UiInstancePlugin`]. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::RaidInstanceMessage, on_packet) + .net_handler(K::InstanceSaveCreated, on_packet) + .net_handler(K::InstanceReset, on_packet) + .net_handler(K::InstanceResetFailed, on_packet) + .net_handler(K::UpdateLastInstance, on_packet) + .net_handler(K::UpdateInstanceOwnership, on_packet); + } + + /// One handler for the six: each is a one-line fold into the same state. + fn on_packet(In(ev): In, mut state: ResMut) { + match ev { + SessionEvent::RaidInstanceMessage { message } => { + raid_instance_message(&mut state, message) + } + SessionEvent::InstanceSaveCreated { flag } => instance_save_created(&mut state, flag), + SessionEvent::InstanceReset { map } => instance_reset(&mut state, map), + SessionEvent::InstanceResetFailed { failure } => { + instance_reset_failed(&mut state, failure) + } + SessionEvent::UpdateLastInstance { map } => update_last_instance(&mut state, map), + SessionEvent::UpdateInstanceOwnership { owns } => { + update_instance_ownership(&mut state, owns) + } + _ => {} + } + } /// `SMSG_RAID_INSTANCE_MESSAGE` — one of four `RAID_INSTANCE_*` lines (`0x49e1c0`). pub(crate) fn raid_instance_message(state: &mut InstanceState, message: RaidInstanceMessage) { @@ -583,6 +620,7 @@ pub(crate) struct UiInstancePlugin; impl Plugin for UiInstancePlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( @@ -723,7 +761,7 @@ mod tests { #[test] fn reset_lines_fill_the_map_name() { let mut state = InstanceState::default(); - apply::instance_reset(&mut state, 36); + net::instance_reset(&mut state, 36); let lines = state.take_lines(); assert_eq!(lines.len(), 1); assert_eq!( @@ -747,7 +785,7 @@ mod tests { ), ] { let mut state = InstanceState::default(); - apply::instance_reset_failed( + net::instance_reset_failed( &mut state, benilla_protocol::messages::InstanceResetFailed { reason, map: 36 }, ); @@ -762,7 +800,7 @@ mod tests { // INSTANCERESET_FAIL_SILENTLY and above: no line, rather than the reference's // uninitialized-buffer print. let mut state = InstanceState::default(); - apply::instance_reset_failed( + net::instance_reset_failed( &mut state, benilla_protocol::messages::InstanceResetFailed { reason: 3, map: 36 }, ); @@ -774,21 +812,21 @@ mod tests { #[test] fn save_created_has_three_arms() { let mut state = InstanceState::default(); - apply::instance_save_created(&mut state, 0); + net::instance_save_created(&mut state, 0); let lines = state.take_lines(); assert_eq!( line_text(&lines[0], None).as_deref(), Some("You are now saved to this instance") ); - apply::instance_save_created(&mut state, 1); + net::instance_save_created(&mut state, 1); let lines = state.take_lines(); assert_eq!( line_text(&lines[0], None).as_deref(), Some("(Debug-Only Lock Notice) You are now saved to this instance") ); - apply::instance_save_created(&mut state, 2); + net::instance_save_created(&mut state, 2); assert!(state.take_lines().is_empty()); } @@ -976,7 +1014,7 @@ mod tests { (s.last_dungeon, s.saw_own_dungeon) }; let queue = |app: &mut App, map: u32| { - apply::update_last_instance(&mut app.world_mut().resource_mut::(), map); + net::update_last_instance(&mut app.world_mut().resource_mut::(), map); }; // Writer 1 — we walk out of the Deadmines ourselves. The first tick only OBSERVES map 36 @@ -1131,7 +1169,7 @@ mod tests { Some("Welcome to Molten Core. This raid instance is scheduled to reset in 3d 2h 5m.") ); let mut state = InstanceState::default(); - apply::instance_reset_failed( + net::instance_reset_failed( &mut state, benilla_protocol::messages::InstanceResetFailed { reason: 0, map: 36 }, ); diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index 45168c83..9e927996 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -9,7 +9,7 @@ //! into the first `CMSG_GET_MAIL_LIST` — one request, Lua-driven, exactly the reference flow //! (MailFrame.lua l.42). //! -//! The net bridge ([`crate::net::apply::mail`]) fills [`MailOpen`] from the wire +//! The mailbox's packet handlers ([`net`]) fill [`MailOpen`] from the wire //! (`SMSG_MAIL_LIST_RESULT` → the inbox rows; `SMSG_ITEM_TEXT_QUERY_RESPONSE` → the body cache; //! `SMSG_SEND_MAIL_RESULT` → the send/take result queues). Each frame [`feed_mail`] resolves each //! wire [`MailListEntry`] to a Lua-facing [`MailInboxRow`] (sender via the ask-once name cache, item @@ -58,6 +58,7 @@ use crate::query_cache::QueryCache; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; +mod net; mod pending; pub(crate) use pending::MailPending; @@ -100,7 +101,7 @@ pub(crate) struct MailSendAck { pub(crate) refusal: Option<&'static str>, } -/// The open mailbox, filled by the net bridge ([`crate::net::apply::mail`]) and read by +/// The open mailbox, filled by the packet handlers ([`net`]) and read by /// [`feed_mail`]. Holds the mailbox guid and the inbox rows exactly as the wire delivered them /// (`SMSG_MAIL_LIST_RESULT`), the ask-once letter-body cache, and the send-result queues; the feed /// resolves each row to a display row and the drain maps a clicked 1-based row to its wire mail id. @@ -121,7 +122,7 @@ pub(crate) struct MailOpen { last_list_query: Option, /// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]). pub(crate) send_acks: Vec, - /// 1-based inbox rows a take just emptied and purged (`net::apply::mail`) — the feed fires + /// 1-based inbox rows a take just emptied and purged ([`net`]) — the feed fires /// `CLOSE_INBOX_ITEM(index)` for each, ahead of the `MAIL_INBOX_UPDATE` the purge causes. pub(crate) close_inbox: Vec, /// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the @@ -206,6 +207,7 @@ pub(crate) struct UiMailPlugin; impl Plugin for UiMailPlugin { fn build(&self, app: &mut App) { crate::query_cache::register::(app); + net::register(app); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/net/apply/mail.rs b/crates/benilla-app/src/ui_mail/net.rs similarity index 61% rename from crates/benilla-app/src/net/apply/mail.rs rename to crates/benilla-app/src/ui_mail/net.rs index 9919c191..a0f53af5 100644 --- a/crates/benilla-app/src/net/apply/mail.rs +++ b/crates/benilla-app/src/ui_mail/net.rs @@ -1,28 +1,111 @@ -//! The mail arc's drain-side arm bodies for [`super::apply_net_updates`]'s dispatch match -//! (decision 0544 P1/P2). Each `pub(super)` fn here is one `SessionEvent` arm's body, filling the -//! [`crate::ui_mail::MailOpen`] session the feed reads; the match at the call site stays the -//! dispatcher. The `UiScript` events these ultimately drive are fired by [`crate::ui_mail::feed_mail`] -//! (the feed owns the VM), so these arms only mutate resources + send the wire re-syncs. +//! The mailbox's packet handlers (decision 0544 P1/P2/P3; in the net handler table since 2306) — +//! the inbox/body/send-result kinds fill the [`MailOpen`] session the feed reads, the arrival +//! pair feeds [`MailPending`] (`HasNewMail()`/the minimap icon). The `UiScript` events these +//! ultimately drive are fired by [`super::feed_mail`] (the feed owns the VM), so nothing here +//! touches it: the handlers only mutate resources + send the wire re-syncs. use benilla_protocol::messages::{mail_action, mail_error, mail_message_type, MailListEntry}; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::prelude::*; -use crate::net::{ClientCommand, NetCommands}; +use super::{mail_refusal, MailOpen, MailPending, MailSendAck}; +use crate::net::{ClientCommand, NetCommands, NetHandlerApp}; use crate::ui_items::{EquipError, EquipErrors}; -use crate::ui_mail::{mail_refusal, MailOpen, MailPending, MailSendAck}; + +/// Register the mailbox's handlers — called from [`super::UiMailPlugin`]. One per kind, plus the +/// session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::MailList, on_mail_list) + .net_handler(K::SendMailResult, on_send_mail_result) + .net_handler(K::MailItemText, on_mail_item_text) + .net_handler(K::ReceivedMail, on_received_mail) + .net_handler(K::NextMailTime, on_next_mail_time) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_mail_list(In(ev): In, mut mail: ResMut, commands: Res) { + if let SessionEvent::MailList { mails } = ev { + mail_list(mails, &mut mail, &commands); + } +} + +fn on_send_mail_result( + In(ev): In, + mut mail: ResMut, + commands: Res, + mut equip_errors: ResMut, +) { + if let SessionEvent::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } = ev + { + send_mail_result( + mail_id, + action, + error, + equip_error, + item, + &mut mail, + &commands, + &mut equip_errors, + ); + } +} + +fn on_mail_item_text(In(ev): In, mut mail: ResMut) { + if let SessionEvent::MailItemText { text_id, text } = ev { + mail_item_text(text_id, text, &mut mail); + } +} + +fn on_received_mail( + In(ev): In, + mut pending: ResMut, + mail: Res, + commands: Res, +) { + if let SessionEvent::ReceivedMail { seconds } = ev { + received_mail(seconds, &mut pending, &mail, &commands); + } +} + +fn on_next_mail_time(In(ev): In, mut pending: ResMut) { + if let SessionEvent::NextMailTime { seconds } = ev { + next_mail_time(seconds, &mut pending); + } +} + +/// An open mailbox dies with the socket, and the arrival countdown is login-scoped (decision +/// 0544 P3): a fresh login re-queries `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing +/// carries over across a reconnect. A listener on the session end, which the drain's dispatch +/// match still owns ([`crate::net::handlers::BROADCAST`]). +fn on_session_end( + In(_): In, + mut mail: ResMut, + mut pending: ResMut, +) { + mail.clear_session(); + *pending = MailPending::default(); +} /// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox /// repaint (via the feed's diff). The inbox handler auto-purges expired mail: any row whose timer ran /// out (`expire_days <= 0`) is deleted server-side (`CMSG_MAIL_DELETE`) and dropped here (wow-re §5, /// `ui/scratch/mail-interaction.md`). /// -/// **It does not touch [`crate::ui_mail::MailPending`]** — and that is a positive fact, not an +/// **It does not touch [`MailPending`]** — and that is a positive fact, not an /// omission (decision 0913). This arm used to clear the countdown when the surviving list had /// nothing unread, on the inferred grounds that "checking your mail clears the icon" had to be the /// list's doing. A full write-xref of the countdown float in wow-re says otherwise: nothing on the /// inbox path writes it. The icon clears because **opening a letter arms the deferred-refresh flag -/// and the mailbox *close* re-asks the server** — modelled in [`crate::ui_mail`], where the close +/// and the mailbox *close* re-asks the server** — modelled in [`super`], where the close /// edge lives. -pub(super) fn mail_list(mails: Vec, mail: &mut MailOpen, commands: &NetCommands) { +fn mail_list(mails: Vec, mail: &mut MailOpen, commands: &NetCommands) { let mailbox = mail.mailbox; mail.mails = mails .into_iter() @@ -76,7 +159,7 @@ fn take_empties(entry: &MailListEntry, action: u32) -> bool { /// take/return/delete then re-syncs the inbox with a fresh `CMSG_GET_MAIL_LIST` (the reference /// client's inbox-refresh moment); an EQUIP_ERROR routes to the existing inventory-error surface; /// any other failure surfaces the red error line. -pub(super) fn send_mail_result( +fn send_mail_result( mail_id: u32, action: u32, error: u32, @@ -145,7 +228,7 @@ pub(super) fn send_mail_result( /// `SessionEvent::MailItemText` (`SMSG_ITEM_TEXT_QUERY_RESPONSE`) — land the letter body in the /// ask-once cache + clear its pending flag; the feed repaints (MAIL_INBOX_UPDATE) on the change. -pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) { +fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) { mail.bodies.insert(text_id, Some(text)); } @@ -158,12 +241,7 @@ pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) { /// 60 s client-side throttle (decision 0544 P3), so a mail arriving while you stand at the mailbox /// shows up. The reference reaches the same place by its close-time re-query; leaving a mail you /// were just told about invisible for up to a minute is the worse client, and this costs one packet. -pub(super) fn received_mail( - seconds: f32, - pending: &mut MailPending, - mail: &MailOpen, - commands: &NetCommands, -) { +fn received_mail(seconds: f32, pending: &mut MailPending, mail: &MailOpen, commands: &NetCommands) { pending.apply_received_mail(seconds, mail.mailbox.is_some()); if let Some(mailbox) = mail.mailbox { let _ = commands.0.send(ClientCommand::GetMailList { mailbox }); @@ -175,6 +253,47 @@ pub(super) fn received_mail( /// signal site `0x4ad605`; decision 0913). `0.0` = mail waiting now, negative (vmangos always sends /// `-86400.0`) = none, a positive value counts down per frame in `crate::ui_mail`'s `feed_mail` and /// flips `HasNewMail()` true as it lands inside ε. -pub(super) fn next_mail_time(seconds: f32, pending: &mut MailPending) { +fn next_mail_time(seconds: f32, pending: &mut MailPending) { pending.apply_query_reply(seconds); } + +#[cfg(test)] +mod tests { + use super::*; + + /// End to end through the real registration: the wire's "mail is waiting" reaches the + /// countdown, a mailbox click survives until the session ends, and the session end — a + /// broadcast the match still owns — resets both. + #[test] + fn the_table_routes_the_arrival_and_the_session_end_to_the_mailbox() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)); + register(&mut app); + + app.world_mut().resource_mut::().click(0x20); + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::NextMailTime { seconds: 0.0 }], + |_, _| panic!("a claimed kind never reaches the match"), + ); + assert!(app.world().resource::().has_new_mail()); + + let mut through_the_match = Vec::new(); + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::Disconnected { + reason: "socket".into(), + end: benilla_protocol::SessionEnd::Lost, + }], + |_, unclaimed| through_the_match.extend(unclaimed.iter().map(SessionEventKind::from)), + ); + assert_eq!(through_the_match, vec![SessionEventKind::Disconnected]); + assert!(!app.world().resource::().has_new_mail()); + assert_eq!(app.world().resource::().mailbox, None); + } +} diff --git a/crates/benilla-app/src/ui_petition/mod.rs b/crates/benilla-app/src/ui_petition/mod.rs index e458dca1..92740f2b 100644 --- a/crates/benilla-app/src/ui_petition/mod.rs +++ b/crates/benilla-app/src/ui_petition/mod.rs @@ -52,7 +52,7 @@ //! `SMSG_PETITION_SIGN_RESULTS` to both parties and **no fresh signatures packet** //! (`:299`, `:312-316`), while the reference's `PetitionFrame` registers only `PETITION_SHOW` and //! `PETITION_CLOSED`. Nothing would ever repaint the name rows. So an `OK` result naming the open -//! charter re-sends `CMSG_PETITION_SHOW_SIGNATURES` ([`apply::sign_results`]) — INFERRED, and +//! charter re-sends `CMSG_PETITION_SHOW_SIGNATURES` ([`net::sign_results`]) — INFERRED, and //! flagged as such below. //! - **Two success paths are silent on the wire and speak locally.** Offering a charter answers the //! *target*, not us, and finding no charter to turn in is refused before any packet is built — @@ -414,6 +414,7 @@ pub(crate) struct UiPetitionPlugin; impl Plugin for UiPetitionPlugin { fn build(&self, app: &mut App) { + net::register(app); crate::query_cache::register::(app); app.init_resource::() .init_resource::() @@ -435,9 +436,111 @@ impl Plugin for UiPetitionPlugin { /// /// **Nothing here fires an event or writes a chat line directly.** Composed lines go onto /// [`PetitionState::lines`] and the feed drains them, because the red `UI_ERROR_MESSAGE` channel -/// needs the `script` handle and apply has none (`crate::ui_bank`'s `BankErrors` shape). -pub(crate) mod apply { +/// needs the `script` handle and a packet handler has none (`crate::ui_bank`'s `BankErrors` +/// shape). In the net handler table since 2312. +pub(crate) mod net { use super::*; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + + use crate::net::{GuidIndex, ObjectStore, SelfGuid}; + use crate::ui_social::SocialState; + + /// Register the family's handlers — called from [`UiPetitionPlugin`]. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::PetitionShowList, on_show_list) + .net_handler(K::PetitionShowSignatures, on_show_signatures) + .net_handler(K::PetitionQueryResponse, on_query_response) + .net_handler(K::PetitionSignResults, on_sign_results) + .net_handler(K::TurnInPetitionResults, on_turn_in_results) + .net_handler(K::PetitionDeclined, on_declined) + .net_handler(K::PetitionRenamed, on_renamed); + } + + /// The registrar's two `UNIT_NPC_FLAGS` gates are on LIVE NPC state rather than on the + /// packet, so the flags are read here, off the store. An unstreamed guid reads `None` and + /// fails the gate, as the client's own resolve does. + fn on_show_list( + In(ev): In, + mut registrar: ResMut, + index: Res, + stores: Query<&ObjectStore>, + ) { + if let SessionEvent::PetitionShowList(list) = ev { + let flags = index + .0 + .get(&list.npc) + .and_then(|e| stores.get(*e).ok()) + .map(|s| s.0.unit_npc_flags()); + show_list(&mut registrar, list, flags); + } + } + + /// An ignored owner suppresses the ENTIRE update — no record fetch, no list, no event, no + /// error line (`0x5eeefe`). Consulted before anything else happens. + fn on_show_signatures( + In(ev): In, + mut petition: ResMut, + social: Res, + commands: Res, + ) { + if let SessionEvent::PetitionShowSignatures(sigs) = ev { + let ignored = social.is_ignored(sigs.owner); + show_signatures(&mut petition, sigs, ignored, &commands); + } + } + + fn on_query_response(In(ev): In, mut petition: ResMut) { + if let SessionEvent::PetitionQueryResponse(response) = ev { + query_response(&mut petition, response); + } + } + + fn on_sign_results( + In(ev): In, + mut petition: ResMut, + names: Res, + self_guid: Res, + commands: Res, + ) { + if let SessionEvent::PetitionSignResults(results) = ev { + sign_results( + &mut petition, + &names, + self_guid.0.unwrap_or(0), + results, + &commands, + ); + } + } + + fn on_turn_in_results( + In(ev): In, + mut petition: ResMut, + mut registrar: ResMut, + ) { + if let SessionEvent::TurnInPetitionResults { result } = ev { + turn_in_results(&mut petition, &mut registrar, result); + } + } + + fn on_declined( + In(ev): In, + mut petition: ResMut, + names: Res, + ) { + if let SessionEvent::PetitionDeclined { player } = ev { + declined(&mut petition, &names, player); + } + } + + fn on_renamed(In(ev): In, mut petition: ResMut) { + if let SessionEvent::PetitionRenamed(rename) = ev { + renamed(&mut petition, rename); + } + } /// `SMSG_PETITION_SHOWLIST` — the registrar's charter list, subject to its three gates. /// @@ -725,7 +828,7 @@ mod tests { min_signatures: 9, ..Default::default() }); - apply::renamed( + net::renamed( &mut petition, PetitionRename { item: 0x99, @@ -735,7 +838,7 @@ mod tests { assert_eq!(open_title(&petition), Some("Second")); // An echo for a charter we do not have open is not ours to apply. - apply::renamed( + net::renamed( &mut petition, PetitionRename { item: 0xdead, diff --git a/crates/benilla-app/src/ui_social/mod.rs b/crates/benilla-app/src/ui_social/mod.rs index 307bda54..3d0828bd 100644 --- a/crates/benilla-app/src/ui_social/mod.rs +++ b/crates/benilla-app/src/ui_social/mod.rs @@ -288,10 +288,61 @@ fn status_flag_key(status: u8) -> Option<&'static str> { }) } -/// The net drain's `SessionEvent::Friend*`/`Who*` arms, factored here so the wire laws live -/// beside the state they drive ([`crate::ui_duel::apply`]'s shape). -pub(crate) mod apply { +/// The social family's packet handlers (decision 0668; in the net handler table since 2312), +/// beside the state they drive ([`crate::ui_duel::net`]'s shape): the friend/ignore lists, the +/// `/who` answer, and the result codes that print their own chat lines. The lines and the Era +/// events fire off the mirror in [`feed_social`] — every one of them needs a NAME, which the +/// feed resolves. +pub(crate) mod net { use super::*; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + + /// Register the family's handlers — called from [`UiSocialPlugin`]. One per kind, plus the + /// session-end listener. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::FriendList, on_friend_list) + .net_handler(K::IgnoreList, on_ignore_list) + .net_handler(K::FriendStatus, on_friend_status) + .net_handler(K::WhoResults, on_who) + .net_handler(K::Disconnected, on_session_end); + } + + fn on_friend_list(In(ev): In, mut social: ResMut) { + if let SessionEvent::FriendList { friends } = ev { + friend_list(&mut social, friends); + } + } + + fn on_ignore_list(In(ev): In, mut social: ResMut) { + if let SessionEvent::IgnoreList { guids } = ev { + ignore_list(&mut social, guids); + } + } + + fn on_friend_status(In(ev): In, mut social: ResMut) { + if let SessionEvent::FriendStatus(update) = ev { + friend_status(&mut social, update); + } + } + + fn on_who(In(ev): In, mut social: ResMut) { + if let SessionEvent::WhoResults(results) = ev { + who(&mut social, results); + } + } + + /// The friend/ignore lists and the last `/who` are session state (decision 0668): the + /// server re-pushes both lists at the next login, and a stale ignore list would silence the + /// wrong guids after a reconnect renumbers nothing but re-streams everything. The `/who` + /// sort chain is the one thing that survives — it is per-PROCESS in the reference, not + /// per-login (decision 2030), which is why this is a `clear_session` and not a `default()`. + /// A listener on the session end ([`crate::net::handlers::BROADCAST`]). + fn on_session_end(In(_): In, mut social: ResMut) { + social.clear_session(); + } /// `SMSG_FRIEND_LIST`. pub(crate) fn friend_list(social: &mut SocialState, friends: Vec) { @@ -319,6 +370,7 @@ pub(crate) struct UiSocialPlugin; impl Plugin for UiSocialPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( diff --git a/crates/benilla-app/src/ui_summon.rs b/crates/benilla-app/src/ui_summon.rs index b913a923..94270afe 100644 --- a/crates/benilla-app/src/ui_summon.rs +++ b/crates/benilla-app/src/ui_summon.rs @@ -27,7 +27,7 @@ //! resolves the active player (`0x468550` → `0x468460` with typemask `0x10` = player) and, if //! `0x605f30` says yes, returns without latching *or* firing (`5e6194: jne`). So the request //! does not merely fail to show a dialog: it does not disturb a previously latched one either, -//! which is why [`apply::request`] is the gate and the popup engine's `whileDead` rule is not. +//! which is why [`net::request`] is the gate and the popup engine's `whileDead` rule is not. //! //! **`0x605f30` is not "is dead"** — that reading cost a wrong first cut here. It is //! `[values+0x40] <= 0` (signed, `605f3b: jle`) **OR** (typemask bit `0x10` **AND** the @@ -78,7 +78,7 @@ use crate::net::{ClientCommand, NetCommands}; use crate::ui_script::{UiFeed, UiInput}; /// The pending summon question — the reference's four-global bank (module doc, pin 1). Written by -/// the net drain's `SummonRequest` arm through [`apply::request`], read by [`feed_summon`] (which +/// the net drain's `SummonRequest` arm through [`net::request`], read by [`feed_summon`] (which /// pushes the three getters' answers and fires `CONFIRM_SUMMON`) and by [`drain_summon`] (which /// turns the dialog's Accept into the response). #[derive(Resource, Default)] @@ -246,12 +246,58 @@ fn end_session_summon( } } -/// The net drain's `SessionEvent::SummonRequest` arm, factored here so the wire law lives beside -/// the state it drives. -pub(crate) mod apply { - use bevy::prelude::debug; +/// The summon's packet handler (decision 1747; in the net handler table since 2312), beside the +/// state it drives. +pub(crate) mod net { + use bevy::prelude::*; use super::SummonState; + use benilla_protocol::{SessionEvent, SessionEventKind}; + + use crate::net::NetHandlerApp; + use crate::net::{GuidIndex, ObjectStore, SelfGuid}; + + /// Register the handler — called from [`super::UiSummonPlugin`]. + pub(super) fn register(app: &mut App) { + app.net_handler(SessionEventKind::SummonRequest, on_request); + } + + /// Someone is asking to pull us to them. The reference gates this in the HANDLER, not the + /// dialog: a dead or ghost player's request is dropped before the latch, so it cannot + /// disturb a live question either (`0x5e6194`). The predicate is `0x605f30` — **health ≤ 0 + /// OR (is-player AND `PLAYER_FLAGS` ghost bit)**, which is both of these accessors and not + /// the one `unit_is_dead` alone would give (a ghost's wire health is 1). A self object we + /// have not streamed yet reads as alive: the reference's own default (`0x5e6189` sends a + /// NULL object through to the latch). + fn on_request( + In(ev): In, + mut summon: ResMut, + self_guid: Res, + index: Res, + stores: Query<&ObjectStore>, + real_clock: Res>, + ) { + if let SessionEvent::SummonRequest { + summoner, + zone, + delay_ms, + } = ev + { + let dead_or_ghost = self_guid + .0 + .and_then(|g| index.0.get(&g)) + .and_then(|e| stores.get(*e).ok()) + .is_some_and(|s| s.0.unit_is_dead() || s.0.player_is_ghost()); + request( + summoner, + zone, + delay_ms, + dead_or_ghost, + real_clock.elapsed_secs_f64(), + &mut summon, + ); + } + } /// `SMSG_SUMMON_REQUEST` — latch the offer and owe the UI a dialog, unless we are dead or a /// ghost. @@ -295,6 +341,7 @@ pub(crate) struct UiSummonPlugin; impl Plugin for UiSummonPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::().add_systems( Update, ( @@ -349,15 +396,15 @@ mod tests { #[test] fn a_summon_that_arrives_while_refused_latches_nothing() { let mut summon = SummonState::default(); - apply::request(0x2a, 1519, 120_000, true, 100.0, &mut summon); + net::request(0x2a, 1519, 120_000, true, 100.0, &mut summon); assert_eq!(summon.pending(), None); assert!(!summon.ask); assert_eq!(summon.time_left_ms(100.0), 0); // A live question, then a second request while refused: the first survives untouched. - apply::request(0x2a, 1519, 120_000, false, 100.0, &mut summon); + net::request(0x2a, 1519, 120_000, false, 100.0, &mut summon); summon.ask = false; - apply::request(0x99, 1, 120_000, true, 150.0, &mut summon); + net::request(0x99, 1, 120_000, true, 150.0, &mut summon); assert_eq!( summon.pending(), Some(0x2a), diff --git a/crates/benilla-app/src/ui_tabard.rs b/crates/benilla-app/src/ui_tabard.rs index d3bfaafa..7f36c68e 100644 --- a/crates/benilla-app/src/ui_tabard.rs +++ b/crates/benilla-app/src/ui_tabard.rs @@ -287,10 +287,49 @@ fn reset_on_world_enter( design.0 = None; } +/// The tabard vendor's packet handlers (in the net handler table since 2312), beside the state +/// they drive. +mod net { + use benilla_protocol::{SessionEvent, SessionEventKind}; + use bevy::prelude::*; + + use super::TabardOpen; + use crate::net::NetHandlerApp; + use crate::ui_guild::GuildState; + + /// Register the two handlers — called from [`super::TabardUiPlugin`]. + pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::TabardVendorActivate, on_activate) + .net_handler(K::SaveGuildEmblemResult, on_save_result); + } + + fn on_activate(In(ev): In, mut tabard: ResMut) { + if let SessionEvent::TabardVendorActivate(vendor) = ev { + tabard.open(vendor); + } + } + + /// A saved emblem evicts our guild's cached record (`0x5e715f`): the next query anywhere + /// re-fetches it — no event, no packet. + fn on_save_result( + In(ev): In, + mut tabard: ResMut, + mut guild: ResMut, + ) { + if let SessionEvent::SaveGuildEmblemResult(result) = ev { + if tabard.apply_result(result) { + guild.evict_own_identity(); + } + } + } +} + pub(crate) struct TabardUiPlugin; impl Plugin for TabardUiPlugin { fn build(&self, app: &mut App) { + net::register(app); app.init_resource::() .init_resource::() .add_systems( diff --git a/crates/benilla-app/src/ui_trade.rs b/crates/benilla-app/src/ui_trade/mod.rs similarity index 98% rename from crates/benilla-app/src/ui_trade.rs rename to crates/benilla-app/src/ui_trade/mod.rs index f3948210..fd0701ce 100644 --- a/crates/benilla-app/src/ui_trade.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -2,7 +2,7 @@ //! [`benilla_ui::script`]'s `trade` module, the two-sided twin of [`crate::ui_mail`]'s mailbox seam. //! //! Trade is entirely server-driven: a right-click → **Trade** ([`crate::ui_trade`] via the UnitPopup -//! row) sends `CMSG_INITIATE_TRADE`; the net bridge ([`crate::net::apply::trade`]) then folds the two +//! row) sends `CMSG_INITIATE_TRADE`; the packet handlers ([`net`]) then fold the two //! status packets into [`TradeSession`] — `SMSG_TRADE_STATUS` drives the open/accept/close state //! machine, `SMSG_TRADE_STATUS_EXTENDED` replaces one side's item/gold snapshot. Each frame //! [`feed_trade`] resolves both sides' wire [`TradeItem`]s to Lua-facing @@ -18,12 +18,13 @@ //! `SMSG_TRADE_STATUS(BEGIN_TRADE)` only *records* the request; [`answer_trade_request`] walks the //! reference's own eight-leg ladder (`0x4bf736`) and either refuses it — on one of three different //! opcodes, or with no packet at all — or accepts it, which is what the client did unconditionally -//! before. The split is what makes the ladder possible: it reads the ignore list, the cinematic -//! state, player control, the auction house, the initiator's descriptor, an outbound initiate of -//! ours and the [`BlockTrades`] CVar, none of which can reach `apply_net_updates` at Bevy's -//! 16-`SystemParam` ceiling — and its answer may span frames, which a wire decoder cannot do at -//! all. Decision 1725 left the cinematic leg unbuilt for exactly that reason and said so; this is -//! the seam that makes it, and the seven beside it, ordinary. +//! before. The split is what made the ladder possible when it was built: it reads the ignore +//! list, the cinematic state, player control, the auction house, the initiator's descriptor, an +//! outbound initiate of ours and the [`BlockTrades`] CVar, none of which could reach the drain's +//! dispatch match at Bevy's 16-`SystemParam` ceiling (decision 1725 left the cinematic leg unbuilt +//! for exactly that reason and said so). Since 2306 the status packet is this module's own +//! handler ([`net`]) and that ceiling is gone; what keeps the ladder a system of its own is that +//! its answer may **span frames**, which a packet handler cannot do. //! //! **There is no consent prompt, and that is faithful rather than missing.** 1.12.1 registers a //! `TRADE_REQUEST` event and signals it from nowhere, so the `TRADE` StaticPopup that would have @@ -60,6 +61,8 @@ use crate::ui_party::GroupState; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::NpcSession; +mod net; + /// One side's offer as the wire delivered it — the seven slots (index 0 = trade slot 1 … index 6 = /// the non-traded / enchant slot) plus the gold and the enchant-slot spell. Filled from /// `SMSG_TRADE_STATUS_EXTENDED`; the feed resolves the [`TradeItem`]s to display rows. @@ -71,7 +74,7 @@ struct TradeOffer { enchant_spell_id: u32, } -/// The open trade window's session, filled by the net bridge ([`crate::net::apply::trade`]) and read +/// The open trade window's session, filled by the packet handlers ([`net`]) and read /// by [`feed_trade`]. Holds the partner guid, the open flag, both sides' wire offers, and the two /// accept flags. Cleared on cancel/complete/close and on disconnect. /// @@ -119,7 +122,7 @@ pub(crate) struct TradeSession { /// The partner pressed Trade (their accept glow) — set by `TRADE_STATUS_TRADE_ACCEPT`. their_accept: bool, /// **A `TRADE_REQUEST_CANCEL` is owed to the VM** — set by the `CANCELED` arm of - /// [`crate::net::apply::trade::trade_status`], drained by [`feed_trade`] on the next frame. + /// [`net`]'s status handler, drained by [`feed_trade`] on the next frame. /// /// It is a *pending signal* rather than session state, so it is the one field /// [`Self::close`] carries across the reset: the reference's own `CANCELED` arm signals the @@ -455,7 +458,7 @@ impl TradeSession { } // ── Test windows onto the private state, for the sibling module that owns the status arm ── - // (`crate::net::apply::trade`, whose tests drive `trade_status` and then have to ask what + // ([`net`], whose tests drive `trade_status` and then have to ask what // it did). Reading them through named accessors rather than opening the fields keeps the // session's invariants — `partner`/`request` mutually exclusive, `open` gating `npc()` — // enforced in exactly one file. @@ -502,6 +505,7 @@ pub(crate) fn on_cvar(ev: On, mut block: ResMut() .init_resource::() .add_systems( @@ -544,14 +548,13 @@ pub(crate) struct BlockTrades(pub(crate) bool); /// **Answer an incoming trade request** — the arm the reference keeps inside `CGTradeInfo`'s /// dispatcher (`0x4bf736`, case 1 of the 23-case `0x4bf720`), lifted out to where its inputs live. /// -/// The net drain only *records* the request ([`TradeSession::request`]); this system decides. The -/// split is the point: the decision reads the ignore list, the cinematic state, player control, the -/// auction house, the initiator's own descriptor, an outbound initiate of ours and a CVar, and none -/// of those can reach `apply_net_updates`, whose signature is at Bevy's 16-`SystemParam` ceiling. -/// That ceiling is exactly why decision 1725 left the cinematic leg unbuilt and said so; it is not -/// a ceiling this side of the seam has. The answer can also **span frames**, which a wire decoder -/// cannot do at all: the `BlockTrades` line names the initiator, and that name may need a -/// `CMSG_NAME_QUERY` first. +/// The status handler only *records* the request ([`TradeSession::request`]); this system decides. +/// The split is the point: the answer can **span frames**, which a packet handler cannot do — the +/// `BlockTrades` line names the initiator, and that name may need a `CMSG_NAME_QUERY` first. (It +/// was first cut for a second reason that 2306 retired: the decision reads the ignore list, the +/// cinematic state, player control, the auction house, the initiator's own descriptor, an outbound +/// initiate of ours and a CVar, none of which could reach the drain's dispatch match at Bevy's +/// 16-`SystemParam` ceiling — exactly why decision 1725 left the cinematic leg unbuilt and said so.) /// /// **The ladder is the reference's, in its order** — eight legs, walked contiguously over /// `[0x4bf736, 0x4bf7f8)` and cross-checked by four independent derivations (wow-re @@ -1427,7 +1430,7 @@ mod tests { assert_eq!(cancels_seen(&mut app), 0, "nothing has cancelled anything"); // A plain close — what `COMPLETE` and `CLOSE_WINDOW` do — signals nothing. (Which - // statuses reach which of these two is `net::apply::trade`'s own test; this is the half + // statuses reach which of these two is `ui_trade::net`'s own test; this is the half // that turns a signal into an event.) app.world_mut() .resource_mut::() diff --git a/crates/benilla-app/src/net/apply/trade.rs b/crates/benilla-app/src/ui_trade/net.rs similarity index 87% rename from crates/benilla-app/src/net/apply/trade.rs rename to crates/benilla-app/src/ui_trade/net.rs index 9995a0c3..d7d387ac 100644 --- a/crates/benilla-app/src/net/apply/trade.rs +++ b/crates/benilla-app/src/ui_trade/net.rs @@ -1,15 +1,49 @@ -//! The player-trade arc's drain-side arm bodies for [`super::apply_net_updates`]'s dispatch match -//! (decision 0592 P1). Each `pub(super)` fn here is one `SessionEvent` arm's body, driving the -//! [`crate::ui_trade::TradeSession`] state machine the feed reads; the match at the call site stays -//! the dispatcher. The `UiScript` events these ultimately drive are fired by -//! [`crate::ui_trade::feed_trade`] (the feed owns the VM), so these arms only mutate the session + -//! send the auto-reply. +//! The player trade's packet handlers (decision 0592 P1; in the net handler table since 2306) — +//! the status packet drives the open/accept/close state machine, the extended snapshot replaces +//! one side's item/gold, both into the [`TradeSession`] the feed reads. The `UiScript` events +//! these ultimately drive are fired by [`super::feed_trade`] (the feed owns the VM), so the +//! handlers only mutate the session + send the auto-reply. use benilla_protocol::messages::{TradeStatus, TradeStatusExtended}; +use benilla_protocol::{SessionEvent, SessionEventKind}; +use bevy::prelude::*; -use crate::net::{ClientCommand, NetCommands}; +use super::{NamedLine, TradeSession}; +use crate::net::{ClientCommand, NetCommands, NetHandlerApp}; use crate::ui_action::{UiError, UiErrorKeys}; -use crate::ui_trade::{NamedLine, TradeSession}; + +/// Register the trade's handlers — called from [`super::UiTradePlugin`]. One per kind, plus the +/// session-end listener. +pub(super) fn register(app: &mut App) { + use SessionEventKind as K; + app.net_handler(K::TradeStatus, on_trade_status) + .net_handler(K::TradeStatusExtended, on_trade_status_extended) + .net_handler(K::Disconnected, on_session_end); +} + +fn on_trade_status( + In(ev): In, + mut trade: ResMut, + mut errors: ResMut, + commands: Res, +) { + if let SessionEvent::TradeStatus { status } = ev { + trade_status(status, &mut trade, &mut errors, &commands); + } +} + +fn on_trade_status_extended(In(ev): In, mut trade: ResMut) { + if let SessionEvent::TradeStatusExtended { state } = ev { + trade_status_extended(&state, &mut trade); + } +} + +/// An open trade dies with the socket (decision 0592) — the reconnect starts with no trade. A +/// listener on the session end, which the drain's dispatch match still owns +/// ([`crate::net::handlers::BROADCAST`]). +fn on_session_end(In(_): In, mut trade: ResMut) { + trade.clear_session(); +} /// `SessionEvent::TradeStatus` (`SMSG_TRADE_STATUS`) — benilla's face of the reference's own /// 23-case dispatcher `CGTradeInfo 0x4bf720` (`ecx` = the status code; jump table `0x4bfa08`; @@ -36,9 +70,9 @@ use crate::ui_trade::{NamedLine, TradeSession}; /// unsettled; §11 settles them). /// /// `BEGIN_TRADE` records the incoming request **without answering it** (decision 1764 — the reply -/// is a ladder of eight gates, so [`crate::ui_trade::answer_trade_request`] owns it, and it is the +/// is a ladder of eight gates, so [`super::answer_trade_request`] owns it, and it is the /// only arm of the reference's dispatcher that speaks to the wire). -pub(super) fn trade_status( +fn trade_status( status: TradeStatus, trade: &mut TradeSession, errors: &mut UiErrorKeys, @@ -138,7 +172,7 @@ enum Line { /// Five arms genuinely say nothing (`OPEN_WINDOW`, `TRADE_ACCEPT`, `BACK_TO_TRADE`, `REJECTED`, /// `UNKNOWN_13`); the other eighteen print. Two of those eighteen answer `None` *here* for reasons /// of their own: `BEGIN_TRADE`'s line is the ladder's -/// ([`crate::ui_trade::answer_trade_request`]'s leg 8), and `CLOSE_WINDOW`'s is the gap at the +/// ([`super::answer_trade_request`]'s leg 8), and `CLOSE_WINDOW`'s is the gap at the /// bottom of this doc. /// /// **Eleven of the ids are outside the trade block** `0xb9..=0xbf` — which is why the arc shipped @@ -167,7 +201,7 @@ enum Line { /// | 22 | `ONLY_CONJURED` | `0x1ca` | `ERR_TRADE_WRONG_REALM` | `2` red | /// /// Case 1's own line (`ERR_TRADE_BLOCKED_S`, leg 8 only) is -/// [`crate::ui_trade::answer_trade_request`]'s, and case 12's is the gap below. +/// [`super::answer_trade_request`]'s, and case 12's is the gap below. /// /// **Nothing here decides a surface.** Three of these are chat lines and thirteen are toasts, /// split between yellow and red, and the split does not follow the English: the two *successful* @@ -215,7 +249,7 @@ fn status_message(status: TradeStatus) -> Option { /// `SessionEvent::TradeStatusExtended` (`SMSG_TRADE_STATUS_EXTENDED`) — replace one side's item/gold /// snapshot (decision 0592 P1); the feed repaints (`TRADE_UPDATE`) on the change. -pub(super) fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut TradeSession) { +fn trade_status_extended(ext: &TradeStatusExtended, trade: &mut TradeSession) { bevy::log::info!( target: "trade", "SMSG_TRADE_STATUS_EXTENDED their_window={} gold={} items={}", @@ -304,6 +338,40 @@ mod tests { trade } + /// End to end through the real registration: a status opens the window, and the session + /// end — a broadcast the match still owns — takes the trade with it. + #[test] + fn the_table_routes_the_status_and_the_session_end_to_the_trade() { + let (_, commands, _rx) = sink(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .init_resource::() + .insert_resource(commands); + register(&mut app); + + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::TradeStatus { + status: TradeStatus::OpenWindow, + }], + |_, _| panic!("a claimed kind never reaches the match"), + ); + assert!(app.world().resource::().is_open()); + + let mut through_the_match = Vec::new(); + crate::net::handlers::dispatch( + app.world_mut(), + vec![SessionEvent::Disconnected { + reason: "socket".into(), + end: benilla_protocol::SessionEnd::Lost, + }], + |_, unclaimed| through_the_match.extend(unclaimed.iter().map(SessionEventKind::from)), + ); + assert_eq!(through_the_match, vec![SessionEventKind::Disconnected]); + assert!(!app.world().resource::().is_open()); + } + /// **Eighteen of the twenty-three arms print; this table carries sixteen.** The arc raised /// four, which is the report. ///