diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 39e7bf88..6d29715b 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -9380,6 +9380,125 @@ mod tests { ); } + #[tokio::test] + async fn duel_accept_live_bridge_records_and_applies_once_like_cpp() { + let (mut source_session, source_send_rx) = make_session(); + let (mut partner_session, partner_send_rx) = make_session(); + let canonical = shared_canonical_map_manager_for_misc_test(); + let source_guid = ObjectGuid::create_player(1, 77); + let partner_guid = ObjectGuid::create_player(1, 88); + let arbiter_guid = ObjectGuid::create_world_object( + wow_core::guid::HighGuid::GameObject, + 0, + 1, + 571, + 0, + 7, + 100, + ); + add_canonical_test_player_on_map_for_misc_test( + &canonical, + source_guid, + Position::new(1.0, 2.0, 3.0, 0.0), + 571, + 0, + ); + add_canonical_test_player_on_map_for_misc_test( + &canonical, + partner_guid, + Position::new(2.0, 2.0, 3.0, 0.0), + 571, + 0, + ); + { + let mut manager = canonical.lock().unwrap(); + let map = manager.find_map_mut(571, 0).unwrap().map_mut(); + map.get_typed_player_mut(source_guid) + .unwrap() + .set_duel_info_like_cpp(Some(wow_entities::PlayerDuelInfoLikeCpp { + opponent: partner_guid, + state: wow_entities::PlayerDuelStateLikeCpp::Challenged, + })); + map.get_typed_player_mut(partner_guid) + .unwrap() + .set_duel_info_like_cpp(Some(wow_entities::PlayerDuelInfoLikeCpp { + opponent: source_guid, + state: wow_entities::PlayerDuelStateLikeCpp::Challenged, + })); + } + source_session.set_player_guid(Some(source_guid)); + source_session.set_player_map_position_like_cpp(571, Position::new(1.0, 2.0, 3.0, 0.0)); + source_session.set_canonical_map_manager(Arc::clone(&canonical)); + partner_session.set_player_guid(Some(partner_guid)); + + let registry = Arc::new(PlayerRegistry::default()); + registry.insert( + partner_guid, + broadcast_info_with_command_tx(partner_session.session_command_tx()), + ); + source_session.set_player_registry(registry); + + assert!( + source_session.record_and_apply_represented_live_intent_like_cpp( + crate::session::RepresentedLiveIntentLikeCpp::DuelAccepted( + crate::session::RepresentedDuelAcceptedLikeCpp { + opponent_guid: partner_guid, + arbiter_guid, + countdown_ms: crate::session::DUEL_COUNTDOWN_MS_LIKE_CPP, + }, + ), + ) + ); + partner_session + .process_represented_session_commands_like_cpp() + .await; + + assert_eq!( + source_session.represented_duel_accepts_like_cpp(), + &[crate::session::RepresentedDuelAcceptedLikeCpp { + opponent_guid: partner_guid, + arbiter_guid, + countdown_ms: crate::session::DUEL_COUNTDOWN_MS_LIKE_CPP, + }], + "the bridge records the accepted intent exactly once" + ); + { + let manager = canonical.lock().unwrap(); + let map = manager.find_map(571, 0).unwrap().map(); + assert_eq!( + map.get_typed_player(source_guid) + .unwrap() + .duel_info_like_cpp() + .unwrap() + .state, + wow_entities::PlayerDuelStateLikeCpp::Countdown + ); + assert_eq!( + map.get_typed_player(partner_guid) + .unwrap() + .duel_info_like_cpp() + .unwrap() + .state, + wow_entities::PlayerDuelStateLikeCpp::Countdown + ); + } + let source_bytes = source_send_rx + .try_recv() + .expect("source bridge duel countdown"); + let partner_bytes = partner_send_rx + .try_recv() + .expect("partner bridge duel countdown"); + assert_eq!(source_bytes, partner_bytes); + assert!( + source_send_rx.try_recv().is_err(), + "the bridge applies the source packet once" + ); + assert!( + partner_send_rx.try_recv().is_err(), + "the bridge applies the partner packet once" + ); + } + #[tokio::test] async fn duel_response_accepts_challenged_duel_and_sends_countdown_like_cpp() { let (mut source_session, source_send_rx) = make_session(); diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..aa4c6e7b 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -2290,6 +2290,13 @@ pub(crate) struct RepresentedDuelAcceptedLikeCpp { pub countdown_ms: u32, } +/// Validated represented intent whose live side effect is applied through the +/// represented->live bridge instead of directly inside the packet handler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RepresentedLiveIntentLikeCpp { + DuelAccepted(RepresentedDuelAcceptedLikeCpp), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RepresentedDuelCancelOutcomeLikeCpp { Interrupted, @@ -37793,6 +37800,71 @@ impl WorldSession { } } + /// Single boundary for validated represented intents that now have a live + /// application. The handler constructs one intent after C++ validation; + /// this bridge records the represented evidence and applies the live state + /// mutation exactly once. + pub(crate) fn record_and_apply_represented_live_intent_like_cpp( + &mut self, + intent: RepresentedLiveIntentLikeCpp, + ) -> bool { + self.record_represented_live_intent_like_cpp(intent); + self.apply_represented_live_intent_like_cpp(intent) + } + + fn record_represented_live_intent_like_cpp(&mut self, intent: RepresentedLiveIntentLikeCpp) { + match intent { + RepresentedLiveIntentLikeCpp::DuelAccepted(accepted) => { + self.represented_duel_accepts_like_cpp.push(accepted); + } + } + } + + fn apply_represented_live_intent_like_cpp( + &mut self, + intent: RepresentedLiveIntentLikeCpp, + ) -> bool { + match intent { + RepresentedLiveIntentLikeCpp::DuelAccepted(accepted) => { + self.apply_represented_duel_accepted_live_like_cpp(accepted) + } + } + } + + /// C++ `WorldSession::HandleDuelAccepted` switches both players to + /// `DUEL_STATE_COUNTDOWN` and sends one `DuelCountdown` packet to each. + fn apply_represented_duel_accepted_live_like_cpp( + &mut self, + accepted: RepresentedDuelAcceptedLikeCpp, + ) -> bool { + let Some(player_guid) = self.player_guid() else { + return false; + }; + + self.set_represented_duel_state_like_cpp( + player_guid, + accepted.opponent_guid, + wow_entities::PlayerDuelStateLikeCpp::Countdown, + ); + self.set_represented_duel_state_like_cpp( + accepted.opponent_guid, + player_guid, + wow_entities::PlayerDuelStateLikeCpp::Countdown, + ); + + use wow_packet::ServerPacket; + let packet = wow_packet::packets::misc::DuelCountdown { + countdown_ms: accepted.countdown_ms, + }; + let packet_bytes = packet.to_bytes(); + self.send_raw_packet(&packet_bytes); + self.send_represented_duel_countdown_to_opponent_like_cpp( + accepted.opponent_guid, + packet_bytes, + ); + true + } + /// C++ `Spell::EffectDuel`. /// /// Represented boundary: canonical connected players only. This creates the @@ -37908,31 +37980,13 @@ impl WorldSession { return false; } - self.set_represented_duel_state_like_cpp( - player_guid, - opponent_guid, - wow_entities::PlayerDuelStateLikeCpp::Countdown, - ); - self.set_represented_duel_state_like_cpp( - opponent_guid, - player_guid, - wow_entities::PlayerDuelStateLikeCpp::Countdown, - ); - - use wow_packet::ServerPacket; - let packet = wow_packet::packets::misc::DuelCountdown { - countdown_ms: DUEL_COUNTDOWN_MS_LIKE_CPP, - }; - let packet_bytes = packet.to_bytes(); - self.send_raw_packet(&packet_bytes); - self.send_represented_duel_countdown_to_opponent_like_cpp(opponent_guid, packet_bytes); - self.represented_duel_accepts_like_cpp - .push(RepresentedDuelAcceptedLikeCpp { + self.record_and_apply_represented_live_intent_like_cpp( + RepresentedLiveIntentLikeCpp::DuelAccepted(RepresentedDuelAcceptedLikeCpp { opponent_guid, arbiter_guid, countdown_ms: DUEL_COUNTDOWN_MS_LIKE_CPP, - }); - true + }), + ) } fn handle_duel_cancelled_like_cpp(&mut self) -> bool { diff --git a/docs/migration/STATE.md b/docs/migration/STATE.md index ac0edac0..f05b2d93 100644 --- a/docs/migration/STATE.md +++ b/docs/migration/STATE.md @@ -32,6 +32,10 @@ This is why the old "98% represented" metric and "bags don't open" coexist witho contradiction: ~98% of logic is *represented*, a much smaller fraction is *live*. The plan's job is to convert represented→live for the playable path, then for everything. +Initial bridge convention: [represented-live-bridge.md](represented-live-bridge.md) documents +the handler → represented intent → live application boundary. The first converted example is +accepted-duel countdown state. + --- ## 1. The honest progress picture (three axes, not one number) diff --git a/docs/migration/represented-live-bridge.md b/docs/migration/represented-live-bridge.md new file mode 100644 index 00000000..00fde036 --- /dev/null +++ b/docs/migration/represented-live-bridge.md @@ -0,0 +1,32 @@ +# Represented -> Live Bridge Convention + +Issue #19 defines the minimal bridge convention for converting represented +handler work into live runtime mutations without ad-hoc per-handler wiring. + +## Convention + +1. Packet handlers still own decoding and C++ validation. +2. After validation, the handler builds one `RepresentedLiveIntentLikeCpp`. +3. The handler calls `WorldSession::record_and_apply_represented_live_intent_like_cpp(intent)` + exactly once. +4. The bridge records the represented evidence and applies the live mutation. +5. The handler must not also mutate the same live state or send the same packets directly. + +The bridge is intentionally narrow. Add one intent variant at a time, with the +C++ anchor, live owner, packet side effects, and a focused test proving one +record + one application. Do not move DB writes, map locks, or broad runtime +ownership through this bridge unless that slice explicitly owns those concerns. + +## First Converted Example + +`RepresentedLiveIntentLikeCpp::DuelAccepted` converts the accepted-duel path. + +- C++ source: `src/server/game/Handlers/DuelHandler.cpp`, `WorldSession::HandleDuelAccepted`. +- Rust handler validation: `WorldSession::handle_duel_accepted_like_cpp`. +- Rust bridge application: `WorldSession::apply_represented_duel_accepted_live_like_cpp`. + +The handler verifies the challenged duel state and arbiter, then emits one +`DuelAccepted` intent. The bridge records the intent, switches both canonical +players to `PlayerDuelStateLikeCpp::Countdown`, and sends one `DuelCountdown` +packet to each participant. Tests cover the direct bridge path and the packet +handler path.