From 83bb5cc040157bcb268547cb155c4c9684e86d05 Mon Sep 17 00:00:00 2001 From: JA Date: Sat, 18 Jul 2026 19:45:25 +0900 Subject: [PATCH] Add numeric and text input to NPC dialogs Handle the server packets that request script input, render a localized text box with keyboard and button submission, and send the matching numeric or variable-length text response. This prevents NPC scripts using input prompts from stalling the dialog stream. --- korangar-networking/src/event.rs | 6 + korangar-networking/src/lib.rs | 78 ++++++++++- .../src/packet_versions/version_20220406.rs | 2 + korangar/archive/data/languages/de-DE.ron | 2 + korangar/archive/data/languages/en-US.ron | 2 + korangar/src/input/event.rs | 14 ++ korangar/src/interface/windows/dialog.rs | 124 +++++++++++++++++- korangar/src/lib.rs | 26 ++++ korangar/src/state/localization/mod.rs | 2 + ragnarok-packets/examples/pcap.rs | 4 + ragnarok-packets/src/lib.rs | 40 ++++++ 11 files changed, 297 insertions(+), 3 deletions(-) diff --git a/korangar-networking/src/event.rs b/korangar-networking/src/event.rs index 3d6703597..099ff2f03 100644 --- a/korangar-networking/src/event.rs +++ b/korangar-networking/src/event.rs @@ -181,6 +181,12 @@ pub enum NetworkEvent { choices: Vec, npc_id: EntityId, }, + OpenNumberInput { + npc_id: EntityId, + }, + OpenTextInput { + npc_id: EntityId, + }, AddQuestEffect { quest_effect: QuestEffectPacket, }, diff --git a/korangar-networking/src/lib.rs b/korangar-networking/src/lib.rs index 682b434fc..62a19dd32 100644 --- a/korangar-networking/src/lib.rs +++ b/korangar-networking/src/lib.rs @@ -775,6 +775,18 @@ where } } + pub fn submit_dialog_number_input(&mut self, npc_id: EntityId, value: i32) -> Result<(), NotConnectedError> { + match self.map_server_packet_version()? { + SupportedPacketVersion::_20220406 => self.send_map_server_packet(NumberInputPacket::new(npc_id, value)), + } + } + + pub fn submit_dialog_text_input(&mut self, npc_id: EntityId, text: String) -> Result<(), NotConnectedError> { + match self.map_server_packet_version()? { + SupportedPacketVersion::_20220406 => self.send_map_server_packet(TextInputPacket::new(npc_id, text)), + } + } + pub fn request_item_equip(&mut self, item_index: InventoryIndex, equip_position: EquipPosition) -> Result<(), NotConnectedError> { match self.map_server_packet_version()? { SupportedPacketVersion::_20220406 => self.send_map_server_packet(RequestEquipItemPacket::new(item_index, equip_position)), @@ -910,7 +922,10 @@ where mod packet_handlers { use ragnarok_bytes::{ByteReader, ByteWriter}; use ragnarok_packets::handler::{HandlerResult, NoPacketCallback}; - use ragnarok_packets::{DisplayEmotionPacket, EntityId, PacketExt, RequestEmotionPacket}; + use ragnarok_packets::{ + DisplayEmotionPacket, EntityId, NumberInputPacket, OpenNumberInputPacket, OpenTextInputPacket, PacketExt, RequestEmotionPacket, + TextInputPacket, + }; use crate::{NetworkEvent, NetworkingSystem, SupportedPacketVersion}; @@ -960,4 +975,65 @@ mod packet_handlers { emotion: 3, }])); } + + #[test] + fn dialog_input_requests_map_to_network_events() { + let mut handler = NetworkingSystem::create_map_server_packet_handler(NoPacketCallback, SupportedPacketVersion::_20220406).unwrap(); + let mut writer = ByteWriter::new(); + OpenNumberInputPacket { + npc_id: EntityId(0x1234_5678), + } + .packet_to_bytes(&mut writer) + .unwrap(); + OpenTextInputPacket { + npc_id: EntityId(0x8765_4321), + } + .packet_to_bytes(&mut writer) + .unwrap(); + let mut reader = ByteReader::without_metadata(writer.as_slice()); + + assert!(matches!( + handler.process_one(&mut reader), + HandlerResult::Ok(events) + if matches!( + events.0.as_slice(), + [NetworkEvent::OpenNumberInput { + npc_id: EntityId(0x1234_5678), + }] + ) + )); + assert!(matches!( + handler.process_one(&mut reader), + HandlerResult::Ok(events) + if matches!( + events.0.as_slice(), + [NetworkEvent::OpenTextInput { + npc_id: EntityId(0x8765_4321), + }] + ) + )); + } + + #[test] + fn dialog_input_responses_use_expected_wire_layouts() { + let mut writer = ByteWriter::new(); + NumberInputPacket { + npc_id: EntityId(0x1234_5678), + value: -42, + } + .packet_to_bytes(&mut writer) + .unwrap(); + assert_eq!(writer.as_slice(), &[0x43, 0x01, 0x78, 0x56, 0x34, 0x12, 0xD6, 0xFF, 0xFF, 0xFF]); + + writer.clear(); + TextInputPacket { + npc_id: EntityId(0x1234_5678), + text: "abc".to_string(), + } + .packet_to_bytes(&mut writer) + .unwrap(); + assert_eq!(writer.as_slice(), &[ + 0xD5, 0x01, 0x0C, 0x00, 0x78, 0x56, 0x34, 0x12, b'a', b'b', b'c', 0x00 + ]); + } } diff --git a/korangar-networking/src/packet_versions/version_20220406.rs b/korangar-networking/src/packet_versions/version_20220406.rs index e47a4590a..8b7112f26 100644 --- a/korangar-networking/src/packet_versions/version_20220406.rs +++ b/korangar-networking/src/packet_versions/version_20220406.rs @@ -711,6 +711,8 @@ where NetworkEvent::OpenDialog { text, npc_id } })?; + packet_handler.register(|packet: OpenNumberInputPacket| NetworkEvent::OpenNumberInput { npc_id: packet.npc_id })?; + packet_handler.register(|packet: OpenTextInputPacket| NetworkEvent::OpenTextInput { npc_id: packet.npc_id })?; packet_handler.register(|packet: RequestEquipItemStatusPacket| match packet.result { RequestEquipItemStatus::Success => Some(NetworkEvent::UpdateEquippedPosition { index: packet.inventory_index, diff --git a/korangar/archive/data/languages/de-DE.ron b/korangar/archive/data/languages/de-DE.ron index 19c2eafcb..2a535e601 100644 --- a/korangar/archive/data/languages/de-DE.ron +++ b/korangar/archive/data/languages/de-DE.ron @@ -36,6 +36,8 @@ dialog_window_title: "Dialog", next_button_text: "Weiter", close_button_text: "Schließen", + okay_button_text: "OK", + dialog_text_box_message: "Wert eingeben", error_window_title: "Fehler", friend_list_window_title: "Freundesliste", friend_list_text_box_message: "Freund durch Name hinzufügen", diff --git a/korangar/archive/data/languages/en-US.ron b/korangar/archive/data/languages/en-US.ron index 94760308f..dd46ebeaf 100644 --- a/korangar/archive/data/languages/en-US.ron +++ b/korangar/archive/data/languages/en-US.ron @@ -36,6 +36,8 @@ dialog_window_title: "Dialog", next_button_text: "Next", close_button_text: "Close", + okay_button_text: "OK", + dialog_text_box_message: "Enter a value", error_window_title: "Error", friend_list_window_title: "Friend List", friend_list_text_box_message: "Add friend by name", diff --git a/korangar/src/input/event.rs b/korangar/src/input/event.rs index bd47c9f97..5cca4586e 100644 --- a/korangar/src/input/event.rs +++ b/korangar/src/input/event.rs @@ -146,6 +146,20 @@ pub enum InputEvent { /// Id of the option. option: i8, }, + /// Submit the number typed into a dialog input. + SubmitDialogNumberInput { + /// Id of the NPC the player is in a dialog with. + npc_id: EntityId, + /// The number the player entered. + value: i32, + }, + /// Submit the text typed into a dialog input. + SubmitDialogTextInput { + /// Id of the NPC the player is in a dialog with. + npc_id: EntityId, + /// The text the player entered. + text: String, + }, /// Move an item in the user interface. MoveItem { /// Source of the move. diff --git a/korangar/src/interface/windows/dialog.rs b/korangar/src/interface/windows/dialog.rs index 0d710905b..c2b9f67e9 100644 --- a/korangar/src/interface/windows/dialog.rs +++ b/korangar/src/interface/windows/dialog.rs @@ -1,5 +1,6 @@ use std::cell::UnsafeCell; +use korangar_interface::components::text_box::DefaultHandler; use korangar_interface::element::store::ElementStoreMut; use korangar_interface::element::{Element, ElementBox, ErasedElement, StateElement}; use korangar_interface::layout::{Resolvers, with_single_resolver}; @@ -13,6 +14,14 @@ use crate::state::localization::LocalizationPathExt; use crate::state::theme::InterfaceThemeType; use crate::state::{ClientState, ClientStatePathExt, client_state}; +/// Maximum number of characters for a dialog input. The server caps text +/// inputs at rAthena's `CHATBOX_SIZE` (70). +const MAXIMUM_INPUT_LENGTH: usize = 70; + +fn parse_dialog_number(text: &str) -> Option { + text.trim().parse().ok() +} + /// A small wrapper struct that serves two purposes: /// - Making the elements nicer to construct by putting the [`UnsafeCell::new`] /// and [`Box::new`] behind a function call. @@ -26,6 +35,9 @@ pub struct DialogElement { #[hidden_element] element: UnsafeCell>, is_next_button: bool, + /// Marks the elements of an input row so they can be removed once the + /// input is submitted. + is_input: bool, } impl DialogElement { @@ -38,6 +50,20 @@ impl DialogElement { Self { element: UnsafeCell::new(ErasedElement::new(element)), is_next_button, + is_input: false, + } + } + + /// Creates a new dialog element that is part of an input row. + #[inline(always)] + fn new_input(element: E) -> Self + where + E: Element + 'static, + { + Self { + element: UnsafeCell::new(ErasedElement::new(element)), + is_next_button: false, + is_input: true, } } } @@ -52,6 +78,8 @@ pub struct DialogWindowState { /// Whether or not the elements should be cleared the next time /// [`start`](Self::start) is called. clear_next: bool, + /// Backing store for the text box of an input row. + input_buffer: String, } impl DialogWindowState { @@ -109,7 +137,7 @@ impl DialogWindowState { pub fn add_close_button(&mut self) { use korangar_interface::prelude::*; - self.elements.retain(|element| !element.is_next_button); + self.elements.retain(|element| !element.is_next_button && !element.is_input); let npc_id = self.npc_id; @@ -132,7 +160,7 @@ impl DialogWindowState { pub fn add_choice_buttons(&mut self, choices: Vec) { use korangar_interface::prelude::*; - self.elements.retain(|element| !element.is_next_button); + self.elements.retain(|element| !element.is_next_button && !element.is_input); let npc_id = self.npc_id; @@ -149,6 +177,70 @@ impl DialogWindowState { }); } + /// Add a number input row to the dialog. + pub fn add_number_input(&mut self) { + self.add_input(true); + } + + /// Add a text input row to the dialog. + pub fn add_text_input(&mut self) { + self.add_input(false); + } + + /// Add an input row (text box and an "OK"-button) to the dialog. + fn add_input(&mut self, numbers_only: bool) { + use korangar_interface::prelude::*; + + if self.clear_next { + // An input request may be the first packet after advancing a dialog page. + // In that case, clear the previous page just like add_text() does. + self.elements.clear(); + self.clear_next = false; + } else { + // The server should only ever request one input at a time. Also remove + // any stale Next button: the server is now waiting for an input packet. + self.elements.retain(|element| !element.is_input && !element.is_next_button); + } + self.input_buffer.clear(); + + let npc_id = self.npc_id; + let input_path = client_state().dialog_window().input_buffer(); + + struct DialogInputTextBox; + + let submit_action = move |state: &State, queue: &mut EventQueue| { + let text = state.get(&input_path).clone(); + + match numbers_only { + true => { + let Some(value) = parse_dialog_number(&text) else { + return; + }; + queue.queue(InputEvent::SubmitDialogNumberInput { npc_id, value }); + } + false => queue.queue(InputEvent::SubmitDialogTextInput { npc_id, text }), + } + }; + + self.elements.push(DialogElement::new_input(text_box! { + ghost_text: client_state().localization().dialog_text_box_message(), + state: input_path, + input_handler: DefaultHandler::<_, _, MAXIMUM_INPUT_LENGTH>::new(input_path, submit_action), + focus_id: DialogInputTextBox, + })); + + self.elements.push(DialogElement::new_input(button! { + text: client_state().localization().okay_button_text(), + event: submit_action, + })); + } + + /// Remove the input row after the input has been submitted. + pub fn input_submitted(&mut self) { + self.elements.retain(|element| !element.is_input); + self.input_buffer.clear(); + } + /// End the dialog. /// /// This has no side effects. @@ -165,6 +257,7 @@ impl Default for DialogWindowState { // Arguably not very clean but avoids using an Option. npc_id: EntityId(0), clear_next: false, + input_buffer: Default::default(), } } } @@ -256,3 +349,30 @@ where } } } + +#[cfg(test)] +mod tests { + use super::{DialogWindowState, parse_dialog_number}; + + #[test] + fn dialog_numbers_require_valid_i32_input() { + assert_eq!(parse_dialog_number(" -42 "), Some(-42)); + assert_eq!(parse_dialog_number(""), None); + assert_eq!(parse_dialog_number("-"), None); + assert_eq!(parse_dialog_number("12x"), None); + assert_eq!(parse_dialog_number("2147483648"), None); + } + + #[test] + fn input_after_next_starts_a_new_dialog_page() { + let mut state = DialogWindowState::default(); + state.add_next_button(); + assert!(state.elements.iter().any(|element| element.is_next_button)); + + state.add_number_input(); + + assert!(!state.clear_next); + assert!(!state.elements.iter().any(|element| element.is_next_button)); + assert!(state.elements.iter().all(|element| element.is_input)); + } +} diff --git a/korangar/src/lib.rs b/korangar/src/lib.rs index 8e082b043..9ad85fe6f 100644 --- a/korangar/src/lib.rs +++ b/korangar/src/lib.rs @@ -1595,6 +1595,24 @@ impl Client { self.interface.open_window(DialogWindow::new(client_state().dialog_window())); } + NetworkEvent::OpenNumberInput { npc_id } => { + self.client_state + .follow_mut(client_state().dialog_window()) + // Some NPCs start the dialog with this packet so we need to make sure it's initialized. + .initialize(npc_id) + .add_number_input(); + + self.interface.open_window(DialogWindow::new(client_state().dialog_window())); + } + NetworkEvent::OpenTextInput { npc_id } => { + self.client_state + .follow_mut(client_state().dialog_window()) + // Some NPCs start the dialog with this packet so we need to make sure it's initialized. + .initialize(npc_id) + .add_text_input(); + + self.interface.open_window(DialogWindow::new(client_state().dialog_window())); + } NetworkEvent::AddQuestEffect { quest_effect } => { if let Some(map) = &self.map { self.particle_holder.add_quest_icon(&self.texture_loader, map, quest_effect) @@ -2284,6 +2302,14 @@ impl Client { self.interface.close_window_with_class(WindowClass::Dialog); } } + InputEvent::SubmitDialogNumberInput { npc_id, value } => { + let _ = self.networking_system.submit_dialog_number_input(npc_id, value); + self.client_state.follow_mut(client_state().dialog_window()).input_submitted(); + } + InputEvent::SubmitDialogTextInput { npc_id, text } => { + let _ = self.networking_system.submit_dialog_text_input(npc_id, text); + self.client_state.follow_mut(client_state().dialog_window()).input_submitted(); + } InputEvent::MoveItem { source, destination, item } => match (source, destination) { (ItemSource::Inventory, ItemSource::Equipment { position }) => { let _ = self.networking_system.request_item_equip(item.index, position); diff --git a/korangar/src/state/localization/mod.rs b/korangar/src/state/localization/mod.rs index 3a7a83b14..8fb875692 100644 --- a/korangar/src/state/localization/mod.rs +++ b/korangar/src/state/localization/mod.rs @@ -168,6 +168,8 @@ pub struct Localization { dialog_window_title: String, next_button_text: String, close_button_text: String, + okay_button_text: String, + dialog_text_box_message: String, error_window_title: String, friend_list_window_title: String, friend_list_text_box_message: String, diff --git a/ragnarok-packets/examples/pcap.rs b/ragnarok-packets/examples/pcap.rs index ec5bbab70..cc4a426f2 100644 --- a/ragnarok-packets/examples/pcap.rs +++ b/ragnarok-packets/examples/pcap.rs @@ -279,6 +279,8 @@ fn main() { UpdateEntityHealthPointsPacket, RequestPlayerAttackFailedPacket, NpcDialogPacket, + OpenNumberInputPacket, + OpenTextInputPacket, RequestEquipItemStatusPacket, RequestUnequipItemStatusPacket, Packet8302, @@ -325,6 +327,8 @@ fn main() { NextDialogPacket, CloseDialogPacket, ChooseDialogOptionPacket, + NumberInputPacket, + TextInputPacket, RequestEquipItemPacket, RequestUnequipItemPacket, UseSkillAtIdPacket, diff --git a/ragnarok-packets/src/lib.rs b/ragnarok-packets/src/lib.rs index ccfb6a4be..e81eb9ebc 100644 --- a/ragnarok-packets/src/lib.rs +++ b/ragnarok-packets/src/lib.rs @@ -2024,6 +2024,24 @@ pub struct DialogMenuPacket { pub message: String, } +/// Sent by the map server to request a number input from the player during an +/// NPC dialog (`ZC_OPEN_EDITDLG`). +#[derive(Debug, Clone, Packet, ServerPacket, MapServer)] +#[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))] +#[header(0x0142)] +pub struct OpenNumberInputPacket { + pub npc_id: EntityId, +} + +/// Sent by the map server to request a text input from the player during an +/// NPC dialog (`ZC_OPEN_EDITDLGSTR`). +#[derive(Debug, Clone, Packet, ServerPacket, MapServer)] +#[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))] +#[header(0x01D4)] +pub struct OpenTextInputPacket { + pub npc_id: EntityId, +} + #[derive(Debug, Clone, Copy, ByteConvertable)] #[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))] #[numeric_type(u32)] @@ -3643,6 +3661,28 @@ pub struct ChooseDialogOptionPacket { pub option: i8, } +/// Sent by the client as a response to a [`OpenNumberInputPacket`] +/// (`CZ_INPUT_EDITDLG`). +#[derive(Debug, Clone, Packet, ClientPacket, MapServer)] +#[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))] +#[header(0x0143)] +pub struct NumberInputPacket { + pub npc_id: EntityId, + pub value: i32, +} + +/// Sent by the client as a response to a [`OpenTextInputPacket`] +/// (`CZ_INPUT_EDITDLGSTR`). +#[derive(Debug, Clone, Packet, ClientPacket, MapServer)] +#[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))] +#[header(0x01D5)] +#[variable_length] +pub struct TextInputPacket { + pub npc_id: EntityId, + #[length_remaining] + pub text: String, +} + bitflags::bitflags! { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "interface", derive(rust_state::RustState, korangar_interface::element::StateElement))]